magic-agent 2.1.5 → 2.3.2
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 +147 -11
- package/README.md +10 -4
- package/client-ui/pc/app.css +17 -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 +24 -2
- package/client-ui/shared/conv/03-chat-message.js +63 -3
- 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/27-conv-ledger.js +1 -1
- 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 +33 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +801 -102
- package/client-ui/shared/transport-http.js +199 -38
- package/client-ui-mobile/index.html +731 -581
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +520 -0
- package/cmd/magic-client/main.go +1 -1
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +14 -0
- package/internal/agent/llmengine_test.go +24 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +48 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +38 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- 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/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +35 -3
- package/internal/client/service_test.go +35 -0
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- 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 +532 -0
- package/internal/suggest/suggest_test.go +268 -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 +6 -2
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// Package suggest - 生成「下一轮快捷回复」的建议(一轮对话结束后给用户可点的下一句)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」):
|
|
4
|
+
// 每轮回答结束后,用户往往还要再打一句「继续 / 看下 diff / 先别动」。这些句子**就在回答里**
|
|
5
|
+
// (AI 问「要我继续吗?」、AI 列了「下一步」),把它提炼成按钮能省一次打字 —— 但提炼必须
|
|
6
|
+
// 来自模型对**这一轮原文**的理解,不能套模板硬凑(模板会给出与内容无关的噪音)。
|
|
7
|
+
//
|
|
8
|
+
// 三条纪律:
|
|
9
|
+
// 1. **一次性旁路调用**:`agent.Runner` + `ToolsOff` + 不带 SessionID —— 独立进程、独立会话,
|
|
10
|
+
// 不续接也不污染用户正在聊的那条上下文;不经过 CLI 的 ask 通道,不留会话登记。
|
|
11
|
+
// 2. **宁缺毋滥**:模型没给出可用句子、或这轮本来就没有下一步时返回空数组,调用方据此不画按钮。
|
|
12
|
+
// 3. **不编造**:提示词明确禁止引入对话里没出现过的文件 / 数字 / 结论;解析侧再做一层长度、
|
|
13
|
+
// 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
|
|
14
|
+
//
|
|
15
|
+
// 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
|
|
16
|
+
// 标题、结果落进标题表;这条只产出一组**临时**建议,不落盘、不缓存 —— 下一轮结束就重算。
|
|
17
|
+
package suggest
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"context"
|
|
21
|
+
"encoding/json"
|
|
22
|
+
"fmt"
|
|
23
|
+
"regexp"
|
|
24
|
+
"strings"
|
|
25
|
+
"time"
|
|
26
|
+
"unicode/utf8"
|
|
27
|
+
|
|
28
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// 默认参数。取「短、快」:建议是锦上添花,不能让它拖住下一轮。
|
|
32
|
+
const (
|
|
33
|
+
// DefaultEngine 默认用 llm:纯文本、不带工具、没有 CLI 会话副作用(与 --title-engine 同口径)。
|
|
34
|
+
DefaultEngine = "llm"
|
|
35
|
+
// DefaultCount 最多给几条建议(UI 一行放得下 3 枚)。
|
|
36
|
+
DefaultCount = 3
|
|
37
|
+
// MaxLabelRunes 单条建议的字数上限。提示词要求 ≤16;解析时放宽到 28 仍然接受,
|
|
38
|
+
// 再长就不是「随手发的一句话」了,直接丢掉(不截断 —— 截断后的半句话发出去更糟)。
|
|
39
|
+
MaxLabelRunes = 28
|
|
40
|
+
// DefaultTimeout 单次旁路调用的超时。到点没有结果就当没有建议(调用方不等待)。
|
|
41
|
+
DefaultTimeout = 22 * time.Second
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Request 生成建议的输入。
|
|
45
|
+
type Request struct {
|
|
46
|
+
// User 用户最新那一句(可为空:比如本轮是系统派发的任务)。
|
|
47
|
+
User string
|
|
48
|
+
// Reply 引擎这一轮的**权威正文**(必填;空 = 没有可提炼的内容)。
|
|
49
|
+
Reply string
|
|
50
|
+
// Engine 用哪个引擎生成(空 = DefaultEngine)。
|
|
51
|
+
Engine string
|
|
52
|
+
// Model 模型标识(空 = 引擎默认)。
|
|
53
|
+
Model string
|
|
54
|
+
// Count 最多几条(<=0 = DefaultCount)。
|
|
55
|
+
Count int
|
|
56
|
+
// Timeout 单次调用超时(<=0 = DefaultTimeout)。
|
|
57
|
+
Timeout time.Duration
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Result 生成结果。
|
|
61
|
+
type Result struct {
|
|
62
|
+
Suggestions []string `json:"suggestions"`
|
|
63
|
+
Engine string `json:"engine,omitempty"`
|
|
64
|
+
Model string `json:"model,omitempty"`
|
|
65
|
+
ElapsedMS int64 `json:"elapsedMs,omitempty"`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// RequestBody 是 CLI / 插件之间传的 JSON 形状(只含对话内容,引擎走 -e/-m)。
|
|
69
|
+
type RequestBody struct {
|
|
70
|
+
User string `json:"user"`
|
|
71
|
+
Reply string `json:"reply"`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Prompt 拼提示词。截断策略:用户那句取**开头**(它通常很短),回复取**结尾** ——
|
|
75
|
+
// 下一步 / 待确认的问题几乎都在最后一段,截掉尾部等于把要提炼的东西扔了。
|
|
76
|
+
func Prompt(user, reply string, count int) string {
|
|
77
|
+
if count <= 0 || count > DefaultCount {
|
|
78
|
+
count = DefaultCount
|
|
79
|
+
}
|
|
80
|
+
u := strings.TrimSpace(user)
|
|
81
|
+
if n := len([]rune(u)); n > 1500 {
|
|
82
|
+
u = string([]rune(u)[:1500]) + "…"
|
|
83
|
+
}
|
|
84
|
+
r := strings.TrimSpace(reply)
|
|
85
|
+
if n := len([]rune(r)); n > 6000 {
|
|
86
|
+
r = "…" + string([]rune(r)[n-6000:])
|
|
87
|
+
}
|
|
88
|
+
var b strings.Builder
|
|
89
|
+
b.WriteString("你是「下一句」建议器,服务于一个正在用 AI agent 干活的用户。\n")
|
|
90
|
+
b.WriteString("读完下面这一轮对话,给出用户此刻最可能**直接发出去**的简短指令。\n\n")
|
|
91
|
+
b.WriteString("硬约束:\n")
|
|
92
|
+
fmt.Fprintf(&b, "1. 最多 %d 条;每条 ≤ 16 个汉字,口语,像用户随手打的;不要编号、引号、句末标点。\n", count)
|
|
93
|
+
b.WriteString("2. 必须是**用户对 AI 说的话**,不是 AI 的回答,也不是给用户的说明。\n")
|
|
94
|
+
b.WriteString("3. 只依据这一轮里**已经出现**的内容(AI 问的问题、列出的下一步、给出的选项)。\n")
|
|
95
|
+
b.WriteString(" 不许编造对话里没有的文件、数字、结论;拿不准就不要写。\n")
|
|
96
|
+
b.WriteString("4. AI 在等拍板 → 给直接的肯定/否定答复;AI 列了下一步 → 挑最关键的 1-2 条;\n")
|
|
97
|
+
b.WriteString(" 给多条时按「最该先做」排序,**第一条就是最佳建议**(托管模式会直接发它);\n")
|
|
98
|
+
b.WriteString(" 这一轮已经彻底结束、没有下一步 → 给空数组。宁缺毋滥。\n")
|
|
99
|
+
b.WriteString("5. 只输出 JSON:{\"suggestions\":[\"…\",\"…\"]},不要解释、不要 markdown 围栏。\n")
|
|
100
|
+
b.WriteString("6. 这一轮如果是**交付型任务**(小说 / 故事 / 文章 / 诗 / 剧本 / 文案 / 报告 / 代码 / 图纸),\n")
|
|
101
|
+
b.WriteString(" 而 AI 已经把成品完整交出来、没有提问、也没有列出下一步 → **必须给空数组**。\n")
|
|
102
|
+
b.WriteString(" 不要建议「再来一篇」「换个题材」「再写一个」「继续写」这类**另起一篇**的话;\n")
|
|
103
|
+
b.WriteString(" 除非 AI 在这一轮明确问「要不要再来一篇 / 下一篇写什么」。即使用户上一句就是\n")
|
|
104
|
+
b.WriteString(" 「再来一篇」,只要这一篇已经交付完、没有新问题,也算结束。\n\n")
|
|
105
|
+
b.WriteString("例 1(交付完成 → 空):\n")
|
|
106
|
+
b.WriteString("用户:写一个短篇恋爱小说\n")
|
|
107
|
+
b.WriteString("AI:# 标题 …(完整故事,结尾收束,没有提问)\n")
|
|
108
|
+
b.WriteString("输出:{\"suggestions\":[]}\n\n")
|
|
109
|
+
b.WriteString("例 2(AI 在等拍板 → 1-2 条):\n")
|
|
110
|
+
b.WriteString("用户:修一下登录 bug\n")
|
|
111
|
+
b.WriteString("AI:修好了,测试通过。要不要我顺手把过期 token 也处理掉?\n")
|
|
112
|
+
b.WriteString("输出:{\"suggestions\":[\"处理过期 token\",\"先这样\"]}\n\n")
|
|
113
|
+
b.WriteString("用户刚才说:\n")
|
|
114
|
+
if u == "" {
|
|
115
|
+
b.WriteString("(本轮由系统派发,没有用户原话)\n")
|
|
116
|
+
} else {
|
|
117
|
+
b.WriteString(u + "\n")
|
|
118
|
+
}
|
|
119
|
+
b.WriteString("\nAI 的回复:\n")
|
|
120
|
+
b.WriteString(r)
|
|
121
|
+
b.WriteString("\n")
|
|
122
|
+
return b.String()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Schema 结构化输出契约(llm 引擎会按它做一次 JSON 后处理;其余引擎靠提示词约束)。
|
|
126
|
+
func Schema() *agent.JSONSchema {
|
|
127
|
+
return &agent.JSONSchema{
|
|
128
|
+
Type: "object",
|
|
129
|
+
Properties: map[string]map[string]any{
|
|
130
|
+
"suggestions": {"type": "array", "items": map[string]any{"type": "string"}},
|
|
131
|
+
},
|
|
132
|
+
Required: []string{"suggestions"},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Parse 从模型输出里抽建议。容忍三种形态:JSON 对象 {"suggestions":[…]}
|
|
137
|
+
// (带不带 ``` 围栏都行)、裸 JSON 数组、以及一眼能认出的逐行短句。
|
|
138
|
+
// 解析失败 / 全是废句 → 返回空切片(不是错误:没有建议是合法结果)。
|
|
139
|
+
func Parse(raw string, count int) []string {
|
|
140
|
+
if count <= 0 || count > DefaultCount {
|
|
141
|
+
count = DefaultCount
|
|
142
|
+
}
|
|
143
|
+
text := strings.TrimSpace(raw)
|
|
144
|
+
if text == "" {
|
|
145
|
+
return []string{}
|
|
146
|
+
}
|
|
147
|
+
if obj := firstJSONValue(text); obj != nil {
|
|
148
|
+
var arr []any
|
|
149
|
+
switch v := obj.(type) {
|
|
150
|
+
case map[string]any:
|
|
151
|
+
if a, ok := v["suggestions"].([]any); ok {
|
|
152
|
+
arr = a
|
|
153
|
+
}
|
|
154
|
+
case []any:
|
|
155
|
+
arr = v
|
|
156
|
+
}
|
|
157
|
+
if arr != nil {
|
|
158
|
+
return normalize(arrToStrings(arr), count)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return normalize(splitLines(text), count)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Generate 跑一次旁路生成。失败时返回空建议 + error(调用方如实降级:不画按钮)。
|
|
165
|
+
//
|
|
166
|
+
// 2026-10-23 用户定调:「建议的生成要走引擎的不启用工具 json 返回的方式,提高速度」。
|
|
167
|
+
// 这里的主路径就是如此:当前引擎(in.Engine)+ ToolsOff + JSONSchema + NoThinking,
|
|
168
|
+
// 各引擎都通过 NoThinking 走官方关闭参数;失败/超时再降级到 llm 的 no-think 变体
|
|
169
|
+
// (纯文本、无工具、1-2s),保证界面不会跟着主引擎的 CLI 一起卡到 20s。
|
|
170
|
+
// `ToolsOff` / `JSONSchema` / `NoThinking` 不是可选项 —— 少任何一条都会把建议变成
|
|
171
|
+
// 一次带工具痕迹(或默认高档思考)的完整 agent 轮次。
|
|
172
|
+
func Generate(ctx context.Context, in Request) (Result, error) {
|
|
173
|
+
reply := strings.TrimSpace(in.Reply)
|
|
174
|
+
if reply == "" {
|
|
175
|
+
return Result{Suggestions: []string{}}, nil
|
|
176
|
+
}
|
|
177
|
+
name := strings.TrimSpace(in.Engine)
|
|
178
|
+
if name == "" {
|
|
179
|
+
name = DefaultEngine
|
|
180
|
+
}
|
|
181
|
+
if agent.Lookup(name) == nil {
|
|
182
|
+
return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
|
|
183
|
+
}
|
|
184
|
+
timeout := in.Timeout
|
|
185
|
+
if timeout <= 0 {
|
|
186
|
+
timeout = DefaultTimeout
|
|
187
|
+
}
|
|
188
|
+
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
189
|
+
defer cancel()
|
|
190
|
+
start := time.Now()
|
|
191
|
+
|
|
192
|
+
/* 建议一律不思考;所有引擎都尽量走官方开关:
|
|
193
|
+
llm 用 no-think 变体 / reasoning_effort=none,claude --thinking disabled,
|
|
194
|
+
codex effort=none,codebuddy settings alwaysThinkingEnabled=false,
|
|
195
|
+
dsh InitializeParams.reasoningEffort=off。没有可靠开关的引擎(trae/openclaw/arkclaw)
|
|
196
|
+
仍会被 NoThinking 标记,失败后再降级 llm;不在这里预先改道。 */
|
|
197
|
+
/* 主引擎:优先当前引擎(用户要的“引擎 JSON 返回”)。claude / codex 给 10s ——
|
|
198
|
+
这是「CLI 启动 + 一次短 JSON」的实测上限;超过 10s 多半是凭据/主会话占用,
|
|
199
|
+
继续等只会把界面的 20s 预算吃光。之后才降级到 llm(1-2s),并清掉当前引擎的 model。 */
|
|
200
|
+
primaryBudget := timeout
|
|
201
|
+
if name != DefaultEngine && primaryBudget > 10*time.Second {
|
|
202
|
+
primaryBudget = 10 * time.Second
|
|
203
|
+
}
|
|
204
|
+
res, err := tryGenerate(cctx, in, name, primaryBudget, start)
|
|
205
|
+
if err == nil {
|
|
206
|
+
return res, nil
|
|
207
|
+
}
|
|
208
|
+
if name == DefaultEngine {
|
|
209
|
+
return Result{}, err
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* 降级:给 llm 留一段独立预算(整体仍受 timeout 约束)。主引擎的错误不丢,
|
|
213
|
+
只有 fallback 也失败时才报它 —— 至少让调用方知道不是“没有建议”,是真失败。 */
|
|
214
|
+
remain := timeout - time.Since(start)
|
|
215
|
+
if remain < 2*time.Second {
|
|
216
|
+
remain = 2 * time.Second
|
|
217
|
+
}
|
|
218
|
+
if remain > 8*time.Second {
|
|
219
|
+
remain = 8 * time.Second
|
|
220
|
+
}
|
|
221
|
+
fallbackIn := in
|
|
222
|
+
fallbackIn.Model = "" // model 是当前引擎的,不能拿去喂 llm
|
|
223
|
+
fallback, ferr := tryGenerate(cctx, fallbackIn, DefaultEngine, remain, start)
|
|
224
|
+
if ferr != nil {
|
|
225
|
+
return Result{}, err
|
|
226
|
+
}
|
|
227
|
+
return fallback, nil
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// fastLLMModel 找 models.json 里第一条「不思考」的变体(extraBody.thinking.type=disabled)。
|
|
231
|
+
// 建议是短文本,思考只会把 1s 拖成 4s;有这种条目就用它,不硬编码某个模型 id。
|
|
232
|
+
func fastLLMModel() string {
|
|
233
|
+
list, err := agent.LoadLLMConfigModels()
|
|
234
|
+
if err != nil {
|
|
235
|
+
return ""
|
|
236
|
+
}
|
|
237
|
+
for _, m := range list {
|
|
238
|
+
if llmModelDisablesThinking(m) {
|
|
239
|
+
return m.ID
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return ""
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// llmModelDisablesThinking 判断 models.json 条目是否声明了不思考变体。
|
|
246
|
+
func llmModelDisablesThinking(m agent.LLMConfigModel) bool {
|
|
247
|
+
if strings.TrimSpace(m.ID) == "" || m.ExtraBody == nil {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
th, ok := m.ExtraBody["thinking"].(map[string]any)
|
|
251
|
+
if !ok {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
return strings.EqualFold(strings.TrimSpace(fmt.Sprint(th["type"])), "disabled")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// noThinkLLMModel 给建议选一个真正不思考的 llm 模型:
|
|
258
|
+
// 用户选的模型本身就是 no-think 变体 → 用它;否则用 models.json 里第一条
|
|
259
|
+
// thinking.disabled 变体;没有变体才退回用户选的模型(无法关闭,至少不阻断)。
|
|
260
|
+
func noThinkLLMModel(selected string) string {
|
|
261
|
+
selected = strings.TrimSpace(selected)
|
|
262
|
+
if selected != "" {
|
|
263
|
+
if m, ok := agent.LookupLLMConfigModel(selected); ok && llmModelDisablesThinking(m) {
|
|
264
|
+
return m.ID
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if fast := fastLLMModel(); fast != "" {
|
|
268
|
+
return fast
|
|
269
|
+
}
|
|
270
|
+
return selected
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// tryGenerate 用指定引擎跑一次 ToolsOff + JSONSchema 的短调用。
|
|
274
|
+
func tryGenerate(ctx context.Context, in Request, name string, budget time.Duration, start time.Time) (Result, error) {
|
|
275
|
+
engine := agent.Lookup(name)
|
|
276
|
+
if engine == nil {
|
|
277
|
+
return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
|
|
278
|
+
}
|
|
279
|
+
reply := strings.TrimSpace(in.Reply)
|
|
280
|
+
attemptCtx, cancel := context.WithTimeout(ctx, budget)
|
|
281
|
+
defer cancel()
|
|
282
|
+
/* 建议是纯文本短任务,**一律不思考**。Reasoning 只作为没有 NoThinking 通道的
|
|
283
|
+
引擎的兜底(minimal 仍比默认高档轻);能真正关闭的引擎看 NoThinking。 */
|
|
284
|
+
reasoning := "minimal"
|
|
285
|
+
model := strings.TrimSpace(in.Model)
|
|
286
|
+
if name == DefaultEngine {
|
|
287
|
+
/* 建议一律不思考:llm 有 no-think 变体就用它(默认引擎不配模型时也走这里)。
|
|
288
|
+
用户显式选了会思考的模型(如 MiniMax-M3.1-Flash)时,宁可换同文件的
|
|
289
|
+
no-think 变体,也不让建议在思考上多花几秒;配置里没有变体才保留原模型。 */
|
|
290
|
+
model = noThinkLLMModel(model)
|
|
291
|
+
}
|
|
292
|
+
runner := &agent.Runner{Engine: engine, Timeout: budget}
|
|
293
|
+
resp, err := runner.Run(attemptCtx, agent.Request{
|
|
294
|
+
Model: model,
|
|
295
|
+
Tools: agent.ToolsOff,
|
|
296
|
+
/* NoThinking:claude → --thinking disabled,codex → effort none,
|
|
297
|
+
llm 换成 thinking.disabled 变体,codebuddy → settings:false,
|
|
298
|
+
dsh → initialize.reasoningEffort=off。 */
|
|
299
|
+
NoThinking: true,
|
|
300
|
+
Reasoning: reasoning,
|
|
301
|
+
MaxTokens: 300,
|
|
302
|
+
Messages: []agent.Message{{Role: "user", Content: Prompt(in.User, reply, in.Count)}},
|
|
303
|
+
JSONSchema: Schema(),
|
|
304
|
+
})
|
|
305
|
+
if err != nil {
|
|
306
|
+
return Result{}, err
|
|
307
|
+
}
|
|
308
|
+
items := Parse(resp.Text, in.Count)
|
|
309
|
+
items = dropCreativeRestarts(items, in.User, reply)
|
|
310
|
+
return Result{
|
|
311
|
+
Suggestions: items,
|
|
312
|
+
Engine: engine.Name(),
|
|
313
|
+
Model: resp.Model,
|
|
314
|
+
ElapsedMS: time.Since(start).Milliseconds(),
|
|
315
|
+
}, nil
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── 交付完成后的「另起一篇」过滤(2026-10-23 用户实测)─────────────────
|
|
319
|
+
//
|
|
320
|
+
// 提示词已经要求「交付完就给空数组」,但模型仍会把「再写一篇 / 换个题材」当成下一步
|
|
321
|
+
// (用户截图:短篇写完,托管自动发出「再来一篇职场题材的」,又生成一篇)。
|
|
322
|
+
// 这一层是**确定性兜底**:只在创作类轮次里,丢掉「另起一篇」形态的建议;
|
|
323
|
+
// AI 明确问「要不要再来一篇 / 下一篇写什么」时放行。
|
|
324
|
+
|
|
325
|
+
var (
|
|
326
|
+
creativeTurnRe = regexp.MustCompile(`小说|故事|文章|短篇|长篇|诗歌?|剧本|文案|作文|题材|写一篇|再写一篇|再来一篇|创作`)
|
|
327
|
+
// 「再来一篇 / 再写一章 / 再画一首」:量词是篇/章/首/部,基本只用于创作。
|
|
328
|
+
anotherWritingRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|两)?(篇|章|首|部)`)
|
|
329
|
+
// 「再来个职场篇 / 再写一个短篇」:没有创作名词,但以篇/章/首/部收尾。
|
|
330
|
+
anotherPieceRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个)?[^,。!?]{0,10}(篇|章|首|部)$`)
|
|
331
|
+
// 「再写一个校园故事 / 来个职场段子」:量词是「个/段」时必须带创作名词。
|
|
332
|
+
anotherCreativeRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个|段)?[^,。!?]{0,8}(小说|故事|文章|散文|诗歌?|剧本|文案|作文|童话|段子|番外|续集|续篇)`)
|
|
333
|
+
switchTopicRe = regexp.MustCompile(`^(换|改)(一|个)?[^,。!?]{0,8}(题材|主题|方向|背景)`)
|
|
334
|
+
nextPieceRe = regexp.MustCompile(`^下一(篇|章|首|部)`)
|
|
335
|
+
continueWriteRe = regexp.MustCompile(`^(继续|接着)(写|创作|生成|画)`)
|
|
336
|
+
bareContinueRe = regexp.MustCompile(`^(继续|接着)(吧|来)?$`)
|
|
337
|
+
inviteAnotherRe = regexp.MustCompile(`(要不要|需要我|想不想|是否).{0,10}(再|下一|续)|要继续吗|接着(写|来)吗|下一(篇|章|个)|续写|告诉我.{0,10}(题材|主题|方向)`)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
// looksLikeCreativeReply 识别「用户只说继续,但 AI 交的是一篇完整创作」:
|
|
341
|
+
// Markdown 一级标题 + 超过 400 字 + 没有代码块。技术长文也按此处理(成品交付完不该再
|
|
342
|
+
// 建议「另起一篇」),带代码块的工程回答则排除,避免误杀「再写一个测试」。
|
|
343
|
+
func looksLikeCreativeReply(reply string) bool {
|
|
344
|
+
r := strings.TrimSpace(reply)
|
|
345
|
+
if !strings.HasPrefix(r, "# ") || strings.Contains(r, "```") {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
348
|
+
return utf8.RuneCountInString(r) >= 400
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// dropCreativeRestarts 把「成品交付完之后的另起一篇」过滤掉。
|
|
352
|
+
// 只在创作类轮次(用户这句本身是创作请求,或 AI 交了一篇完整创作)生效 ——
|
|
353
|
+
// 写代码时「再写一个测试」是正常下一步,不能误杀。
|
|
354
|
+
func dropCreativeRestarts(items []string, user, reply string) []string {
|
|
355
|
+
if !creativeTurnRe.MatchString(strings.TrimSpace(user)) && !looksLikeCreativeReply(reply) {
|
|
356
|
+
return items
|
|
357
|
+
}
|
|
358
|
+
tail := []rune(strings.TrimSpace(reply))
|
|
359
|
+
if len(tail) > 600 {
|
|
360
|
+
tail = tail[len(tail)-600:]
|
|
361
|
+
}
|
|
362
|
+
invited := inviteAnotherRe.MatchString(string(tail))
|
|
363
|
+
unfinished := strings.Contains(string(tail), "未完") || strings.Contains(string(tail), "待续") || strings.Contains(string(tail), "下一章")
|
|
364
|
+
explicitCreative := creativeTurnRe.MatchString(strings.TrimSpace(user))
|
|
365
|
+
out := make([]string, 0, len(items))
|
|
366
|
+
for _, s := range items {
|
|
367
|
+
t := strings.TrimSpace(s)
|
|
368
|
+
if t == "" {
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
restart := anotherWritingRe.MatchString(t) || anotherPieceRe.MatchString(t) ||
|
|
372
|
+
anotherCreativeRe.MatchString(t) || switchTopicRe.MatchString(t) || nextPieceRe.MatchString(t)
|
|
373
|
+
if restart && !invited {
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if continueWriteRe.MatchString(t) && !invited && !unfinished {
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
/* 光秃秃一句「继续」是最容易把成品续写成第二篇的:只在用户这句本身是创作请求时挡。 */
|
|
380
|
+
if bareContinueRe.MatchString(t) && explicitCreative && !invited && !unfinished {
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
out = append(out, s)
|
|
384
|
+
}
|
|
385
|
+
return out
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── 解析辅助 ──────────────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
// firstJSONValue 从可能裹着围栏 / 解释文字的输出里抠出**第一个** JSON 值。
|
|
391
|
+
// 用 json.Decoder 逐个 '{' / '[' 试(比正则稳:能正确处理字符串里的括号)。
|
|
392
|
+
func firstJSONValue(s string) any {
|
|
393
|
+
s = stripFence(s)
|
|
394
|
+
for i := 0; i < len(s); i++ {
|
|
395
|
+
if s[i] != '{' && s[i] != '[' {
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
dec := json.NewDecoder(strings.NewReader(s[i:]))
|
|
399
|
+
dec.UseNumber()
|
|
400
|
+
var v any
|
|
401
|
+
if err := dec.Decode(&v); err == nil {
|
|
402
|
+
return v
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return nil
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// dropFenced 去掉整段 ``` 围栏(含围栏里的代码):逐行兜底时不能把代码示例当建议。
|
|
409
|
+
func dropFenced(s string) string {
|
|
410
|
+
var b strings.Builder
|
|
411
|
+
inFence := false
|
|
412
|
+
for _, ln := range strings.Split(s, "\n") {
|
|
413
|
+
if strings.HasPrefix(strings.TrimSpace(ln), "```") {
|
|
414
|
+
inFence = !inFence
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
if !inFence {
|
|
418
|
+
b.WriteString(ln)
|
|
419
|
+
b.WriteString("\n")
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return b.String()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
func stripFence(s string) string {
|
|
426
|
+
t := strings.TrimSpace(s)
|
|
427
|
+
if !strings.HasPrefix(t, "```") {
|
|
428
|
+
return t
|
|
429
|
+
}
|
|
430
|
+
if i := strings.Index(t, "\n"); i >= 0 {
|
|
431
|
+
t = t[i+1:]
|
|
432
|
+
} else {
|
|
433
|
+
t = strings.TrimPrefix(t, "```")
|
|
434
|
+
}
|
|
435
|
+
if i := strings.LastIndex(t, "```"); i >= 0 {
|
|
436
|
+
t = t[:i]
|
|
437
|
+
}
|
|
438
|
+
return strings.TrimSpace(t)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func arrToStrings(a []any) []string {
|
|
442
|
+
out := make([]string, 0, len(a))
|
|
443
|
+
for _, v := range a {
|
|
444
|
+
if s, ok := v.(string); ok {
|
|
445
|
+
out = append(out, s)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return out
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// splitLines 兜底:模型没按 JSON 回(或只回了一串短句)时,按行取。
|
|
452
|
+
// 只保留「像一句指令」的行 —— 带冒号解释、带代码、太长的一律不要。
|
|
453
|
+
func splitLines(text string) []string {
|
|
454
|
+
var out []string
|
|
455
|
+
for _, ln := range strings.Split(dropFenced(text), "\n") {
|
|
456
|
+
ln = strings.TrimSpace(ln)
|
|
457
|
+
if ln == "" {
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
ln = trimListPrefix(ln)
|
|
461
|
+
if strings.ContainsAny(ln, "`{}<>") || strings.Contains(ln, ":") || strings.Contains(ln, "://") {
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
out = append(out, ln)
|
|
465
|
+
}
|
|
466
|
+
return out
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// normalize 归一 + 校验:剥引号/列表号/句末标点,丢弃过长、多行、像 AI 回答的句子,去重截断。
|
|
470
|
+
func normalize(in []string, count int) []string {
|
|
471
|
+
out := make([]string, 0, count)
|
|
472
|
+
seen := make(map[string]bool, len(in))
|
|
473
|
+
for _, raw := range in {
|
|
474
|
+
s := strings.TrimSpace(raw)
|
|
475
|
+
s = trimListPrefix(s)
|
|
476
|
+
/* 引号与句末标点会互相挡着(「好的」。 里右引号在句号前面):多剥两轮。 */
|
|
477
|
+
for i := 0; i < 2; i++ {
|
|
478
|
+
s = strings.TrimRight(s, "。..!!??;;,,")
|
|
479
|
+
s = strings.Trim(s, " \t\r\n\"'“”‘’「」『』")
|
|
480
|
+
}
|
|
481
|
+
s = strings.TrimRight(s, "。..!!??;;,,")
|
|
482
|
+
s = strings.Join(strings.Fields(s), " ")
|
|
483
|
+
if s == "" || utf8.RuneCountInString(s) > MaxLabelRunes {
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
/* 以省略号开头 = 上游被截断的半句话(见 Prompt 的截断),发出去会缺主语。 */
|
|
487
|
+
if strings.HasPrefix(s, "…") || strings.ContainsAny(s, "\n\r") {
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
/* 「AI 的答复」冒充「用户的话」:这类开头一定不是用户要发的。 */
|
|
491
|
+
if strings.HasPrefix(s, "AI") || strings.HasPrefix(s, "助手") || strings.HasPrefix(s, "Agent") {
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
if seen[s] {
|
|
495
|
+
continue
|
|
496
|
+
}
|
|
497
|
+
seen[s] = true
|
|
498
|
+
out = append(out, s)
|
|
499
|
+
if len(out) >= count {
|
|
500
|
+
break
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return out
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// trimListPrefix 去掉「1. / 1、/ - / • / (1)」这类列表前缀(模型偶尔会自作主张编号)。
|
|
507
|
+
func trimListPrefix(s string) string {
|
|
508
|
+
t := strings.TrimSpace(s)
|
|
509
|
+
for i := 0; i < 3; i++ { // 最多剥三层(如 "- 1. xxx")
|
|
510
|
+
prev := t
|
|
511
|
+
t = strings.TrimLeft(t, "-*•· ")
|
|
512
|
+
for _, sep := range []string{".", "、", ")", ")", ":"} {
|
|
513
|
+
if j := strings.Index(t, sep); j > 0 && j <= 3 {
|
|
514
|
+
allDigit := true
|
|
515
|
+
for _, r := range t[:j] {
|
|
516
|
+
if r < '0' || r > '9' {
|
|
517
|
+
allDigit = false
|
|
518
|
+
break
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if allDigit {
|
|
522
|
+
t = strings.TrimSpace(t[j+len(sep):])
|
|
523
|
+
break
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if t == prev {
|
|
528
|
+
break
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return strings.TrimSpace(t)
|
|
532
|
+
}
|