@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0-dev.41051739681

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +12 -24
  2. package/lib/host/agent.d.ts +8 -4
  3. package/lib/host/agent.js +16 -99
  4. package/lib/host/agent.js.map +1 -1
  5. package/lib/host/bot.js +9 -7
  6. package/lib/host/bot.js.map +1 -1
  7. package/lib/host/config-migration.d.ts +3 -0
  8. package/lib/host/config-migration.js +103 -0
  9. package/lib/host/config-migration.js.map +1 -0
  10. package/lib/host/config.d.ts +4 -18
  11. package/lib/host/config.js +77 -130
  12. package/lib/host/config.js.map +1 -1
  13. package/lib/host/feature-config.d.ts +20 -0
  14. package/lib/host/feature-config.js +264 -0
  15. package/lib/host/feature-config.js.map +1 -0
  16. package/lib/host/gateway.d.ts +4 -6
  17. package/lib/host/gateway.js +42 -104
  18. package/lib/host/gateway.js.map +1 -1
  19. package/lib/host/github.d.ts +2 -2
  20. package/lib/host/harness.d.ts +23 -0
  21. package/lib/host/harness.js +209 -0
  22. package/lib/host/harness.js.map +1 -0
  23. package/lib/host/index.js +22 -12
  24. package/lib/host/index.js.map +1 -1
  25. package/lib/host/mcp.d.ts +1 -1
  26. package/lib/host/mcp.js +15 -11
  27. package/lib/host/mcp.js.map +1 -1
  28. package/lib/host/panel.html +2 -1
  29. package/lib/host/panel.js +40 -24
  30. package/lib/host/panel.js.map +1 -1
  31. package/lib/host/types.d.ts +104 -16
  32. package/package.json +1 -1
  33. package/scripts/dump-npm-latest.mjs +29 -10
  34. package/scripts/host-smoke.mjs +139 -0
  35. package/src/agent.ts +34 -114
  36. package/src/bot.ts +9 -7
  37. package/src/config-migration.ts +148 -0
  38. package/src/config.ts +116 -152
  39. package/src/feature-config.ts +351 -0
  40. package/src/gateway.ts +48 -115
  41. package/src/harness.ts +253 -0
  42. package/src/index.ts +25 -18
  43. package/src/mcp.ts +23 -11
  44. package/src/panel.html +2 -1
  45. package/src/panel.ts +53 -24
  46. package/src/types.ts +75 -13
  47. package/test/config-migration.test.mjs +137 -0
  48. package/test/core.test.mjs +82 -16
  49. package/test/fixtures/host.mjs +133 -0
  50. package/test/harness.test.mjs +90 -0
  51. package/test/integration.test.mjs +10 -12
  52. package/test/panel.test.mjs +71 -3
package/src/gateway.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  import type { ConfigSource, ErrorSink } from "./types.js";
2
2
  import { createServer } from "node:http";
3
3
  import { randomUUID, randomBytes } from "node:crypto";
4
- import { Readable } from "node:stream";
5
- import { pipeline } from "node:stream/promises";
6
- import { StringDecoder } from "node:string_decoder";
7
4
  import { body, json, equalSecret } from "./http.js";
