@fa-software/semi2-dsh 0.0.10-preview.20260920042705
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/cordis.patch.yml +5 -0
- package/lib/client.js +351 -0
- package/lib/client.js.map +1 -0
- package/lib/codegraph/cli.js +142 -0
- package/lib/codegraph/config.js +22 -0
- package/lib/codegraph/ensure.js +25 -0
- package/lib/codegraph/guide.js +21 -0
- package/lib/codegraph/index.js +97 -0
- package/lib/codegraph/tools.js +227 -0
- package/lib/codegraph/workspace.js +52 -0
- package/lib/configuration/compile.js +177 -0
- package/lib/configuration/credentials.js +104 -0
- package/lib/configuration/generated/graphql.js +1 -0
- package/lib/configuration/generated/schema.js +95 -0
- package/lib/configuration/graphql.js +193 -0
- package/lib/configuration/index.js +29 -0
- package/lib/configuration/login.js +96 -0
- package/lib/configuration/runtime.js +1100 -0
- package/lib/configuration/skill-resources.js +218 -0
- package/lib/configuration/skills.js +185 -0
- package/lib/configuration/status.js +57 -0
- package/lib/configuration/workspace.js +55 -0
- package/lib/configuration/workspaces.js +169 -0
- package/lib/contract.js +42 -0
- package/lib/index.js +14 -0
- package/lib/types/codegraph/cli.d.ts +20 -0
- package/lib/types/codegraph/config.d.ts +10 -0
- package/lib/types/codegraph/ensure.d.ts +3 -0
- package/lib/types/codegraph/guide.d.ts +4 -0
- package/lib/types/codegraph/index.d.ts +5 -0
- package/lib/types/codegraph/tools.d.ts +17 -0
- package/lib/types/codegraph/workspace.d.ts +8 -0
- package/lib/types/configuration/compile.d.ts +81 -0
- package/lib/types/configuration/credentials.d.ts +35 -0
- package/lib/types/configuration/generated/graphql.d.ts +100 -0
- package/lib/types/configuration/generated/schema.d.ts +1003 -0
- package/lib/types/configuration/graphql.d.ts +46 -0
- package/lib/types/configuration/index.d.ts +9 -0
- package/lib/types/configuration/login.d.ts +22 -0
- package/lib/types/configuration/runtime.d.ts +320 -0
- package/lib/types/configuration/skill-resources.d.ts +14 -0
- package/lib/types/configuration/skills.d.ts +8 -0
- package/lib/types/configuration/status.d.ts +9 -0
- package/lib/types/configuration/workspace.d.ts +7 -0
- package/lib/types/configuration/workspaces.d.ts +96 -0
- package/lib/types/contract.d.ts +32 -0
- package/lib/types/index.d.ts +11 -0
- package/lib/types/web/index.d.ts +2 -0
- package/lib/types/web/login.d.ts +2 -0
- package/lib/types/web/loopback-bridge.d.ts +8 -0
- package/lib/types/web/preview-proxy.d.ts +17 -0
- package/lib/types/web/runtime.d.ts +8 -0
- package/lib/types/web/welcome-ack.d.ts +33 -0
- package/lib/web/index.js +38 -0
- package/lib/web/login.js +79 -0
- package/lib/web/loopback-bridge.js +12 -0
- package/lib/web/preview-proxy.js +201 -0
- package/lib/web/runtime.js +19 -0
- package/lib/web/welcome-ack.js +51 -0
- package/package.json +81 -0
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { SEMI2_RUNTIME_SERVICE, } from '../contract.js';
|
|
5
|
+
import { compileBoot, connectionApiKeyEnv, connectionRoute, providerProfile, remapSelections, } from './compile.js';
|
|
6
|
+
import { clearConnection, connectionOf, resolveConnectionFields, storeConnection, } from './credentials.js';
|
|
7
|
+
import { ConfigurationError, consoleEndpoint, postGraphql, RUNTIME_BOOT_DOCUMENT, RUNTIME_SKILL_FILES_DOCUMENT, RUNTIME_WORKSPACE_PROJECTS_DOCUMENT, } from './graphql.js';
|
|
8
|
+
import { exchangeCnbToken, loginToConsole } from './login.js';
|
|
9
|
+
import { STATUS_FIELDS, statusFields } from './status.js';
|
|
10
|
+
import { matchProject, workspaceSlug } from './workspace.js';
|
|
11
|
+
import { canonicalPath, installWorkspaceFeed, pendingEntry, persistedWorkspaces, trackedWorkspaces, WorkspaceBook, } from './workspaces.js';
|
|
12
|
+
/** 等运行面契约出现的上限;超时按宿主直跑(无适配器)处理。 */
|
|
13
|
+
const CONTRACT_TIMEOUT_MS = 5_000;
|
|
14
|
+
const CONTRACT_POLL_MS = 25;
|
|
15
|
+
const CREDENTIAL_TRIES = 10;
|
|
16
|
+
const CREDENTIAL_INTERVAL_MS = 150;
|
|
17
|
+
const NAMESPACE_TRIES = 40;
|
|
18
|
+
const NAMESPACE_INTERVAL_MS = 50;
|
|
19
|
+
/** 自铸路由的键形状(semi2-<32 hex>):归属表里没有记录时按名字兜底认领。 */
|
|
20
|
+
const LEGACY_OWNED_PROVIDER = /^semi2-[\da-f]{32}$/;
|
|
21
|
+
/** 设置页 Semi2 分区的命名空间:reload 由 UI 写(触发重载),status 由本插件写(展示)。 */
|
|
22
|
+
const SETTINGS_NAMESPACE = 'semi2';
|
|
23
|
+
/**
|
|
24
|
+
* 本插件写过什么的归属表。预设连接的路由键就是 dsh 的提供方 id(anthropic / zai …),
|
|
25
|
+
* 光看名字分不清是插件的还是用户自己写的 profile;密钥与 llm-deepseek 覆盖同理。
|
|
26
|
+
* 服务端撤掉的本地要跟着撤,而「撤」的前提是知道哪几条是自己写的,所以这些事实单独存一份。
|
|
27
|
+
* 单列命名空间而不是塞进 `semi2`:后者由 publishStatus 走 replace 整体覆盖。
|
|
28
|
+
*/
|
|
29
|
+
const OWNED_NAMESPACE = 'semi2-providers';
|
|
30
|
+
/**
|
|
31
|
+
* 工作区 → git 项目映射的持久化命名空间。映射是本地事实(换台机器要重解析),
|
|
32
|
+
* 只存目录与解析结果;单列命名空间的理由同归属表——`semi2` 由 publishStatus 整体覆盖。
|
|
33
|
+
*/
|
|
34
|
+
const WORKSPACES_NAMESPACE = 'semi2-workspaces';
|
|
35
|
+
const OWNED_SCHEMA = z.object({
|
|
36
|
+
routes: z.array(z.string()).default([]),
|
|
37
|
+
credentials: z.array(z.string()).default([]),
|
|
38
|
+
deepseek: z.boolean().default(false),
|
|
39
|
+
});
|
|
40
|
+
/** 工作区映射的 settings schema(空值统一落空串,读侧再归一为 null)。 */
|
|
41
|
+
const WORKSPACES_SCHEMA = z.object({
|
|
42
|
+
entries: z
|
|
43
|
+
.array(z.object({
|
|
44
|
+
path: z.string().default(''),
|
|
45
|
+
title: z.string().default(''),
|
|
46
|
+
slug: z.string().default(''),
|
|
47
|
+
projectId: z.string().default(''),
|
|
48
|
+
name: z.string().default(''),
|
|
49
|
+
syncedAt: z.string().default(''),
|
|
50
|
+
}))
|
|
51
|
+
.default([]),
|
|
52
|
+
});
|
|
53
|
+
/** 本插件在 `llm-deepseek` 段写过的键:撤销只删这几个,不碰同段里用户自己加的键。 */
|
|
54
|
+
const DEEPSEEK_OWNED_KEYS = ['apiKeyEnv', 'baseURL', 'models'];
|
|
55
|
+
/** 从既有 provider profile 上取密钥引用名(回收归属表外的自铸路由时连带清理其密钥)。 */
|
|
56
|
+
function apiKeyEnvOf(profile) {
|
|
57
|
+
const value = profile?.apiKeyEnv;
|
|
58
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** 状态字段的 settings schema(键集 = status.ts 的 STATUS_FIELDS)。 */
|
|
61
|
+
const STATUS_SCHEMA = Object.fromEntries(STATUS_FIELDS.map(key => [key, z.string().default('')]));
|
|
62
|
+
function initialState(connect, consoleUrl, workspaces = []) {
|
|
63
|
+
return {
|
|
64
|
+
status: connect ? 'loading' : 'local',
|
|
65
|
+
loadedAt: null,
|
|
66
|
+
error: null,
|
|
67
|
+
consoleUrl,
|
|
68
|
+
identity: null,
|
|
69
|
+
model: null,
|
|
70
|
+
// null = 没拿到权威技能目录(≠ 空目录):provider 据此不得谎报权威空。
|
|
71
|
+
skills: null,
|
|
72
|
+
projects: [],
|
|
73
|
+
workspaces,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function text(value) {
|
|
77
|
+
const trimmed = value?.trim();
|
|
78
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
|
79
|
+
}
|
|
80
|
+
/** 持久化段 → 工作区条目;形状不合的行直接丢弃,不让坏数据拖垮启动。 */
|
|
81
|
+
function readWorkspaces(settings) {
|
|
82
|
+
const value = settings.describe().find(entry => entry.ns === WORKSPACES_NAMESPACE)?.value ??
|
|
83
|
+
{};
|
|
84
|
+
const rows = Array.isArray(value.entries) ? value.entries : [];
|
|
85
|
+
const entries = [];
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row))
|
|
88
|
+
continue;
|
|
89
|
+
const record = row;
|
|
90
|
+
const path = typeof record.path === 'string' ? record.path.trim() : '';
|
|
91
|
+
if (path === '')
|
|
92
|
+
continue;
|
|
93
|
+
entries.push({
|
|
94
|
+
path,
|
|
95
|
+
title: text(typeof record.title === 'string' ? record.title : undefined) ?? path,
|
|
96
|
+
slug: text(typeof record.slug === 'string' ? record.slug : undefined) ?? null,
|
|
97
|
+
projectId: text(typeof record.projectId === 'string' ? record.projectId : undefined) ??
|
|
98
|
+
null,
|
|
99
|
+
name: text(typeof record.name === 'string' ? record.name : undefined) ?? null,
|
|
100
|
+
syncedAt: text(typeof record.syncedAt === 'string' ? record.syncedAt : undefined) ??
|
|
101
|
+
null,
|
|
102
|
+
implicit: false,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
/** 契约是否自带连接凭据(自带即只读:宿主不可用登录覆盖)。 */
|
|
108
|
+
function hasContractCredentials(credentials) {
|
|
109
|
+
return (text(credentials?.configUrl) !== undefined ||
|
|
110
|
+
text(credentials?.accessToken) !== undefined);
|
|
111
|
+
}
|
|
112
|
+
function codeOf(error) {
|
|
113
|
+
return error instanceof ConfigurationError
|
|
114
|
+
? error.code
|
|
115
|
+
: 'CONFIG_APPLY_FAILED';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 附件清单:字段类型刻意放宽成可选——codegen 出来的节点声明它是非空数组,读取侧写不出判空,
|
|
119
|
+
* 而 hub 未下发这个字段时必须退化成空清单,不能因为附件缺席就抹掉整条技能。
|
|
120
|
+
*/
|
|
121
|
+
function fileFactsOf(node) {
|
|
122
|
+
const files = node.files;
|
|
123
|
+
if (files === null || files === undefined)
|
|
124
|
+
return [];
|
|
125
|
+
return files.map(file => ({ ...file }));
|
|
126
|
+
}
|
|
127
|
+
export class Semi2ConfigRuntime {
|
|
128
|
+
ctx;
|
|
129
|
+
current;
|
|
130
|
+
settled;
|
|
131
|
+
/** 构造时冻结的进程 env:用于项目反解兜底(CNB_REPO_SLUG)与回环 http 判定(NODE_ENV)。 */
|
|
132
|
+
environment;
|
|
133
|
+
/** 当前连接凭据(契约或 dsh credentials 存储);未配置为 null。 */
|
|
134
|
+
connection = null;
|
|
135
|
+
/** 运行面契约:适配器 provide 优先,缺席 = 宿主直跑;未解析为 undefined。 */
|
|
136
|
+
contract;
|
|
137
|
+
contractResolved = false;
|
|
138
|
+
contractTimeoutMs;
|
|
139
|
+
settings;
|
|
140
|
+
/** 最近一次成功编译的模型目录(headless 角色选择按它解析)。 */
|
|
141
|
+
compiled = null;
|
|
142
|
+
defaultModelId = null;
|
|
143
|
+
reloadToken = '';
|
|
144
|
+
/** 工作区登记表(dsh 工作区 → git 项目映射)。 */
|
|
145
|
+
book = new WorkspaceBook();
|
|
146
|
+
/** hub 明确没有的 slug(不在可读范围内):负缓存,免得每次会话都去问一遍。 */
|
|
147
|
+
missingProjects = new Set();
|
|
148
|
+
/** 工作区同步串行队列:feed 帧与「同步」可能并发,串起来才不会互相覆盖。 */
|
|
149
|
+
workspaceTail = Promise.resolve();
|
|
150
|
+
resolveSettled;
|
|
151
|
+
constructor(ctx, environment, options = {}) {
|
|
152
|
+
this.ctx = ctx;
|
|
153
|
+
this.current = initialState(false, null);
|
|
154
|
+
this.environment = { ...environment };
|
|
155
|
+
this.contractTimeoutMs = options.contractTimeoutMs ?? CONTRACT_TIMEOUT_MS;
|
|
156
|
+
const settled = Promise.withResolvers();
|
|
157
|
+
this.settled = settled.promise;
|
|
158
|
+
this.resolveSettled = settled.resolve;
|
|
159
|
+
}
|
|
160
|
+
get state() {
|
|
161
|
+
return this.current;
|
|
162
|
+
}
|
|
163
|
+
get ready() {
|
|
164
|
+
return this.settled;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 解析连接凭据;非 CNB 面先把启动环境的连接令牌摘掉再解析。
|
|
168
|
+
* 那枚令牌只属 CNB 面(`runtimeFromCnbEnv` 从 secret imports 注入的 env 取),宿主 / 容器面认了它
|
|
169
|
+
* 就再没有登录入口:dsh 的启动环境层只读(set/unset 都被拒)又排在临时存储之前,登录写进去的令牌
|
|
170
|
+
* 也读不回来。地址不是密钥、还是登录表单的预填来源,照旧留给 `SEMI2_CONFIG_URL`。
|
|
171
|
+
*/
|
|
172
|
+
async connectionFields() {
|
|
173
|
+
await this.ensureContract();
|
|
174
|
+
if (this.contract?.platform !== 'cnb') {
|
|
175
|
+
delete process.env.SEMI2_ACCESS_TOKEN;
|
|
176
|
+
delete this.environment.SEMI2_ACCESS_TOKEN;
|
|
177
|
+
}
|
|
178
|
+
return resolveConnectionFields(this.ctx, this.contract, this.environment);
|
|
179
|
+
}
|
|
180
|
+
/** 契约解析完成后才给出答案;缺席即宿主直跑。 */
|
|
181
|
+
get platform() {
|
|
182
|
+
if (!this.contractResolved)
|
|
183
|
+
return null;
|
|
184
|
+
return this.contract?.platform ?? 'host';
|
|
185
|
+
}
|
|
186
|
+
async reload() {
|
|
187
|
+
// 只在无凭据时重解析:令牌已从进程 env 删除,this.connection 才是权威副本(登录/登出显式重解析)。
|
|
188
|
+
const fields = await this.connectionFields();
|
|
189
|
+
if (this.connection === null)
|
|
190
|
+
this.connection = await this.exchangeCnbCredential(connectionOf(fields));
|
|
191
|
+
if (this.connection === null) {
|
|
192
|
+
this.publish(initialState(false, fields.url?.value ?? null, this.trackedWorkspaces()));
|
|
193
|
+
this.ctx.emit('semi2/config-updated', this.current);
|
|
194
|
+
await this.publishStatus();
|
|
195
|
+
return this.current;
|
|
196
|
+
}
|
|
197
|
+
return this.load();
|
|
198
|
+
}
|
|
199
|
+
async projectFor(session) {
|
|
200
|
+
return this.projectForCwd(session.header?.cwd);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 两层解析:已登记工作区按其解析结果取项目;未登记目录回落 git slug,并**按需**问一次
|
|
204
|
+
* 那一个项目(不拉全量项目表)。未就绪时项目表是空的,答了也是错的。
|
|
205
|
+
*/
|
|
206
|
+
async projectForCwd(cwd) {
|
|
207
|
+
if (this.current.status !== 'ready')
|
|
208
|
+
return null;
|
|
209
|
+
const directory = cwd?.trim();
|
|
210
|
+
if (directory !== undefined && directory !== '') {
|
|
211
|
+
const entry = this.book.get(await canonicalPath(directory));
|
|
212
|
+
if (entry !== undefined && entry.projectId !== null) {
|
|
213
|
+
const matched = this.current.projects.find(project => project.id === entry.projectId);
|
|
214
|
+
if (matched !== undefined)
|
|
215
|
+
return matched;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const slug = await workspaceSlug(directory, this.environment);
|
|
219
|
+
if (slug === null)
|
|
220
|
+
return null;
|
|
221
|
+
const known = matchProject(this.current.projects, slug);
|
|
222
|
+
if (known !== null)
|
|
223
|
+
return known;
|
|
224
|
+
await this.fetchProjects([slug]);
|
|
225
|
+
return matchProject(this.current.projects, slug);
|
|
226
|
+
}
|
|
227
|
+
/** 已登记工作区命中的项目(按 id 去重):状态展示与技能作用域按它取,没工作区的项目不进这个集合。 */
|
|
228
|
+
workspaceProjects() {
|
|
229
|
+
const known = new Map(this.current.projects.map(project => [project.id, project]));
|
|
230
|
+
const projects = [];
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
for (const entry of this.book.entries()) {
|
|
233
|
+
if (entry.projectId === null || seen.has(entry.projectId))
|
|
234
|
+
continue;
|
|
235
|
+
const project = known.get(entry.projectId);
|
|
236
|
+
if (project === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
seen.add(entry.projectId);
|
|
239
|
+
projects.push(project);
|
|
240
|
+
}
|
|
241
|
+
return projects;
|
|
242
|
+
}
|
|
243
|
+
/** 状态展示与持久化的工作区集合:未成 git 项目的目录直接忽略(簿记里仍留着等重解析)。 */
|
|
244
|
+
trackedWorkspaces() {
|
|
245
|
+
return trackedWorkspaces(this.book.entries());
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 技能附件字节:boot 文档只有清单,字节在真正加载该技能时按需取。
|
|
249
|
+
* 三次降级口径与其余领域一致——可空根字段为 null(领域失败)、传输异常、没连 hub 都返回 null,
|
|
250
|
+
* 由调用方放弃物化;只有「字段在场且为数组」才算权威答案(空数组 = 没有附件)。
|
|
251
|
+
*/
|
|
252
|
+
async skillFiles(skillId) {
|
|
253
|
+
try {
|
|
254
|
+
const outcome = await this.requestDomain('RuntimeSkillFiles', RUNTIME_SKILL_FILES_DOCUMENT, { skillId });
|
|
255
|
+
if (outcome.errorCode !== null) {
|
|
256
|
+
this.warn(`技能附件读取失败(runtimeSkillFiles,${outcome.errorCode})`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const files = outcome.data?.runtimeSkillFiles;
|
|
260
|
+
if (files === null || files === undefined) {
|
|
261
|
+
this.warn('技能附件读取失败(runtimeSkillFiles 降级),本次不物化资源');
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return files.map(file => ({ ...file }));
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
this.warn(`技能附件读取异常(${codeOf(error)}),本次不物化资源`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async selectHeadlessModel(input) {
|
|
272
|
+
const compiled = this.compiled;
|
|
273
|
+
if (compiled === null || this.current.status !== 'ready')
|
|
274
|
+
return;
|
|
275
|
+
const project = await this.projectFor(input.cwd === undefined ? {} : { header: { cwd: input.cwd } });
|
|
276
|
+
const roleId = input.workmode
|
|
277
|
+
? project?.executeModelId
|
|
278
|
+
: project?.planModelId;
|
|
279
|
+
const modelId = roleId ?? this.defaultModelId;
|
|
280
|
+
const selection = modelId === null ? undefined : compiled.selections.get(modelId);
|
|
281
|
+
if (selection === undefined)
|
|
282
|
+
return;
|
|
283
|
+
const defaultModel = this.ctx.get('agentDefaultModel');
|
|
284
|
+
if (defaultModel === undefined)
|
|
285
|
+
return;
|
|
286
|
+
await defaultModel.saveSelection(selection);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* 首次加载;未配凭据或失败只落到 state.error,绝不 throw,也不注入任何配置。
|
|
290
|
+
* 未登录(只有地址、没有令牌)时仍回报地址:登录表单靠它预填。
|
|
291
|
+
*/
|
|
292
|
+
async start() {
|
|
293
|
+
try {
|
|
294
|
+
const settings = await this.settingsService();
|
|
295
|
+
this.settings = settings;
|
|
296
|
+
if (settings !== undefined) {
|
|
297
|
+
this.installStatusSection(settings);
|
|
298
|
+
// 上次的映射先恢复:headless 面无 dsh 工作区服务,这份本地事实就是全部列表。
|
|
299
|
+
this.book.restore(readWorkspaces(settings));
|
|
300
|
+
}
|
|
301
|
+
this.installWorkspaceSubscription();
|
|
302
|
+
await this.ensureContract();
|
|
303
|
+
this.ensureImplicitWorkspace();
|
|
304
|
+
const { connection, consoleUrl } = await this.resolveConnectionWithRetry();
|
|
305
|
+
this.connection = await this.exchangeCnbCredential(connection);
|
|
306
|
+
if (this.connection === null)
|
|
307
|
+
this.publish(initialState(false, consoleUrl, this.trackedWorkspaces()));
|
|
308
|
+
else
|
|
309
|
+
await this.load(settings);
|
|
310
|
+
await this.publishStatus();
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
this.resolveSettled();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async service(key) {
|
|
317
|
+
return new Promise((resolve) => {
|
|
318
|
+
this.ctx.inject([key], (injected) => {
|
|
319
|
+
resolve(injected.get(key));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/** settings 服务缺失(非 web/headless 宿主)时不阻塞启动,5s 后放弃。 */
|
|
324
|
+
async settingsService() {
|
|
325
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
326
|
+
const timer = globalThis.setTimeout(() => { resolve(undefined); }, 5_000);
|
|
327
|
+
this.ctx.inject(['settings'], (injected) => {
|
|
328
|
+
globalThis.clearTimeout(timer);
|
|
329
|
+
resolve(injected.get('settings'));
|
|
330
|
+
});
|
|
331
|
+
return promise;
|
|
332
|
+
}
|
|
333
|
+
/** 运行面契约只解析一次(适配器在 apply 里同步 provide)。 */
|
|
334
|
+
async ensureContract() {
|
|
335
|
+
if (this.contractResolved)
|
|
336
|
+
return;
|
|
337
|
+
this.contract = await this.resolveContract();
|
|
338
|
+
this.contractResolved = true;
|
|
339
|
+
}
|
|
340
|
+
async resolveContract() {
|
|
341
|
+
const deadline = Date.now() + this.contractTimeoutMs;
|
|
342
|
+
for (;;) {
|
|
343
|
+
// 适配器 apply 是长跑 async(等配置 settle),其 fiber 尚未 active:严格查找一直 undefined,
|
|
344
|
+
// 故不用 ctx.inject 而直接 ctx.get(strict=false) 轮询(服务注册本身是同步的)。
|
|
345
|
+
const contract = this.ctx.get(SEMI2_RUNTIME_SERVICE, false);
|
|
346
|
+
if (contract !== undefined)
|
|
347
|
+
return contract;
|
|
348
|
+
if (Date.now() >= deadline)
|
|
349
|
+
return undefined;
|
|
350
|
+
await delay(CONTRACT_POLL_MS);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** credentials 存储首次读盘可能晚于插件启动:有界窗口内重试,仍无凭据即按「未配置」处理。 */
|
|
354
|
+
async resolveConnectionWithRetry() {
|
|
355
|
+
let consoleUrl = null;
|
|
356
|
+
for (let attempt = 0; attempt < CREDENTIAL_TRIES; attempt += 1) {
|
|
357
|
+
const fields = await this.connectionFields();
|
|
358
|
+
consoleUrl = fields.url?.value ?? consoleUrl;
|
|
359
|
+
const connection = connectionOf(fields);
|
|
360
|
+
if (connection !== null)
|
|
361
|
+
return { connection, consoleUrl };
|
|
362
|
+
if (attempt < CREDENTIAL_TRIES - 1)
|
|
363
|
+
await delay(CREDENTIAL_INTERVAL_MS);
|
|
364
|
+
}
|
|
365
|
+
return { connection: null, consoleUrl };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* CNB 面:把平台注入的短期 CNB_TOKEN 换成 hub 用户密钥(一次兑换,之后一律用换来的密钥)。
|
|
369
|
+
* 兑换失败按现有降级口径:本次不注入配置,不阻断会话。其它面 / 登录态原样返回。
|
|
370
|
+
*/
|
|
371
|
+
async exchangeCnbCredential(connection) {
|
|
372
|
+
if (connection === null ||
|
|
373
|
+
this.contract?.platform !== 'cnb' ||
|
|
374
|
+
connection.source !== 'runtime')
|
|
375
|
+
return connection;
|
|
376
|
+
try {
|
|
377
|
+
const exchanged = await exchangeCnbToken({
|
|
378
|
+
url: connection.url,
|
|
379
|
+
token: connection.token,
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
url: connection.url,
|
|
383
|
+
token: exchanged.token,
|
|
384
|
+
source: 'cnb-exchange',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
this.warn(`CNB 令牌兑换失败(${codeOf(error)}),本次不注入配置`);
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
installStatusSection(settings) {
|
|
393
|
+
// 用户写入只触发 onChange(setSource 是组合层),故在 onChange 里读有效值比较。
|
|
394
|
+
this.reloadToken = this.readReloadToken();
|
|
395
|
+
settings.installSection(this.ctx, SETTINGS_NAMESPACE, z.object({
|
|
396
|
+
reload: z.string().default(''),
|
|
397
|
+
...STATUS_SCHEMA,
|
|
398
|
+
}), Object.fromEntries(['reload', ...STATUS_FIELDS].map(key => [key, ''])), {
|
|
399
|
+
setSource: () => { },
|
|
400
|
+
onChange: () => {
|
|
401
|
+
const token = this.readReloadToken();
|
|
402
|
+
if (token === this.reloadToken)
|
|
403
|
+
return;
|
|
404
|
+
this.reloadToken = token;
|
|
405
|
+
void this.reload();
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
// 归属表由插件自己写、自己读,不暴露给用户;没有它就无法区分
|
|
409
|
+
// 「上次注入的条目」与「用户自己写的同名条目」。
|
|
410
|
+
settings.installSection(this.ctx, OWNED_NAMESPACE, OWNED_SCHEMA, {
|
|
411
|
+
routes: [],
|
|
412
|
+
credentials: [],
|
|
413
|
+
deepseek: false,
|
|
414
|
+
}, { setSource: () => { }, onChange: () => { } });
|
|
415
|
+
// 工作区映射同理:纯本地事实,用户不编辑(工作区增删在 dsh 自己的工作区面板)。
|
|
416
|
+
settings.installSection(this.ctx, WORKSPACES_NAMESPACE, WORKSPACES_SCHEMA, {
|
|
417
|
+
entries: [],
|
|
418
|
+
}, { setSource: () => { }, onChange: () => { } });
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 工作区登记 = dsh 工作区列表变化即同步:新增/改名解析该项目并落库,移除即撤下。
|
|
422
|
+
* 服务缺席(headless 面)时回调不执行,由隐式条目兜底;配置未就绪时只记列表、不解析。
|
|
423
|
+
*/
|
|
424
|
+
installWorkspaceSubscription() {
|
|
425
|
+
installWorkspaceFeed(this.ctx, {
|
|
426
|
+
baseline: items => this.enqueueWorkspace(async () => {
|
|
427
|
+
this.book.applyBaseline(items);
|
|
428
|
+
if (this.current.status !== 'ready') {
|
|
429
|
+
this.publishWorkspaces(false);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
await this.syncWorkspaces(true);
|
|
433
|
+
}),
|
|
434
|
+
upsert: view => this.enqueueWorkspace(async () => {
|
|
435
|
+
this.book.upsertWorkspace(view);
|
|
436
|
+
if (this.current.status !== 'ready') {
|
|
437
|
+
this.publishWorkspaces(false);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
await this.syncWorkspace(view.path, true);
|
|
441
|
+
}),
|
|
442
|
+
remove: workspaceId => this.enqueueWorkspace(async () => {
|
|
443
|
+
this.book.removeWorkspace(workspaceId);
|
|
444
|
+
await this.persistWorkspaces();
|
|
445
|
+
this.publishWorkspaces(true);
|
|
446
|
+
}),
|
|
447
|
+
}, (message) => {
|
|
448
|
+
this.warn(message);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* headless 面(NPC / 无 web 服务的宿主)没有 dsh 工作区服务:启动目录就是唯一工作区。
|
|
453
|
+
* 有服务时不播——基线帧稍后给出权威列表,多一条隐式条目反而会被状态与技能作用域当成真工作区。
|
|
454
|
+
*/
|
|
455
|
+
ensureImplicitWorkspace() {
|
|
456
|
+
if (this.ctx.get('workspaceController', false) !== undefined)
|
|
457
|
+
return;
|
|
458
|
+
this.book.ensureImplicit(process.cwd());
|
|
459
|
+
}
|
|
460
|
+
/** 工作区同步串行:feed 帧与「同步」交错时按到达顺序逐条落定。 */
|
|
461
|
+
enqueueWorkspace(work) {
|
|
462
|
+
const next = this.workspaceTail.then(work, work);
|
|
463
|
+
this.workspaceTail = next.then(() => undefined, () => undefined);
|
|
464
|
+
return next;
|
|
465
|
+
}
|
|
466
|
+
/** 全量工作区解析(启动、「同步」、基线帧共用):一个都不在列表里时什么都不做。 */
|
|
467
|
+
async syncWorkspaces(notify) {
|
|
468
|
+
// 用**簿记全量**解析:忽略过滤是展示/落库口径,拿它当入口会把待解析条目也滤掉。
|
|
469
|
+
const entries = this.book.entries();
|
|
470
|
+
if (entries.length === 0)
|
|
471
|
+
return;
|
|
472
|
+
await this.applyResolved(entries);
|
|
473
|
+
await this.persistWorkspaces();
|
|
474
|
+
this.publishWorkspaces(notify);
|
|
475
|
+
}
|
|
476
|
+
/** 单个工作区解析(upsert 帧):映射失败也要写回 error,UI 才看得出「为什么没同步」。 */
|
|
477
|
+
async syncWorkspace(path, notify) {
|
|
478
|
+
const entry = this.book.get(path) ?? pendingEntry(path, path);
|
|
479
|
+
await this.applyResolved([entry]);
|
|
480
|
+
await this.persistWorkspaces();
|
|
481
|
+
this.publishWorkspaces(notify);
|
|
482
|
+
}
|
|
483
|
+
async applyResolved(entries) {
|
|
484
|
+
const slugs = await Promise.all(entries.map(entry => this.lookupSlug(entry.path)));
|
|
485
|
+
// 只为「本地还没有的 slug」问一次 hub:请求里不会出现没用到的工作区项目。
|
|
486
|
+
const wanted = [];
|
|
487
|
+
for (const slug of slugs) {
|
|
488
|
+
if (slug === null || matchProject(this.current.projects, slug) !== null)
|
|
489
|
+
continue;
|
|
490
|
+
wanted.push(slug);
|
|
491
|
+
}
|
|
492
|
+
await this.fetchProjects(wanted);
|
|
493
|
+
entries.forEach((entry, index) => {
|
|
494
|
+
this.book.set(this.resolveEntry(entry, slugs[index] ?? null));
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/** 反解失败与非 git 目录一样不可映射:只 warn(诊断留日志),条目按未命中处理。 */
|
|
498
|
+
async lookupSlug(path) {
|
|
499
|
+
try {
|
|
500
|
+
return await workspaceSlug(path, this.environment);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.warn(`工作区 ${path} 反解失败(${codeOf(error)})`);
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 反解不出 slug(非 git / 无 origin)与 hub 里查无此项目都不算失败:该目录不归
|
|
509
|
+
* semi2-hub 管——只落 slug 供诊断,项目三件套留空,由 `trackedWorkspaces` 挡在状态外。
|
|
510
|
+
*/
|
|
511
|
+
resolveEntry(entry, slug) {
|
|
512
|
+
const base = {
|
|
513
|
+
...entry,
|
|
514
|
+
slug,
|
|
515
|
+
projectId: null,
|
|
516
|
+
name: null,
|
|
517
|
+
syncedAt: new Date().toISOString(),
|
|
518
|
+
};
|
|
519
|
+
if (slug === null)
|
|
520
|
+
return base;
|
|
521
|
+
const project = matchProject(this.current.projects, slug);
|
|
522
|
+
if (project === null)
|
|
523
|
+
return base;
|
|
524
|
+
return {
|
|
525
|
+
...base,
|
|
526
|
+
projectId: project.id,
|
|
527
|
+
name: project.name,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* 按 slug 取项目绑定并并入缓存(幂等):只问点名的 slug,hub 那边再叠一层读范围过滤。
|
|
532
|
+
* 结果空 = 该 slug 确实不在可读范围内,记进负缓存免得每次会话都重问。
|
|
533
|
+
*/
|
|
534
|
+
async fetchProjects(slugs) {
|
|
535
|
+
const wanted = [
|
|
536
|
+
...new Set(slugs.map(slug => slug.trim().toLowerCase())),
|
|
537
|
+
].filter(slug => slug !== '' && !this.missingProjects.has(slug));
|
|
538
|
+
if (wanted.length === 0 || this.connection === null)
|
|
539
|
+
return;
|
|
540
|
+
try {
|
|
541
|
+
const outcome = await this.requestDomain('RuntimeWorkspaceProjects', RUNTIME_WORKSPACE_PROJECTS_DOCUMENT, { slugs: wanted });
|
|
542
|
+
const projects = outcome.data?.runtimeProjects;
|
|
543
|
+
if (projects === null || projects === undefined) {
|
|
544
|
+
this.warn('工作区项目读取失败(runtimeProjects),本次按未命中处理');
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const merged = new Map(this.current.projects.map(project => [project.id, project]));
|
|
548
|
+
const hit = new Set();
|
|
549
|
+
for (const project of projects) {
|
|
550
|
+
merged.set(project.id, project);
|
|
551
|
+
hit.add(project.slug.toLowerCase());
|
|
552
|
+
}
|
|
553
|
+
for (const slug of wanted)
|
|
554
|
+
if (!hit.has(slug))
|
|
555
|
+
this.missingProjects.add(slug);
|
|
556
|
+
this.publish({ ...this.current, projects: [...merged.values()] });
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
this.warn(`工作区项目读取失败(${codeOf(error)})`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async persistWorkspaces() {
|
|
563
|
+
const settings = this.settings;
|
|
564
|
+
if (settings === undefined)
|
|
565
|
+
return;
|
|
566
|
+
try {
|
|
567
|
+
await this.replaceNamespace(settings, WORKSPACES_NAMESPACE, {
|
|
568
|
+
entries: persistedWorkspaces(this.trackedWorkspaces()),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
this.warn(`工作区登记写入失败(${codeOf(error)})`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
publishWorkspaces(notify) {
|
|
576
|
+
this.publish({ ...this.current, workspaces: this.trackedWorkspaces() });
|
|
577
|
+
if (notify)
|
|
578
|
+
this.ctx.emit('semi2/workspaces-updated', this.current);
|
|
579
|
+
}
|
|
580
|
+
readSetting(key) {
|
|
581
|
+
const entry = this.settings
|
|
582
|
+
?.describe()
|
|
583
|
+
.find(candidate => candidate.ns === SETTINGS_NAMESPACE);
|
|
584
|
+
const value = entry?.value?.[key];
|
|
585
|
+
return typeof value === 'string' ? value : '';
|
|
586
|
+
}
|
|
587
|
+
readReloadToken() {
|
|
588
|
+
return this.readSetting('reload');
|
|
589
|
+
}
|
|
590
|
+
async publishStatus() {
|
|
591
|
+
if (this.settings === undefined)
|
|
592
|
+
return;
|
|
593
|
+
// 权威写入(replace):合并 update 无法把字段写回空值;
|
|
594
|
+
// reload 令牌由 UI 持有,必须原样保留。
|
|
595
|
+
const section = {
|
|
596
|
+
...statusFields(this.current, {
|
|
597
|
+
credentialSource: this.connection?.source ?? null,
|
|
598
|
+
platform: this.platform,
|
|
599
|
+
}),
|
|
600
|
+
reload: this.readReloadToken(),
|
|
601
|
+
};
|
|
602
|
+
try {
|
|
603
|
+
if (this.settings.replace !== undefined)
|
|
604
|
+
await this.settings.replace(SETTINGS_NAMESPACE, section);
|
|
605
|
+
else
|
|
606
|
+
await this.settings.update(SETTINGS_NAMESPACE, section);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
this.warn('设置页状态写入失败');
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async updateNamespace(settings, ns, patch) {
|
|
613
|
+
await this.writeNamespace(settings, ns, () => settings.update(ns, patch));
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* 权威写入:把用户段整体替换成给定内容。
|
|
617
|
+
* `update` 是**递归合并**(dsh-settings 的 mergeLayers),只增不减——服务端撤掉的条目
|
|
618
|
+
* 会在本地留成残渣,provider 路由尤其致命:目录里已不存在的模型继续可选。
|
|
619
|
+
* 所以凡是本插件全权决定的段(`llm-pi-ai` 只此一个键)都走替换。
|
|
620
|
+
*/
|
|
621
|
+
async replaceNamespace(settings, ns, section) {
|
|
622
|
+
const replace = settings.replace?.bind(settings);
|
|
623
|
+
if (replace === undefined) {
|
|
624
|
+
this.warn(`宿主 settings 无 replace,${ns} 退回合并写入:服务端已撤下的条目可能残留`);
|
|
625
|
+
await this.updateNamespace(settings, ns, section);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
await this.writeNamespace(settings, ns, () => replace(ns, section));
|
|
629
|
+
}
|
|
630
|
+
/** 按路径删除本插件写过的键(合并写入删不掉)。 */
|
|
631
|
+
async clearKeys(settings, ns, keys) {
|
|
632
|
+
const mutate = settings.mutate?.bind(settings);
|
|
633
|
+
if (mutate === undefined) {
|
|
634
|
+
this.warn(`宿主 settings 无 mutate,${ns} 无法按路径删除:服务端已撤销的覆盖可能残留`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const ops = keys.map(key => ({ op: 'unset', path: [key] }));
|
|
638
|
+
await this.writeNamespace(settings, ns, () => mutate(ns, ops));
|
|
639
|
+
}
|
|
640
|
+
/** 等命名空间注册完成再写(settings 服务与各 bundle 的注册有先后)。 */
|
|
641
|
+
async writeNamespace(settings, ns, write) {
|
|
642
|
+
for (let attempt = 0; attempt < NAMESPACE_TRIES; attempt += 1) {
|
|
643
|
+
if (settings.describe().some(entry => entry.ns === ns)) {
|
|
644
|
+
await write();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
await delay(NAMESPACE_INTERVAL_MS);
|
|
648
|
+
}
|
|
649
|
+
throw new ConfigurationError('CONFIG_APPLY_FAILED');
|
|
650
|
+
}
|
|
651
|
+
/** 读取一次运行配置;整体失败只落 state.error,绝不 throw(start/reload 均可重试)。 */
|
|
652
|
+
async load(settings) {
|
|
653
|
+
this.publish({ ...this.current, status: 'loading', error: null });
|
|
654
|
+
// 重新装配 = 重新问项目:服务端可能刚把这几个项目放回可读范围。
|
|
655
|
+
this.missingProjects.clear();
|
|
656
|
+
try {
|
|
657
|
+
const outcome = await this.fetchBoot();
|
|
658
|
+
const boot = outcome.data;
|
|
659
|
+
if (boot === null) {
|
|
660
|
+
const code = outcome.errorCode ?? 'CONFIG_REQUEST_FAILED';
|
|
661
|
+
this.warn(`运行配置读取失败(${code}),不做任何注入,dsh 按自身配置运行`);
|
|
662
|
+
// 连不上 semi2-hub 时一切照旧,失败码留 state.error 供诊断、可重载重试。
|
|
663
|
+
this.publish({
|
|
664
|
+
...this.current,
|
|
665
|
+
status: 'failed',
|
|
666
|
+
error: code,
|
|
667
|
+
workspaces: this.trackedWorkspaces(),
|
|
668
|
+
});
|
|
669
|
+
this.ctx.emit('semi2/config-updated', this.current);
|
|
670
|
+
// 失败也必须回写设置页:登录成功但同步失败时,错误码是唯一提示(客户端按 error 渲染「最近失败」)。
|
|
671
|
+
await this.publishStatus();
|
|
672
|
+
return this.current;
|
|
673
|
+
}
|
|
674
|
+
const target = settings ?? (await this.service('settings'));
|
|
675
|
+
const failures = outcome.errorCode === null ? [] : [outcome.errorCode];
|
|
676
|
+
const owned = this.readOwned(target);
|
|
677
|
+
let compiled = null;
|
|
678
|
+
let next = owned;
|
|
679
|
+
let staleCredentials = [];
|
|
680
|
+
try {
|
|
681
|
+
const applied = await this.applyModels(boot, target, owned);
|
|
682
|
+
if (applied !== null) {
|
|
683
|
+
compiled = applied.compiled;
|
|
684
|
+
staleCredentials = applied.staleCredentials;
|
|
685
|
+
next = { ...next, routes: applied.routes, deepseek: applied.deepseek };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
failures.push(this.noteFailure('模型目录', error));
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const refs = await this.applyModelCredentials(boot, compiled !== null, owned, staleCredentials);
|
|
693
|
+
if (refs !== null)
|
|
694
|
+
next = { ...next, credentials: refs };
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
failures.push(this.noteFailure('模型密钥', error));
|
|
698
|
+
}
|
|
699
|
+
// 归属表 = 本轮实际写入的事实;某领域降级时保持上次的值(不动即不回收)。
|
|
700
|
+
try {
|
|
701
|
+
await this.updateNamespace(target, OWNED_NAMESPACE, { ...next });
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
failures.push(this.noteFailure('注入归属表', error));
|
|
705
|
+
}
|
|
706
|
+
// 令牌只用于读取,不留给会话子进程;权威副本是 this.connection(reload 复用)。
|
|
707
|
+
delete process.env.SEMI2_ACCESS_TOKEN;
|
|
708
|
+
this.compiled = compiled;
|
|
709
|
+
this.defaultModelId = boot.runtimeDefaultModelId ?? null;
|
|
710
|
+
this.publish(this.applied(boot, compiled, failures[0] ?? null));
|
|
711
|
+
// 工作区映射依赖项目表:全局事实先入状态再解析;不发 workspaces 事件——
|
|
712
|
+
// 紧接着的 config-updated 已会让技能目录缓存失效,两次下发只会白丢两遍缓存。
|
|
713
|
+
await this.syncWorkspaces(false);
|
|
714
|
+
this.ctx.emit('semi2/config-updated', this.current);
|
|
715
|
+
await this.publishStatus();
|
|
716
|
+
return this.current;
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
const code = codeOf(error);
|
|
720
|
+
this.warn(`读取或应用失败(${code}),不做任何注入,dsh 按自身配置运行`);
|
|
721
|
+
this.publish({
|
|
722
|
+
...this.current,
|
|
723
|
+
status: 'failed',
|
|
724
|
+
error: code,
|
|
725
|
+
workspaces: this.trackedWorkspaces(),
|
|
726
|
+
});
|
|
727
|
+
this.ctx.emit('semi2/config-updated', this.current);
|
|
728
|
+
// 同上:失败态不过设置页等于静默,用户只会看到「已登录」然后一片空白。
|
|
729
|
+
await this.publishStatus();
|
|
730
|
+
return this.current;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/** 启动装配:五个用户级领域问题一个文档(项目绑定按工作区另行按需取)。 */
|
|
734
|
+
fetchBoot() {
|
|
735
|
+
return this.requestDomain('RuntimeBoot', RUNTIME_BOOT_DOCUMENT);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* 运行面读取:身份就是连接凭据换来的用户密钥本人;读取者事实由 `runtimeViewer`
|
|
739
|
+
* 领域下发(密码登录 / hub 接入密钥 / CNB 令牌兑换都返回服务器里同一账户),请求不带 subject。
|
|
740
|
+
* operation 名同时选中结果类型,调用侧不必再断言;`variables` 是本操作自己的入参。
|
|
741
|
+
*/
|
|
742
|
+
async requestDomain(operationName, document, variables = {}) {
|
|
743
|
+
const connection = this.connection;
|
|
744
|
+
if (connection === null)
|
|
745
|
+
throw new ConfigurationError('MISSING_ACCESS_TOKEN');
|
|
746
|
+
// CNB 面带上环境创建者:hub 用它决定读取身份(密钥只证明来自 CNB 环境)。
|
|
747
|
+
const creator = text(this.environment.CNB_BUILD_USER_ID);
|
|
748
|
+
const client = {
|
|
749
|
+
endpoint: consoleEndpoint(connection.url, this.environment.NODE_ENV),
|
|
750
|
+
token: connection.token,
|
|
751
|
+
...(creator === undefined ? {} : { creator }),
|
|
752
|
+
};
|
|
753
|
+
const outcome = await postGraphql(client, {
|
|
754
|
+
operationName,
|
|
755
|
+
document,
|
|
756
|
+
...(Object.keys(variables).length === 0 ? {} : { variables }),
|
|
757
|
+
});
|
|
758
|
+
return {
|
|
759
|
+
data: outcome.data,
|
|
760
|
+
errorCode: outcome.errorCode,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
/** `runtimeModels` 降级时返回 null:不动任何模型相关设置,其余领域照常。 */
|
|
764
|
+
async applyModels(boot, settings, owned) {
|
|
765
|
+
const models = boot.runtimeModels;
|
|
766
|
+
if (models === null || models === undefined) {
|
|
767
|
+
this.warn('模型目录读取失败(runtimeModels),跳过 provider 与默认模型注入');
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const compiled = compileBoot(models, boot.runtimeDefaultModelId ?? null);
|
|
771
|
+
// 路由让位(同名用户 profile)会改路由键:`compiled` 里的选择必须跟着改写,
|
|
772
|
+
// 否则默认模型会指向不属于我们的 profile。
|
|
773
|
+
const applied = await this.applyProviders(settings, compiled.providers, owned);
|
|
774
|
+
const routes = applied.routes;
|
|
775
|
+
const remapped = remapSelections(compiled, routes);
|
|
776
|
+
// DEEPSEEK 协议覆盖内置原生适配器:key/endpoint/目录都来自 semi2-hub。
|
|
777
|
+
// 服务端不给 DEEPSEEK 连接时反向撤销自己写过的覆盖(用户手写的段不动),
|
|
778
|
+
// 否则会一直留着服务端已下线的 deepseek 模型清单。
|
|
779
|
+
if (remapped.deepseek !== null)
|
|
780
|
+
await this.updateNamespace(settings, 'llm-deepseek', {
|
|
781
|
+
apiKeyEnv: remapped.deepseek.apiKeyEnv,
|
|
782
|
+
baseURL: remapped.deepseek.baseURL,
|
|
783
|
+
models: remapped.deepseek.models,
|
|
784
|
+
});
|
|
785
|
+
else if (owned.deepseek)
|
|
786
|
+
await this.clearKeys(settings, 'llm-deepseek', DEEPSEEK_OWNED_KEYS);
|
|
787
|
+
const final = [...routes.values()];
|
|
788
|
+
await this.applyDefaultModel(settings, remapped, final, owned);
|
|
789
|
+
return {
|
|
790
|
+
compiled: remapped,
|
|
791
|
+
routes: final,
|
|
792
|
+
deepseek: remapped.deepseek !== null,
|
|
793
|
+
staleCredentials: applied.staleCredentials,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* 默认模型:semi2-hub 是唯一来源,总是写(会话内的临时选择记在 session log,不受影响)。
|
|
798
|
+
*
|
|
799
|
+
* 服务端不给可用默认模型、且现行默认正指向**本轮被回收**的路由时必须把它让回组合层:
|
|
800
|
+
* 路由已从 `llm-pi-ai` 撤下,该选择就成了悬空引用,下一轮请求会以 `NO_ADAPTER` 失败
|
|
801
|
+
* (dsh-llm 的 registration 查不到适配器直接抛)。
|
|
802
|
+
*/
|
|
803
|
+
async applyDefaultModel(settings, compiled, finalRoutes, owned) {
|
|
804
|
+
const defaultModel = this.ctx.get('agentDefaultModel', false);
|
|
805
|
+
if (compiled.defaultModel !== null) {
|
|
806
|
+
if (defaultModel !== undefined)
|
|
807
|
+
await defaultModel.saveSelection(compiled.defaultModel);
|
|
808
|
+
else
|
|
809
|
+
await this.updateNamespace(settings, 'agent-default-model', compiled.defaultModel);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const current = this.currentSelection(settings);
|
|
813
|
+
const dropped = current !== null &&
|
|
814
|
+
owned.routes.includes(current.provider) &&
|
|
815
|
+
!finalRoutes.includes(current.provider);
|
|
816
|
+
if (!dropped)
|
|
817
|
+
return;
|
|
818
|
+
// replace({}) 重新继承组合层与 schema 默认:这里不能只删字段,
|
|
819
|
+
// 该段 schema 要求 provider/model 同时在场。
|
|
820
|
+
await this.replaceNamespace(settings, 'agent-default-model', {});
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* 现行默认模型选择:dsh 服务优先(它读到的是含用户层的有效值),
|
|
824
|
+
* 服务缺席或没有读口时回落 `agent-default-model` 段——插件自己写进去的那一份就在那里。
|
|
825
|
+
*/
|
|
826
|
+
currentSelection(settings) {
|
|
827
|
+
const service = this.ctx.get('agentDefaultModel', false);
|
|
828
|
+
const live = service?.currentSelection?.();
|
|
829
|
+
if (live !== undefined)
|
|
830
|
+
return live;
|
|
831
|
+
const value = settings
|
|
832
|
+
.describe()
|
|
833
|
+
.find(entry => entry.ns === 'agent-default-model')?.value;
|
|
834
|
+
const provider = typeof value?.provider === 'string' ? value.provider : '';
|
|
835
|
+
const model = typeof value?.model === 'string' ? value.model : '';
|
|
836
|
+
return provider === '' || model === '' ? null : { provider, model };
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* 写入 `llm-pi-ai.providers`:本插件的路由完全对齐服务端现状(含回收),用户自己写的条目原样保留。
|
|
840
|
+
*
|
|
841
|
+
* 预设路由键就是 dsh 的提供方 id,与用户手写 profile 同名时以用户为准(改用自铸路由),
|
|
842
|
+
* 否则会把人家配置里的 `anthropic:` 段整段替换掉。
|
|
843
|
+
*
|
|
844
|
+
* @returns 实际落地的路由表(原路由键 → 最终路由键),调用侧据此改写默认模型与项目选择——
|
|
845
|
+
* 让位后若仍按原路由键选模型,`agent-default-model` 会指向一条属于用户的 profile。
|
|
846
|
+
*/
|
|
847
|
+
async applyProviders(settings, compiled, facts) {
|
|
848
|
+
const existing = this.readProviders(settings);
|
|
849
|
+
const owned = new Set(facts.routes);
|
|
850
|
+
const injected = {};
|
|
851
|
+
const routes = new Map();
|
|
852
|
+
for (const [route, provider] of Object.entries(compiled)) {
|
|
853
|
+
const taken = Object.hasOwn(existing, route) && !owned.has(route);
|
|
854
|
+
if (!taken) {
|
|
855
|
+
injected[route] = providerProfile(provider);
|
|
856
|
+
routes.set(route, route);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
// 让位:自铸路由没有目录兜底,必须自带 api / baseURL(providerProfile 会带上)。
|
|
860
|
+
const fallback = connectionRoute(provider.connectionId);
|
|
861
|
+
this.warn(`provider 路由 ${route} 已被本地配置占用,改用自铸路由 ${fallback}`);
|
|
862
|
+
injected[fallback] = providerProfile({ ...provider, preset: false });
|
|
863
|
+
routes.set(route, fallback);
|
|
864
|
+
}
|
|
865
|
+
// 归属表外的自铸路由一并回收,避免留下死路由(重载与登出都走这里)。
|
|
866
|
+
// 它们的密钥引用只能从被丢弃的 profile 上取——归属表里没有这条记录。
|
|
867
|
+
const staleCredentials = [];
|
|
868
|
+
const kept = Object.fromEntries(Object.entries(existing).filter(([id, profile]) => {
|
|
869
|
+
if (owned.has(id))
|
|
870
|
+
return false;
|
|
871
|
+
if (!LEGACY_OWNED_PROVIDER.test(id))
|
|
872
|
+
return true;
|
|
873
|
+
const env = apiKeyEnvOf(profile);
|
|
874
|
+
if (env !== undefined)
|
|
875
|
+
staleCredentials.push(env);
|
|
876
|
+
return false;
|
|
877
|
+
}));
|
|
878
|
+
// 整体替换而非合并:这个段只有 `providers` 一个键,替换后本表即服务端现状 + 用户自有条目,
|
|
879
|
+
// 上一轮注入、这一轮服务端已撤下的路由才会真的消失。
|
|
880
|
+
await this.replaceNamespace(settings, 'llm-pi-ai', {
|
|
881
|
+
providers: { ...kept, ...injected },
|
|
882
|
+
});
|
|
883
|
+
return { routes, staleCredentials };
|
|
884
|
+
}
|
|
885
|
+
readProviders(settings) {
|
|
886
|
+
const providers = settings
|
|
887
|
+
.describe()
|
|
888
|
+
.find(entry => entry.ns === 'llm-pi-ai')?.value?.providers;
|
|
889
|
+
return providers !== null &&
|
|
890
|
+
typeof providers === 'object' &&
|
|
891
|
+
!Array.isArray(providers)
|
|
892
|
+
? providers
|
|
893
|
+
: {};
|
|
894
|
+
}
|
|
895
|
+
readOwned(settings) {
|
|
896
|
+
const value = settings.describe().find(entry => entry.ns === OWNED_NAMESPACE)?.value ??
|
|
897
|
+
{};
|
|
898
|
+
const strings = (input) => Array.isArray(input)
|
|
899
|
+
? input.filter((item) => typeof item === 'string')
|
|
900
|
+
: [];
|
|
901
|
+
return {
|
|
902
|
+
routes: strings(value.routes),
|
|
903
|
+
credentials: strings(value.credentials),
|
|
904
|
+
deepseek: value.deepseek === true,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* 连接级密钥 → dsh credentials;`runtimeModelCredentials` 降级时整体跳过(返回 null)。
|
|
909
|
+
*
|
|
910
|
+
* `reconcile`(模型目录同时读到了)时才回收服务端已撤下连接的密钥:此时「服务端没有它」
|
|
911
|
+
* 是确定的,否则与「这次没读到」不可区分,只能连归属表一起原样保留。
|
|
912
|
+
*
|
|
913
|
+
* @returns 归属表该记的密钥名(本轮写过 ∪ 上次写过)。
|
|
914
|
+
*/
|
|
915
|
+
async applyModelCredentials(boot, reconcile, facts, staleCredentials) {
|
|
916
|
+
const entries = boot.runtimeModelCredentials;
|
|
917
|
+
if (entries === null || entries === undefined) {
|
|
918
|
+
this.warn('模型密钥读取失败(runtimeModelCredentials),跳过密钥写入');
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
const credentials = await this.service('credentials');
|
|
922
|
+
const written = new Set();
|
|
923
|
+
for (const entry of entries) {
|
|
924
|
+
const apiKeyEnv = connectionApiKeyEnv(entry.connectionId);
|
|
925
|
+
if (written.has(apiKeyEnv))
|
|
926
|
+
continue;
|
|
927
|
+
written.add(apiKeyEnv);
|
|
928
|
+
// dsh credentials 解析顺序是 env 先于存储:删掉同名 env 才能让 semi2-hub 密钥生效
|
|
929
|
+
//(该名字由 client 自铸,不与本地 key 撞名)。
|
|
930
|
+
Reflect.deleteProperty(process.env, apiKeyEnv);
|
|
931
|
+
await credentials.set(credentialRef(apiKeyEnv), entry.value);
|
|
932
|
+
}
|
|
933
|
+
if (!reconcile)
|
|
934
|
+
return [...new Set([...facts.credentials, ...written])];
|
|
935
|
+
const obsolete = new Set([...facts.credentials, ...staleCredentials]);
|
|
936
|
+
for (const ref of obsolete) {
|
|
937
|
+
if (written.has(ref))
|
|
938
|
+
continue;
|
|
939
|
+
try {
|
|
940
|
+
await credentials.unset(credentialRef(ref));
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
this.warn(`陈旧模型密钥 ${ref} 回收失败`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return [...written];
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* 控制台技能目录:`runtimeSkills` 为 null/undefined 都算「没拿到权威目录」——返回 null 只降级该块,
|
|
950
|
+
* 状态里 null ≠ 空数组(provider 据此报 complete:false,dsh 才不把抖动缓存成空目录)。
|
|
951
|
+
*/
|
|
952
|
+
applySkills(boot) {
|
|
953
|
+
const skills = boot.runtimeSkills;
|
|
954
|
+
if (skills === null || skills === undefined) {
|
|
955
|
+
this.warn('技能目录读取失败(runtimeSkills),本次不提供控制台技能');
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
return skills.map(skill => ({
|
|
959
|
+
id: skill.id,
|
|
960
|
+
projectId: skill.projectId,
|
|
961
|
+
name: skill.name,
|
|
962
|
+
description: skill.description,
|
|
963
|
+
whenToUse: skill.whenToUse,
|
|
964
|
+
content: skill.content,
|
|
965
|
+
version: skill.version,
|
|
966
|
+
files: fileFactsOf(skill),
|
|
967
|
+
}));
|
|
968
|
+
}
|
|
969
|
+
applied(boot, compiled, errorCode) {
|
|
970
|
+
const defaultModelId = boot.runtimeDefaultModelId ?? null;
|
|
971
|
+
const selection = compiled === null || defaultModelId === null
|
|
972
|
+
? null
|
|
973
|
+
: (compiled.selections.get(defaultModelId) ?? null);
|
|
974
|
+
return {
|
|
975
|
+
...this.current,
|
|
976
|
+
status: 'ready',
|
|
977
|
+
error: errorCode,
|
|
978
|
+
consoleUrl: this.connection?.url ?? this.current.consoleUrl,
|
|
979
|
+
loadedAt: new Date().toISOString(),
|
|
980
|
+
identity: boot.runtimeViewer ?? null,
|
|
981
|
+
model: selection === null
|
|
982
|
+
? null
|
|
983
|
+
: { id: selection.model, provider: selection.provider },
|
|
984
|
+
skills: this.applySkills(boot),
|
|
985
|
+
// 项目缓存随本次装配作废:接着由工作区同步按 slug 重新取,服务端已撤下的项目不会残留。
|
|
986
|
+
projects: [],
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
async login(input) {
|
|
990
|
+
await this.ensureContract();
|
|
991
|
+
if (hasContractCredentials(this.contract?.credentials))
|
|
992
|
+
throw new ConfigurationError('CREDENTIAL_SOURCE_READONLY');
|
|
993
|
+
const outcome = await loginToConsole({
|
|
994
|
+
url: input.url,
|
|
995
|
+
username: input.username,
|
|
996
|
+
password: input.password,
|
|
997
|
+
});
|
|
998
|
+
await storeConnection(this.ctx, { url: outcome.url, token: outcome.token });
|
|
999
|
+
this.connection = await this.exchangeCnbCredential(connectionOf(await this.connectionFields()));
|
|
1000
|
+
await this.load();
|
|
1001
|
+
return { userName: outcome.userName };
|
|
1002
|
+
}
|
|
1003
|
+
async logout() {
|
|
1004
|
+
await clearConnection(this.ctx);
|
|
1005
|
+
// 登出后地址仍在(env 或契约给的),登录表单不该被清空。
|
|
1006
|
+
const fields = await this.connectionFields();
|
|
1007
|
+
this.connection = await this.exchangeCnbCredential(connectionOf(fields));
|
|
1008
|
+
// 部署面凭据(契约 / CNB 面 env)不受登出影响:配置照旧可读,不回收。
|
|
1009
|
+
if (this.connection !== null) {
|
|
1010
|
+
await this.load();
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
await this.reclaim();
|
|
1014
|
+
this.publish(initialState(false, fields.url?.value ?? null, this.trackedWorkspaces()));
|
|
1015
|
+
this.ctx.emit('semi2/config-updated', this.current);
|
|
1016
|
+
await this.publishStatus();
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* 登出 = 回到「从未登录」:本地不留任何 semi2-hub 下发的运行配置。
|
|
1020
|
+
* 残留不是显示问题——路由与密钥还在,会话就继续用着一个已登出账号的模型,
|
|
1021
|
+
* 默认模型甚至指向目录里已不存在的条目(选中即 `NO_ADAPTER`)。
|
|
1022
|
+
*
|
|
1023
|
+
* 只删归属表记着的条目:用户自己写的 provider profile、`llm-deepseek` 键、
|
|
1024
|
+
* 组合层默认模型都不属于我们。回收失败只 warn,登出本身必须成功。
|
|
1025
|
+
*/
|
|
1026
|
+
async reclaim() {
|
|
1027
|
+
this.compiled = null;
|
|
1028
|
+
this.defaultModelId = null;
|
|
1029
|
+
this.missingProjects.clear();
|
|
1030
|
+
// 工作区簿记里的 hub 解析结果一并撤下(目录、标题、git slug 是本地事实,保留)。
|
|
1031
|
+
for (const entry of this.book.entries())
|
|
1032
|
+
this.book.set({
|
|
1033
|
+
...entry,
|
|
1034
|
+
projectId: null,
|
|
1035
|
+
name: null,
|
|
1036
|
+
syncedAt: null,
|
|
1037
|
+
});
|
|
1038
|
+
const settings = this.settings;
|
|
1039
|
+
if (settings === undefined)
|
|
1040
|
+
return;
|
|
1041
|
+
try {
|
|
1042
|
+
const owned = this.readOwned(settings);
|
|
1043
|
+
// 空目录 + 归属表 = 只撤自己写过的路由(连带归属表外的自铸路由与它们的密钥引用)。
|
|
1044
|
+
const { staleCredentials } = await this.applyProviders(settings, {}, owned);
|
|
1045
|
+
if (owned.deepseek)
|
|
1046
|
+
await this.clearKeys(settings, 'llm-deepseek', DEEPSEEK_OWNED_KEYS);
|
|
1047
|
+
// 默认模型正指向我们的路由时交还组合层:路由已撤,留着就是悬空引用。
|
|
1048
|
+
const selection = this.currentSelection(settings);
|
|
1049
|
+
if (selection !== null && owned.routes.includes(selection.provider))
|
|
1050
|
+
await this.replaceNamespace(settings, 'agent-default-model', {});
|
|
1051
|
+
await this.reclaimCredentials([...owned.credentials, ...staleCredentials]);
|
|
1052
|
+
await this.updateNamespace(settings, OWNED_NAMESPACE, {
|
|
1053
|
+
routes: [],
|
|
1054
|
+
credentials: [],
|
|
1055
|
+
deepseek: false,
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
this.warn(`登出回收失败(${codeOf(error)}),本地可能留有上次注入的条目`);
|
|
1060
|
+
}
|
|
1061
|
+
await this.persistWorkspaces();
|
|
1062
|
+
}
|
|
1063
|
+
/** 回收模型密钥(按归属表、以及归属表外自铸路由自带的引用);单条失败只 warn。 */
|
|
1064
|
+
async reclaimCredentials(refs) {
|
|
1065
|
+
const obsolete = [...new Set(refs)];
|
|
1066
|
+
if (obsolete.length === 0)
|
|
1067
|
+
return;
|
|
1068
|
+
// 非严格查找:登出不能被一个缺席的服务卡住(登录 / 登出时该服务本就存在)。
|
|
1069
|
+
const credentials = this.ctx.get('credentials', false);
|
|
1070
|
+
if (credentials === undefined) {
|
|
1071
|
+
this.warn('credentials 服务缺席,模型密钥回收跳过');
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
for (const ref of obsolete) {
|
|
1075
|
+
try {
|
|
1076
|
+
await credentials.unset(credentialRef(ref));
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
this.warn(`模型密钥 ${ref} 回收失败`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
publish(next) {
|
|
1084
|
+
this.current = next;
|
|
1085
|
+
}
|
|
1086
|
+
/** 记一条领域降级码并返回它(调用方收集,首个作为 state.error)。 */
|
|
1087
|
+
noteFailure(area, error) {
|
|
1088
|
+
const code = codeOf(error);
|
|
1089
|
+
this.warn(`${area}应用失败(${code}),该领域降级,其余领域照常`);
|
|
1090
|
+
return code;
|
|
1091
|
+
}
|
|
1092
|
+
warn(message) {
|
|
1093
|
+
try {
|
|
1094
|
+
this.ctx.logger('semi2-config').warn(message);
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
// 无 logger 的宿主(单测)忽略。
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|