@changfenhuang/dsh-genui 0.11.4 → 0.12.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -4
  3. package/README.zh-CN.md +12 -4
  4. package/SKILL.md +2 -2
  5. package/lib/assets/mermaid.js +325 -325
  6. package/lib/assets/standalone-runtime.js +9 -9
  7. package/lib/client.js +30 -30
  8. package/lib/index.js +101 -14
  9. package/lib/types/client/dom-fence.d.ts +2 -1
  10. package/lib/types/client/file-address.d.ts +4 -0
  11. package/lib/types/client/file-link-context.d.ts +41 -0
  12. package/lib/types/client/file-link.d.ts +17 -0
  13. package/lib/types/client/i18n/en.d.ts +5 -1
  14. package/lib/types/client/inline.d.ts +1 -1
  15. package/lib/types/client/mermaid-colors.d.ts +2 -0
  16. package/lib/types/client/panel.d.ts +6 -1
  17. package/lib/types/plugin/fence-feedback.d.ts +6 -2
  18. package/lib/types/plugin/index.d.ts +4 -0
  19. package/lib/types/shared/fence-repair.d.ts +1 -0
  20. package/package.json +1 -12
  21. package/src/client/GenuiBlock.module.css +35 -3
  22. package/src/client/artifact/ArtifactExport.module.css +34 -3
  23. package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
  24. package/src/client/blocks/charts.tsx +22 -6
  25. package/src/client/blocks/render-node.tsx +10 -7
  26. package/src/client/dom-fence.tsx +97 -30
  27. package/src/client/file-address.ts +37 -0
  28. package/src/client/file-link-context.tsx +114 -0
  29. package/src/client/file-link.ts +46 -0
  30. package/src/client/guard.ts +5 -2
  31. package/src/client/i18n/en.ts +5 -1
  32. package/src/client/i18n/zh.ts +5 -1
  33. package/src/client/index.tsx +23 -5
  34. package/src/client/inline.ts +17 -6
  35. package/src/client/mermaid-colors.ts +39 -0
  36. package/src/client/mermaid-core.ts +7 -3
  37. package/src/client/panel.tsx +18 -5
  38. package/src/plugin/fence-feedback.ts +17 -5
  39. package/src/plugin/index.ts +14 -4
  40. package/src/plugin/tool.ts +8 -2
  41. package/src/shared/fence-repair.ts +36 -0
  42. package/tsdown.config.ts +3 -7
  43. package/lib/invariant.js +0 -19
  44. package/lib/types/plugin/invariant.d.ts +0 -15
  45. package/src/plugin/invariant.ts +0 -30
package/lib/index.js CHANGED
@@ -1547,6 +1547,46 @@ function opt(key, value) {
1547
1547
  return value === void 0 ? {} : { [key]: value };
1548
1548
  }
1549
1549
  //#endregion
1550
+ //#region src/client/file-link.ts
1551
+ /** Decode a local file destination and its optional first line fragment. */
1552
+ function parseFileLink(value) {
1553
+ if (value.length > 2048) return void 0;
1554
+ const hash = value.indexOf("#");
1555
+ const destination = hash < 0 ? value : value.slice(0, hash);
1556
+ if (destination.includes("?")) return void 0;
1557
+ let path;
1558
+ try {
1559
+ path = decodeURIComponent(destination);
1560
+ } catch {
1561
+ return;
1562
+ }
1563
+ if (path === "" || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path) || /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path)) return void 0;
1564
+ if (hash < 0) return { path };
1565
+ const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(value.slice(hash + 1));
1566
+ if (match === null) return void 0;
1567
+ const line = Number(match[1]);
1568
+ const end = match[2] === void 0 ? line : Number(match[2]);
1569
+ if (!Number.isSafeInteger(line) || !Number.isSafeInteger(end) || end < line) return void 0;
1570
+ return {
1571
+ path,
1572
+ line
1573
+ };
1574
+ }
1575
+ /** Classify a link destination while preserving the existing external URL policy. */
1576
+ function classifyLinkTarget(value) {
1577
+ const external = safeHref(value);
1578
+ if (external !== void 0) return {
1579
+ type: "external",
1580
+ href: external
1581
+ };
1582
+ if (typeof value !== "string") return { type: "invalid" };
1583
+ const file = parseFileLink(value.trim());
1584
+ return file === void 0 ? { type: "invalid" } : {
1585
+ type: "file",
1586
+ file
1587
+ };
1588
+ }
1589
+ //#endregion
1550
1590
  //#region src/client/guard.ts