5
+ import type { ApprovalAnswer } from "./harness.js";
8
6
  interface Run {
9
7
  created: number;
10
8
  events: any[];
@@ -12,6 +10,8 @@ interface Run {
12
10
  done: boolean;
13
11
  controller: AbortController;
14
12
  task?: Promise<void>;
13
+ approval?: (answer: ApprovalAnswer) => void;
14
+ approvalQueue: Promise<unknown>;
15
15
  }
16
16
  export class Gateway {
17
17
  agent: import("./agent.js").Agent;
@@ -19,8 +19,6 @@ export class Gateway {
19
19
  onError: ErrorSink;
20
20
  token: string;
21
21
  runs: Map<string, Run>;
22
- external: Map<string, { input: any; created: number }>;
23
- controllers: Set<AbortController>;
24
22
  closed: boolean;
25
23
  server: import("node:http").Server;
26
24
  url: string;
@@ -36,8 +34,6 @@ export class Gateway {
36
34
  Object.assign(this, { agent, config, onError });
37
35
  this.token = randomBytes(32).toString("hex");
38
36
  this.runs = new Map();
39
- this.external = new Map();
40
- this.controllers = new Set();
41
37
  this.closed = false;
42
38
  }
43
39
  async start() {
@@ -58,8 +54,6 @@ export class Gateway {
58
54
  this.gc = setInterval(() => {
59
55
  for (const [id, run] of this.runs)
60
56
  if (run.done && Date.now() - run.created > 600000) this.runs.delete(id);
61
- for (const [id, run] of this.external)
62
- if (Date.now() - run.created > 3600000) this.external.delete(id);
63
57
  }, 60000).unref();
64
58
  }
65
59
  async handle(req, res) {
@@ -73,7 +67,6 @@ export class Gateway {
73
67
  )
74
68
  return json(res, { error: "Not found" }, 404);
75
69
  const c = this.config();
76
- if (c.externalHermes) return this.proxy(req, res, url.pathname, c);
77
70
  if (url.pathname === "/v1/runs" && req.method === "POST") {
78
71
  if (
79
72
  [...this.runs.values()].filter((r) => !r.done).length >= 16 ||
@@ -89,6 +82,7 @@ export class Gateway {
89
82
  listeners: new Set(),
90
83
  done: false,
91
84
  controller,
85
+ approvalQueue: Promise.resolve(),
92
86
  };
93
87
  this.runs.set(id, run);
94
88
  const emit = (event, payload) => {
@@ -110,6 +104,9 @@ export class Gateway {
110
104
  controller.signal,
111
105
  emit,
112
106
  data.instructions || "",
107
+ c.modules.chat.approvalEnabled
108
+ ? (request) => this.askApproval(run, request, emit)
109
+ : undefined,
113
110
  )
114
111
  .then(
115
112
  (output) => emit("run.completed", { output }),
@@ -150,113 +147,50 @@ export class Gateway {
150
147
  run.controller.abort();
151
148
  return json(res, { ok: true });
152
149
  }
153
- return json(
154
- res,
155
- { error: "本地模式仅执行管理员允许的 MCP 工具,无待审批请求" },
156
- 409,
157
- );
158
- }
159
- async proxy(req, res, pathname, c) {
160
- let payload;
161
- const controller = new AbortController();
162
- this.controllers.add(controller);
163
- res.on("close", () => controller.abort());
164
- try {
165
- if (req.method === "POST")
166
- payload = JSON.parse((await body(req)).toString() || "{}");
167
- const input = payload?.input;
168
- if (pathname === "/v1/runs")
169
- payload.instructions =
170
- (await this.agent.context(input, "chat")) +
171
- "\n\n" +
172
- (payload.instructions || "");
173
- const upstream = await fetch(c.hermesUrl.replace(/\/$/, "") + pathname, {
174
- method: req.method,
175
- headers: {
176
- "Content-Type": "application/json",
177
- Authorization: `Bearer ${c.hermesKey}`,
178
- },
179
- body: payload ? JSON.stringify(payload) : undefined,
180
- signal: AbortSignal.any([
181
- controller.signal,
182
- AbortSignal.timeout(c.timeoutSec * 1000),
183
- ]),
184
- });
185
- if (pathname === "/v1/runs") {
186
- const result = await upstream.json();
187
- if (upstream.ok)
188
- this.external.set(result.run_id || result.id, {
189
- input,
190
- created: Date.now(),
191
- });
192
- return json(res, result, upstream.status);
193
- }
194
- res.writeHead(upstream.status, {
195
- "Content-Type":
196
- upstream.headers.get("content-type") || "application/json",
197
- "Cache-Control": "no-store",
198
- });
199
- if (pathname.endsWith("/events") && upstream.ok) {
200
- let buffer = "",
201
- eventName = "";
202
- const decoder = new StringDecoder("utf8");
203
- const id = pathname.split("/")[3];
204
- for await (const chunk of Readable.fromWeb(
205
- upstream.body as unknown as import("node:stream/web").ReadableStream,
206
- )) {
207
- res.write(chunk);
208
- buffer += decoder.write(chunk);
209
- let end;
210
- while ((end = buffer.indexOf("\n")) >= 0) {
211
- const line = buffer.slice(0, end).trim();
212
- buffer = buffer.slice(end + 1);
213
- if (line.startsWith("event:")) {
214
- eventName = line.slice(6).trim();
215
- continue;
216
- }
217
- if (!line.startsWith("data:")) continue;
218
- let event;
219
- try {
220
- event = JSON.parse(line.slice(5));
221
- } catch {
222
- continue;
223
- }
224
- if (
225
- event.event === "run.completed" ||
226
- event.type === "run.completed" ||
227
- eventName === "run.completed"
228
- ) {
229
- const run = this.external.get(id);
230
- this.external.delete(id);
231
- if (run) {
232
- const task = this.agent
233
- .learn(
234
- typeof run.input === "string"
235
- ? run.input
236
- : JSON.stringify(run.input),
237
- event.output || "",
238
- "chat",
239
- this.shutdown.signal,
240
- )
241
- .catch(this.onError);
242
- this.background.add(task);
243
- void task.finally(() => this.background.delete(task));
244
- }
245
- }
246
- eventName = "";
247
- }
248
- }
249
- res.end();
250
- } else
251
- await pipeline(
252
- Readable.fromWeb(
253
- upstream.body as unknown as import("node:stream/web").ReadableStream,
254
- ),
150
+ if (action === "approval" && req.method === "POST") {
151
+ const { choice } = JSON.parse((await body(req)).toString());
152
+ if (!["once", "deny"].includes(choice))
153
+ return json(
255
154
  res,
155
+ { error: "DSH 审批仅对本次操作生效,请选择 once 或 deny" },
156
+ 400,
256
157
  );
257
- } finally {
258
- this.controllers.delete(controller);
158
+ if (!run.approval) return json(res, { error: "当前没有待审批请求" }, 409);
159
+ run.approval(choice === "once" ? "allowed-once" : "rejected");
160
+ return json(res, { ok: true });
259
161
  }
162
+ return json(res, { error: "Method not allowed" }, 405);
163
+ }
164
+ private askApproval(run: Run, request, emit): Promise<ApprovalAnswer> {
165
+ const task = run.approvalQueue.then(
166
+ () =>
167
+ new Promise<ApprovalAnswer>((resolve) => {
168
+ const signal = AbortSignal.any([
169
+ run.controller.signal,
170
+ ...(request.signal ? [request.signal] : []),
171
+ ]);
172
+ if (signal.aborted) return resolve("cancelled");
173
+ let timer: NodeJS.Timeout;
174
+ const finish = (answer: ApprovalAnswer) => {
175
+ clearTimeout(timer);
176
+ signal.removeEventListener("abort", abort);
177
+ if (run.approval === finish) run.approval = undefined;
178
+ resolve(answer);
179
+ };
180
+ const abort = () => finish("cancelled");
181
+ run.approval = finish;
182
+ signal.addEventListener("abort", abort, { once: true });
183
+ const seconds = this.config().modules.chat.approvalTimeoutSec;
184
+ if (seconds > 0)
185
+ timer = setTimeout(() => finish("rejected"), seconds * 1000);
186
+ emit("approval.request", {
187
+ command: request.toolName,
188
+ description: `${request.reason || "DSH 工具操作"}(仅支持本次允许 once / 拒绝 deny)`,
189
+ });
190
+ }),
191
+ );
192
+ run.approvalQueue = task.catch(() => {});
193
+ return task;
260
194
  }
261
195
  async close() {
262
196
  this.closed = true;
@@ -265,7 +199,6 @@ export class Gateway {
265
199
  for (const run of this.runs.values()) {
266
200
  run.controller.abort();
267
201
  }
268
- for (const c of this.controllers) c.abort();
269
202
  this.server?.closeAllConnections();
270
203
  await new Promise<void>((resolve) =>
271
204
  this.server ? this.server.close(() => resolve()) : resolve(),
package/src/harness.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { HostAPI } from "@dsh-plugin/dsh-loader";
5
+ import type {
6
+ ConfigSource,
7
+ PluginPaths,
8
+ ChatMessage,
9
+ AgentEvent,
10
+ AvailableTool,
11
+ } from "./types.js";
12
+ import type { McpManager } from "./mcp.js";
13
+
14
+ export type ApprovalAnswer = "allowed-once" | "rejected" | "cancelled";
15
+ export type AskApproval = (request: {
16
+ toolName: string;
17
+ reason?: string;
18
+ signal?: AbortSignal;
19
+ }) => Promise<ApprovalAnswer>;
20
+
21
+ /** DSH owns model routing, the tool loop, policies, cancellation and session logs. */
22
+ export class Harness {
23
+ private shutdown = new AbortController();
24
+ private tasks = new Set<Promise<string>>();
25
+ constructor(
26
+ private loader: HostAPI,
27
+ private config: ConfigSource,
28
+ private paths: PluginPaths,
29
+ private mcp: McpManager,
30
+ ) {}
31
+
32
+ run(
33
+ system: string,
34
+ input: unknown,
35
+ history: ChatMessage[] = [],
36
+ tools: AvailableTool[] = [],
37
+ signal?: AbortSignal,
38
+ onEvent: AgentEvent = () => {},
39
+ ask?: AskApproval,
40
+ ) {
41
+ const task = this.execute(
42
+ system,
43
+ input,
44
+ history,
45
+ tools,
46
+ signal,
47
+ onEvent,
48
+ ask,
49
+ );
50
+ this.tasks.add(task);
51
+ void task.finally(() => this.tasks.delete(task)).catch(() => {});
52
+ return task;
53
+ }
54
+
55
+ private async content(input: unknown): Promise<any[]> {
56
+ if (typeof input === "string") return [{ type: "text", text: input }];
57
+ if (!Array.isArray(input)) throw new Error("聊天输入必须是文本或消息数组");
58
+ const parts = input.flatMap((item) =>
59
+ item.role
60
+ ? item.role === "user"
61
+ ? typeof item.content === "string"
62
+ ? [{ type: "text", text: item.content }]
63
+ : item.content
64
+ : []
65
+ : [item],
66
+ );
67
+ const result = [];
68
+ for (const part of parts) {
69
+ if (part.type === "text" && typeof part.text === "string")
70
+ result.push({ type: "text", text: part.text });
71
+ else if (part.type === "image_url") {
72
+ const match = String(part.image_url?.url).match(
73
+ /^data:(image\/(?:png|jpeg|webp|gif));base64,\s*([A-Za-z0-9+/=\s]+)$/,
74
+ );
75
+ if (!match) throw new Error("图片须由聊天通道先转换为 data URL");
76
+ if (match[2].length > 28_000_000) throw new Error("图片超过大小上限");
77
+ const attachments = this.loader.services.get("attachments");
78
+ if (!attachments?.saveImage) throw new Error("DSH 附件服务不可用");
79
+ result.push({
80
+ type: "image",
81
+ attachment: await attachments.saveImage({
82
+ data: Buffer.from(match[2], "base64"),
83
+ mediaType: match[1],
84
+ }),
85
+ });
86
+ } else throw new Error(`不支持的消息内容:${part.type}`);
87
+ }
88
+ return result;
89
+ }
90
+
91
+ private async execute(
92
+ system: string,
93
+ input: unknown,
94
+ history: ChatMessage[],
95
+ available: AvailableTool[],
96
+ callerSignal: AbortSignal,
97
+ onEvent: AgentEvent,
98
+ ask?: AskApproval,
99
+ ): Promise<string> {
100
+ const signal = AbortSignal.any([
101
+ this.shutdown.signal,
102
+ AbortSignal.timeout(this.config().timeoutSec * 1000),
103
+ ...(callerSignal ? [callerSignal] : []),
104
+ ]);
105
+ signal.throwIfAborted();
106
+ const agents = this.loader.services.get("agents");
107
+ const defaults = this.loader.services.get("agentDefaultModel");
108
+ if (!agents?.create || !defaults?.currentSelection)
109
+ throw new Error("DSH Agent / 默认模型服务不可用,请检查宿主加载状态");
110
+ const selection = defaults.currentSelection();
111
+ if (!selection?.provider || !selection?.model)
112
+ throw new Error("请先在 DSH 的模型设置中选择默认模型");
113
+ const content = await this.content(input);
114
+ const cwd = path.join(this.paths.data, "sessions");
115
+ await mkdir(cwd, { recursive: true });
116
+ let error: Error;
117
+ let output = "",
118
+ attemptText = "";
119
+ let steps = 0;
120
+ const handle = await agents.create({
121
+ sessionId: randomUUID(),
122
+ meta: { cwd },
123
+ agentOptions: selection,
124
+ signal,
125
+ setup: (scope, agent) => {
126
+ const prompt = scope.get("systemPrompt"),
127
+ tools = scope.get("tools");
128
+ if (
129
+ !prompt?.section ||
130
+ !prompt?.variable ||
131
+ !tools?.restrict ||
132
+ !tools?.register
133
+ )
134
+ throw new Error("DSH 提示词或工具作用域服务不可用");
135
+ prompt.variable("synapse_context", () => system);
136
+ prompt.section({
137
+ name: "deployment:persona-prefix",
138
+ order: 0,
139
+ text: "{{synapse_context}}",
140
+ });
141
+ // Preserve the existing channel boundary: only explicitly allowed MCP tools.
142
+ // Actual calls still pass through the DSH executor and permission policies.
143
+ tools.restrict({ allow: [] });
144
+ for (const tool of available)
145
+ tools.register({
146
+ name: tool.name,
147
+ description: tool.description,
148
+ parameters: tool.parameters,
149
+ output: {
150
+ schema: { type: "object", additionalProperties: true },
151
+ render: (_args, value) => [
152
+ { type: "text", text: JSON.stringify(value).slice(0, 40000) },
153
+ ],
154
+ },
155
+ execute: async (args, exec) => {
156
+ const started = Date.now();
157
+ let failed = false;
158
+ onEvent("tool.started", {
159
+ tool: `${tool.server}/${tool.rawName}`,
160
+ });
161
+ try {
162
+ return await this.mcp.call(tool, args, exec.signal);
163
+ } catch (e) {
164
+ failed = true;
165
+ throw e;
166
+ } finally {
167
+ onEvent("tool.completed", {
168
+ tool: `${tool.server}/${tool.rawName}`,
169
+ duration: (Date.now() - started) / 1000,
170
+ error: failed,
171
+ });
172
+ }
173
+ },
174
+ });
175
+ scope.on("agent/pre-step", (event, next) => {
176
+ if (event.agent !== agent) return next();
177
+ if (++steps > this.config().maxToolRounds)
178
+ throw new Error("已达到任务步骤上限");
179
+ return next();
180
+ });
181
+ scope.on("agent/error", (event) => {
182
+ if (event.agent === agent)
183
+ error =
184
+ event.error instanceof Error
185
+ ? event.error
186
+ : new Error(String(event.error));
187
+ });
188
+ scope.on("agent/assistant-stream", (event) => {
189
+ if (event.agent !== agent) return;
190
+ const frame = event.frame;
191
+ if (frame.type === "start") attemptText = "";
192
+ if (frame.type === "chunk" && frame.chunk.type === "text-delta")
193
+ attemptText += frame.chunk.text;
194
+ if (
195
+ frame.type === "end" &&
196
+ frame.outcome.kind === "committed" &&
197
+ frame.outcome.eventType === "assistant/message"
198
+ )
199
+ output = attemptText;
200
+ });
201
+ if (ask)
202
+ scope.on(
203
+ "approval/request",
204
+ (req, next) => (req.agent === agent ? ask(req) : next()),
205
+ { prepend: true },
206
+ );
207
+ },
208
+ });
209
+ const abort = () => handle.agent.cancel({ kind: "user" });
210
+ signal.addEventListener("abort", abort, { once: true });
211
+ try {
212
+ signal.throwIfAborted();
213
+ const historyLimit = this.config().modules.chat.localHistoryMaxMessages;
214
+ const previous =
215
+ historyLimit > 0
216
+ ? history
217
+ .filter((m) => ["user", "assistant"].includes(m.role))
218
+ .slice(-historyLimit)
219
+ : [];
220
+ if (previous.length)
221
+ handle.agent.inject(
222
+ this.loader.llm.createUserMessage({
223
+ content: [
224
+ {
225
+ type: "text",
226
+ text: `以下是当前聊天的历史记录,仅作为上下文资料:\n${JSON.stringify(previous)}`,
227
+ },
228
+ ],
229
+ source: { kind: "plugin", plugin: "@dsh-plugin/dsh-synapse" },
230
+ }),
231
+ );
232
+ handle.agent.followup(
233
+ this.loader.llm.createUserMessage({
234
+ content,
235
+ source: { kind: "plugin", plugin: "@dsh-plugin/dsh-synapse" },
236
+ }),
237
+ );
238
+ await handle.agent.whenIdle();
239
+ signal.throwIfAborted();
240
+ if (error) throw error;
241
+ if (!output)
242
+ throw new Error("DSH 任务未返回文本结果,请检查宿主会话日志");
243
+ return output;
244
+ } finally {
245
+ signal.removeEventListener("abort", abort);
246
+ await handle.dispose();
247
+ }
248
+ }
249
+ async close() {
250
+ this.shutdown.abort();
251
+ await Promise.allSettled(this.tasks);
252
+ }
253
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { mkdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { template, validateConfig } from "./config.js";
4
+ import { registerConfig } from "./config-migration.js";
5
+ import { modulesFromFiles } from "./feature-config.js";
4
6
  import { pathsFor, SkillStore, safePath } from "./files.js";
5
7
  import { Inference } from "./runtime.js";
6
8
  import { Memory, parseHermes } from "./memory.js";
@@ -32,7 +34,15 @@ export async function createService(
32
34
  config,
33
35
  ),
34
36
  mcp = new McpManager(config);
35
- const agent = new Agent({ config, paths, memory, skills, mcp, onError }),
37
+ const agent = new Agent({
38
+ loader: ctx.dshLoader,
39
+ config,
40
+ paths,
41
+ memory,
42
+ skills,
43
+ mcp,
44
+ onError,
45
+ }),
36
46
  gateway = new Gateway(agent, config, onError);
37
47
  const bot = new Bot(paths, config, gateway, onError),
38
48
  reviews = new Reviews(memory.db, agent, config, bot, onError);
@@ -41,6 +51,7 @@ export async function createService(
41
51
  reviews.close(),
42
52
  bot.stop(),
43
53
  gateway.close(),
54
+ agent.close(),
44
55
  mcp.close(),
45
56
  inference.close(),
46
57
  ]);
@@ -71,10 +82,7 @@ export async function createService(
71
82
  };
72
83
  }
73
84
  export async function apply(ctx: SynapseContext) {
74
- const handle = await ctx.dshLoader.config.register<SynapseConfig>(
75
- ctx,
76
- template,
77
- );
85
+ const handle = await registerConfig(ctx);
78
86
  let service,
79
87
  disposed = false,
80
88
  lastError = "",
@@ -129,21 +137,20 @@ export async function apply(ctx: SynapseContext) {
129
137
  res,
130
138
  service.memory.list(url.searchParams.get("channel") || "chat"),
131
139
  );
132
- if (req.method === "GET" && action === "features") {
133
- const live = await service.bot.liveFeatures();
134
- return json(
135
- res,
136
- service.config().features.map((f) => ({
137
- id: f.id,
138
- value: {
139
- ...JSON.parse(f.json),
140
- ...(live.find((x) => x.id === f.id)?.value || {}),
141
- },
142
- })),
143
- );
144
- }
145
140
  if (req.method === "POST") {
146
141
  const data = JSON.parse((await body(req)).toString() || "{}");
142
+ if (action === "features/refresh") {
143
+ const current = handle.get();
144
+ if (current.revision !== data.expectedRevision)
145
+ return json(res, { error: "配置已变更,请重新载入后同步" }, 409);
146
+ const modules = modulesFromFiles(
147
+ current.value.modules,
148
+ await service.bot.liveFeatures(),
149
+ );
150
+ validateConfig({ ...current.value, modules });
151
+ await handle.update({ modules }, current.revision);
152
+ return json(res, { ok: true });
153
+ }
147
154
  if (action === "skills/save")
148
155
  return json(
149
156
  res,
package/src/mcp.ts CHANGED
@@ -15,7 +15,9 @@ export class McpManager {
15
15
  }
16
16
  async connect(name) {
17
17
  if (this.closed) throw new Error("MCP 已关闭");
18
- const spec = this.config().mcp.find((x) => x.name === name && x.enabled);
18
+ const spec = this.config().mcpServers.find(
19
+ (x) => x.name === name && x.enabled,
20
+ );
19
21
  if (!spec) throw new Error("MCP 服务未启用");
20
22
  if (!this.connections.has(name))
21
23
  this.connections.set(
@@ -26,22 +28,32 @@ export class McpManager {
26
28
  spec.transport === "stdio"
27
29
  ? new StdioClientTransport({
28
30
  command: spec.command,
29
- args: JSON.parse(spec.argsJson),
31
+ args: spec.args,
30
32
  env: {
31
33
  PATH: process.env.PATH ?? "",
32
34
  ...(process.env.SystemRoot
33
35
  ? { SystemRoot: process.env.SystemRoot }
34
36
  : {}),
35
- ...JSON.parse(spec.envJson),
37
+ ...Object.fromEntries(
38
+ spec.env.map((e) => [e.name, e.value]),
39
+ ),
36
40
  },
37
41
  stderr: "ignore",
38
42
  })
39
43
  : spec.transport === "sse"
40
44
  ? new SSEClientTransport(new URL(spec.url), {
41
- requestInit: { headers: JSON.parse(spec.headersJson) },
45
+ requestInit: {
46
+ headers: Object.fromEntries(
47
+ spec.headers.map((e) => [e.name, e.value]),
48
+ ),
49
+ },
42
50
  })
43
51
  : new StreamableHTTPClientTransport(new URL(spec.url), {
44
- requestInit: { headers: JSON.parse(spec.headersJson) },
52
+ requestInit: {
53
+ headers: Object.fromEntries(
54
+ spec.headers.map((e) => [e.name, e.value]),
55
+ ),
56
+ },
45
57
  });
46
58
  try {
47
59
  await client.connect(transport, { timeout: 15000 });
@@ -75,8 +87,8 @@ export class McpManager {
75
87
  }
76
88
  async available(): Promise<AvailableTool[]> {
77
89
  const result: AvailableTool[] = [];
78
- for (const spec of this.config().mcp.filter((s) => s.enabled)) {
79
- const allowed = new Set(spec.allowedTools.split(/\s+/).filter(Boolean));
90
+ for (const spec of this.config().mcpServers.filter((s) => s.enabled)) {
91
+ const allowed = new Set(spec.allowedTools);
80
92
  if (!allowed.size) continue;
81
93
  for (const tool of await this.list(spec.name))
82
94
  if (allowed.has(tool.name))
@@ -90,16 +102,16 @@ export class McpManager {
90
102
  }
91
103
  return result;
92
104
  }
93
- async call(tool, args) {
94
- const spec = this.config().mcp.find(
105
+ async call(tool, args, signal?: AbortSignal) {
106
+ const spec = this.config().mcpServers.find(
95
107
  (s) => s.name === tool.server && s.enabled,
96
108
  );
97
- if (!spec?.allowedTools.split(/\s+/).includes(tool.rawName))
109
+ if (!spec?.allowedTools.includes(tool.rawName))
98
110
  throw new Error("工具不在允许列表");
99
111
  return (await this.connect(tool.server)).callTool(
100
112
  { name: tool.rawName, arguments: args },
101
113
  undefined,
102
- { timeout: 60000 },
114
+ { timeout: 60000, signal },
103
115
  );
104
116
  }
105
117
  async close() {
package/src/panel.html CHANGED
@@ -166,7 +166,8 @@
166
166
  <div id="content"></div>
167
167
  <footer class="muted">
168
168
  配置保存在当前 DSH
169
- profile。模型首次使用时下载;聊天与审查记忆分开检索。<br />AGPL-3.0 ·
169
+ profile。本地记忆模型首次使用时下载;聊天与审查记忆分开检索。<br />AGPL-3.0
170
+ ·
170
171
  <a href="https://github.com/dsh-plugins/dsh-synapse">获取完整源码</a>
171
172
  </footer>
172
173
  </main>