@klarkxy/dsh-current-title 0.1.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/LICENSE +42 -0
- package/README.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/README.zh-CN.md +13 -0
- package/lib/client.inner.cjs +455 -0
- package/lib/client.inner.cjs.map +1 -0
- package/lib/client.js +625 -0
- package/lib/contracts.d.ts +74 -0
- package/lib/contracts.js +43 -0
- package/lib/contracts.js.map +1 -0
- package/lib/index.d.ts +160 -0
- package/lib/index.js +714 -0
- package/lib/index.js.map +1 -0
- package/package.json +103 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { PLUGIN_NAME, PROMPT_VERSION, PROVIDER_ID, PURPOSE_ID, RPC_CHANNEL, SCHEMA_VERSION, SETTINGS_KEY, defaultGenerateConfig, defaultSettings, isTitleLocaleMode } from "./contracts.js";
|
|
2
|
+
import { registerHostRpc } from "@klarkxy/dsh-ai-services/host-rpc";
|
|
3
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/input.ts
|
|
6
|
+
const FRAME_PREFIX = "Choose the current task from these recent human messages, ordered oldest to newest:\n";
|
|
7
|
+
function frameMessages(messages) {
|
|
8
|
+
return `${FRAME_PREFIX}${JSON.stringify(messages)}`;
|
|
9
|
+
}
|
|
10
|
+
function utf8Bytes(value) {
|
|
11
|
+
return Buffer.byteLength(value, "utf8");
|
|
12
|
+
}
|
|
13
|
+
function middleSlice(codePoints, kept) {
|
|
14
|
+
if (kept >= codePoints.length) return codePoints.join("");
|
|
15
|
+
if (kept <= 0) return "";
|
|
16
|
+
if (kept === 1) return "…";
|
|
17
|
+
const contentPoints = kept - 1;
|
|
18
|
+
const headCount = Math.ceil(contentPoints / 2);
|
|
19
|
+
const tailCount = Math.floor(contentPoints / 2);
|
|
20
|
+
return `${codePoints.slice(0, headCount).join("")}…${tailCount === 0 ? "" : codePoints.slice(-tailCount).join("")}`;
|
|
21
|
+
}
|
|
22
|
+
function fitSingleMessage(message, maxInputBytes) {
|
|
23
|
+
if (utf8Bytes(frameMessages([{
|
|
24
|
+
...message,
|
|
25
|
+
text: ""
|
|
26
|
+
}])) > maxInputBytes) throw new Error("dsh-current-title: maxInputBytes is too small for the input envelope");
|
|
27
|
+
if (utf8Bytes(frameMessages([message])) <= maxInputBytes) return message;
|
|
28
|
+
const points = Array.from(message.text);
|
|
29
|
+
let low = 0;
|
|
30
|
+
let high = points.length;
|
|
31
|
+
let best = "";
|
|
32
|
+
while (low <= high) {
|
|
33
|
+
const kept = Math.floor((low + high) / 2);
|
|
34
|
+
const text = middleSlice(points, kept);
|
|
35
|
+
if (utf8Bytes(frameMessages([{
|
|
36
|
+
...message,
|
|
37
|
+
text
|
|
38
|
+
}])) <= maxInputBytes) {
|
|
39
|
+
best = text;
|
|
40
|
+
low = kept + 1;
|
|
41
|
+
} else high = kept - 1;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...message,
|
|
45
|
+
text: best
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function selectRecentMessages(messages, maxRecentMessages, maxInputBytes) {
|
|
49
|
+
if (messages.length === 0) return [];
|
|
50
|
+
const selected = messages.slice(-maxRecentMessages).map((message) => ({ ...message }));
|
|
51
|
+
while (selected.length > 1 && utf8Bytes(frameMessages(selected)) > maxInputBytes) selected.shift();
|
|
52
|
+
if (utf8Bytes(frameMessages(selected)) > maxInputBytes) selected[0] = fitSingleMessage(selected[0], maxInputBytes);
|
|
53
|
+
return selected;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/output.ts
|
|
57
|
+
const TITLE_TYPE_KEYS = [
|
|
58
|
+
"feature",
|
|
59
|
+
"fix",
|
|
60
|
+
"optimize",
|
|
61
|
+
"refactor",
|
|
62
|
+
"test",
|
|
63
|
+
"docs",
|
|
64
|
+
"release",
|
|
65
|
+
"config",
|
|
66
|
+
"explore",
|
|
67
|
+
"discuss"
|
|
68
|
+
];
|
|
69
|
+
const TITLE_TYPE_LABELS = {
|
|
70
|
+
zh: {
|
|
71
|
+
feature: "功能",
|
|
72
|
+
fix: "修复",
|
|
73
|
+
optimize: "优化",
|
|
74
|
+
refactor: "重构",
|
|
75
|
+
test: "测试",
|
|
76
|
+
docs: "文档",
|
|
77
|
+
release: "发布",
|
|
78
|
+
config: "配置",
|
|
79
|
+
explore: "探索",
|
|
80
|
+
discuss: "讨论"
|
|
81
|
+
},
|
|
82
|
+
en: {
|
|
83
|
+
feature: "Feature",
|
|
84
|
+
fix: "Fix",
|
|
85
|
+
optimize: "Optimize",
|
|
86
|
+
refactor: "Refactor",
|
|
87
|
+
test: "Test",
|
|
88
|
+
docs: "Docs",
|
|
89
|
+
release: "Release",
|
|
90
|
+
config: "Config",
|
|
91
|
+
explore: "Explore",
|
|
92
|
+
discuss: "Discussion"
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu;
|
|
96
|
+
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
|
|
97
|
+
const ESC_SEQUENCE = /\u001B[@-_]/gu;
|
|
98
|
+
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu;
|
|
99
|
+
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu;
|
|
100
|
+
function cleanTitleText(input) {
|
|
101
|
+
return input.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESC_SEQUENCE, "").replace(CONTROL_CHARACTER, "").replace(DIRECTIONAL_CONTROL, "").replace(/\s+/gu, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
function truncateTitleUtf8(input, maxBytes) {
|
|
104
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0) throw new Error("maxBytes must be a positive integer");
|
|
105
|
+
if (Buffer.byteLength(input, "utf8") <= maxBytes) return input;
|
|
106
|
+
let used = 0;
|
|
107
|
+
let output = "";
|
|
108
|
+
for (const character of input) {
|
|
109
|
+
const bytes = Buffer.byteLength(character, "utf8");
|
|
110
|
+
if (used + bytes > maxBytes) break;
|
|
111
|
+
output += character;
|
|
112
|
+
used += bytes;
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
/** Same sanitizing rules as DSH 0.1.7-alpha.1 `normalizeSessionTitle`. */
|
|
117
|
+
function normalizeSessionTitle(input, maxBytes) {
|
|
118
|
+
return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd();
|
|
119
|
+
}
|
|
120
|
+
function isTitleType(value) {
|
|
121
|
+
return TITLE_TYPE_KEYS.includes(value);
|
|
122
|
+
}
|
|
123
|
+
function resolveTitleLocale(mode, messages, preference) {
|
|
124
|
+
if (mode !== "auto") return mode;
|
|
125
|
+
const preferred = preference?.trim().toLowerCase().split("-")[0];
|
|
126
|
+
if (preferred === "zh" || preferred === "en") return preferred;
|
|
127
|
+
const text = messages.join("\n");
|
|
128
|
+
if (/[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(text)) return "en";
|
|
129
|
+
return /\p{Script=Han}/u.test(text) ? "zh" : "en";
|
|
130
|
+
}
|
|
131
|
+
function removeTitleSyntax(value) {
|
|
132
|
+
return value.replace(/[|`*_~#\[\]"“”]/gu, " ");
|
|
133
|
+
}
|
|
134
|
+
function truncateSummary(value, targetWords, targetCjkCharacters) {
|
|
135
|
+
if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value)) return Array.from(value.replace(/\s+/gu, "")).slice(0, targetCjkCharacters).join("");
|
|
136
|
+
return value.split(/\s+/u).filter(Boolean).slice(0, targetWords).join(" ");
|
|
137
|
+
}
|
|
138
|
+
function parseModelTitle(raw, targetWords, targetCjkCharacters) {
|
|
139
|
+
let value;
|
|
140
|
+
try {
|
|
141
|
+
value = JSON.parse(raw.trim());
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
throw new Error("dsh-current-title: title model returned invalid JSON", { cause });
|
|
144
|
+
}
|
|
145
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("dsh-current-title: title model response must be an object");
|
|
146
|
+
const candidate = value;
|
|
147
|
+
if (Object.keys(candidate).sort().join(",") !== "summary,type") throw new Error("dsh-current-title: title model response must contain only type and summary");
|
|
148
|
+
if (typeof candidate.type !== "string" || !isTitleType(candidate.type)) throw new Error("dsh-current-title: title model returned an unsupported type");
|
|
149
|
+
if (typeof candidate.summary !== "string") throw new Error("dsh-current-title: title model summary must be a string");
|
|
150
|
+
const summary = truncateSummary(normalizeSessionTitle(removeTitleSyntax(candidate.summary), Number.MAX_SAFE_INTEGER), targetWords, targetCjkCharacters);
|
|
151
|
+
if (summary.length === 0) throw new Error("dsh-current-title: title model returned an empty summary");
|
|
152
|
+
return {
|
|
153
|
+
type: candidate.type,
|
|
154
|
+
summary
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function localMonthDay(now) {
|
|
158
|
+
return `${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
|
159
|
+
}
|
|
160
|
+
function formatTitle(parsed, locale, now = /* @__PURE__ */ new Date()) {
|
|
161
|
+
return `${localMonthDay(now)} | ${TITLE_TYPE_LABELS[locale][parsed.type]} | ${parsed.summary}`;
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/messages.ts
|
|
165
|
+
function textFromContent(content) {
|
|
166
|
+
if (!Array.isArray(content)) return "";
|
|
167
|
+
return content.filter((block) => block && block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
|
|
168
|
+
}
|
|
169
|
+
/** Eligible human prompts only. Plugin/tool/model user-role rows are excluded. */
|
|
170
|
+
function collectHumanMessages(events, throughSeq) {
|
|
171
|
+
const messages = [];
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
if (throughSeq !== void 0 && event.seq > throughSeq) break;
|
|
174
|
+
if (event.type !== "user/message") continue;
|
|
175
|
+
if (event.data?.source?.kind !== "user") continue;
|
|
176
|
+
const text = textFromContent(event.data.content);
|
|
177
|
+
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue;
|
|
178
|
+
messages.push({
|
|
179
|
+
seq: event.seq,
|
|
180
|
+
text
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return messages;
|
|
184
|
+
}
|
|
185
|
+
function sourceVersionOf(sessionId, messages) {
|
|
186
|
+
return `${sessionId}:${messages.at(-1)?.seq ?? -1}:${messages.length}`;
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/generate.ts
|
|
190
|
+
function systemPrompt(config) {
|
|
191
|
+
return [
|
|
192
|
+
"Name the current task in an AI coding-assistant session.",
|
|
193
|
+
"Prioritize the newest human messages. Older messages are context only and must not keep a completed task in the title.",
|
|
194
|
+
"Allowed type values: feature, fix, optimize, refactor, test, docs, release, config, explore, discuss.",
|
|
195
|
+
"Return exactly one JSON object: {\"type\":\"<type>\",\"summary\":\"<summary>\"}.",
|
|
196
|
+
"Return no Markdown, code fence, explanation, date, or extra key.",
|
|
197
|
+
"Keep the summary in the language of the recent messages.",
|
|
198
|
+
`Aim for at most ${config.targetWords} non-CJK words or ${config.targetCjkCharacters} CJK characters.`
|
|
199
|
+
].join("\n");
|
|
200
|
+
}
|
|
201
|
+
function registerTitlePurpose(scope) {
|
|
202
|
+
return scope.registerPurpose({
|
|
203
|
+
id: PURPOSE_ID,
|
|
204
|
+
label: "当前标题",
|
|
205
|
+
defaultTarget: {
|
|
206
|
+
kind: "role",
|
|
207
|
+
role: "weak"
|
|
208
|
+
},
|
|
209
|
+
maxOutputTokens: 64
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
async function generateCurrentTitle(options) {
|
|
213
|
+
options.signal.throwIfAborted();
|
|
214
|
+
if (!options.scope.active || !options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
|
|
215
|
+
const selected = selectRecentMessages(options.messages, options.config.maxRecentMessages, options.config.maxInputBytes);
|
|
216
|
+
if (selected.length === 0) throw new Error("dsh-current-title: at least one source message is required");
|
|
217
|
+
const framedInput = frameMessages(selected);
|
|
218
|
+
const locale = resolveTitleLocale(options.localeMode ?? options.config.locale, selected.map((message) => message.text), options.localePreference);
|
|
219
|
+
const sourceVersion = sourceVersionOf(options.sessionId, selected);
|
|
220
|
+
let settled = false;
|
|
221
|
+
const aborted = new Promise((_, reject) => {
|
|
222
|
+
const fail = () => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
reject(options.signal.reason ?? /* @__PURE__ */ new Error("dsh-current-title: generation cancelled"));
|
|
225
|
+
};
|
|
226
|
+
if (options.signal.aborted) {
|
|
227
|
+
fail();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
options.signal.addEventListener("abort", fail, { once: true });
|
|
231
|
+
});
|
|
232
|
+
let result;
|
|
233
|
+
try {
|
|
234
|
+
result = await Promise.race([options.scope.run({
|
|
235
|
+
purpose: PURPOSE_ID,
|
|
236
|
+
sessionId: options.sessionId,
|
|
237
|
+
input: framedInput,
|
|
238
|
+
system: systemPrompt(options.config),
|
|
239
|
+
sourceVersion,
|
|
240
|
+
contractVersion: 1,
|
|
241
|
+
promptVersion: PROMPT_VERSION,
|
|
242
|
+
schemaVersion: SCHEMA_VERSION,
|
|
243
|
+
signal: options.signal,
|
|
244
|
+
isCurrent: () => options.scope.active && options.isCurrent(),
|
|
245
|
+
priority: "background"
|
|
246
|
+
}), aborted]);
|
|
247
|
+
} finally {
|
|
248
|
+
settled = true;
|
|
249
|
+
}
|
|
250
|
+
options.signal.throwIfAborted();
|
|
251
|
+
if (!options.scope.active || !options.isCurrent()) throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation is no longer current"), { code: "superseded" });
|
|
252
|
+
if (result.receipt.status === "cancelled" || result.receipt.status === "superseded") throw Object.assign(/* @__PURE__ */ new Error("dsh-current-title: generation did not complete"), { code: result.receipt.status });
|
|
253
|
+
const title = normalizeSessionTitle(formatTitle(parseModelTitle(result.text, options.config.targetWords, options.config.targetCjkCharacters), locale, options.now ?? /* @__PURE__ */ new Date()), options.config.maxTitleBytes);
|
|
254
|
+
if (!title) throw new Error("dsh-current-title: title model returned an empty summary");
|
|
255
|
+
const route = result.receipt.route;
|
|
256
|
+
return {
|
|
257
|
+
title,
|
|
258
|
+
messageSeqs: selected.map((message) => message.seq),
|
|
259
|
+
...route?.provider && route.model ? { model: {
|
|
260
|
+
provider: route.provider,
|
|
261
|
+
model: route.model
|
|
262
|
+
} } : {},
|
|
263
|
+
sourceVersion
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/ownership.ts
|
|
268
|
+
/** Exclusive title-provider ownership. Enable claims the slot; disable releases only while this id still holds it. */
|
|
269
|
+
async function claimTitleSlot(slot, id) {
|
|
270
|
+
return {
|
|
271
|
+
id,
|
|
272
|
+
dispose: await slot.occupy(id)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
async function releaseTitleSlot(slot, claim) {
|
|
276
|
+
if (!claim) return "idle";
|
|
277
|
+
if (slot.owner() !== claim.id) return "left-other-owner";
|
|
278
|
+
await claim.dispose();
|
|
279
|
+
if (slot.owner() && slot.owner() !== claim.id) return "left-other-owner";
|
|
280
|
+
return "released";
|
|
281
|
+
}
|
|
282
|
+
function occupiedProviderId(error) {
|
|
283
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
284
|
+
return /session-title provider "([^"]+)" is already registered/.exec(message)?.[1];
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/native-slot.ts
|
|
288
|
+
function catalogLookupId(entryId) {
|
|
289
|
+
const colon = entryId.indexOf(":");
|
|
290
|
+
return colon === -1 ? entryId : entryId.slice(colon + 1);
|
|
291
|
+
}
|
|
292
|
+
function entryName(entry) {
|
|
293
|
+
return String(entry.options?.name ?? "");
|
|
294
|
+
}
|
|
295
|
+
function entryDisabled(entry) {
|
|
296
|
+
return entry.options?.disabled === true;
|
|
297
|
+
}
|
|
298
|
+
function displacementIdentity(entry) {
|
|
299
|
+
const { disabled: _disabled, ...rest } = entry.options ?? {};
|
|
300
|
+
return JSON.stringify({
|
|
301
|
+
id: entry.id,
|
|
302
|
+
name: entryName(entry),
|
|
303
|
+
rest
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function captureDisplacement(entry) {
|
|
307
|
+
return {
|
|
308
|
+
id: entry.id,
|
|
309
|
+
name: entryName(entry),
|
|
310
|
+
identity: displacementIdentity(entry)
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function displacementUnchanged(snapshot, entry) {
|
|
314
|
+
return entry.id === snapshot.id && entryName(entry) === snapshot.name && displacementIdentity(entry) === snapshot.identity;
|
|
315
|
+
}
|
|
316
|
+
function findEntryById(loader, id) {
|
|
317
|
+
for (const entry of loader.entries()) if (entry.id === id) return entry;
|
|
318
|
+
}
|
|
319
|
+
function findLoaderEntry(loader, providerId) {
|
|
320
|
+
for (const entry of loader.entries()) {
|
|
321
|
+
const name = entryName(entry);
|
|
322
|
+
if ([
|
|
323
|
+
entry.id,
|
|
324
|
+
entry.options?.id,
|
|
325
|
+
name,
|
|
326
|
+
catalogLookupId(entry.id)
|
|
327
|
+
].some((value) => value === providerId)) return entry;
|
|
328
|
+
if (name.endsWith(`/${providerId}`) || name.endsWith(providerId)) return entry;
|
|
329
|
+
if (providerId === "session-title-first-prompt-llm" && catalogLookupId(entry.id) === "session-title-llm") return entry;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function restoreOwnDisplacement(loader, snapshot) {
|
|
333
|
+
if (!loader || !snapshot) return "skipped";
|
|
334
|
+
const current = findEntryById(loader, snapshot.id);
|
|
335
|
+
if (!current || !entryDisabled(current) || !displacementUnchanged(snapshot, current)) return "skipped";
|
|
336
|
+
await loader.update(snapshot.id, { disabled: false });
|
|
337
|
+
return "restored";
|
|
338
|
+
}
|
|
339
|
+
function createNativeTitleSlot(options) {
|
|
340
|
+
let owner;
|
|
341
|
+
const slot = {
|
|
342
|
+
owner: () => owner,
|
|
343
|
+
async occupy(id) {
|
|
344
|
+
const provider = {
|
|
345
|
+
id: PROVIDER_ID,
|
|
346
|
+
automatic: "all-prompts",
|
|
347
|
+
generate: options.generate
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
const dispose = options.sessionTitle.register(provider);
|
|
351
|
+
owner = id;
|
|
352
|
+
return async () => {
|
|
353
|
+
await dispose();
|
|
354
|
+
if (owner === id) owner = void 0;
|
|
355
|
+
};
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const occupied = occupiedProviderId(error);
|
|
358
|
+
if (!occupied) throw error;
|
|
359
|
+
const entry = findLoaderEntry(options.loader, occupied);
|
|
360
|
+
if (!entry || entryDisabled(entry)) throw error;
|
|
361
|
+
const snapshot = captureDisplacement(entry);
|
|
362
|
+
await options.loader.update(entry.id, { disabled: true });
|
|
363
|
+
const afterDisable = findEntryById(options.loader, entry.id);
|
|
364
|
+
if (!afterDisable || !displacementUnchanged(snapshot, afterDisable)) throw error;
|
|
365
|
+
try {
|
|
366
|
+
const dispose = options.sessionTitle.register(provider);
|
|
367
|
+
slot.displacement = snapshot;
|
|
368
|
+
owner = id;
|
|
369
|
+
return async () => {
|
|
370
|
+
await dispose();
|
|
371
|
+
if (owner === id) owner = void 0;
|
|
372
|
+
};
|
|
373
|
+
} catch (second) {
|
|
374
|
+
if (findEntryById(options.loader, snapshot.id) && displacementUnchanged(snapshot, findEntryById(options.loader, snapshot.id))) await options.loader.update(snapshot.id, { disabled: false });
|
|
375
|
+
throw second;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
return slot;
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/storage.ts
|
|
384
|
+
const titleSettingsSchema = z.object({
|
|
385
|
+
revision: z.number().int().nonnegative(),
|
|
386
|
+
locale: z.enum([
|
|
387
|
+
"auto",
|
|
388
|
+
"zh",
|
|
389
|
+
"en"
|
|
390
|
+
])
|
|
391
|
+
}).strict();
|
|
392
|
+
const titleDomain = defineDomain({
|
|
393
|
+
name: "dsh_editor_current_title",
|
|
394
|
+
version: 1,
|
|
395
|
+
tables: { settings: domainTable(titleSettingsSchema) }
|
|
396
|
+
});
|
|
397
|
+
function parseTitleSettings(value) {
|
|
398
|
+
const parsed = titleSettingsSchema.safeParse(value);
|
|
399
|
+
if (!parsed.success) return void 0;
|
|
400
|
+
if (!isTitleLocaleMode(parsed.data.locale)) return void 0;
|
|
401
|
+
return parsed.data;
|
|
402
|
+
}
|
|
403
|
+
function storedSettings(value, fallbackLocale = "auto") {
|
|
404
|
+
return parseTitleSettings(value) ?? defaultSettings(fallbackLocale);
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/service.ts
|
|
408
|
+
var CurrentTitleService = class {
|
|
409
|
+
options;
|
|
410
|
+
settings;
|
|
411
|
+
config;
|
|
412
|
+
scope;
|
|
413
|
+
unregisterPurpose;
|
|
414
|
+
claim;
|
|
415
|
+
nativeSlot;
|
|
416
|
+
generation = 0;
|
|
417
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
418
|
+
support;
|
|
419
|
+
constructor(options) {
|
|
420
|
+
this.options = options;
|
|
421
|
+
const stored = storedSettings(options.store?.load(), options.initialLocale ?? "auto");
|
|
422
|
+
this.settings = stored;
|
|
423
|
+
this.config = defaultGenerateConfig(this.settings.locale);
|
|
424
|
+
this.support = {
|
|
425
|
+
nativeOwner: null,
|
|
426
|
+
weOwn: false
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async start() {
|
|
430
|
+
const { ai, sessionTitle, loader, sessions } = this.options;
|
|
431
|
+
if (!ai || !sessionTitle?.register || !sessions || !loader) {
|
|
432
|
+
this.support.weOwn = false;
|
|
433
|
+
this.support.limitation = "required host services are missing";
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.scope = ai.activate(this.options.plugin);
|
|
437
|
+
this.unregisterPurpose = registerTitlePurpose(this.scope);
|
|
438
|
+
this.nativeSlot = createNativeTitleSlot({
|
|
439
|
+
sessionTitle,
|
|
440
|
+
loader,
|
|
441
|
+
generate: (request) => this.nativeGenerate(request)
|
|
442
|
+
});
|
|
443
|
+
try {
|
|
444
|
+
this.claim = await claimTitleSlot(this.nativeSlot, PROVIDER_ID);
|
|
445
|
+
this.support.weOwn = true;
|
|
446
|
+
this.support.nativeOwner = PROVIDER_ID;
|
|
447
|
+
delete this.support.limitation;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
this.support.weOwn = false;
|
|
450
|
+
this.support.nativeOwner = this.nativeSlot.owner() ?? null;
|
|
451
|
+
this.support.limitation = error instanceof Error ? error.message : "native title provider slot is occupied";
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async dispose() {
|
|
455
|
+
this.generation += 1;
|
|
456
|
+
for (const controller of this.inFlight.values()) controller.abort(/* @__PURE__ */ new Error("current-title disabled"));
|
|
457
|
+
this.inFlight.clear();
|
|
458
|
+
const snapshot = this.nativeSlot?.displacement;
|
|
459
|
+
const outcome = await releaseTitleSlot(this.nativeSlot ?? {
|
|
460
|
+
owner: () => void 0,
|
|
461
|
+
occupy: async () => () => {}
|
|
462
|
+
}, this.claim);
|
|
463
|
+
if (outcome === "released") await restoreOwnDisplacement(this.options.loader, snapshot);
|
|
464
|
+
this.claim = void 0;
|
|
465
|
+
this.support.weOwn = false;
|
|
466
|
+
this.support.nativeOwner = this.nativeSlot?.owner() ?? null;
|
|
467
|
+
if (outcome === "left-other-owner") this.support.limitation = "another title provider owns the slot; previous owner was not restored";
|
|
468
|
+
this.unregisterPurpose?.();
|
|
469
|
+
this.unregisterPurpose = void 0;
|
|
470
|
+
this.scope?.dispose();
|
|
471
|
+
this.scope = void 0;
|
|
472
|
+
}
|
|
473
|
+
status(sessionId) {
|
|
474
|
+
const session = sessionId && this.options.sessions ? this.options.sessions.get(sessionId) : void 0;
|
|
475
|
+
const snapshot = session ? this.read(session) : void 0;
|
|
476
|
+
return {
|
|
477
|
+
settings: { ...this.settings },
|
|
478
|
+
support: {
|
|
479
|
+
...this.support,
|
|
480
|
+
nativeOwner: this.nativeSlot?.owner() ?? this.support.nativeOwner
|
|
481
|
+
},
|
|
482
|
+
...sessionId ? { session: {
|
|
483
|
+
sessionId,
|
|
484
|
+
title: snapshot?.title,
|
|
485
|
+
sourceKind: snapshot?.source.kind,
|
|
486
|
+
generating: this.inFlight.has(sessionId),
|
|
487
|
+
pinned: snapshot?.source.kind === "user"
|
|
488
|
+
} } : {}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async updateLocale(locale, expectedRevision) {
|
|
492
|
+
if (this.settings.revision !== expectedRevision) throw Object.assign(/* @__PURE__ */ new Error("current-title settings changed"), { code: "revision" });
|
|
493
|
+
const next = {
|
|
494
|
+
revision: this.settings.revision + 1,
|
|
495
|
+
locale
|
|
496
|
+
};
|
|
497
|
+
if (this.options.store) await this.options.store.save(next);
|
|
498
|
+
this.settings = next;
|
|
499
|
+
this.config = {
|
|
500
|
+
...this.config,
|
|
501
|
+
locale
|
|
502
|
+
};
|
|
503
|
+
return { ...this.settings };
|
|
504
|
+
}
|
|
505
|
+
async regenerate(sessionId, signal) {
|
|
506
|
+
if (!this.support.weOwn) throw Object.assign(/* @__PURE__ */ new Error("title provider is inactive"), { code: "unavailable" });
|
|
507
|
+
const session = this.options.sessions?.get(sessionId);
|
|
508
|
+
if (!session) throw Object.assign(/* @__PURE__ */ new Error("session not found"), { code: "not-found" });
|
|
509
|
+
await this.options.sessionTitle?.refresh(session, signal);
|
|
510
|
+
return this.read(session);
|
|
511
|
+
}
|
|
512
|
+
read(session) {
|
|
513
|
+
const snapshot = this.options.sessionTitle?.get(session);
|
|
514
|
+
if (!snapshot?.title) return void 0;
|
|
515
|
+
const kind = snapshot.source?.kind;
|
|
516
|
+
return {
|
|
517
|
+
title: snapshot.title,
|
|
518
|
+
messageSeqs: snapshot.messageSeqs ? [...snapshot.messageSeqs] : [],
|
|
519
|
+
source: {
|
|
520
|
+
kind: kind === "user" || kind === "fallback" || kind === "provider" ? kind : "provider",
|
|
521
|
+
...typeof snapshot.source?.provider === "string" ? { provider: snapshot.source.provider } : {}
|
|
522
|
+
},
|
|
523
|
+
eventSeq: Number(snapshot.eventSeq ?? 0),
|
|
524
|
+
updatedAt: snapshot.updatedAt ?? 0
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async nativeGenerate(request) {
|
|
528
|
+
const scope = this.scope;
|
|
529
|
+
if (!scope) throw new Error("dsh-current-title: aiServices is not available");
|
|
530
|
+
const generation = this.generation;
|
|
531
|
+
const controller = new AbortController();
|
|
532
|
+
this.inFlight.get(request.session.id)?.abort(/* @__PURE__ */ new Error("newer title generation superseded older work"));
|
|
533
|
+
this.inFlight.set(request.session.id, controller);
|
|
534
|
+
try {
|
|
535
|
+
const combined = AbortSignal.any([
|
|
536
|
+
request.signal,
|
|
537
|
+
controller.signal,
|
|
538
|
+
scope.signal
|
|
539
|
+
]);
|
|
540
|
+
const session = this.options.sessions?.get(request.session.id);
|
|
541
|
+
const messages = collectHumanMessages(request.messages.map((message) => ({
|
|
542
|
+
type: "user/message",
|
|
543
|
+
seq: message.seq,
|
|
544
|
+
data: {
|
|
545
|
+
source: { kind: "user" },
|
|
546
|
+
content: [{
|
|
547
|
+
type: "text",
|
|
548
|
+
text: message.text
|
|
549
|
+
}]
|
|
550
|
+
}
|
|
551
|
+
})));
|
|
552
|
+
const generated = await generateCurrentTitle({
|
|
553
|
+
scope,
|
|
554
|
+
config: this.config,
|
|
555
|
+
sessionId: request.session.id,
|
|
556
|
+
messages: messages.length > 0 ? messages : [...request.messages],
|
|
557
|
+
localePreference: this.options.localePreference?.(),
|
|
558
|
+
signal: combined,
|
|
559
|
+
isCurrent: () => this.generation === generation && scope.active && this.support.weOwn && (!session || this.options.sessions?.get(request.session.id) === session),
|
|
560
|
+
now: this.options.now?.()
|
|
561
|
+
});
|
|
562
|
+
return {
|
|
563
|
+
title: generated.title,
|
|
564
|
+
messageSeqs: generated.messageSeqs,
|
|
565
|
+
model: generated.model
|
|
566
|
+
};
|
|
567
|
+
} finally {
|
|
568
|
+
if (this.inFlight.get(request.session.id) === controller) this.inFlight.delete(request.session.id);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
function hostLocalePreference(host) {
|
|
573
|
+
try {
|
|
574
|
+
const value = host.get("settings")?.get?.("locale");
|
|
575
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
576
|
+
const preference = value.preference;
|
|
577
|
+
return typeof preference === "string" ? preference : void 0;
|
|
578
|
+
} catch {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
async function handleRpc(service, endpoint, payload, signal) {
|
|
583
|
+
signal.throwIfAborted();
|
|
584
|
+
const body = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
|
|
585
|
+
if (endpoint === "status") return {
|
|
586
|
+
ok: true,
|
|
587
|
+
value: service.status(typeof body.sessionId === "string" ? body.sessionId : void 0)
|
|
588
|
+
};
|
|
589
|
+
if (endpoint === "settings") {
|
|
590
|
+
if (!isTitleLocaleMode(body.locale) || typeof body.expectedRevision !== "number") return {
|
|
591
|
+
ok: false,
|
|
592
|
+
error: {
|
|
593
|
+
code: "bad-request",
|
|
594
|
+
message: "标题语言设置无效。"
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
try {
|
|
598
|
+
return {
|
|
599
|
+
ok: true,
|
|
600
|
+
value: await service.updateLocale(body.locale, body.expectedRevision)
|
|
601
|
+
};
|
|
602
|
+
} catch (error) {
|
|
603
|
+
const code = error instanceof Error && "code" in error ? String(error.code) : "failed";
|
|
604
|
+
if (code === "revision") return {
|
|
605
|
+
ok: false,
|
|
606
|
+
error: {
|
|
607
|
+
code,
|
|
608
|
+
message: "设置已更新,请刷新后重试。"
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
error: {
|
|
614
|
+
code: "failed",
|
|
615
|
+
message: "无法保存标题设置。"
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (endpoint === "regenerate") {
|
|
621
|
+
if (typeof body.sessionId !== "string" || !body.sessionId) return {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: {
|
|
624
|
+
code: "bad-request",
|
|
625
|
+
message: "缺少会话。"
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
try {
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
value: await service.regenerate(body.sessionId, signal) ?? null
|
|
632
|
+
};
|
|
633
|
+
} catch (error) {
|
|
634
|
+
const code = error instanceof Error && "code" in error ? String(error.code) : "failed";
|
|
635
|
+
if (code === "not-found") return {
|
|
636
|
+
ok: false,
|
|
637
|
+
error: {
|
|
638
|
+
code,
|
|
639
|
+
message: "找不到该会话。"
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
if (code === "unavailable") return {
|
|
643
|
+
ok: false,
|
|
644
|
+
error: {
|
|
645
|
+
code,
|
|
646
|
+
message: "标题服务未就绪。"
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
if (signal.aborted) return {
|
|
650
|
+
ok: false,
|
|
651
|
+
error: {
|
|
652
|
+
code: "cancelled",
|
|
653
|
+
message: "已取消。"
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
error: {
|
|
659
|
+
code: "failed",
|
|
660
|
+
message: "标题生成失败。"
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
ok: false,
|
|
667
|
+
error: {
|
|
668
|
+
code: "bad-request",
|
|
669
|
+
message: "未知操作。"
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/index.ts
|
|
675
|
+
const name = PLUGIN_NAME;
|
|
676
|
+
const inject = [
|
|
677
|
+
"sessionTitle",
|
|
678
|
+
"aiServices",
|
|
679
|
+
"sessions",
|
|
680
|
+
"loader",
|
|
681
|
+
"storageDomain",
|
|
682
|
+
"connection",
|
|
683
|
+
"webServer"
|
|
684
|
+
];
|
|
685
|
+
async function apply(ctx, config = {}) {
|
|
686
|
+
const host = ctx;
|
|
687
|
+
const domain = await host.storageDomain.open(titleDomain);
|
|
688
|
+
const table = domain.table("settings");
|
|
689
|
+
const locale = isTitleLocaleMode(config.locale) ? config.locale : "auto";
|
|
690
|
+
const service = new CurrentTitleService({
|
|
691
|
+
plugin: PLUGIN_NAME,
|
|
692
|
+
ai: host.aiServices,
|
|
693
|
+
sessionTitle: host.sessionTitle,
|
|
694
|
+
loader: host.loader,
|
|
695
|
+
sessions: host.sessions,
|
|
696
|
+
store: {
|
|
697
|
+
load: () => parseTitleSettings(table.get(SETTINGS_KEY)),
|
|
698
|
+
save: (settings) => table.put(SETTINGS_KEY, settings)
|
|
699
|
+
},
|
|
700
|
+
initialLocale: locale,
|
|
701
|
+
localePreference: () => hostLocalePreference(host)
|
|
702
|
+
});
|
|
703
|
+
await service.start();
|
|
704
|
+
ctx.provide("currentTitle", service);
|
|
705
|
+
ctx.effect(() => async () => {
|
|
706
|
+
await service.dispose();
|
|
707
|
+
await domain.close();
|
|
708
|
+
}, "current-title.lifecycle");
|
|
709
|
+
ctx.effect(() => registerHostRpc(host, RPC_CHANNEL, (endpoint, payload, signal) => handleRpc(service, endpoint, payload, signal)), "current-title.rpc");
|
|
710
|
+
}
|
|
711
|
+
//#endregion
|
|
712
|
+
export { CurrentTitleService, apply, formatTitle, frameMessages, inject, name, parseModelTitle, resolveTitleLocale, selectRecentMessages };
|
|
713
|
+
|
|
714
|
+
//# sourceMappingURL=index.js.map
|