1551
1591
  function fieldKindMatches(value, kind) {
1552
1592
  switch (kind) {
@@ -1791,10 +1831,11 @@ function repairNodeFields(value, ctx, depth) {
1791
1831
  case "link": {
1792
1832
  const label = str(v.label, GENUI_LIMITS.maxString);
1793
1833
  if (label === void 0) return null;
1834
+ const target = classifyLinkTarget(v.href);
1794
1835
  return {
1795
1836
  type: "link",
1796
1837
  label,
1797
- ...opt("href", safeHref(v.href))
1838
+ ...opt("href", target.type === "external" ? target.href : target.type === "file" ? String(v.href).trim() : void 0)
1798
1839
  };
1799
1840
  }
1800
1841
  case "image": {
@@ -3270,6 +3311,7 @@ function validateNode(value, depth, at, errors, walk) {
3270
3311
  * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
3271
3312
  * fail near that quote with "Expected ',' or ']'...".
3272
3313
  * 2. Trailing commas before `}` / `]` or at end of input.
3314
+ * 3. Valueless object keys immediately before a complete key-and-colon pair.
3273
3315
  *
3274
3316
  * A shared grammar-aware scan distinguishes object keys from values and
3275
3317
  * checks the continuation after a potential string terminator. Ambiguous
@@ -3292,6 +3334,33 @@ const MAX_QUOTE_LOOKAHEAD = 4096;
3292
3334
  function isJsonSpace(ch) {
3293
3335
  return ch === " " || ch === " " || ch === "\n" || ch === "\r";
3294
3336
  }
3337
+ /** Skip only complete, valueless object keys followed by a valued member. */
3338
+ function skipValuelessKeys(raw, start, limit = raw.length) {
3339
+ let cursor = start;
3340
+ let repairs = 0;
3341
+ while (cursor < limit && raw[cursor] === "\"") {
3342
+ const keyStart = cursor++;
3343
+ while (cursor < limit && raw[cursor] !== "\"") if (raw[cursor] === "\\") cursor += 2;
3344
+ else cursor++;
3345
+ if (cursor >= limit) return null;
3346
+ cursor++;
3347
+ try {
3348
+ JSON.parse(raw.slice(keyStart, cursor));
3349
+ } catch {
3350
+ return null;
3351
+ }
3352
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3353
+ if (raw[cursor] === ":") return repairs > 0 ? {
3354
+ start: keyStart,
3355
+ repairs
3356
+ } : null;
3357
+ if (raw[cursor] !== ",") return null;
3358
+ repairs++;
3359
+ cursor++;
3360
+ while (cursor < limit && isJsonSpace(raw[cursor])) cursor++;
3361
+ }
3362
+ return null;
3363
+ }
3295
3364
  /**
3296
3365
  * Quote closure is contextual: only keys may be followed by `:`, and a comma
3297
3366
  * must introduce the next member of the enclosing object/array. Walk closing
@@ -3330,6 +3399,8 @@ function quoteCanClose(raw, index, key, scopes, complete) {
3330
3399
  if (raw[cursor] === scope.closer) return true;
3331
3400
  if (scope.closer === "]") return /["[{tfn\-0-9]/.test(raw[cursor]);
3332
3401
  if (raw[cursor] !== "\"") return false;
3402
+ const valueless = skipValuelessKeys(raw, cursor, limit);
3403
+ if (valueless !== null) cursor = valueless.start;
3333
3404
  cursor++;
3334
3405
  while (cursor < limit) {
3335
3406
  if (raw[cursor] === "\\") {
@@ -3406,6 +3477,14 @@ function scanFenceCandidate(raw, complete, contentQuotes) {
3406
3477
  }
3407
3478
  if (ch === "\"") {
3408
3479
  key = scopes[scopes.length - 1]?.expecting === "key";
3480
+ if (key) {
3481
+ const valueless = skipValuelessKeys(raw, i);
3482
+ if (valueless !== null) {
3483
+ repairs += valueless.repairs;
3484
+ i = valueless.start - 1;
3485
+ continue;
3486
+ }
3487
+ }
3409
3488
  interiorQuote = false;
3410
3489
  inString = true;
3411
3490
  out += ch;
@@ -4132,6 +4211,7 @@ function formatProcessWarnings(processed) {
4132
4211
  const canonicalPath = `${separator < 0 ? "" : warning.path.slice(0, separator + 1)}${warning.canonical}`;
4133
4212
  return warning.message.includes("ignored") ? `warning=alias_ignored path=${warning.path} canonical=${canonicalPath}` : `warning=alias_normalized path=${warning.path} canonical=${canonicalPath}`;
4134
4213
  }
4214
+ if (warning.kind === "unknown-field") return `warning=unknown_field path=${JSON.stringify(warning.path)} detail=${JSON.stringify(warning.message)}`;
4135
4215
  return `warning=process detail=${JSON.stringify(warning.message)}`;
4136
4216
  });
4137
4217
  }
@@ -4226,7 +4306,7 @@ function createRenderUiTool() {
4226
4306
  * before emitting — turning "render a red banner after the fact" into
4227
4307
  * "verify before you send". Purely local: no LLM, no network, no DOM.
4228
4308
  */
4229
- const VALIDATE_DESCRIPTION = "Validate the JSON body of a ```dsh-ui fence BEFORE emitting it — use for non-trivial specs (≥3 nodes or containing a table); skip for trivial ones (≤2 nodes). Pass the exact JSON text you are about to put inside the fence as the \"spec\" argument (a string). Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. A warning=block_markdown requires the indicated replacement node and another validation call.";
4309
+ const VALIDATE_DESCRIPTION = "Validate the JSON body of a ```dsh-ui fence BEFORE emitting it — use for non-trivial specs (≥3 nodes or containing a table); skip for trivial ones (≤2 nodes). Pass the exact JSON text you are about to put inside the fence as the \"spec\" argument (a string). Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. A warning=unknown_field requires fixing the indicated path and another validation call. A warning=block_markdown requires the indicated replacement node and another validation call.";
4230
4310
  const VALIDATE_PARAMETERS = {
4231
4311
  type: "object",
4232
4312
  properties: { spec: {
@@ -4342,6 +4422,7 @@ function createValidateDshUiTool() {
4342
4422
  if (processed.spec !== null && processed.errors.length === 0) {
4343
4423
  const warnings = formatProcessWarnings(processed);
4344
4424
  const inlineWarnings = formatInlineContentWarnings(processed.spec);
4425
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some((warning) => warning.kind === "unknown-field");
4345
4426
  return `${validationProtocol([
4346
4427
  "status=invalid",
4347
4428
  "error=invalid_json",
@@ -4351,7 +4432,7 @@ function createValidateDshUiTool() {
4351
4432
  ...inlineWarnings,
4352
4433
  "repair=applied",
4353
4434
  `repair_count=${repaired.repairs}`,
4354
- inlineWarnings.length === 0 ? "next=emit_repaired_fence" : "next=fix_and_revalidate"
4435
+ requiresCorrection ? "next=fix_and_revalidate" : "next=emit_repaired_fence"
4355
4436
  ])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``;
4356
4437
  }
4357
4438
  }
@@ -4383,12 +4464,13 @@ function createValidateDshUiTool() {
4383
4464
  }
4384
4465
  const warnings = formatProcessWarnings(processed);
4385
4466
  const inlineWarnings = formatInlineContentWarnings(processed.spec);
4467
+ const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some((warning) => warning.kind === "unknown-field");
4386
4468
  return validationProtocol([
4387
4469
  "status=valid",
4388
4470
  `rendered=${processed.renderedCount}`,
4389
4471
  ...warnings,
4390
4472
  ...inlineWarnings,
4391
- inlineWarnings.length === 0 ? "next=emit_fence" : "next=fix_and_revalidate"
4473
+ requiresCorrection ? "next=fix_and_revalidate" : "next=emit_fence"
4392
4474
  ]);
4393
4475
  },
4394
4476
  presentCall() {
@@ -4631,9 +4713,9 @@ function fenceFailures(text) {
4631
4713
  }
4632
4714
  /** `null` when this fence renders; otherwise the reason it does not. */
4633
4715
  function fenceFailureDetail(fence) {
4634
- if (!fence.closed) return "error=unterminated_fence\nrequired=closing_fence";
4635
4716
  const resolution = resolveFence(fence.raw, { settled: true });
4636
4717
  if (resolution.spec !== null) return null;
4718
+ if (!fence.closed) return "error=unterminated_fence\nrequired=closing_fence";
4637
4719
  if (resolution.processed !== null) return droppedNodeFailure(resolution.processed, resolution.value)?.join("\n") ?? ["error=invalid_spec", ...resolution.processed.errors.map((error) => `diagnostic=${JSON.stringify(error)}`)].join("\n");
4638
4720
  return "error=invalid_json\nrepair=failed";
4639
4721
  }
@@ -4710,7 +4792,7 @@ function planFenceFeedback(input) {
4710
4792
  if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null;
4711
4793
  if (input.deliveryRemindedTurns?.has(input.turn) === true) return null;
4712
4794
  return {
4713
- text: missingBodyCorrectionText(input.turn, used + 1),
4795
+ text: missingBodyCorrectionText(input.turn, used + 1, input.renderUiToolEnabled ?? true),
4714
4796
  fingerprints: [],
4715
4797
  turn: input.turn,
4716
4798
  kind: "delivery"
@@ -4722,10 +4804,11 @@ function planFenceFeedback(input) {
4722
4804
  *
4723
4805
  * @param turn - turn that reached the boundary.
4724
4806
  * @param attempt - correction number within the shared turn budget.
4807
+ * @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
4725
4808
  * @returns the message text to steer into the running turn.
4726
4809
  */
4727
- function missingBodyCorrectionText(turn, attempt = 1) {
4728
- return `${`${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`}本轮尚未产生正式回答,也没有通过支持的通道交付结果${attempt <= 1 ? "" : `(第 ${attempt} 次提醒)`}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`;
4810
+ function missingBodyCorrectionText(turn, attempt = 1, renderUiToolEnabled = true) {
4811
+ return `${`${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`}本轮尚未产生正式回答,也没有通过支持的通道交付结果${attempt <= 1 ? "" : `(第 ${attempt} 次提醒)`}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏${renderUiToolEnabled ? ",或明确调用 render_ui" : ""}。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`;
4729
4812
  }
4730
4813
  /**
4731
4814
  * 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
@@ -4861,8 +4944,9 @@ function isFeedbackSource(source) {
4861
4944
  *
4862
4945
  * @param ctx - 宿主 Context。
4863
4946
  * @param enabled - 是否启用同回合围栏修正。
4947
+ * @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
4864
4948
  */
4865
- function installFenceFeedback(ctx, enabled) {
4949
+ function installFenceFeedback(ctx, enabled, renderUiToolEnabled = true) {
4866
4950
  const sessions = /* @__PURE__ */ new Map();
4867
4951
  const stateOf = (sessionId) => {
4868
4952
  let state = sessions.get(sessionId);
@@ -4992,7 +5076,8 @@ function installFenceFeedback(ctx, enabled) {
4992
5076
  deliveredThisTurn: state.deliveredThisTurn,
4993
5077
  aborted: signal.aborted,
4994
5078
  correctionsThisTurn: usedThisTurn,
4995
- correctionsTurn: state.correctionsTurn
5079
+ correctionsTurn: state.correctionsTurn,
5080
+ renderUiToolEnabled
4996
5081
  });
4997
5082
  if (plan === null) return;
4998
5083
  for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint);
@@ -5064,6 +5149,7 @@ async function serveGenuiAsset(req, res) {
5064
5149
  res.end();
5065
5150
  }
5066
5151
  }
5152
+ const RENDER_UI_TOOL_GUIDANCE = "- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。";
5067
5153
  /** The fence language description injected into every assembled system prompt.
5068
5154
  * Deliberately slim: the `genui` skill carries the full component→field
5069
5155
  * mapping; this section keeps only the contract that must always be
@@ -5104,7 +5190,7 @@ Rules:
5104
5190
  - Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
5105
5191
  - 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
5106
5192
  - Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
5107
- - Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。
5193
+ ${RENDER_UI_TOOL_GUIDANCE}
5108
5194
  - 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
5109
5195
  - Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`;
5110
5196
  /**
@@ -5159,15 +5245,16 @@ function bundledSkillProvider() {
5159
5245
  };
5160
5246
  }
5161
5247
  function apply(ctx, config) {
5248
+ const renderUiToolEnabled = config?.renderUiTool !== false;
5162
5249
  ctx.systemPrompt.section({
5163
5250
  name: "genui:fence",
5164
5251
  order: ctx.systemPrompt.getSectionOrder("STRUCTURED_OUTPUT"),
5165
- text: GENUI_SECTION_TEXT
5252
+ text: renderUiToolEnabled ? GENUI_SECTION_TEXT : GENUI_SECTION_TEXT.replace(`${RENDER_UI_TOOL_GUIDANCE}\n`, "")
5166
5253
  });
5167
- installFenceFeedback(ctx, config?.fenceFeedback !== false);
5254
+ installFenceFeedback(ctx, config?.fenceFeedback !== false, renderUiToolEnabled);
5168
5255
  ctx.inject(["tools"], (toolsCtx) => {
5169
5256
  toolsCtx.effect(function* () {
5170
- yield toolsCtx.tools.register(createRenderUiTool());
5257
+ if (renderUiToolEnabled) yield toolsCtx.tools.register(createRenderUiTool());
5171
5258
  yield toolsCtx.tools.register(createValidateDshUiTool());
5172
5259
  }, "dsh-genui: model tools");
5173
5260
  });
@@ -1,6 +1,7 @@
1
1
  import { type Root } from 'react-dom/client';
2
2
  import type { Context } from '@deepseek-ai/cordis';
3
3
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
4
+ import { type FileLinkAvailability } from './file-link-context.tsx';
4
5
  /** Override the React root factory (tests / tuning). */
5
6
  export declare function setDomRootFactory(factory: (container: HTMLElement) => Root): void;
6
7
  /**
@@ -27,4 +28,4 @@ export declare function hostFenceIndexOf(row: Element, block: Element): number;
27
28
  * @param sendAction - plugin-owned relay: (sessionId, action, payload) → the
28
29
  * scoped conversation send carrying the `[genui-action]` prompt.
29
30
  */
30
- export declare function installDomFenceRenderer(ctx: Context, sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void): () => void;
31
+ export declare function installDomFenceRenderer(ctx: Context, sendAction: (sessionId: SessionId, action: string, payload: Record<string, unknown>) => void, fileLinkAvailability?: FileLinkAvailability): () => void;
@@ -0,0 +1,4 @@
1
+ /** Build a session resource address while preserving encoded path segments. */
2
+ export declare function sessionFileAddress(sessionId: string, path: string): string;
3
+ /** Resolve workspace absolute paths to session-relative resource addresses. */
4
+ export declare function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string;
@@ -0,0 +1,41 @@
1
+ import { type ReactNode } from 'react';
2
+ interface SidebarRightService {
3
+ openResource(address: string, options?: {
4
+ params: {
5
+ line: number;
6
+ };
7
+ }): void;
8
+ }
9
+ interface FileLinkHostContext {
10
+ get(name: 'sidebarRight', strict?: false): SidebarRightService | undefined;
11
+ sessions: {
12
+ list: {
13
+ getSnapshot(): unknown;
14
+ };
15
+ };
16
+ }
17
+ interface FileLinkScope {
18
+ get(name: 'sidebarRight'): SidebarRightService | undefined;
19
+ effect(callback: () => void | (() => void), name?: string): unknown;
20
+ }
21
+ export interface FileLinkCordisContext extends FileLinkHostContext {
22
+ inject(services: ['sidebarRight'], callback: (scope: FileLinkScope) => void): unknown;
23
+ }
24
+ export interface FileLinkAvailability {
25
+ subscribe(listener: () => void): () => void;
26
+ getSnapshot(): boolean;
27
+ }
28
+ /** Track an optional host service through arrival, removal, and plugin unload. */
29
+ export declare function createFileLinkAvailability(ctx: FileLinkCordisContext): FileLinkAvailability;
30
+ /** Render a file link as an accessible host navigation button when available. */
31
+ export declare function FileLink({ path, line, label, children }: {
32
+ path: string;
33
+ line?: number;
34
+ label: string;
35
+ children?: ReactNode;
36
+ }): import("react").DetailedReactHTMLElement<{
37
+ title: string;
38
+ }, HTMLElement>;
39
+ /** Add session-scoped file navigation to one fence render tree. */
40
+ export declare function withFileLinkContext(ctx: FileLinkHostContext, availability: FileLinkAvailability, sessionId: string | undefined, streaming: boolean, children: ReactNode): ReactNode;
41
+ export {};
@@ -0,0 +1,17 @@
1
+ export interface ParsedFileLink {
2
+ path: string;
3
+ line?: number;
4
+ }
5
+ export type LinkTarget = {
6
+ type: 'external';
7
+ href: string;
8
+ } | {
9
+ type: 'file';
10
+ file: ParsedFileLink;
11
+ } | {
12
+ type: 'invalid';
13
+ };
14
+ /** Decode a local file destination and its optional first line fragment. */
15
+ export declare function parseFileLink(value: string): ParsedFileLink | undefined;
16
+ /** Classify a link destination while preserving the existing external URL policy. */
17
+ export declare function classifyLinkTarget(value: unknown): LinkTarget;
@@ -262,6 +262,10 @@ export declare const EN: {
262
262
  readonly 'label.copyButtonTitle': "{action}; right-click for copy options";
263
263
  readonly 'block.copyMarkdown': "Copy Markdown";
264
264
  readonly 'block.copyCsv': "Copy CSV";
265
+ readonly 'block.table.expandDetails': "Expand details for {label}";
266
+ readonly 'block.table.collapseDetails': "Collapse details for {label}";
267
+ readonly 'block.table.expandGroup': "Expand group {label}";
268
+ readonly 'block.table.collapseGroup': "Collapse group {label}";
265
269
  readonly 'block.total': "Total";
266
270
  readonly 'block.filteredRows': "{visible} / {total} rows after filtering";
267
271
  readonly 'block.filterMatch': "{shown} / {total} matched";
@@ -277,7 +281,7 @@ export declare const EN: {
277
281
  readonly 'block.svgPreview': "Preview";
278
282
  readonly 'block.svgSource': "Source";
279
283
  readonly 'block.svgError': "SVG could not be displayed — showing the source instead";
280
- readonly 'block.mermaidError': "Invalid diagram syntax — showing the source instead";
284
+ readonly 'block.mermaidError': "Diagram rendering failed — showing the source instead";
281
285
  readonly 'block.mermaidLoading': "Rendering…";
282
286
  readonly 'block.scene3dLoading': "Loading 3D scene…";
283
287
  readonly 'block.scene3dError': "3D rendering failed";
@@ -2,4 +2,4 @@
2
2
  import { type ReactNode } from 'react';
3
3
  export declare function hasInlineMarkup(text: string): boolean;
4
4
  /** Render safe phrasing content, usable in headings, buttons and labels too. */
5
- export declare function renderInline(text: string, allowLinks?: boolean, depth?: number): ReactNode;
5
+ export declare function renderInline(text: string, allowLinks?: boolean | 'file', depth?: number): ReactNode;
@@ -0,0 +1,2 @@
1
+ /** Resolve host CSS colors before passing them to Mermaid's limited parser. */
2
+ export declare function readMermaidThemeColor(name: string, fallback: string): string;
@@ -1,9 +1,14 @@
1
1
  import { type GenuiActionHandler } from './action-context.ts';
2
+ import { type FileLinkAvailability, type FileLinkCordisContext } from './file-link-context.tsx';
2
3
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
3
4
  /** Injection face built per session in apply (scoped conversation send). */
4
5
  export interface GenuiPanelInjected {
5
6
  sessionId: string;
6
7
  sendGenuiAction: GenuiActionHandler;
8
+ fileLinkContext?: {
9
+ ctx: FileLinkCordisContext;
10
+ availability: FileLinkAvailability;
11
+ };
7
12
  /** Template center "try it": insert the template instruction into the
8
13
  * current composer draft (standard conversation.input.for channel). */
9
14
  insertTemplate: (text: string) => void;
@@ -15,4 +20,4 @@ export type GenuiPanelProps = PropsRuntime<'conversation.input.dock'> & GenuiPan
15
20
  * default so the dock never steals the message flow's scroll room; the
16
21
  * header always shows the current panel title.
17
22
  */
18
- export declare function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate }: GenuiPanelProps): import("react").JSX.Element | null;
23
+ export declare function GenuiPanel({ sessionId, sendGenuiAction, insertTemplate, fileLinkContext }: GenuiPanelProps): import("react").JSX.Element | null;
@@ -105,6 +105,8 @@ export interface FenceFeedbackPlanInput {
105
105
  readonly correctionsThisTurn?: number | undefined;
106
106
  /** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
107
107
  readonly correctionsTurn?: number | undefined;
108
+ /** Whether the host has registered the `render_ui` tool. Defaults to enabled. */
109
+ readonly renderUiToolEnabled?: boolean | undefined;
108
110
  }
109
111
  /** A correction the caller must account for before steering. */
110
112
  export interface FenceFeedbackPlan {
@@ -139,13 +141,15 @@ export declare function planFenceFeedback(input: FenceFeedbackPlanInput): FenceF
139
141
  *
140
142
  * @param turn - turn that reached the boundary.
141
143
  * @param attempt - correction number within the shared turn budget.
144
+ * @param renderUiToolEnabled - whether the correction may recommend `render_ui`.
142
145
  * @returns the message text to steer into the running turn.
143
146
  */
144
- export declare function missingBodyCorrectionText(turn: number, attempt?: number): string;
147
+ export declare function missingBodyCorrectionText(turn: number, attempt?: number, renderUiToolEnabled?: boolean): string;
145
148
  /**
146
149
  * 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
147
150
  *
148
151
  * @param ctx - 宿主 Context。
149
152
  * @param enabled - 是否启用同回合围栏修正。
153
+ * @param renderUiToolEnabled - 是否已注册 `render_ui` 工具。
150
154
  */
151
- export declare function installFenceFeedback(ctx: Context, enabled: boolean): void;
155
+ export declare function installFenceFeedback(ctx: Context, enabled: boolean, renderUiToolEnabled?: boolean): void;
@@ -31,5 +31,9 @@ export interface GenuiPluginConfig {
31
31
  * 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
32
32
  */
33
33
  fenceFeedback?: boolean;
34
+ /**
35
+ * 是否注册 render_ui 模型工具。默认开启,设置为 false 时关闭;不影响 validate_dsh_ui 和 dsh-ui 围栏渲染。
36
+ */
37
+ renderUiTool?: boolean;
34
38
  }
35
39
  export declare function apply(ctx: Context, config?: GenuiPluginConfig): void;
@@ -31,6 +31,7 @@ export declare function describeJsonFailure(raw: string): string | null;
31
31
  * with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
32
32
  * fail near that quote with "Expected ',' or ']'...".
33
33
  * 2. Trailing commas before `}` / `]` or at end of input.
34
+ * 3. Valueless object keys immediately before a complete key-and-colon pair.
34
35
  *
35
36
  * A shared grammar-aware scan distinguishes object keys from values and
36
37
  * checks the continuation after a potential string terminator. Ambiguous
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@changfenhuang/dsh-genui",
3
3
  "description": "GenUI for DeepSeek Harness: interactive UI components rendered inline in assistant replies via the ```dsh-ui fence — layout, charts, plots, forms, quizzes, mermaid, 3D scenes, and an action event loop back to the model. Ships the fence-teaching host plugin, the browser renderer (client half), and the genui skill.",
4
- "version": "0.11.4",
4
+ "version": "0.12.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/plugin/index.d.ts",
@@ -10,10 +10,6 @@
10
10
  "types": "./lib/types/plugin/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
- "./invariant": {
14
- "types": "./lib/types/plugin/invariant.d.ts",
15
- "default": "./lib/invariant.js"
16
- },
17
13
  "./client": {
18
14
  "types": "./lib/types/client/index.d.ts",
19
15
  "default": "./lib/client.js"
@@ -29,12 +25,10 @@
29
25
  },
30
26
  "files": [
31
27
  "lib/index.js",
32
- "lib/invariant.js",
33
28
  "lib/client.js",
34
29
  "lib/assets/mermaid.js",
35
30
  "lib/assets/three.js",
36
31
  "lib/types/plugin/index.d.ts",
37
- "lib/types/plugin/invariant.d.ts",
38
32
  "lib/types/client/index.d.ts",
39
33
  "SKILL.md",
40
34
  "README.md",
@@ -86,7 +80,6 @@
86
80
  "@deepseek-ai/dsh-client-ui-session": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
87
81
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
88
82
  "@deepseek-ai/dsh-client-ui-tool": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
89
- "@deepseek-ai/dsh-invariants": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
90
83
  "@deepseek-ai/dsh-llm": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
91
84
  "@deepseek-ai/dsh-session": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
92
85
  "@deepseek-ai/dsh-skill": "^0.1.2-rc.1 || ^0.1.5-alpha.1 || ^0.1.6-alpha.1 || ^0.1.7-alpha.1 || >=0.2.0-rc.1 <0.3.0-0",
@@ -122,7 +115,6 @@
122
115
  "@deepseek-ai/dsh-deque": "0.1.2-rc.1",
123
116
  "@deepseek-ai/dsh-file-reference": "0.1.2-rc.1",
124
117
  "@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
125
- "@deepseek-ai/dsh-invariants": "0.1.2-rc.1",
126
118
  "@deepseek-ai/dsh-jobs": "0.1.2-rc.1",
127
119
  "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
128
120
  "@deepseek-ai/dsh-native-command": "0.1.2-rc.1",
@@ -226,9 +218,6 @@
226
218
  "@deepseek-ai/dsh-client-ui-tool": {
227
219
  "optional": true
228
220
  },
229
- "@deepseek-ai/dsh-invariants": {
230
- "optional": true
231
- },
232
221
  "@deepseek-ai/dsh-llm": {
233
222
  "optional": true
234
223
  },
@@ -421,6 +421,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
421
421
  }
422
422
  .inlineLink { color: var(--dsl-g-accent); text-decoration: underline; text-underline-offset: 2px; }
423
423
  .inlineLink:hover { text-decoration-thickness: 2px; }
424
+ .inlineFileLink { padding: 0; border: 0; background: none; color: var(--dsl-g-accent); font: inherit; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
425
+ .inlineFileLink:hover { text-decoration-thickness: 2px; }
426
+ .inlineFileLink:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 3px; }
424
427
 
425
428
  /* ---------- badges / stat / progress ---------- */
426
429
 
@@ -651,6 +654,18 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
651
654
  /* An inline code span keeps its own spacing too (a pasted snippet often lives
652
655
  inside backticks), matching the block-level code surface. */
653
656
  .inlineCode { white-space: pre-wrap; }
657
+ /* Table cells are single-line by default, so a chip inside them must not
658
+ re-open line breaking. Host shells render message bodies inside `.markdown`,
659
+ which makes every chip an atomic `inline-flex` box and inherits
660
+ `overflow-wrap: anywhere` into the cell; together with `pre-wrap` the chip's
661
+ automatic minimum size collapses, the column can then be sized below its
662
+ content, and the still-unbroken text is painted over the next column (#280).
663
+ Keep the cell's own line-breaking contract for every table section. */
664
+ .table > thead > tr > th:not(.tdCode):not(.tdMultiline) .inlineCode,
665
+ .table > tbody > tr:not(.detailRow) > td:not(.tdCode):not(.tdMultiline) .inlineCode,
666
+ .table > tfoot > tr > td:not(.tdCode):not(.tdMultiline) .inlineCode {
667
+ white-space: pre;
668
+ }
654
669
  /* Rich-text owners must paint the real newlines kept by renderInline. Scope
655
670
  this to prose/labels: table data keeps nowrap and code keeps pre-wrap. For
656
671
  flex controls, target the label span so emphasis and plain text share one
@@ -1127,11 +1142,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1127
1142
  /* Master-detail rows (table.details): a chevron in the first cell opens a
1128
1143
  full-width panel underneath, so a table can drill down without leaving the
1129
1144
  page or opening a modal. */
1130
- .detailCell { padding-left: var(--dsl-g-gap-sm); }
1145
+ .detailCell { position: relative; padding-left: var(--dsl-g-gap-sm); }
1131
1146
  .detailToggle {
1147
+ position: relative;
1148
+ z-index: 1;
1132
1149
  display: inline-flex;
1133
1150
  align-items: center;
1134
- gap: 6px;
1151
+ justify-content: center;
1152
+ vertical-align: middle;
1153
+ width: 24px;
1154
+ height: 24px;
1155
+ margin-right: 4px;
1135
1156
  padding: 0;
1136
1157
  border: none;
1137
1158
  background: none;
@@ -1142,7 +1163,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1142
1163
  text-align: left;
1143
1164
  }
1144
1165
  .detailToggle:hover { color: var(--dsl-g-accent); }
1166
+ .detailCell:hover .detailToggle { color: var(--dsl-g-accent); }
1145
1167
  .detailToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
1168
+ .detailContent { position: relative; z-index: 1; vertical-align: middle; cursor: pointer; }
1146
1169
  .detailChevron {
1147
1170
  flex-shrink: 0;
1148
1171
  font-size: 10px;
@@ -1182,10 +1205,17 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1182
1205
  .table tbody tr.groupRow:first-child td { box-shadow: none; padding-top: 10px; }
1183
1206
  .table tbody tr.groupRow:hover { background: none; }
1184
1207
  .table tbody tr.groupRow:nth-child(even) { background: none; }
1208
+ .groupRow td { position: relative; }
1185
1209
  .groupToggle {
1210
+ position: relative;
1211
+ z-index: 1;
1186
1212
  display: inline-flex;
1187
1213
  align-items: center;
1188
- gap: 7px;
1214
+ justify-content: center;
1215
+ vertical-align: middle;
1216
+ width: 24px;
1217
+ height: 24px;
1218
+ margin-right: 7px;
1189
1219
  padding: 0;
1190
1220
  border: none;
1191
1221
  background: none;
@@ -1197,7 +1227,9 @@ img.mediaPlayer { aspect-ratio: 16 / 9; object-fit: cover; background: var(--dsl
1197
1227
  cursor: pointer;
1198
1228
  }
1199
1229
  .groupToggle:hover { color: var(--dsl-g-accent); }
1230
+ .groupLabel:hover { color: var(--dsl-g-accent); }
1200
1231
  .groupToggle:focus-visible { outline: 2px solid var(--dsl-g-accent); outline-offset: 2px; border-radius: 4px; }
1232
+ .groupLabel { position: relative; z-index: 1; display: inline-flex; vertical-align: middle; align-items: center; gap: 7px; cursor: pointer; }
1201
1233
  .groupChevron { color: var(--dsw-alias-label-tertiary); font-size: 11px; width: 10px; }
1202
1234
  .groupCount {
1203
1235
  padding: 0 7px;
@@ -8,7 +8,37 @@
8
8
  font: 12px/1.4 system-ui, sans-serif;
9
9
  }
10
10
 
11
- .trigger,
11
+ /* 导出入口:无边框图标按钮。形状照项目里的 chrome 图标按钮 .panelClose
12
+ (26px 命中区 / 6px 圆角 / 透明底 / 无边框)。 */
13
+ .trigger {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ width: 26px;
18
+ height: 26px;
19
+ padding: 0;
20
+ border: 0;
21
+ border-radius: 6px;
22
+ background: transparent;
23
+ color: var(--dsw-alias-label-tertiary);
24
+ cursor: pointer;
25
+ }
26
+
27
+ /* 悬浮沿用区块内表格行 / .tab / .accHead 那条配方(--dsl-g-hover):主题文字色
28
+ 4% 淡洗 + 文字色升到 label-primary。不用 rgba(255,255,255,…) 与没带兜底的
29
+ --dsw-alias-fill-hover —— 浅色主题下两者都看不见。 */
30
+ .trigger:hover,
31
+ .trigger:focus-visible {
32
+ background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent);
33
+ color: var(--dsw-alias-label-primary);
34
+ }
35
+
36
+ /* 聚焦轮廓同 .panelClose。 */
37
+ .trigger:focus-visible {
38
+ outline: 1px solid var(--dsw-alias-state-business-primary, #4f8ef7);
39
+ outline-offset: 1px;
40
+ }
41
+
12
42
  .item {
13
43
  border: 1px solid var(--dsw-alias-border-l1);
14
44
  border-radius: 7px;
@@ -18,9 +48,10 @@
18
48
  cursor: pointer;
19
49
  }
20
50
 
21
- .trigger:hover,
51
+ /* 菜单项沿用宿主的 fill-hover;第三方主题可能没有定义该 token(例如浅色
52
+ dream-skin),此时回退到区块内那条 4% 文字色淡洗,悬浮不会没有反馈。 */
22
53
  .item:hover {
23
- background: var(--dsw-alias-fill-hover);
54
+ background: var(--dsw-alias-fill-hover, color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent));
24
55
  color: var(--dsw-alias-label-primary);
25
56
  }
26
57