@mackwan84/dsh-tool-ui-mockup 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
3
+ import { access, appendFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
7
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
8
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
9
+ import { ImageProviderError, } from '@mackwan84/dsh-image';
10
+ import { DEFAULT_PREFS, PrefsSchema, clampCount, clampPage, clampPageSize, filterHistory, parseHistoryLine, sanitizeAnchorFileName, } from './prefs.js';
11
+ import { buildPrompt } from './prompt.js';
12
+ export const name = 'ui-mockup';
13
+ /** 硬依赖:工具注册与使用规则注入是本插件的全部职责,等待这些服务出现。 */
14
+ export const inject = ['tools', 'systemPrompt'];
15
+ /** 成功结果包装。 */
16
+ function rpcOk(value) {
17
+ return { ok: true, value };
18
+ }
19
+ /** 失败结果包装:错误码与提供方 resolver 语义保持一致风格。 */
20
+ function rpcError(code, message) {
21
+ return { ok: false, error: { code, message } };
22
+ }
23
+ const USAGE_SECTION = {
24
+ name: 'ui-mockup-usage',
25
+ order: 500,
26
+ text: [
27
+ '# 界面草图工具使用规则',
28
+ '',
29
+ '工具 ui_mockup 用于在讨论阶段生成界面草图或高保真设计稿, 让用户在开始写实现代码之前确认界面方向, 避免代码完成后才发现界面不符合预期。',
30
+ '',
31
+ '使用时机:',
32
+ '- 功能需求讨论基本明确、准备开始写前端实现代码之前, 主动提议生成草图确认, 不要直接开始写代码。',
33
+ '- 用户提到界面、页面、UI、视觉风格时, 主动询问是否需要生成草图。',
34
+ "- 布局与信息架构待确认: fidelity='wireframe'(面板分层默认里的线框图模型, 速度快)。",
35
+ "- 视觉风格待确认: fidelity='high-fidelity'(面板分层默认里的高保真模型, 质量优先), 建议 count=2~4 一次给多个方向供用户选择。",
36
+ '- 同一个站点的多个页面在高保真阶段应传 reference=已确认页面的图, 保持风格一致(图生图模式)。',
37
+ '- 用户对生成的图提出修改意见: 优先走编辑模式(同时传 baseImage=上一版生成图路径 与 editNote=修改指令, 只重绘要改的部分, 更快且更贴近原稿); 大改布局或换风格时才在 description 中写修改后的完整描述整体重新生成。',
38
+ '- 编辑模式当前仅火山方舟 Provider 支持; 生效提供方为阿里云百炼时编辑调用会返回 NOT_IMPLEMENTED, 此时改用整体重新生成。',
39
+ '- 模型分层默认由设置面板管理; 用户没有点名具体模型时不要传 model 参数, 否则会绕过面板配置(面板未配置时回落生效提供方的内置分层默认)。',
40
+ '',
41
+ '确认与锁定:',
42
+ '- 生成后展示图片, 请用户反馈, 循环修改直到用户明确确认。',
43
+ '- 用户确认某一版设计后: 把设计提炼为规格写入 design/spec.md, 内容包括配色、字体、间距、组件清单、页面清单; 该文件成为后续实现代码的依据。',
44
+ '- design/spec.md 尚未生成或未获用户确认前, 不要开始编写前端实现代码。',
45
+ ].join('\n'),
46
+ };
47
+ export const Config = PrefsSchema;
48
+ /** 语义路径(agent / reference 兼容层,映射到资产库物理路径)。 */
49
+ const IMAGE_DIR = 'design/images';
50
+ import { homedir } from 'node:os';
51
+ /**
52
+ * 工作区根 → slug:与 DSH sessions 目录同款转义——先补尾分隔符再做
53
+ * 分隔符→`-` 替换并首尾包 `-`,例 /Users/l/x → --Users-l-x--
54
+ * (首尾各两个 `-`:首来自前导空段+包裹,尾来自补的尾分隔符+包裹,
55
+ * 与 ~/.dsh/sessions 的实测目录名一致)。
56
+ */
57
+ function workspaceSlug(workspaceRoot) {
58
+ const withTrailing = workspaceRoot.endsWith('/') ? workspaceRoot : `${workspaceRoot}/`;
59
+ return `-${withTrailing.split(/[\\/]+/).join('-')}-`;
60
+ }
61
+ /**
62
+ * $DSH_HOME:与宿主 resolveDshHome 同口径——环境变量优先、默认 ~/.dsh;
63
+ * 纯空白值视为未设置(避免把 home 解析成 cwd 下的怪目录),前导 ~ 展开为 home。
64
+ */
65
+ function dshHome() {
66
+ const fromEnv = process.env['DSH_HOME'];
67
+ if (fromEnv !== undefined && fromEnv.trim() !== '') {
68
+ return resolve(fromEnv.startsWith('~') ? join(homedir(), fromEnv.slice(1)) : fromEnv);
69
+ }
70
+ return resolve(homedir(), '.dsh');
71
+ }
72
+ /**
73
+ * 工作区的设计资产库:$DSH_HOME/mockups/<slug>/{images,anchor.json,history.jsonl}。
74
+ * 项目工作区不再落任何运行时产物(spec.md 等交付物仍留项目内)。
75
+ */
76
+ function storeOf(workspaceRoot) {
77
+ const root = resolve(dshHome(), 'mockups', workspaceSlug(workspaceRoot));
78
+ return {
79
+ root,
80
+ imagesDir: resolve(root, 'images'),
81
+ anchorFile: resolve(root, 'anchor.json'),
82
+ historyFile: resolve(root, 'history.jsonl'),
83
+ };
84
+ }
85
+ /**
86
+ * 把语义 reference 翻译为可解析路径:
87
+ * - `design/images/x.png` / `design/x.png`(agent 习惯写法)→ 资产库内绝对路径;
88
+ * - 其余输入(如用户自备的 `assets/base.png`)按「相对工作区」原样返回,
89
+ * 由 Provider 以 cwd 为根防逃逸。
90
+ */
91
+ function translateReference(reference, workspaceRoot) {
92
+ const store = storeOf(workspaceRoot);
93
+ if (reference === 'design' || reference === 'design/')
94
+ return store.root;
95
+ if (reference.startsWith('design/images/')) {
96
+ return resolve(store.imagesDir, reference.slice('design/images/'.length));
97
+ }
98
+ if (reference.startsWith('design/')) {
99
+ return resolve(store.root, reference.slice('design/'.length));
100
+ }
101
+ return reference;
102
+ }
103
+ /** 偏好生效值:settings 服务可用时以命名空间 resolved 值为准;
104
+ * 缺席(纯内存部署)时退化为 cordis.yml 配置层覆盖内置默认。 */
105
+ export function effectivePrefs(scope, config) {
106
+ if (scope !== undefined)
107
+ return scope.get();
108
+ const merged = { ...DEFAULT_PREFS };
109
+ for (const [key, value] of Object.entries(config)) {
110
+ if (value !== undefined)
111
+ merged[key] = value;
112
+ }
113
+ return merged;
114
+ }
115
+ /**
116
+ * 锚点登记是资产库里的一个两行 JSON 文件:只存「当前锚点文件名 + 时间」,
117
+ * 不复制图片内容。读取即校验文件名合法且目标图仍存在,任一不满足视为无锚点
118
+ * (清空历史、删图后残留的 anchor.json 自愈为空态)。
119
+ */
120
+ async function readAnchor(workspaceRoot, requireExists = true) {
121
+ const store = storeOf(workspaceRoot);
122
+ let raw;
123
+ try {
124
+ raw = await readFile(store.anchorFile, 'utf8');
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ let name;
130
+ try {
131
+ name = JSON.parse(raw).file;
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ const file = sanitizeAnchorFileName(name);
137
+ if (file === null)
138
+ return null;
139
+ if (!requireExists)
140
+ return file;
141
+ try {
142
+ await access(resolve(store.imagesDir, file));
143
+ return file;
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ /**
150
+ * 原子写:先写同目录临时文件再 rename(POSIX 原子),避免并发写或进程
151
+ * 中断在 anchor.json / history.jsonl 上留下半截 JSON。
152
+ */
153
+ async function writeFileAtomic(path, content) {
154
+ const tmp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
155
+ await writeFile(tmp, content);
156
+ await rename(tmp, path);
157
+ }
158
+ async function writeAnchor(workspaceRoot, fileName) {
159
+ const store = storeOf(workspaceRoot);
160
+ await mkdir(store.root, { recursive: true });
161
+ await writeFileAtomic(store.anchorFile, `${JSON.stringify({ file: fileName, time: new Date().toISOString() })}\n`);
162
+ }
163
+ /** 清除锚点:文件不存在也算清除成功(幂等)。 */
164
+ async function clearAnchor(workspaceRoot) {
165
+ await rm(storeOf(workspaceRoot).anchorFile, { force: true });
166
+ }
167
+ /** 读取并解析历史文件;目录不存在按空历史处理。 */
168
+ async function readHistory(workspaceRoot) {
169
+ let raw;
170
+ try {
171
+ raw = await readFile(storeOf(workspaceRoot).historyFile, 'utf8');
172
+ }
173
+ catch {
174
+ return [];
175
+ }
176
+ return raw
177
+ .split('\n')
178
+ .map(parseHistoryLine)
179
+ .filter((entry) => entry !== null)
180
+ .reverse();
181
+ }
182
+ /**
183
+ * 当前生效提供方:读 image 槽位的 providerId(唯一事实源,不从偏好/组合文件推断)。
184
+ * 服务未挂载时 id 为 unknown、凭据名回退 DashScope(安装面默认提供方)。
185
+ */
186
+ function activeProviderOf(ctx) {
187
+ const service = ctx.get('image');
188
+ const id = service?.providerId ?? 'unknown';
189
+ const fallback = PROBES.dashscope.defaultCredential;
190
+ const credential = id === 'volcengine' || id === 'dashscope'
191
+ ? (readProviderConfigString(service, 'apiKey') ?? PROBES[id].defaultCredential)
192
+ : fallback;
193
+ return { id, credential };
194
+ }
195
+ /**
196
+ * 面板用的凭据状态:credentials 服务在场时走 describe(拿到来源层与可写性),
197
+ * 缺席时退化为启动环境探测(configured + 来源 ambient + 不可写)。
198
+ * 返回值永不携带密钥本身。
199
+ */
200
+ async function credentialStatus(ctx, credentialName) {
201
+ const ref = credentialRef(credentialName);
202
+ const credentials = ctx.get('credentials');
203
+ if (credentials !== undefined) {
204
+ const info = await credentials.describe(ref).catch(() => undefined);
205
+ if (info !== undefined) {
206
+ return { configured: info.configured, source: info.source, writable: info.writable };
207
+ }
208
+ }
209
+ // 与 Provider 相同的启动环境回退(.env / 进程环境),保证面板口径与实际生成一致
210
+ const ambient = launchEnvironmentOf(ctx).get(ref);
211
+ return {
212
+ configured: ambient !== undefined && ambient.value.length > 0,
213
+ source: 'ambient',
214
+ writable: false,
215
+ };
216
+ }
217
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
218
+ /** 按魔数嗅探图片真实类型:OSS 签名 URL 常以 application/octet-stream 返回生成图,content-type 不可信。 */
219
+ function detectMediaType(buffer) {
220
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE))
221
+ return 'image/png';
222
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
223
+ return 'image/jpeg';
224
+ if (buffer.length >= 12 &&
225
+ buffer.subarray(0, 4).toString('latin1') === 'RIFF' &&
226
+ buffer.subarray(8, 12).toString('latin1') === 'WEBP')
227
+ return 'image/webp';
228
+ if (buffer.length >= 6) {
229
+ const head = buffer.subarray(0, 6).toString('latin1');
230
+ if (head === 'GIF87a' || head === 'GIF89a')
231
+ return 'image/gif';
232
+ }
233
+ return undefined;
234
+ }
235
+ /** 把 HTTP 媒体类型归一化为图片附件允许的媒体类型,未知时回退 PNG。 */
236
+ function toMediaType(contentType) {
237
+ if (contentType === null)
238
+ return 'image/png';
239
+ const type = contentType.split(';')[0].trim().toLowerCase();
240
+ return ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(type) ? type : 'image/png';
241
+ }
242
+ function extensionFor(mediaType) {
243
+ return mediaType === 'image/jpeg'
244
+ ? 'jpg'
245
+ : mediaType === 'image/webp'
246
+ ? 'webp'
247
+ : mediaType === 'image/gif'
248
+ ? 'gif'
249
+ : 'png';
250
+ }
251
+ /** 按生成图文件名扩展名推断 HTTP 媒体类型(图片路由响应头)。 */
252
+ function mediaTypeForExtension(name) {
253
+ if (name.endsWith('.jpg') || name.endsWith('.jpeg'))
254
+ return 'image/jpeg';
255
+ if (name.endsWith('.webp'))
256
+ return 'image/webp';
257
+ if (name.endsWith('.gif'))
258
+ return 'image/gif';
259
+ return 'image/png';
260
+ }
261
+ /**
262
+ * canonical 化工作区根:realpath 解析符号链接(与宿主 sandboxPolicy 的
263
+ * resolveWorkspaceRoot 同语义),路径暂不可达时回退词法 resolve。
264
+ * 会话 cwd 含符号链接时(如 macOS /tmp → /private/tmp),落盘根与路由
265
+ * 读取根必须经过同一规范化,否则卡片按原始 cwd 请求会 404。
266
+ */
267
+ function canonicalRoot(path) {
268
+ try {
269
+ return realpathSync.native(path);
270
+ }
271
+ catch {
272
+ return resolve(path);
273
+ }
274
+ }
275
+ /**
276
+ * 路由 cwd 的信任源全集:本插件登记过的工作区根、宿主已知会话的 cwd、
277
+ * 宿主进程级 fallback。webServer 是进程级端口,本机任意网页都能发起请求,
278
+ * 未经验证的 cwd 等于开放任意目录的 design/images 读取。
279
+ */
280
+ function allowedRoots(ctx, known) {
281
+ const roots = new Set(known);
282
+ const sessions = ctx.get('sessions');
283
+ if (sessions !== undefined) {
284
+ for (const session of sessions.list()) {
285
+ const cwd = session.header.cwd;
286
+ if (cwd !== undefined && cwd !== '')
287
+ roots.add(canonicalRoot(cwd));
288
+ }
289
+ }
290
+ const policy = ctx.get('sandboxPolicy');
291
+ if (policy !== undefined)
292
+ roots.add(canonicalRoot(policy.workspaceRoot));
293
+ return roots;
294
+ }
295
+ /** DashScope 当前可安全接收单参考图的模型族。 */
296
+ function supportsDashscopeReference(model) {
297
+ return model.startsWith('qwen-image') || model === 'wan2.7-image' || model === 'wan2.7-image-pro';
298
+ }
299
+ export function apply(ctx, config = {}) {
300
+ const tools = ctx.get('tools');
301
+ // 本插件进程内已生成过图片的工作区根(canonical):图片路由 cwd 白名单的信任源之一
302
+ const knownRoots = new Set();
303
+ /**
304
+ * 偏好命名空间:settings 是晚就绪的宿主平面服务(与 webServer/connection 同类),
305
+ * apply 瞬间 ctx.get 可能拿到 undefined 而永久错过注册——面板写入会存进设置文档
306
+ * 但 execute 永远读不到。改用 ctx.inject 等服务就绪后再注册;服务缺席(纯内存
307
+ * 部署)时保持 undefined,各调用点退化为 config + 内置默认,面板只读不可写。
308
+ * effect 挂 inject 回调的 scope:服务重载时命名空间随 scope 销毁重建,引用同步复位。
309
+ */
310
+ let prefsScope;
311
+ ctx.inject(['settings'], (scope) => {
312
+ const settings = scope.get('settings');
313
+ scope.effect(() => {
314
+ prefsScope = settings.register('ui-mockup', PrefsSchema, { base: config });
315
+ return () => {
316
+ prefsScope = undefined;
317
+ };
318
+ }, 'ui-mockup: preferences namespace');
319
+ });
320
+ ctx.effect(() => tools.register({
321
+ name: 'ui_mockup',
322
+ description: '调用外部图像生成接口, 为界面/页面生成线框图(wireframe)或高保真(high-fidelity)设计草图, 供用户在编写实现代码之前确认界面方向。图片显示在对话中并保存到 DSH 设计资产库($DSH_HOME/mockups/<工作区>/images/)。用户需要修改时, 用修改后的完整描述再次调用。模型分层默认由设置面板「提供方与模型」管理(未配置时回落生效提供方的内置分层默认); 用户未点名模型时不要传 model 参数, 以免绕过面板配置。传 reference 参数可用已确认的图作为风格基准(图生图), 保持多页面风格一致。接口限流时自动退避重试。需要生效提供方的凭据(阿里云百炼 DASHSCOPE_API_KEY 或火山方舟 ARK_API_KEY, 取决于面板生效提供方, 通过凭据服务或环境变量提供)。',
323
+ parameters: {
324
+ type: 'object',
325
+ properties: {
326
+ description: {
327
+ type: 'string',
328
+ description: '界面/页面的完整描述, 包括布局、区块、内容与功能。',
329
+ },
330
+ fidelity: {
331
+ type: 'string',
332
+ enum: ['wireframe', 'high-fidelity'],
333
+ description: '草图精度, 由用户选择: wireframe=黑白线框图(确认布局与信息架构); high-fidelity=高保真设计稿(确认视觉风格)。',
334
+ },
335
+ platform: {
336
+ type: 'string',
337
+ enum: ['web', 'mobile'],
338
+ default: 'web',
339
+ description: '目标平台, 决定画幅方向。',
340
+ },
341
+ style: {
342
+ type: 'string',
343
+ description: '视觉风格描述(仅 high-fidelity 时使用), 例如"极简浅色"、"深色科技感"、"温暖电商风"。',
344
+ },
345
+ count: {
346
+ type: 'integer',
347
+ description: '一次生成的方案数量(1-4)。风格探索时建议 2-4, 让用户挑选方向; 未指定时取设置面板的「一次生成数量」偏好。',
348
+ },
349
+ model: {
350
+ type: 'string',
351
+ description: '可选: 显式覆盖模型。默认取设置面板「提供方与模型」的分层默认(未配置时回落生效提供方的内置分层默认)。用户未点名具体模型时请省略本参数, 否则会绕过面板配置。',
352
+ },
353
+ size: {
354
+ type: 'string',
355
+ description: '可选: 覆盖默认画幅, 如 "1024*1024"、"1280*720"、"720*1280"。',
356
+ },
357
+ reference: {
358
+ type: 'string',
359
+ description: '可选: 参考图路径(相对工作区), 图生图模式用它保持风格一致, 例如 design/images/mockup-xxx.png。',
360
+ },
361
+ baseImage: {
362
+ type: 'string',
363
+ description: '可选: 待编辑的基准图路径(相对工作区, 生成图可写 design/images/mockup-xxx.png)。与 editNote 成对出现时走指令编辑(整图重绘), 此时忽略 description 之外的生成参数。',
364
+ },
365
+ editNote: {
366
+ type: 'string',
367
+ description: '可选: 编辑指令, 描述在基准图上改什么(如"把主按钮改成绿色并加大间距")。与 baseImage 成对出现。',
368
+ },
369
+ },
370
+ required: ['description', 'fidelity'],
371
+ },
372
+ output: {
373
+ schema: {
374
+ type: 'object',
375
+ additionalProperties: false,
376
+ properties: {
377
+ ok: { type: 'boolean', description: '本次调用是否成功。' },
378
+ message: { type: 'string', description: '面向用户的说明或错误信息。' },
379
+ images: {
380
+ type: 'array',
381
+ description: '生成图片列表(含会话附件引用与文件路径)。',
382
+ items: {
383
+ type: 'object',
384
+ additionalProperties: false,
385
+ properties: {
386
+ path: { type: 'string' },
387
+ name: { type: 'string' },
388
+ width: { type: 'number' },
389
+ height: { type: 'number' },
390
+ attachmentId: { type: 'string' },
391
+ mediaType: { type: 'string' },
392
+ bytes: { type: 'number' },
393
+ },
394
+ },
395
+ },
396
+ },
397
+ },
398
+ render(_args, value) {
399
+ const blocks = [];
400
+ const result = value;
401
+ if (result.images !== undefined) {
402
+ for (const image of result.images) {
403
+ // 未入附件(超限/无附件服务)的条目没有可解析的引用,跳过图片块,路径已在文本中给出
404
+ if (image.attachmentId === '')
405
+ continue;
406
+ blocks.push({
407
+ type: 'image',
408
+ attachment: {
409
+ attachmentId: image.attachmentId,
410
+ mediaType: image.mediaType,
411
+ bytes: image.bytes,
412
+ width: image.width,
413
+ height: image.height,
414
+ name: image.name,
415
+ },
416
+ });
417
+ }
418
+ }
419
+ blocks.push({ type: 'text', text: result.message });
420
+ return blocks;
421
+ },
422
+ },
423
+ async execute(args, exec) {
424
+ try {
425
+ if (typeof args.description !== 'string' || args.description.trim() === '') {
426
+ return { ok: false, message: '缺少 description 参数: 请描述要生成的界面内容。' };
427
+ }
428
+ const fidelity = args.fidelity;
429
+ if (fidelity !== 'wireframe' && fidelity !== 'high-fidelity') {
430
+ return {
431
+ ok: false,
432
+ message: 'fidelity 必须是 "wireframe"(线框图) 或 "high-fidelity"(高保真)。',
433
+ };
434
+ }
435
+ const platform = args.platform === 'mobile' || args.platform === 'web'
436
+ ? args.platform
437
+ : effectivePrefs(prefsScope, config).defaultPlatform;
438
+ let reference = typeof args.reference === 'string' && args.reference.trim() !== ''
439
+ ? args.reference.trim()
440
+ : undefined;
441
+ const sandboxPolicy = ctx.get('sandboxPolicy');
442
+ const session = exec.agent?.session;
443
+ // 会话工作区优先(sandboxPolicy.resolve 会取 session.header.cwd),
444
+ // 退化为会话 cwd、进程级 fallback,避免图片落到宿主进程 CWD。
445
+ // 统一 canonical 化:sandboxPolicy 返回值本身已 canonical,但 fallback
446
+ // 分支的 header.cwd 未规范化;同时与路由白名单的比对口径保持一致。
447
+ const workspaceRoot = canonicalRoot(sandboxPolicy?.resolve(session === undefined ? {} : { session })?.workspaceRoot ??
448
+ session?.header.cwd ??
449
+ '.');
450
+ const prefs = effectivePrefs(prefsScope, config);
451
+ // 编辑模式:baseImage + editNote 成对出现时走指令编辑(整图重绘)。
452
+ // 单独出现是模型常见的半截调用,显式拒绝并说明,避免静默降级为生成。
453
+ const baseImageArg = typeof args.baseImage === 'string' && args.baseImage.trim() !== ''
454
+ ? args.baseImage.trim()
455
+ : undefined;
456
+ const editNoteArg = typeof args.editNote === 'string' && args.editNote.trim() !== ''
457
+ ? args.editNote.trim()
458
+ : undefined;
459
+ if ((baseImageArg === undefined) !== (editNoteArg === undefined)) {
460
+ return {
461
+ ok: false,
462
+ message: 'baseImage 与 editNote 必须成对出现: 编辑已有图时同时传基准图路径与编辑指令。',
463
+ };
464
+ }
465
+ const isEdit = baseImageArg !== undefined;
466
+ const effectiveModel = typeof args.model === 'string' && args.model.trim() !== ''
467
+ ? args.model
468
+ : (fidelity === 'high-fidelity' ? prefs.highFidelityModel : prefs.wireframeModel) ||
469
+ undefined;
470
+ // 风格锚点联动:调用未显式传 reference 时自动引用当前锚点(I2I 保持多页风格一致)。
471
+ // 参考图能力按生效提供方分流:DashScope 当前支持 qwen-image 与 Wan 2.7;
472
+ // 其他自定义模型跳过注入并说明,否则 Provider 会以 INVALID_PARAMETER 拒绝且用户难以归因;
473
+ // volcengine 的 seedream 系原生支持 image 参考图,锚点正常注入。
474
+ // 模型为空串(交 Provider 自决)时仍注入:各提供方分层默认均支持参考图。
475
+ let anchorInjected = null;
476
+ let anchorSkippedForModel = null;
477
+ // 编辑模式不注入锚点:基准图本身就是风格基准,再叠参考图会互相干扰
478
+ if (reference === undefined && !isEdit) {
479
+ const anchorFile = await readAnchor(workspaceRoot);
480
+ if (anchorFile !== null) {
481
+ const activeProviderId = ctx.get('image')
482
+ ?.providerId;
483
+ const referenceUnsupported = activeProviderId === 'dashscope' &&
484
+ effectiveModel !== undefined &&
485
+ !supportsDashscopeReference(effectiveModel);
486
+ if (referenceUnsupported) {
487
+ anchorSkippedForModel = effectiveModel;
488
+ }
489
+ else {
490
+ anchorInjected = anchorFile;
491
+ reference = `${IMAGE_DIR}/${anchorFile}`;
492
+ }
493
+ }
494
+ }
495
+ // reference 语义翻译:design/ 前缀(生成图/锚点)→资产库绝对路径, 其余(用户自备图)保持相对工作区。
496
+ // cwd 随之分流:store 路径以 store 为根防逃逸, 相对路径仍以工作区为根。
497
+ const translatedReference = reference === undefined ? undefined : translateReference(reference, workspaceRoot);
498
+ const referenceInStore = translatedReference !== undefined && translatedReference !== reference;
499
+ const spec = {
500
+ prompt: buildPrompt({
501
+ description: args.description,
502
+ fidelity,
503
+ platform,
504
+ style: typeof args.style === 'string' ? args.style : undefined,
505
+ }, reference !== undefined),
506
+ fidelity,
507
+ platform,
508
+ style: typeof args.style === 'string' ? args.style : undefined,
509
+ size: typeof args.size === 'string' && args.size.trim() !== ''
510
+ ? args.size
511
+ : prefs.defaultSize.trim() !== ''
512
+ ? prefs.defaultSize
513
+ : undefined,
514
+ n: clampCount(args.count ?? prefs.defaultCount),
515
+ model: effectiveModel,
516
+ // 偏好层缺字段时避免 NaN 下传;缺省让 Provider 走自身配置
517
+ ...(Number.isFinite(prefs.pollTimeoutMinutes) && prefs.pollTimeoutMinutes > 0
518
+ ? { pollTimeoutMs: Math.round(prefs.pollTimeoutMinutes * 60_000) }
519
+ : {}),
520
+ reference: translatedReference,
521
+ // 参考图解析根随语义分流: 资产库绝对路径以 store 为根, 其余以会话工作区为根
522
+ cwd: referenceInStore ? storeOf(workspaceRoot).root : workspaceRoot,
523
+ };
524
+ const service = ctx.get('image');
525
+ if (service === undefined) {
526
+ return {
527
+ ok: false,
528
+ message: '未挂载图像生成服务(image): 请安装 @mackwan84/dsh-image-dashscope 或 @mackwan84/dsh-image-volcengine 并加入组合。',
529
+ };
530
+ }
531
+ const generated = isEdit
532
+ ? await service.edit({
533
+ prompt: editNoteArg ?? '',
534
+ // baseImage 与 reference 同语义:design/ 前缀(生成图/锚点)→资产库
535
+ // 绝对路径,其余保持相对工作区;cwd 随之分流防逃逸。
536
+ baseImage: translateReference(baseImageArg ?? '', workspaceRoot),
537
+ platform,
538
+ size: spec.size,
539
+ model: effectiveModel,
540
+ cwd: (baseImageArg ?? '').startsWith('design/')
541
+ ? storeOf(workspaceRoot).root
542
+ : workspaceRoot,
543
+ }, exec.signal)
544
+ : await service.generate(spec, exec.signal);
545
+ const attachments = ctx.get('attachments');
546
+ const maxImageBytes = attachments?.imageLimits?.maxImageBytes;
547
+ const stamp = Date.now();
548
+ const runId = randomUUID().slice(0, 8);
549
+ const images = [];
550
+ const failures = [];
551
+ let oversize = 0;
552
+ for (let i = 0; i < generated.images.length; i++) {
553
+ const item = generated.images[i];
554
+ try {
555
+ const res = await fetch(item.url, { signal: exec.signal });
556
+ if (!res.ok) {
557
+ failures.push(`第 ${i + 1} 张: HTTP ${res.status}`);
558
+ continue;
559
+ }
560
+ const buffer = Buffer.from(await res.arrayBuffer());
561
+ const mediaType = detectMediaType(buffer) ?? toMediaType(res.headers.get('content-type'));
562
+ // 文件名带随机段: 工具标记为可并发, 同毫秒完成的两次调用不应互相覆盖
563
+ const fileName = `mockup-${stamp}-${runId}-${i + 1}.${extensionFor(mediaType)}`;
564
+ const store = storeOf(workspaceRoot);
565
+ await mkdir(store.imagesDir, { recursive: true });
566
+ await writeFile(resolve(store.imagesDir, fileName), buffer);
567
+ // 模型可见路径写真实物理位置(资产库绝对路径),避免 agent 去工作区找不到
568
+ const relPath = resolve(store.imagesDir, fileName);
569
+ let entry;
570
+ if (attachments !== undefined &&
571
+ maxImageBytes !== undefined &&
572
+ buffer.byteLength > maxImageBytes) {
573
+ // 超过会话附件上限: 图片仍落盘工作区, 但不进附件, 避免拖垮会话上下文
574
+ oversize += 1;
575
+ entry = {
576
+ path: relPath,
577
+ name: fileName,
578
+ width: 0,
579
+ height: 0,
580
+ attachmentId: '',
581
+ mediaType,
582
+ bytes: buffer.byteLength,
583
+ };
584
+ }
585
+ else if (attachments !== undefined) {
586
+ const ref = await attachments.saveImage({
587
+ data: new Uint8Array(buffer),
588
+ mediaType,
589
+ name: fileName,
590
+ });
591
+ entry = {
592
+ path: relPath,
593
+ name: ref.name ?? fileName,
594
+ width: ref.width,
595
+ height: ref.height,
596
+ attachmentId: ref.attachmentId,
597
+ mediaType: ref.mediaType,
598
+ bytes: ref.bytes,
599
+ };
600
+ }
601
+ else {
602
+ entry = {
603
+ path: relPath,
604
+ name: fileName,
605
+ width: 0,
606
+ height: 0,
607
+ attachmentId: '',
608
+ mediaType,
609
+ bytes: buffer.byteLength,
610
+ };
611
+ }
612
+ images.push(entry);
613
+ }
614
+ catch (error) {
615
+ // 中途取消如实上抛; 单张下载失败不丢弃其余已消耗配额的图片
616
+ if (exec.signal.aborted)
617
+ throw error;
618
+ failures.push(`第 ${i + 1} 张: ${error instanceof Error ? error.message : String(error)}`);
619
+ }
620
+ }
621
+ if (images.length === 0) {
622
+ return { ok: false, message: `生成成功但全部图片下载失败: ${failures.join('; ')}` };
623
+ }
624
+ // 登记本工作区根:此后该会话的卡片可凭 cwd 经图片路由回看生成图
625
+ knownRoots.add(workspaceRoot);
626
+ // 生成历史元数据:设置面板历史页的数据来源(M3 消费)。
627
+ // 写失败不阻断结果返回,但留 debug 日志:历史页缺记录时可据此排查。
628
+ const historyWrite = storeOf(workspaceRoot);
629
+ void appendFile(historyWrite.historyFile, `${JSON.stringify({
630
+ time: new Date().toISOString(),
631
+ files: images.map((image) => image.path),
632
+ description: args.description,
633
+ model: generated.model,
634
+ fidelity,
635
+ platform,
636
+ ...(spec.size !== undefined ? { size: spec.size } : {}),
637
+ status: isEdit ? 'edited' : 'generated',
638
+ })}\n`).catch((error) => {
639
+ ctx
640
+ .logger('ui-mockup')
641
+ .debug(`history.jsonl 写入失败: ${error instanceof Error ? error.message : String(error)}`);
642
+ });
643
+ const label = fidelity === 'wireframe' ? '线框图' : '高保真设计稿';
644
+ const paths = images.map((image) => image.path).join(', ');
645
+ let message = isEdit
646
+ ? `已用模型 ${generated.model} 按编辑指令在基准图上重绘, 生成 ${images.length} 张新版本。图片已保存到 ${paths}, 请在对话中查看并反馈。`
647
+ : `已用模型 ${generated.model} 生成 ${images.length} 张${label}。图片已保存到 ${paths}, 请在对话中查看并反馈; 需要修改时直接描述要改的地方。确认无误后我会将设计提炼为 design/spec.md 作为实现规格。`;
648
+ if (anchorInjected !== null) {
649
+ message += ` 已按风格锚点 ${anchorInjected} 自动注入参考图(可在设置 · UI 草图 · 生成历史中解除)。`;
650
+ }
651
+ if (anchorSkippedForModel !== null) {
652
+ message += ` 当前生效模型 ${anchorSkippedForModel} 不支持参考图(I2I), 已跳过风格锚点注入; 请改用 qwen-image 或 wan2.7-image 系列。`;
653
+ }
654
+ if (failures.length > 0) {
655
+ message += ` 注意: 有 ${failures.length} 张下载失败(${failures.join('; ')}), 其余图片已保留。`;
656
+ }
657
+ if (oversize > 0) {
658
+ message += ` 其中 ${oversize} 张超过会话附件大小上限, 仅保存到工作区, 未在对话中展示。`;
659
+ }
660
+ return { ok: true, message, images };
661
+ }
662
+ catch (error) {
663
+ if (error instanceof ImageProviderError) {
664
+ return { ok: false, message: `生成失败 [${error.code}]: ${error.message}` };
665
+ }
666
+ return {
667
+ ok: false,
668
+ message: `生成失败: ${error instanceof Error ? error.message : String(error)}`,
669
+ };
670
+ }
671
+ },
672
+ timeoutMs: 900_000,
673
+ isConcurrencySafe() {
674
+ return true;
675
+ },
676
+ /**
677
+ * 双通道展示的 host 半区投影:keyed toolview(client/index.ts)接管 web 渲染,
678
+ * 本 presentResult 产生的 resultView 会随会话日志持久化,供无 toolview
679
+ * 能力的 UI(terminal/精简客户端)在 live 与 replay 路径回退渲染,
680
+ * 删除它会让这些客户端退化为纯文本卡片 —— 两者是分工而非冗余。
681
+ */
682
+ presentResult(_args, result) {
683
+ const images = result.content.filter((block) => block.type === 'image');
684
+ return { card: 'generic', title: 'UI 草图', content: images };
685
+ },
686
+ }));
687
+ const systemPrompt = ctx.get('systemPrompt');
688
+ ctx.effect(() => systemPrompt.section(USAGE_SECTION));
689
+ // 图片路由:服务工作区 design/images/ 目录,供客户端卡片 <img> 内嵌展示。
690
+ // webServer 是 host-plane 服务,其就绪晚于 tools/systemPrompt(webServer 等待
691
+ // webStartup 提供)。若用 ctx.get 在 apply 时读取,会因未就绪返回 undefined
692
+ // 而漏注册。改用 ctx.inject 等 webServer 就绪后再注册,不阻塞工具注册。
693
+ ctx.inject(['webServer'], (scope) => {
694
+ const webServer = scope.get('webServer');
695
+ // effect 挂在 scope 而非外层 ctx:inject 回调在 webServer 服务变化时会
696
+ // unload 并 re-run,路由注册必须随本次回调的子 fiber 一起销毁,
697
+ // 否则 disposer 悬空、注册随重载次数累积。
698
+ scope.effect(() => webServer.register({
699
+ kind: 'prefix',
700
+ path: '/ui-mockup/images',
701
+ async handler(req, res) {
702
+ const url = new URL(req.url ?? '/', 'http://x');
703
+ const rawPath = url.pathname;
704
+ const prefix = '/ui-mockup/images/';
705
+ if (!rawPath.startsWith(prefix)) {
706
+ res.writeHead(400);
707
+ res.end();
708
+ return;
709
+ }
710
+ const name = decodeURIComponent(rawPath.slice(prefix.length));
711
+ // 只接受纯文件名(无路径分隔符),防路径逃逸;生成文件名恒为 mockup-*.ext
712
+ const fileName = basename(name);
713
+ if (fileName === '' || fileName !== name || fileName === '.' || fileName === '..') {
714
+ res.writeHead(400);
715
+ res.end();
716
+ return;
717
+ }
718
+ // 会话工作区由卡片经 query 传入(webServer 是进程级服务,无会话上下文)。
719
+ // 信任判定与空 cwd 回退与 RPC 端点共用 trustedRoot:非空 cwd 必须命中
720
+ // 信任源(execute 登记的根 / 已知会话 cwd),空 cwd 走「宿主会话 cwd →
721
+ // 最近登记根 → 进程根」回退链——旧实现独立回退进程根,缩略图因此 404。
722
+ const cwdParam = url.searchParams.get('cwd');
723
+ const rootOrError = trustedRoot(ctx, knownRoots, cwdParam ?? '');
724
+ if (!rootOrError.ok) {
725
+ res.writeHead(400);
726
+ res.end();
727
+ return;
728
+ }
729
+ const root = rootOrError.root;
730
+ try {
731
+ // 信任链按工作区根判定, 文件本体在资产库该工作区目录下
732
+ const buffer = await readFile(resolve(storeOf(root).imagesDir, fileName));
733
+ res.writeHead(200, {
734
+ 'Content-Type': mediaTypeForExtension(fileName),
735
+ 'Content-Length': buffer.byteLength,
736
+ 'Cache-Control': 'no-store',
737
+ });
738
+ res.end(buffer);
739
+ }
740
+ catch {
741
+ res.writeHead(404);
742
+ res.end();
743
+ }
744
+ },
745
+ }));
746
+ });
747
+ /**
748
+ * 设置面板数据通道:私有 RPC 频道,承载概览/历史/锚点/测试连接四类端点。
749
+ * 偏好读写不在此通道——客户端直接绑定同名 settings 命名空间镜像。
750
+ * connection 与 webServer 同为晚就绪服务,走相同的 ctx.inject 等待模式。
751
+ */
752
+ ctx.inject(['connection'], (scope) => {
753
+ const connection = scope.get('connection');
754
+ // 同图片路由:effect 挂 scope,随本轮回调的子 fiber 销毁,防止重载累积
755
+ scope.effect(() => connection.rpc.handle('/ui-mockup', async (endpoint, payload) => {
756
+ const body = (payload ?? {});
757
+ const cwd = typeof body.cwd === 'string' ? body.cwd : '';
758
+ switch (endpoint) {
759
+ case 'overview': {
760
+ const rootOrError = trustedRoot(ctx, knownRoots, cwd);
761
+ if (!rootOrError.ok)
762
+ return rootOrError.error;
763
+ const active = activeProviderOf(ctx);
764
+ return rpcOk({
765
+ provider: active.id,
766
+ credential: await credentialStatus(ctx, active.credential),
767
+ anchor: await readAnchor(rootOrError.root),
768
+ });
769
+ }
770
+ case 'credential/set': {
771
+ const credentials = ctx.get('credentials');
772
+ if (credentials === undefined) {
773
+ return rpcError('NOT_AVAILABLE', '凭据服务不可用:当前部署没有可写密钥存储,请改用环境变量或 .env。');
774
+ }
775
+ const value = typeof body.value === 'string' ? body.value.trim() : '';
776
+ if (value === '') {
777
+ return rpcError('INVALID_PARAMETER', '密钥不能为空;如需删除请用清除操作。');
778
+ }
779
+ try {
780
+ await credentials.set(credentialRef(activeProviderOf(ctx).credential), value);
781
+ }
782
+ catch (error) {
783
+ // 不原样透传上游异常文本:provider message 未来若夹带密钥片段会经
784
+ // RPC 抵达浏览器,「永不回值」承诺同样适用于错误通道;原文留服务端日志。
785
+ ctx
786
+ .logger('ui-mockup')
787
+ .debug(`凭据写入失败: ${error instanceof Error ? error.message : String(error)}`);
788
+ return rpcError('CREDENTIAL_WRITE_FAILED', '凭据写入失败:存储层拒绝了本次写入(可能被环境变量等只读来源遮蔽),详见宿主日志。');
789
+ }
790
+ // 写入成功后回安全视图(仅 configured/source/writable,永不回值)
791
+ return rpcOk({
792
+ credential: await credentialStatus(ctx, activeProviderOf(ctx).credential),
793
+ });
794
+ }
795
+ case 'credential/unset': {
796
+ const credentials = ctx.get('credentials');
797
+ if (credentials === undefined) {
798
+ return rpcError('NOT_AVAILABLE', '凭据服务不可用,无存储可清除。');
799
+ }
800
+ try {
801
+ await credentials.unset(credentialRef(activeProviderOf(ctx).credential));
802
+ }
803
+ catch (error) {
804
+ // 同 credential/set:不透传上游原文,防错误通道夹带密钥
805
+ ctx
806
+ .logger('ui-mockup')
807
+ .debug(`凭据清除失败: ${error instanceof Error ? error.message : String(error)}`);
808
+ return rpcError('CREDENTIAL_WRITE_FAILED', '凭据清除失败:存储层拒绝了本次操作,详见宿主日志。');
809
+ }
810
+ return rpcOk({
811
+ credential: await credentialStatus(ctx, activeProviderOf(ctx).credential),
812
+ });
813
+ }
814
+ case 'history/list': {
815
+ const rootOrError = trustedRoot(ctx, knownRoots, cwd);
816
+ if (!rootOrError.ok)
817
+ return rootOrError.error;
818
+ // 服务端分页:先按 query 过滤得到全量有序列表,再切片返回当前页,
819
+ // 附带 total 与锚点在过滤后列表中的索引(客户端据此提示锚点所在页)。
820
+ const all = filterHistory(await readHistory(rootOrError.root), typeof body.query === 'string' ? body.query : undefined);
821
+ const pageSize = clampPageSize(body.pageSize);
822
+ const totalPages = Math.max(1, Math.ceil(all.length / pageSize));
823
+ const page = clampPage(body.page, totalPages);
824
+ const anchorFile = await readAnchor(rootOrError.root, false);
825
+ const entries = all.slice((page - 1) * pageSize, page * pageSize);
826
+ const anchorIndex = anchorFile === null
827
+ ? -1
828
+ : all.findIndex((entry) => entry.files.some((file) => basename(file) === anchorFile));
829
+ return rpcOk({
830
+ anchorFile,
831
+ anchorIndex,
832
+ total: all.length,
833
+ page,
834
+ pageSize,
835
+ entries: entries.map((entry) => ({
836
+ ...entry,
837
+ anchored: anchorFile !== null &&
838
+ entry.files.some((file) => basename(file) === anchorFile),
839
+ })),
840
+ });
841
+ }
842
+ case 'history/clear': {
843
+ const rootOrError = trustedRoot(ctx, knownRoots, cwd);
844
+ if (!rootOrError.ok)
845
+ return rootOrError.error;
846
+ const store = storeOf(rootOrError.root);
847
+ // 目录可能尚不存在(从未生成过就点清空),先建目录再原子截断
848
+ await mkdir(store.root, { recursive: true });
849
+ await writeFileAtomic(store.historyFile, '');
850
+ // 清空历史后锚点记录指向的行不复存在,按规格一并解除
851
+ await clearAnchor(rootOrError.root);
852
+ return rpcOk({});
853
+ }
854
+ case 'anchor/set': {
855
+ const rootOrError = trustedRoot(ctx, knownRoots, cwd);
856
+ if (!rootOrError.ok)
857
+ return rootOrError.error;
858
+ const file = sanitizeAnchorFileName(body.file);
859
+ if (file === null)
860
+ return rpcError('INVALID_PARAMETER', `不是合法的生成图文件名: ${String(body.file)}`);
861
+ try {
862
+ await access(resolve(storeOf(rootOrError.root).imagesDir, file));
863
+ }
864
+ catch {
865
+ return rpcError('NOT_FOUND', `工作区中没有这张生成图: ${file}`);
866
+ }
867
+ await writeAnchor(rootOrError.root, file);
868
+ return rpcOk({ anchorFile: file });
869
+ }
870
+ case 'anchor/unset': {
871
+ const rootOrError = trustedRoot(ctx, knownRoots, cwd);
872
+ if (!rootOrError.ok)
873
+ return rootOrError.error;
874
+ await clearAnchor(rootOrError.root);
875
+ return rpcOk({});
876
+ }
877
+ case 'provider/status': {
878
+ // 面板「当前生效提供方」的唯一事实源:读 image 槽位的 providerId,
879
+ // 不从偏好或组合文件推断,避免面板与实际挂载漂移。
880
+ const service = ctx.get('image');
881
+ return rpcOk({
882
+ active: service?.providerId ?? 'unknown',
883
+ });
884
+ }
885
+ case 'provider/switch': {
886
+ // 一键切换:改写 DSH home 用户层 patch($DSH_HOME/cordis.patch.yml),
887
+ // launcher 的 HMR watcher 监听该文件并事务性重放组合,image 槽位
888
+ // 随之热替换(旧 Provider dispose、新 Provider 加载),无需重启。
889
+ // 只写 id + disabled 两行,不触碰用户层中任何其他内容。
890
+ const provider = body.provider;
891
+ if (provider !== 'dashscope' && provider !== 'volcengine') {
892
+ return rpcError('INVALID_PARAMETER', 'provider 必须是 "dashscope" 或 "volcengine"。');
893
+ }
894
+ const service = ctx.get('image');
895
+ if (service?.providerId === provider) {
896
+ return rpcOk({ active: provider });
897
+ }
898
+ const patchFile = join(dshHome(), HOME_PATCH_FILENAME);
899
+ // catch 的各分支要么赋值要么 return,TS 可判定赋值完毕
900
+ let patches;
901
+ try {
902
+ const content = await readFile(patchFile, 'utf8');
903
+ const parsed = loadYaml(content);
904
+ // 合法 YAML 但非数组同样是坏 patch(宿主会 fail-loud)——
905
+ // 抛进 catch 统一走中止分支,绝不能用空数组覆盖用户文件
906
+ if (Array.isArray(parsed))
907
+ patches = parsed;
908
+ else
909
+ throw new Error('patch file is not a top-level array');
910
+ }
911
+ catch {
912
+ // 文件不存在 = 空用户层;存在但不可解析时不要吞掉用户的错误——
913
+ // 直接中止切换并报告,绝不能用空数组覆盖用户写好的 patch。
914
+ let content;
915
+ try {
916
+ content = await readFile(patchFile, 'utf8');
917
+ }
918
+ catch {
919
+ content = undefined;
920
+ }
921
+ if (content === undefined) {
922
+ patches = [];
923
+ }
924
+ else if (content.includes('!!js')) {
925
+ // 宿主 patch schema 允许 !!js 表达式,js-yaml 默认 schema 不认;
926
+ // 代写会把表达式物化成字面值,只能请用户手工翻转
927
+ return rpcError('PROVIDER_SWITCH_FAILED', `用户层 patch 文件 ${patchFile} 含 !!js 表达式(宿主专有语法,本插件无法安全代写);请手工翻转两行 Provider 的 disabled 后再试。`);
928
+ }
929
+ else {
930
+ return rpcError('PROVIDER_SWITCH_FAILED', `用户层 patch 文件 ${patchFile} 不是合法的 YAML 数组,请先手工修复后再切换。`);
931
+ }
932
+ }
933
+ const merged = mergeProviderSwitchRows(patches, provider);
934
+ // 临时文件 + rename 原子写:watcher 读到半截文件会误判为坏 patch。
935
+ // js-yaml dump 无法保留原文件注释,头部注明代写来源与这一限制。
936
+ const tmpFile = `${patchFile}.${randomUUID().slice(0, 8)}.tmp`;
937
+ try {
938
+ await writeFile(tmpFile, '# 本文件由 ui-mockup 提供方切换代写(js-yaml 往返不保留原注释)\n' +
939
+ dumpYaml(merged, { lineWidth: -1 }), 'utf8');
940
+ await rename(tmpFile, patchFile);
941
+ }
942
+ catch (error) {
943
+ await rm(tmpFile, { force: true }).catch(() => { });
944
+ return rpcError('PROVIDER_SWITCH_FAILED', `写入 ${patchFile} 失败: ${error instanceof Error ? error.message : String(error)}`);
945
+ }
946
+ // 等热重载落位:轮询 image 槽位直到目标 providerId 或超时
947
+ const deadline = Date.now() + 8_000;
948
+ while (Date.now() < deadline) {
949
+ await sleep(250);
950
+ const current = ctx.get('image')
951
+ ?.providerId;
952
+ if (current === provider)
953
+ return rpcOk({ active: current });
954
+ // 热重载中槽位可能短暂为 undefined(旧实例已卸、新实例未就绪),继续等
955
+ }
956
+ const current = ctx.get('image')
957
+ ?.providerId;
958
+ return rpcOk({
959
+ active: current ?? 'unknown',
960
+ pending: true,
961
+ });
962
+ }
963
+ case 'test-connection': {
964
+ // 只回机器可判的 reason + 原始 detail;用户可见文案由客户端按语言渲染。
965
+ // 探测参数随生效提供方分流(网关、凭据引用、探测路径都不同)。
966
+ const service = ctx.get('image');
967
+ const probe = service?.providerId === 'volcengine' ? PROBES.volcengine : PROBES.dashscope;
968
+ const credentialName = readProviderConfigString(service, 'apiKey') ?? probe.defaultCredential;
969
+ const ref = credentialRef(credentialName);
970
+ // 取值仅用于探测请求的 Authorization 头,永不进入任何响应
971
+ let apiKey;
972
+ const credentials = ctx.get('credentials');
973
+ if (credentials !== undefined) {
974
+ const hit = await credentials.resolve(ref).catch(() => undefined);
975
+ apiKey = hit?.value;
976
+ }
977
+ if (apiKey === undefined || apiKey === '') {
978
+ apiKey = launchEnvironmentOf(ctx).get(ref)?.value;
979
+ }
980
+ if (apiKey === undefined || apiKey === '') {
981
+ return rpcOk({ ok: false, reason: 'missing-key' });
982
+ }
983
+ const baseUrl = readProviderConfigString(service, 'baseUrl') ?? probe.defaultBaseUrl;
984
+ try {
985
+ // 鉴权探测:向图像生成端点发空体 POST(不消耗生成配额)。
986
+ // 两家网关都是鉴权先于参数校验:无效 key → 401;
987
+ // 有效 key → 400 参数错误;429 限流也说明鉴权已通过。
988
+ const res = await fetch(baseUrl + probe.path, {
989
+ method: 'POST',
990
+ headers: {
991
+ Authorization: `Bearer ${apiKey}`,
992
+ 'content-type': 'application/json',
993
+ },
994
+ body: '{}',
995
+ signal: AbortSignal.timeout(8_000),
996
+ redirect: 'error',
997
+ });
998
+ const respBody = (await res.json().catch(() => ({})));
999
+ const code = typeof respBody.code === 'string' ? respBody.code : '';
1000
+ if (res.status === 401 ||
1001
+ code.includes('InvalidApiKey') ||
1002
+ code.includes('Unauthorized') ||
1003
+ code.includes('AuthenticationError')) {
1004
+ return rpcOk({ ok: false, reason: 'invalid-key' });
1005
+ }
1006
+ // 正向判定鉴权已通过:网关鉴权先于参数与配额校验——
1007
+ // 400 参数错误、429 限流都说明 key 已被网关接受。
1008
+ // 其余未知响应(如未来新增的 403 或前置校验码)归入 unknown 灰态,
1009
+ // 宁可不确定也不误报「连接正常」。
1010
+ if (res.status === 400 || res.status === 429) {
1011
+ return rpcOk({ ok: true, reason: 'ok' });
1012
+ }
1013
+ return rpcOk({
1014
+ ok: false,
1015
+ reason: 'unknown',
1016
+ detail: `HTTP ${res.status}${code !== '' ? ` ${code}` : ''}`,
1017
+ });
1018
+ }
1019
+ catch (error) {
1020
+ return rpcOk({
1021
+ ok: false,
1022
+ reason: 'gateway',
1023
+ detail: error instanceof Error ? error.message : String(error),
1024
+ });
1025
+ }
1026
+ }
1027
+ default:
1028
+ return rpcError('NOT_FOUND', `未知端点: ${endpoint}`);
1029
+ }
1030
+ // 类型穷尽保底:所有分支均已 return,执行不会到达此处
1031
+ throw new Error('unreachable');
1032
+ }, { authority: 'trusted-host' }));
1033
+ });
1034
+ }
1035
+ /**
1036
+ * 设置面板端点的 cwd 信任检查:与图片路由同一信任源全集
1037
+ * (execute 登记的根 / 已知会话 cwd / 宿主进程 fallback)。
1038
+ * 无 cwd 时回退宿主进程级配置根(可信),与图片路由口径一致。
1039
+ */
1040
+ function trustedRoot(ctx, known, cwd) {
1041
+ if (cwd !== '') {
1042
+ const canonical = canonicalRoot(cwd);
1043
+ if (!allowedRoots(ctx, known).has(canonical)) {
1044
+ return { ok: false, error: rpcError('UNTRUSTED_WORKSPACE', '请求的工作区不在信任源内') };
1045
+ }
1046
+ return { ok: true, root: canonical };
1047
+ }
1048
+ // 空 cwd 的回退顺序:宿主已知会话的 cwd(去重后唯一即采用——数据源头,
1049
+ // 与 allowedRoots 同源;单工作区场景直接命中)→ 本插件登记过的最近工作区根
1050
+ // (进程内 execute 插入序)→ sandboxPolicy 进程根(最后的最后)。
1051
+ // 旧实现直接退进程根:DSH 不从项目目录启动时指向无关目录,面板读到空 store。
1052
+ const sessions = ctx.get('sessions');
1053
+ if (sessions !== undefined) {
1054
+ const cwds = [
1055
+ ...new Set(sessions
1056
+ .list()
1057
+ .map((session) => session.header.cwd)
1058
+ .filter((value) => value !== undefined && value !== '')),
1059
+ ];
1060
+ if (cwds.length === 1)
1061
+ return { ok: true, root: canonicalRoot(cwds[0]) };
1062
+ if (known.size > 0) {
1063
+ const latest = [...known].at(-1);
1064
+ return { ok: true, root: canonicalRoot(latest) };
1065
+ }
1066
+ }
1067
+ const policy = ctx.get('sandboxPolicy');
1068
+ return { ok: true, root: canonicalRoot(policy?.workspaceRoot ?? '.') };
1069
+ }
1070
+ /**
1071
+ * 各提供方的鉴权探测参数:网关、凭据引用名与探测路径。
1072
+ * 默认值兜底 Provider config 缺失(服务未挂载)的场景;生效 Provider 的
1073
+ * config.apiKey / config.baseUrl 可被用户改写,优先于默认值。
1074
+ */
1075
+ const PROBES = {
1076
+ dashscope: {
1077
+ defaultCredential: 'DASHSCOPE_API_KEY',
1078
+ defaultBaseUrl: 'https://dashscope.aliyuncs.com',
1079
+ path: '/api/v1/services/aigc/image-generation/generation',
1080
+ },
1081
+ volcengine: {
1082
+ defaultCredential: 'ARK_API_KEY',
1083
+ defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
1084
+ path: '/images/generations',
1085
+ },
1086
+ };
1087
+ /** 从生效 Provider 的 config 读字符串字段;服务缺失或字段非字符串时返回 undefined。 */
1088
+ function readProviderConfigString(service, key) {
1089
+ if (service === undefined)
1090
+ return undefined;
1091
+ const value = service.config?.[key];
1092
+ return typeof value === 'string' && value !== '' ? value : undefined;
1093
+ }
1094
+ /** DSH home 用户层 patch 文件(launcher 实时 watch,编辑后组合热重载)。 */
1095
+ export const HOME_PATCH_FILENAME = 'cordis.patch.yml';
1096
+ /**
1097
+ * 生成切换提供方后的用户层 patch 行:对两行 Provider 做 id 定向 disabled 翻转。
1098
+ * 用户层 applied after bundle layers——bundle 插入的行由此覆盖 enabled 状态,
1099
+ * 且只携带 id/disabled 两个字段,不触碰用户可能写在同 id 行上的其他定制;
1100
+ * 已有行原位更新(保留其余字段),缺失行追加在列表尾部。
1101
+ * 纯函数:宿主端点与单测共用同一合并语义。
1102
+ */
1103
+ export function mergeProviderSwitchRows(patches, target) {
1104
+ const rows = [
1105
+ { id: 'image-dashscope', disabled: target !== 'dashscope' },
1106
+ { id: 'image-volcengine', disabled: target !== 'volcengine' },
1107
+ ];
1108
+ const merged = patches.filter((entry) => entry !== null && typeof entry === 'object');
1109
+ for (const row of rows) {
1110
+ const index = merged.findIndex((entry) => entry['id'] === row.id);
1111
+ if (index >= 0)
1112
+ merged[index] = { ...merged[index], disabled: row.disabled };
1113
+ else
1114
+ merged.push(row);
1115
+ }
1116
+ return merged;
1117
+ }
1118
+ /** 供单元测试引用的内部实现。 */
1119
+ export { buildPrompt, dshHome };
1120
+ export { sanitizeOutputDir, clampCount, parseHistoryLine, filterHistory, DEFAULT_PREFS, } from './prefs.js';
1121
+ //# sourceMappingURL=index.js.map