dsh-plugin-agentflow 0.0.0-stage → 0.2.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/README.md +88 -2
- package/cordis.patch.yml +195 -0
- package/index.js +448 -0
- package/lib/bridge.js +858 -0
- package/lib/client.js +4 -0
- package/package.json +42 -4
- package/runtime/boards.mjs +486 -0
- package/runtime/bridge.mjs +230 -0
- package/runtime/dsh-source.mjs +289 -0
- package/runtime/engine.mjs +352 -0
- package/runtime/index.html +3299 -0
- package/runtime/llm.mjs +252 -0
- package/runtime/mcp.mjs +823 -0
- package/runtime/preset-gen.mjs +1117 -0
- package/runtime/presets.mjs +399 -0
- package/runtime/run.mjs +76 -0
- package/runtime/server.mjs +1151 -0
- package/runtime/skills.mjs +131 -0
- package/runtime/store.mjs +103 -0
- package/runtime/verify.mjs +219 -0
- package/runtime/web/dist/assets/index-CkM6YPl4.js +348 -0
- package/runtime/web/dist/assets/index-DEZcpxgr.css +1 -0
- package/runtime/web/dist/index.html +13 -0
- package/scripts/build-client.mjs +36 -0
- package/scripts/sync-runtime.mjs +84 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// bridge.mjs —— AgentFlow 与 DSH 内核里 llm bridge 的通信层。
|
|
2
|
+
//
|
|
3
|
+
// 契约见 docs/bridge-contract.md。要点:
|
|
4
|
+
// - 不保留直连退路:bridge 连不上就是「DSH 内核未连接」,调用方必须失败
|
|
5
|
+
// - 发现文件由插件写、我们只读;按 mtime 缓存 2 秒
|
|
6
|
+
// - 流事件只有 delta / tool / usage / done / error 五种
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
13
|
+
const DEFAULT_DIR = path.join(DSH_HOME, 'agent-flow');
|
|
14
|
+
|
|
15
|
+
const INFO_TTL_MS = 2000;
|
|
16
|
+
const HEALTH_TIMEOUT_MS = 300;
|
|
17
|
+
const CATALOG_TIMEOUT_MS = 3000;
|
|
18
|
+
|
|
19
|
+
/** 发现文件位置:<agentflowDir>/data/dsh-bridge.json */
|
|
20
|
+
export function bridgeInfoPath(dir = DEFAULT_DIR) {
|
|
21
|
+
return path.join(dir, 'data', 'dsh-bridge.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let infoCache = { key: '', mtimeMs: 0, at: 0, info: null };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 读发现文件。失效的情况全部返回 null,由 bridgeUnavailableReason 给原因。
|
|
28
|
+
* @returns {Promise<{version:number,pid:number,port:number,token:string,startedAt:number}|null>}
|
|
29
|
+
*/
|
|
30
|
+
export async function readBridgeInfo(dir = DEFAULT_DIR) {
|
|
31
|
+
const file = bridgeInfoPath(dir);
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = await fs.stat(file);
|
|
35
|
+
} catch {
|
|
36
|
+
infoCache = { key: file, mtimeMs: 0, at: Date.now(), info: null };
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const key = file;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (infoCache.info && infoCache.key === key && infoCache.mtimeMs === stat.mtimeMs && now - infoCache.at < INFO_TTL_MS) {
|
|
42
|
+
return infoCache.info;
|
|
43
|
+
}
|
|
44
|
+
let info = null;
|
|
45
|
+
try {
|
|
46
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (parsed && Number.isInteger(parsed.pid) && Number.isInteger(parsed.port) && typeof parsed.token === 'string' && parsed.token) {
|
|
49
|
+
info = {
|
|
50
|
+
version: Number(parsed.version) || 1,
|
|
51
|
+
pid: parsed.pid,
|
|
52
|
+
port: parsed.port,
|
|
53
|
+
token: parsed.token,
|
|
54
|
+
startedAt: Number(parsed.startedAt) || 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
info = null;
|
|
59
|
+
}
|
|
60
|
+
infoCache = { key, mtimeMs: stat.mtimeMs, at: now, info };
|
|
61
|
+
return info;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 进程是否活着;EPERM 也算活着(存在但没权限发信号) */
|
|
65
|
+
export function pidAlive(pid) {
|
|
66
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return true;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return Boolean(err && err.code === 'EPERM');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function urlOf(info, suffix) {
|
|
76
|
+
return `http://127.0.0.1:${info.port}${suffix}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function authHeaders(info) {
|
|
80
|
+
return { authorization: `Bearer ${info.token}`, accept: 'application/json' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* bridge 不可用的原因;可用时返回 null。
|
|
85
|
+
* 文案直接给用户看,必须说清楚是哪一环断了。
|
|
86
|
+
*/
|
|
87
|
+
export async function bridgeUnavailableReason(dir = DEFAULT_DIR) {
|
|
88
|
+
const info = await readBridgeInfo(dir);
|
|
89
|
+
if (!info) return 'bridge 发现文件不存在(DSH 未运行或插件未加载)';
|
|
90
|
+
if (!pidAlive(info.pid)) return `bridge 进程已退出(pid ${info.pid})`;
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(urlOf(info, '/health'), {
|
|
93
|
+
headers: authHeaders(info),
|
|
94
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
95
|
+
});
|
|
96
|
+
if (res.status === 401) return 'bridge token 校验失败(401)';
|
|
97
|
+
if (!res.ok) return `bridge 健康检查返回 HTTP ${res.status}`;
|
|
98
|
+
const body = await res.json().catch(() => null);
|
|
99
|
+
if (!body || body.ok !== true) return 'bridge 健康检查返回异常内容';
|
|
100
|
+
return null;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const msg = String((err && err.message) || err);
|
|
103
|
+
if (/timeout|aborted/i.test(msg)) return `bridge 健康检查超时(${HEALTH_TIMEOUT_MS}ms)`;
|
|
104
|
+
return `bridge 健康检查失败:${msg}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 健康检查;不可用时抛错(由调用方决定怎么展示) */
|
|
109
|
+
export async function bridgeHealth(dir = DEFAULT_DIR) {
|
|
110
|
+
const reason = await bridgeUnavailableReason(dir);
|
|
111
|
+
if (reason) throw new Error(reason);
|
|
112
|
+
const info = await readBridgeInfo(dir);
|
|
113
|
+
const res = await fetch(urlOf(info, '/health'), {
|
|
114
|
+
headers: authHeaders(info),
|
|
115
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
116
|
+
});
|
|
117
|
+
return res.json();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 读模型目录;失败抛错(错误信息含原因) */
|
|
121
|
+
export async function bridgeCatalog(dir = DEFAULT_DIR) {
|
|
122
|
+
const info = await readBridgeInfo(dir);
|
|
123
|
+
if (!info) throw new Error('bridge 发现文件不存在(DSH 未运行或插件未加载)');
|
|
124
|
+
if (!pidAlive(info.pid)) throw new Error(`bridge 进程已退出(pid ${info.pid})`);
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await fetch(urlOf(info, '/catalog'), {
|
|
128
|
+
headers: authHeaders(info),
|
|
129
|
+
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
throw new Error(`bridge /catalog 请求失败:${String((err && err.message) || err)}`);
|
|
133
|
+
}
|
|
134
|
+
if (res.status === 401) throw new Error('bridge token 校验失败(401)');
|
|
135
|
+
if (!res.ok) throw new Error(`bridge /catalog 返回 HTTP ${res.status}`);
|
|
136
|
+
const body = await res.json().catch(() => null);
|
|
137
|
+
if (!body || !Array.isArray(body.providers)) throw new Error('bridge /catalog 返回结构不对');
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 发起一次流式模型调用。
|
|
143
|
+
* @param {object} request 见契约:provider/model/reasoningEffort/system/messages/tools
|
|
144
|
+
* @param {{dir?:string, signal?:AbortSignal}} [opts]
|
|
145
|
+
* @returns {Promise<{events: AsyncIterable<object>, abort: () => void}>}
|
|
146
|
+
*/
|
|
147
|
+
export async function bridgeStream(request, opts = {}) {
|
|
148
|
+
const dir = opts.dir || DEFAULT_DIR;
|
|
149
|
+
const info = await readBridgeInfo(dir);
|
|
150
|
+
if (!info) throw new Error('DSH 内核未连接(bridge 发现文件不存在)');
|
|
151
|
+
if (!pidAlive(info.pid)) throw new Error(`DSH 内核未连接(bridge 进程已退出,pid ${info.pid})`);
|
|
152
|
+
|
|
153
|
+
const controller = new AbortController();
|
|
154
|
+
const onOuterAbort = () => controller.abort();
|
|
155
|
+
if (opts.signal) {
|
|
156
|
+
if (opts.signal.aborted) controller.abort();
|
|
157
|
+
else opts.signal.addEventListener('abort', onOuterAbort, { once: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let res;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(urlOf(info, '/stream'), {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { ...authHeaders(info), 'content-type': 'application/json' },
|
|
165
|
+
body: JSON.stringify(request),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onOuterAbort);
|
|
170
|
+
throw new Error(`DSH 内核未连接(bridge /stream 请求失败:${String((err && err.message) || err)})`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (res.status === 401) throw new Error('DSH 内核未连接(bridge token 校验失败)');
|
|
174
|
+
if (res.status === 429) throw new Error('bridge 忙(并发超限)');
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
const text = await res.text().catch(() => '');
|
|
177
|
+
throw new Error(`bridge /stream 返回 HTTP ${res.status}${text ? ':' + text.slice(0, 200) : ''}`);
|
|
178
|
+
}
|
|
179
|
+
if (!res.body) throw new Error('bridge /stream 没有响应体');
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
events: readSseEvents(res.body),
|
|
183
|
+
abort: () => controller.abort(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 把 SSE 字节流解析成事件对象。只认 `data: ` 行;注释行(`: ping`)忽略。
|
|
189
|
+
* 结束时若没收到 done/error,抛错,避免调用方把截断当成功。
|
|
190
|
+
*/
|
|
191
|
+
async function* readSseEvents(body) {
|
|
192
|
+
const reader = body.getReader();
|
|
193
|
+
const decoder = new TextDecoder();
|
|
194
|
+
let buffer = '';
|
|
195
|
+
let finished = false;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
while (true) {
|
|
199
|
+
const { value, done } = await reader.read();
|
|
200
|
+
if (done) break;
|
|
201
|
+
buffer += decoder.decode(value, { stream: true });
|
|
202
|
+
let idx;
|
|
203
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
204
|
+
const line = buffer.slice(0, idx).replace(/\r$/, '');
|
|
205
|
+
buffer = buffer.slice(idx + 1);
|
|
206
|
+
if (!line || line.startsWith(':')) continue;
|
|
207
|
+
if (!line.startsWith('data:')) continue;
|
|
208
|
+
const payload = line.slice(5).trim();
|
|
209
|
+
if (!payload) continue;
|
|
210
|
+
let evt;
|
|
211
|
+
try {
|
|
212
|
+
evt = JSON.parse(payload);
|
|
213
|
+
} catch {
|
|
214
|
+
continue; // 坏行跳过,别把整次调用打断
|
|
215
|
+
}
|
|
216
|
+
if (evt && evt.type === 'error') {
|
|
217
|
+
finished = true;
|
|
218
|
+
throw new Error(String(evt.message || 'bridge 调用失败'));
|
|
219
|
+
}
|
|
220
|
+
if (evt && evt.type === 'done') finished = true;
|
|
221
|
+
yield evt;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
try {
|
|
226
|
+
await reader.cancel();
|
|
227
|
+
} catch {}
|
|
228
|
+
if (!finished) throw new Error('bridge 流提前结束(没有收到 done/error)');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// dsh-source.mjs —— AgentFlow 的「DSH 版」配置来源。
|
|
2
|
+
//
|
|
3
|
+
// 原则:模型、MCP、技能**一律取自 DSH 自己的配置**,AgentFlow 不再自带一套。
|
|
4
|
+
// - 模型:DSH 内核的 llm bridge(GET /catalog),不读凭据、不直连 /models
|
|
5
|
+
// - MCP :cordis.patch.yml 里所有 dsh-mcp-client 行
|
|
6
|
+
// - 技能:~/.dsh/skills
|
|
7
|
+
//
|
|
8
|
+
// 这样做的代价是:DSH 里没配的东西,AgentFlow 里也用不了。这是有意的。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { bridgeCatalog } from './bridge.mjs';
|
|
15
|
+
|
|
16
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
17
|
+
const PROFILE_DIR = path.join(DSH_HOME, 'profiles', 'desktop');
|
|
18
|
+
const PATCH_FILE = path.join(PROFILE_DIR, 'cordis.patch.yml');
|
|
19
|
+
const SKILL_DIR = path.join(DSH_HOME, 'skills');
|
|
20
|
+
/** bridge 发现文件所在目录:<dir>/data/dsh-bridge.json */
|
|
21
|
+
const DEFAULT_BRIDGE_DIR = path.join(DSH_HOME, 'agent-flow');
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 极简 YAML:够读 cordis.patch.yml 的「数组项 → 缩进映射/列表」这一种形状。
|
|
25
|
+
// 不追求通用,只求把 loader patch 的结构读出来。注释、`!!js` 标签都跳过。
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function parseScalar(raw) {
|
|
29
|
+
let v = raw.trim();
|
|
30
|
+
if (v === '') return '';
|
|
31
|
+
// 去掉行尾注释(引号外的 #)
|
|
32
|
+
let inQuote = null;
|
|
33
|
+
for (let i = 0; i < v.length; i++) {
|
|
34
|
+
const c = v[i];
|
|
35
|
+
if (inQuote) {
|
|
36
|
+
if (c === inQuote) inQuote = null;
|
|
37
|
+
} else if (c === '"' || c === "'") {
|
|
38
|
+
inQuote = c;
|
|
39
|
+
} else if (c === '#' && (i === 0 || v[i - 1] === ' ')) {
|
|
40
|
+
v = v.slice(0, i).trim();
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (v === '') return '';
|
|
45
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
46
|
+
return v.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
if (v === 'true') return true;
|
|
49
|
+
if (v === 'false') return false;
|
|
50
|
+
if (v === 'null' || v === '~') return null;
|
|
51
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 把 YAML 文本拆成 {indent, content, raw} 行数组,丢掉空行与整行注释 */
|
|
56
|
+
function meaningfulLines(text) {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
59
|
+
const trimmed = raw.trimStart();
|
|
60
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
61
|
+
const indent = raw.length - trimmed.length;
|
|
62
|
+
out.push({ indent, content: trimmed.replace(/\s+$/, ''), raw });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 解析一小段 YAML(从 start 行开始的连续块),返回 {value, next}。
|
|
69
|
+
* 只处理三种形状:映射 `k: v`、数组 `- v`、数组里的映射 `- k: v`。
|
|
70
|
+
*/
|
|
71
|
+
function parseBlock(lines, start, indent) {
|
|
72
|
+
if (start >= lines.length) return { value: null, next: start };
|
|
73
|
+
const isArray = lines[start].indent === indent && lines[start].content.startsWith('-');
|
|
74
|
+
const container = isArray ? [] : {};
|
|
75
|
+
let i = start;
|
|
76
|
+
|
|
77
|
+
while (i < lines.length) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
if (line.indent < indent) break;
|
|
80
|
+
if (line.indent > indent) { i++; continue; } // 不该出现,跳过
|
|
81
|
+
if (isArray && !line.content.startsWith('-')) break;
|
|
82
|
+
if (!isArray && line.content.startsWith('-')) break;
|
|
83
|
+
|
|
84
|
+
if (isArray) {
|
|
85
|
+
const body = line.content.slice(1).trim();
|
|
86
|
+
if (body === '') {
|
|
87
|
+
// 「- 」后面换行接子块
|
|
88
|
+
const sub = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? indent + 2);
|
|
89
|
+
container.push(sub.value);
|
|
90
|
+
i = sub.next;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const kv = /^([\w$.\-@/]+)\s*:\s*(.*)$/.exec(body);
|
|
94
|
+
if (kv) {
|
|
95
|
+
// 「- key: value」开始一个映射,后续同缩进的键并入
|
|
96
|
+
const itemIndent = line.indent + 2;
|
|
97
|
+
const item = {};
|
|
98
|
+
const headKey = kv[1];
|
|
99
|
+
const headVal = kv[2];
|
|
100
|
+
if (headVal !== '') {
|
|
101
|
+
item[headKey] = parseScalar(headVal);
|
|
102
|
+
i++;
|
|
103
|
+
} else {
|
|
104
|
+
const sub = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? itemIndent + 2);
|
|
105
|
+
item[headKey] = sub.value;
|
|
106
|
+
i = sub.next;
|
|
107
|
+
}
|
|
108
|
+
// 继续吃同一项的其它键
|
|
109
|
+
while (i < lines.length && lines[i].indent === itemIndent && !lines[i].content.startsWith('-')) {
|
|
110
|
+
const k2 = /^([\w$.\-@/]+)\s*:\s*(.*)$/.exec(lines[i].content);
|
|
111
|
+
if (!k2) { i++; continue; }
|
|
112
|
+
if (k2[2] !== '') {
|
|
113
|
+
item[k2[1]] = parseScalar(k2[2]);
|
|
114
|
+
i++;
|
|
115
|
+
} else {
|
|
116
|
+
const sub2 = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? itemIndent + 2);
|
|
117
|
+
item[k2[1]] = sub2.value;
|
|
118
|
+
i = sub2.next;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
container.push(item);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
container.push(parseScalar(body));
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const kv = /^([\w$.\-@/]+)\s*:\s*(.*)$/.exec(line.content);
|
|
130
|
+
if (!kv) { i++; continue; }
|
|
131
|
+
if (kv[2] !== '') {
|
|
132
|
+
container[kv[1]] = parseScalar(kv[2]);
|
|
133
|
+
i++;
|
|
134
|
+
} else {
|
|
135
|
+
const sub = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? indent + 2);
|
|
136
|
+
container[kv[1]] = sub.value;
|
|
137
|
+
i = sub.next;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { value: container, next: i };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 读 patch 文件 → 顶层数组 */
|
|
144
|
+
async function readPatch() {
|
|
145
|
+
const text = await fs.readFile(PATCH_FILE, 'utf8').catch(() => '');
|
|
146
|
+
if (!text.trim()) return [];
|
|
147
|
+
const lines = meaningfulLines(text);
|
|
148
|
+
if (!lines.length) return [];
|
|
149
|
+
const { value } = parseBlock(lines, 0, lines[0].indent);
|
|
150
|
+
return Array.isArray(value) ? value : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 把 patch 里的每一行摊平(insert 进来的行也摊平) */
|
|
154
|
+
function flattenRows(patch) {
|
|
155
|
+
const rows = [];
|
|
156
|
+
for (const entry of patch) {
|
|
157
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
158
|
+
if (Array.isArray(entry.insert)) {
|
|
159
|
+
for (const row of entry.insert) if (row && typeof row === 'object') rows.push(row);
|
|
160
|
+
} else if (entry.id || entry.name) {
|
|
161
|
+
rows.push(entry);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return rows;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 读 DSH 配的 MCP 服务器。
|
|
169
|
+
* @returns {Promise<Record<string, object>>} serverName → spec
|
|
170
|
+
*/
|
|
171
|
+
export async function readDshMcpServers() {
|
|
172
|
+
const rows = flattenRows(await readPatch());
|
|
173
|
+
const servers = {};
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const pkg = String(row.name || '');
|
|
176
|
+
if (!pkg.includes('dsh-mcp-client')) continue;
|
|
177
|
+
const cfg = row.config || {};
|
|
178
|
+
const name = cfg.serverName;
|
|
179
|
+
if (!name) continue;
|
|
180
|
+
// 转成 AgentFlow mcp.mjs 认的 spec 形状
|
|
181
|
+
if (cfg.transport === 'stdio') {
|
|
182
|
+
servers[name] = {
|
|
183
|
+
command: cfg.command,
|
|
184
|
+
args: cfg.args || [],
|
|
185
|
+
env: cfg.env || {},
|
|
186
|
+
cwd: cfg.cwd,
|
|
187
|
+
source: 'dsh',
|
|
188
|
+
};
|
|
189
|
+
} else {
|
|
190
|
+
servers[name] = {
|
|
191
|
+
url: cfg.url,
|
|
192
|
+
headers: cfg.headers || {},
|
|
193
|
+
source: 'dsh',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return servers;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** DSH 的技能目录 */
|
|
201
|
+
export function readDshSkillDirs() {
|
|
202
|
+
return [SKILL_DIR];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// 模型目录:只从 bridge 的 GET /catalog 取。
|
|
207
|
+
//
|
|
208
|
+
// 不再读 .credentials.yaml、不再直连各家的 /models、也不再判断「是不是 OpenAI 兼容」——
|
|
209
|
+
// DSH 里能用什么模型,由内核说了算;bridge 侧缓存 ≤10 秒,这里不再加自己的长缓存。
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** 最近一次成功读到目录的时间;/api/bridge 用它算 catalogAgeMs */
|
|
213
|
+
let lastCatalogOkAt = 0;
|
|
214
|
+
|
|
215
|
+
/** 最近一次成功拿到目录距今多久(ms);从未成功返回 null */
|
|
216
|
+
export function dshCatalogAgeMs() {
|
|
217
|
+
return lastCatalogOkAt ? Date.now() - lastCatalogOkAt : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 把 bridge 的 model 条目归一成 AgentFlow 前端认的形状;字段缺了就留空,不编默认值 */
|
|
221
|
+
function normalizeCatalogModel(raw) {
|
|
222
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
223
|
+
const id = typeof raw.id === 'string' ? raw.id : '';
|
|
224
|
+
if (!id) return null;
|
|
225
|
+
const levels = Array.isArray(raw.levels) ? raw.levels.filter((x) => typeof x === 'string' && x) : [];
|
|
226
|
+
return {
|
|
227
|
+
id,
|
|
228
|
+
name: typeof raw.name === 'string' && raw.name ? raw.name : id,
|
|
229
|
+
levels,
|
|
230
|
+
defaultLevel: typeof raw.defaultLevel === 'string' ? raw.defaultLevel : levels[0] || '',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 把 bridge 的 providers 归一:单条缺 id 的丢掉,models 过滤坏项 */
|
|
235
|
+
export function normalizeCatalogProviders(rawProviders) {
|
|
236
|
+
if (!Array.isArray(rawProviders)) return [];
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const p of rawProviders) {
|
|
239
|
+
if (!p || typeof p !== 'object') continue;
|
|
240
|
+
const id = typeof p.id === 'string' ? p.id : '';
|
|
241
|
+
if (!id) continue;
|
|
242
|
+
const models = Array.isArray(p.models)
|
|
243
|
+
? p.models.map(normalizeCatalogModel).filter(Boolean)
|
|
244
|
+
: [];
|
|
245
|
+
out.push({
|
|
246
|
+
id,
|
|
247
|
+
label: typeof p.label === 'string' && p.label ? p.label : id,
|
|
248
|
+
models,
|
|
249
|
+
error: p.error == null ? null : String(p.error),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 读 DSH 实际可用的模型目录(只走 bridge)。
|
|
257
|
+
* bridge 不可用时返回空 providers + error,不抛错 —— 页面照常能开,只是选不到模型。
|
|
258
|
+
* @param {string} [dir] bridge 发现文件所在目录
|
|
259
|
+
* @returns {Promise<{providers: Array, fetchedAt: number, cached: boolean, error?: string}>}
|
|
260
|
+
*/
|
|
261
|
+
export async function readDshModelCatalog(dir = DEFAULT_BRIDGE_DIR) {
|
|
262
|
+
let body;
|
|
263
|
+
try {
|
|
264
|
+
body = await bridgeCatalog(dir);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return {
|
|
267
|
+
providers: [],
|
|
268
|
+
fetchedAt: Date.now(),
|
|
269
|
+
cached: false,
|
|
270
|
+
error: String((err && err.message) || err),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
lastCatalogOkAt = Date.now();
|
|
274
|
+
return {
|
|
275
|
+
providers: normalizeCatalogProviders(body.providers),
|
|
276
|
+
fetchedAt: lastCatalogOkAt,
|
|
277
|
+
cached: false,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 一次性读除模型外的 DSH 配置(模型目录由 readDshModelCatalog 单独取,走 bridge) */
|
|
282
|
+
export async function readDshSource() {
|
|
283
|
+
const mcpServers = await readDshMcpServers();
|
|
284
|
+
return {
|
|
285
|
+
mcpServers,
|
|
286
|
+
skillDirs: readDshSkillDirs(),
|
|
287
|
+
paths: { patch: PATCH_FILE, skills: SKILL_DIR },
|
|
288
|
+
};
|
|
289
|
+
}
|