magic-agent 2.4.3 → 2.6.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 +284 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +6 -0
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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 +8 -2
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
13
|
+
"github.com/darren/magic-agent/internal/session"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// TestAppendAndCompactRelations 追加重复行 → compact 后去重、字段取最新、顺序稳定。
|
|
17
|
+
func TestAppendAndCompactRelations(t *testing.T) {
|
|
18
|
+
root := testEnv(t)
|
|
19
|
+
id := DirID(filepath.Join(root, "proj-rel"))
|
|
20
|
+
|
|
21
|
+
base := Relation{Session: "s-1", STitle: "会话一", Module: "任务列表", Feature: "加待办", N: 3}
|
|
22
|
+
r1 := base
|
|
23
|
+
r1.Ts = 100
|
|
24
|
+
r2 := base
|
|
25
|
+
r2.Ts = 200 // 同键重复:compact 应保 Ts=200 这条
|
|
26
|
+
r3 := Relation{Session: "s-1", STitle: "会话一", Module: "任务列表", Feature: "加待办", N: 5, Ts: 300}
|
|
27
|
+
r4 := Relation{Session: "s-2", Module: "任务列表", Feature: "加待办", N: 3, Ts: 400}
|
|
28
|
+
if err := appendRelations(id, []Relation{r1, r3, r4}); err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
if err := appendRelations(id, []Relation{r2}); err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
if rels := readRelations(id); len(rels) != 4 {
|
|
35
|
+
t.Fatalf("追加期不去重,应 4 行,实际 %d", len(rels))
|
|
36
|
+
}
|
|
37
|
+
if err := compactRelations(id); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
rels := readRelations(id)
|
|
41
|
+
if len(rels) != 3 {
|
|
42
|
+
t.Fatalf("去重后应 3 行,实际 %d: %+v", len(rels), rels)
|
|
43
|
+
}
|
|
44
|
+
if rels[0].Ts != 200 {
|
|
45
|
+
t.Errorf("同键重复应保最新,实际 Ts=%d", rels[0].Ts)
|
|
46
|
+
}
|
|
47
|
+
if rels[2].STitle != "" {
|
|
48
|
+
t.Errorf("快照缺失就该缺着,不伪造: %+v", rels[2])
|
|
49
|
+
}
|
|
50
|
+
// compact 幂等
|
|
51
|
+
if err := compactRelations(id); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if rels2 := readRelations(id); len(rels2) != 3 {
|
|
55
|
+
t.Errorf("二次 compact 不应变化,实际 %d", len(rels2))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TestConsolidateWritesRelations 归纳落账关系:module 用**改写后**的归属;
|
|
60
|
+
// 模块合并后 relations 里的 from 一并改写成 to。
|
|
61
|
+
func TestConsolidateWritesRelations(t *testing.T) {
|
|
62
|
+
root := testEnv(t)
|
|
63
|
+
proj := filepath.Join(root, "proj-rel-cons")
|
|
64
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
65
|
+
t.Fatal(err)
|
|
66
|
+
}
|
|
67
|
+
writeSessionLog(t, session.Dir(), "s-rel", proj, "引擎切换加上", "调用时带上超时")
|
|
68
|
+
id := DirID(proj)
|
|
69
|
+
if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
|
|
70
|
+
t.Fatal(err)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
old := newCaller
|
|
74
|
+
defer func() { newCaller = old }()
|
|
75
|
+
round := 0
|
|
76
|
+
newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
|
|
77
|
+
return func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
78
|
+
switch {
|
|
79
|
+
case strings.Contains(prompt, "== 归纳规则 =="):
|
|
80
|
+
round++
|
|
81
|
+
if round == 1 {
|
|
82
|
+
return `{"features":[{"module":"引擎管理","name":"引擎切换","desc":"用户切换默认引擎","points":["可切换默认引擎"],"sources":[1,2]}]}`, nil
|
|
83
|
+
}
|
|
84
|
+
return `{"features":[{"module":"引擎调用","name":"调用超时","desc":"用户设置调用超时","points":["可设置调用超时"],"sources":[3]}]}`, nil
|
|
85
|
+
case strings.Contains(prompt, "本轮新增模块"):
|
|
86
|
+
return `{"pairs":[]}`, nil
|
|
87
|
+
default:
|
|
88
|
+
return `{"pairs":[{"from":"引擎调用","to":"引擎管理"}]}`, nil
|
|
89
|
+
}
|
|
90
|
+
}, nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if _, err := Consolidate(context.Background(), proj, Options{}); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
rels := readRelations(id)
|
|
97
|
+
if len(rels) != 2 {
|
|
98
|
+
t.Fatalf("第一轮应落 2 条关系(#1#2 → 引擎切换),实际 %+v", rels)
|
|
99
|
+
}
|
|
100
|
+
for _, r := range rels {
|
|
101
|
+
if r.Session != "s-rel" {
|
|
102
|
+
t.Errorf("session 应取记忆条目,实际 %q", r.Session)
|
|
103
|
+
}
|
|
104
|
+
if r.STitle == "" {
|
|
105
|
+
t.Errorf("STitle 应有会话标题快照: %+v", r)
|
|
106
|
+
}
|
|
107
|
+
if r.Feature == "引擎切换" && r.Module != "引擎管理" {
|
|
108
|
+
t.Errorf("module 用改写后的归属,实际 %q", r.Module)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 追加一条记忆触发第二轮 + 模块合并 → relations 的 module 应已改写。
|
|
113
|
+
ev, err := os.OpenFile(filepath.Join(session.Dir(), "s-rel.jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatal(err)
|
|
116
|
+
}
|
|
117
|
+
line := `{"seq":100,"kind":"user","text":"调用默认引擎带上超时","at":"2026-10-25T10:00:00Z","session_id":"s-rel"}` + "\n"
|
|
118
|
+
if _, err := ev.WriteString(line); err != nil {
|
|
119
|
+
t.Fatal(err)
|
|
120
|
+
}
|
|
121
|
+
if err := ev.Close(); err != nil {
|
|
122
|
+
t.Fatal(err)
|
|
123
|
+
}
|
|
124
|
+
if _, err := Consolidate(context.Background(), proj, Options{}); err != nil {
|
|
125
|
+
t.Fatal(err)
|
|
126
|
+
}
|
|
127
|
+
found := false
|
|
128
|
+
for _, r := range readRelations(id) {
|
|
129
|
+
if r.Feature == "调用超时" && r.N == 3 {
|
|
130
|
+
found = true
|
|
131
|
+
}
|
|
132
|
+
if r.Module == "引擎调用" {
|
|
133
|
+
t.Errorf("模块合并后 relations 里的 from 应已改写: %+v", r)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if !found {
|
|
137
|
+
t.Errorf("第二轮应补上 #3 → 调用超时的关系: %+v", readRelations(id))
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// TestRewriteRelationsModule 直接改写:from → to。
|
|
142
|
+
func TestRewriteRelationsModule(t *testing.T) {
|
|
143
|
+
root := testEnv(t)
|
|
144
|
+
id := DirID(filepath.Join(root, "proj-rel-rw"))
|
|
145
|
+
writeFeatureDoc(t, id, "内容创作", "写文案", "生成文案")
|
|
146
|
+
if err := appendRelations(id, []Relation{
|
|
147
|
+
{Session: "s-1", Module: "内容创作", Feature: "写文案", N: 1, Ts: 1},
|
|
148
|
+
{Session: "s-1", Module: "团队协作", Feature: "分工", N: 2, Ts: 2},
|
|
149
|
+
}); err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if err := rewriteRelationsModule(id, "内容创作", "创作"); err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
got := map[string]string{}
|
|
156
|
+
for _, r := range readRelations(id) {
|
|
157
|
+
got[r.Feature] = r.Module
|
|
158
|
+
}
|
|
159
|
+
if got["写文案"] != "创作" || got["分工"] != "团队协作" {
|
|
160
|
+
t.Fatalf("改写不正确: %v", got)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// TestBuildGraph 多会话 × 多功能点多对多 → 节点/边正确;文档不存在的关系丢弃。
|
|
165
|
+
func TestBuildGraph(t *testing.T) {
|
|
166
|
+
root := testEnv(t)
|
|
167
|
+
proj := filepath.Join(root, "proj-graph")
|
|
168
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
169
|
+
t.Fatal(err)
|
|
170
|
+
}
|
|
171
|
+
id := DirID(proj)
|
|
172
|
+
writeFeatureDoc(t, id, "任务列表", "加待办", "新增待办")
|
|
173
|
+
writeFeatureDoc(t, id, "任务列表", "删待办", "删除待办")
|
|
174
|
+
if err := appendRelations(id, []Relation{
|
|
175
|
+
{Session: "s-a", STitle: "会话A", Module: "任务列表", Feature: "加待办", N: 1, Ts: 1},
|
|
176
|
+
{Session: "s-a", STitle: "会话A", Module: "任务列表", Feature: "删待办", N: 2, Ts: 2},
|
|
177
|
+
{Session: "s-b", Module: "任务列表", Feature: "加待办", N: 3, Ts: 3},
|
|
178
|
+
// 悬空关系:文档已不在 → 节点与边都应丢弃
|
|
179
|
+
{Session: "s-a", STitle: "会话A", Module: "旧模块", Feature: "旧功能", N: 4, Ts: 4},
|
|
180
|
+
}); err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
b, err := BuildGraph(proj)
|
|
185
|
+
if err != nil {
|
|
186
|
+
t.Fatal(err)
|
|
187
|
+
}
|
|
188
|
+
var out struct {
|
|
189
|
+
OK bool `json:"ok"`
|
|
190
|
+
Workspaces []struct {
|
|
191
|
+
ID string `json:"id"`
|
|
192
|
+
Nodes []graphNode `json:"nodes"`
|
|
193
|
+
Edges []graphEdge `json:"edges"`
|
|
194
|
+
} `json:"workspaces"`
|
|
195
|
+
}
|
|
196
|
+
if err := json.Unmarshal(b, &out); err != nil {
|
|
197
|
+
t.Fatal(err)
|
|
198
|
+
}
|
|
199
|
+
if !out.OK || len(out.Workspaces) != 1 {
|
|
200
|
+
t.Fatalf("应输出 1 个工作区: %+v", out)
|
|
201
|
+
}
|
|
202
|
+
g := out.Workspaces[0]
|
|
203
|
+
if g.ID != id {
|
|
204
|
+
t.Errorf("工作区 id 应是 %s,实际 %s", id, g.ID)
|
|
205
|
+
}
|
|
206
|
+
var feats, sess int
|
|
207
|
+
for _, n := range g.Nodes {
|
|
208
|
+
if n.Type == "feature" {
|
|
209
|
+
feats++
|
|
210
|
+
} else {
|
|
211
|
+
sess++
|
|
212
|
+
if n.ID == "s:s-a" && n.Title != "会话A" {
|
|
213
|
+
t.Errorf("会话标题应取快照,实际 %q", n.Title)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if feats != 2 || sess != 2 {
|
|
218
|
+
t.Errorf("应 2 个功能点 + 2 个会话节点,实际 feature=%d session=%d: %+v", feats, sess, g.Nodes)
|
|
219
|
+
}
|
|
220
|
+
if len(g.Edges) != 3 {
|
|
221
|
+
t.Fatalf("3 条有效边(悬空关系丢弃),实际 %+v", g.Edges)
|
|
222
|
+
}
|
|
223
|
+
// 多对多:s-a 关联两个功能点、加待办有两个会话
|
|
224
|
+
targets := map[string]int{}
|
|
225
|
+
for _, e := range g.Edges {
|
|
226
|
+
targets[e.Source]++
|
|
227
|
+
}
|
|
228
|
+
if targets["s:s-a"] != 2 {
|
|
229
|
+
t.Errorf("s-a 应有 2 条边,实际 %v", targets)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -86,6 +86,12 @@ type Feature struct {
|
|
|
86
86
|
HasFinal bool `json:"has_final"`
|
|
87
87
|
// FinalPreview 最终版的第一句实质内容(卡片头条)。
|
|
88
88
|
FinalPreview string `json:"final_preview,omitempty"`
|
|
89
|
+
// PageURL 该功能点对应的页面地址(归纳时从对话里提取;可空)。
|
|
90
|
+
PageURL string `json:"page_url,omitempty"`
|
|
91
|
+
// Shots 页面完成情况截图数。
|
|
92
|
+
Shots int `json:"shots"`
|
|
93
|
+
// ShotAt 最后一张截图的时刻(unix 毫秒,0 = 还没截)。
|
|
94
|
+
ShotAt int64 `json:"shot_at,omitempty"`
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
// Module 一个模块(两级结构的一级)及其下的功能点。
|
|
@@ -171,6 +177,10 @@ type Result struct {
|
|
|
171
177
|
Replayed int `json:"replayed,omitempty"`
|
|
172
178
|
// Calls 本轮实际调了几次 LLM(分片)。
|
|
173
179
|
Calls int `json:"calls"`
|
|
180
|
+
// Merged 本轮合并掉的相似模块个数(模块去重,见 modules.go)。
|
|
181
|
+
Merged int `json:"merged,omitempty"`
|
|
182
|
+
// MergedModules 合并清单(「被合并模块 → 并入模块」,内容不丢、只并目录)。
|
|
183
|
+
MergedModules []string `json:"merged_modules,omitempty"`
|
|
174
184
|
// Pending 归纳完还剩多少条未归纳。
|
|
175
185
|
Pending int `json:"pending"`
|
|
176
186
|
// Error 失败原因(OK=false 时有值)。
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// shots.go - 「页面完成情况」截图的落盘与文档追加。
|
|
4
|
+
//
|
|
5
|
+
// 需求(2026-10-25 用户):「需求文档整理时 如果设计页面改造 调用内置浏览器mcp
|
|
6
|
+
// 对完成情况截图标注并放在文档中」。于是每个功能点的文档多出第七段
|
|
7
|
+
// 「页面完成情况」:页面地址 + 若干张带说明的截图。
|
|
8
|
+
//
|
|
9
|
+
// ── 为什么截图落盘在 core 而不在客户端 ──
|
|
10
|
+
// 文档是 core 的资产(~/.magic-agent/requirements/<wsid>/features/…),客户端只
|
|
11
|
+
// 负责发起与回显。截图写进文档必须与归纳写文档走同一套锁与原子写,否则两边
|
|
12
|
+
// 同时写会把对方的段落冲掉。
|
|
13
|
+
//
|
|
14
|
+
// ── 为什么追加截图用**文本级插入**而不是全量重渲染 ──
|
|
15
|
+
// ParseDoc → RenderDoc 的往返会丢东西:未知 ## 段、段内空行、Final 里的缩进、
|
|
16
|
+
// 用户手改过的措辞。归纳每轮都会重写文档(这是既有的既定行为),但**追加截图**
|
|
17
|
+
// 是「往用户手边的文档里加两行」的动作 —— 它不该顺手抹掉用户两次归纳之间
|
|
18
|
+
// 手写的内容。所以这里只做「定位段落 → 追加两行 → 其余字节原样写回」。
|
|
19
|
+
// 代价:文档里若有奇怪的段落顺序,插入点可能不理想 —— 那比丢内容好。
|
|
20
|
+
//
|
|
21
|
+
// ── 存储形状 ──
|
|
22
|
+
// <root>/<wsid>/shots/<模块>/<功能点slug>/<unix毫秒>.png
|
|
23
|
+
// 模块名为空(未归类)时用 unfiled;功能点用 SlugifyFeature 归一。
|
|
24
|
+
// 路径在文档里**一律正斜杠**(Windows 上反斜杠会让 markdown 的 `\` 变成转义)。
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"bytes"
|
|
28
|
+
"encoding/json"
|
|
29
|
+
"fmt"
|
|
30
|
+
"os"
|
|
31
|
+
"path/filepath"
|
|
32
|
+
"strconv"
|
|
33
|
+
"strings"
|
|
34
|
+
"time"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const (
|
|
38
|
+
// MaxShotBytes 单张截图的大小上限(PNG)。
|
|
39
|
+
//
|
|
40
|
+
// 8MB 对应约 2000×2000 的 PNG —— 手机全屏截图(含标注)远小于此。
|
|
41
|
+
MaxShotBytes = 8 << 20
|
|
42
|
+
// MaxShots 一个功能点最多保留的截图数(超出的丢最旧的)。
|
|
43
|
+
MaxShots = 12
|
|
44
|
+
// shotsDirName 截图目录名(相对 <wsid>)。
|
|
45
|
+
shotsDirName = "shots"
|
|
46
|
+
// unfiledModule 未归类模块的目录名(模块名为空时用它)。
|
|
47
|
+
unfiledModule = "unfiled"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// pngMagic PNG 文件头(8 字节签名)。
|
|
51
|
+
var pngMagic = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
|
|
52
|
+
|
|
53
|
+
// ShotResult 追加一张截图的结果(CLI 输出 / HTTP 响应共用)。
|
|
54
|
+
type ShotResult struct {
|
|
55
|
+
OK bool `json:"ok"`
|
|
56
|
+
// Workspace / Dir 归属的工作区。
|
|
57
|
+
Workspace string `json:"workspace,omitempty"`
|
|
58
|
+
Dir string `json:"dir"`
|
|
59
|
+
// Module / Name 落在哪个功能点上。
|
|
60
|
+
Module string `json:"module,omitempty"`
|
|
61
|
+
Name string `json:"name,omitempty"`
|
|
62
|
+
// File 文档路径(绝对路径)。
|
|
63
|
+
File string `json:"file,omitempty"`
|
|
64
|
+
// Path 截图落盘后的绝对路径(正斜杠)。
|
|
65
|
+
Path string `json:"path,omitempty"`
|
|
66
|
+
// Shots 这个功能点现有截图数。
|
|
67
|
+
Shots int `json:"shots"`
|
|
68
|
+
// Error 失败原因(OK=false 时有值)。
|
|
69
|
+
Error string `json:"error,omitempty"`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// AttachShot 给一个功能点追加一张「页面完成情况」截图。
|
|
73
|
+
//
|
|
74
|
+
// where 可以是工作区 id(ws-xxxx)或目录路径;module 为空表示不限模块(按名字找)。
|
|
75
|
+
// name 是功能点名(= 文档名)。caption 是标注说明(可空)。
|
|
76
|
+
//
|
|
77
|
+
// 找不到对应文档时**报错**而不是新建 —— 截图是给已有需求点补证据,
|
|
78
|
+
// 凭空造一篇「同名空文档」只会污染清单。
|
|
79
|
+
func AttachShot(where, module, name, caption string, png []byte) (ShotResult, error) {
|
|
80
|
+
res := ShotResult{}
|
|
81
|
+
dir := strings.TrimSpace(where)
|
|
82
|
+
if dir == "" {
|
|
83
|
+
if cwd, err := os.Getwd(); err == nil {
|
|
84
|
+
dir = cwd
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ws, ok := resolveWorkspace(dir)
|
|
88
|
+
if !ok {
|
|
89
|
+
res.Error = "工作区不存在: " + dir
|
|
90
|
+
return res, fmt.Errorf("requirements: workspace not found: %s", dir)
|
|
91
|
+
}
|
|
92
|
+
if Root() == "" {
|
|
93
|
+
res.Error = "无法确定需求存储目录"
|
|
94
|
+
return res, fmt.Errorf("requirements: no storage root")
|
|
95
|
+
}
|
|
96
|
+
res.Workspace, res.Dir = ws.ID, ws.Dir
|
|
97
|
+
|
|
98
|
+
name = strings.TrimSpace(truncRunes(strings.TrimSpace(name), TitleMaxChars))
|
|
99
|
+
if name == "" {
|
|
100
|
+
res.Error = "功能点名不能为空"
|
|
101
|
+
return res, fmt.Errorf("requirements: empty feature name")
|
|
102
|
+
}
|
|
103
|
+
if len(png) == 0 {
|
|
104
|
+
res.Error = "截图内容为空"
|
|
105
|
+
return res, fmt.Errorf("requirements: empty png")
|
|
106
|
+
}
|
|
107
|
+
if len(png) > MaxShotBytes {
|
|
108
|
+
res.Error = fmt.Sprintf("截图过大(%d 字节,上限 %d)", len(png), MaxShotBytes)
|
|
109
|
+
return res, fmt.Errorf("requirements: png too large")
|
|
110
|
+
}
|
|
111
|
+
if !bytes.HasPrefix(png, pngMagic) {
|
|
112
|
+
res.Error = "不是 PNG 图片"
|
|
113
|
+
return res, fmt.Errorf("requirements: not a png")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 与归纳共用一把锁:两个进程同时写同一篇文档会把对方的段落冲掉。
|
|
117
|
+
release, busy := acquireLock(ws.ID)
|
|
118
|
+
if busy {
|
|
119
|
+
res.Error = "另一个进程正在整理这个空间,稍后再试"
|
|
120
|
+
return res, errBusy
|
|
121
|
+
}
|
|
122
|
+
defer release()
|
|
123
|
+
|
|
124
|
+
// 定位文档:经既有文件列表匹配,**不把用户输入拼进路径**(防目录穿越)。
|
|
125
|
+
merger := newDocMerger(ws.ID)
|
|
126
|
+
hit, found := merger.match(name)
|
|
127
|
+
if !found {
|
|
128
|
+
res.Error = "找不到这个功能点的文档:" + name
|
|
129
|
+
return res, fmt.Errorf("requirements: feature not found: %s", name)
|
|
130
|
+
}
|
|
131
|
+
mod := SlugifyModule(hit.Module)
|
|
132
|
+
if m := SlugifyModule(module); m != "" && m != mod {
|
|
133
|
+
res.Error = fmt.Sprintf("功能点「%s」属于模块「%s」,不是「%s」", name, mod, module)
|
|
134
|
+
return res, fmt.Errorf("requirements: module mismatch")
|
|
135
|
+
}
|
|
136
|
+
res.Module, res.Name, res.File = hit.Module, hit.Name, hit.File
|
|
137
|
+
|
|
138
|
+
// 落盘:<wsid>/shots/<模块>/<功能点slug>/<毫秒>.png
|
|
139
|
+
dirName := mod
|
|
140
|
+
if dirName == "" {
|
|
141
|
+
dirName = unfiledModule
|
|
142
|
+
}
|
|
143
|
+
shotDir := filepath.Join(wsDir(ws.ID), shotsDirName, dirName, SlugifyFeature(hit.Name))
|
|
144
|
+
if err := os.MkdirAll(shotDir, 0o700); err != nil {
|
|
145
|
+
res.Error = "创建截图目录失败:" + err.Error()
|
|
146
|
+
return res, err
|
|
147
|
+
}
|
|
148
|
+
now := time.Now()
|
|
149
|
+
// 文件名带毫秒 + 序号(同一毫秒内多张不至于撞名)。
|
|
150
|
+
base := strconv.FormatInt(now.UnixMilli(), 10)
|
|
151
|
+
full := filepath.Join(shotDir, base+".png")
|
|
152
|
+
for k := 2; fileExists(full); k++ {
|
|
153
|
+
full = filepath.Join(shotDir, base+"-"+strconv.Itoa(k)+".png")
|
|
154
|
+
}
|
|
155
|
+
if err := os.WriteFile(full, png, 0o600); err != nil {
|
|
156
|
+
res.Error = "写入截图失败:" + err.Error()
|
|
157
|
+
return res, err
|
|
158
|
+
}
|
|
159
|
+
// 文档里存**正斜杠**路径:Windows 的反斜杠在 markdown 里是转义符。
|
|
160
|
+
rel := filepath.ToSlash(full)
|
|
161
|
+
cap := strings.TrimSpace(caption)
|
|
162
|
+
|
|
163
|
+
// 追加到文档:文本级插入,其余字节不动。
|
|
164
|
+
raw, err := os.ReadFile(hit.File)
|
|
165
|
+
if err != nil {
|
|
166
|
+
res.Error = "读取文档失败:" + err.Error()
|
|
167
|
+
return res, err
|
|
168
|
+
}
|
|
169
|
+
out, count, err := insertShot(string(raw), rel, cap, now.UnixMilli())
|
|
170
|
+
if err != nil {
|
|
171
|
+
res.Error = err.Error()
|
|
172
|
+
return res, err
|
|
173
|
+
}
|
|
174
|
+
if err := writeFileAtomic(hit.File, []byte(out)); err != nil {
|
|
175
|
+
res.Error = "写文档失败:" + err.Error()
|
|
176
|
+
return res, err
|
|
177
|
+
}
|
|
178
|
+
res.Path = rel
|
|
179
|
+
res.Shots = count
|
|
180
|
+
res.OK = true
|
|
181
|
+
return res, nil
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// fileExists 判断文件是否存在。
|
|
185
|
+
func fileExists(p string) bool {
|
|
186
|
+
_, err := os.Stat(p)
|
|
187
|
+
return err == nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// insertShot 把一行截图插进「页面完成情况」段。
|
|
191
|
+
//
|
|
192
|
+
// 段不存在时新建:插在「## 来源对话」之前(缺来源段就插在「## 更新记录」之前,
|
|
193
|
+
// 再缺就追加到文件末尾)。已有段则追加到该段末尾。
|
|
194
|
+
//
|
|
195
|
+
// 返回:新文档全文、该功能点现有截图数。
|
|
196
|
+
func insertShot(text, path, caption string, at int64) (string, int, error) {
|
|
197
|
+
if strings.TrimSpace(path) == "" {
|
|
198
|
+
return "", 0, fmt.Errorf("requirements: empty shot path")
|
|
199
|
+
}
|
|
200
|
+
// 与归纳重渲染写出的形状一致(列表项 `- ![…]`),免得同一篇文档里两种写法混着。
|
|
201
|
+
line := "- " + shotLine(caption, path, at)
|
|
202
|
+
if line == "" {
|
|
203
|
+
return "", 0, fmt.Errorf("requirements: bad shot line")
|
|
204
|
+
}
|
|
205
|
+
lines := strings.Split(text, "\n")
|
|
206
|
+
// 定位「## 页面完成情况」段。
|
|
207
|
+
start := -1
|
|
208
|
+
for i, l := range lines {
|
|
209
|
+
if strings.TrimSpace(l) == "## 页面完成情况" {
|
|
210
|
+
start = i
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if start < 0 {
|
|
215
|
+
// 新建段:插在来源对话之前(没有就插在更新记录之前,再没有就追加到末尾)。
|
|
216
|
+
pos := len(lines)
|
|
217
|
+
for i, l := range lines {
|
|
218
|
+
t := strings.TrimSpace(l)
|
|
219
|
+
if t == "## 来源对话" || t == "## 更新记录" {
|
|
220
|
+
pos = i
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
block := []string{"## 页面完成情况", line, ""}
|
|
225
|
+
out := make([]string, 0, len(lines)+len(block))
|
|
226
|
+
out = append(out, lines[:pos]...)
|
|
227
|
+
out = append(out, block...)
|
|
228
|
+
out = append(out, lines[pos:]...)
|
|
229
|
+
return strings.Join(out, "\n"), countShots(out), nil
|
|
230
|
+
}
|
|
231
|
+
// 已有段:找段尾(下一个 ## 或文件末)。
|
|
232
|
+
end := len(lines)
|
|
233
|
+
for i := start + 1; i < len(lines); i++ {
|
|
234
|
+
if strings.HasPrefix(strings.TrimSpace(lines[i]), "## ") {
|
|
235
|
+
end = i
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 段内占位符「(暂无)」在有图后就不该留着(它会显示成一条假内容)。
|
|
240
|
+
seg := lines[start:end]
|
|
241
|
+
cleaned := make([]string, 0, len(seg))
|
|
242
|
+
for _, l := range seg {
|
|
243
|
+
if strings.TrimSpace(l) == "(暂无)" {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
cleaned = append(cleaned, l)
|
|
247
|
+
}
|
|
248
|
+
cleaned = append(cleaned, line)
|
|
249
|
+
cleaned = trimShotLines(cleaned)
|
|
250
|
+
cleaned = dedupeShotLines(cleaned)
|
|
251
|
+
// 段与下一段之间要留一个空行,否则 markdown 会把下一段的标题吃掉。
|
|
252
|
+
tail := lines[end:]
|
|
253
|
+
if len(cleaned) > 1 && strings.TrimSpace(cleaned[len(cleaned)-1]) != "" && len(tail) > 0 {
|
|
254
|
+
cleaned = append(cleaned, "")
|
|
255
|
+
}
|
|
256
|
+
out := make([]string, 0, len(lines)+2)
|
|
257
|
+
out = append(out, lines[:start]...)
|
|
258
|
+
out = append(out, cleaned...)
|
|
259
|
+
out = append(out, tail...)
|
|
260
|
+
return strings.Join(out, "\n"), countShots(out), nil
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// trimShotLines 把一段里的截图行裁到上限(超出的丢**最旧的**)。
|
|
264
|
+
//
|
|
265
|
+
// 丢最旧而不是最新:最新的截图是「最近一次改造的样子」,最该留。
|
|
266
|
+
func trimShotLines(seg []string) []string {
|
|
267
|
+
idx := []int{}
|
|
268
|
+
for i, l := range seg {
|
|
269
|
+
if shotPathRe.MatchString(strings.TrimSpace(l)) {
|
|
270
|
+
idx = append(idx, i)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if len(idx) <= MaxShots {
|
|
274
|
+
return seg
|
|
275
|
+
}
|
|
276
|
+
drop := map[int]bool{}
|
|
277
|
+
for _, i := range idx[:len(idx)-MaxShots] {
|
|
278
|
+
drop[i] = true
|
|
279
|
+
}
|
|
280
|
+
out := make([]string, 0, len(seg))
|
|
281
|
+
for i, l := range seg {
|
|
282
|
+
if drop[i] {
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
out = append(out, l)
|
|
286
|
+
}
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// dedupeShotLines 去掉段内**路径相同**的重复截图行。
|
|
291
|
+
//
|
|
292
|
+
// 同一张图被追加两次(客户端重试、或用户手动重放了一次归纳)时,
|
|
293
|
+
// 「页面完成情况」不该出现两行一样的图。
|
|
294
|
+
//
|
|
295
|
+
// 语义与 TrimShots 一致:**留最后一条的内容,位置不动** —— 图的先后顺序
|
|
296
|
+
// 是用户「改的次序」,不该被去重动作重排。
|
|
297
|
+
func dedupeShotLines(seg []string) []string {
|
|
298
|
+
pos := map[string]int{}
|
|
299
|
+
content := map[string]string{}
|
|
300
|
+
for i, l := range seg {
|
|
301
|
+
t := strings.TrimSpace(l)
|
|
302
|
+
if !shotPathRe.MatchString(t) {
|
|
303
|
+
continue
|
|
304
|
+
}
|
|
305
|
+
p := shotPathOf(t)
|
|
306
|
+
if p == "" {
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if _, ok := pos[p]; !ok {
|
|
310
|
+
pos[p] = i
|
|
311
|
+
}
|
|
312
|
+
content[p] = l
|
|
313
|
+
}
|
|
314
|
+
out := make([]string, 0, len(seg))
|
|
315
|
+
for i, l := range seg {
|
|
316
|
+
t := strings.TrimSpace(l)
|
|
317
|
+
if shotPathRe.MatchString(t) {
|
|
318
|
+
if p := shotPathOf(t); p != "" {
|
|
319
|
+
if pos[p] != i {
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
out = append(out, content[p])
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
out = append(out, l)
|
|
327
|
+
}
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// shotPathOf 从一行截图里取出路径(去掉 `<>` 包与时间戳注释)。
|
|
332
|
+
func shotPathOf(line string) string {
|
|
333
|
+
m := shotPathRe.FindStringSubmatch(strings.TrimSpace(line))
|
|
334
|
+
if m == nil {
|
|
335
|
+
return ""
|
|
336
|
+
}
|
|
337
|
+
return trimAngle(strings.TrimSpace(m[2]))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// countShots 数一篇文档里现有多少张截图。
|
|
341
|
+
func countShots(lines []string) int {
|
|
342
|
+
n := 0
|
|
343
|
+
in := false
|
|
344
|
+
for _, l := range lines {
|
|
345
|
+
t := strings.TrimSpace(l)
|
|
346
|
+
if strings.HasPrefix(t, "## ") {
|
|
347
|
+
in = t == "## 页面完成情况"
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
if in && shotPathRe.MatchString(t) {
|
|
351
|
+
n++
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return n
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// TrimShots 截断一个功能点的截图到上限(超出的丢最旧的),并**按 path 去重**。
|
|
358
|
+
//
|
|
359
|
+
// 只在归纳重写文档时用:RenderDoc 之前先把 Doc.Shots 裁到 MaxShots。
|
|
360
|
+
//
|
|
361
|
+
// 去重按 path:同一张图被写进文档两遍(比如手动重放了一次归纳)时不该在
|
|
362
|
+
// 「页面完成情况」里出现两次。**留最后一条的内容,但位置不动** —— 重排会把
|
|
363
|
+
// 文档里图的先后顺序打乱(用户是按「改的次序」攒图的,顺序本身有意义)。
|
|
364
|
+
func TrimShots(shots []Shot) []Shot {
|
|
365
|
+
if len(shots) == 0 {
|
|
366
|
+
return shots
|
|
367
|
+
}
|
|
368
|
+
// 记下每个 path 最后一次出现的内容与**首次**出现的位置。
|
|
369
|
+
pos := map[string]int{}
|
|
370
|
+
content := map[string]Shot{}
|
|
371
|
+
for i, s := range shots {
|
|
372
|
+
p := strings.TrimSpace(s.Path)
|
|
373
|
+
if p == "" {
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if _, ok := pos[p]; !ok {
|
|
377
|
+
pos[p] = i
|
|
378
|
+
}
|
|
379
|
+
content[p] = s
|
|
380
|
+
}
|
|
381
|
+
out := make([]Shot, 0, len(shots))
|
|
382
|
+
for i, s := range shots {
|
|
383
|
+
p := strings.TrimSpace(s.Path)
|
|
384
|
+
if p == "" {
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
if pos[p] != i {
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
out = append(out, content[p])
|
|
391
|
+
}
|
|
392
|
+
if len(out) <= MaxShots {
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
return out[len(out)-MaxShots:]
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// writeFileAtomic 原子写文件(tmp + rename):并发读不会读到半个文件。
|
|
399
|
+
func writeFileAtomic(path string, data []byte) error {
|
|
400
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
401
|
+
return err
|
|
402
|
+
}
|
|
403
|
+
tmp := path + ".tmp"
|
|
404
|
+
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
405
|
+
return err
|
|
406
|
+
}
|
|
407
|
+
if err := os.Rename(tmp, path); err != nil {
|
|
408
|
+
_ = os.Remove(tmp)
|
|
409
|
+
return err
|
|
410
|
+
}
|
|
411
|
+
return nil
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// shotJSON 便于测试与调试的紧凑表示。
|
|
415
|
+
func shotJSON(s Shot) string {
|
|
416
|
+
b, _ := json.Marshal(s)
|
|
417
|
+
return string(b)
|
|
418
|
+
}
|