dsh-cuigengji-plugin 0.4.3 → 0.4.4
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 +21 -21
- package/README.md +1 -1
- package/cordis.patch.yml +3 -3
- package/docs/CHANGELOG.md +7 -0
- package/docs/agent-installation.md +19 -12
- package/docs/architecture.md +74 -74
- package/docs/desktop-migration-20260930.md +26 -26
- package/docs/development.md +7 -3
- package/docs/getting-started.md +17 -7
- package/docs/layout-progress.md +31 -31
- package/docs/market-listing.md +5 -3
- package/docs/npm-publishing.md +28 -12
- package/docs/package-name-migration.md +3 -3
- package/docs/planning-board.md +44 -44
- package/docs/presets.md +24 -24
- package/docs/release-process.md +15 -11
- package/docs/tavern-compatibility-audit-20261001.md +109 -109
- package/docs/test-report.md +139 -129
- package/docs/upgrade.md +3 -1
- package/docs/usage-review.md +13 -13
- package/lib/client.js +18 -14
- package/package.json +18 -14
- package/skills/cuigengji-maintenance/SKILL.md +2 -2
- package/src/adapters/dsh/prompt.js +36 -36
- package/src/adapters/dsh/rpc.js +96 -96
- package/src/adapters/dsh/skills.js +17 -17
- package/src/adapters/mcp/actions.js +6 -6
- package/src/adapters/mcp/client.js +172 -172
- package/src/adapters/mcp/server.js +40 -40
- package/src/application/bindings/session.js +72 -72
- package/src/application/queries/context.js +15 -15
- package/src/application/store.js +412 -412
- package/src/client/features/chapters/clipboard.ts +27 -27
- package/src/client/features/chapters/history.ts +11 -11
- package/src/client/features/chapters/index.jsx +118 -118
- package/src/client/features/chapters/sync.ts +155 -155
- package/src/client/features/chapters/useChapterSync.ts +39 -39
- package/src/client/features/context/index.jsx +22 -22
- package/src/client/features/memory/index.jsx +73 -73
- package/src/client/features/planning/Decorations.tsx +1 -1
- package/src/client/features/planning/Pages.tsx +11 -11
- package/src/client/features/planning/data.js +16 -16
- package/src/client/index.tsx +130 -130
- package/src/client/launcher.tsx +78 -78
- package/src/client/shared/creation.tsx +34 -34
- package/src/client/shared/detail-page.tsx +61 -61
- package/src/client/shared/drafts.js +17 -17
- package/src/client/shared/evidence.jsx +11 -11
- package/src/client/shared/groups.tsx +34 -34
- package/src/client/shared/preferences.tsx +104 -104
- package/src/client/shared/select-style.ts +25 -25
- package/src/client/shared/state.js +95 -95
- package/src/client/shared/ui.jsx +19 -19
- package/src/client/shared/workspace.tsx +11 -11
- package/src/client/style.ts +274 -274
- package/src/client/visual.ts +190 -190
- package/src/contracts/actions.js +19 -19
- package/src/contracts/planning.ts +52 -52
- package/src/domain/manuscript.ts +4 -4
- package/src/domain/planning/index.ts +113 -113
- package/src/domain/planning/model.ts +59 -59
- package/src/domain/preset/index.js +89 -89
- package/src/index.js +91 -91
- package/src/infrastructure/migrations/legacy.js +96 -96
- package/src/infrastructure/migrations/tavern.js +78 -78
- package/src/infrastructure/persistence/atomic-store.ts +80 -80
- package/src/infrastructure/persistence/project-layout.ts +1 -1
package/src/adapters/dsh/rpc.js
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { HUMAN_ACTIONS } from '../../contracts/actions.js';
|
|
3
|
-
import { convertLegacyExport } from '../../infrastructure/migrations/legacy.js';
|
|
4
|
-
|
|
5
|
-
const manifest = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
|
|
6
|
-
export const RPC_ENDPOINT = 'cuigengji/dispatch';
|
|
7
|
-
const RPC_PATH = `/api/${RPC_ENDPOINT}`;
|
|
8
|
-
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
-
const invalid = message => Object.assign(new Error(message), { code: 'INVALID_INPUT' });
|
|
10
|
-
|
|
11
|
-
function failure(error) {
|
|
12
|
-
return {
|
|
13
|
-
ok: false,
|
|
14
|
-
error: {
|
|
15
|
-
code: error.code || 'CUIGENGJI_ERROR',
|
|
16
|
-
message: error.message || String(error),
|
|
17
|
-
details: {},
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function registerRpcRoute(ctx, { dispatch, dataRoot, workData, transfer, maintenance }) {
|
|
23
|
-
const ensureReady = () => maintenance?.ensureReady();
|
|
24
|
-
const dispatchRpc = async (payload, signal) => {
|
|
25
|
-
try {
|
|
26
|
-
signal.throwIfAborted();
|
|
27
|
-
if (!record(payload) || !HUMAN_ACTIONS.includes(payload.action)) throw invalid('无效的催更姬操作');
|
|
28
|
-
const { action, args = {}, sessionId } = payload;
|
|
29
|
-
if (!record(args)) throw invalid('args 必须是对象');
|
|
30
|
-
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 512)) {
|
|
31
|
-
throw invalid('sessionId 必须是有效的会话标识');
|
|
32
|
-
}
|
|
33
|
-
if (!action.startsWith('transfer.') && !['novel.list', 'novel.create', 'novel.import', 'legacy.preview', 'settings.get', 'maintenance.status', 'maintenance.verify', 'workspace.status', 'workspace.export', 'workspace.preview', 'workspace.import'].includes(action) && !sessionId) {
|
|
34
|
-
throw Object.assign(new Error('请选择一个 DSH 会话'), { code: 'SESSION_REQUIRED' });
|
|
35
|
-
}
|
|
36
|
-
if (action === 'settings.get') {
|
|
37
|
-
return { ok: true, value: { dataRoot, pluginVersion: manifest.version, supportedDsh: manifest.peerDependencies['@deepseek-ai/dsh-tools'] } };
|
|
38
|
-
}
|
|
39
|
-
if (action === 'legacy.preview') return { ok: true, value: convertLegacyExport(args.input) };
|
|
40
|
-
if (action === 'maintenance.status') {
|
|
41
|
-
if (!maintenance) throw invalid('维护服务未初始化');
|
|
42
|
-
return { ok: true, value: await maintenance.status() };
|
|
43
|
-
}
|
|
44
|
-
if (action === 'maintenance.verify') {
|
|
45
|
-
if (!maintenance) throw invalid('维护服务未初始化');
|
|
46
|
-
return { ok: true, value: await maintenance.verify(args) };
|
|
47
|
-
}
|
|
48
|
-
if (action.startsWith('transfer.')) {
|
|
49
|
-
await ensureReady();
|
|
50
|
-
if (!transfer) throw invalid('导入 / 导出服务未初始化');
|
|
51
|
-
const method = {'transfer.catalog':'catalog','transfer.inspect':'inspect','transfer.preview':'preview','transfer.import':'import','transfer.exportPreview':'exportPreview','transfer.export':'export'}[action];
|
|
52
|
-
return {ok: true, value: await transfer[method](args, {sessionId})};
|
|
53
|
-
}
|
|
54
|
-
if (action.startsWith('workspace.')) {
|
|
55
|
-
await ensureReady();
|
|
56
|
-
if (!workData) throw invalid('工作数据服务未初始化');
|
|
57
|
-
const method = { 'workspace.status':'status', 'workspace.export':'export', 'workspace.preview':'preview', 'workspace.import':'import' }[action];
|
|
58
|
-
return { ok: true, value: await workData[method](args.backup) };
|
|
59
|
-
}
|
|
60
|
-
await ensureReady();
|
|
61
|
-
return { ok: true, value: await dispatch(action, args, { kind: 'human', sessionId }) };
|
|
62
|
-
} catch (error) {
|
|
63
|
-
return failure(error);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
ctx.connection.fetch.register({
|
|
68
|
-
path: RPC_PATH,
|
|
69
|
-
methods: ['POST'],
|
|
70
|
-
requestBody: 'buffered',
|
|
71
|
-
fetch: async request => {
|
|
72
|
-
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
73
|
-
return new Response('content type must be application/json', { status: 415 });
|
|
74
|
-
}
|
|
75
|
-
let message;
|
|
76
|
-
try {
|
|
77
|
-
message = await request.json();
|
|
78
|
-
} catch {
|
|
79
|
-
return new Response('body is not JSON', { status: 400 });
|
|
80
|
-
}
|
|
81
|
-
const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
82
|
-
if (!record(message) || message.type !== 'client-request' || message.method !== RPC_ENDPOINT || typeof message.rpcId !== 'string' || !message.rpcId.trim()) {
|
|
83
|
-
return Response.json({
|
|
84
|
-
type: 'server-response',
|
|
85
|
-
rpcId,
|
|
86
|
-
result: failure(invalid('无效的 RPC 请求')),
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
return Response.json({
|
|
90
|
-
type: 'server-response',
|
|
91
|
-
rpcId,
|
|
92
|
-
result: await dispatchRpc(message.payload, request.signal),
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
});
|
|
96
|
-
}
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { HUMAN_ACTIONS } from '../../contracts/actions.js';
|
|
3
|
+
import { convertLegacyExport } from '../../infrastructure/migrations/legacy.js';
|
|
4
|
+
|
|
5
|
+
const manifest = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
export const RPC_ENDPOINT = 'cuigengji/dispatch';
|
|
7
|
+
const RPC_PATH = `/api/${RPC_ENDPOINT}`;
|
|
8
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
const invalid = message => Object.assign(new Error(message), { code: 'INVALID_INPUT' });
|
|
10
|
+
|
|
11
|
+
function failure(error) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
error: {
|
|
15
|
+
code: error.code || 'CUIGENGJI_ERROR',
|
|
16
|
+
message: error.message || String(error),
|
|
17
|
+
details: {},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function registerRpcRoute(ctx, { dispatch, dataRoot, workData, transfer, maintenance }) {
|
|
23
|
+
const ensureReady = () => maintenance?.ensureReady();
|
|
24
|
+
const dispatchRpc = async (payload, signal) => {
|
|
25
|
+
try {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
if (!record(payload) || !HUMAN_ACTIONS.includes(payload.action)) throw invalid('无效的催更姬操作');
|
|
28
|
+
const { action, args = {}, sessionId } = payload;
|
|
29
|
+
if (!record(args)) throw invalid('args 必须是对象');
|
|
30
|
+
if (sessionId !== undefined && (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 512)) {
|
|
31
|
+
throw invalid('sessionId 必须是有效的会话标识');
|
|
32
|
+
}
|
|
33
|
+
if (!action.startsWith('transfer.') && !['novel.list', 'novel.create', 'novel.import', 'legacy.preview', 'settings.get', 'maintenance.status', 'maintenance.verify', 'workspace.status', 'workspace.export', 'workspace.preview', 'workspace.import'].includes(action) && !sessionId) {
|
|
34
|
+
throw Object.assign(new Error('请选择一个 DSH 会话'), { code: 'SESSION_REQUIRED' });
|
|
35
|
+
}
|
|
36
|
+
if (action === 'settings.get') {
|
|
37
|
+
return { ok: true, value: { dataRoot, pluginVersion: manifest.version, supportedDsh: manifest.peerDependencies['@deepseek-ai/dsh-tools'] } };
|
|
38
|
+
}
|
|
39
|
+
if (action === 'legacy.preview') return { ok: true, value: convertLegacyExport(args.input) };
|
|
40
|
+
if (action === 'maintenance.status') {
|
|
41
|
+
if (!maintenance) throw invalid('维护服务未初始化');
|
|
42
|
+
return { ok: true, value: await maintenance.status() };
|
|
43
|
+
}
|
|
44
|
+
if (action === 'maintenance.verify') {
|
|
45
|
+
if (!maintenance) throw invalid('维护服务未初始化');
|
|
46
|
+
return { ok: true, value: await maintenance.verify(args) };
|
|
47
|
+
}
|
|
48
|
+
if (action.startsWith('transfer.')) {
|
|
49
|
+
await ensureReady();
|
|
50
|
+
if (!transfer) throw invalid('导入 / 导出服务未初始化');
|
|
51
|
+
const method = {'transfer.catalog':'catalog','transfer.inspect':'inspect','transfer.preview':'preview','transfer.import':'import','transfer.exportPreview':'exportPreview','transfer.export':'export'}[action];
|
|
52
|
+
return {ok: true, value: await transfer[method](args, {sessionId})};
|
|
53
|
+
}
|
|
54
|
+
if (action.startsWith('workspace.')) {
|
|
55
|
+
await ensureReady();
|
|
56
|
+
if (!workData) throw invalid('工作数据服务未初始化');
|
|
57
|
+
const method = { 'workspace.status':'status', 'workspace.export':'export', 'workspace.preview':'preview', 'workspace.import':'import' }[action];
|
|
58
|
+
return { ok: true, value: await workData[method](args.backup) };
|
|
59
|
+
}
|
|
60
|
+
await ensureReady();
|
|
61
|
+
return { ok: true, value: await dispatch(action, args, { kind: 'human', sessionId }) };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return failure(error);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
ctx.connection.fetch.register({
|
|
68
|
+
path: RPC_PATH,
|
|
69
|
+
methods: ['POST'],
|
|
70
|
+
requestBody: 'buffered',
|
|
71
|
+
fetch: async request => {
|
|
72
|
+
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
|
73
|
+
return new Response('content type must be application/json', { status: 415 });
|
|
74
|
+
}
|
|
75
|
+
let message;
|
|
76
|
+
try {
|
|
77
|
+
message = await request.json();
|
|
78
|
+
} catch {
|
|
79
|
+
return new Response('body is not JSON', { status: 400 });
|
|
80
|
+
}
|
|
81
|
+
const rpcId = typeof message?.rpcId === 'string' ? message.rpcId : 'invalid-request';
|
|
82
|
+
if (!record(message) || message.type !== 'client-request' || message.method !== RPC_ENDPOINT || typeof message.rpcId !== 'string' || !message.rpcId.trim()) {
|
|
83
|
+
return Response.json({
|
|
84
|
+
type: 'server-response',
|
|
85
|
+
rpcId,
|
|
86
|
+
result: failure(invalid('无效的 RPC 请求')),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return Response.json({
|
|
90
|
+
type: 'server-response',
|
|
91
|
+
rpcId,
|
|
92
|
+
result: await dispatchRpc(message.payload, request.signal),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
export const skillSpecs = [
|
|
3
|
-
['cuigengji-project', '接手催更姬小说项目,定位作者的协作目标,按需读取资料并安全保存正文、规划与设定。'],
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
export const skillSpecs = [
|
|
3
|
+
['cuigengji-project', '接手催更姬小说项目,定位作者的协作目标,按需读取资料并安全保存正文、规划与设定。'],
|
|
4
4
|
['cuigengji-plan', '讨论剧情走向、人物行动、因果或备选方案时使用;主动把结构性讨论维护到相关共享画布页,用事件、并行动线与分支帮助作者对齐思路。'],
|
|
5
|
-
['cuigengji-write', '按作者意图写作或续写小说正文,用章节工具保存,并按实际结果维护相关规划与设定。'],
|
|
6
|
-
['cuigengji-revise', '审阅或修订已保存正文,定位具体问题,用版本检查保留作者并行修改,并核对受影响的规划与设定。'],
|
|
5
|
+
['cuigengji-write', '按作者意图写作或续写小说正文,用章节工具保存,并按实际结果维护相关规划与设定。'],
|
|
6
|
+
['cuigengji-revise', '审阅或修订已保存正文,定位具体问题,用版本检查保留作者并行修改,并核对受影响的规划与设定。'],
|
|
7
7
|
['cuigengji-memory', '讨论、设计或完善人物动机、世界规则、阵营与关系时使用;主动复用设定卡与关系图,保留未决提案并区分既定事实。'],
|
|
8
|
-
['cuigengji-maintenance', '安装或更新催更姬后,查询自动迁移、备份和完整性校验结果;不要求绑定小说。'],
|
|
9
|
-
];
|
|
10
|
-
export function registerSkills(ctx) {
|
|
11
|
-
const shared = readFileSync(new URL('../../../skills/cuigengji-project/SKILL.md', import.meta.url), 'utf8');
|
|
12
|
-
const body = text => text.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, '').trim();
|
|
13
|
-
for (const [name, description] of skillSpecs) {
|
|
14
|
-
const task = readFileSync(new URL(`../../../skills/${name}/SKILL.md`, import.meta.url), 'utf8');
|
|
15
|
-
ctx.skills.register({ name, description, source: 'cuigengji',
|
|
16
|
-
content: (name === 'cuigengji-project' || name === 'cuigengji-maintenance') ? task : `${task.trim()}\n\n${body(shared)}`,
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
8
|
+
['cuigengji-maintenance', '安装或更新催更姬后,查询自动迁移、备份和完整性校验结果;不要求绑定小说。'],
|
|
9
|
+
];
|
|
10
|
+
export function registerSkills(ctx) {
|
|
11
|
+
const shared = readFileSync(new URL('../../../skills/cuigengji-project/SKILL.md', import.meta.url), 'utf8');
|
|
12
|
+
const body = text => text.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, '').trim();
|
|
13
|
+
for (const [name, description] of skillSpecs) {
|
|
14
|
+
const task = readFileSync(new URL(`../../../skills/${name}/SKILL.md`, import.meta.url), 'utf8');
|
|
15
|
+
ctx.skills.register({ name, description, source: 'cuigengji',
|
|
16
|
+
content: (name === 'cuigengji-project' || name === 'cuigengji-maintenance') ? task : `${task.trim()}\n\n${body(shared)}`,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
20
|
export const persona = `你是帮助用户创作和维护小说的写作助手。
|
|
21
21
|
当前共享协作规则为 shared-canvas-v2;旧会话首次处理相关任务时,已加载的指南若没有这个版本标记,重新加载对应 Skill 一次。
|
|
22
22
|
讨论剧情结构、走向和备选方案时加载 cuigengji-plan,主动维护相关共享画布页;设计人物与世界规则时加载 cuigengji-memory。结构性讨论默认包含记录未确认提案,不必等作者点名画布;明确只聊不保存、简单问答或只读审阅时不写入。
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { MEMORY_ACTIONS } from '../../contracts/actions.js';
|
|
2
2
|
export { MEMORY_ACTIONS };
|
|
3
|
-
|
|
4
|
-
export const toolName = action => `memory_${action.replaceAll('.', '_')}`;
|
|
5
|
-
|
|
6
|
-
export function assertMemoryAction(action) {
|
|
7
|
-
if (!MEMORY_ACTIONS.includes(action)) throw new Error(`不允许的记忆操作:${action}`);
|
|
8
|
-
}
|
|
3
|
+
|
|
4
|
+
export const toolName = action => `memory_${action.replaceAll('.', '_')}`;
|
|
5
|
+
|
|
6
|
+
export function assertMemoryAction(action) {
|
|
7
|
+
if (!MEMORY_ACTIONS.includes(action)) throw new Error(`不允许的记忆操作:${action}`);
|
|
8
|
+
}
|
|
@@ -1,172 +1,172 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import { randomBytes } from 'node:crypto';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
-
import { assertMemoryAction, toolName } from './actions.js';
|
|
7
|
-
|
|
8
|
-
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
-
|
|
10
|
-
export function memoryRuntime(options = {}, runtime = process) {
|
|
11
|
-
const command = options.nodeCommand ?? runtime.env.CUIGENGJI_DSH_NODE ?? runtime.execPath;
|
|
12
|
-
const env = {};
|
|
13
|
-
if (runtime.env.NODE_OPTIONS) env.NODE_OPTIONS = runtime.env.NODE_OPTIONS;
|
|
14
|
-
// Desktop hosts use the Electron executable as Node. The SDK's default
|
|
15
|
-
// environment omits this switch, which otherwise launches a second GUI.
|
|
16
|
-
if (runtime.versions.electron || runtime.env.ELECTRON_RUN_AS_NODE === '1') {
|
|
17
|
-
env.ELECTRON_RUN_AS_NODE = '1';
|
|
18
|
-
}
|
|
19
|
-
return { command, env };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class MemoryBridge {
|
|
23
|
-
constructor(store, options = {}) {
|
|
24
|
-
this.store = store;
|
|
25
|
-
this.dispatch = options.dispatch ?? ((action, args, actor) => store.dispatch(action, args, actor));
|
|
26
|
-
this.resolveNovel = options.resolveNovel;
|
|
27
|
-
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
28
|
-
const runtime = memoryRuntime(options);
|
|
29
|
-
this.nodeCommand = runtime.command;
|
|
30
|
-
this.runtimeEnv = runtime.env;
|
|
31
|
-
this.sessions = new Map();
|
|
32
|
-
this.tokens = new Map();
|
|
33
|
-
this.closed = false;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async start() {
|
|
37
|
-
if (this.closed) throw new Error('记忆服务已关闭');
|
|
38
|
-
if (this.starting) return this.starting;
|
|
39
|
-
this.starting = (async () => {
|
|
40
|
-
this.server = http.createServer((request, response) => { void this.handle(request, response); });
|
|
41
|
-
this.server.requestTimeout = this.timeoutMs;
|
|
42
|
-
await new Promise((resolve, reject) => {
|
|
43
|
-
this.server.once('error', reject);
|
|
44
|
-
this.server.listen(0, '127.0.0.1', resolve);
|
|
45
|
-
});
|
|
46
|
-
this.endpoint = `http://127.0.0.1:${this.server.address().port}/memory`;
|
|
47
|
-
return this;
|
|
48
|
-
})();
|
|
49
|
-
return this.starting;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async handle(request, response) {
|
|
53
|
-
const reply = (status, data) => {
|
|
54
|
-
if (response.destroyed) return;
|
|
55
|
-
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
56
|
-
response.end(JSON.stringify(data));
|
|
57
|
-
};
|
|
58
|
-
try {
|
|
59
|
-
if (request.method !== 'POST' || request.url !== '/memory') return reply(404, { error: '未知接口' });
|
|
60
|
-
const sessionId = this.tokens.get((request.headers.authorization ?? '').replace(/^Bearer /, ''));
|
|
61
|
-
if (!sessionId) return reply(401, { error: '会话凭据失效' });
|
|
62
|
-
let size = 0;
|
|
63
|
-
const chunks = [];
|
|
64
|
-
for await (const chunk of request) {
|
|
65
|
-
size += chunk.length;
|
|
66
|
-
if (size > MAX_BODY_BYTES) return reply(413, { error: '记忆请求过大' });
|
|
67
|
-
chunks.push(chunk);
|
|
68
|
-
}
|
|
69
|
-
const { action, args = {} } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
70
|
-
assertMemoryAction(action);
|
|
71
|
-
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('参数必须是对象');
|
|
72
|
-
const actor = { kind: 'agent', sessionId };
|
|
73
|
-
const novelId = await this.boundNovel(sessionId);
|
|
74
|
-
if (!novelId) throw new Error('当前会话尚未绑定小说');
|
|
75
|
-
if (args.novelId && args.novelId !== novelId) throw new Error('不能访问其他小说');
|
|
76
|
-
// Never forward model-supplied session/actor identities.
|
|
77
|
-
const { sessionId: _session, actor: _actor, ...safeArgs } = args;
|
|
78
|
-
const result = await this.dispatch(action, { ...safeArgs, novelId }, actor);
|
|
79
|
-
reply(200, { result });
|
|
80
|
-
} catch (error) {
|
|
81
|
-
reply(400, { error: error.message });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async boundNovel(sessionId) {
|
|
86
|
-
if (this.resolveNovel) return this.resolveNovel(sessionId);
|
|
87
|
-
const binding = await this.store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
88
|
-
return binding?.novelId;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async connection(sessionId) {
|
|
92
|
-
if (!sessionId || typeof sessionId !== 'string') throw new Error('缺少会话 ID');
|
|
93
|
-
await this.start();
|
|
94
|
-
const existing = this.sessions.get(sessionId);
|
|
95
|
-
if (existing) return existing.pending ?? existing;
|
|
96
|
-
const entry = { pending: null };
|
|
97
|
-
entry.pending = this.connect(sessionId, entry);
|
|
98
|
-
this.sessions.set(sessionId, entry);
|
|
99
|
-
try { return await entry.pending; }
|
|
100
|
-
catch (error) {
|
|
101
|
-
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
102
|
-
throw error;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async connect(sessionId, entry) {
|
|
107
|
-
const token = randomBytes(32).toString('hex');
|
|
108
|
-
this.tokens.set(token, sessionId);
|
|
109
|
-
const env = { ...this.runtimeEnv, CUIGENGJI_MEMORY_ENDPOINT: this.endpoint, CUIGENGJI_MEMORY_TOKEN: token };
|
|
110
|
-
const transport = new StdioClientTransport({
|
|
111
|
-
command: this.nodeCommand,
|
|
112
|
-
args: [fileURLToPath(new URL('./server.js', import.meta.url))],
|
|
113
|
-
env,
|
|
114
|
-
stderr: 'pipe',
|
|
115
|
-
});
|
|
116
|
-
const client = new Client({ name: 'cuigengji-host', version: '0.1.0' });
|
|
117
|
-
// Keep child diagnostics out of model output; drain to avoid pipe backpressure.
|
|
118
|
-
transport.stderr?.on('data', () => {});
|
|
119
|
-
client.onclose = () => {
|
|
120
|
-
this.tokens.delete(token);
|
|
121
|
-
// A reconnect may have replaced this entry. Never tear down the new
|
|
122
|
-
// connection when an older MCP child exits asynchronously.
|
|
123
|
-
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
124
|
-
};
|
|
125
|
-
try {
|
|
126
|
-
await client.connect(transport);
|
|
127
|
-
return { client, transport, token };
|
|
128
|
-
} catch (error) {
|
|
129
|
-
this.tokens.delete(token);
|
|
130
|
-
await transport.close().catch(() => {});
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async call(sessionId, action, args = {}, { signal } = {}) {
|
|
136
|
-
assertMemoryAction(action);
|
|
137
|
-
if (signal?.aborted) throw signal.reason ?? new Error('已取消');
|
|
138
|
-
const { client } = await this.connection(sessionId);
|
|
139
|
-
const result = await client.callTool({ name: toolName(action), arguments: args }, undefined, {
|
|
140
|
-
timeout: this.timeoutMs, signal,
|
|
141
|
-
});
|
|
142
|
-
const output = result.content?.find(item => item.type === 'text')?.text;
|
|
143
|
-
if (result.isError) throw new Error(output ?? '记忆工具失败');
|
|
144
|
-
return JSON.parse(output ?? 'null');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async disconnect(sessionId) {
|
|
148
|
-
const entry = this.sessions.get(sessionId);
|
|
149
|
-
this.sessions.delete(sessionId);
|
|
150
|
-
if (!entry) return;
|
|
151
|
-
const connection = await (entry.pending ?? entry).catch(() => null);
|
|
152
|
-
if (!connection) return;
|
|
153
|
-
this.tokens.delete(connection.token);
|
|
154
|
-
await connection.client.close();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async close() {
|
|
158
|
-
this.closed = true;
|
|
159
|
-
await Promise.allSettled([...this.sessions.keys()].map(sessionId => this.disconnect(sessionId)));
|
|
160
|
-
this.tokens.clear();
|
|
161
|
-
if (this.server) {
|
|
162
|
-
this.server.closeAllConnections();
|
|
163
|
-
await new Promise(resolve => this.server.close(resolve));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export class MemoryClient {
|
|
169
|
-
constructor(bridge, sessionId) { this.bridge = bridge; this.sessionId = sessionId; }
|
|
170
|
-
call(action, args, options) { return this.bridge.call(this.sessionId, action, args, options); }
|
|
171
|
-
close() { return this.bridge.disconnect(this.sessionId); }
|
|
172
|
-
}
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
6
|
+
import { assertMemoryAction, toolName } from './actions.js';
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function memoryRuntime(options = {}, runtime = process) {
|
|
11
|
+
const command = options.nodeCommand ?? runtime.env.CUIGENGJI_DSH_NODE ?? runtime.execPath;
|
|
12
|
+
const env = {};
|
|
13
|
+
if (runtime.env.NODE_OPTIONS) env.NODE_OPTIONS = runtime.env.NODE_OPTIONS;
|
|
14
|
+
// Desktop hosts use the Electron executable as Node. The SDK's default
|
|
15
|
+
// environment omits this switch, which otherwise launches a second GUI.
|
|
16
|
+
if (runtime.versions.electron || runtime.env.ELECTRON_RUN_AS_NODE === '1') {
|
|
17
|
+
env.ELECTRON_RUN_AS_NODE = '1';
|
|
18
|
+
}
|
|
19
|
+
return { command, env };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryBridge {
|
|
23
|
+
constructor(store, options = {}) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.dispatch = options.dispatch ?? ((action, args, actor) => store.dispatch(action, args, actor));
|
|
26
|
+
this.resolveNovel = options.resolveNovel;
|
|
27
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
28
|
+
const runtime = memoryRuntime(options);
|
|
29
|
+
this.nodeCommand = runtime.command;
|
|
30
|
+
this.runtimeEnv = runtime.env;
|
|
31
|
+
this.sessions = new Map();
|
|
32
|
+
this.tokens = new Map();
|
|
33
|
+
this.closed = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async start() {
|
|
37
|
+
if (this.closed) throw new Error('记忆服务已关闭');
|
|
38
|
+
if (this.starting) return this.starting;
|
|
39
|
+
this.starting = (async () => {
|
|
40
|
+
this.server = http.createServer((request, response) => { void this.handle(request, response); });
|
|
41
|
+
this.server.requestTimeout = this.timeoutMs;
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
this.server.once('error', reject);
|
|
44
|
+
this.server.listen(0, '127.0.0.1', resolve);
|
|
45
|
+
});
|
|
46
|
+
this.endpoint = `http://127.0.0.1:${this.server.address().port}/memory`;
|
|
47
|
+
return this;
|
|
48
|
+
})();
|
|
49
|
+
return this.starting;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async handle(request, response) {
|
|
53
|
+
const reply = (status, data) => {
|
|
54
|
+
if (response.destroyed) return;
|
|
55
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
56
|
+
response.end(JSON.stringify(data));
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
if (request.method !== 'POST' || request.url !== '/memory') return reply(404, { error: '未知接口' });
|
|
60
|
+
const sessionId = this.tokens.get((request.headers.authorization ?? '').replace(/^Bearer /, ''));
|
|
61
|
+
if (!sessionId) return reply(401, { error: '会话凭据失效' });
|
|
62
|
+
let size = 0;
|
|
63
|
+
const chunks = [];
|
|
64
|
+
for await (const chunk of request) {
|
|
65
|
+
size += chunk.length;
|
|
66
|
+
if (size > MAX_BODY_BYTES) return reply(413, { error: '记忆请求过大' });
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
const { action, args = {} } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
70
|
+
assertMemoryAction(action);
|
|
71
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('参数必须是对象');
|
|
72
|
+
const actor = { kind: 'agent', sessionId };
|
|
73
|
+
const novelId = await this.boundNovel(sessionId);
|
|
74
|
+
if (!novelId) throw new Error('当前会话尚未绑定小说');
|
|
75
|
+
if (args.novelId && args.novelId !== novelId) throw new Error('不能访问其他小说');
|
|
76
|
+
// Never forward model-supplied session/actor identities.
|
|
77
|
+
const { sessionId: _session, actor: _actor, ...safeArgs } = args;
|
|
78
|
+
const result = await this.dispatch(action, { ...safeArgs, novelId }, actor);
|
|
79
|
+
reply(200, { result });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
reply(400, { error: error.message });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async boundNovel(sessionId) {
|
|
86
|
+
if (this.resolveNovel) return this.resolveNovel(sessionId);
|
|
87
|
+
const binding = await this.store.dispatch('binding.get', {}, { kind: 'agent', sessionId });
|
|
88
|
+
return binding?.novelId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async connection(sessionId) {
|
|
92
|
+
if (!sessionId || typeof sessionId !== 'string') throw new Error('缺少会话 ID');
|
|
93
|
+
await this.start();
|
|
94
|
+
const existing = this.sessions.get(sessionId);
|
|
95
|
+
if (existing) return existing.pending ?? existing;
|
|
96
|
+
const entry = { pending: null };
|
|
97
|
+
entry.pending = this.connect(sessionId, entry);
|
|
98
|
+
this.sessions.set(sessionId, entry);
|
|
99
|
+
try { return await entry.pending; }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async connect(sessionId, entry) {
|
|
107
|
+
const token = randomBytes(32).toString('hex');
|
|
108
|
+
this.tokens.set(token, sessionId);
|
|
109
|
+
const env = { ...this.runtimeEnv, CUIGENGJI_MEMORY_ENDPOINT: this.endpoint, CUIGENGJI_MEMORY_TOKEN: token };
|
|
110
|
+
const transport = new StdioClientTransport({
|
|
111
|
+
command: this.nodeCommand,
|
|
112
|
+
args: [fileURLToPath(new URL('./server.js', import.meta.url))],
|
|
113
|
+
env,
|
|
114
|
+
stderr: 'pipe',
|
|
115
|
+
});
|
|
116
|
+
const client = new Client({ name: 'cuigengji-host', version: '0.1.0' });
|
|
117
|
+
// Keep child diagnostics out of model output; drain to avoid pipe backpressure.
|
|
118
|
+
transport.stderr?.on('data', () => {});
|
|
119
|
+
client.onclose = () => {
|
|
120
|
+
this.tokens.delete(token);
|
|
121
|
+
// A reconnect may have replaced this entry. Never tear down the new
|
|
122
|
+
// connection when an older MCP child exits asynchronously.
|
|
123
|
+
if (this.sessions.get(sessionId) === entry) this.sessions.delete(sessionId);
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
await client.connect(transport);
|
|
127
|
+
return { client, transport, token };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.tokens.delete(token);
|
|
130
|
+
await transport.close().catch(() => {});
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async call(sessionId, action, args = {}, { signal } = {}) {
|
|
136
|
+
assertMemoryAction(action);
|
|
137
|
+
if (signal?.aborted) throw signal.reason ?? new Error('已取消');
|
|
138
|
+
const { client } = await this.connection(sessionId);
|
|
139
|
+
const result = await client.callTool({ name: toolName(action), arguments: args }, undefined, {
|
|
140
|
+
timeout: this.timeoutMs, signal,
|
|
141
|
+
});
|
|
142
|
+
const output = result.content?.find(item => item.type === 'text')?.text;
|
|
143
|
+
if (result.isError) throw new Error(output ?? '记忆工具失败');
|
|
144
|
+
return JSON.parse(output ?? 'null');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async disconnect(sessionId) {
|
|
148
|
+
const entry = this.sessions.get(sessionId);
|
|
149
|
+
this.sessions.delete(sessionId);
|
|
150
|
+
if (!entry) return;
|
|
151
|
+
const connection = await (entry.pending ?? entry).catch(() => null);
|
|
152
|
+
if (!connection) return;
|
|
153
|
+
this.tokens.delete(connection.token);
|
|
154
|
+
await connection.client.close();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async close() {
|
|
158
|
+
this.closed = true;
|
|
159
|
+
await Promise.allSettled([...this.sessions.keys()].map(sessionId => this.disconnect(sessionId)));
|
|
160
|
+
this.tokens.clear();
|
|
161
|
+
if (this.server) {
|
|
162
|
+
this.server.closeAllConnections();
|
|
163
|
+
await new Promise(resolve => this.server.close(resolve));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class MemoryClient {
|
|
169
|
+
constructor(bridge, sessionId) { this.bridge = bridge; this.sessionId = sessionId; }
|
|
170
|
+
call(action, args, options) { return this.bridge.call(this.sessionId, action, args, options); }
|
|
171
|
+
close() { return this.bridge.disconnect(this.sessionId); }
|
|
172
|
+
}
|