magic-agent 2.1.5 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +103 -6
- package/client-ui/pc/app.css +3 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +21 -1
- package/client-ui/shared/conv/03-chat-message.js +43 -0
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +16 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +655 -87
- package/client-ui/shared/transport-http.js +16 -0
- package/client-ui-mobile/index.html +401 -553
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +507 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/cli/ask.go +6 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/service.go +11 -1
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +421 -0
- package/internal/suggest/suggest_test.go +218 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +5 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
// suggest.go - `--suggest-followups <file|->`:一轮结束后生成「用户下一句」的快捷回复。
|
|
4
|
+
//
|
|
5
|
+
// 为什么要单独一条 CLI(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,
|
|
6
|
+
// 点击即可发送」):界面(移动端 / PC)不该自己拼提示词、自己调模型 —— 那样每端一份,
|
|
7
|
+
// 同一轮回答在两处会给出不同建议。口径收进 core 之后只有一份(internal/suggest),
|
|
8
|
+
// 客户端(magic-client 的 /desk/suggest)exec 这一条即可。
|
|
9
|
+
//
|
|
10
|
+
// 与 `--summarize-title` 同一套旁路调用:独立进程、新会话、ToolsOff,不续接当前对话,
|
|
11
|
+
// 因此不会污染用户正在聊的那条上下文;也不写会话登记 / 日志(不经过 ask 主流程)。
|
|
12
|
+
//
|
|
13
|
+
// 退出码:
|
|
14
|
+
//
|
|
15
|
+
// 0 成功(哪怕建议为空数组 —— 「没有下一步」是合法结果,调用方不画按钮)
|
|
16
|
+
// 1 引擎失败(不可用 / 超时 / 报错)
|
|
17
|
+
// 2 参数错(请求 JSON 读不到 / 不合法 / 缺 reply)
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"encoding/json"
|
|
21
|
+
"fmt"
|
|
22
|
+
"io"
|
|
23
|
+
"os"
|
|
24
|
+
"strings"
|
|
25
|
+
|
|
26
|
+
"github.com/spf13/cobra"
|
|
27
|
+
|
|
28
|
+
"github.com/darren/magic-agent/internal/suggest"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// runSuggestFollowups 读取请求 JSON → 调 suggest.Generate → 打印 JSON 结果。
|
|
32
|
+
func runSuggestFollowups(cmd *cobra.Command, opts *askOptions) error {
|
|
33
|
+
body, err := readSuggestInput(cmd, opts.suggestInput)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return &usageError{err}
|
|
36
|
+
}
|
|
37
|
+
/* 引擎:没显式传 -e 时用 llm(纯文本、无工具、没有 CLI 会话副作用,与 --title-engine 同口径);
|
|
38
|
+
传了就以调用方的为准 —— 移动端把当前引擎带下来,用户的机器上一定装过它。 */
|
|
39
|
+
engine := suggest.DefaultEngine
|
|
40
|
+
if flagChanged(cmd, "engine") {
|
|
41
|
+
engine = opts.engine
|
|
42
|
+
}
|
|
43
|
+
model := ""
|
|
44
|
+
if flagChanged(cmd, "model") {
|
|
45
|
+
model = opts.model
|
|
46
|
+
}
|
|
47
|
+
res, err := suggest.Generate(cmd.Context(), suggest.Request{
|
|
48
|
+
User: body.User,
|
|
49
|
+
Reply: body.Reply,
|
|
50
|
+
Engine: engine,
|
|
51
|
+
Model: model,
|
|
52
|
+
Timeout: suggest.DefaultTimeout,
|
|
53
|
+
})
|
|
54
|
+
if err != nil {
|
|
55
|
+
return fmt.Errorf("--suggest-followups: 生成失败: %w", err)
|
|
56
|
+
}
|
|
57
|
+
if res.Suggestions == nil {
|
|
58
|
+
res.Suggestions = []string{}
|
|
59
|
+
}
|
|
60
|
+
return printJSON(cmd.OutOrStdout(), res)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// readSuggestInput 读请求 JSON:`-` = stdin,其余按文件路径(支持 `@file` 写法)。
|
|
64
|
+
// 上限 1MB:建议只该带「这一轮」的正文,超了说明调用方把整条会话塞进来了。
|
|
65
|
+
func readSuggestInput(cmd *cobra.Command, spec string) (suggest.RequestBody, error) {
|
|
66
|
+
var body suggest.RequestBody
|
|
67
|
+
src := strings.TrimSpace(spec)
|
|
68
|
+
switch src {
|
|
69
|
+
case "":
|
|
70
|
+
return body, fmt.Errorf("--suggest-followups 需要请求 JSON:传文件路径或 \"-\"(stdin)")
|
|
71
|
+
case "-":
|
|
72
|
+
b, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
|
73
|
+
if err != nil {
|
|
74
|
+
return body, fmt.Errorf("读 stdin 失败:%w", err)
|
|
75
|
+
}
|
|
76
|
+
if err := json.Unmarshal(b, &body); err != nil {
|
|
77
|
+
return body, fmt.Errorf("stdin 不是合法 JSON:%w", err)
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
path := strings.TrimPrefix(src, "@")
|
|
81
|
+
if strings.HasPrefix(path, "~/") {
|
|
82
|
+
if home, herr := os.UserHomeDir(); herr == nil {
|
|
83
|
+
path = home + path[1:]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
b, err := os.ReadFile(path)
|
|
87
|
+
if err != nil {
|
|
88
|
+
return body, fmt.Errorf("读请求文件失败:%w", err)
|
|
89
|
+
}
|
|
90
|
+
if err := json.Unmarshal(b, &body); err != nil {
|
|
91
|
+
return body, fmt.Errorf("请求文件不是合法 JSON:%w", err)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if strings.TrimSpace(body.Reply) == "" {
|
|
95
|
+
return body, fmt.Errorf(`请求 JSON 缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
|
|
96
|
+
}
|
|
97
|
+
return body, nil
|
|
98
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
package cli
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/darren/magic-agent/internal/suggest"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// TestSuggestFollowupsJSON 走完整 CLI:读 stdin 的请求 JSON → 假引擎 → 输出建议。
|
|
12
|
+
func TestSuggestFollowupsJSON(t *testing.T) {
|
|
13
|
+
registerFake(&stringEngine{
|
|
14
|
+
name: "fake-suggest-cli",
|
|
15
|
+
text: "这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```",
|
|
16
|
+
})
|
|
17
|
+
body := `{"user":"改完了吗","reply":"要我继续吗?"}`
|
|
18
|
+
out, errOut, err := runAskCmd(t, body, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatalf("suggest: %v(stderr=%s)", err, errOut)
|
|
21
|
+
}
|
|
22
|
+
var res suggest.Result
|
|
23
|
+
if uerr := json.Unmarshal([]byte(out), &res); uerr != nil {
|
|
24
|
+
t.Fatalf("输出不是 JSON:%v\n%s", uerr, out)
|
|
25
|
+
}
|
|
26
|
+
if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
|
|
27
|
+
t.Fatalf("Suggestions = %#v", res.Suggestions)
|
|
28
|
+
}
|
|
29
|
+
if res.Engine != "fake-suggest-cli" {
|
|
30
|
+
t.Fatalf("Engine = %q", res.Engine)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TestSuggestFollowupsEmptyReply 没有 reply 是**参数错**,不能当成空建议静默成功。
|
|
35
|
+
func TestSuggestFollowupsEmptyReply(t *testing.T) {
|
|
36
|
+
_, _, err := runAskCmd(t, `{"user":"只说了一句"}`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
37
|
+
if err == nil {
|
|
38
|
+
t.Fatal("缺 reply 应报参数错")
|
|
39
|
+
}
|
|
40
|
+
if !strings.Contains(err.Error(), "reply") {
|
|
41
|
+
t.Fatalf("报错没说清缺什么:%v", err)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// TestSuggestFollowupsBadJSON 非法 JSON 也必须明确报错(exit 2 的语义)。
|
|
46
|
+
func TestSuggestFollowupsBadJSON(t *testing.T) {
|
|
47
|
+
_, _, err := runAskCmd(t, `not json`, "--suggest-followups", "-", "-e", "fake-suggest-cli")
|
|
48
|
+
if err == nil {
|
|
49
|
+
t.Fatal("非法 JSON 应报参数错")
|
|
50
|
+
}
|
|
51
|
+
if _, ok := err.(*usageError); !ok {
|
|
52
|
+
t.Fatalf("应是 usageError(exit 2),实际 %T: %v", err, err)
|
|
53
|
+
}
|
|
54
|
+
}
|
package/internal/cli/team.go
CHANGED
|
@@ -17,6 +17,8 @@ import (
|
|
|
17
17
|
"encoding/json"
|
|
18
18
|
"fmt"
|
|
19
19
|
"strings"
|
|
20
|
+
"sync"
|
|
21
|
+
"time"
|
|
20
22
|
|
|
21
23
|
"github.com/spf13/cobra"
|
|
22
24
|
|
|
@@ -98,7 +100,50 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
98
100
|
// 事件出口:--no-thinking 只压思考。
|
|
99
101
|
// 团队模式下主会话只出现任务列表快照(KindTasks)与汇总,子任务的过程事件
|
|
100
102
|
// 走各自子会话的日志(见 team_session.go),不再带 parent 转发进来。
|
|
101
|
-
|
|
103
|
+
//
|
|
104
|
+
// ⚠️ 登记表的「还活着」靠这里续期(2026-10-22 报障:「团队模式子任务还在跑 主会话
|
|
105
|
+
// 就说结束了」)。流式路径(ask.go)对**每条事件**都写一次状态,团队路径此前只在
|
|
106
|
+
// 开跑前写一次 running —— 计划 + 子任务几分钟里 updated_at 一动不动,网关的尾随
|
|
107
|
+
// 连接(followStateEvery=5s)据 terminalSessionState 判「这一轮已结束」,画面落
|
|
108
|
+
// 红点收尾行,而子任务进程其实还在跑。团队模式没有 turn_end 可依,所以任何**主会话
|
|
109
|
+
// 自己发出的事件**(计划、任务列表快照、心跳、汇总)都当成一次「还在跑」。
|
|
110
|
+
// (h 在下面才建:闭包定义在这里不带值,第一次调用在 beginSession 之后。)
|
|
111
|
+
var emit func(agent.StreamEvent)
|
|
112
|
+
|
|
113
|
+
ctx := cmd.Context()
|
|
114
|
+
if !opts.keepAlive || flagChanged(cmd, "timeout") {
|
|
115
|
+
var cancel context.CancelFunc
|
|
116
|
+
ctx, cancel = context.WithTimeout(ctx, opts.timeout)
|
|
117
|
+
defer cancel()
|
|
118
|
+
}
|
|
119
|
+
// --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
|
|
120
|
+
var cancelAll context.CancelFunc
|
|
121
|
+
if opts.control {
|
|
122
|
+
ctx, cancelAll = context.WithCancel(ctx)
|
|
123
|
+
defer cancelAll()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
h := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
127
|
+
/* 团队模式**不挂** spawn hook:登记表里的 pid 必须一直是 magic-agent 自己。
|
|
128
|
+
团队路径会多次 spawn 主引擎(计划 / 汇总 / 补充规划),hook 会把 pid 换成
|
|
129
|
+
这些**短命**子进程的 pid;第一个(计划阶段)一退,normalizeDead 见 pid 不在
|
|
130
|
+
就判整条主会话 gone —— 而此刻子任务正在跑。真机复现:24 分钟的一轮里主会话
|
|
131
|
+
在第 32 秒就被判死,直到收尾才写回 done。
|
|
132
|
+
子引擎进程不靠这条登记收:它们各有自己的子会话记录(openSubSession),
|
|
133
|
+
异常退出时由 orphanSubs 按进程组收。 */
|
|
134
|
+
_ = withSessionHook
|
|
135
|
+
if h != nil {
|
|
136
|
+
// 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
|
|
137
|
+
h.SetMode(session.ModeTeam)
|
|
138
|
+
h.Mark(session.StateRunning)
|
|
139
|
+
}
|
|
140
|
+
emit = func(ev agent.StreamEvent) {
|
|
141
|
+
h.Mark(session.StateRunning)
|
|
142
|
+
/* 续期(2026-10-22 真机复现):Mark 在新旧状态相同时**不写盘**,所以上面那一句
|
|
143
|
+
对「已经 running」的会话等于空操作 —— updated_at 从第一条事件起就冻住了,
|
|
144
|
+
网关尾随连接据此判终态,画面在子任务还在跑时就落「这一轮已结束」。
|
|
145
|
+
Touch 才是真正的续期;5 秒节流对齐网关的 followStateEvery。 */
|
|
146
|
+
h.Touch(5 * time.Second)
|
|
102
147
|
if ev.Kind == agent.KindThinking && opts.noThinking {
|
|
103
148
|
return
|
|
104
149
|
}
|
|
@@ -136,27 +181,6 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
136
181
|
})
|
|
137
182
|
}
|
|
138
183
|
}
|
|
139
|
-
|
|
140
|
-
ctx := cmd.Context()
|
|
141
|
-
if !opts.keepAlive || flagChanged(cmd, "timeout") {
|
|
142
|
-
var cancel context.CancelFunc
|
|
143
|
-
ctx, cancel = context.WithTimeout(ctx, opts.timeout)
|
|
144
|
-
defer cancel()
|
|
145
|
-
}
|
|
146
|
-
// --control 的打断:取消 ctx → 在跑的子任务进程组一起收。
|
|
147
|
-
var cancelAll context.CancelFunc
|
|
148
|
-
if opts.control {
|
|
149
|
-
ctx, cancelAll = context.WithCancel(ctx)
|
|
150
|
-
defer cancelAll()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
h := beginSession(engine.Name(), req, opts.saveRegistry)
|
|
154
|
-
ctx = withSessionHook(ctx, h)
|
|
155
|
-
if h != nil {
|
|
156
|
-
// 开跑前就标上:计划阶段可能几十秒,这期间列表里已经能看到这条会话了。
|
|
157
|
-
h.SetMode(session.ModeTeam)
|
|
158
|
-
h.Mark(session.StateRunning)
|
|
159
|
-
}
|
|
160
184
|
if sessionLog != nil && h != nil {
|
|
161
185
|
sessionLog.setRunID(h.RunID())
|
|
162
186
|
}
|
|
@@ -180,6 +204,10 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
180
204
|
if h != nil {
|
|
181
205
|
runID = h.RunID()
|
|
182
206
|
}
|
|
207
|
+
/* 本次运行开出来的子会话(按 pid 收进程组用,见 orphanSubs)。
|
|
208
|
+
写它的是跑子任务的 goroutine,读的是异常退出那一支 —— 加锁。 */
|
|
209
|
+
var mu sync.Mutex
|
|
210
|
+
var subs []*subSession
|
|
183
211
|
res, err := team.Run(ctx, team.Options{
|
|
184
212
|
Engine: engine,
|
|
185
213
|
Model: req.Model,
|
|
@@ -204,13 +232,48 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
204
232
|
PlanOnly: opts.teamPlanOnly,
|
|
205
233
|
Adaptive: opts.teamAdaptive,
|
|
206
234
|
// 每个子任务 = 一条普通会话:登记记录 + 自己的日志,父指针挂在本会话下。
|
|
235
|
+
//
|
|
236
|
+
// 开出来的子会话都记进 subs:父进程异常退出时要按它们的 pid 收进程组
|
|
237
|
+
// (见下面的 orphan sweep),否则主会话没了、子引擎还在跑,列表里挂一条永远
|
|
238
|
+
// running 的子会话。
|
|
207
239
|
OpenSubSession: func(t team.Task, runDir, engine, model string) team.SubSession {
|
|
208
|
-
|
|
240
|
+
s := openSubSession(h, opts.saveRegistry, opts.saveHistory, stderr, t, runDir, engine, model)
|
|
241
|
+
if ss, ok := s.(*subSession); ok && ss != nil {
|
|
242
|
+
mu.Lock()
|
|
243
|
+
subs = append(subs, ss)
|
|
244
|
+
mu.Unlock()
|
|
245
|
+
}
|
|
246
|
+
return s
|
|
209
247
|
},
|
|
210
248
|
OnEvent: emit,
|
|
249
|
+
// 计划阶段不发事件的续期(见 team.Options.OnTick)。
|
|
250
|
+
OnTick: func() { h.Touch(0) },
|
|
211
251
|
})
|
|
252
|
+
mu.Lock()
|
|
253
|
+
running := append([]*subSession(nil), subs...)
|
|
254
|
+
mu.Unlock()
|
|
212
255
|
if err != nil {
|
|
213
|
-
|
|
256
|
+
/* 异常退出也要有**终态**(2026-10-22):团队模式没有 turn_end 可依,父进程失败时
|
|
257
|
+
一条终态事件都不发 —— 客户端只能看登记表,而登记表停在 running,网关按「还在跑」
|
|
258
|
+
一直等(或按内核被杀归一成 gone)。这里补一条 turn_failed:走的是流式路径
|
|
259
|
+
同一条收尾口径(turn_failed → 画面一行「本轮异常收尾」),调用方不必再猜。 */
|
|
260
|
+
interrupted := ctx.Err() != nil
|
|
261
|
+
reason := ""
|
|
262
|
+
if interrupted {
|
|
263
|
+
reason = "已中断(主进程被取消或被杀)"
|
|
264
|
+
} else {
|
|
265
|
+
reason = err.Error()
|
|
266
|
+
}
|
|
267
|
+
emit(agent.StreamEvent{Kind: agent.KindTurnFailed, Text: reason, Reason: reason})
|
|
268
|
+
state := session.StateFailed
|
|
269
|
+
if interrupted {
|
|
270
|
+
state = session.StateInterrupted
|
|
271
|
+
}
|
|
272
|
+
finishSession(h, "", state)
|
|
273
|
+
/* 子引擎是独立进程组(子会话上下文里挂的 SpawnHook 按组登记 pid):
|
|
274
|
+
父这一轮已经作废,把还在跑的一起收掉 —— 否则会话列表里永远挂着一条 running
|
|
275
|
+
的子会话,用户点进去只看到半截过程,而且它会一直烧额度。 */
|
|
276
|
+
orphanSubs(running)
|
|
214
277
|
if format == agent.FormatJSON {
|
|
215
278
|
_ = sink.event(streamErrorPayload(engine.Name(), err))
|
|
216
279
|
_ = agent.WriteError(stderr, format, engine.Name(), 1, err)
|
|
@@ -253,6 +316,30 @@ func runTeamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
253
316
|
return nil
|
|
254
317
|
}
|
|
255
318
|
|
|
319
|
+
// orphanSubs 收掉还没落定的子会话对应的子引擎进程组。
|
|
320
|
+
//
|
|
321
|
+
// 只在团队这一轮**异常退出**时调:正常收尾时每个子会话都在 runTask 的 end() 里
|
|
322
|
+
// Finish 过(进程已经自己退了)。这里兜的是「父进程这一轮作废、子引擎还在跑」那条路
|
|
323
|
+
// —— 不收就是孤儿进程:它一边烧额度,一边让会话列表里那条子会话永远 running。
|
|
324
|
+
//
|
|
325
|
+
// 幂等:进程已经不在(PIDAlive=false)就只补登记表状态,不再发信号。
|
|
326
|
+
func orphanSubs(list []*subSession) {
|
|
327
|
+
for _, s := range list {
|
|
328
|
+
if s == nil {
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
rec := s.h.Record()
|
|
332
|
+
if rec.PID > 0 && agent.PIDAlive(rec.PID) {
|
|
333
|
+
_ = agent.KillPID(rec.PID, rec.KillGroup)
|
|
334
|
+
}
|
|
335
|
+
s.h.Finish(rec.SessionID, session.StateInterrupted)
|
|
336
|
+
if s.log != nil {
|
|
337
|
+
s.log.close()
|
|
338
|
+
s.log = nil
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
256
343
|
// agentRefs 把子任务结果按「类别 + 引擎 + 模型」归并成登记表里的 Agents。
|
|
257
344
|
//
|
|
258
345
|
// 为什么按类别归并而不是一个任务一条:登记表是列表页的数据源,一个 6 任务的团队会话
|
|
@@ -221,6 +221,9 @@ func (s *Service) Handler() http.Handler {
|
|
|
221
221
|
// 按**绝对路径**读文本文件(Markdown / 日志 / 源码):移动端产物卡「查看」在浮层里阅读。
|
|
222
222
|
mux.HandleFunc("/desk/path-read", s.guard(s.handlePathRead))
|
|
223
223
|
mux.HandleFunc("/desk/titles", s.guard(s.handleTitles))
|
|
224
|
+
// 下一轮快捷回复(2026-10-23):一轮结束后生成 0-3 条「用户下一句」。
|
|
225
|
+
// 旁路调用 core 的 --suggest-followups(不续接会话),界面点击即发。
|
|
226
|
+
mux.HandleFunc("/desk/suggest", s.guard(s.handleSuggest))
|
|
224
227
|
// 移动端「环境配置」(引擎/模型/团队/空间选择)的远端存储:多客户端连同一台桌面时共用一份,
|
|
225
228
|
// 不再各存各的 localStorage(见 prefs.go)。
|
|
226
229
|
mux.HandleFunc("/desk/prefs", s.guard(s.handlePrefs))
|
|
@@ -1074,6 +1077,8 @@ func truncateForMsg(s string) string {
|
|
|
1074
1077
|
// 2026-10-19:实现挪进 `session.Delete`(internal/session/delete.go)—— CLI 新增的
|
|
1075
1078
|
// `--delete-session` 走的是同一份,两条路不再各写一遍(此前只有本接口能做真删除,
|
|
1076
1079
|
// 于是桌面端只能在自己本地藏起来,出现「PC 删了、移动端还在」)。
|
|
1080
|
+
// 2026-10-23:`session.Delete` 会沿父指针级联删掉团队主会话下的子任务会话,
|
|
1081
|
+
// 本接口因此也一并生效(响应多一个只增字段 childSessionsRemoved)。
|
|
1077
1082
|
func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
|
|
1078
1083
|
id, action, ok := parseSessionPath(r.URL.Path)
|
|
1079
1084
|
if !ok || action != "" {
|
|
@@ -1085,7 +1090,12 @@ func (s *Service) handleSessionDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
1085
1090
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("删除会话 %s 失败:%v", id, err))
|
|
1086
1091
|
return
|
|
1087
1092
|
}
|
|
1088
|
-
|
|
1093
|
+
// 子会话计数只增字段(旧客户端忽略):告诉 UI「这次连子任务一起删了几条」。
|
|
1094
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
1095
|
+
"id": res.ID,
|
|
1096
|
+
"recordsRemoved": res.RecordsRemoved,
|
|
1097
|
+
"childSessionsRemoved": res.ChildSessionsRemoved,
|
|
1098
|
+
})
|
|
1089
1099
|
}
|
|
1090
1100
|
|
|
1091
1101
|
// parseSessionPath 从 `/desk/session/{id}/{action}` 拆出 id 与 action,并做**路径安全校验**。
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// suggest.go - `POST /desk/suggest`:生成「下一轮快捷回复」(一轮结束后可点的下一句)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么放在插件而不是各端自己调模型(用户 2026-10-23:「在回复结束后 ai 自动生成
|
|
6
|
+
// 下一轮的建议,点击即可发送」,先给了方案、选了「B. 模型生成」):
|
|
7
|
+
// 1. 提示词与解析口径只能有一份 —— 移动端 / PC 各写一份,同一轮回答会给出不同建议;
|
|
8
|
+
// 2. 模型调用要落在桌面端(引擎 CLI、凭据、配额都在那台机器上),手机只拿结果;
|
|
9
|
+
// 3. **不续接当前会话**:core 的 `--suggest-followups` 是一次独立旁路调用(ToolsOff、
|
|
10
|
+
// 不带 SessionID),不会把「请生成建议」这类指令写进用户正在聊的上下文。
|
|
11
|
+
//
|
|
12
|
+
// 与 `/desk/session/{id}/title/summarize` 同一套做法:插件 exec core,不自己实现归纳/提炼。
|
|
13
|
+
// 失败一律如实回错(502/504),界面侧静默不画按钮 —— 没有建议是合法结果,不拿模板凑。
|
|
14
|
+
|
|
15
|
+
import (
|
|
16
|
+
"bytes"
|
|
17
|
+
"context"
|
|
18
|
+
"encoding/json"
|
|
19
|
+
"io"
|
|
20
|
+
"net/http"
|
|
21
|
+
"os/exec"
|
|
22
|
+
"strings"
|
|
23
|
+
"time"
|
|
24
|
+
"unicode/utf8"
|
|
25
|
+
|
|
26
|
+
"github.com/darren/magic-agent/internal/suggest"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const (
|
|
30
|
+
// suggestTimeout 单次生成的超时。界面侧 20s 就放弃(见 conversation.js),
|
|
31
|
+
// 这里留一点余量,避免「进程还没退、上下文已经被撤」的夹缝报错。
|
|
32
|
+
suggestTimeout = 25 * time.Second
|
|
33
|
+
// suggestMaxBody 请求体上限:只该带「这一轮」的问答,超了说明调用方塞了整条会话。
|
|
34
|
+
suggestMaxBody = 64 << 10
|
|
35
|
+
// suggestMaxUserRunes / suggestMaxReplyRunes 服务端兜底截断(core 还会再截一次)。
|
|
36
|
+
suggestMaxUserRunes = 4000
|
|
37
|
+
suggestMaxReplyRunes = 16000
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// suggestSlots 并发闸:建议是锦上添花,不能因为客户端重试/多开把引擎进程堆起来。
|
|
41
|
+
// 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
|
|
42
|
+
var suggestSlots = make(chan struct{}, 2)
|
|
43
|
+
|
|
44
|
+
// handleSuggest POST /desk/suggest。
|
|
45
|
+
//
|
|
46
|
+
// 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
|
|
47
|
+
// 响应:{"suggestions":["…", "…"], "engine":"llm", "elapsedMs":1234}
|
|
48
|
+
func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
|
|
49
|
+
if r.Method != http.MethodPost {
|
|
50
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
var body struct {
|
|
54
|
+
User string `json:"user"`
|
|
55
|
+
Reply string `json:"reply"`
|
|
56
|
+
Engine string `json:"engine"`
|
|
57
|
+
Model string `json:"model"`
|
|
58
|
+
}
|
|
59
|
+
if r.Body != nil {
|
|
60
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
|
|
61
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+truncateForMsg(err.Error()))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
body.Reply = strings.TrimSpace(body.Reply)
|
|
66
|
+
if body.Reply == "" {
|
|
67
|
+
writeErr(w, http.StatusBadRequest, `缺少 reply(本轮回答正文):形如 {"user":"…","reply":"…"}`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
|
|
71
|
+
body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
|
|
72
|
+
|
|
73
|
+
select {
|
|
74
|
+
case suggestSlots <- struct{}{}:
|
|
75
|
+
defer func() { <-suggestSlots }()
|
|
76
|
+
default:
|
|
77
|
+
writeErr(w, http.StatusTooManyRequests, "建议生成太频繁:上一轮还没结束,请稍后")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ctx, cancel := context.WithTimeout(r.Context(), suggestTimeout)
|
|
82
|
+
defer cancel()
|
|
83
|
+
|
|
84
|
+
payload, _ := json.Marshal(suggest.RequestBody{User: body.User, Reply: body.Reply})
|
|
85
|
+
args := []string{"--suggest-followups", "-"}
|
|
86
|
+
/* 引擎/模型由界面带(当前引擎一定装过);不传则 core 用 llm(见 runSuggestFollowups)。 */
|
|
87
|
+
if e := strings.TrimSpace(body.Engine); e != "" {
|
|
88
|
+
args = append(args, "-e", e)
|
|
89
|
+
}
|
|
90
|
+
if m := strings.TrimSpace(body.Model); m != "" {
|
|
91
|
+
args = append(args, "-m", m)
|
|
92
|
+
}
|
|
93
|
+
cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
|
|
94
|
+
cmd.Stdin = bytes.NewReader(payload)
|
|
95
|
+
var stdout, stderr bytes.Buffer
|
|
96
|
+
cmd.Stdout = &stdout
|
|
97
|
+
cmd.Stderr = &stderr
|
|
98
|
+
if err := cmd.Run(); err != nil {
|
|
99
|
+
if ctx.Err() == context.DeadlineExceeded {
|
|
100
|
+
writeErr(w, http.StatusGatewayTimeout, "生成建议超时(>25s):这一轮先不给建议")
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
msg := strings.TrimSpace(stderr.String())
|
|
104
|
+
if msg == "" {
|
|
105
|
+
msg = strings.TrimSpace(stdout.String())
|
|
106
|
+
}
|
|
107
|
+
if msg == "" {
|
|
108
|
+
msg = err.Error()
|
|
109
|
+
}
|
|
110
|
+
writeErr(w, http.StatusBadGateway, "生成建议失败:"+truncateForMsg(msg))
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
var res suggest.Result
|
|
114
|
+
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
|
115
|
+
// core 换了输出形状就如实报错,别静默回空(那会表现成「功能时灵时不灵」)。
|
|
116
|
+
writeErr(w, http.StatusBadGateway, "core 回的建议不是 JSON:"+truncateForMsg(stdout.String()))
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if res.Suggestions == nil {
|
|
120
|
+
res.Suggestions = []string{}
|
|
121
|
+
}
|
|
122
|
+
writeJSON(w, http.StatusOK, res)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// clipRunes 按 rune 截断(中文不能按字节切)。
|
|
126
|
+
func clipRunes(s string, max int) string {
|
|
127
|
+
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
128
|
+
return s
|
|
129
|
+
}
|
|
130
|
+
return string([]rune(s)[:max])
|
|
131
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
|
|
14
|
+
func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
|
|
15
|
+
t.Helper()
|
|
16
|
+
argLog := filepath.Join(dir, "args.txt")
|
|
17
|
+
p := filepath.Join(dir, "magic-agent")
|
|
18
|
+
body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
|
|
19
|
+
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
return p, argLog
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func TestSuggestEndpoint(t *testing.T) {
|
|
26
|
+
dir := t.TempDir()
|
|
27
|
+
core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
|
|
28
|
+
s := &Service{opts: Options{MagicAgent: core}}
|
|
29
|
+
|
|
30
|
+
req := httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
31
|
+
strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine","model":"fake-model"}`))
|
|
32
|
+
rec := httptest.NewRecorder()
|
|
33
|
+
s.handleSuggest(rec, req)
|
|
34
|
+
if rec.Code != http.StatusOK {
|
|
35
|
+
t.Fatalf("code = %d,body=%s", rec.Code, rec.Body.String())
|
|
36
|
+
}
|
|
37
|
+
var res struct {
|
|
38
|
+
Suggestions []string `json:"suggestions"`
|
|
39
|
+
Engine string `json:"engine"`
|
|
40
|
+
}
|
|
41
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
|
|
42
|
+
t.Fatalf("响应不是 JSON:%v", err)
|
|
43
|
+
}
|
|
44
|
+
if len(res.Suggestions) != 2 || res.Suggestions[0] != "好的,执行吧" {
|
|
45
|
+
t.Fatalf("suggestions = %#v", res.Suggestions)
|
|
46
|
+
}
|
|
47
|
+
args, _ := os.ReadFile(argLog)
|
|
48
|
+
if !strings.Contains(string(args), "--suggest-followups -") {
|
|
49
|
+
t.Fatalf("没走 --suggest-followups:%s", args)
|
|
50
|
+
}
|
|
51
|
+
if !strings.Contains(string(args), "-e fake-engine") || !strings.Contains(string(args), "-m fake-model") {
|
|
52
|
+
t.Fatalf("引擎/模型没透传:%s", args)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestSuggestEndpointBadRequests(t *testing.T) {
|
|
57
|
+
dir := t.TempDir()
|
|
58
|
+
core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
|
|
59
|
+
s := &Service{opts: Options{MagicAgent: core}}
|
|
60
|
+
|
|
61
|
+
cases := []struct {
|
|
62
|
+
name string
|
|
63
|
+
req *http.Request
|
|
64
|
+
code int
|
|
65
|
+
}{
|
|
66
|
+
{"GET 不允许", httptest.NewRequest(http.MethodGet, "/desk/suggest", nil), http.StatusMethodNotAllowed},
|
|
67
|
+
{"缺 reply", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`{"user":"只说了半句"}`)), http.StatusBadRequest},
|
|
68
|
+
{"非法 JSON", httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(`nope`)), http.StatusBadRequest},
|
|
69
|
+
}
|
|
70
|
+
for _, c := range cases {
|
|
71
|
+
rec := httptest.NewRecorder()
|
|
72
|
+
s.handleSuggest(rec, c.req)
|
|
73
|
+
if rec.Code != c.code {
|
|
74
|
+
t.Errorf("%s:code = %d,期望 %d(body=%s)", c.name, rec.Code, c.code, rec.Body.String())
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func TestSuggestEndpointCoreFailure(t *testing.T) {
|
|
80
|
+
dir := t.TempDir()
|
|
81
|
+
core, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
|
|
82
|
+
s := &Service{opts: Options{MagicAgent: core}}
|
|
83
|
+
rec := httptest.NewRecorder()
|
|
84
|
+
s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
85
|
+
strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
|
|
86
|
+
if rec.Code != http.StatusBadGateway {
|
|
87
|
+
t.Fatalf("code = %d,期望 502", rec.Code)
|
|
88
|
+
}
|
|
89
|
+
if !strings.Contains(rec.Body.String(), "llm CLI not found") {
|
|
90
|
+
t.Fatalf("core 的失败原因没带回:%s", rec.Body.String())
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestSuggestEndpointGarbageCoreOutput(t *testing.T) {
|
|
95
|
+
dir := t.TempDir()
|
|
96
|
+
core, _ := fakeSuggestCore(t, dir, `printf 'not json\n'`)
|
|
97
|
+
s := &Service{opts: Options{MagicAgent: core}}
|
|
98
|
+
rec := httptest.NewRecorder()
|
|
99
|
+
s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
100
|
+
strings.NewReader(`{"user":"x","reply":"要我继续吗?"}`)))
|
|
101
|
+
if rec.Code != http.StatusBadGateway {
|
|
102
|
+
t.Fatalf("code = %d,期望 502", rec.Code)
|
|
103
|
+
}
|
|
104
|
+
if !strings.Contains(rec.Body.String(), "不是 JSON") {
|
|
105
|
+
t.Fatalf("应明确说 core 的输出不是 JSON:%s", rec.Body.String())
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestClipRunes(t *testing.T) {
|
|
110
|
+
if got := clipRunes("一二三四五", 3); got != "一二三" {
|
|
111
|
+
t.Fatalf("clipRunes = %q", got)
|
|
112
|
+
}
|
|
113
|
+
if got := clipRunes("短", 10); got != "短" {
|
|
114
|
+
t.Fatalf("短文本不该变:%q", got)
|
|
115
|
+
}
|
|
116
|
+
}
|