@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +21 -0
  7. package/dist/cli.js +353 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
@@ -0,0 +1,902 @@
1
+ /**
2
+ * 对话服务:一段对话的诞生、改名、置顶、删除、撤回,以及两个「会话级调整」(换模型、换思考档位)。
3
+ *
4
+ * 对应能力编号:F-C1(稳定 id)、F-C3(可改名字)、F-C5(置顶 + 创建时间倒序)、
5
+ * F-C7(开新会话)、F-C8 / D24(撤回硬删 + 回填输入框)、F-C10 / D17(硬删 + 警告)、
6
+ * F-B4 / D20(会话级换模型、历史有图时拒绝降级)、F-B6 / F-B7(思考档位)、
7
+ * L5(重连按 id 拉)、L6(id 宿主分配)、R18(身份创建时必须有助手与模型)、
8
+ * R22(消息原样用 core 的类型)。
9
+ *
10
+ * 两条贯穿这个文件的边界:
11
+ * 1. **项目与助手创建即定死**:`workspaceId` / `agentId` 由 `create` 解析后落库,此后没有
12
+ * 任何方法改它们。模型与思考档位不在这条线上:它们是这段会话自己的,存在会话行上、可以改。
13
+ * 2. **服务层不缓存 agent、不驻留对话状态**:`running` 与「删掉之后的现场清理」都从注入的
14
+ * {@link ConversationRuntimePort} 现取现放;模型与档位在库里,不在内存里存第二份。
15
+ */
16
+ import { randomUUID } from "node:crypto";
17
+ import { isAbsolute, join, resolve } from "node:path";
18
+ import { AgentNS, ZERO_USAGE } from "@ai-zen/agents-core";
19
+ import { AgentRepository, resolveModel } from "@ai-zen/agents-sdk";
20
+ import { ConversationDrafts } from "../runtime/ConversationDrafts.js";
21
+ import { injectedContextOf } from "../runtime/ChatRunner.js";
22
+ import { DEFAULT_LANGUAGE } from "../i18n/index.js";
23
+ import { HostError } from "../wire/errors.js";
24
+ import { assetIdsOf } from "./AssetService.js";
25
+ import { readUsage, toAgentMessage } from "./MessageService.js";
26
+ import { toFolderFact } from "./WorkspaceService.js";
27
+ /** 时间戳转 wire 上的 ISO 8601 */
28
+ function iso(ms) {
29
+ return new Date(ms).toISOString();
30
+ }
31
+ /**
32
+ * 对话的原名:创建时间(PRD D2「原名=创建时间」,起不出名就保持它)。
33
+ *
34
+ * 用本地时区的 `YYYY-MM-DD HH:mm`:这是摆在侧栏里给人看的一句话,不是可排序的机器时间
35
+ * (排序只按 `pinned` + `createdAt`,D13)。
36
+ */
37
+ function autoName(ms) {
38
+ const date = new Date(ms);
39
+ const pad = (value) => String(value).padStart(2, "0");
40
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
41
+ }
42
+ /**
43
+ * cwd 归一化成绝对路径。
44
+ *
45
+ * 与 `WorkspaceService` 同一套口径(D9 的 cwd 一旦定死就不再变):相对路径不能拿
46
+ * 进程工作目录去解,否则同一个入参在不同启动方式下会得到两个项目。
47
+ */
48
+ function normalizeCwd(value) {
49
+ const raw = typeof value === "string" ? value.trim() : "";
50
+ if (raw.length === 0) {
51
+ throw new HostError("invalid_params", {
52
+ params: { field: "cwd" },
53
+ detail: { field: "cwd", reason: "empty" },
54
+ });
55
+ }
56
+ if (!isAbsolute(raw)) {
57
+ throw new HostError("invalid_params", {
58
+ params: { field: "cwd" },
59
+ detail: { field: "cwd", reason: "not_absolute", cwd: value },
60
+ });
61
+ }
62
+ return resolve(raw);
63
+ }
64
+ /**
65
+ * 撤回时回填的正文只取纯文本段(R62)。
66
+ *
67
+ * 一条带附件的用户消息里既有正文又有 `image_url` / `file` 段;原样回填会让客户端既拿到
68
+ * 已经转换好的段、又拿到 `assetIds`(图片会显示两遍)。附件靠 `assetIds` 复原(F-H3)。
69
+ */
70
+ function textOnlyContent(content) {
71
+ if (content === undefined)
72
+ return "";
73
+ if (typeof content === "string")
74
+ return content;
75
+ const sections = content.filter((section) => section.type === "text");
76
+ return sections.length === 0 ? "" : sections;
77
+ }
78
+ /** 必填的 id / 名字类参数:空串按「参数不对」处理,不拿它去查库 */
79
+ function requireId(value, field) {
80
+ const text = typeof value === "string" ? value.trim() : "";
81
+ if (text.length === 0) {
82
+ throw new HostError("invalid_params", {
83
+ params: { field },
84
+ detail: { field, reason: "empty" },
85
+ });
86
+ }
87
+ return text;
88
+ }
89
+ /** 结构相等:思考档位的补丁与助手定义上的参数要能对得上 */
90
+ function deepEqual(left, right) {
91
+ if (left === right)
92
+ return true;
93
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
94
+ return false;
95
+ }
96
+ const leftEntries = Object.entries(left);
97
+ const rightEntries = Object.entries(right);
98
+ if (leftEntries.length !== rightEntries.length)
99
+ return false;
100
+ return leftEntries.every(([key, value]) => deepEqual(value, right[key]));
101
+ }
102
+ /**
103
+ * 助手定义上的参数是否「就是这一档」:档位补丁的每个键都在,且值相等。
104
+ *
105
+ * 档位展开后写进 `AgentDefinition.modelParams` 的是参数而不是档位名(F-B6),所以读的时候
106
+ * 只能反过来按补丁认档位。补丁允许是定义参数的子集(定义里可能还有与思考无关的参数)。
107
+ */
108
+ function matchesPatch(params, patch) {
109
+ if (!params)
110
+ return false;
111
+ const entries = Object.entries(patch);
112
+ if (entries.length === 0)
113
+ return false;
114
+ return entries.every(([key, value]) => deepEqual(params[key], value));
115
+ }
116
+ export class ConversationService {
117
+ conversations;
118
+ messages;
119
+ workspaces;
120
+ assets;
121
+ sweepAssets;
122
+ questions;
123
+ configManager;
124
+ runtime;
125
+ drafts;
126
+ emit;
127
+ now;
128
+ /** 正在落库的草稿:同一个 id 的并发调用共用同一次写入(key 是 conversationId) */
129
+ persisting = new Map();
130
+ /** 助手定义是文件(PRD F-G3:一实体一文件),按配置根下的 `agents/` 目录读 */
131
+ agents;
132
+ constructor(deps) {
133
+ this.conversations = deps.conversations;
134
+ this.messages = deps.messages;
135
+ this.workspaces = deps.workspaces;
136
+ this.assets = deps.assets;
137
+ this.sweepAssets = deps.sweepAssets;
138
+ this.questions = deps.questions;
139
+ this.configManager = deps.configManager;
140
+ this.runtime = deps.runtime;
141
+ this.drafts = deps.drafts ?? new ConversationDrafts();
142
+ this.emit = deps.emit;
143
+ this.now = deps.now ?? Date.now;
144
+ this.agents = new AgentRepository(join(deps.configManager.basePath, "agents"));
145
+ }
146
+ /**
147
+ * 列表(PRD F-C5 / D13)。
148
+ *
149
+ * 顺序由仓储定死:`pinned DESC, createdAt DESC` —— 活动、打开、改名都不影响它,所以这里
150
+ * 除了按 Workspace / 置顶筛、按 `limit` / `offset` 切页之外,不再重排。
151
+ *
152
+ * 列表只给已经落库的对话:草稿(还没发过消息,R56)不在侧栏里出现 —— 建了不发就断线
153
+ * 的那段对话应该像没建过一样。
154
+ */
155
+ async list(params = {}) {
156
+ const rows = params.workspaceId === undefined
157
+ ? await this.conversations.list()
158
+ : await this.conversations.listByWorkspace(params.workspaceId);
159
+ const filtered = params.pinned === undefined
160
+ ? rows
161
+ : rows.filter((row) => row.pinned === params.pinned);
162
+ const offset = params.offset === undefined ? 0 : Math.max(params.offset, 0);
163
+ const page = params.limit === undefined
164
+ ? filtered.slice(offset)
165
+ : filtered.slice(offset, offset + Math.max(params.limit, 0));
166
+ return { conversations: await this.summarize(page), total: filtered.length };
167
+ }
168
+ /**
169
+ * 开一段新对话(PRD F-C7 / L7 / R18 / R56)。
170
+ *
171
+ * 项目与助手在这里定死:项目就是入参的那个已登记项目(项目只走显式添加,PRD D10),助手从
172
+ * 磁盘上的定义文件解析。模型的值也在这里算出来(SDK 的三级解析 `会话级 > 助手定义 > 配置里的
173
+ * 兜底`,唯一实现在 `resolveModel`),但它落的是**会话行**:之后可以由 `setModel` 改。解析不
174
+ * 出来就报错,不写半成品行 —— 一条没有助手或没有模型的对话没有可用的身份。
175
+ *
176
+ * **这一行现在不写库**:只把 id 与身份留在草稿里(R56 / F-C2「没有用户消息就不写」)。
177
+ * 首次 `conversation.send` 时由 {@link ensurePersisted} 用同一个 id 落库。
178
+ */
179
+ async create(params) {
180
+ const workspace = await this.requireWorkspace(requireId(params.workspaceId, "workspaceId"));
181
+ const config = await this.readConfig();
182
+ const agent = await this.readAgent(requireId(params.agentId, "agentId"));
183
+ const sessionModelId = params.modelId == null ? undefined : requireId(params.modelId, "modelId");
184
+ const resolved = resolveModel({
185
+ sessionModelId,
186
+ agentModelId: agent.modelId,
187
+ defaultModel: config.defaultModel,
188
+ });
189
+ if (!resolved) {
190
+ // 三级解析都没拿到模型:「给这个助手挑一个模型」那条文案(R38),结构化原因在 detail 里
191
+ throw new HostError("config_invalid", {
192
+ messageKey: "error.modelUnresolved",
193
+ detail: { agentId: agent.id, defaultModel: config.defaultModel ?? null },
194
+ });
195
+ }
196
+ const model = config.models.find((item) => item.id === resolved.modelId);
197
+ if (!model) {
198
+ // 调用方点名要的模型不存在 → 找不到;配置自己指着不存在的模型 → 解析出来的模型用不了
199
+ if (resolved.level === "session") {
200
+ throw new HostError("not_found", {
201
+ params: { entity: "model" },
202
+ detail: { modelId: resolved.modelId, level: resolved.level },
203
+ });
204
+ }
205
+ throw new HostError("config_invalid", {
206
+ messageKey: "error.modelUnresolved",
207
+ detail: { modelId: resolved.modelId, level: resolved.level, agentId: agent.id },
208
+ });
209
+ }
210
+ const createdAt = this.now();
211
+ const draft = {
212
+ id: randomUUID(),
213
+ workspaceId: workspace.id,
214
+ name: autoName(createdAt),
215
+ nameIsAuto: true,
216
+ pinned: false,
217
+ agentId: agent.id,
218
+ modelId: model.id,
219
+ // 新建的会话不选档位:思考强度跟随助手定义上的那份(F-B6)
220
+ thinkingLevel: null,
221
+ createdAt,
222
+ updatedAt: createdAt,
223
+ };
224
+ this.drafts.put(draft);
225
+ const context = await this.viewContext(config, [[workspace.id, workspace]], [[agent.id, agent]]);
226
+ const { view } = await this.composeView(draft, context);
227
+ return { conversation: view };
228
+ }
229
+ /**
230
+ * 把草稿落成 `conversations` 的一行(R56:首次 `send` 时用同一个 id 写)。
231
+ *
232
+ * 已经在库里就直接返回那一行(重复调用是幂等的);草稿不在、库里也没这一行就是
233
+ * `not_found`。先把草稿摘掉再写库:两次并发的调用里后到的那一次就抢不到同一份草稿,
234
+ * 不会两次插同一行;写失败时把草稿放回去,下一次还能再试。
235
+ *
236
+ * 但「抢不到草稿」不等于「这段对话不存在」:同一个 id 的并发调用(例如一次拖进四张图,
237
+ * 每个上传都在 `asset.begin` 里调一次)在这里排队共用同一次写入,免得后到的那次误报
238
+ * `not_found`、也免得那条隐藏的 cwd 消息被写两遍。
239
+ *
240
+ * 落行那一刻同时写上这段对话的第一条消息:一条 `hidden` 的 `user` 消息,告诉模型这段对话
241
+ * 对应的 cwd(PRD F-D9)。它最先写,所以 `seq` 在真实用户消息之前;之后每轮都不再重写。
242
+ * cwd 从这段对话的 Workspace 取(不是进程工作目录);`workspaceId` 建会话时就定死了,
243
+ * 这里只读不改。
244
+ *
245
+ * `language` 是这条消息正文的渲染语言(D8:调用方传发起这条连接的语言;不传时用缺省语言)。
246
+ */
247
+ async ensurePersisted(conversationId, language = DEFAULT_LANGUAGE) {
248
+ const id = requireId(conversationId, "conversationId");
249
+ const existing = await this.conversations.get(id);
250
+ if (existing) {
251
+ this.drafts.forget(id);
252
+ return existing;
253
+ }
254
+ const running = this.persisting.get(id);
255
+ if (running)
256
+ return await running;
257
+ const task = this.persistDraft(id, language);
258
+ this.persisting.set(id, task);
259
+ try {
260
+ return await task;
261
+ }
262
+ finally {
263
+ this.persisting.delete(id);
264
+ }
265
+ }
266
+ /** 草稿 → 库里那一行(含那条隐藏的 cwd 消息);并发去重由 {@link ensurePersisted} 负责 */
267
+ async persistDraft(id, language) {
268
+ const draft = this.drafts.take(id);
269
+ if (!draft) {
270
+ throw new HostError("not_found", {
271
+ params: { entity: "conversation" },
272
+ detail: { conversationId: id },
273
+ });
274
+ }
275
+ try {
276
+ await this.conversations.create({
277
+ id: draft.id,
278
+ workspaceId: draft.workspaceId,
279
+ name: draft.name,
280
+ agentId: draft.agentId,
281
+ modelId: draft.modelId,
282
+ // 草稿期间用户先调过档的话,落库时跟着写进去
283
+ thinkingLevel: draft.thinkingLevel,
284
+ createdAt: draft.createdAt,
285
+ updatedAt: draft.updatedAt,
286
+ });
287
+ await this.writeCwdMessage(draft, language);
288
+ // 新行默认是「自动命名、未置顶」;草稿期间用户改过名 / 置过顶的,落库时补写一次
289
+ if (draft.nameIsAuto === false) {
290
+ await this.conversations.renameByUser(draft.id, draft.name, draft.updatedAt);
291
+ }
292
+ if (draft.pinned) {
293
+ await this.conversations.setPinned(draft.id, true, draft.updatedAt);
294
+ }
295
+ }
296
+ catch (error) {
297
+ this.drafts.put(draft);
298
+ throw error;
299
+ }
300
+ const row = await this.conversations.get(id);
301
+ if (!row) {
302
+ throw new HostError("internal", {
303
+ detail: { reason: "conversation vanished after insert", id },
304
+ });
305
+ }
306
+ // 草稿变成真行那一刻才第一次出现在其它窗口的侧栏里,所以这里补一条 `created`
307
+ this.emitChanged(row.id, row.workspaceId, "created", 0);
308
+ return row;
309
+ }
310
+ /**
311
+ * 写会话开头那条 `hidden` 的 cwd 消息(PRD F-D9)。
312
+ *
313
+ * 它是一条普通的 `user` 消息,只是 `hidden` 为真:模型在上下文里能看到它,界面上不展示
314
+ * 它(core 的 `Message.hidden`)。内容用的是 i18n 里那两条既有键的拼装(与临时对话共用
315
+ * `injectedContextOf`)。
316
+ *
317
+ * cwd 来自这段对话所属的 Workspace(PRD D9:一段对话绑一个项目,cwd 创建即定死),
318
+ * 不读进程工作目录。
319
+ */
320
+ async writeCwdMessage(draft, language) {
321
+ const workspace = await this.workspaces.get(draft.workspaceId);
322
+ if (!workspace) {
323
+ // 行已经写进去了、外键指着它,所以这里只可能是库被别处改坏了
324
+ throw new HostError("internal", {
325
+ detail: { reason: "workspace_missing", workspaceId: draft.workspaceId },
326
+ });
327
+ }
328
+ await this.messages.upsert({
329
+ id: randomUUID(),
330
+ conversationId: draft.id,
331
+ role: AgentNS.Role.User,
332
+ status: AgentNS.MessageStatus.Completed,
333
+ omit: false,
334
+ content: injectedContextOf(workspace.cwd, language),
335
+ data: { hidden: true },
336
+ createdAt: this.now(),
337
+ });
338
+ }
339
+ /** 按 id 打开一段对话(PRD F-A6:宿主只提供「按 id 打开」) */
340
+ async get(params) {
341
+ const conversation = await this.requireConversation(params.conversationId);
342
+ const { view } = await this.composeView(conversation, await this.viewContext(await this.readConfig()));
343
+ return { conversation: view };
344
+ }
345
+ /** 改名(PRD D1 / F-C3):只换名字,id、身份与排序都不动;名字从此归用户(R35) */
346
+ async rename(params) {
347
+ const conversation = await this.requireConversation(params.conversationId);
348
+ const name = requireId(params.name, "name");
349
+ const updatedAt = this.now();
350
+ const draft = this.drafts.get(conversation.id);
351
+ if (draft) {
352
+ this.drafts.put({ ...draft, name, nameIsAuto: false, updatedAt });
353
+ }
354
+ else {
355
+ await this.conversations.renameByUser(conversation.id, name, updatedAt);
356
+ }
357
+ const view = await this.reloadView(conversation.id);
358
+ this.emitChanged(view.id, view.workspaceId, "renamed", view.messageCount);
359
+ return { conversation: view };
360
+ }
361
+ /** 手动置顶(PRD F-C5 / D13):只动 `pinned`,不碰创建时间 */
362
+ async pin(params) {
363
+ const conversation = await this.requireConversation(params.conversationId);
364
+ if (typeof params.pinned !== "boolean") {
365
+ throw new HostError("invalid_params", {
366
+ params: { field: "pinned" },
367
+ detail: { field: "pinned", reason: "not_boolean" },
368
+ });
369
+ }
370
+ const updatedAt = this.now();
371
+ const draft = this.drafts.get(conversation.id);
372
+ if (draft) {
373
+ this.drafts.put({ ...draft, pinned: params.pinned, updatedAt });
374
+ }
375
+ else {
376
+ await this.conversations.setPinned(conversation.id, params.pinned, updatedAt);
377
+ }
378
+ const view = await this.reloadView(conversation.id);
379
+ this.emitChanged(view.id, view.workspaceId, "pinned", view.messageCount);
380
+ return { conversation: view };
381
+ }
382
+ /**
383
+ * 硬删一段对话(PRD F-C10 / D17 / F-F9 / R7)。
384
+ *
385
+ * `dryRun` 先算账(消息数、素材数),客户端据此警告用户。真删是 delete 语句 + 外键级联:
386
+ * 消息行、问题行、素材的引用关系(`conversation_assets`)随对话一起消失。素材行与 `assets/`
387
+ * 下的文件不一定:那张图可能还有别的对话在用,所以把「这段对话引用过哪几份素材」交给
388
+ * asset 层在删完之后再问一次「还剩几张引用」,归零才删(PRD D17「图片按引用清理」)。
389
+ *
390
+ * 还没落库的草稿也能删:没有行、没有消息、没有素材,代价是零。
391
+ */
392
+ async delete(params) {
393
+ const conversation = await this.requireConversation(params.conversationId);
394
+ const draft = this.drafts.get(conversation.id);
395
+ if (draft) {
396
+ const draftConsequences = { conversations: 1, messages: 0, assets: 0 };
397
+ if (params.dryRun === true) {
398
+ return { removed: false, consequences: draftConsequences, assetIds: [] };
399
+ }
400
+ this.drafts.forget(conversation.id);
401
+ await this.runtime.forget(conversation.id);
402
+ this.emitChanged(conversation.id, draft.workspaceId, "deleted", null);
403
+ return { removed: true, consequences: draftConsequences, assetIds: [] };
404
+ }
405
+ const assetRows = await this.assets.listByConversation(conversation.id);
406
+ const consequences = {
407
+ conversations: 1,
408
+ // 这个数来自 `countByConversation`:库里这段对话的全部消息行,**含子 Agent 的消息**
409
+ // (它们落在同一张表、`parentToolCallId` 非空),但**不含 `hidden` 的**(会话创建时那条
410
+ // cwd 消息、交接摘要)。所以它会比侧栏摘要里的 `messageCount` 大:那个数来自
411
+ // `listByConversation`,只数主线。两者是两个不同的量(这一处是「删掉会少多少行」的代价,
412
+ // 那一处是「侧栏显示几句话」),不互相换算。
413
+ messages: await this.messages.countByConversation(conversation.id),
414
+ assets: assetRows.length,
415
+ };
416
+ const assetIds = assetRows.map((asset) => asset.id);
417
+ if (params.dryRun === true) {
418
+ return { removed: false, consequences, assetIds };
419
+ }
420
+ await this.runtime.forget(conversation.id);
421
+ const changes = await this.conversations.delete(conversation.id);
422
+ const removed = changes > 0;
423
+ if (removed) {
424
+ // 关系行已经随外键没了:现在问「还剩几张引用」,归零的那几份才删字节
425
+ await this.sweepAssets?.(assetIds);
426
+ this.emitChanged(conversation.id, conversation.workspaceId, "deleted", null);
427
+ }
428
+ return { removed, consequences, assetIds };
429
+ }
430
+ /**
431
+ * 撤回(PRD F-C8 / D24 / R10)。
432
+ *
433
+ * 撤回到某条**用户消息之前**:那条消息及其后全部硬删,被撤回的内容按 core 的内容形状
434
+ * 回给客户端填回输入框(只有纯文本段 + 当时带的素材 id,R62 / F-H3「带图消息连图一起
435
+ * 恢复」)。不留删除痕迹(R10);正在跑一轮时拒绝,不然删的正是流式还在写的那几条。
436
+ */
437
+ async revert(params) {
438
+ const row = await this.requireConversation(params.conversationId);
439
+ const anchorId = requireId(params.beforeMessageId, "beforeMessageId");
440
+ if (this.runtime.isRunning(row.id)) {
441
+ throw new HostError("busy", { detail: { conversationId: row.id, action: "revert" } });
442
+ }
443
+ const anchorRow = await this.messages.get(anchorId);
444
+ if (!anchorRow || anchorRow.conversationId !== row.id) {
445
+ throw new HostError("not_found", {
446
+ params: { entity: "message" },
447
+ detail: { messageId: anchorId, conversationId: row.id },
448
+ });
449
+ }
450
+ const anchor = toAgentMessage(anchorRow);
451
+ if (anchor.role !== AgentNS.Role.User) {
452
+ throw new HostError("invalid_params", {
453
+ params: { field: "beforeMessageId" },
454
+ detail: { field: "beforeMessageId", reason: "not_a_user_message", role: anchor.role },
455
+ });
456
+ }
457
+ // 锚点已确认存在,所以这次取到的就是「从它开始(含它)」的那一段(A12)
458
+ const doomed = await this.messages.listByConversation(row.id, anchor.id);
459
+ const removedMessageIds = doomed.map((message) => message.id);
460
+ /**
461
+ * 回填输入框要挂的素材(R62:只给纯文本 + assetIds,图靠 id 复原)。
462
+ *
463
+ * 两个来源合并:
464
+ * - `conversation_assets`:消息落库时记下的关系行(这条消息与它之前几轮引用过的都在里面);
465
+ * - 消息内容里的 `__asset_id`:同一张图可以被好几条消息引用,而关系行是按消息记的 ——
466
+ * 这条消息自己挂了哪几张,content 永远是那个事实。
467
+ */
468
+ const linkedIds = await this.assets.listAssetIdsByMessage(anchor.id);
469
+ const assetIds = [...new Set([...linkedIds, ...assetIdsOf(anchor.content)])];
470
+ const content = textOnlyContent(anchor.content);
471
+ const restoredInput = assetIds.length > 0
472
+ ? { content, assetIds }
473
+ : { content };
474
+ // 关系行随消息级联消失:先记下这段区间的消息引用过哪些素材,删完再看谁归零了
475
+ const doomedAssetIds = await this.collectAssetsOfMessages(removedMessageIds);
476
+ await this.messages.deleteAfter(row.id, anchor.id, { includeSelf: true });
477
+ await this.sweepAssets?.(doomedAssetIds);
478
+ const remaining = (await this.messages.listByConversation(row.id)).length;
479
+ this.emitChanged(row.id, row.workspaceId, "message_removed", remaining);
480
+ return { removedMessageIds, restoredInput };
481
+ }
482
+ /**
483
+ * 换这段会话的模型(PRD F-B4 / F-B8 / D20)。
484
+ *
485
+ * 写的是**会话行**上的 `modelId`:这一段对话以后都用新模型,重开、重启都还是它。换模型
486
+ * 同时把这段会话的思考档位清成「跟随助手定义」—— 档位是模型的档位,同一个名字在不同
487
+ * 模型上对应不同参数,留着就变成把旧模型的参数发给新模型。
488
+ *
489
+ * `modelId: null` 表示回到助手定义上那个模型(它也没绑时用配置里的兜底)。
490
+ * 历史里已经有图、而新模型不看图时拒绝(D20),并说明原因。
491
+ */
492
+ async setModel(params) {
493
+ const row = await this.requireConversation(params.conversationId);
494
+ const config = await this.readConfig();
495
+ const modelId = params.modelId == null
496
+ ? await this.agentModelIdOf(row.agentId, config)
497
+ : requireId(params.modelId, "modelId");
498
+ const model = config.models.find((item) => item.id === modelId);
499
+ if (!model) {
500
+ throw new HostError("not_found", {
501
+ params: { entity: "model" },
502
+ detail: { modelId },
503
+ });
504
+ }
505
+ if (model.vision !== true && (await this.hasImages(row.id))) {
506
+ throw new HostError("model_unsupported", {
507
+ params: { model: model.name },
508
+ detail: { modelId: model.id, conversationId: row.id, reason: "history_has_images" },
509
+ });
510
+ }
511
+ const updated = model.id === row.modelId ? row : await this.writeModel(row, model.id);
512
+ const { view, context } = await this.composeView(updated, await this.viewContext(config));
513
+ // 换了的模型会出现在别的窗口的状态栏上(L5 / D22),推一条让它们去拉
514
+ if (updated.modelId !== row.modelId) {
515
+ this.emitChanged(row.id, row.workspaceId, "settings_changed", null);
516
+ }
517
+ return { conversation: view, model: view.model, context };
518
+ }
519
+ /**
520
+ * 调这段会话的思考档位(PRD F-B6 / F-B7)。
521
+ *
522
+ * 写的是档位名(不是展开后的参数):档位属于模型,同一个名字在不同模型的策略表里对应
523
+ * 不同参数。`level: null` 表示回到「跟随助手定义」。改助手默认档是另一个动作
524
+ * (`agent.setThinking`)。
525
+ *
526
+ * 档位名必须在该模型引用的策略表里 —— 表里没有的档发出去只会换来一句 400;模型没有
527
+ * 可调档位时整件事不成立(`unsupported`)。
528
+ */
529
+ async setThinking(params) {
530
+ const row = await this.requireConversation(params.conversationId);
531
+ const context = await this.viewContext(await this.readConfig());
532
+ const model = this.effectiveModel(row, context.config);
533
+ if (!model) {
534
+ throw new HostError("not_found", {
535
+ params: { entity: "model" },
536
+ detail: { modelId: row.modelId },
537
+ });
538
+ }
539
+ const preset = this.presetOf(model, context.presets);
540
+ if (!preset) {
541
+ throw new HostError("unsupported", {
542
+ params: { what: "thinking levels for this model" },
543
+ detail: { modelId: model.id, preset: model.reasoningPreset ?? null },
544
+ });
545
+ }
546
+ const level = params.level == null ? null : requireId(params.level, "level");
547
+ if (level !== null && !Object.prototype.hasOwnProperty.call(preset.levels, level)) {
548
+ throw new HostError("invalid_params", {
549
+ params: { field: "level" },
550
+ detail: { field: "level", level, levels: Object.keys(preset.levels) },
551
+ });
552
+ }
553
+ const updated = await this.writeThinkingLevel(row, level);
554
+ const agent = await this.loadAgent(row.agentId, context);
555
+ this.emitChanged(row.id, row.workspaceId, "settings_changed", null);
556
+ return { thinking: this.buildThinking(updated, model, agent, context.presets) };
557
+ }
558
+ /**
559
+ * 把一批对话行拼成侧栏用的摘要(列表与搜索共用同一份口径)。
560
+ *
561
+ * `nameIsAuto` 直接读库里的那一列(R35):它是「这个名字是不是自动起的」的唯一真值,
562
+ * 不由名字内容反推。
563
+ */
564
+ async summarize(rows) {
565
+ const summaries = [];
566
+ for (const row of rows) {
567
+ const stored = await this.messages.listByConversation(row.id);
568
+ const pending = await this.questions.listByConversation(row.id, "pending");
569
+ summaries.push(this.toSummary(row, stored, pending.length));
570
+ }
571
+ return summaries;
572
+ }
573
+ /** 一行对话 → 侧栏摘要;`pendingCount` 是挂在这段对话上还没答的问题数 */
574
+ toSummary(row, stored, pendingCount) {
575
+ return {
576
+ id: row.id,
577
+ workspaceId: row.workspaceId,
578
+ name: row.name,
579
+ nameIsAuto: row.nameIsAuto,
580
+ pinned: row.pinned,
581
+ agentId: row.agentId ?? "",
582
+ modelId: row.modelId ?? "",
583
+ createdAt: iso(row.createdAt),
584
+ updatedAt: iso(row.updatedAt),
585
+ // 这个数来自 `listByConversation` 的长度:只有**主线**消息(不含子 Agent 的,那些在同一个
586
+ // 表里 `parentToolCallId` 非空),也不含 `hidden` 的。删除代价(`consequences.messages`)用的是另一条读数
587
+ // `countByConversation`,数的是表里的全部行(只筛 `hidden`),所以同一段对话这两处数字可以不等。
588
+ messageCount: stored.length,
589
+ lastMessageAt: stored.length === 0 ? null : iso(latestCreatedAt(stored)),
590
+ running: this.runtime.isRunning(row.id),
591
+ hasPendingQuestion: pendingCount > 0,
592
+ };
593
+ }
594
+ /** 拼一段对话的完整形状,顺带把 `setModel` 要的上下文占用一起算出来 */
595
+ async composeView(row, context) {
596
+ const stored = await this.messages.listByConversation(row.id);
597
+ const pending = await this.questions.listByConversation(row.id, "pending");
598
+ const workspace = await this.loadWorkspace(row.workspaceId, context);
599
+ const agent = await this.loadAgent(row.agentId, context);
600
+ const model = this.effectiveModel(row, context.config);
601
+ const modelState = {
602
+ modelId: model?.id ?? row.modelId ?? "",
603
+ // 模型可能在配置里被删掉了:行上的 id 仍然有效(它是这段会话现在用的那个),显示名退回 id
604
+ name: model?.name ?? row.modelId ?? "",
605
+ vision: model?.vision === true,
606
+ source: this.modelSource(row, agent, context.config),
607
+ };
608
+ const view = {
609
+ ...this.toSummary(row, stored, pending.length),
610
+ model: modelState,
611
+ thinking: this.buildThinking(row, model, agent, context.presets),
612
+ folder: await toFolderFact(workspace.cwd),
613
+ };
614
+ return { view, context: buildContext(model, lastUsage(stored)) };
615
+ }
616
+ /** 改完之后重新读一遍再拼视图:改名与置顶都要回写后的完整对象 */
617
+ async reloadView(conversationId) {
618
+ const row = await this.requireConversation(conversationId);
619
+ const { view } = await this.composeView(row, await this.viewContext(await this.readConfig()));
620
+ return view;
621
+ }
622
+ /**
623
+ * 推一条 `conversation.changed`(L5 / D22:两个窗口同时开着时,对方界面靠它去拉)。
624
+ *
625
+ * 只报「什么变了 + 够用的定位信息」,不搬整个对象(architecture §7)。
626
+ */
627
+ emitChanged(conversationId, workspaceId, change, messageCount) {
628
+ this.emit?.("conversation.changed", {
629
+ conversationId,
630
+ workspaceId,
631
+ change,
632
+ messageCount,
633
+ at: iso(this.now()),
634
+ });
635
+ }
636
+ /**
637
+ * 读配置。
638
+ *
639
+ * `config.json` 是手改得了的文件(F-G3 的同款思路):它坏了要让用户看见「配置用不了 +
640
+ * 哪一步读不动」,而不是一句「宿主内部错误」——所以解析失败在这里就折成 `config_invalid`。
641
+ */
642
+ async readConfig() {
643
+ try {
644
+ return await this.configManager.read();
645
+ }
646
+ catch (error) {
647
+ throw new HostError("config_invalid", {
648
+ params: { reason: error instanceof Error ? error.message : String(error) },
649
+ detail: { path: this.configManager.configPath },
650
+ cause: error,
651
+ });
652
+ }
653
+ }
654
+ async viewContext(config, workspaces = [], agents = []) {
655
+ return {
656
+ config,
657
+ presets: await this.configManager.readReasoningPresets(),
658
+ workspaces: new Map(workspaces),
659
+ agents: new Map(agents),
660
+ };
661
+ }
662
+ /**
663
+ * 按 id 取一段对话:先看库里那一行,再看草稿(R56)。两者用同一份行形状,
664
+ * 于是视图拼装、换模型、写档位这些路径对草稿一字不改。
665
+ */
666
+ async requireConversation(conversationId) {
667
+ const id = typeof conversationId === "string" ? conversationId.trim() : "";
668
+ const row = id.length === 0 ? undefined : await this.conversations.get(id);
669
+ if (row)
670
+ return row;
671
+ const draft = id.length === 0 ? undefined : this.drafts.get(id);
672
+ if (draft)
673
+ return draft;
674
+ throw new HostError("not_found", {
675
+ params: { entity: "conversation" },
676
+ detail: { conversationId },
677
+ });
678
+ }
679
+ async requireWorkspace(workspaceId) {
680
+ const id = typeof workspaceId === "string" ? workspaceId.trim() : "";
681
+ const row = id.length === 0 ? undefined : await this.workspaces.get(id);
682
+ if (!row) {
683
+ throw new HostError("not_found", {
684
+ params: { entity: "workspace" },
685
+ detail: { workspaceId },
686
+ });
687
+ }
688
+ return row;
689
+ }
690
+ async loadWorkspace(workspaceId, context) {
691
+ const cached = context.workspaces.get(workspaceId);
692
+ if (cached)
693
+ return cached;
694
+ const row = await this.workspaces.get(workspaceId);
695
+ if (!row) {
696
+ throw new HostError("not_found", {
697
+ params: { entity: "workspace" },
698
+ detail: { workspaceId },
699
+ });
700
+ }
701
+ context.workspaces.set(workspaceId, row);
702
+ return row;
703
+ }
704
+ async loadAgent(agentId, context) {
705
+ const cached = context.agents.get(agentId);
706
+ if (cached !== undefined)
707
+ return cached;
708
+ const agent = await this.readAgentOrNull(agentId);
709
+ context.agents.set(agentId, agent);
710
+ return agent;
711
+ }
712
+ /** 读一个助手定义文件;不存在就是「没有这个助手」(PRD F-B5:不可用的也要说明原因) */
713
+ async readAgent(agentId) {
714
+ const agent = await this.readAgentOrNull(agentId);
715
+ if (!agent) {
716
+ throw new HostError("not_found", {
717
+ params: { entity: "assistant" },
718
+ detail: { agentId },
719
+ });
720
+ }
721
+ return agent;
722
+ }
723
+ async readAgentOrNull(agentId) {
724
+ try {
725
+ return await this.agents.read(agentId);
726
+ }
727
+ catch (error) {
728
+ // 仓储会挡掉逃逸出 agents/ 目录的 id(抛的是普通 Error),这里转成参数错误
729
+ throw new HostError("invalid_params", {
730
+ params: { field: "agentId" },
731
+ detail: {
732
+ field: "agentId",
733
+ agentId,
734
+ reason: error instanceof Error ? error.message : String(error),
735
+ },
736
+ });
737
+ }
738
+ }
739
+ /**
740
+ * 这段对话**现在**用哪个模型:就是会话行上那个 `modelId`。
741
+ *
742
+ * 它可能已经从配置里删掉了 —— 那时 `model` 为空,调用方按 ID 显示。
743
+ */
744
+ effectiveModel(row, config) {
745
+ return config.models.find((item) => item.id === row.modelId);
746
+ }
747
+ /**
748
+ * 这个模型是谁定的(PRD F-B4 两个动作的区别)。
749
+ *
750
+ * 会话行上的模型与助手定义绑的那个一致时算「跟着助手走」;否则算「会话自己定的」
751
+ * (创建时点名,或 `setModel` 改过)。助手没绑模型时它实际上用配置里的兜底,那一档也算
752
+ * 「跟着助手走」:装配时用的参数口径与这里同一个判断(见 `ChatRunner` 的 `conversationModelParams`)。
753
+ */
754
+ modelSource(row, agent, config) {
755
+ const agentModelId = agent?.modelId ?? config.defaultModel;
756
+ return agentModelId !== undefined && agentModelId === row.modelId
757
+ ? "agent"
758
+ : "conversation";
759
+ }
760
+ /** 助手定义上那个模型(`/model` 不带 id 时回到它);它没绑且没有兜底时报错 */
761
+ async agentModelIdOf(agentId, config) {
762
+ const agent = await this.readAgentOrNull(agentId);
763
+ const resolved = resolveModel({
764
+ agentModelId: agent?.modelId,
765
+ defaultModel: config.defaultModel,
766
+ });
767
+ if (!resolved) {
768
+ throw new HostError("config_invalid", {
769
+ messageKey: "error.modelUnresolved",
770
+ detail: { agentId, defaultModel: config.defaultModel ?? null },
771
+ });
772
+ }
773
+ return resolved.modelId;
774
+ }
775
+ /**
776
+ * 把模型写进会话(草稿还没落库时写进草稿,落库那一刻跟着写进去)。
777
+ *
778
+ * 与仓储的 `setModel` 一个口径:档位跟着清掉(档位是模型的档位)。
779
+ */
780
+ async writeModel(row, modelId) {
781
+ const updatedAt = this.now();
782
+ const draft = this.drafts.get(row.id);
783
+ if (draft) {
784
+ const next = { ...draft, modelId, thinkingLevel: null, updatedAt };
785
+ this.drafts.put(next);
786
+ return next;
787
+ }
788
+ await this.conversations.setModel(row.id, modelId, updatedAt);
789
+ return { ...row, modelId, thinkingLevel: null, updatedAt };
790
+ }
791
+ /** 把思考档位写进会话(`null` = 回到跟随助手定义),回写后的行 */
792
+ async writeThinkingLevel(row, thinkingLevel) {
793
+ const updatedAt = this.now();
794
+ const draft = this.drafts.get(row.id);
795
+ if (draft) {
796
+ const next = { ...draft, thinkingLevel, updatedAt };
797
+ this.drafts.put(next);
798
+ return next;
799
+ }
800
+ await this.conversations.setThinkingLevel(row.id, thinkingLevel, updatedAt);
801
+ return { ...row, thinkingLevel, updatedAt };
802
+ }
803
+ /** 模型引用的思考策略(表里没有、或没有档位,就是「这个模型不可调」,F-B7) */
804
+ presetOf(model, presets) {
805
+ const name = model.reasoningPreset;
806
+ if (!name)
807
+ return undefined;
808
+ const preset = presets[name];
809
+ return preset && Object.keys(preset.levels).length > 0 ? preset : undefined;
810
+ }
811
+ /**
812
+ * 思考强度状态(PRD F-B6 / F-B7)。
813
+ *
814
+ * 三种来源:会话行上选了某一档(`manual`);没选、而助手定义上的参数正好是某一档
815
+ * (`following_default`);模型没有思考策略(`not_adjustable`)。
816
+ * 行上那个档位名在当前模型的策略表里不存在(换过模型、或策略表被改)时退回「跟随助手」。
817
+ */
818
+ buildThinking(row, model, agent, presets) {
819
+ const presetName = model?.reasoningPreset ?? null;
820
+ const preset = model ? this.presetOf(model, presets) : undefined;
821
+ if (!preset) {
822
+ return {
823
+ preset: presetName,
824
+ levels: [],
825
+ selected: null,
826
+ source: "not_adjustable",
827
+ params: {},
828
+ };
829
+ }
830
+ const levels = Object.keys(preset.levels);
831
+ const chosen = row.thinkingLevel;
832
+ if (chosen !== null && Object.prototype.hasOwnProperty.call(preset.levels, chosen)) {
833
+ return {
834
+ preset: presetName,
835
+ levels,
836
+ selected: chosen,
837
+ source: "manual",
838
+ params: preset.levels[chosen] ?? {},
839
+ };
840
+ }
841
+ const detected = levels.find((level) => matchesPatch(agent?.modelParams, preset.levels[level] ?? {}));
842
+ return {
843
+ preset: presetName,
844
+ levels,
845
+ selected: detected ?? null,
846
+ source: "following_default",
847
+ params: detected ? preset.levels[detected] ?? {} : {},
848
+ };
849
+ }
850
+ /** 历史里有没有图(D20 的依据:消息内容里的图片段,或这段对话名下带图的素材) */
851
+ async hasImages(conversationId) {
852
+ const assets = await this.assets.listByConversation(conversationId);
853
+ if (assets.some((asset) => asset.mime.startsWith("image/")))
854
+ return true;
855
+ const stored = await this.messages.listByConversation(conversationId);
856
+ return stored.some((message) => contentHasImage(toAgentMessage(message).content));
857
+ }
858
+ /** 一批消息引用过的素材 id(删消息之前读一次:关系行随消息级联消失) */
859
+ async collectAssetsOfMessages(messageIds) {
860
+ const ids = new Set();
861
+ for (const messageId of messageIds) {
862
+ for (const assetId of await this.assets.listAssetIdsByMessage(messageId))
863
+ ids.add(assetId);
864
+ }
865
+ return [...ids];
866
+ }
867
+ }
868
+ /** 一批消息里最晚的创建时间(消息顺序按 seq,时间可能同毫秒,取最大值最稳) */
869
+ function latestCreatedAt(stored) {
870
+ return stored.reduce((latest, message) => (message.createdAt > latest ? message.createdAt : latest), 0);
871
+ }
872
+ /** 最近一次请求的用量:从后往前找第一条带 `usage` 的消息(assistant 才有,core 口径) */
873
+ function lastUsage(stored) {
874
+ for (let index = stored.length - 1; index >= 0; index -= 1) {
875
+ const usage = readUsage(stored[index]?.data);
876
+ if (usage)
877
+ return usage;
878
+ }
879
+ return { ...ZERO_USAGE };
880
+ }
881
+ /** 消息内容里是否有图片段(core 的 `image_url` 段) */
882
+ function contentHasImage(content) {
883
+ if (!content || typeof content === "string")
884
+ return false;
885
+ return content.some((section) => section.type === "image_url");
886
+ }
887
+ /**
888
+ * 上下文占用(PRD F-D1 / D27):最近一次请求的用量 + 这个模型的两条阈值。
889
+ *
890
+ * 阈值跟着模型走,所以换模型之后要用新模型的阈值重算;比例由客户端自己算。
891
+ */
892
+ function buildContext(model, usage) {
893
+ const handoffThreshold = model?.tokenThresholds.handoff ?? 0;
894
+ const guardThreshold = model?.tokenThresholds.guard ?? 0;
895
+ return {
896
+ usage,
897
+ handoffThreshold,
898
+ guardThreshold,
899
+ overHandoff: handoffThreshold > 0 && usage.prompt_tokens >= handoffThreshold,
900
+ overGuard: guardThreshold > 0 && usage.prompt_tokens >= guardThreshold,
901
+ };
902
+ }