@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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +353 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 问题服务:把「要不要交接 / 要不要登记这个目录 / 确认一下」变成**对象**(PRD D22 / F-I9)。
|
|
3
|
+
*
|
|
4
|
+
* 一个问题的四件事实都在这里:有 id、有状态、有生命周期、可持久化。多端看到的是同一个
|
|
5
|
+
* 对象(库里那一行),所以「另一个窗口已经答过了」这件事不需要另开一条同步通道 ——
|
|
6
|
+
* 第二次拉 `question.list` 就能看到状态变了。
|
|
7
|
+
*
|
|
8
|
+
* 三条贯穿本文件的边界:
|
|
9
|
+
*
|
|
10
|
+
* 1. **存码不存句子**(D8):`questions.payload` 里存的是文案键 + 参数 + 候选项的值,
|
|
11
|
+
* 读出时按调用方给的语言渲染。一条问题存死一种语言,换个连接就错了。
|
|
12
|
+
* 2. **答完之后接着往下走**(D22):`answer` 的副作用是宿主自己做掉的 —— 答「自交接 /
|
|
13
|
+
* 独立摘要」就当场开始交接,客户端不必再发一次 `handoff.start`。
|
|
14
|
+
* 3. **越线两端都拒绝时问题继续挂着**(D23 / F-D10):答不下去的那些路径(对话正在跑、
|
|
15
|
+
* 已经过了硬线而用户选了「就在这段里继续」)都不动这一行状态,所以问题不会被悄悄关掉。
|
|
16
|
+
* 本文件里没有「自动过期」「答错一次就作废」这类隐式转移。
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { DEFAULT_LANGUAGE, hasMessage, render } from "../i18n/index.js";
|
|
20
|
+
import { HostError } from "../wire/errors.js";
|
|
21
|
+
import { QUESTION_KINDS } from "../wire/states.js";
|
|
22
|
+
import { readUsage } from "./MessageService.js";
|
|
23
|
+
/** 交接问题的三个候选值(F-D2:自交接 / 独立摘要 / 不交接) */
|
|
24
|
+
export const HANDOFF_OPTION_SELF = "self";
|
|
25
|
+
export const HANDOFF_OPTION_SUMMARY = "summary";
|
|
26
|
+
export const HANDOFF_OPTION_DECLINE = "decline";
|
|
27
|
+
function iso(ms) {
|
|
28
|
+
return new Date(ms).toISOString();
|
|
29
|
+
}
|
|
30
|
+
/** 缺省挡不挡着对话:问题就是为「停下来问」而造的,默认挡着,除非创建时显式说不管 */
|
|
31
|
+
function defaultBlocking(_kind) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
/** 一个 JSON 值是不是普通对象(解析库里的列用) */
|
|
35
|
+
function isRecord(value) {
|
|
36
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 库里那列 JSON → 打字过的载荷。
|
|
40
|
+
*
|
|
41
|
+
* 读不出来时给一份「只有码、没有候选」的形状而不是抛错:一行被手改坏的提问不该让整个
|
|
42
|
+
* `question.list` 打不开,界面上至少能显示 id 与状态。
|
|
43
|
+
*/
|
|
44
|
+
function toStoredPayload(value, fallbackCode) {
|
|
45
|
+
if (!isRecord(value)) {
|
|
46
|
+
return {
|
|
47
|
+
code: fallbackCode,
|
|
48
|
+
prompt: { messageKey: "question.blocking", params: {} },
|
|
49
|
+
options: [],
|
|
50
|
+
blocking: false,
|
|
51
|
+
data: {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const prompt = isRecord(value.prompt) ? value.prompt : {};
|
|
55
|
+
const options = Array.isArray(value.options) ? value.options : [];
|
|
56
|
+
return {
|
|
57
|
+
code: typeof value.code === "string" ? value.code : fallbackCode,
|
|
58
|
+
prompt: {
|
|
59
|
+
messageKey: typeof prompt.messageKey === "string" ? prompt.messageKey : "question.blocking",
|
|
60
|
+
params: isRecord(prompt.params) ? prompt.params : {},
|
|
61
|
+
},
|
|
62
|
+
options: options.filter(isRecord).map((option) => ({
|
|
63
|
+
value: typeof option.value === "string" ? option.value : "",
|
|
64
|
+
label: isRecord(option.label)
|
|
65
|
+
? {
|
|
66
|
+
messageKey: typeof option.label.messageKey === "string"
|
|
67
|
+
? option.label.messageKey
|
|
68
|
+
: "question.blocking",
|
|
69
|
+
params: isRecord(option.label.params) ? option.label.params : {},
|
|
70
|
+
}
|
|
71
|
+
: { messageKey: "question.blocking", params: {} },
|
|
72
|
+
detail: isRecord(option.detail)
|
|
73
|
+
? {
|
|
74
|
+
messageKey: typeof option.detail.messageKey === "string"
|
|
75
|
+
? option.detail.messageKey
|
|
76
|
+
: "question.blocking",
|
|
77
|
+
params: isRecord(option.detail.params) ? option.detail.params : {},
|
|
78
|
+
}
|
|
79
|
+
: null,
|
|
80
|
+
})),
|
|
81
|
+
blocking: value.blocking === true,
|
|
82
|
+
data: isRecord(value.data) ? value.data : {},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** 库里那列 answer 的 JSON → 打字过的回答 */
|
|
86
|
+
function toStoredAnswer(value) {
|
|
87
|
+
if (!isRecord(value) || typeof value.value !== "string")
|
|
88
|
+
return null;
|
|
89
|
+
return {
|
|
90
|
+
value: value.value,
|
|
91
|
+
answeredBy: typeof value.answeredBy === "string" ? value.answeredBy : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** 渲染一句话(键认不出来时退回「被这个问题挡住了」那句,至少说明有东西在等回答) */
|
|
95
|
+
function localizeText(code, source, language) {
|
|
96
|
+
const messageKey = hasMessage(source.messageKey)
|
|
97
|
+
? source.messageKey
|
|
98
|
+
: "question.blocking";
|
|
99
|
+
return {
|
|
100
|
+
code,
|
|
101
|
+
messageKey,
|
|
102
|
+
params: source.params,
|
|
103
|
+
message: render(language, messageKey, source.params),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** 一行库记录 → 契约上的问题对象(按给定语言渲染) */
|
|
107
|
+
function toView(question, language) {
|
|
108
|
+
const payload = toStoredPayload(question.payload, `question.${question.kind}`);
|
|
109
|
+
const answer = toStoredAnswer(question.answer);
|
|
110
|
+
const options = payload.options.map((option) => ({
|
|
111
|
+
value: option.value,
|
|
112
|
+
label: localizeText(payload.code, option.label, language),
|
|
113
|
+
detail: option.detail === null ? null : localizeText(payload.code, option.detail, language),
|
|
114
|
+
}));
|
|
115
|
+
const view = {
|
|
116
|
+
id: question.id,
|
|
117
|
+
conversationId: question.conversationId,
|
|
118
|
+
kind: question.kind,
|
|
119
|
+
state: question.state,
|
|
120
|
+
prompt: localizeText(payload.code, payload.prompt, language),
|
|
121
|
+
options,
|
|
122
|
+
answer: answer === null
|
|
123
|
+
? null
|
|
124
|
+
: {
|
|
125
|
+
value: answer.value,
|
|
126
|
+
answeredAt: iso(question.answeredAt ?? question.updatedAt),
|
|
127
|
+
answeredBy: answer.answeredBy,
|
|
128
|
+
},
|
|
129
|
+
blocking: payload.blocking,
|
|
130
|
+
createdAt: iso(question.createdAt),
|
|
131
|
+
updatedAt: iso(question.updatedAt),
|
|
132
|
+
};
|
|
133
|
+
return view;
|
|
134
|
+
}
|
|
135
|
+
export class QuestionService {
|
|
136
|
+
questions;
|
|
137
|
+
runs;
|
|
138
|
+
guard;
|
|
139
|
+
handoff;
|
|
140
|
+
emit;
|
|
141
|
+
now;
|
|
142
|
+
newId;
|
|
143
|
+
constructor(deps) {
|
|
144
|
+
this.questions = deps.questions;
|
|
145
|
+
this.runs = deps.runs;
|
|
146
|
+
this.guard = deps.guard;
|
|
147
|
+
this.handoff = deps.handoff;
|
|
148
|
+
this.emit = deps.emit;
|
|
149
|
+
this.now = deps.now ?? Date.now;
|
|
150
|
+
this.newId = deps.newId ?? randomUUID;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 问一个问题(内部入口)。
|
|
154
|
+
*
|
|
155
|
+
* 先落库再推事件:库里那一行是「多端看同一个」的真相(D22),推送只是提醒在线的那几个
|
|
156
|
+
* 窗口去拉(L5 断线即作废)。
|
|
157
|
+
*/
|
|
158
|
+
async ask(input, language = DEFAULT_LANGUAGE) {
|
|
159
|
+
if (!QUESTION_KINDS.includes(input.kind)) {
|
|
160
|
+
throw new HostError("invalid_params", {
|
|
161
|
+
params: { field: "kind" },
|
|
162
|
+
detail: { field: "kind", value: input.kind, kinds: QUESTION_KINDS },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!hasMessage(input.prompt.messageKey)) {
|
|
166
|
+
throw new HostError("invalid_params", {
|
|
167
|
+
params: { field: "prompt" },
|
|
168
|
+
detail: { field: "prompt", reason: "unknown_message_key" },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (input.options.length === 0) {
|
|
172
|
+
throw new HostError("invalid_params", {
|
|
173
|
+
params: { field: "options" },
|
|
174
|
+
detail: { field: "options", reason: "empty" },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const option of input.options) {
|
|
179
|
+
if (option.value.trim().length === 0 || seen.has(option.value)) {
|
|
180
|
+
throw new HostError("invalid_params", {
|
|
181
|
+
params: { field: "options" },
|
|
182
|
+
detail: { field: "options", reason: "duplicate_or_empty_value", value: option.value },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
seen.add(option.value);
|
|
186
|
+
if (!hasMessage(option.label.messageKey)) {
|
|
187
|
+
throw new HostError("invalid_params", {
|
|
188
|
+
params: { field: "options" },
|
|
189
|
+
detail: { field: "options", reason: "unknown_message_key", value: option.value },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const payload = {
|
|
194
|
+
code: input.code ?? input.prompt.messageKey,
|
|
195
|
+
prompt: { messageKey: input.prompt.messageKey, params: input.prompt.params ?? {} },
|
|
196
|
+
options: input.options.map((option) => ({
|
|
197
|
+
value: option.value,
|
|
198
|
+
label: { messageKey: option.label.messageKey, params: option.label.params ?? {} },
|
|
199
|
+
detail: option.detail === undefined || option.detail === null
|
|
200
|
+
? null
|
|
201
|
+
: { messageKey: option.detail.messageKey, params: option.detail.params ?? {} },
|
|
202
|
+
})),
|
|
203
|
+
blocking: input.blocking ?? defaultBlocking(input.kind),
|
|
204
|
+
data: input.data ?? {},
|
|
205
|
+
};
|
|
206
|
+
const id = this.newId();
|
|
207
|
+
const createdAt = this.now();
|
|
208
|
+
await this.questions.create({
|
|
209
|
+
id,
|
|
210
|
+
conversationId: input.conversationId,
|
|
211
|
+
kind: input.kind,
|
|
212
|
+
payload,
|
|
213
|
+
state: "pending",
|
|
214
|
+
createdAt,
|
|
215
|
+
});
|
|
216
|
+
const stored = await this.questions.get(id);
|
|
217
|
+
if (!stored) {
|
|
218
|
+
throw new HostError("internal", { detail: { reason: "question vanished after insert", id } });
|
|
219
|
+
}
|
|
220
|
+
const view = toView(stored, language);
|
|
221
|
+
this.emit?.("question", { question: view, at: iso(createdAt) });
|
|
222
|
+
return view;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* 到交接线时问「自交接 / 独立摘要 / 不交接」(PRD F-D2 / D22)。
|
|
226
|
+
*
|
|
227
|
+
* 触发点在**对话停下之后**:这里只负责把问题摆出来,不参与任何流式过程。
|
|
228
|
+
* `percent` 是给人看的那个数(用量 / 交接线),数字本身留在库里与状态里。
|
|
229
|
+
*/
|
|
230
|
+
async askHandoff(input, language = DEFAULT_LANGUAGE) {
|
|
231
|
+
const percent = input.handoffTokens > 0
|
|
232
|
+
? Math.min(999, Math.round((input.promptTokens / input.handoffTokens) * 100))
|
|
233
|
+
: 100;
|
|
234
|
+
return await this.ask({
|
|
235
|
+
conversationId: input.conversationId,
|
|
236
|
+
kind: "handoff",
|
|
237
|
+
code: "question.handoff.prompt",
|
|
238
|
+
prompt: { messageKey: "question.handoff.prompt", params: { percent } },
|
|
239
|
+
options: [
|
|
240
|
+
{
|
|
241
|
+
value: HANDOFF_OPTION_SELF,
|
|
242
|
+
label: { messageKey: "question.handoff.self" },
|
|
243
|
+
detail: { messageKey: "question.handoff.selfDetail" },
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
value: HANDOFF_OPTION_SUMMARY,
|
|
247
|
+
label: { messageKey: "question.handoff.summary" },
|
|
248
|
+
detail: { messageKey: "question.handoff.summaryDetail" },
|
|
249
|
+
},
|
|
250
|
+
{ value: HANDOFF_OPTION_DECLINE, label: { messageKey: "question.handoff.decline" } },
|
|
251
|
+
],
|
|
252
|
+
blocking: input.blocking ?? true,
|
|
253
|
+
}, language);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 列出问题(PRD D22:多端看同一个)。
|
|
257
|
+
*
|
|
258
|
+
* `conversationId` 可选(R61):给了就只看那段对话的;不给就看全库(含没挂对话的那些,
|
|
259
|
+
* 例如「要不要把这个目录登记成项目」)。`state` 两种情况下都生效。
|
|
260
|
+
*/
|
|
261
|
+
async list(params = {}, language = DEFAULT_LANGUAGE) {
|
|
262
|
+
const conversationId = typeof params.conversationId === "string" ? params.conversationId.trim() : "";
|
|
263
|
+
const rows = conversationId.length > 0
|
|
264
|
+
? await this.questions.listByConversation(conversationId, params.state)
|
|
265
|
+
: await this.questions.listAll(params.state);
|
|
266
|
+
return { questions: rows.map((row) => toView(row, language)) };
|
|
267
|
+
}
|
|
268
|
+
/** 这段对话上还挂着、且正在挡着它的那些问题(`conversation.send` 的前置检查用它) */
|
|
269
|
+
async blockingQuestions(conversationId) {
|
|
270
|
+
const rows = await this.questions.listByConversation(conversationId, "pending");
|
|
271
|
+
return rows
|
|
272
|
+
.map((row) => toView(row, DEFAULT_LANGUAGE))
|
|
273
|
+
.filter((view) => view.blocking);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 有挡着的问题就拒绝(PRD D23 / F-D10 的「宿主也拒绝」)。
|
|
277
|
+
*
|
|
278
|
+
* 只在真有 `blocking` 的问题时抛 `question_pending`,并把那条问题的提问当参数带上:
|
|
279
|
+
* 用户看到的是「哪个问题在挡着」,不是一句抽象的「还有问题没答」。
|
|
280
|
+
*/
|
|
281
|
+
async assertSendable(conversationId) {
|
|
282
|
+
const blocking = await this.blockingQuestions(conversationId);
|
|
283
|
+
const first = blocking[0];
|
|
284
|
+
if (!first)
|
|
285
|
+
return;
|
|
286
|
+
throw new HostError("question_pending", {
|
|
287
|
+
params: { question: first.prompt.message },
|
|
288
|
+
detail: { conversationId, questionId: first.id, kind: first.kind },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 回答一个问题(PRD D22 / F-I9)。
|
|
293
|
+
*
|
|
294
|
+
* 顺序是刻意的:
|
|
295
|
+
* 1. 已经答过 / 已失效的直接回当前状态(多端同时点,后到的那一端看到的是「已经答了」,
|
|
296
|
+
* 不重复触发副作用);
|
|
297
|
+
* 2. 值不在候选里 → `invalid_params`;
|
|
298
|
+
* 3. 对话正在跑一轮 → `busy`(D22:交接在对话停下之后触发,不在流式中途);
|
|
299
|
+
* 4. 过了硬线又选了「就在这段里继续」 → `context_guard`,**问题继续挂着**(D23);
|
|
300
|
+
* 5. 改状态 → 该交接就当场交接。
|
|
301
|
+
*
|
|
302
|
+
* 第 3、4 两步都在写状态之前返回,所以「答不下去」不会把问题弄成一个半答的中间态。
|
|
303
|
+
*/
|
|
304
|
+
async answer(params, context = {}) {
|
|
305
|
+
const language = context.language ?? DEFAULT_LANGUAGE;
|
|
306
|
+
const question = await this.requireQuestion(params.questionId);
|
|
307
|
+
const value = typeof params.value === "string" ? params.value.trim() : "";
|
|
308
|
+
if (value.length === 0) {
|
|
309
|
+
throw new HostError("invalid_params", {
|
|
310
|
+
params: { field: "value" },
|
|
311
|
+
detail: { field: "value", reason: "empty" },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const payload = toStoredPayload(question.payload, `question.${question.kind}`);
|
|
315
|
+
if (question.state !== "pending") {
|
|
316
|
+
// 已经在别处答过(或已作废):不报错、不重复触发副作用,回当前状态
|
|
317
|
+
return { question: toView(question, language), effects: noEffects() };
|
|
318
|
+
}
|
|
319
|
+
const chosen = payload.options.find((option) => option.value === value);
|
|
320
|
+
if (!chosen) {
|
|
321
|
+
throw new HostError("invalid_params", {
|
|
322
|
+
params: { field: "value" },
|
|
323
|
+
detail: { field: "value", value, options: payload.options.map((option) => option.value) },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const conversationId = question.conversationId;
|
|
327
|
+
if (this.runs?.isRunning(conversationId) === true) {
|
|
328
|
+
throw new HostError("busy", {
|
|
329
|
+
detail: { conversationId, questionId: question.id, reason: "turn_in_flight" },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const kind = question.kind;
|
|
333
|
+
const handoffMode = handoffModeOf(kind, value);
|
|
334
|
+
const continuesHere = kind === "handoff" && handoffMode === null;
|
|
335
|
+
if (continuesHere && this.guard) {
|
|
336
|
+
if (await this.guard.overGuard(conversationId)) {
|
|
337
|
+
// D23:越过硬线,宿主也拒绝;这一行**不动**,问题继续挂着等人处理
|
|
338
|
+
throw new HostError("context_guard", {
|
|
339
|
+
detail: {
|
|
340
|
+
conversationId,
|
|
341
|
+
questionId: question.id,
|
|
342
|
+
reason: "over_guard",
|
|
343
|
+
nextStep: "handoff",
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const answeredAt = this.now();
|
|
349
|
+
const answer = { value, answeredBy: context.answeredBy ?? null };
|
|
350
|
+
const patch = {
|
|
351
|
+
state: "answered",
|
|
352
|
+
answer,
|
|
353
|
+
answeredAt,
|
|
354
|
+
updatedAt: answeredAt,
|
|
355
|
+
};
|
|
356
|
+
await this.questions.update(question.id, patch);
|
|
357
|
+
const answeredRow = await this.questions.get(question.id);
|
|
358
|
+
const view = toView(answeredRow ?? question, language);
|
|
359
|
+
this.emit?.("question", { question: view, at: iso(answeredAt) });
|
|
360
|
+
const effects = await this.applyEffects({ kind, value, handoffMode, conversationId, data: payload.data }, language);
|
|
361
|
+
return { question: view, effects };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* 显式取消一个问题(生命周期里的 `cancelled`)。
|
|
365
|
+
*
|
|
366
|
+
* 只有调用方明确要关掉时才用(例如对话被删、工作流换了方向)。没有任何自动取消的路径 ——
|
|
367
|
+
* 越线拒绝之后问题要继续挂着(D23),所以「答不下去」与「关掉它」是两件事。
|
|
368
|
+
*/
|
|
369
|
+
async cancel(questionId, language = DEFAULT_LANGUAGE) {
|
|
370
|
+
const question = await this.requireQuestion(questionId);
|
|
371
|
+
const cancelledAt = this.now();
|
|
372
|
+
await this.questions.update(question.id, { state: "cancelled", updatedAt: cancelledAt });
|
|
373
|
+
const row = await this.questions.get(question.id);
|
|
374
|
+
const view = toView(row ?? question, language);
|
|
375
|
+
this.emit?.("question", { question: view, at: iso(cancelledAt) });
|
|
376
|
+
return view;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* 答完之后接着往下走:交接(D22)。
|
|
380
|
+
*
|
|
381
|
+
* 只在问题已经落成 `answered` 之后才跑:答不下去的那些情况(越线、对话在跑)
|
|
382
|
+
* 在写状态之前就返回了,所以这里不需要再判断一遍。
|
|
383
|
+
*/
|
|
384
|
+
async applyEffects(input, language) {
|
|
385
|
+
if (input.kind !== "handoff" || input.handoffMode === null || !this.handoff) {
|
|
386
|
+
return noEffects();
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const started = await this.handoff.start({
|
|
390
|
+
conversationId: input.conversationId,
|
|
391
|
+
mode: input.handoffMode,
|
|
392
|
+
then: "new_conversation",
|
|
393
|
+
}, language);
|
|
394
|
+
return { handoffStarted: true, newConversationId: started.newConversationId };
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
// 用户取消了交接(F-D7:回到交接前,不重试、不报错):问题已答,只是没有交出文档
|
|
398
|
+
if (HostError.is(error) && error.code === "handoff_declined") {
|
|
399
|
+
return noEffects();
|
|
400
|
+
}
|
|
401
|
+
throw error;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async requireQuestion(questionId) {
|
|
405
|
+
const id = typeof questionId === "string" ? questionId.trim() : "";
|
|
406
|
+
const row = id.length === 0 ? undefined : await this.questions.get(id);
|
|
407
|
+
if (!row) {
|
|
408
|
+
throw new HostError("not_found", {
|
|
409
|
+
params: { entity: "question" },
|
|
410
|
+
detail: { questionId },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return row;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** 没有副作用 */
|
|
417
|
+
function noEffects() {
|
|
418
|
+
return { handoffStarted: false, newConversationId: null };
|
|
419
|
+
}
|
|
420
|
+
/** 这个候选值要不要交接、用哪种执行者(只有交接问题有这一层语义) */
|
|
421
|
+
function handoffModeOf(kind, value) {
|
|
422
|
+
if (kind !== "handoff")
|
|
423
|
+
return null;
|
|
424
|
+
if (value === HANDOFF_OPTION_SELF)
|
|
425
|
+
return "self";
|
|
426
|
+
if (value === HANDOFF_OPTION_SUMMARY)
|
|
427
|
+
return "summary";
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* 按配置与历史算「这段对话是不是已经过了硬线」。
|
|
432
|
+
*
|
|
433
|
+
* 硬线跟着模型走(`tokenThresholds.guard`,D27),用量取最近一条带用量的消息:
|
|
434
|
+
* 与 `conversation.state` 里那份 `ContextState` 是同一个口径(都看 `usage.prompt_tokens`)。
|
|
435
|
+
*/
|
|
436
|
+
export function createConversationGuardProbe(deps) {
|
|
437
|
+
return {
|
|
438
|
+
async overGuard(conversationId) {
|
|
439
|
+
const conversation = await deps.conversations.get(conversationId);
|
|
440
|
+
if (!conversation)
|
|
441
|
+
return false;
|
|
442
|
+
const config = await deps.configManager.read();
|
|
443
|
+
const model = config.models.find((candidate) => candidate.id === conversation.modelId);
|
|
444
|
+
const guardThreshold = model?.tokenThresholds.guard ?? 0;
|
|
445
|
+
if (guardThreshold <= 0)
|
|
446
|
+
return false;
|
|
447
|
+
const rows = await deps.messages.listByConversation(conversationId);
|
|
448
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
449
|
+
const row = rows[index];
|
|
450
|
+
const usage = row ? readUsage(row.data) : null;
|
|
451
|
+
if (usage)
|
|
452
|
+
return usage.prompt_tokens >= guardThreshold;
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜索服务:按名称找对话、按聊天内容全文找消息,都能按 Workspace 过滤。
|
|
3
|
+
*
|
|
4
|
+
* 对应能力编号:F-C6 / D16(名称搜索 + 全文搜索 + 项目过滤;中文分词成本接受)。
|
|
5
|
+
*
|
|
6
|
+
* 两条实现上的边界:
|
|
7
|
+
* 1. 全文检索走 `MessageRepository.search`(FTS5 trigram,短查询它自己退化到 `LIKE`),
|
|
8
|
+
* 服务层不写 SQL、也不自己分词 —— 分词口径随库一起改。
|
|
9
|
+
* 2. 对话命中的形状要和侧栏一致(`ConversationSummary`),所以摘要由
|
|
10
|
+
* {@link ConversationSummarizer} 注入,服务之间不各自拼一份。
|
|
11
|
+
*/
|
|
12
|
+
import type { ConversationRepository } from "../storage/ConversationRepository.js";
|
|
13
|
+
import type { MessageRepository } from "../storage/MessageRepository.js";
|
|
14
|
+
import type { Conversation } from "../storage/types.js";
|
|
15
|
+
import type { ConversationSummary, SearchConversationsParams, SearchConversationsResult, SearchMessagesParams, SearchMessagesResult } from "../wire/index.js";
|
|
16
|
+
/**
|
|
17
|
+
* 把一批对话行拼成侧栏摘要;入参顺序即出参顺序。
|
|
18
|
+
*
|
|
19
|
+
* 实现由 `ConversationService.summarize` 提供 —— 搜索命中里的对话与侧栏里的同一段对话,
|
|
20
|
+
* 必须长得一模一样(消息数、是否在跑、有没有问题挂着)。
|
|
21
|
+
*/
|
|
22
|
+
export type ConversationSummarizer = (rows: Conversation[]) => Promise<ConversationSummary[]>;
|
|
23
|
+
/** 搜索服务依赖 */
|
|
24
|
+
export interface SearchServiceDeps {
|
|
25
|
+
conversations: ConversationRepository;
|
|
26
|
+
messages: MessageRepository;
|
|
27
|
+
summarize: ConversationSummarizer;
|
|
28
|
+
}
|
|
29
|
+
export declare class SearchService {
|
|
30
|
+
private readonly conversationRepo;
|
|
31
|
+
private readonly messageRepo;
|
|
32
|
+
private readonly summarize;
|
|
33
|
+
constructor(deps: SearchServiceDeps);
|
|
34
|
+
/**
|
|
35
|
+
* 按名字搜对话(PRD F-C6 / D16)。
|
|
36
|
+
*
|
|
37
|
+
* 名字搜索走子串匹配而不是全文索引:名字短,还要能搜到一两个字的查询。空查询直接回空
|
|
38
|
+
* 结果(用户还没输入完),不当作错误。
|
|
39
|
+
*/
|
|
40
|
+
conversations(params: SearchConversationsParams): Promise<SearchConversationsResult>;
|
|
41
|
+
/**
|
|
42
|
+
* 按聊天内容全文搜消息(PRD F-C6 / D16)。
|
|
43
|
+
*
|
|
44
|
+
* 过滤范围:给了 `conversationId` 就搜这一段对话,否则给了 `workspaceId` 就搜这个项目下
|
|
45
|
+
* 的全部对话,都没给就是不限范围。
|
|
46
|
+
*
|
|
47
|
+
* `offset` 由服务层切片:仓储的搜索按相关度/时间排好序后一次取 `offset + limit` 条,
|
|
48
|
+
* 这里再切掉前 `offset` 条。于是 `total` 在不超过「取数上限」时是准确的条数,超过时是
|
|
49
|
+
* 一个下界(仓储没有单独的数数入口,见 `docs/plan-questions.md`)。
|
|
50
|
+
*/
|
|
51
|
+
messages(params: SearchMessagesParams): Promise<SearchMessagesResult>;
|
|
52
|
+
private scopeOf;
|
|
53
|
+
private toMessageHit;
|
|
54
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜索服务:按名称找对话、按聊天内容全文找消息,都能按 Workspace 过滤。
|
|
3
|
+
*
|
|
4
|
+
* 对应能力编号:F-C6 / D16(名称搜索 + 全文搜索 + 项目过滤;中文分词成本接受)。
|
|
5
|
+
*
|
|
6
|
+
* 两条实现上的边界:
|
|
7
|
+
* 1. 全文检索走 `MessageRepository.search`(FTS5 trigram,短查询它自己退化到 `LIKE`),
|
|
8
|
+
* 服务层不写 SQL、也不自己分词 —— 分词口径随库一起改。
|
|
9
|
+
* 2. 对话命中的形状要和侧栏一致(`ConversationSummary`),所以摘要由
|
|
10
|
+
* {@link ConversationSummarizer} 注入,服务之间不各自拼一份。
|
|
11
|
+
*/
|
|
12
|
+
import { HostError } from "../wire/errors.js";
|
|
13
|
+
import { toCoreRole } from "./MessageService.js";
|
|
14
|
+
/** 一次搜索默认返回多少条 */
|
|
15
|
+
const DEFAULT_LIMIT = 20;
|
|
16
|
+
/** 一次搜索最多返回多少条(再大对界面也没有意义,还会把传输撑起来) */
|
|
17
|
+
const MAX_LIMIT = 100;
|
|
18
|
+
/** 时间戳转 wire 上的 ISO 8601 */
|
|
19
|
+
function iso(ms) {
|
|
20
|
+
return new Date(ms).toISOString();
|
|
21
|
+
}
|
|
22
|
+
/** `limit` 缺省与上限都收在这里,两个方法共用 */
|
|
23
|
+
function clampLimit(limit) {
|
|
24
|
+
if (limit === undefined || !Number.isFinite(limit))
|
|
25
|
+
return DEFAULT_LIMIT;
|
|
26
|
+
return Math.min(Math.max(Math.trunc(limit), 1), MAX_LIMIT);
|
|
27
|
+
}
|
|
28
|
+
/** 名字里命中的那一段(供高亮):找不到时回查询词本身 */
|
|
29
|
+
function matchedSegment(name, query) {
|
|
30
|
+
const index = name.toLowerCase().indexOf(query.toLowerCase());
|
|
31
|
+
return index < 0 ? query : name.slice(index, index + query.length);
|
|
32
|
+
}
|
|
33
|
+
export class SearchService {
|
|
34
|
+
// 字段名带 `Repo` 后缀:`conversations` / `messages` 已经是方法名(对应 `search.*`)
|
|
35
|
+
conversationRepo;
|
|
36
|
+
messageRepo;
|
|
37
|
+
summarize;
|
|
38
|
+
constructor(deps) {
|
|
39
|
+
this.conversationRepo = deps.conversations;
|
|
40
|
+
this.messageRepo = deps.messages;
|
|
41
|
+
this.summarize = deps.summarize;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 按名字搜对话(PRD F-C6 / D16)。
|
|
45
|
+
*
|
|
46
|
+
* 名字搜索走子串匹配而不是全文索引:名字短,还要能搜到一两个字的查询。空查询直接回空
|
|
47
|
+
* 结果(用户还没输入完),不当作错误。
|
|
48
|
+
*/
|
|
49
|
+
async conversations(params) {
|
|
50
|
+
const query = typeof params.query === "string" ? params.query.trim() : "";
|
|
51
|
+
if (query.length === 0)
|
|
52
|
+
return { hits: [] };
|
|
53
|
+
const rows = await this.conversationRepo.searchByName(query, params.workspaceId, clampLimit(params.limit));
|
|
54
|
+
const summaries = await this.summarize(rows);
|
|
55
|
+
if (summaries.length !== rows.length) {
|
|
56
|
+
throw new HostError("internal", {
|
|
57
|
+
detail: {
|
|
58
|
+
reason: "summarizer returned a different number of rows",
|
|
59
|
+
rows: rows.length,
|
|
60
|
+
summaries: summaries.length,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
hits: rows.map((row, index) => ({
|
|
66
|
+
conversation: summaries[index],
|
|
67
|
+
matchedName: matchedSegment(row.name, query),
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 按聊天内容全文搜消息(PRD F-C6 / D16)。
|
|
73
|
+
*
|
|
74
|
+
* 过滤范围:给了 `conversationId` 就搜这一段对话,否则给了 `workspaceId` 就搜这个项目下
|
|
75
|
+
* 的全部对话,都没给就是不限范围。
|
|
76
|
+
*
|
|
77
|
+
* `offset` 由服务层切片:仓储的搜索按相关度/时间排好序后一次取 `offset + limit` 条,
|
|
78
|
+
* 这里再切掉前 `offset` 条。于是 `total` 在不超过「取数上限」时是准确的条数,超过时是
|
|
79
|
+
* 一个下界(仓储没有单独的数数入口,见 `docs/plan-questions.md`)。
|
|
80
|
+
*/
|
|
81
|
+
async messages(params) {
|
|
82
|
+
const query = typeof params.query === "string" ? params.query.trim() : "";
|
|
83
|
+
if (query.length === 0)
|
|
84
|
+
return { hits: [], total: 0 };
|
|
85
|
+
const limit = clampLimit(params.limit);
|
|
86
|
+
const offset = params.offset === undefined || !Number.isFinite(params.offset)
|
|
87
|
+
? 0
|
|
88
|
+
: Math.max(Math.trunc(params.offset), 0);
|
|
89
|
+
const scope = this.scopeOf(params);
|
|
90
|
+
const fetched = await this.messageRepo.search(scope, query, offset + limit);
|
|
91
|
+
const page = fetched.slice(offset);
|
|
92
|
+
const capped = fetched.length >= offset + limit;
|
|
93
|
+
return {
|
|
94
|
+
hits: page.map((hit) => this.toMessageHit(hit)),
|
|
95
|
+
total: capped ? offset + limit : fetched.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
scopeOf(params) {
|
|
99
|
+
if (params.conversationId !== undefined && params.conversationId.length > 0) {
|
|
100
|
+
return { conversationId: params.conversationId };
|
|
101
|
+
}
|
|
102
|
+
if (params.workspaceId !== undefined && params.workspaceId.length > 0) {
|
|
103
|
+
return { workspaceId: params.workspaceId };
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
toMessageHit(hit) {
|
|
108
|
+
return {
|
|
109
|
+
conversationId: hit.conversationId,
|
|
110
|
+
conversationName: hit.conversationName,
|
|
111
|
+
messageId: hit.id,
|
|
112
|
+
role: toCoreRole(hit.role),
|
|
113
|
+
snippet: hit.snippet,
|
|
114
|
+
createdAt: iso(hit.createdAt),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|