@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.1

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 +22 -0
  7. package/dist/cli.js +354 -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,489 @@
1
+ /**
2
+ * 模型服务:模型清单、图片模型、模型增删改、恢复出厂(F-F3 / F-F4 / F-F7 / D6 / D27)。
3
+ *
4
+ * 配置只有一处真相(D21):`config.json`,读写都经 SDK 的 `ConfigManager`。这个服务不缓存配置,
5
+ * 每次调用现读现写 —— 配置是几百行的 JSON,读一次比「缓存与磁盘谁更新」的排查便宜得多。
6
+ *
7
+ * 两条与产品行为直接相关的口径:
8
+ *
9
+ * - **编辑即转自定义**(F-F3):改过的模型要打上 `custom: true`,否则 SDK 下次初始化会拿出厂
10
+ * 定义把它冲回去(`syncManagedModels` 只保留 `custom: true` 的条目)。
11
+ * - **删除要算引用**(F-F2 / F-F7):被助手引用的模型不能删,并且要列出「还有谁指它」;
12
+ * 默认项指向被删的那个模型时要改指或清空,不留悬空引用。
13
+ */
14
+ import { DEFAULT_APP_CONFIG, } from "@ai-zen/agents-sdk";
15
+ import { DEFAULT_LANGUAGE, render } from "../i18n/index.js";
16
+ import { HostError } from "../wire/errors.js";
17
+ // ---------------------------------------------------------------------------
18
+ // 文案
19
+ // ---------------------------------------------------------------------------
20
+ /** 一条给人看的事实(码 = 文案键,与素材层同口径:存码不存句子,D8) */
21
+ function text(messageKey, params = {}) {
22
+ return {
23
+ code: messageKey,
24
+ messageKey,
25
+ params,
26
+ message: render(DEFAULT_LANGUAGE, messageKey, params),
27
+ };
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // 出厂默认的两条阈值
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * 新建模型不传阈值时套的默认档(F-F3)。
34
+ *
35
+ * 唯一来源是 SDK 出厂配置里各模型的那份 `tokenThresholds`,这里不另写数字:取清单里
36
+ * **用得最多的那一档**(出厂清单里绝大多数模型的窗口都很大,用的是同一档;只有一个
37
+ * 200K 窗口的模型例外,那档给新模型既过度保守也说不通)。出厂清单改了,这里跟着改。
38
+ */
39
+ function factoryDefaultThresholds() {
40
+ const tally = new Map();
41
+ for (const model of DEFAULT_APP_CONFIG.models) {
42
+ const { handoff, guard } = model.tokenThresholds;
43
+ const key = `${handoff}/${guard}`;
44
+ const seen = tally.get(key);
45
+ if (seen)
46
+ seen.hits += 1;
47
+ else
48
+ tally.set(key, { value: { handoff, guard }, hits: 1 });
49
+ }
50
+ let winner;
51
+ for (const candidate of tally.values()) {
52
+ if (winner === undefined || candidate.hits > winner.hits)
53
+ winner = candidate;
54
+ }
55
+ // 出厂清单为空是 SDK 侧的不变量被破坏;当场报出来,不默默写出一个没有阈值的模型
56
+ if (winner === undefined)
57
+ throw new Error("SDK 的出厂配置里没有任何模型,拿不到默认阈值");
58
+ return { handoff: winner.value.handoff, guard: winner.value.guard };
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // 纯函数(配置里那些「查一查」的公共口径,助手服务也用)
62
+ // ---------------------------------------------------------------------------
63
+ /** 按 id 找端点 */
64
+ export function findEndpoint(config, endpointId) {
65
+ return config.endpoints.find((endpoint) => endpoint.id === endpointId);
66
+ }
67
+ /** 按 id 找模型 */
68
+ export function findModel(config, modelId) {
69
+ return config.models.find((model) => model.id === modelId);
70
+ }
71
+ /** 按 id 找图片模型 */
72
+ export function findImageModel(config, imageModelId) {
73
+ return (config.imageModels ?? []).find((model) => model.id === imageModelId);
74
+ }
75
+ /**
76
+ * 这个模型现在能不能用;能用回 `null`,不能用回一条带原因的事实(F-B5:藏着不如说清楚)。
77
+ *
78
+ * 两种用不了的原因都在配置层:端点没了(模型指向一个已删的端点),或端点还没配密钥
79
+ * (F-A7 的「这个端点配好了没」)。模型自身的字段没有第三种「用不了」的形态。
80
+ */
81
+ export function modelUnavailableProblem(config, model) {
82
+ const endpoint = findEndpoint(config, model.endpointId);
83
+ if (!endpoint) {
84
+ return text("model.endpointMissing", { model: model.name, id: model.endpointId });
85
+ }
86
+ if (endpoint.apiKey.trim() === "") {
87
+ return text("endpoint.noKey", { endpoint: endpoint.name === "" ? endpoint.id : endpoint.name });
88
+ }
89
+ return null;
90
+ }
91
+ /** 模型引用的思考策略(表里没有、或者一条档位都没有,就是这个模型不可调,PRD F-B7) */
92
+ export function reasoningPresetOf(model, presets) {
93
+ const name = model?.reasoningPreset;
94
+ if (!name)
95
+ return undefined;
96
+ const preset = presets[name];
97
+ return preset && Object.keys(preset.levels).length > 0 ? preset : undefined;
98
+ }
99
+ /** 这个模型可选的思考档位名(客户端据此渲染「能调哪几档」) */
100
+ export function thinkingLevelsOf(model, presets) {
101
+ const preset = reasoningPresetOf(model, presets);
102
+ return preset ? Object.keys(preset.levels) : [];
103
+ }
104
+ /** 两条阈值的合法性:都是正数,且硬线要留出交接的余量(handoff < guard) */
105
+ function assertThresholds(thresholds) {
106
+ const { handoff, guard } = thresholds;
107
+ if (!Number.isFinite(handoff) || !Number.isFinite(guard) || handoff <= 0 || guard <= 0) {
108
+ throw new HostError("invalid_params", {
109
+ params: { field: "tokenThresholds" },
110
+ detail: { field: "tokenThresholds", reason: "not_positive", thresholds },
111
+ });
112
+ }
113
+ if (handoff >= guard) {
114
+ throw new HostError("invalid_params", {
115
+ params: { field: "tokenThresholds" },
116
+ detail: { field: "tokenThresholds", reason: "handoff_not_below_guard", thresholds },
117
+ });
118
+ }
119
+ }
120
+ /** 必填的字符串参数:空串按「参数不对」处理,不拿它去查配置 */
121
+ function requireText(value, field) {
122
+ const raw = typeof value === "string" ? value.trim() : "";
123
+ if (raw === "") {
124
+ throw new HostError("invalid_params", {
125
+ params: { field },
126
+ detail: { field, reason: "empty" },
127
+ });
128
+ }
129
+ return raw;
130
+ }
131
+ // ---------------------------------------------------------------------------
132
+ // 服务
133
+ // ---------------------------------------------------------------------------
134
+ export class ModelService {
135
+ configManager;
136
+ agents;
137
+ constructor(options) {
138
+ this.configManager = options.configManager;
139
+ this.agents = options.agents;
140
+ }
141
+ /** 读一次配置(写方法都从这里出发,避免「基于旧快照覆盖别人刚写的东西」) */
142
+ async readConfig() {
143
+ return await this.configManager.read();
144
+ }
145
+ // -------------------------------------------------------------------------
146
+ // 读
147
+ // -------------------------------------------------------------------------
148
+ /**
149
+ * 模型清单(F-F3)。
150
+ *
151
+ * 默认连不可用的也列(F-B5 的口径:藏起来会让人以为它没了),`includeUnavailable: false`
152
+ * 时才把它们筛掉。
153
+ */
154
+ async list(params = {}) {
155
+ const config = await this.readConfig();
156
+ const presets = await this.configManager.readReasoningPresets();
157
+ const models = config.models.map((model) => this.toView(config, model, presets));
158
+ return {
159
+ models: params.includeUnavailable === false ? models.filter((model) => model.available) : models,
160
+ };
161
+ }
162
+ /** 图片模型与那个「默认图片模型」(F-F4 / D6) */
163
+ async listImages() {
164
+ const config = await this.readConfig();
165
+ const imageModels = (config.imageModels ?? []).map((model) => toImageModelView(model));
166
+ return { imageModels, defaultImageModel: config.defaultImageModel ?? null };
167
+ }
168
+ // -------------------------------------------------------------------------
169
+ // 写:文本模型
170
+ // -------------------------------------------------------------------------
171
+ /** 新建模型(建出来就是自定义,F-F3) */
172
+ async create(params) {
173
+ const input = params.model;
174
+ const config = await this.readConfig();
175
+ const id = requireText(input?.id, "model.id");
176
+ if (findModel(config, id)) {
177
+ throw new HostError("invalid_params", {
178
+ params: { field: "model.id" },
179
+ detail: { field: "model.id", reason: "already_exists", id },
180
+ });
181
+ }
182
+ const presets = await this.configManager.readReasoningPresets();
183
+ const model = this.buildModel(config, presets, id, input);
184
+ const next = { ...config, models: [...config.models, model] };
185
+ await this.configManager.write(next);
186
+ return { model: this.toView(next, model, presets) };
187
+ }
188
+ /** 编辑模型(编辑即转自定义,F-F3 —— 否则下轮初始化会被出厂定义冲回去) */
189
+ async update(params) {
190
+ const config = await this.readConfig();
191
+ const modelId = requireText(params.modelId, "modelId");
192
+ const existing = findModel(config, modelId);
193
+ if (!existing) {
194
+ throw new HostError("not_found", {
195
+ params: { entity: "model" },
196
+ detail: { modelId },
197
+ });
198
+ }
199
+ const presets = await this.configManager.readReasoningPresets();
200
+ const patch = params.patch ?? {};
201
+ const merged = {
202
+ ...existing,
203
+ ...definedOnly({
204
+ name: patch.name,
205
+ endpointId: patch.endpointId,
206
+ modelName: patch.modelName,
207
+ tokenThresholds: patch.tokenThresholds,
208
+ defaultParams: patch.defaultParams,
209
+ reasoningPreset: patch.reasoningPreset,
210
+ vision: patch.vision,
211
+ description: patch.description,
212
+ }),
213
+ custom: true,
214
+ };
215
+ validateModelShape(config, presets, merged);
216
+ const next = {
217
+ ...config,
218
+ models: config.models.map((model) => (model.id === modelId ? merged : model)),
219
+ };
220
+ await this.configManager.write(next);
221
+ return { model: this.toView(next, merged, presets) };
222
+ }
223
+ /**
224
+ * 删除模型(F-F2 / F-F7)。
225
+ *
226
+ * 被助手引用时拒删并列出引用者;默认项(兜底模型 / 迁移模型 / 取名模型)指向它时改指或清空
227
+ * —— 留在配置里指向一个不存在的 id,会在下一次真要跑的时候才炸出来。
228
+ */
229
+ async delete(params) {
230
+ const config = await this.readConfig();
231
+ const modelId = requireText(params.modelId, "modelId");
232
+ if (!findModel(config, modelId)) {
233
+ throw new HostError("not_found", { params: { entity: "model" }, detail: { modelId } });
234
+ }
235
+ const references = await this.referencesOf(modelId);
236
+ if (references.length > 0) {
237
+ throw new HostError("config_invalid", {
238
+ messageKey: "config.referenced",
239
+ params: { count: references.length, names: references.map((item) => item.name).join(", ") },
240
+ detail: { modelId, references: { referenced: true, items: references } },
241
+ });
242
+ }
243
+ if (params.dryRun === true) {
244
+ return { removed: false, references: { referenced: false, items: [] } };
245
+ }
246
+ const remaining = config.models.filter((model) => model.id !== modelId);
247
+ const next = {
248
+ ...config,
249
+ models: remaining,
250
+ ...redirectDefaults(config, modelId, remaining),
251
+ };
252
+ await this.configManager.write(next);
253
+ return { removed: true, references: { referenced: false, items: [] } };
254
+ }
255
+ /**
256
+ * 恢复出厂(F-F3:整条替换)。
257
+ *
258
+ * 只有出厂清单里有的模型才有「出厂版本」可恢复;用户自建的模型没有,回
259
+ * `error.notFactoryModel`。恢复之后这一条重新归 SDK 托管(去掉 `custom`),
260
+ * 所以下次初始化会跟着出厂清单走。
261
+ */
262
+ async restoreFactory(params) {
263
+ const config = await this.readConfig();
264
+ const modelId = requireText(params.modelId, "modelId");
265
+ if (!findModel(config, modelId)) {
266
+ throw new HostError("not_found", { params: { entity: "model" }, detail: { modelId } });
267
+ }
268
+ const factory = DEFAULT_APP_CONFIG.models.find((model) => model.id === modelId);
269
+ if (!factory) {
270
+ throw new HostError("invalid_params", {
271
+ messageKey: "error.notFactoryModel",
272
+ params: { id: modelId },
273
+ detail: { modelId, reason: "not_a_factory_model" },
274
+ });
275
+ }
276
+ const restored = { ...structuredClone(factory) };
277
+ const next = {
278
+ ...config,
279
+ models: config.models.map((model) => (model.id === modelId ? restored : model)),
280
+ };
281
+ await this.configManager.write(next);
282
+ const presets = await this.configManager.readReasoningPresets();
283
+ return { model: this.toView(next, restored, presets) };
284
+ }
285
+ // -------------------------------------------------------------------------
286
+ // 写:图片模型
287
+ // -------------------------------------------------------------------------
288
+ /** 图片模型的创建与修改(F-F4:同一个方法兼两件事,`id` 在就改、不在就建) */
289
+ async saveImage(params) {
290
+ const input = params.imageModel;
291
+ const config = await this.readConfig();
292
+ const id = requireText(input?.id, "imageModel.id");
293
+ requireText(input?.name, "imageModel.name");
294
+ requireText(input?.modelName, "imageModel.modelName");
295
+ const endpointId = requireText(input?.endpointId, "imageModel.endpointId");
296
+ if (!findEndpoint(config, endpointId)) {
297
+ throw new HostError("invalid_params", {
298
+ params: { field: "imageModel.endpointId" },
299
+ detail: { field: "imageModel.endpointId", reason: "endpoint_not_found", endpointId },
300
+ });
301
+ }
302
+ const imageModels = config.imageModels ?? [];
303
+ const existing = imageModels.find((model) => model.id === id);
304
+ const saved = {
305
+ ...(existing ?? {}),
306
+ ...definedOnly({
307
+ description: input.description,
308
+ defaultSize: input.defaultSize,
309
+ defaultQuality: input.defaultQuality,
310
+ }),
311
+ id,
312
+ name: requireText(input.name, "imageModel.name"),
313
+ endpointId,
314
+ modelName: requireText(input.modelName, "imageModel.modelName"),
315
+ custom: true,
316
+ };
317
+ const next = {
318
+ ...config,
319
+ imageModels: existing
320
+ ? imageModels.map((model) => (model.id === id ? saved : model))
321
+ : [...imageModels, saved],
322
+ };
323
+ await this.configManager.write(next);
324
+ return { imageModel: toImageModelView(saved) };
325
+ }
326
+ /** 删除图片模型;它正被「默认图片模型」指着时拒删(F-F4 / F-F7) */
327
+ async deleteImage(params) {
328
+ const config = await this.readConfig();
329
+ const imageModelId = requireText(params.imageModelId, "imageModelId");
330
+ const imageModels = config.imageModels ?? [];
331
+ if (!imageModels.some((model) => model.id === imageModelId)) {
332
+ throw new HostError("not_found", {
333
+ params: { entity: "image model" },
334
+ detail: { imageModelId },
335
+ });
336
+ }
337
+ const references = config.defaultImageModel === imageModelId
338
+ ? {
339
+ referenced: true,
340
+ items: [{ kind: "default", id: "defaultImageModel", name: imageModelId }],
341
+ }
342
+ : { referenced: false, items: [] };
343
+ if (references.referenced) {
344
+ throw new HostError("config_invalid", {
345
+ messageKey: "config.referenced",
346
+ params: {
347
+ count: references.items.length,
348
+ names: references.items.map((item) => item.name).join(", "),
349
+ },
350
+ detail: { imageModelId, references },
351
+ });
352
+ }
353
+ if (params.dryRun === true)
354
+ return { removed: false, references };
355
+ const next = {
356
+ ...config,
357
+ imageModels: imageModels.filter((model) => model.id !== imageModelId),
358
+ };
359
+ await this.configManager.write(next);
360
+ return { removed: true, references };
361
+ }
362
+ // -------------------------------------------------------------------------
363
+ // 视图
364
+ // -------------------------------------------------------------------------
365
+ /** 一行模型定义 → 对外形状 */
366
+ toView(config, model, presets) {
367
+ const unavailable = modelUnavailableProblem(config, model);
368
+ return {
369
+ id: model.id,
370
+ name: model.name,
371
+ endpointId: model.endpointId,
372
+ modelName: model.modelName ?? null,
373
+ tokenThresholds: {
374
+ handoff: model.tokenThresholds.handoff,
375
+ guard: model.tokenThresholds.guard,
376
+ },
377
+ defaultParams: model.defaultParams ?? {},
378
+ reasoningPreset: model.reasoningPreset ?? null,
379
+ thinkingLevels: thinkingLevelsOf(model, presets),
380
+ vision: model.vision === true,
381
+ description: model.description ?? null,
382
+ custom: model.custom === true,
383
+ available: unavailable === null,
384
+ unavailableReason: unavailable,
385
+ };
386
+ }
387
+ // -------------------------------------------------------------------------
388
+ // 内部
389
+ // -------------------------------------------------------------------------
390
+ /** 入参(新建)→ 一条模型定义;校验全在这里,写盘之前就拦下 */
391
+ buildModel(config, presets, id, input) {
392
+ const model = {
393
+ id,
394
+ name: requireText(input?.name, "model.name"),
395
+ endpointId: requireText(input?.endpointId, "model.endpointId"),
396
+ ...definedOnly({
397
+ modelName: input.modelName,
398
+ tokenThresholds: input.tokenThresholds,
399
+ defaultParams: input.defaultParams,
400
+ reasoningPreset: input.reasoningPreset,
401
+ vision: input.vision,
402
+ description: input.description,
403
+ }),
404
+ // 阈值:入参给了用入参,省略就套出厂默认(出厂清单里最多人用的那一档)
405
+ tokenThresholds: input.tokenThresholds ?? factoryDefaultThresholds(),
406
+ custom: true,
407
+ };
408
+ validateModelShape(config, presets, model);
409
+ return model;
410
+ }
411
+ /**
412
+ * 谁在引用这个模型:读助手定义(一实体一文件,F-G3)。
413
+ *
414
+ * 默认项不列在这里:它们会跟着改指或清空(F-F7),是「删完自动收拾」而不是「拦住删除」。
415
+ */
416
+ async referencesOf(modelId) {
417
+ const references = [];
418
+ const agents = this.agents ? await this.agents.list() : [];
419
+ for (const agent of agents) {
420
+ if (agent.modelId === modelId) {
421
+ references.push({ kind: "agent", id: agent.id, name: agent.name });
422
+ }
423
+ }
424
+ return references;
425
+ }
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // 纯函数
429
+ // ---------------------------------------------------------------------------
430
+ /** 只保留「入参真的给了」的那些字段(`undefined` 表示这次不改它) */
431
+ function definedOnly(input) {
432
+ const result = {};
433
+ for (const [key, value] of Object.entries(input)) {
434
+ if (value !== undefined)
435
+ result[key] = value;
436
+ }
437
+ return result;
438
+ }
439
+ /** 一条模型定义的形状校验:端点要在、阈值要合法、引用的思考策略要在表里 */
440
+ function validateModelShape(config, presets, model) {
441
+ requireText(model.name, "model.name");
442
+ if (!findEndpoint(config, model.endpointId)) {
443
+ throw new HostError("invalid_params", {
444
+ params: { field: "model.endpointId" },
445
+ detail: { field: "model.endpointId", reason: "endpoint_not_found", endpointId: model.endpointId },
446
+ });
447
+ }
448
+ assertThresholds(model.tokenThresholds);
449
+ if (model.reasoningPreset !== undefined && !presets[model.reasoningPreset]) {
450
+ throw new HostError("invalid_params", {
451
+ params: { field: "model.reasoningPreset" },
452
+ detail: {
453
+ field: "model.reasoningPreset",
454
+ reason: "unknown_preset",
455
+ preset: model.reasoningPreset,
456
+ presets: Object.keys(presets),
457
+ },
458
+ });
459
+ }
460
+ }
461
+ /**
462
+ * 被删模型的默认指望去向(F-F7:自动改指或清空)。
463
+ *
464
+ * 口径是「同端点的第一条剩余模型」:换指到别的端点等于把请求发去另一家服务商,那件事该由人决定。
465
+ * 同一端点没有剩余模型时清空(`null`),让界面显示「还没选」而不是显示一个不存在的 id。
466
+ */
467
+ function redirectDefaults(config, removedModelId, remaining) {
468
+ const removed = findModel(config, removedModelId);
469
+ const replacement = remaining.find((model) => model.endpointId === removed?.endpointId)?.id ?? null;
470
+ const pick = (current) => current === removedModelId ? (replacement ?? undefined) : current;
471
+ return {
472
+ defaultModel: pick(config.defaultModel),
473
+ defaultMigrationModel: pick(config.defaultMigrationModel),
474
+ namingModel: pick(config.namingModel),
475
+ };
476
+ }
477
+ /** 图片模型定义 → 对外形状 */
478
+ function toImageModelView(model) {
479
+ return {
480
+ id: model.id,
481
+ name: model.name,
482
+ endpointId: model.endpointId,
483
+ modelName: model.modelName,
484
+ description: model.description ?? null,
485
+ defaultSize: model.defaultSize ?? null,
486
+ defaultQuality: model.defaultQuality ?? null,
487
+ custom: model.custom === true,
488
+ };
489
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * 通知服务:一条**独立通道**(PRD F-I8),它不进消息流。
3
+ *
4
+ * 三件事在这里定死:
5
+ *
6
+ * 1. **持久化**(PRD L3):通知落 `notices` 表,重启之后还在;永久通知的消失只有「手动清」
7
+ * 这一条路径(`notice.dismiss` / `notice.clear`),带持续时间的到点自己消失。
8
+ * 2. **持续时间的两套单位**(R15):契约用**秒**(用户语义:「这条提示留 3 秒」),
9
+ * 库里存**毫秒**(`notices.durationMs`)。换算只有本文件这一处 —— 别处再换一次,
10
+ * 迟早有一条通知留 3 毫秒或 3 天。
11
+ * 3. **存码不存句子**(D8 / architecture §6):库里存的是「码 + 参数」,读出时按**调用方
12
+ * 给的语言**渲染。语言挂在连接上,一条通知存死一种语言、换个语言连回来就错了。
13
+ *
14
+ * 语气三档 `info | warning | error`(R4):「已保存」这种一秒提示是 `info` + 短 duration,
15
+ * 不新增 success 档;`warning` / `error` 同时是宿主日志上浮的通道(PRD F-J2)。
16
+ */
17
+ import type { Language, MessageKey } from "../i18n/index.js";
18
+ import type { ChatEventEmitter } from "../runtime/ChatRunner.js";
19
+ import type { NoticeRepository } from "../storage/NoticeRepository.js";
20
+ import type { NoticeClearParams, NoticeClearResult, NoticeDismissParams, NoticeDismissResult, NoticeLevel, NoticeListParams, NoticeListResult, NoticeView } from "../wire/index.js";
21
+ import type { EventMap, EventName } from "../wire/events.js";
22
+ import type { LocalizedText } from "../wire/states.js";
23
+ /** 造一条通知的入参(内部入口;`notice.*` 三个方法只读 / 只清) */
24
+ export interface NoticeCreateInput {
25
+ level: NoticeLevel;
26
+ /**
27
+ * 文案键,同时作为出网的稳定码(`LocalizedText.code`)。
28
+ *
29
+ * 两者合一是有意的:通知的稳定码本来就是「哪句话」这件事,另起一套码只会多一张对照表。
30
+ */
31
+ code: MessageKey;
32
+ params?: Record<string, unknown>;
33
+ /** 秒;不给或给 `null` 表示永久,直到被手动清掉(PRD L3) */
34
+ duration?: number | null;
35
+ /** 与哪段对话有关;不给表示全局通知 */
36
+ conversationId?: string | null;
37
+ }
38
+ export interface NoticeServiceDeps {
39
+ notices: NoticeRepository;
40
+ /** 推 `notice` 事件;不注入就不推(用例、或不需要在线推送的场合) */
41
+ emit?: ChatEventEmitter;
42
+ /** 时钟,测试注入用;缺省 `Date.now` */
43
+ now?: () => number;
44
+ /** 通知 id 的分配口,测试注入用;缺省 `crypto.randomUUID` */
45
+ newId?: () => string;
46
+ }
47
+ /** 按语言重渲染一句已存的文案(`messageKey` + `params` 都在 `LocalizedText` 里) */
48
+ export declare function relocalizeText(text: LocalizedText, language: Language): LocalizedText;
49
+ /**
50
+ * 事件出网前的按连接本地化(`WsHub` 的 `localizeEvent` 注入点)。
51
+ *
52
+ * 事件是在产生的现场按缺省语言渲染的,而语言挂在连接上,所以出网前要把 `notice` 与
53
+ * `question` 这两类载荷里的人话换成这条连接的语言;其余事件没有文案,原样返回。
54
+ */
55
+ export type EventLocalizer = <E extends EventName>(event: E, payload: EventMap[E], language: Language) => EventMap[E];
56
+ /** 造上面那个本地化器(接线层把它注入 `WsHub` 的 `localizeEvent`) */
57
+ export declare function createEventLocalizer(): EventLocalizer;
58
+ export declare class NoticeService {
59
+ private readonly notices;
60
+ private readonly emit?;
61
+ private readonly now;
62
+ private readonly newId;
63
+ constructor(deps: NoticeServiceDeps);
64
+ /**
65
+ * 发一条通知(内部入口)。
66
+ *
67
+ * 落库 → 推 `notice` 事件 → 返回渲染好的视图。落库在推送之前:推送只对在线连接有意义
68
+ * (D5:断线即作废),而库里的那条是重连之后还能看到的真相(L3)。
69
+ */
70
+ create(input: NoticeCreateInput, language?: Language): Promise<NoticeView>;
71
+ /**
72
+ * 日志上浮(PRD F-J2:warn / error 也要上通知栏)。
73
+ *
74
+ * 没有持续时间 = 永久:界面上的一闪而过不适合承载「宿主出了什么事」,
75
+ * 这类通知要留到用户看过并手动清掉。
76
+ */
77
+ log(level: Extract<NoticeLevel, "warning" | "error">, summary: string, language?: Language): Promise<NoticeView>;
78
+ /**
79
+ * 现在该显示的通知(PRD L3)。
80
+ *
81
+ * 先清掉已经到点的(`deleteExpired`),再按「永久 + 还没到点」读一遍:只按时间过滤而不清,
82
+ * 表会一直涨;只清不按时间过滤,同一毫秒内到点的那条仍会被读出来。
83
+ */
84
+ list(params?: NoticeListParams, language?: Language): Promise<NoticeListResult>;
85
+ /**
86
+ * 清掉一条(PRD L3:永久通知唯一能消失的方式)。
87
+ *
88
+ * 已经不存在时回 `dismissed: false`(这次有没有真的改动库),与 `notice.clear` 的口径一致。
89
+ */
90
+ dismiss(params: NoticeDismissParams): Promise<NoticeDismissResult>;
91
+ /** 清掉一批(不给 `conversationId` 就清全部) */
92
+ clear(params?: NoticeClearParams): Promise<NoticeClearResult>;
93
+ }