From e97e5aef335167ee89986f8420b7d5142d607e70 Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Wed, 28 Oct 2026 23:11:21 -0700 Subject: [PATCH 1/2] feat(desktop,models): render Markdown update notes and infer discovered model capabilities --- docs/model-discovery-capabilities-patch.md | 22 ++++ ...ent-ui-settings-models+0.2.2-alpha.2.patch | 69 +++++++----- .../@deepseek-ai+dsh-llm+0.2.2-alpha.2.patch | 30 +++++ ...pseek-ai+dsh-llm-pi-ai+0.2.2-alpha.2.patch | 105 +++++++++++++++++- src/preload/desktop-update-ui.ts | 26 ++++- src/preload/update-notes-view.ts | 42 +++++++ test/desktop-update-ui.test.ts | 20 ++++ test/model-discovery-capabilities.test.mjs | 102 +++++++++++++++++ 8 files changed, 383 insertions(+), 33 deletions(-) create mode 100644 docs/model-discovery-capabilities-patch.md create mode 100644 patches/@deepseek-ai+dsh-llm+0.2.2-alpha.2.patch create mode 100644 src/preload/update-notes-view.ts create mode 100644 test/model-discovery-capabilities.test.mjs diff --git a/docs/model-discovery-capabilities-patch.md b/docs/model-discovery-capabilities-patch.md new file mode 100644 index 000000000..c024928d4 --- /dev/null +++ b/docs/model-discovery-capabilities-patch.md @@ -0,0 +1,22 @@ +# Models 发现能力兼容补丁 + +## 范围与版本 + +目标:@deepseek-ai/dsh-llm-pi-ai、@deepseek-ai/dsh-llm、@deepseek-ai/dsh-client-ui-settings-models **0.2.2-alpha.2**,wire 语义核对 @earendil-works/pi-ai **1.3.0**。 + +补丁修复现有发现 → LlmDiscoveredModel/服务序列化 → UI adopt 链路丢失明确能力元数据的问题,不新增协议或加载入口,不改变 Profile/Safe Mode 的解析基准。失败或未知元数据仍保留原有人工配置入口;不修改用户已有模型行。 + +权威输入在 patches/ 对应三个包的 patch 中;node_modules 仅供重放及行为验证。移除条件:上游端到端保留经校验的等价能力契约、采用时不覆盖已有用户配置,并通过本次行为/wire 回归;升级时重新核对 pi-ai 映射,不能只机械调整补丁上下文。 + +## 官方输入与保守边界 + +- [OpenRouter models](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties.md):校验 architecture.input_modalities 数组及 reasoning 对象。supported_efforts 缺失是未知,null 才表示网关接受全部 effort 值。mandatory 为 true 时不提供关闭。none 对应 UI off,但实际 wire 必须为 none;max/xhigh 不降级。校验 default_effort/default_enabled/supports_max_tokens 的已知形状,不把这些默认值强制写成用户偏好,不从 supported_parameters 猜测等级。 +- [Anthropic models](https://platform.claude.com/docs/en/api/models/list.md):读取 capabilities.image_input.supported、thinking.supported/types、effort.supported 及各等级的 supported boolean。只有明确支持 adaptive 且披露可用 effort 才自动配置等级,并传递最小 forceAdaptiveThinking 开关,避免 pi-ai budget 路径把扩展等级折成 high。只有 disabled.supported 为 true 才加入 off;between_tools 不代表 disabled。 +- OpenAI 标准 models 没有这些能力字段,缺失/畸形不能解释为“不支持”。未声明 reasoning、旧式 budget thinking 等继续人工配置。只投影 runtime 支持的 text/image,忽略合法但 runtime 未支持的 file/audio/video,不声称支持这些输入。 +- 内置目录保留 reasoning=false 和显式 thinkingLevelMap 的支持/禁用及 wire 值,不凭 reasoning=true 发明 max/xhigh。 + +## 验证与限制 + +行为回归:test/model-discovery-capabilities.test.mjs,覆盖官方形状、缺失/畸形、目录 wire map、服务传递/隔离、UI adopt 与已有行保留。实际导入安装的 pi-ai streamSimple,并在 onPayload 处中止网络,验证 Anthropic off 发送 thinking.disabled、max/xhigh 精确 effort,OpenRouter off 发送 reasoning.effort=none、max/xhigh 精确传递。 + +补丁在独立临时树从反向重放所得基线重新应用并逐字节比对三包目标文件。patch-package 自动生成时其内部 npm install 退出 255(无诊断输出),因此采用本地已装版本及既有补丁反向基线生成,没有进行破坏性 npm ci;未宣称已验证全新联网安装。未重启正式安装应用、未做真实浏览器/UI 点击验收,行为测试不替代实际加载与保存流程验收。 diff --git a/patches/@deepseek-ai+dsh-client-ui-settings-models+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-client-ui-settings-models+0.2.2-alpha.2.patch index 04f459649..b352c6dfb 100644 --- a/patches/@deepseek-ai+dsh-client-ui-settings-models+0.2.2-alpha.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-settings-models+0.2.2-alpha.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-settings-models/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-settings-models/lib/client.js -index d411b8cedd..af1df0c633 100644 +index d411b8c..0a44491 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-settings-models/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-settings-models/lib/client.js @@ -64,6 +64,38 @@ window.__ModuleLoader__.load({ @@ -284,7 +284,18 @@ index d411b8cedd..af1df0c633 100644 }), (0, react_jsx_runtime.jsxs)("button", { type: "button", -@@ -1027,6 +1200,7 @@ window.__ModuleLoader__.load({ +@@ -985,7 +1158,9 @@ window.__ModuleLoader__.load({ + ...candidate.name === void 0 ? {} : { name: candidate.name }, + ...candidate.contextWindow === void 0 ? {} : { contextWindow: candidate.contextWindow }, + ...candidate.maxTokens === void 0 ? {} : { maxTokens: candidate.maxTokens }, +- ...candidate.inputModalities === void 0 ? {} : { input: [...candidate.inputModalities] } ++ ...candidate.inputModalities === void 0 ? {} : { input: [...candidate.inputModalities] }, ++ ...candidate.reasoningEfforts === void 0 ? {} : { reasoningEfforts: candidate.reasoningEfforts === false ? false : { ...candidate.reasoningEfforts } }, ++ ...candidate.reasoningCompat === void 0 ? {} : { compat: { ...candidate.reasoningCompat } } + }; + } + /** +@@ -1027,6 +1202,7 @@ window.__ModuleLoader__.load({ const [flipped, setFlipped] = (0, react.useState)(/* @__PURE__ */ new Set()); const [candidateQuery, setCandidateQuery] = (0, react.useState)(""); const rowState = useModelRows(models, onChange, disabled); @@ -292,7 +303,7 @@ index d411b8cedd..af1df0c633 100644 const editCapacity = (index, field, text) => { rowState.edit(index, field, text); patch(index, { [field]: parseCapacity(text) }); -@@ -1155,16 +1329,18 @@ window.__ModuleLoader__.load({ +@@ -1155,16 +1331,18 @@ window.__ModuleLoader__.load({ }) ] }), @@ -313,7 +324,7 @@ index d411b8cedd..af1df0c633 100644 rowKey: key, order, model, -@@ -1203,7 +1379,7 @@ window.__ModuleLoader__.load({ +@@ -1203,7 +1381,7 @@ window.__ModuleLoader__.load({ onChange(models.filter((_model, at) => at !== index)); rowState.forget(new Set([index])); } @@ -322,7 +333,7 @@ index d411b8cedd..af1df0c633 100644 }), (0, react_jsx_runtime.jsxs)("button", { type: "button", -@@ -1652,10 +1828,10 @@ window.__ModuleLoader__.load({ +@@ -1652,10 +1830,10 @@ window.__ModuleLoader__.load({ * credential seam with a raw regular expression the user cannot act on. */ const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; @@ -336,7 +347,7 @@ index d411b8cedd..af1df0c633 100644 } catch { return false; } -@@ -1674,6 +1850,7 @@ window.__ModuleLoader__.load({ +@@ -1674,6 +1852,7 @@ window.__ModuleLoader__.load({ const [protocol, setProtocol] = (0, react.useState)(protocols[0] ?? ""); const [keyDraft, setKeyDraft] = (0, react.useState)(""); const [models, setModels] = (0, react.useState)([]); @@ -344,7 +355,7 @@ index d411b8cedd..af1df0c633 100644 const [busy, setBusy] = (0, react.useState)(false); const [listBusy, setListBusy] = (0, react.useState)(false); (0, react.useEffect)(() => { -@@ -1741,6 +1918,11 @@ window.__ModuleLoader__.load({ +@@ -1741,6 +1920,11 @@ window.__ModuleLoader__.load({ setBusy(false); } }; @@ -356,7 +367,7 @@ index d411b8cedd..af1df0c633 100644 return (0, react_jsx_runtime.jsxs)("div", { className: ModelsSection_module_css_default["editor"], children: [ -@@ -1853,6 +2035,8 @@ window.__ModuleLoader__.load({ +@@ -1853,6 +2037,8 @@ window.__ModuleLoader__.load({ models, onChange: setModels, reasoningLevels, @@ -365,7 +376,7 @@ index d411b8cedd..af1df0c633 100644 probe: { settingsNs: NS$1, baseURL: normalizedBaseURL, -@@ -1873,18 +2057,27 @@ window.__ModuleLoader__.load({ +@@ -1873,18 +2059,27 @@ window.__ModuleLoader__.load({ className: ModelsSection_module_css_default["advancedHint"], children: hint }), @@ -405,7 +416,7 @@ index d411b8cedd..af1df0c633 100644 }) ] }); -@@ -1978,6 +2171,8 @@ window.__ModuleLoader__.load({ +@@ -1978,6 +2173,8 @@ window.__ModuleLoader__.load({ onBusyChange ]); const [failure, setFailure] = (0, react.useState)(void 0); @@ -414,7 +425,7 @@ index d411b8cedd..af1df0c633 100644 const [committedOriginal, setCommittedOriginal] = (0, react.useState)(() => schema.getPath(namespace.user, settingsPath)); const [expectedRevision, setExpectedRevision] = (0, react.useState)(() => namespace.revision); const root = (0, react.useMemo)(() => schema.rehydrate(namespace.schema), [namespace.schema, schema]); -@@ -2017,6 +2212,7 @@ window.__ModuleLoader__.load({ +@@ -2017,6 +2214,7 @@ window.__ModuleLoader__.load({ const shownKeyFailure = (props.credentialRequired === true && keyDraft.length > 0 && keyValue.length === 0 ? "keyRequired" : void 0) ?? keyFailure; const probeApi = stringAt(draft, "api") ?? stringAt(fallback, "api"); const probeBaseURL = stringAt(draft, "baseURL") ?? stringAt(fallback, "baseURL"); @@ -422,7 +433,7 @@ index d411b8cedd..af1df0c633 100644 const probe = { settingsNs: namespace.ns, provider: props.provider, -@@ -2031,6 +2227,7 @@ window.__ModuleLoader__.load({ +@@ -2031,6 +2229,7 @@ window.__ModuleLoader__.load({ * outside the card. Ops name only the fields this card can see. */ const applyOnce = async () => { @@ -430,7 +441,7 @@ index d411b8cedd..af1df0c633 100644 const ns = namespace.ns; const next = layout === "pi-ai" && stringAt(draft, "apiKeyEnv") === void 0 && stringAt(fallback, "apiKeyEnv") === void 0 && keyValue.length > 0 ? schema.setPath(draft, ["apiKeyEnv"], keyRef) : draft; if (props.credentialOnly !== true) { -@@ -2094,6 +2291,15 @@ window.__ModuleLoader__.load({ +@@ -2094,6 +2293,15 @@ window.__ModuleLoader__.load({ const inheritedModels = () => { return schema.getPath(namespace.base, [...settingsPath, "models"]) ?? schema.nodeAtPath(root, [...settingsPath, "models"])?.meta.default; }; @@ -446,7 +457,7 @@ index d411b8cedd..af1df0c633 100644 /** * The curated fields of one known adapter family. The family arrives * narrowed so the per-family branches below are total: an unknown namespace -@@ -2143,6 +2349,8 @@ window.__ModuleLoader__.load({ +@@ -2143,6 +2351,8 @@ window.__ModuleLoader__.load({ const catalogProps = { models, overridden: modelsOverridden, @@ -455,7 +466,7 @@ index d411b8cedd..af1df0c633 100644 t, disabled, onChange: (next) => { -@@ -2185,6 +2393,10 @@ window.__ModuleLoader__.load({ +@@ -2185,6 +2395,10 @@ window.__ModuleLoader__.load({ ] }), props.credentialOnly === true ? null : (0, react_jsx_runtime.jsxs)("details", { className: ModelsSection_module_css_default["customized"], @@ -466,7 +477,7 @@ index d411b8cedd..af1df0c633 100644 children: [(0, react_jsx_runtime.jsx)("summary", { className: ModelsSection_module_css_default["customizedSummary"], children: t("customized") -@@ -2224,7 +2436,7 @@ window.__ModuleLoader__.load({ +@@ -2224,7 +2438,7 @@ window.__ModuleLoader__.load({ "aria-label": t("baseUrl"), disabled, onChange: (event) => { @@ -475,7 +486,7 @@ index d411b8cedd..af1df0c633 100644 } }), endpointHint === void 0 ? null : (0, react_jsx_runtime.jsx)("span", { -@@ -2274,8 +2486,23 @@ window.__ModuleLoader__.load({ +@@ -2274,8 +2488,23 @@ window.__ModuleLoader__.load({ })] })] }); }; @@ -500,7 +511,7 @@ index d411b8cedd..af1df0c633 100644 children: [ props.hideTitle === true ? null : (0, react_jsx_runtime.jsxs)("div", { className: ModelsSection_module_css_default["editorHeader"], -@@ -2295,24 +2522,20 @@ window.__ModuleLoader__.load({ +@@ -2295,24 +2524,20 @@ window.__ModuleLoader__.load({ className: ModelsSection_module_css_default["error"], children: failure }) : null, @@ -535,7 +546,7 @@ index d411b8cedd..af1df0c633 100644 }) ] }); -@@ -2426,6 +2649,26 @@ window.__ModuleLoader__.load({ +@@ -2426,6 +2651,26 @@ window.__ModuleLoader__.load({ function providerCopy(template, target) { return template.replace("{provider}", () => providerTargetLabel(target)); } @@ -562,7 +573,7 @@ index d411b8cedd..af1df0c633 100644 /** * Render the Models section content column. * @param props - slot-delivered injected dependencies. -@@ -2476,6 +2719,7 @@ window.__ModuleLoader__.load({ +@@ -2476,6 +2721,7 @@ window.__ModuleLoader__.load({ const [deleting, setDeleting] = (0, react.useState)(false); const [deleteFailure, setDeleteFailure] = (0, react.useState)(void 0); const [savedTarget, setSavedTarget] = (0, react.useState)(void 0); @@ -570,7 +581,7 @@ index d411b8cedd..af1df0c633 100644 const [dismissedSetup, setDismissedSetup] = (0, react.useState)(() => /* @__PURE__ */ new Set()); const announceSaved = (target) => { controller.load().then(() => { -@@ -2573,6 +2817,10 @@ window.__ModuleLoader__.load({ +@@ -2573,6 +2819,10 @@ window.__ModuleLoader__.load({ row, namespace }]; @@ -581,7 +592,7 @@ index d411b8cedd..af1df0c633 100644 }); const piAi = state.namespaces.get("llm-pi-ai"); const protocols = protocolChoices(piAi, schema); -@@ -2995,9 +3243,24 @@ window.__ModuleLoader__.load({ +@@ -2995,9 +3245,24 @@ window.__ModuleLoader__.load({ function assertNever$1(_value) { throw new Error("unexpected DeepSeek onboarding state"); } @@ -608,7 +619,7 @@ index d411b8cedd..af1df0c633 100644 * @param props - settings-shell owner state and Models feature dependencies. * @returns the onboarding modal or null when onboarding needs no intervention. */ -@@ -3006,6 +3269,16 @@ window.__ModuleLoader__.load({ +@@ -3006,6 +3271,16 @@ window.__ModuleLoader__.load({ const [apiKey, setApiKey] = (0, react.useState)(explicit); const state = useModels((snapshot) => snapshot); const readiness = onboardingReadiness(state); @@ -625,7 +636,7 @@ index d411b8cedd..af1df0c633 100644 (0, react.useEffect)(() => { if ((automatic || explicit) && state.status === "idle") controller.load(); }, [ -@@ -3358,6 +3631,8 @@ window.__ModuleLoader__.load({ +@@ -3358,6 +3633,8 @@ window.__ModuleLoader__.load({ addCatalogExhausted: "Every catalog provider is already configured.", addCustomUnavailable: "No API protocol is available to declare.", provider: "Provider", @@ -634,7 +645,7 @@ index d411b8cedd..af1df0c633 100644 close: "Close", cancel: "Cancel", apply: "Apply", -@@ -3416,6 +3691,14 @@ window.__ModuleLoader__.load({ +@@ -3416,6 +3693,14 @@ window.__ModuleLoader__.load({ modelReasoningAdd: "Add level", modelReasoningRemove: "Delete level", modelReasoningOffValuePlaceholder: "Turns reasoning off", @@ -649,7 +660,7 @@ index d411b8cedd..af1df0c633 100644 addModel: "Add model", removeModel: "Delete model", reorderModel: "Reorder model", -@@ -3467,10 +3750,14 @@ window.__ModuleLoader__.load({ +@@ -3467,10 +3752,14 @@ window.__ModuleLoader__.load({ welcomeBody: "DeepSeek Harness 0.2 is still in preview, and many areas need continued improvement and refinement. We welcome feedback and suggestions from all developers and users. The new desktop app now targets a broad range of users, while developer-related advanced features can be enabled in the settings. DeepSeek Harness’s product features and plugin APIs are expected to continue rapid iteration and evolution, and will gradually stabilize over time.\n\nWe look forward to exploring the limits of intelligence together with users and developers around the world, building on open-source, reusable, and composable infrastructure. We welcome everyone to bring their ideas to life with DeepSeek Harness and participate in the community to enrich the plugin ecosystem.", welcomeContinue: "Continue", welcomeError: "The acknowledgement could not be saved. Please try again.", @@ -667,7 +678,7 @@ index d411b8cedd..af1df0c633 100644 onboardingSaving: "Saving…", keyRequired: "Enter an API key to continue." }; -@@ -3556,6 +3843,14 @@ window.__ModuleLoader__.load({ +@@ -3556,6 +3845,14 @@ window.__ModuleLoader__.load({ modelReasoningAdd: "添加档位", modelReasoningRemove: "删除档位", modelReasoningOffValuePlaceholder: "关闭思考", @@ -682,7 +693,7 @@ index d411b8cedd..af1df0c633 100644 addModel: "添加模型", removeModel: "删除模型", reorderModel: "调整模型顺序", -@@ -3607,10 +3902,14 @@ window.__ModuleLoader__.load({ +@@ -3607,10 +3904,14 @@ window.__ModuleLoader__.load({ welcomeBody: "DeepSeek Harness 目前的 0.2 版本仍处于预览阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者和用户的反馈建议。现在,新的桌面端面向广泛用户,开发者相关的进阶功能可在配置中开启使用。预计 DeepSeek Harness 的产品功能以及插件 API 都会继续快速迭代、持续演化,并逐渐趋于稳定。\n\n我们期待与全球用户和开发者一起,在开源、可复用、可组合的基础设施之上,共同探索智能上限。欢迎大家用 DeepSeek Harness 将想法变成现实,与社区一起丰富插件生态。", welcomeContinue: "继续", welcomeError: "暂时无法保存确认状态,请重试。", @@ -700,7 +711,7 @@ index d411b8cedd..af1df0c633 100644 onboardingSaving: "保存中…", keyRequired: "请输入 API 密钥后继续。" }; -@@ -5461,22 +5760,7 @@ window.__ModuleLoader__.load({ +@@ -5461,22 +5762,7 @@ window.__ModuleLoader__.load({ } } }, ModelsSection)); diff --git a/patches/@deepseek-ai+dsh-llm+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-llm+0.2.2-alpha.2.patch new file mode 100644 index 000000000..b489b19ac --- /dev/null +++ b/patches/@deepseek-ai+dsh-llm+0.2.2-alpha.2.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/@deepseek-ai/dsh-llm/lib/index.js b/node_modules/@deepseek-ai/dsh-llm/lib/index.js +index a66228c..db680f1 100644 +--- a/node_modules/@deepseek-ai/dsh-llm/lib/index.js ++++ b/node_modules/@deepseek-ai/dsh-llm/lib/index.js +@@ -2010,7 +2010,9 @@ let LlmRuntime = (() => { + ...model.name === void 0 ? {} : { name: model.name }, + ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow }, + ...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }, +- ...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] } ++ ...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] }, ++ ...model.reasoningEfforts === void 0 ? {} : { reasoningEfforts: model.reasoningEfforts === false ? false : { ...model.reasoningEfforts } }, ++ ...model.reasoningCompat === void 0 ? {} : { reasoningCompat: { ...model.reasoningCompat } } + }); + } + return models; +diff --git a/node_modules/@deepseek-ai/dsh-llm/lib/types/types.d.ts b/node_modules/@deepseek-ai/dsh-llm/lib/types/types.d.ts +index 46cfbe0..7c1e0c8 100644 +--- a/node_modules/@deepseek-ai/dsh-llm/lib/types/types.d.ts ++++ b/node_modules/@deepseek-ai/dsh-llm/lib/types/types.d.ts +@@ -323,6 +323,10 @@ export interface LlmDiscoveredModel { + maxTokens?: number; + /** Accepted input types when disclosed by the catalog or endpoint; absent means unknown. */ + inputModalities?: readonly ModelModality[]; ++ /** Explicit selectable levels and wire values; absent means unknown, false means non-reasoning. Null is valid only for off (no wire value). */ ++ reasoningEfforts?: false | Partial>; ++ /** Minimal pi-ai switches needed to dispatch the disclosed effort values accurately. */ ++ reasoningCompat?: { forceAdaptiveThinking?: true; thinkingFormat?: 'openrouter' }; + } + /** + * Emphasis of a model badge: `neutral` states a plain fact, `info` highlights diff --git a/patches/@deepseek-ai+dsh-llm-pi-ai+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-llm-pi-ai+0.2.2-alpha.2.patch index 6c73283b3..b0a797321 100644 --- a/patches/@deepseek-ai+dsh-llm-pi-ai+0.2.2-alpha.2.patch +++ b/patches/@deepseek-ai+dsh-llm-pi-ai+0.2.2-alpha.2.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js -index 0520d6f80b..a1e812f324 100644 +index 0520d6f..b195eb2 100644 --- a/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js +++ b/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js @@ -1455,8 +1455,9 @@ function mapUsage(usage) { @@ -143,3 +143,106 @@ index 0520d6f80b..a1e812f324 100644 }), model.contextWindow, options.signal, model.id)[Symbol.asyncIterator](); let exhausted = false; try { +@@ -2346,7 +2368,64 @@ async function readBounded(response, url) { + * a working endpoint's catalog. Missing names fall back to the adopted id so + * the Web form receives a complete human-readable row. + */ +-function readListing(body) { ++/** Only documented, well-shaped metadata is adopted; unknown capabilities stay editable by hand. */ ++function discoveryObject(value) { ++ return value !== null && typeof value === "object" && !Array.isArray(value); ++} ++function listingCapabilities(entry, api) { ++ const result = {}; ++ const modalities = entry.architecture?.input_modalities; ++ if (Array.isArray(modalities) && modalities.length > 0 && modalities.every((value) => ["text", "image", "file", "audio", "video"].includes(value))) { ++ const input = modalities.filter((value) => value === "text" || value === "image"); ++ if (input.length > 0) result.inputModalities = [...new Set(input)]; ++ } ++ if (api === "anthropic-messages") { ++ const capabilities = entry.capabilities; ++ if (typeof capabilities?.image_input?.supported === "boolean") result.inputModalities = capabilities.image_input.supported ? ["text", "image"] : ["text"]; ++ const thinking = capabilities?.thinking; ++ if (thinking?.supported === false) result.reasoningEfforts = false; ++ // pi-ai's budget path ignores effort strings. Only adaptive thinking can send these levels exactly. ++ else if (thinking?.supported === true && thinking.types?.adaptive?.supported === true && capabilities?.effort?.supported === true) { ++ const efforts = {}; ++ for (const level of ["low", "medium", "high", "xhigh", "max"]) if (capabilities.effort[level]?.supported === true) efforts[level] = level; ++ if (Object.keys(efforts).length > 0) { ++ if (thinking.types.disabled?.supported === true) efforts.off = null; ++ result.reasoningEfforts = efforts; ++ result.reasoningCompat = { forceAdaptiveThinking: true }; ++ } ++ } ++ } else if (api === "openai-completions") { ++ const reasoning = entry.reasoning; ++ const allowed = ["max", "xhigh", "high", "medium", "low", "minimal", "none"]; ++ if (discoveryObject(reasoning) && typeof reasoning.mandatory === "boolean" && (reasoning.default_enabled === void 0 || typeof reasoning.default_enabled === "boolean") && (reasoning.default_effort === void 0 || reasoning.default_effort === null || allowed.includes(reasoning.default_effort)) && (reasoning.supports_max_tokens === void 0 || typeof reasoning.supports_max_tokens === "boolean")) { ++ const disclosed = reasoning.supported_efforts; ++ if (disclosed === null || Array.isArray(disclosed) && disclosed.every((level) => level === null || allowed.includes(level))) { ++ const efforts = {}; ++ for (const level of disclosed === null ? allowed : disclosed) { ++ if (level === null || level === "none" && reasoning.mandatory) continue; ++ efforts[level === "none" ? "off" : level] = level; ++ } ++ if (Object.keys(efforts).some((level) => level !== "off")) { ++ result.reasoningEfforts = efforts; ++ result.reasoningCompat = { thinkingFormat: "openrouter" }; ++ } ++ } ++ } ++ } ++ return result; ++} ++/** Preserve the installed catalog's supported levels and explicit wire spellings, never invent extended levels. */ ++function catalogReasoning(model) { ++ if (!model.reasoning) return false; ++ const efforts = {}; ++ for (const level of THINKING_LEVELS) { ++ const wire = model.thinkingLevelMap?.[level]; ++ if (wire === null || wire === void 0 && (level === "xhigh" || level === "max")) continue; ++ efforts[level] = wire ?? (level === "off" ? null : model.api === "anthropic-messages" && level === "minimal" ? "low" : level); ++ } ++ return Object.keys(efforts).some((level) => level !== "off") ? efforts : void 0; ++} ++function readListing(body, api) { + const listing = body; + const data = listing?.data; + let listed; +@@ -2361,6 +2440,7 @@ function readListing(body) { + } + const models = []; + for (const { key, raw } of listed) { ++ if (!discoveryObject(raw)) continue; + const entry = raw; + const id = label(key, entry?.id); + if (id === void 0) continue; +@@ -2371,7 +2451,8 @@ function readListing(body) { + id, + name, + ...contextWindow === void 0 ? {} : { contextWindow }, +- ...maxTokens === void 0 ? {} : { maxTokens } ++ ...maxTokens === void 0 ? {} : { maxTokens }, ++ ...listingCapabilities(entry, api) + }); + } + return models; +@@ -2407,7 +2488,8 @@ async function discoverModels(request, storedProfile) { + name: model.name, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, +- inputModalities: [...model.input] ++ inputModalities: [...model.input], ++ ...catalogReasoning(model) === void 0 ? {} : { reasoningEfforts: catalogReasoning(model) } + })); + } + if (request.baseURL === void 0 || request.baseURL.length === 0) throw new LlmError(`pi-ai ships no catalog for provider "${request.provider ?? ""}", so its models can only come from its endpoint; set a baseURL, or enter this provider's models by hand`, "DISCOVERY_FAILED"); +@@ -2449,7 +2531,7 @@ async function discoverModels(request, storedProfile) { + } catch (error) { + throw new LlmError(`${url} did not answer with JSON`, "DISCOVERY_FAILED", { cause: error }); + } +- return readListing(body); ++ return readListing(body, api); + } + //#endregion + //#region lib/types/login.js diff --git a/src/preload/desktop-update-ui.ts b/src/preload/desktop-update-ui.ts index df6b48d2f..aaf3a20a4 100644 --- a/src/preload/desktop-update-ui.ts +++ b/src/preload/desktop-update-ui.ts @@ -1,3 +1,4 @@ +import { renderUpdateNotes } from './update-notes-view' import { popupReleaseNotes } from '../shared/update-release-notes' import { ipcRenderer } from 'electron' import type { AvailableRelease, UpdateStatus } from '../shared/contracts' @@ -153,8 +154,11 @@ function render(): void { if (centered && status.phase === 'available') { const label = element('p', 'notes-label') label.textContent = locale === 'zh' ? '更新日志' : 'What’s new' - const notes = element('p', 'release-notes') - notes.textContent = releaseNotes || (locale === 'zh' ? '此版本暂无更新日志。' : 'No release notes for this version.') + const notes = element('div', 'release-notes') + notes.tabIndex = 0 + notes.setAttribute('role', 'region') + notes.setAttribute('aria-label', label.textContent) + renderUpdateNotes(notes, releaseNotes || (locale === 'zh' ? '此版本暂无更新日志。' : 'No release notes for this version.')) body.append(label, notes) } if (!centered && status.phase === 'available') { @@ -522,7 +526,23 @@ const styles = ` .title,.about-title { margin:16px 0 8px; font-size:18px; line-height:26px; font-weight:600; } .notification .title { margin:0; font-size:14px; line-height:20px; } .description,.about-line,.release-notes,.notes-label,.current-version { margin:8px 0; font-size:13px; line-height:20px; color:var(--dsw-alias-label-secondary,#666b73); } - .release-notes { white-space:pre-wrap; max-height:180px; overflow:auto; } + .release-notes { max-height:min(280px,35vh); overflow:auto; overflow-wrap:anywhere; padding-right:4px; overscroll-behavior:contain; } + .release-notes p { margin:0 0 8px; white-space:pre-wrap; } + .release-notes h3 { margin:16px 0 8px; font-size:13px; color:var(--dsw-alias-label-primary,inherit); } + .release-notes ul,.release-notes ol { margin:8px 0 12px; padding-left:22px; } + .release-notes li { margin:5px 0; padding-left:2px; } + .release-notes code { font-size:12px; padding:1px 4px; border-radius:4px; background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12)); } + .release-notes > :first-child { margin-top:0; } + .release-notes > :last-child { margin-bottom:0; } + .card,.release-notes,.version-picker-container { scrollbar-width:thin; scrollbar-color:var(--dsw-alias-border-l2,#b4b7be) transparent; } + :is(.card,.release-notes,.version-picker-container)::-webkit-scrollbar { width:6px; height:6px; } + :is(.card,.release-notes,.version-picker-container)::-webkit-scrollbar-track { background:transparent; } + :is(.card,.release-notes,.version-picker-container)::-webkit-scrollbar-thumb { background:var(--dsw-alias-border-l2,#b4b7be); border-radius:999px; } + :is(.card,.release-notes,.version-picker-container)::-webkit-scrollbar-thumb:hover { background:var(--dsw-alias-label-secondary,#888d96); } + /* Hide only the visual scrollbar; preserve wheel, touch and keyboard scrolling. */ + .release-notes { scrollbar-width:none; } + .release-notes::-webkit-scrollbar { display:none; width:0; height:0; } + .release-notes:focus-visible { outline:2px solid var(--dsw-alias-label-secondary,#888d96); outline-offset:3px; border-radius:4px; } .notes-label { margin-top:24px; } .current-version { text-align:center; font-size:12px; margin:14px 0 0; } button { appearance:none; border:0; font:inherit; cursor:pointer; border-radius:6px; min-height:32px; } diff --git a/src/preload/update-notes-view.ts b/src/preload/update-notes-view.ts new file mode 100644 index 000000000..f1cdbb563 --- /dev/null +++ b/src/preload/update-notes-view.ts @@ -0,0 +1,42 @@ +/** A deliberately small Markdown subset. Remote HTML and links remain inert text. */ +export function renderUpdateNotes(container: HTMLElement, source: string): void { + const doc = container.ownerDocument + let list: HTMLUListElement | HTMLOListElement | undefined + for (const line of source.replace(/\r\n?/g, '\n').split('\n')) { + const item = /^\s*(?:([-+*])|([0-9]+)[.)])\s+(.+)$/.exec(line) + if (item) { + const tag = item[2] ? 'ol' : 'ul' + if (!list || list.localName !== tag) { + list = doc.createElement(tag) + if (item[2]) list.setAttribute('start', item[2]) + container.appendChild(list) + } + const node = doc.createElement('li') + appendInline(node, item[3] ?? '') + list.appendChild(node) + continue + } + list = undefined + if (!line.trim()) continue + const heading = /^#{1,6}\s+(.+)$/.exec(line) + const node = doc.createElement(heading ? 'h3' : 'p') + appendInline(node, heading?.[1] ?? line) + container.appendChild(node) + } +} + +function appendInline(container: HTMLElement, text: string): void { + // Never use innerHTML: even text inside Markdown delimiters is untrusted. + const tokens = /(`[^`]+`|\*\*[^*]+\*\*)/g + let offset = 0 + for (const match of text.matchAll(tokens)) { + const index = match.index ?? 0 + container.appendChild(container.ownerDocument.createTextNode(text.slice(offset, index))) + const code = match[0].startsWith('`') + const node = container.ownerDocument.createElement(code ? 'code' : 'strong') + node.textContent = match[0].slice(code ? 1 : 2, code ? -1 : -2) + container.appendChild(node) + offset = index + match[0].length + } + container.appendChild(container.ownerDocument.createTextNode(text.slice(offset))) +} diff --git a/test/desktop-update-ui.test.ts b/test/desktop-update-ui.test.ts index ae9c43e89..0c3d3df22 100644 --- a/test/desktop-update-ui.test.ts +++ b/test/desktop-update-ui.test.ts @@ -62,6 +62,26 @@ afterEach(() => { const status = (phase: UpdateStatus['phase'], extra: Partial = {}): UpdateStatus => ({ phase, currentVersion: '1.0.0', manual: true, presentationId: 7, ...extra }) describe('desktop update presentation at the IPC boundary', () => { + it.each(['zh', 'en'] as const)('renders safe Markdown notes in %s with a keyboard-scrollable region', async locale => { + const ui = await mount(Promise.resolve(status('idle', { manual: false }))) + ui.emit(status('checking', { locale, presentationId: 8 })) + ui.emit(status('available', { locale, presentationId: 8, availableVersion: '1.1.0', releaseNotes: '# Changes\n- **Fixed** scrolling\n- Use `code`\n\n3. Third\n4. Fourth\n\n\n\n[unsafe](javascript:alert(1))' })) + const notes = required(ui.shadow.querySelector('.release-notes')) + expect(notes.querySelector('h3')?.textContent).toBe('Changes') + expect(notes.querySelectorAll('ul > li')).toHaveLength(2) + expect(notes.querySelector('strong')?.textContent).toBe('Fixed') + expect(notes.querySelector('code')?.textContent).toBe('code') + expect(notes.querySelector('ol')?.getAttribute('start')).toBe('3') + expect(notes.querySelectorAll('ol > li')).toHaveLength(2) + expect(notes.querySelector('script,img,a')).toBeNull() + expect(notes.textContent).toContain('') + expect(notes.tabIndex).toBe(0) + expect(notes.getAttribute('aria-label')).toBe(locale === 'zh' ? '更新日志' : 'What’s new') + ui.emit(status('available', { locale, presentationId: 8, availableVersion: '1.1.0', releaseNotes: '- Refreshed' })) + expect(ui.shadow.querySelectorAll('.release-notes')).toHaveLength(1) + expect(ui.shadow.querySelectorAll('.release-notes li')).toHaveLength(1) + }) + it.each(['up-to-date', 'error', 'unsupported', 'checking'] as const)('does not replay historical manual %s snapshots or same-request events', async phase => { const ui = await mount(Promise.resolve(status(phase))) expect(ui.host.style.display).toBe('none') diff --git a/test/model-discovery-capabilities.test.mjs b/test/model-discovery-capabilities.test.mjs new file mode 100644 index 000000000..c38a250a5 --- /dev/null +++ b/test/model-discovery-capabilities.test.mjs @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' + +const adapter = readFileSync('node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js', 'utf8') +const llm = readFileSync('node_modules/@deepseek-ai/dsh-llm/lib/index.js', 'utf8') +const client = readFileSync('node_modules/@deepseek-ai/dsh-client-ui-settings-models/lib/client.js', 'utf8') +const levels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] +function helper(source, name, indent = '') { + const start = source.indexOf('function ' + name + '(') + expect(start).toBeGreaterThanOrEqual(0) + return source.slice(start, source.indexOf('\n' + indent + '}', start) + indent.length + 2) +} +const parse = new Function('LlmError', helper(adapter, 'capacity') + helper(adapter, 'label') + helper(adapter, 'discoveryObject') + helper(adapter, 'listingCapabilities') + helper(adapter, 'readListing') + ';return readListing')(Error) +const catalog = new Function('THINKING_LEVELS', helper(adapter, 'catalogReasoning') + ';return catalogReasoning')(levels) +const adopt = new Function(helper(client, 'adopt', '\t\t') + ';return adopt')() +const openrouter = (reasoning, input_modalities = ['text', 'image']) => parse({ data: [{ id: 'model', architecture: { input_modalities }, reasoning }] }, 'openai-completions')[0] +const support = supported => ({ supported }) +const anthropic = (thinking, effort, image = true) => parse({ data: [{ id: 'claude', capabilities: { image_input: support(image), thinking, effort } }] }, 'anthropic-messages')[0] + +it('reads documented OpenRouter shapes with exact none, max and xhigh wire values', () => { + const candidate = openrouter({ mandatory: false, default_effort: 'none', default_enabled: false, supports_max_tokens: true, supported_efforts: ['max', 'xhigh', 'high', 'low', 'none', null] }, ['text', 'image', 'file', 'audio', 'video']) + expect(candidate.inputModalities).toEqual(['text', 'image']) + expect(candidate.reasoningEfforts).toEqual({ max: 'max', xhigh: 'xhigh', high: 'high', low: 'low', off: 'none' }) + expect(adopt(candidate)).toMatchObject({ input: ['text', 'image'], reasoningEfforts: candidate.reasoningEfforts, compat: { thinkingFormat: 'openrouter' } }) + expect(openrouter({ mandatory: true, supported_efforts: ['none', 'high'] }).reasoningEfforts).toEqual({ high: 'high' }) +}) + +it('distinguishes null allowlists from missing, empty and malformed metadata without guessing', () => { + expect(Object.keys(openrouter({ mandatory: false, supported_efforts: null }).reasoningEfforts).sort()).toEqual([...levels].sort()) + for (const reasoning of [undefined, true, {}, { mandatory: false }, { mandatory: false, supported_efforts: [] }, { mandatory: false, supported_efforts: ['none'] }, { mandatory: 'false', supported_efforts: ['high'] }, { mandatory: false, supported_efforts: ['high', 'future'] }, { mandatory: false, supported_efforts: 'high' }, { mandatory: false, supported_efforts: ['high'], default_enabled: 'yes' }]) { + expect(openrouter(reasoning)).not.toHaveProperty('reasoningEfforts') + } + for (const modalities of [null, 'image', [], ['text', 7], ['text', 'future'], ['audio']]) expect(openrouter(undefined, modalities)).not.toHaveProperty('inputModalities') + expect(parse({ data: [null, 1, [], { id: 'plain', supported_parameters: ['reasoning'] }] }, 'openai-completions')).toEqual([{ id: 'plain', name: 'plain' }]) +}) + +it('adopts Anthropic adaptive capabilities only when effort dispatch is representable', () => { + const thinking = { supported: true, types: { adaptive: support(true), enabled: support(true), disabled: support(true), between_tools: support(true) } } + const effort = { supported: true, low: support(true), medium: support(false), high: support(true), xhigh: support(true), max: support(true) } + expect(adopt(anthropic(thinking, effort))).toMatchObject({ input: ['text', 'image'], reasoningEfforts: { low: 'low', high: 'high', xhigh: 'xhigh', max: 'max', off: null }, compat: { forceAdaptiveThinking: true } }) + expect(anthropic({ ...thinking, types: { adaptive: support(true), between_tools: support(true) } }, effort).reasoningEfforts).not.toHaveProperty('off') + expect(anthropic({ supported: false }, effort, false)).toMatchObject({ inputModalities: ['text'], reasoningEfforts: false }) + for (const t of [undefined, { supported: 'true' }, { supported: true }, { supported: true, types: { enabled: support(true), adaptive: support(false) } }]) expect(anthropic(t, effort)).not.toHaveProperty('reasoningEfforts') + expect(anthropic(thinking, { supported: true, high: support('true') })).not.toHaveProperty('reasoningEfforts') +}) + +it('preserves catalog support and explicit wire maps without inventing max/xhigh', () => { + expect(catalog({ reasoning: false })).toBe(false) + expect(catalog({ reasoning: true, api: 'openai-completions', thinkingLevelMap: { off: 'none', minimal: null, high: 'custom', xhigh: 'xhigh', max: 'max' } })).toEqual({ off: 'none', low: 'low', medium: 'medium', high: 'custom', xhigh: 'xhigh', max: 'max' }) + expect(catalog({ reasoning: true, api: 'openai-completions' })).not.toHaveProperty('max') +}) + +it('passes detached metadata through the real discovery service method', async () => { + const start = llm.indexOf('async discoverModels(settingsNs, request, signal) {') + const method = llm.slice(start, llm.indexOf('\n\t\t}', start) + 4) + const service = new Function('LlmError', 'return ({' + method + '})')(Error) + const candidate = openrouter({ mandatory: false, supported_efforts: ['high', 'none'] }) + service.discoveries = new Map([['pi', async () => [candidate]]]) + const result = await service.discoverModels('pi', { baseURL: 'https://models.example' }) + expect(result).toEqual([candidate]) + result[0].reasoningEfforts.high = 'changed' + result[0].reasoningCompat.thinkingFormat = 'changed' + result[0].inputModalities.push('changed') + expect(candidate.reasoningEfforts.high).toBe('high') + expect(candidate.reasoningCompat.thinkingFormat).toBe('openrouter') + expect(candidate.inputModalities).toEqual(['text', 'image']) +}) + +it('sends exact provider payloads for off, max and xhigh through installed pi-ai', async () => { + const { streamSimple: anthropicStream } = await import('@earendil-works/pi-ai/api/anthropic-messages') + const { streamSimple: openaiStream } = await import('@earendil-works/pi-ai/api/openai-completions') + const resolve = new Function('THINKING_LEVELS', helper(adapter, 'resolveModelReasoning') + ';return resolveModelReasoning')(levels) + const profileOptions = new Function(helper(adapter, 'profileOptions') + ';return profileOptions')() + const thinking = { supported: true, types: { adaptive: support(true), disabled: support(true) } } + const effort = { supported: true, max: support(true), xhigh: support(true) } + for (const [candidate, api, stream] of [[anthropic(thinking, effort), 'anthropic-messages', anthropicStream], [openrouter({ mandatory: false, supported_efforts: ['none', 'max', 'xhigh'] }), 'openai-completions', openaiStream]]) { + const row = adopt(candidate) + const model = { id: row.id, name: row.id, provider: 'test', api, baseUrl: 'https://models.example', input: row.input, maxTokens: 4096, contextWindow: 16384, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, ...resolve('test', row, undefined), compat: row.compat } + for (const level of ['off', 'max', 'xhigh']) { + let payload + const events = stream(model, { messages: [{ role: 'user', content: 'Hi', timestamp: 1 }] }, { ...profileOptions({}, level, 'fixture-key'), onPayload(value) { payload = value; throw new Error('stop before network') } }) + await events.result() + expect(payload).toBeDefined() + if (api === 'anthropic-messages') { + expect(payload.thinking.type).toBe(level === 'off' ? 'disabled' : 'adaptive') + expect(payload.output_config?.effort).toBe(level === 'off' ? undefined : level) + } else expect(payload.reasoning.effort).toBe(level === 'off' ? 'none' : level) + } + } +}) + +it('executes the picker adoption action without overwriting existing user configuration', () => { + const start = client.indexOf('const configuredIds = new Set(') + const end = client.indexOf('\n\t\t\tconst toggle = (id) => {', start) + const action = `${client.slice(start, end)}\nreturn adoptPicked` + const existing = { id: 'model', input: ['text'], reasoningEfforts: false, compat: { thinkingFormat: 'manual' } } + const candidate = openrouter({ mandatory: false, supported_efforts: ['high'] }) + let result + new Function('models', 'candidates', 'flipped', 'rowState', 'textOf', 'adopt', 'onChange', 'closePicker', action)([existing], [candidate, { ...candidate, id: 'new' }], new Set(['new']), { forget() {} }, (m, k) => m[k], adopt, next => { result = next }, () => {})() + expect(result[0]).toBe(existing) + expect(result[1]).toMatchObject({ id: 'new', input: ['text', 'image'], reasoningEfforts: { high: 'high' } }) +}) From f6e1f571bb319e1a3546115fd27f8f8a7268775d Mon Sep 17 00:00:00 2001 From: yaojin3616 Date: Wed, 28 Oct 2026 23:11:21 -0700 Subject: [PATCH 2/2] fix(preview): strip and forward line numbers when opening file paths --- ...-ai+dsh-client-ui-chat+0.2.2-alpha.2.patch | 89 ++++++----- ...client-ui-deliverables+0.2.2-alpha.2.patch | 61 +++++--- ...h-client-ui-primitives+0.2.2-alpha.2.patch | 31 +++- test/local-path-links.test.ts | 146 +++++++++++++++++- 4 files changed, 263 insertions(+), 64 deletions(-) diff --git a/patches/@deepseek-ai+dsh-client-ui-chat+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-client-ui-chat+0.2.2-alpha.2.patch index 023373ea6..d781d7570 100644 --- a/patches/@deepseek-ai+dsh-client-ui-chat+0.2.2-alpha.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-chat+0.2.2-alpha.2.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js -index 0f920afed6..95f4a097ab 100644 +index 0f920af..a5c2006 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js -@@ -93,6 +93,23 @@ window.__ModuleLoader__.load({ +@@ -93,6 +93,43 @@ window.__ModuleLoader__.load({ if (!/^https?:/u.test(base) && !base.startsWith("dsh-app://app/") || !isAbsoluteWorkspacePath(path) || /^[/\\]{2}/u.test(path) || /[\u0000-\u001f\u007f]/u.test(path)) return void 0; return new URL(`api/file?path=${encodeURIComponent(path)}`, base).href; } @@ -13,6 +13,26 @@ index 0f920afed6..95f4a097ab 100644 + if (root === "") return path; + return `${root}/${normalized.replace(/^\/+/, "")}`; + } ++ function normalizeOpenFileTarget(rawPath, options) { ++ const trimmed = rawPath.trim(); ++ const hash = /^(.*?)#L(\d+)(?:C\d+)?(?:-L?\d+(?:C\d+)?)?$/.exec(trimmed); ++ if (hash !== null && hash[1] !== "") { ++ const line = Number(hash[2]); ++ return { ++ path: hash[1], ++ options: line >= 1 && Number.isSafeInteger(line) && options?.line === void 0 && options?.revealInFileManager !== true ? { ...options, line } : options ++ }; ++ } ++ const colon = /^(.*?):(\d+)(?::\d+)?(?:-\d+(?::\d+)?)?$/.exec(trimmed); ++ if (colon !== null && colon[1] !== "" && !/^[a-z]:$/i.test(colon[1])) { ++ const line = Number(colon[2]); ++ return { ++ path: colon[1], ++ options: line >= 1 && Number.isSafeInteger(line) && options?.line === void 0 && options?.revealInFileManager !== true ? { ...options, line } : options ++ }; ++ } ++ return { path: rawPath, options }; ++ } + async function openResolvedHostFile(ctx, sessionId, cwd, hostPath, options) { + const address = fileAddressFor(sessionId, cwd, hostPath); + if (ctx.sidebarRightTabs.candidates(address).length === 0) { @@ -26,7 +46,7 @@ index 0f920afed6..95f4a097ab 100644 //#endregion //#region lib/types/client/contract/snapshot.js const EMPTY_LIST$1 = []; -@@ -3406,6 +3423,12 @@ window.__ModuleLoader__.load({ +@@ -3406,6 +3443,12 @@ window.__ModuleLoader__.load({ const message = error instanceof Error ? error.message : String(error); return message === "" ? fallback : message; } @@ -39,7 +59,7 @@ index 0f920afed6..95f4a097ab 100644 /** * Durable input identities suppress matching echoes in the same render. * The last input's Turn also distinguishes an empty opening control from -@@ -3501,6 +3524,7 @@ window.__ModuleLoader__.load({ +@@ -3501,6 +3544,7 @@ window.__ModuleLoader__.load({ if (id !== fileOpenRequest.current) return; setFileOpenError({ path, @@ -47,7 +67,7 @@ index 0f920afed6..95f4a097ab 100644 message: openFailureMessage(error, t("fileOpen.unknown")) }); setFileOpenBusy(false); -@@ -3661,7 +3685,7 @@ window.__ModuleLoader__.load({ +@@ -3661,7 +3705,7 @@ window.__ModuleLoader__.load({ busy: fileOpenBusy, onClose: closeFileOpenError, onRetry: () => { @@ -56,7 +76,7 @@ index 0f920afed6..95f4a097ab 100644 }, t }) -@@ -4439,7 +4463,7 @@ window.__ModuleLoader__.load({ +@@ -4439,7 +4483,7 @@ window.__ModuleLoader__.load({ * Policy reads select this seat's own conclusion, so a mode change re-renders * only seats whose visibility actually changes. */ @@ -65,7 +85,7 @@ index 0f920afed6..95f4a097ab 100644 const bottom = useChatNodeBottom(nodeKey); const node = useChatNode(nodeKey); const routedNode = node; -@@ -4528,6 +4552,7 @@ window.__ModuleLoader__.load({ +@@ -4528,6 +4572,7 @@ window.__ModuleLoader__.load({ if (routedNode === void 0 || owner === null) return null; const routedOwner = { ...owner, @@ -73,7 +93,7 @@ index 0f920afed6..95f4a097ab 100644 node: routedNode }; const flowKey = groupPart === void 0 || groupPart === "response" ? routedNode.key : JSON.stringify([routedNode.key, groupPart]); -@@ -5025,6 +5050,7 @@ window.__ModuleLoader__.load({ +@@ -5025,6 +5070,7 @@ window.__ModuleLoader__.load({ "compactionTitle": "Sixlwa_compactionTitle", "contextRow": "Sixlwa_contextRow", "fileCard": "Sixlwa_fileCard", @@ -81,7 +101,7 @@ index 0f920afed6..95f4a097ab 100644 "fileContent": "Sixlwa_fileContent", "fileIcon": "Sixlwa_fileIcon", "fileMeta": "Sixlwa_fileMeta", -@@ -5991,6 +6017,7 @@ window.__ModuleLoader__.load({ +@@ -5991,6 +6037,7 @@ window.__ModuleLoader__.load({ if (code === "INTERRUPTED") return t("message.failure.interrupted"); if (code === "ACCOUNT_SIGN_IN_REQUIRED") return t("message.failure.accountSignInRequired"); if (code === "QUOTA" || code === "ACCOUNT_QUOTA") return t("message.failure.quota"); @@ -89,7 +109,7 @@ index 0f920afed6..95f4a097ab 100644 return code === "AUTH" ? t("message.failure.auth") : message; } function ModelRetryItem({ node, active, t }) { -@@ -6107,7 +6134,7 @@ window.__ModuleLoader__.load({ +@@ -6107,7 +6154,7 @@ window.__ModuleLoader__.load({ /** Occupant used by rows that do not own the message-source seat. */ const NO_MESSAGE_SOURCE = () => null; /** Right-aligned bubble shared by user and steering rows. */ @@ -98,7 +118,7 @@ index 0f920afed6..95f4a097ab 100644 const parts = (0, react.useMemo)(() => contentParts(content), [content]); const prepared = (0, react.useMemo)(() => ({ text: parts.text, -@@ -6140,6 +6167,7 @@ window.__ModuleLoader__.load({ +@@ -6140,6 +6187,7 @@ window.__ModuleLoader__.load({ }, { fallback: null }), renderFactorySlot("conversation.chat.user.content", { content: prepared, @@ -106,7 +126,7 @@ index 0f920afed6..95f4a097ab 100644 loadImage, facts, interactive -@@ -6178,8 +6206,9 @@ window.__ModuleLoader__.load({ +@@ -6178,8 +6226,9 @@ window.__ModuleLoader__.load({ * @param props - the pending submission, authorized image loader, and render seats. * @returns the echoed user bubble. */ @@ -117,7 +137,7 @@ index 0f920afed6..95f4a097ab 100644 node: UNDECORATED_ROW, renderSlotChain: NO_MESSAGE_SOURCE, content: (0, react.useMemo)(() => submission.text === "" ? [] : [{ -@@ -6218,12 +6247,13 @@ window.__ModuleLoader__.load({ +@@ -6218,12 +6267,13 @@ window.__ModuleLoader__.load({ * declared by the `user` registration alone, so a steering row renders the same * bubble without asking for it. */ @@ -132,7 +152,7 @@ index 0f920afed6..95f4a097ab 100644 interactive: true, renderFactorySlot, loadImage, -@@ -6395,6 +6425,10 @@ window.__ModuleLoader__.load({ +@@ -6395,6 +6445,10 @@ window.__ModuleLoader__.load({ /** Render the flow without adding a DOM parent or changing keyed row positions. */ const ChatFlow = (0, react.memo)(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState, useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions }) { const nodeStore = useChat((snapshot) => snapshot.nodes); @@ -143,7 +163,7 @@ index 0f920afed6..95f4a097ab 100644 const running = useSession((snapshot) => snapshot.running); const disconnected = useConnectionState((state) => state === "disconnected" || state === "connecting"); const latestTurnAnchor = useChat((snapshot) => snapshot.navigation.items().at(-1)?.anchorKey); -@@ -6432,6 +6466,8 @@ window.__ModuleLoader__.load({ +@@ -6432,6 +6486,8 @@ window.__ModuleLoader__.load({ ...seatProps, key: chatRenderKey(entry), nodeKey: entry.key, @@ -152,7 +172,7 @@ index 0f920afed6..95f4a097ab 100644 ...entry.groupPart === void 0 ? {} : { groupPart: entry.groupPart } }); case "group": return (0, react.createElement)(ChatGroupSeat, { -@@ -6451,6 +6487,7 @@ window.__ModuleLoader__.load({ +@@ -6451,6 +6507,7 @@ window.__ModuleLoader__.load({ for (const [index, item] of pendingInputs.entries()) { const row = isLocalSubmission(item) ? (0, react_jsx_runtime.jsx)(PendingSubmissionBubble, { submission: item, @@ -160,7 +180,7 @@ index 0f920afed6..95f4a097ab 100644 loadImage, renderFactorySlot, t -@@ -6655,7 +6692,8 @@ window.__ModuleLoader__.load({ +@@ -6655,7 +6712,8 @@ window.__ModuleLoader__.load({ "message.failure.accountSignedOut": "任务已因退出 DeepSeek 登录而停止。", "message.failure.interrupted": "DSH 进程在本轮完成前退出(例如被强制结束、崩溃或关闭超时),重新启动后本轮已中止。未完成的工具调用结果未知。", "message.failure.accountSignInRequired": "请先登录 DeepSeek,并确认请求地址支持账号认证。", @@ -170,7 +190,7 @@ index 0f920afed6..95f4a097ab 100644 "message.turnError": "本轮运行失败", "message.maxTokens": "已达到输出 token 上限", "message.maxTokens.hint": "回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。", -@@ -6853,7 +6891,8 @@ window.__ModuleLoader__.load({ +@@ -6853,7 +6911,8 @@ window.__ModuleLoader__.load({ "message.failure.accountSignedOut": "Stopped because you signed out of DeepSeek.", "message.failure.interrupted": "The DSH process exited before this turn finished (for example, it was killed, crashed, or timed out while shutting down), so the turn was closed when DSH restarted. Unfinished tool calls have unknown outcomes.", "message.failure.accountSignInRequired": "Sign in to DeepSeek and ensure the request destination supports account authentication.", @@ -180,7 +200,7 @@ index 0f920afed6..95f4a097ab 100644 "message.turnError": "This turn failed", "message.maxTokens": "Output token limit reached", "message.maxTokens.hint": "The reply was cut off; earlier output is preserved in the conversation. Send \"continue\" to let the model resume.", -@@ -9257,11 +9296,12 @@ window.__ModuleLoader__.load({ +@@ -9257,11 +9316,12 @@ window.__ModuleLoader__.load({ * @param props - prepared content, facts, authorized image loader, and framework Factory/locale seats. * @returns the content stack using the existing user-message layout. */ @@ -195,7 +215,7 @@ index 0f920afed6..95f4a097ab 100644 return (0, react_jsx_runtime.jsxs)("div", { className: MessageItem_module_css_default.userStack, children: [ -@@ -9273,8 +9313,11 @@ window.__ModuleLoader__.load({ +@@ -9273,8 +9333,11 @@ window.__ModuleLoader__.load({ loadImage, align: "end", compact: compactImages @@ -209,7 +229,7 @@ index 0f920afed6..95f4a097ab 100644 title: attachment.file.name, children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.FileTypeIcon, { path: attachment.file.name, -@@ -9303,8 +9346,9 @@ window.__ModuleLoader__.load({ +@@ -9303,8 +9366,9 @@ window.__ModuleLoader__.load({ label: t("message.extraBlock"), payload: block, truncatedLabel: truncated @@ -220,7 +240,7 @@ index 0f920afed6..95f4a097ab 100644 referenceLabels.length > 0 && (0, react_jsx_runtime.jsx)("div", { className: MessageItem_module_css_default.referenceSummary, children: t("message.referenceSummary", { labels: referenceLabels.join(t("message.referenceSeparator")) }) -@@ -15160,6 +15204,7 @@ window.__ModuleLoader__.load({ +@@ -15160,6 +15224,7 @@ window.__ModuleLoader__.load({ "remote", "remote.session", "sidebarRight", @@ -228,7 +248,7 @@ index 0f920afed6..95f4a097ab 100644 "fileDrag" ]; /** -@@ -15257,6 +15302,14 @@ window.__ModuleLoader__.load({ +@@ -15257,6 +15322,14 @@ window.__ModuleLoader__.load({ }); const transcriptView = new TranscriptViewPolicy(chatSettings, "dshDesktop" in globalThis ? "standard" : DEFAULT_TRANSCRIPT_VIEW_MODE); const presentation = derivePresentationPolicy(transcriptView.mode, transcriptView.collapseTiming, transcriptView.foldedReplies); @@ -243,7 +263,7 @@ index 0f920afed6..95f4a097ab 100644 const performancePolicy = new PerformanceUsagePolicy(chatSettings); ctx.effect(() => () => { transcriptView.dispose(); -@@ -15268,6 +15321,7 @@ window.__ModuleLoader__.load({ +@@ -15268,6 +15341,7 @@ window.__ModuleLoader__.load({ name: "conversation.chat.user.content", scope: "session", locale: NS, @@ -251,7 +271,7 @@ index 0f920afed6..95f4a097ab 100644 children: { "conversation.chat.user.images": { kind: "single", scope: "session" -@@ -15322,7 +15376,7 @@ window.__ModuleLoader__.load({ +@@ -15322,7 +15396,7 @@ window.__ModuleLoader__.load({ name: "conversation.chat.flow", locale: NS, store: chatStore, @@ -260,9 +280,11 @@ index 0f920afed6..95f4a097ab 100644 kind: "keyed", scope: "session", inject: CHAT_NODE_INJECT -@@ -15374,10 +15428,18 @@ window.__ModuleLoader__.load({ +@@ -15373,11 +15447,20 @@ window.__ModuleLoader__.load({ + dispatchInteraction: (gesture) => ctx.uiConversation.interactions.dispatch(sessionId, gesture), fileMentions: (owner) => ctx.get("chatFileMentions")?.forClosing(owner, sessionId), openFile: async (path, options) => { ++ ({ path, options } = normalizeOpenFileTarget(path, options)); const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd; - const url = fileAddressFor(sessionId, cwd, resolveWorkspacePath(session.projections.faceOf("workingDirectory").getSnapshot() ?? void 0, path)); - if (options?.line === void 0) ctx.sidebarRight.openResource(url); @@ -284,7 +306,7 @@ index 0f920afed6..95f4a097ab 100644 openExternalLink: (url) => { if (linkOpening.getSnapshot() === "sidebar" && ctx.get("sidebarRightTabs")?.get("browser") !== void 0) ctx.sidebarRight.openTab("browser", { params: { url } }); diff --git a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/MessageItem.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/MessageItem.d.ts -index ea989f7223..1c9c40ffc1 100644 +index ea989f7..1c9c40f 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/MessageItem.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/MessageItem.d.ts @@ -21,8 +21,9 @@ export declare function PendingSteeringBubble({ content, loadImage, renderFactor @@ -308,7 +330,7 @@ index ea989f7223..1c9c40ffc1 100644 renderSlotChain: PropsRenderSlots<"conversation.chat.user.before">["renderSlotChain"]; }) => import("react").JSX.Element>; diff --git a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/UserContent.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/UserContent.d.ts -index ece0cd8b40..14f86da777 100644 +index ece0cd8..14f86da 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/UserContent.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/chat/UserContent.d.ts @@ -5,5 +5,5 @@ import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots'; @@ -320,7 +342,7 @@ index ece0cd8b40..14f86da777 100644 //# sourceMappingURL=UserContent.d.ts.map \ No newline at end of file diff --git a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/contract/slots.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/contract/slots.d.ts -index d278964e9e..dc5c2a4252 100644 +index d278964..dc5c2a4 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/contract/slots.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/contract/slots.d.ts @@ -34,12 +34,18 @@ export type UsePresentation = SnapshotSelectorHook; @@ -401,15 +423,8 @@ index d278964e9e..dc5c2a4252 100644 /** * Command row keyed by the command name. The component receives the folded * command lifecycle and linked compaction when present. Reusing a key -@@ -453,4 +476,4 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { - }; - } - } --//# sourceMappingURL=slots.d.ts.map -\ No newline at end of file -+//# sourceMappingURL=slots.d.ts.map diff --git a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/index.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/index.d.ts -index 97ac6b9a35..c4cd0047a2 100644 +index 97ac6b9..c4cd004 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/index.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-chat/lib/types/client/index.d.ts @@ -1,4 +1,10 @@ diff --git a/patches/@deepseek-ai+dsh-client-ui-deliverables+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-client-ui-deliverables+0.2.2-alpha.2.patch index bc3225d57..01935a252 100644 --- a/patches/@deepseek-ai+dsh-client-ui-deliverables+0.2.2-alpha.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-deliverables+0.2.2-alpha.2.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/client.js -index 3d7675f21b..8951693184 100644 +index 3d7675f..559b892 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/client.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/client.js -@@ -1271,19 +1271,82 @@ window.__ModuleLoader__.load({ +@@ -1271,19 +1271,105 @@ window.__ModuleLoader__.load({ * @returns The resolver MarkdownText consumes; the full path rides `title`, * the same disambiguator the row's chips carry. */ @@ -10,10 +10,11 @@ index 3d7675f21b..8951693184 100644 + * A known or heuristic path. Folders open in the system file manager; + * files stay on the document-preview opener. `kind` selects the icon. + */ -+ function fileMention(path, kind, openFile, label) { ++ function fileMention(path, kind, openFile, label, line) { + return { + open: () => { + if (kind === "folder") openFile(path, { revealInFileManager: true }); ++ else if (line !== void 0) openFile(path, { line }); + else openFile(path); + }, + label: label(path, kind), @@ -22,6 +23,24 @@ index 3d7675f21b..8951693184 100644 + }; + } + /** ++ * Split a trailing `#L` or `:` suffix from a trimmed candidate ++ * path, returning `{ path, line }` where `line` is a 1-based safe integer ++ * when the suffix names a positive line number. ++ */ ++ function splitLineSuffix(candidate) { ++ const hash = /^(.*?)#L(\d+)(?:C\d+)?(?:-L?\d+(?:C\d+)?)?$/.exec(candidate); ++ if (hash !== null) { ++ const line = Number(hash[2]); ++ return line >= 1 && Number.isSafeInteger(line) ? { path: hash[1], line } : { path: hash[1] }; ++ } ++ const colon = /^(.*?):(\d+)(?::\d+)?(?:-\d+(?::\d+)?)?$/.exec(candidate); ++ if (colon !== null && colon[1] !== "" && !/^[a-z]:$/i.test(colon[1])) { ++ const line = Number(colon[2]); ++ return line >= 1 && Number.isSafeInteger(line) ? { path: colon[1], line } : { path: colon[1] }; ++ } ++ return { path: candidate }; ++ } ++ /** + * Resolve local path references that were not produced by a tool in the + * current turn. Branch names (`codex/foo`, `origin/main`) and bare tokens + * (`package.json`, `console.log`) stay inert: a relative slash path is a @@ -38,9 +57,9 @@ index 3d7675f21b..8951693184 100644 + function localPathReference(value) { + const candidate = value.trim(); + if (candidate === "" || /[\r\n]/.test(candidate)) return void 0; -+ if (/^[a-z][a-z\d+.-]*:/i.test(candidate) && !/^[a-z]:[\\/]/i.test(candidate)) return void 0; -+ const path = candidate.replace(/(?:#L\d+(?:C\d+)?|:\d+(?::\d+)?)$/, ""); ++ const { path, line } = splitLineSuffix(candidate); + if (path === "") return void 0; ++ if (/^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path)) return void 0; + if (/^@[^\\/@\s]+\/[^\\/@\s]+(?:@[^\\/\s]+)?$/.test(path)) return void 0; + if (/^[^@\s\\/]+@[^@\s\\/]+\.[A-Za-z]{2,}$/.test(path)) return void 0; + if (/^v?\d+(?:\.\d+){1,4}(?:-[A-Za-z0-9.-]+)?$/.test(path)) return void 0; @@ -52,9 +71,11 @@ index 3d7675f21b..8951693184 100644 + const separated = /[\\/]/.test(path); + const worktree = segments.some((segment) => /[A-Z]/.test(rawSuffix(segment))); + if (!explicit && !trailingDir && !(separated && extension !== "") && !worktree) return void 0; ++ const kind = !trailingDir && extension !== "" ? "file" : "folder"; + return { + path, -+ kind: !trailingDir && extension !== "" ? "file" : "folder" ++ kind, ++ ...kind === "file" && line !== void 0 ? { line } : {} + }; + } + /** Characters after the segment's last dot, or "" when there is none. */ @@ -75,8 +96,8 @@ index 3d7675f21b..8951693184 100644 + } function producedFileMentions(paths, openFile, label, operations = []) { return { resolve(value) { - const matches = new Set(paths.filter((path) => path === value || basename(path) === value)); - for (const operation of operations) if (operation.argumentPath === value) matches.add(operation.path); +- const matches = new Set(paths.filter((path) => path === value || basename(path) === value)); +- for (const operation of operations) if (operation.argumentPath === value) matches.add(operation.path); - const path = matches.size === 1 ? matches.values().next().value : void 0; - if (path === void 0) return void 0; - return { @@ -86,15 +107,19 @@ index 3d7675f21b..8951693184 100644 - label: label(path), - title: path - }; ++ const suffix = splitLineSuffix(value.trim()); ++ const target = suffix.path; ++ const matches = new Set(paths.filter((path) => path === value || basename(path) === value || path === target || basename(path) === target)); ++ for (const operation of operations) if (operation.argumentPath === value || operation.argumentPath === target) matches.add(operation.path); + const known = matches.size === 1 ? matches.values().next().value : void 0; -+ if (known !== void 0) return fileMention(known, "file", openFile, label); ++ if (known !== void 0) return fileMention(known, "file", openFile, label, suffix.line); + const local = localPathReference(value); + if (local === void 0) return void 0; -+ return fileMention(local.path, local.kind, openFile, label); ++ return fileMention(local.path, local.kind, openFile, label, local.line); } }; } //#endregion -@@ -2104,6 +2167,7 @@ window.__ModuleLoader__.load({ +@@ -2104,6 +2190,7 @@ window.__ModuleLoader__.load({ "presented.hostError": "无法读取主机桌面信息", "presented.preview": "在侧边栏预览", "presented.previewButton": "在侧边栏打开 {name}", @@ -102,7 +127,7 @@ index 3d7675f21b..8951693184 100644 "presented.previewCard": "在侧边栏预览 {name}", "presented.all": "全部 {count} 个文件", "presented.expandAria": "展开全部 {count} 个交付文件", -@@ -2164,6 +2228,7 @@ window.__ModuleLoader__.load({ +@@ -2164,6 +2251,7 @@ window.__ModuleLoader__.load({ "presented.hostError": "Could not read the Host desktop information", "presented.preview": "Preview in sidebar", "presented.previewButton": "Open {name} in sidebar", @@ -110,7 +135,7 @@ index 3d7675f21b..8951693184 100644 "presented.previewCard": "Preview {name} in sidebar", "presented.all": "All {count} files", "presented.expandAria": "Show all {count} delivered files", -@@ -2672,8 +2737,7 @@ window.__ModuleLoader__.load({ +@@ -2672,8 +2760,7 @@ window.__ModuleLoader__.load({ ctx.provide("chatFileMentions", { forClosing(owner) { const paths = selectProducedFiles(owner); const presented = presentedForClosing(owner); @@ -121,7 +146,7 @@ index 3d7675f21b..8951693184 100644 } //#endregion diff --git a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/locales.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/locales.d.ts -index 59725309e1..6ae6fbba70 100644 +index 5972530..6ae6fbb 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/locales.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/locales.d.ts @@ -13,6 +13,7 @@ export declare const zh: { @@ -133,7 +158,7 @@ index 59725309e1..6ae6fbba70 100644 'presented.all': string; 'presented.expandAria': string; diff --git a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/turn-deliverables.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/turn-deliverables.d.ts -index 14677b6dfc..6fe940766d 100644 +index 14677b6..a5cfd4e 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/turn-deliverables.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/types/client/turn-deliverables.d.ts @@ -82,5 +82,5 @@ export { basename } from '../presented.ts'; @@ -141,7 +166,5 @@ index 14677b6dfc..6fe940766d 100644 * the same disambiguator the row's chips carry. */ -export declare function producedFileMentions(paths: readonly string[], openFile: (path: string) => void, label: (path: string) => string, operations?: readonly Pick[]): MarkdownFileMentions; --//# sourceMappingURL=turn-deliverables.d.ts.map -\ No newline at end of file -+export declare function producedFileMentions(paths: readonly string[], openFile: (path: string, options?: { readonly revealInFileManager?: boolean }) => void, label: (path: string, kind?: 'file' | 'folder') => string, operations?: readonly Pick[]): MarkdownFileMentions; -+//# sourceMappingURL=turn-deliverables.d.ts.map ++export declare function producedFileMentions(paths: readonly string[], openFile: (path: string, options?: { readonly line?: number; readonly revealInFileManager?: boolean }) => void, label: (path: string, kind?: 'file' | 'folder') => string, operations?: readonly Pick[]): MarkdownFileMentions; + //# sourceMappingURL=turn-deliverables.d.ts.map diff --git a/patches/@deepseek-ai+dsh-client-ui-primitives+0.2.2-alpha.2.patch b/patches/@deepseek-ai+dsh-client-ui-primitives+0.2.2-alpha.2.patch index 3a09719a5..a60f23a8c 100644 --- a/patches/@deepseek-ai+dsh-client-ui-primitives+0.2.2-alpha.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-primitives+0.2.2-alpha.2.patch @@ -1,18 +1,41 @@ diff --git a/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js b/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js -index 5b6450c675..4b5673c9d5 100644 +index 5b6450c..f0dd986 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js +++ b/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js -@@ -10428,7 +10428,7 @@ function renderNode(node, key, context) { +@@ -10006,10 +10006,20 @@ function parseFileLink(value) { + } catch (_error) { + return; + } ++ let colonLine; ++ if (hash < 0) { ++ const colon = /^(.*?):([1-9]\d*)(?::[1-9]\d*)?(?:-[1-9]\d*(?::[1-9]\d*)?)?$/.exec(path); ++ if (colon !== null && colon[1] !== "" && !/^[a-z]:$/i.test(colon[1]) && (/[\\/]/.test(colon[1]) || /\.[a-z][a-z\d]{0,9}$/i.test(colon[1]))) { ++ const parsedLine = Number(colon[2]); ++ if (!Number.isSafeInteger(parsedLine)) return void 0; ++ path = colon[1]; ++ colonLine = parsedLine; ++ } ++ } + if (path.length === 0 || /[\u0000-\u001f\u007f]/.test(path) || /^[\\/]{2}/.test(path) || /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path)) return void 0; +- if (hash < 0) return { path }; ++ if (hash < 0) return colonLine === void 0 ? { path } : { path, line: colonLine }; + const fragment = value.slice(hash + 1); +- const match = /^L([1-9]\d*)(?:-L([1-9]\d*))?$/.exec(fragment); ++ const match = /^L([1-9]\d*)(?:C[1-9]\d*)?(?:-L?([1-9]\d*)(?:C[1-9]\d*)?)?$/.exec(fragment); + if (match === null) return void 0; + const line = Number(match[1]); + const end = match[2] === void 0 ? line : Number(match[2]); +@@ -10428,7 +10438,7 @@ function renderNode(node, key, context) { "aria-label": mention.label, onClick: mention.open, children: [jsx(LinkIconMedium, { - kind: classifyLinkPath(value), -+ kind: mention.kind === "folder" ? "folder" : classifyLinkPath(value), ++ kind: mention.kind === "folder" ? "folder" : classifyLinkPath(mention.title || value), className: markdownCss.linkIcon }), value] }) }, key); diff --git a/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/types/markdown/render.d.ts b/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/types/markdown/render.d.ts -index bf96376990..cbbbf81e48 100644 +index bf96376..cbbbf81 100644 --- a/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/types/markdown/render.d.ts +++ b/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/types/markdown/render.d.ts @@ -86,6 +86,11 @@ export interface MarkdownFileMentions { diff --git a/test/local-path-links.test.ts b/test/local-path-links.test.ts index 7fd7f3659..1d3861d73 100644 --- a/test/local-path-links.test.ts +++ b/test/local-path-links.test.ts @@ -5,6 +5,7 @@ import { patchPath } from './patch-path' interface LocalPath { path: string kind: 'file' | 'folder' + line?: number } /** @@ -24,14 +25,16 @@ async function loadLocalPathReference(): Promise< .filter((line) => line.startsWith('+') && !line.startsWith('+++')) .map((line) => line.slice(1)) .join('\n') + const splitLineSuffix = extractFunction(added, 'splitLineSuffix') const source = extractFunction(added, 'localPathReference') const rawSuffix = extractFunction(added, 'rawSuffix') const extension = extractFunction(added, 'fileExtensionOf') + expect(splitLineSuffix).toBeDefined() expect(source).toBeDefined() expect(rawSuffix).toBeDefined() expect(extension).toBeDefined() return new Function( - `${source}\n${rawSuffix}\n${extension}\nreturn localPathReference` + `${splitLineSuffix}\n${source}\n${rawSuffix}\n${extension}\nreturn localPathReference` )() as (value: string) => LocalPath | undefined } @@ -181,10 +184,145 @@ describe('assistant local path links', () => { }) }) - it('strips line and column suffixes from resolved paths', async () => { + it('strips line and column suffixes from resolved paths and preserves positive line numbers', async () => { const localPathReference = await loadLocalPathReference() - expect(localPathReference('src/main.ts#L42')).toEqual({ path: 'src/main.ts', kind: 'file' }) - expect(localPathReference('src/main.ts:42:7')).toEqual({ path: 'src/main.ts', kind: 'file' }) + expect(localPathReference('src/main.ts#L42')).toEqual({ + path: 'src/main.ts', + kind: 'file', + line: 42 + }) + expect(localPathReference('src/main.ts#L42C7')).toEqual({ + path: 'src/main.ts', + kind: 'file', + line: 42 + }) + expect(localPathReference('src/main.ts#L42-L50')).toEqual({ + path: 'src/main.ts', + kind: 'file', + line: 42 + }) + expect(localPathReference('src/main.ts:42:7')).toEqual({ + path: 'src/main.ts', + kind: 'file', + line: 42 + }) + expect(localPathReference('/Users/alex/Library/Logs/BISHENG/harness.log:10092')).toEqual({ + path: '/Users/alex/Library/Logs/BISHENG/harness.log', + kind: 'file', + line: 10092 + }) + expect(localPathReference('C:\\Users\\me\\file.txt:15')).toEqual({ + path: 'C:\\Users\\me\\file.txt', + kind: 'file', + line: 15 + }) + expect(localPathReference('src/main.ts:0')).toEqual({ + path: 'src/main.ts', + kind: 'file' + }) + expect(localPathReference('/private/tmp/somedir:10')).toEqual({ + path: '/private/tmp/somedir', + kind: 'folder' + }) + }) + + it('forwards line numbers from produced and heuristic mentions to openFile', async () => { + const deliverables = await readFile( + 'node_modules/@deepseek-ai/dsh-client-ui-deliverables/lib/client.js', + 'utf8' + ) + const basenameFn = extractFunction(deliverables, 'basename') + const producedFileMentionsFn = extractFunction(deliverables, 'producedFileMentions') + const fileMentionFn = extractFunction(deliverables, 'fileMention') + const splitLineSuffixFn = extractFunction(deliverables, 'splitLineSuffix') + const localPathReferenceFn = extractFunction(deliverables, 'localPathReference') + const rawSuffixFn = extractFunction(deliverables, 'rawSuffix') + const fileExtensionOfFn = extractFunction(deliverables, 'fileExtensionOf') + expect(basenameFn).toBeDefined() + expect(producedFileMentionsFn).toBeDefined() + + const createMentions = new Function( + `${basenameFn}\n${producedFileMentionsFn}\n${fileMentionFn}\n${splitLineSuffixFn}\n${localPathReferenceFn}\n${rawSuffixFn}\n${fileExtensionOfFn}\nreturn producedFileMentions` + )() as ( + paths: readonly string[], + openFile: (path: string, options?: { line?: number; revealInFileManager?: boolean }) => void, + label: (path: string, kind?: string) => string + ) => { resolve: (value: string) => { open: () => void; title: string; kind: string } | undefined } + + const calls: Array<{ path: string; options?: { line?: number; revealInFileManager?: boolean } }> = [] + const mentions = createMentions( + ['/workspace/src/main.ts'], + (path, options) => calls.push({ path, options }), + (path) => path + ) + + const producedByBasename = mentions.resolve('main.ts:42:7') + expect(producedByBasename?.title).toBe('/workspace/src/main.ts') + producedByBasename?.open() + + const heuristicWithLine = mentions.resolve('/Users/alex/Library/Logs/BISHENG/harness.log:10092') + expect(heuristicWithLine?.title).toBe('/Users/alex/Library/Logs/BISHENG/harness.log') + heuristicWithLine?.open() + + expect(calls).toEqual([ + { path: '/workspace/src/main.ts', options: { line: 42 } }, + { path: '/Users/alex/Library/Logs/BISHENG/harness.log', options: { line: 10092 } } + ]) + }) + + it('parses colon and GitHub line suffixes in Markdown file links and normalizes chat openFile targets', async () => { + const primitives = await readFile( + 'node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js', + 'utf8' + ) + const parseFileLinkFn = extractFunction(primitives, 'parseFileLink') + expect(parseFileLinkFn).toBeDefined() + const parseFileLink = new Function(`${parseFileLinkFn}\nreturn parseFileLink`)() as ( + value: string + ) => { path: string; line?: number } | undefined + + expect(parseFileLink('/Users/alex/Library/Logs/BISHENG/harness.log:10092')).toEqual({ + path: '/Users/alex/Library/Logs/BISHENG/harness.log', + line: 10092 + }) + expect(parseFileLink('harness.log:10092')).toEqual({ + path: 'harness.log', + line: 10092 + }) + expect(parseFileLink('src/main.ts:42:7')).toEqual({ + path: 'src/main.ts', + line: 42 + }) + expect(parseFileLink('src/main.ts#L42C7-L50C2')).toEqual({ + path: 'src/main.ts', + line: 42 + }) + expect(parseFileLink('http://example.com:8080')).toBeUndefined() + expect(parseFileLink('localhost:8080')).toBeUndefined() + + const chat = await readFile( + 'node_modules/@deepseek-ai/dsh-client-ui-chat/lib/client.js', + 'utf8' + ) + const normalizeFn = extractFunction(chat, 'normalizeOpenFileTarget') + expect(normalizeFn).toBeDefined() + const normalizeOpenFileTarget = new Function(`${normalizeFn}\nreturn normalizeOpenFileTarget`)() as ( + rawPath: string, + options?: { line?: number; revealInFileManager?: boolean } + ) => { path: string; options?: { line?: number; revealInFileManager?: boolean } } + + expect(normalizeOpenFileTarget('/Users/alex/Library/Logs/BISHENG/harness.log:10092')).toEqual({ + path: '/Users/alex/Library/Logs/BISHENG/harness.log', + options: { line: 10092 } + }) + expect(normalizeOpenFileTarget('src/main.ts#L18C3')).toEqual({ + path: 'src/main.ts', + options: { line: 18 } + }) + expect(normalizeOpenFileTarget('src/main.ts:18', { line: 99 })).toEqual({ + path: 'src/main.ts', + options: { line: 99 } + }) }) })