magic-agent 2.4.2 → 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 +290 -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 +7 -10
- 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 +193 -7
- package/internal/client/lan_test.go +110 -0
- 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/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -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
|
@@ -16,13 +16,18 @@ package requirements
|
|
|
16
16
|
import (
|
|
17
17
|
"os"
|
|
18
18
|
"path/filepath"
|
|
19
|
+
"regexp"
|
|
19
20
|
"sort"
|
|
20
21
|
"strings"
|
|
22
|
+
"time"
|
|
21
23
|
"unicode"
|
|
22
24
|
)
|
|
23
25
|
|
|
24
|
-
// docSections
|
|
25
|
-
|
|
26
|
+
// docSections 骨架的段落名(顺序即 renderDoc 的写盘顺序)。
|
|
27
|
+
//
|
|
28
|
+
// 「页面完成情况」是第七段(2026-10-25 用户:「需求文档整理时如果设计页面改造,
|
|
29
|
+
// 调用内置浏览器 MCP 对完成情况截图标注并放在文档中」):记页面地址 + 改造后的截图。
|
|
30
|
+
var docSections = []string{"最终版", "需求描述", "需求点", "已定结论", "页面完成情况", "来源对话", "更新记录"}
|
|
26
31
|
|
|
27
32
|
// Doc 一篇功能点文档的解析结果。
|
|
28
33
|
type Doc struct {
|
|
@@ -34,6 +39,20 @@ type Doc struct {
|
|
|
34
39
|
Conclusions []string
|
|
35
40
|
Sources []string
|
|
36
41
|
Log []string
|
|
42
|
+
// PageURL 该功能点对应的页面地址(归纳时从对话里提取,可空)。
|
|
43
|
+
PageURL string
|
|
44
|
+
// Shots 页面改造的完成情况截图(按时间正序)。
|
|
45
|
+
Shots []Shot
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Shot 一张「页面完成情况」截图。
|
|
49
|
+
type Shot struct {
|
|
50
|
+
// Path 截图在本机的绝对路径(**正斜杠**,跨平台一致)。
|
|
51
|
+
Path string
|
|
52
|
+
// Caption 标注说明(用户写的一句话,可空)。
|
|
53
|
+
Caption string
|
|
54
|
+
// At 截图时刻(unix 毫秒)。
|
|
55
|
+
At int64
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
// isPlaceholder 占位符判定:整条就等于占位符的一律丢弃。
|
|
@@ -90,7 +109,7 @@ func ParseDoc(text string) Doc {
|
|
|
90
109
|
if isPlaceholder(final) {
|
|
91
110
|
final = ""
|
|
92
111
|
}
|
|
93
|
-
|
|
112
|
+
d := Doc{
|
|
94
113
|
Final: final,
|
|
95
114
|
Desc: strings.TrimSpace(strings.Join(buf["需求描述"], " ")),
|
|
96
115
|
Points: lines("需求点"),
|
|
@@ -98,6 +117,127 @@ func ParseDoc(text string) Doc {
|
|
|
98
117
|
Sources: lines("来源对话"),
|
|
99
118
|
Log: lines("更新记录"),
|
|
100
119
|
}
|
|
120
|
+
d.PageURL, d.Shots = parseShots(buf["页面完成情况"])
|
|
121
|
+
return d
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// shotPathRe 截图行:``(路径是本机绝对路径,正斜杠),
|
|
125
|
+
// 末尾可跟一个时间戳注释 `<!-- 2026-10-25T14:30:00Z -->`。
|
|
126
|
+
//
|
|
127
|
+
// 说明里不许出现 `]`(写入时会被清掉),路径用 `<>` 包时能容纳空格与括号
|
|
128
|
+
// (Windows 下 `C:/Users/x/a b (v2)/x.png` 正是这种形状)。
|
|
129
|
+
//
|
|
130
|
+
// 前缀的列表符号 `- ` 是**可选**的:归纳重渲染写的是列表项(`- ![…]`),
|
|
131
|
+
// 文本级插入写的是裸行 —— 两种形状都要能数出、认得出。
|
|
132
|
+
var shotPathRe = regexp.MustCompile(`^(?:[-*+]\s+)?!\[([^\]]*)\]\((.*)\)(?:\s*<!--\s*([^>]*?)\s*-->)?\s*$`)
|
|
133
|
+
|
|
134
|
+
// shotTimeRe 截图时间戳注释里的时间(RFC3339)。
|
|
135
|
+
var shotTimeRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?`)
|
|
136
|
+
|
|
137
|
+
// pageURLPrefix 页面地址行前缀。
|
|
138
|
+
const pageURLPrefix = "页面地址:"
|
|
139
|
+
|
|
140
|
+
// parseShots 解析「页面完成情况」段:页面地址 + 截图列表。
|
|
141
|
+
//
|
|
142
|
+
// 形状(代码写、模型不参与 —— 截图是事实,不能让模型编):
|
|
143
|
+
//
|
|
144
|
+
// 页面地址:https://example.com/a
|
|
145
|
+
//  <!-- 2026-10-25T14:30:00Z -->
|
|
146
|
+
func parseShots(raw []string) (string, []Shot) {
|
|
147
|
+
url := ""
|
|
148
|
+
shots := []Shot{}
|
|
149
|
+
for _, line := range raw {
|
|
150
|
+
l := strings.TrimSpace(stripListMarker(strings.TrimSpace(line)))
|
|
151
|
+
if l == "" {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if strings.HasPrefix(l, pageURLPrefix) {
|
|
155
|
+
v := strings.TrimSpace(strings.TrimPrefix(l, pageURLPrefix))
|
|
156
|
+
if v != "" && !isPlaceholder(v) {
|
|
157
|
+
url = v
|
|
158
|
+
}
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
m := shotPathRe.FindStringSubmatch(l)
|
|
162
|
+
if m == nil {
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
s := Shot{Path: trimAngle(strings.TrimSpace(m[2])), Caption: strings.TrimSpace(m[1])}
|
|
166
|
+
if m[3] != "" {
|
|
167
|
+
if tm := shotTimeRe.FindString(m[3]); tm != "" {
|
|
168
|
+
if t, err := time.Parse(time.RFC3339, normalizeShotTime(tm)); err == nil {
|
|
169
|
+
s.At = t.UnixMilli()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
shots = append(shots, s)
|
|
174
|
+
}
|
|
175
|
+
return url, shots
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// normalizeShotTime 补齐 RFC3339 需要的时区。
|
|
179
|
+
//
|
|
180
|
+
// 写盘时用 UTC 的 RFC3339(结尾带 Z),但**历史数据**或手改过的文档可能缺时区 ——
|
|
181
|
+
// 那种按 UTC 补 Z。带偏移(+08:00)的原样保留。
|
|
182
|
+
func normalizeShotTime(s string) string {
|
|
183
|
+
v := strings.TrimSpace(s)
|
|
184
|
+
if v == "" {
|
|
185
|
+
return v
|
|
186
|
+
}
|
|
187
|
+
if strings.HasSuffix(v, "Z") || strings.HasSuffix(v, "z") {
|
|
188
|
+
return v
|
|
189
|
+
}
|
|
190
|
+
// 结尾是 ±HH:MM 偏移 → 已经是合法 RFC3339。
|
|
191
|
+
if len(v) >= 6 {
|
|
192
|
+
tail := v[len(v)-6:]
|
|
193
|
+
if (tail[0] == '+' || tail[0] == '-') && tail[3] == ':' {
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return v + "Z"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// trimAngle 去掉路径外的 `<>`(markdown 的转义写法)。
|
|
201
|
+
func trimAngle(p string) string {
|
|
202
|
+
if len(p) >= 2 && strings.HasPrefix(p, "<") && strings.HasSuffix(p, ">") {
|
|
203
|
+
return strings.TrimSpace(p[1 : len(p)-1])
|
|
204
|
+
}
|
|
205
|
+
return p
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// shotLine 渲染一行截图:说明 + 路径(+ 时间戳注释)。
|
|
209
|
+
//
|
|
210
|
+
// 路径含空格 / 括号时用 `<>` 包(markdown 的标准转义),否则解析回来会断。
|
|
211
|
+
// 时间戳写成 HTML 注释:markdown 渲染时**看不见**,但解析能取回来
|
|
212
|
+
// (清单上要显示「最后截图时间」,注释里不占视觉位置)。
|
|
213
|
+
func shotLine(caption, path string, at int64) string {
|
|
214
|
+
c := sanitizeCaption(caption)
|
|
215
|
+
p := strings.TrimSpace(path)
|
|
216
|
+
if p == "" {
|
|
217
|
+
return ""
|
|
218
|
+
}
|
|
219
|
+
if strings.ContainsAny(p, " ()") {
|
|
220
|
+
p = "<" + p + ">"
|
|
221
|
+
}
|
|
222
|
+
var b strings.Builder
|
|
223
|
+
b.WriteString("
|
|
226
|
+
b.WriteString(p)
|
|
227
|
+
b.WriteString(")")
|
|
228
|
+
if at > 0 {
|
|
229
|
+
b.WriteString(" <!-- " + time.UnixMilli(at).UTC().Format(time.RFC3339) + " -->")
|
|
230
|
+
}
|
|
231
|
+
return b.String()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// sanitizeCaption 清掉会破坏 markdown 结构的字符(`]` 会截断说明、换行会断行)。
|
|
235
|
+
func sanitizeCaption(c string) string {
|
|
236
|
+
s := strings.TrimSpace(c)
|
|
237
|
+
s = strings.ReplaceAll(s, "]", ")")
|
|
238
|
+
s = strings.ReplaceAll(s, "\r", " ")
|
|
239
|
+
s = strings.ReplaceAll(s, "\n", " ")
|
|
240
|
+
return strings.TrimSpace(s)
|
|
101
241
|
}
|
|
102
242
|
|
|
103
243
|
// stripListMarker 去掉行首的列表标记(`1. ` / `- ` / `* ` / `1、`)。
|
|
@@ -169,6 +309,17 @@ func RenderDoc(name string, d Doc) string {
|
|
|
169
309
|
b.WriteString("- " + c + "\n")
|
|
170
310
|
}
|
|
171
311
|
}
|
|
312
|
+
b.WriteString("\n## 页面完成情况\n")
|
|
313
|
+
if strings.TrimSpace(d.PageURL) == "" && len(d.Shots) == 0 {
|
|
314
|
+
b.WriteString("(暂无)\n")
|
|
315
|
+
} else {
|
|
316
|
+
if u := strings.TrimSpace(d.PageURL); u != "" {
|
|
317
|
+
b.WriteString(pageURLPrefix + u + "\n")
|
|
318
|
+
}
|
|
319
|
+
for _, s := range d.Shots {
|
|
320
|
+
b.WriteString("- " + shotLine(s.Caption, s.Path, s.At) + "\n")
|
|
321
|
+
}
|
|
322
|
+
}
|
|
172
323
|
b.WriteString("\n## 来源对话\n")
|
|
173
324
|
if len(S) == 0 {
|
|
174
325
|
b.WriteString("(暂无)\n")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
"github.com/darren/magic-agent/internal/session"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// 会话↔需求关系图谱:把 relations.jsonl 聚合成 nodes/edges 给前端画图。
|
|
12
|
+
// feature 清单以文档目录**现算**为准(模块合并/改名后 relations 里可能残留
|
|
13
|
+
// 旧名 —— 查不到的功能点连同边一起丢弃,不报错),relations 只供会话侧。
|
|
14
|
+
|
|
15
|
+
type graphNode struct {
|
|
16
|
+
ID string `json:"id"`
|
|
17
|
+
Type string `json:"type"` // "session" | "feature"
|
|
18
|
+
Title string `json:"title"`
|
|
19
|
+
Module string `json:"module,omitempty"`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type graphEdge struct {
|
|
23
|
+
Source string `json:"source"`
|
|
24
|
+
Target string `json:"target"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type graphWorkspace struct {
|
|
28
|
+
ID string `json:"id"`
|
|
29
|
+
Name string `json:"name"`
|
|
30
|
+
Dir string `json:"dir,omitempty"`
|
|
31
|
+
Nodes []graphNode `json:"nodes"`
|
|
32
|
+
Edges []graphEdge `json:"edges"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type graphOut struct {
|
|
36
|
+
OK bool `json:"ok"`
|
|
37
|
+
Root string `json:"root"`
|
|
38
|
+
Workspaces []graphWorkspace `json:"workspaces"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// BuildGraph 聚合一个工作区(ws id / 目录 / ""=all)的关系图谱。
|
|
42
|
+
func BuildGraph(where string) ([]byte, error) {
|
|
43
|
+
out := graphOut{OK: true, Root: Root()}
|
|
44
|
+
wsids := []workspaceEntry{}
|
|
45
|
+
if where == "" || where == "all" {
|
|
46
|
+
ws, err := Workspaces()
|
|
47
|
+
if err != nil {
|
|
48
|
+
return nil, err
|
|
49
|
+
}
|
|
50
|
+
for _, w := range ws {
|
|
51
|
+
wsids = append(wsids, workspaceEntry{ID: w.ID, Dir: w.Dir, Name: w.Name})
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
ws, ok := resolveWorkspace(where)
|
|
55
|
+
if !ok {
|
|
56
|
+
return nil, fmt.Errorf("requirements: workspace not found: %s", where)
|
|
57
|
+
}
|
|
58
|
+
wsids = append(wsids, ws)
|
|
59
|
+
}
|
|
60
|
+
for _, ws := range wsids {
|
|
61
|
+
out.Workspaces = append(out.Workspaces, buildGraphWS(ws))
|
|
62
|
+
}
|
|
63
|
+
if out.Workspaces == nil {
|
|
64
|
+
out.Workspaces = []graphWorkspace{}
|
|
65
|
+
}
|
|
66
|
+
b, err := json.Marshal(out)
|
|
67
|
+
if err != nil {
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
return b, nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func buildGraphWS(ws workspaceEntry) graphWorkspace {
|
|
74
|
+
g := graphWorkspace{ID: ws.ID, Name: ws.Name, Dir: ws.Dir, Nodes: []graphNode{}, Edges: []graphEdge{}}
|
|
75
|
+
if g.Name == "" {
|
|
76
|
+
g.Name = ws.ID
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
docs := featureDocsOf(ws.ID)
|
|
80
|
+
featOK := make(map[string]graphNode, len(docs))
|
|
81
|
+
for _, d := range docs {
|
|
82
|
+
if d.Name == "" {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
n := graphNode{ID: "f:" + d.Module + "/" + d.Name, Type: "feature", Title: d.Name, Module: d.Module}
|
|
86
|
+
featOK[n.ID] = n
|
|
87
|
+
g.Nodes = append(g.Nodes, n)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
rels := readRelations(ws.ID)
|
|
91
|
+
seenEdge := make(map[string]bool, len(rels))
|
|
92
|
+
sessTitle := make(map[string]string)
|
|
93
|
+
for _, r := range rels {
|
|
94
|
+
if r.Session == "" || r.Feature == "" {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
fid := "f:" + r.Module + "/" + r.Feature
|
|
98
|
+
if _, ok := featOK[fid]; !ok {
|
|
99
|
+
continue // 功能点已被合并/改名:关系随文档走,旧名丢弃
|
|
100
|
+
}
|
|
101
|
+
sid := "s:" + r.Session
|
|
102
|
+
if _, ok := sessTitle[r.Session]; !ok {
|
|
103
|
+
sessTitle[r.Session] = r.STitle
|
|
104
|
+
}
|
|
105
|
+
ek := r.Session + "\x00" + fid
|
|
106
|
+
if !seenEdge[ek] {
|
|
107
|
+
seenEdge[ek] = true
|
|
108
|
+
g.Edges = append(g.Edges, graphEdge{Source: sid, Target: fid})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 会话节点放最后(标题:关系快照 → 登记表兜底)。
|
|
113
|
+
for s, t := range sessTitle {
|
|
114
|
+
if t == "" {
|
|
115
|
+
t = sessionTitleFallback(s)
|
|
116
|
+
}
|
|
117
|
+
g.Nodes = append(g.Nodes, graphNode{ID: "s:" + s, Type: "session", Title: t})
|
|
118
|
+
}
|
|
119
|
+
return g
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// sessionTitleFallback 关系快照没存标题时从登记表/标题表兜底。
|
|
123
|
+
func sessionTitleFallback(sessionID string) string {
|
|
124
|
+
if recs, err := session.List(); err == nil {
|
|
125
|
+
for _, r := range recs {
|
|
126
|
+
if r.SessionID == sessionID || r.RunID == sessionID {
|
|
127
|
+
if t := session.TitleForAny(r.SessionID, r.RunID); t != "" {
|
|
128
|
+
return t
|
|
129
|
+
}
|
|
130
|
+
return strings.TrimSpace(r.PromptHead)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if t := session.TitleForAny(sessionID, ""); t != "" {
|
|
135
|
+
return t
|
|
136
|
+
}
|
|
137
|
+
return sessionID
|
|
138
|
+
}
|
|
@@ -302,6 +302,13 @@ func featureSummary(f featureFile) Feature {
|
|
|
302
302
|
item.Conclusions = len(d.Conclusions)
|
|
303
303
|
item.Sources = len(d.Sources)
|
|
304
304
|
item.HasFinal = strings.TrimSpace(d.Final) != ""
|
|
305
|
+
item.PageURL = d.PageURL
|
|
306
|
+
item.Shots = len(d.Shots)
|
|
307
|
+
for _, s := range d.Shots {
|
|
308
|
+
if s.At > item.ShotAt {
|
|
309
|
+
item.ShotAt = s.At
|
|
310
|
+
}
|
|
311
|
+
}
|
|
305
312
|
if len(d.Conclusions) > 0 {
|
|
306
313
|
item.Conclusion = d.Conclusions[0]
|
|
307
314
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// modules.go - 模块去重:把「表达同一件事」的已有模块合并成一个。
|
|
4
|
+
//
|
|
5
|
+
// 为什么归纳需要它(2026-10-25 用户报障,配图:magic-agent 空间 12 个模块里
|
|
6
|
+
// 躺着「引擎管理 / 引擎调用」「配置管理 / 模型配置」「会话管理 / 对话交互」
|
|
7
|
+
// 这种近义模块):
|
|
8
|
+
//
|
|
9
|
+
// · 现有的同义二审(consolidate.go 的 resolveModuleAlias)只挡**本轮新出现**的
|
|
10
|
+
// 模块名 vs 已有模块 —— 跨轮堆积下来的相似模块(这轮新的、下轮就成了「已有」)
|
|
11
|
+
// 从来没人互相比,用户在清单里看到的就是十几个近义一级分组;
|
|
12
|
+
// · 首次目录初始化(bootstrap.go)写文档时 alias 恒为空,模型一次输出 5-20 个
|
|
13
|
+
// 功能点,想造几个近义模块就造几个。
|
|
14
|
+
//
|
|
15
|
+
// 三条口径(与归纳 prompt 的规则 2 同一套判据,别在这里发明新说法):
|
|
16
|
+
//
|
|
17
|
+
// ① 合并是**语义判断**,交给模型;代码只做确定性候选(名字共享字/包含)当
|
|
18
|
+
// 「要不要问这次模型」的成本闸门 —— 自动路径每轮都可能跑,不能每轮都烧一次;
|
|
19
|
+
// ② 方向归一:并入**功能点更多**的那个模块(更主要的那个),数量持平听模型的;
|
|
20
|
+
// ③ 内容不丢:无同名文档整体搬走 + 补一行「模块合并」记录;同名文档合并
|
|
21
|
+
// (要点/结论保头去重、来源/流水保尾去重),不生成第二篇。
|
|
22
|
+
//
|
|
23
|
+
// 失败一律「不合并」(模型不可用 / 输出不可解析 / 幻觉模块名)—— 去重是质量增强,
|
|
24
|
+
// 不该让归纳整轮失败,更不该把用户的文档搬丢。
|
|
25
|
+
|
|
26
|
+
import (
|
|
27
|
+
"encoding/json"
|
|
28
|
+
"os"
|
|
29
|
+
"path/filepath"
|
|
30
|
+
"sort"
|
|
31
|
+
"strings"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// moduleMergeRules 模块合并的判据(与归纳规则 2 同一口径;prompt 与二审共用)。
|
|
35
|
+
const moduleMergeRules = "「任务列表」与「对话列表」都指\"列表\"这件事 → 同一件事,应合并;" +
|
|
36
|
+
"「对话交互」指交互行为、「对话列表」指列表展示 → 不是同一件事,不许合并;" +
|
|
37
|
+
"同一功能域才算同一件事(如「引擎管理」与「引擎调用」同属引擎这一个功能域);" +
|
|
38
|
+
"名字像但功能域不同(如「内容创作」与「团队协作」)不许合并。"
|
|
39
|
+
|
|
40
|
+
// similarModulePairs 确定性候选:两个模块名共享任一字,或互为子串。
|
|
41
|
+
//
|
|
42
|
+
// 它**不做合并判断**(那是模型的语义活),只回答「值不值得为这批模块名跑一次模型」。
|
|
43
|
+
// 中文产品模块名(≤6 字)里真正的同义项几乎都共享字符或互为子串;一个字都不搭的
|
|
44
|
+
// (「远程接入」vs「局域网访问」这种)理论漏判,换取自动路径不为每个工作区每轮烧调用。
|
|
45
|
+
func similarModulePairs(mods []string) [][2]string {
|
|
46
|
+
sets := make([]map[rune]bool, len(mods))
|
|
47
|
+
for i, m := range mods {
|
|
48
|
+
sets[i] = map[rune]bool{}
|
|
49
|
+
for _, r := range m {
|
|
50
|
+
sets[i][r] = true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
out := [][2]string{}
|
|
54
|
+
for i := 0; i < len(mods); i++ {
|
|
55
|
+
for j := i + 1; j < len(mods); j++ {
|
|
56
|
+
if strings.Contains(mods[i], mods[j]) || strings.Contains(mods[j], mods[i]) {
|
|
57
|
+
out = append(out, [2]string{mods[i], mods[j]})
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
for r := range sets[i] {
|
|
61
|
+
if sets[j][r] {
|
|
62
|
+
out = append(out, [2]string{mods[i], mods[j]})
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// moduleFeatureCounts 每个模块下的功能点文档数(挑合并目标用:更主要的那个)。
|
|
72
|
+
func moduleFeatureCounts(id string) map[string]int {
|
|
73
|
+
out := map[string]int{}
|
|
74
|
+
for _, f := range listFeatureFiles(id) {
|
|
75
|
+
if f.Module != "" {
|
|
76
|
+
out[f.Module]++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// resolveModuleMerge 问模型:哪些已有模块表达同一件事,需要合并。
|
|
83
|
+
//
|
|
84
|
+
// 输出契约复用 aliasSchema({pairs:[{from,to}]},from=被合并、to=并入)。
|
|
85
|
+
// 校验:from / to 必须原样取自真实模块名(挡幻觉);任何失败返回空表。
|
|
86
|
+
func resolveModuleMerge(call llmCall, id string) [][2]string {
|
|
87
|
+
mods := []string{}
|
|
88
|
+
names := map[string][]string{}
|
|
89
|
+
for _, f := range listFeatureFiles(id) {
|
|
90
|
+
if f.Module == "" {
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if _, ok := names[f.Module]; !ok {
|
|
94
|
+
mods = append(mods, f.Module)
|
|
95
|
+
}
|
|
96
|
+
names[f.Module] = append(names[f.Module], f.Name)
|
|
97
|
+
}
|
|
98
|
+
if len(mods) < 2 {
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
var b strings.Builder
|
|
102
|
+
b.WriteString("你是资深产品经理。下面是同一个产品工作区的【已有模块】(一级功能点分组)。\n")
|
|
103
|
+
b.WriteString("请找出哪些模块其实表达**同一件事**(语义相同,不是字面相近),需要合并成一个。\n\n")
|
|
104
|
+
b.WriteString("== 已有模块 ==\n")
|
|
105
|
+
for _, m := range mods {
|
|
106
|
+
b.WriteString("· " + m + "(" + itoa(len(names[m])) + " 个功能点:" + strings.Join(names[m], "、") + ")\n")
|
|
107
|
+
}
|
|
108
|
+
b.WriteString("\n判据(与归纳规则同一口径):\n· " + moduleMergeRules + "\n")
|
|
109
|
+
b.WriteString("没有要合并的就输出空数组 —— **宁可漏,不可错并**。\n")
|
|
110
|
+
b.WriteString("规则:from / to 都必须**原样**取自上面模块名。\n")
|
|
111
|
+
b.WriteString(`只输出 JSON:{"pairs":[{"from":"被合并模块","to":"并入模块"}]}`)
|
|
112
|
+
|
|
113
|
+
text, err := call(b.String(), aliasSchema)
|
|
114
|
+
if err != nil {
|
|
115
|
+
return nil
|
|
116
|
+
}
|
|
117
|
+
obj, ok := firstJSONObject(text)
|
|
118
|
+
if !ok {
|
|
119
|
+
return nil
|
|
120
|
+
}
|
|
121
|
+
var pairs struct {
|
|
122
|
+
Pairs []struct {
|
|
123
|
+
From string `json:"from"`
|
|
124
|
+
To string `json:"to"`
|
|
125
|
+
} `json:"pairs"`
|
|
126
|
+
}
|
|
127
|
+
if json.Unmarshal([]byte(obj), &pairs) != nil {
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
valid := map[string]bool{}
|
|
131
|
+
for _, m := range mods {
|
|
132
|
+
valid[m] = true
|
|
133
|
+
}
|
|
134
|
+
out := [][2]string{}
|
|
135
|
+
for _, p := range pairs.Pairs {
|
|
136
|
+
from, to := strings.TrimSpace(p.From), strings.TrimSpace(p.To)
|
|
137
|
+
if from == "" || to == "" || from == to || !valid[from] || !valid[to] {
|
|
138
|
+
continue // 幻觉模块名一律不认
|
|
139
|
+
}
|
|
140
|
+
out = append(out, [2]string{from, to})
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// dedupeModules 找出「表达同一件事」的已有模块并合并。返回合并清单("from → to")。
|
|
146
|
+
//
|
|
147
|
+
// 流程:问模型 → 方向归一(功能点多的幸存,持平听模型的)→ 解链(A→B、B→C 都归 C,
|
|
148
|
+
// visited 防环)→ 按幸存者分组搬目录。任何一步拿不到结果就原样返回。
|
|
149
|
+
func dedupeModules(id string, call llmCall) []string {
|
|
150
|
+
pairs := resolveModuleMerge(call, id)
|
|
151
|
+
if len(pairs) == 0 {
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
counts := moduleFeatureCounts(id)
|
|
155
|
+
// target[drop] = keep:drop 被合并掉、keep 幸存。
|
|
156
|
+
target := map[string]string{}
|
|
157
|
+
for _, p := range pairs {
|
|
158
|
+
drop, keep := p[0], p[1]
|
|
159
|
+
if counts[drop] > counts[keep] {
|
|
160
|
+
drop, keep = keep, drop // 功能点更多的幸存(更主要的那个);持平听模型的
|
|
161
|
+
}
|
|
162
|
+
target[drop] = keep
|
|
163
|
+
}
|
|
164
|
+
// 解链:沿着 target 走到不再被合并的那个模块为止。
|
|
165
|
+
root := map[string]string{}
|
|
166
|
+
for d := range target {
|
|
167
|
+
seen := map[string]bool{}
|
|
168
|
+
cur := d
|
|
169
|
+
for {
|
|
170
|
+
if seen[cur] {
|
|
171
|
+
break // 环(方向归一后理论上不会出现):就地停下,不合并这一组
|
|
172
|
+
}
|
|
173
|
+
seen[cur] = true
|
|
174
|
+
next, ok := target[cur]
|
|
175
|
+
if !ok {
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
cur = next
|
|
179
|
+
}
|
|
180
|
+
root[d] = cur
|
|
181
|
+
}
|
|
182
|
+
groups := map[string][]string{}
|
|
183
|
+
for d, r := range root {
|
|
184
|
+
if d == r {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
groups[r] = append(groups[r], d)
|
|
188
|
+
}
|
|
189
|
+
roots := make([]string, 0, len(groups))
|
|
190
|
+
for r := range groups {
|
|
191
|
+
roots = append(roots, r)
|
|
192
|
+
}
|
|
193
|
+
sort.Strings(roots)
|
|
194
|
+
out := []string{}
|
|
195
|
+
for _, r := range roots {
|
|
196
|
+
drops := groups[r]
|
|
197
|
+
sort.Strings(drops)
|
|
198
|
+
for _, d := range drops {
|
|
199
|
+
if err := mergeModuleInto(id, d, r); err == nil {
|
|
200
|
+
out = append(out, d+" → "+r)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// mergeModuleInto 把模块 from 的全部功能点文档并进 to(内容不丢)。
|
|
208
|
+
func mergeModuleInto(id, from, to string) error {
|
|
209
|
+
base := featuresDir(id)
|
|
210
|
+
srcDir := filepath.Join(base, from)
|
|
211
|
+
dstDir := filepath.Join(base, to)
|
|
212
|
+
ents, err := os.ReadDir(srcDir)
|
|
213
|
+
if err != nil {
|
|
214
|
+
return err // from 不存在 = 没东西可搬
|
|
215
|
+
}
|
|
216
|
+
if err := os.MkdirAll(dstDir, 0o700); err != nil {
|
|
217
|
+
return err
|
|
218
|
+
}
|
|
219
|
+
for _, e := range ents {
|
|
220
|
+
if e.IsDir() {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
n := e.Name()
|
|
224
|
+
if strings.HasPrefix(n, ".") || !strings.HasSuffix(n, ".md") {
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
src := filepath.Join(srcDir, n)
|
|
228
|
+
dst := filepath.Join(dstDir, n)
|
|
229
|
+
if _, err := os.Stat(dst); err == nil {
|
|
230
|
+
// 同名文档:两个模块下有同一个功能点 → 合并内容,不生成第二篇。
|
|
231
|
+
if err := mergeDocFile(src, dst, from, to); err != nil {
|
|
232
|
+
return err
|
|
233
|
+
}
|
|
234
|
+
_ = os.Remove(src)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if err := os.Rename(src, dst); err != nil {
|
|
238
|
+
return err
|
|
239
|
+
}
|
|
240
|
+
// 搬走的文档补一行合并记录(可追溯:这篇原来属于哪个模块)。
|
|
241
|
+
noteModuleMerge(dst, from, to)
|
|
242
|
+
}
|
|
243
|
+
_ = os.Remove(srcDir) // 搬空了撤目录;有残留(非 .md)也不阻断(清单只收 .md)
|
|
244
|
+
// 关系流的 module 归属同步改写(relations.jsonl 记的是模块名,不改会悬空)。
|
|
245
|
+
_ = rewriteRelationsModule(id, from, to)
|
|
246
|
+
return nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// mergeDocFile 两个模块下的同名文档 → 合并成一篇。
|
|
250
|
+
//
|
|
251
|
+
// 以目标文档为基座:desc 取目标已有(没有才用来源的);要点/结论目标在前来源在后
|
|
252
|
+
// (保头去重,清单稳定);来源/流水保留最新(保尾去重),合并记录排在最后。
|
|
253
|
+
func mergeDocFile(src, dst, from, to string) error {
|
|
254
|
+
sb, err := os.ReadFile(src)
|
|
255
|
+
if err != nil {
|
|
256
|
+
return err
|
|
257
|
+
}
|
|
258
|
+
db, err := os.ReadFile(dst)
|
|
259
|
+
if err != nil {
|
|
260
|
+
return err
|
|
261
|
+
}
|
|
262
|
+
a, b := ParseDoc(string(sb)), ParseDoc(string(db))
|
|
263
|
+
desc := b.Desc
|
|
264
|
+
if desc == "" {
|
|
265
|
+
desc = a.Desc
|
|
266
|
+
}
|
|
267
|
+
concs := mergeUnique(b.Conclusions, a.Conclusions, MaxConclusions, true, nil)
|
|
268
|
+
line := todayStr() + " 模块合并:从「" + from + "」并入「" + to + "」"
|
|
269
|
+
hist := append(append([]string{}, b.Log...), a.Log...)
|
|
270
|
+
doc := Doc{
|
|
271
|
+
Final: renderFinal(desc, concs),
|
|
272
|
+
Desc: desc,
|
|
273
|
+
Points: mergeUnique(b.Points, a.Points, MaxPoints, true, nil),
|
|
274
|
+
Conclusions: concs,
|
|
275
|
+
Sources: mergeUnique(b.Sources, a.Sources, MaxSources, false, sourceKey),
|
|
276
|
+
Log: mergeUnique(hist, []string{line}, MaxLogLines, false, nil),
|
|
277
|
+
}
|
|
278
|
+
name := strings.TrimSuffix(filepath.Base(dst), ".md")
|
|
279
|
+
return os.WriteFile(dst, []byte(RenderDoc(name, doc)), 0o600)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// noteModuleMerge 给整体搬过来的文档补一行「模块合并」记录(尽力而为,失败不阻断)。
|
|
283
|
+
func noteModuleMerge(file, from, to string) {
|
|
284
|
+
b, err := os.ReadFile(file)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
d := ParseDoc(string(b))
|
|
289
|
+
line := todayStr() + " 模块合并:从「" + from + "」并入「" + to + "」"
|
|
290
|
+
d.Log = mergeUnique(d.Log, []string{line}, MaxLogLines, false, nil)
|
|
291
|
+
_ = os.WriteFile(file, []byte(RenderDoc(strings.TrimSuffix(filepath.Base(file), ".md"), d)), 0o600)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// moduleDedupeWanted 这一轮要不要顺手做模块去重。
|
|
295
|
+
//
|
|
296
|
+
// · 模块级整理(opts.Module 非空)永远不跑 —— 用户点哪个模块就只动那个,
|
|
297
|
+
// 「只整理一个模块」不能顺手把模块结构改了(2026-10-23 报障的同一条纪律);
|
|
298
|
+
// · 名字毫无交集(无确定性候选)不跑 —— 不为「看起来很干净」的清单烧一次模型;
|
|
299
|
+
// · 自动路径(插件轮末防抖,每轮都可能跑)额外要求本轮真写了新文档;
|
|
300
|
+
// · 手动(含 force / all / 无新记忆的 noop):用户点了「整理」就认真查一次。
|
|
301
|
+
func moduleDedupeWanted(opts Options, res Result, mods []string) bool {
|
|
302
|
+
if len(mods) < 2 || opts.Module != "" {
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
if len(similarModulePairs(mods)) == 0 {
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
if opts.Auto {
|
|
309
|
+
return res.Created > 0
|
|
310
|
+
}
|
|
311
|
+
return true
|
|
312
|
+
}
|