Skip to content

Commit 512adf9

Browse files
committed
Merge branch 'feat/session-commands' into 'main'
feat(api): add session command system See merge request cupola/morrigan!6
2 parents f5caf17 + 2e93c96 commit 512adf9

4 files changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
---
2+
title: feat: 添加会话指令系统 (/new, /clear)
3+
type: feat
4+
status: active
5+
date: 2026-04-02
6+
---
7+
8+
# 添加会话指令系统 (/new, /clear)
9+
10+
## 概述
11+
12+
在消息处理流程中检测特殊指令(如 `/new`),执行对应操作(如重置会话)后再进入正常 LLM 处理流程。指令在内容中居首位的才有效。
13+
14+
## 动机
15+
16+
- 用户需要一个快捷方式来重置会话、开始新的对话上下文
17+
- 当前没有机制来区分普通用户输入和系统指令
18+
- 未来可能扩展更多指令(/help, /reset 等)
19+
20+
## 解决方案
21+
22+
### 1. 设计指令检测与执行机制
23+
24+
`pkg/web/api/handle_platform.go``MessageHandler` 中,在构建 `cs` (Conversation) **之前** 检测消息内容是否以指令开头。
25+
26+
#### 指令注册表设计
27+
28+
```go
29+
// pkg/web/api/commands.go
30+
31+
type Command struct {
32+
Name string // 指令名,如 "new", "clear"
33+
Aliases []string // 别名列表,如 []string{"/new", "/clear"}
34+
Desc string // 指令描述
35+
Action func(ctx context.Context, msg *channel.Message) (bool, error)
36+
// 返回 (handled, error): handled=true 表示指令已处理,不再继续 LLM 调用
37+
}
38+
39+
var commandRegistry = []Command{
40+
{
41+
Name: "reset",
42+
Aliases: []string{"/reset", "/new", "/clear"},
43+
Desc: "重置会话,创建新的 csid",
44+
Action: handleResetCommand,
45+
},
46+
}
47+
48+
func handleResetCommand(ctx context.Context, msg *channel.Message) (bool, error) {
49+
// 调用 stores 包的导出函数删除 sessionKey -> csid 映射
50+
if err := stores.ResetSessionBySessionKey(ctx, msg.SessionKey); err != nil {
51+
return false, err
52+
}
53+
logger().Infow("command: session reset", "sessionKey", msg.SessionKey)
54+
return true, nil // 指令已处理,不继续 LLM 调用
55+
}
56+
```
57+
58+
#### 修改 MessageHandler 流程
59+
60+
```
61+
MessageHandler
62+
└─> 检测指令 ──> 执行指令 Action
63+
└─> handled=true? ─┬─> true: 发送确认回复,结束
64+
└─> false: 继续正常 LLM 流程
65+
```
66+
67+
`handle_platform.go:MessageHandler` 的第 112 行 `cs := stores.GetOrCreateConversationBySessionKey(...)` 之前插入指令检测:
68+
69+
```go
70+
// Check for commands at the beginning of content
71+
if cmd := DetectCommand(msg.Content); cmd != nil {
72+
handled, err := cmd.Action(ctx, msg)
73+
if err != nil {
74+
logger().Warnw("command execution failed", "cmd", cmd.Name, "err", err)
75+
}
76+
if handled {
77+
// Send acknowledgment to user
78+
if err := p.Reply(ctx, msg.ReplyCtx, "会话已重置,开始新对话"); err != nil {
79+
logger().Warnw("reply after command failed", "err", err)
80+
}
81+
return
82+
}
83+
// Fall through to normal processing with trimmed content
84+
}
85+
```
86+
87+
### 2. 实现 DetectCommand 函数
88+
89+
```go
90+
// pkg/web/api/commands.go
91+
92+
func DetectCommand(content string) *Command {
93+
trimmed := strings.TrimSpace(content)
94+
for _, cmd := range commandRegistry {
95+
for _, alias := range cmd.Aliases {
96+
if strings.HasPrefix(trimmed, alias) {
97+
return &cmd
98+
}
99+
}
100+
}
101+
return nil
102+
}
103+
```
104+
105+
### 3. 扩展指令注册表(未来)
106+
107+
`commands.go``commandRegistry` 中添加新条目即可:
108+
109+
```go
110+
{
111+
Name: "help",
112+
Aliases: []string{"/help", "/h", "?"},
113+
Desc: "显示可用指令列表",
114+
Action: handleHelpCommand,
115+
},
116+
```
117+
118+
## 系统影响
119+
120+
### 交互图
121+
122+
```
123+
MessageHandler
124+
└─> DetectCommand() ──> Command.Action()
125+
├─> new/clear: Del Redis key
126+
└─> (future) help: Reply with command list
127+
```
128+
129+
### 错误传播
130+
131+
- 指令执行失败:记录日志,继续正常 LLM 处理流程(不阻塞用户)
132+
- Redis 删除失败:返回 false 让流程继续(容错设计)
133+
134+
### 状态生命周期
135+
136+
- `/new` 删除 Redis 中的 `platform:csid:{sessionKey}` 映射
137+
- 后续 `GetOrCreateConversationBySessionKey` 会创建新的 csid
138+
- 旧的 Redis history key (`convs-{old_csid}`) 保留直到 TTL 过期
139+
140+
## 验收标准
141+
142+
- [ ] 发送 `/new``/clear` 时,系统生成新的 csid,后续对话与之前隔离
143+
- [ ] 发送普通消息时,不触发指令检测,正常进入 LLM 处理
144+
- [ ] 指令处理后向用户发送确认消息
145+
- [ ] 指令可扩展,添加新指令只需在 registry 中添加条目
146+
147+
## 技术细节
148+
149+
### 关键文件
150+
151+
- `pkg/services/stores/conversation.go` — 添加 `ResetSessionBySessionKey()` 导出函数
152+
- `pkg/web/api/handle_platform.go:112` — 插入指令检测逻辑
153+
- `pkg/web/api/commands.go` (新建) — 指令注册与执行逻辑
154+
155+
### 别名实现
156+
157+
别名在 `Command.Aliases` 中定义,检测时遍历所有别名匹配。指令匹配以空格或消息结束为边界,防止误匹配(如 `/newuser` 不会匹配 `/new`)。
158+
159+
## 依赖与风险
160+
161+
- 无新依赖
162+
- 风险:指令检测应在 LLM 处理之前,但不影响现有消息流
163+
164+
## 实现步骤
165+
166+
1.`stores/conversation.go` 添加 `ResetSessionBySessionKey(ctx, sessionKey)` 导出函数
167+
2. 创建 `pkg/web/api/commands.go`,定义 `Command` 结构体和 `commandRegistry`
168+
3. 实现 `DetectCommand()` 函数
169+
4.`MessageHandler` 第 112 行前插入指令检测
170+
5. 实现 `handleNewCommand`,调用 `stores.ResetSessionBySessionKey` 后发送确认
171+
6. 添加单元测试(检测指令解析、别名匹配)

pkg/services/stores/conversation.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ func GetOrCreateConversationBySessionKey(ctx context.Context, sessionKey string)
5252
return cs
5353
}
5454

55+
// ResetSessionBySessionKey 删除 sessionKey -> csid 的映射,强制重建会话
56+
func ResetSessionBySessionKey(ctx context.Context, sessionKey string) error {
57+
key := sessionKeyCSIDPrefix + sessionKey
58+
return SgtRC().Del(ctx, key).Err()
59+
}
60+
5561
// newConversation is internal constructor, supports injecting Redis client (for testing)
5662
func newConversation(ctx context.Context, id any, rc RedisClient) Conversation {
5763
sto := Sgt()

pkg/web/api/commands.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
"github.com/liut/morign/pkg/models/channel"
8+
"github.com/liut/morign/pkg/services/stores"
9+
)
10+
11+
type Command struct {
12+
Name string
13+
Aliases []string
14+
Desc string
15+
Action func(ctx context.Context, msg *channel.Message) (bool, error)
16+
}
17+
18+
var commandRegistry = []Command{
19+
{
20+
Name: "reset",
21+
Aliases: []string{"/reset", "/new", "/clear"},
22+
Desc: "重置会话,创建新的 csid",
23+
Action: handleResetCommand,
24+
},
25+
}
26+
27+
func DetectCommand(content string) Command {
28+
trimmed := strings.TrimSpace(content)
29+
for _, cmd := range commandRegistry {
30+
for _, alias := range cmd.Aliases {
31+
if strings.HasPrefix(trimmed, alias) {
32+
return cmd
33+
}
34+
}
35+
}
36+
return Command{}
37+
}
38+
39+
func handleResetCommand(ctx context.Context, msg *channel.Message) (bool, error) {
40+
if err := stores.ResetSessionBySessionKey(ctx, msg.SessionKey); err != nil {
41+
return false, err
42+
}
43+
logger().Infow("command: session reset", "sessionKey", msg.SessionKey)
44+
return true, nil
45+
}

pkg/web/api/handle_platform.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,24 @@ func (chh *channelHandler) MessageHandler(p channel.Channel, msg *channel.Messag
108108
logger().Infow("not found user", "userID", msg.UserID, "err", err)
109109
}
110110

111+
// Check for commands at the beginning of content
112+
if cmd := DetectCommand(msg.Content); cmd.Name != "" {
113+
handled, err := cmd.Action(ctx, msg)
114+
if handled {
115+
replyMsg := "会话已重置,开始新对话"
116+
if err != nil {
117+
replyMsg = "指令执行失败,请重试"
118+
}
119+
if err := p.Reply(ctx, msg.ReplyCtx, replyMsg); err != nil {
120+
logger().Warnw("reply after command failed", "err", err)
121+
}
122+
return
123+
}
124+
if err != nil {
125+
logger().Warnw("command execution failed", "cmd", cmd.Name, "err", err)
126+
}
127+
}
128+
111129
// Build the chat request
112130
cs := stores.GetOrCreateConversationBySessionKey(ctx, msg.SessionKey)
113131

0 commit comments

Comments
 (0)