diff --git a/packages/ppt-runtime/adapter/lib/client.js b/packages/ppt-runtime/adapter/lib/client.js index ff5dcaa70..f71093fd3 100644 --- a/packages/ppt-runtime/adapter/lib/client.js +++ b/packages/ppt-runtime/adapter/lib/client.js @@ -620,6 +620,177 @@ button[data-desktop-ppt]:focus-visible{outline:2px solid var(--dsw-alias-label-p }) }); } + const OPENING_TEMPLATE_STYLE_ID = "dsh-ppt-opening-template"; + function ensureOpeningTemplateStyles() { + if (typeof document === "undefined" || document.getElementById(OPENING_TEMPLATE_STYLE_ID) !== null) return; + const tag = document.createElement("style"); + tag.id = OPENING_TEMPLATE_STYLE_ID; + tag.dataset.pluginCss = OPENING_TEMPLATE_STYLE_ID; + tag.textContent = ".dsh-ppt-opening-row{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-secondary)}.dsh-ppt-opening-row:not(:first-child){margin-top:8px;padding-top:8px;border-top:.5px solid var(--dsw-alias-border-l2)}.dsh-ppt-opening-file{flex:none;color:var(--dsw-static-amber-500)}.dsh-ppt-opening-file-fold{fill:var(--dsw-static-neutral-00);fill-opacity:.7}.dsh-ppt-opening-file-mark{fill:var(--dsw-static-neutral-00)}.dsh-ppt-opening-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:0;background:transparent;padding:0;color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 68%,var(--dsw-alias-label-primary));font:inherit;font-size:13px;line-height:20px;text-align:left;text-decoration:none;cursor:pointer}.dsh-ppt-opening-title:hover{color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,var(--dsw-alias-label-primary))}.dsh-ppt-opening-stage{position:relative;overflow:hidden;width:100%;aspect-ratio:16/9;border-radius:10px;background:var(--dsw-alias-bg-base)}.dsh-ppt-opening-stage>*{position:absolute;inset:0;width:100%;height:100%}.dsh-ppt-opening-dialog{box-sizing:border-box;width:min(760px,calc(100vw - 48px));max-height:calc(100dvh - 48px);margin:auto;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-panel,16px);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit}.dsh-ppt-opening-dialog::backdrop{background:color-mix(in srgb,var(--dsw-alias-label-primary) 40%,transparent)}.dsh-ppt-opening-dialog[open]{display:flex;flex-direction:column;gap:16px}.dsh-ppt-opening-dialog header{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsh-ppt-opening-dialog h2{margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:16px;line-height:24px;font-weight:600}.dsh-ppt-opening-close{display:grid;place-items:center;flex:none;width:28px;height:28px;padding:0;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:18px}.dsh-ppt-opening-pages{display:flex;align-items:center;justify-content:center;gap:16px;color:var(--dsw-alias-label-secondary);font-size:13px}.dsh-ppt-opening-pages button{display:grid;place-items:center;width:32px;height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:inherit;cursor:pointer;font:inherit}.dsh-ppt-opening-pages button:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.dsh-ppt-opening-title:focus-visible,.dsh-ppt-opening-dialog :focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}@media (prefers-reduced-motion:reduce){.dsh-ppt-opening-stage *{animation:none!important}}"; + document.head.appendChild(tag); + } + /** Orange document mark matching the PPT file icon: folded page and a white P. */ + function OpeningPptFileIcon() { + return (0, react_jsx_runtime.jsxs)("svg", { + className: "dsh-ppt-opening-file", + width: 14, + height: 14, + viewBox: "0 0 28 28", + fill: "none", + "aria-hidden": true, + focusable: false, + children: [ + (0, react_jsx_runtime.jsx)("path", { fill: "currentColor", d: "M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z" }), + (0, react_jsx_runtime.jsx)("path", { className: "dsh-ppt-opening-file-fold", d: "M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z" }), + (0, react_jsx_runtime.jsx)("path", { className: "dsh-ppt-opening-file-mark", transform: "translate(14 16) scale(1.22) translate(-14 -16)", d: "M11.0132 20.5V13.5H14.2132C14.8532 13.5 15.4032 13.6033 15.8632 13.81C16.3299 14.0167 16.6899 14.3167 16.9432 14.71C17.1966 15.0967 17.3232 15.5567 17.3232 16.09C17.3232 16.6167 17.1966 17.0733 16.9432 17.46C16.6899 17.8467 16.3299 18.1467 15.8632 18.36C15.4032 18.5667 14.8532 18.67 14.2132 18.67H12.1132L12.9932 17.81V20.5H11.0132ZM12.9932 18.02L12.1132 17.11H14.0932C14.5066 17.11 14.8132 17.02 15.0132 16.84C15.2199 16.66 15.3232 16.41 15.3232 16.09C15.3232 15.7633 15.2199 15.51 15.0132 15.33C14.8132 15.15 14.5066 15.06 14.0932 15.06H12.1132L12.9932 14.15V18.02Z" }) + ] + }); + } + /** Selected template pinned under the opening user message after PPT starts. */ + function OfficePptOpeningTemplateCard({ client, mode, sessionId, leading, t }) { + ensureOpeningTemplateStyles(); + const state = useMode(mode, sessionId); + const bindingRef = (0, react.useRef)({ client, sessionId }); + bindingRef.current = { client, sessionId }; + const catalogRevision = mode.revision(sessionId); + const [open, setOpen] = (0, react.useState)(false); + const [page, setPage] = (0, react.useState)(0); + const buttonRef = (0, react.useRef)(null); + const dialogRef = (0, react.useRef)(null); + const openedRef = (0, react.useRef)(false); + (0, react.useEffect)(() => { + if (leading !== true || !client?.bound || state.templates.length > 0 || state.error !== "") return; + const request = { client, sessionId }; + const seenRevision = catalogRevision; + mode.setLoading(sessionId, true); + let active = true; + loadTemplateState(client, t("templates.loadTimeout")).then((next) => { + if (!active) return; + const current = bindingRef.current; + mode.applyLoadedTemplates(current.sessionId, current.client, seenRevision, next, request); + }).catch((reason) => { + if (!active) return; + const current = bindingRef.current; + if (current.client !== request.client || modeKey(current.sessionId) !== modeKey(request.sessionId)) return; + if (mode.revision(request.sessionId) !== seenRevision) return; + mode.setError(request.sessionId, reason instanceof Error ? reason.message : String(reason)); + }); + return () => { + active = false; + }; + }, [ + catalogRevision, + client, + leading, + mode, + sessionId, + state.error, + state.templates.length, + t + ]); + const selected = state.activeMode === "ppt" ? state.templates.find((template) => template.id === state.selectedId) : void 0; + const pageCount = selected === void 0 ? 0 : templatePreviewPages(selected); + (0, react.useEffect)(() => { + const node = dialogRef.current; + if (!open || node === null) return; + if (!node.open) node.showModal(); + return () => { + if (node.open) node.close(); + }; + }, [open]); + (0, react.useEffect)(() => { + if (open) { + openedRef.current = true; + return; + } + if (openedRef.current) buttonRef.current?.focus(); + }, [open]); + if (leading !== true || selected === void 0) return null; + const closePreview = () => { + setOpen(false); + }; + return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "dsh-ppt-opening-row", + "data-ppt-opening-template": selected.id, + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(OpeningPptFileIcon, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + ref: buttonRef, + type: "button", + className: "dsh-ppt-opening-title", + "aria-haspopup": "dialog", + "aria-label": t("opening.preview", { name: selected.name }), + onClick: () => { + setPage(0); + setOpen(true); + }, + children: selected.name + })] + }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dialog", { + ref: dialogRef, + className: "dsh-ppt-opening-dialog", + "aria-modal": true, + "aria-labelledby": "dsh-ppt-opening-title", + onCancel: (event) => { + event.preventDefault(); + closePreview(); + }, + onKeyDown: (event) => { + if (event.key !== "Tab") return; + const fields = [...event.currentTarget.querySelectorAll("button:not(:disabled)")]; + const first = fields[0]; + const last = fields.at(-1); + if (event.shiftKey && (document.activeElement === first || !event.currentTarget.contains(document.activeElement))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !event.currentTarget.contains(document.activeElement))) { + event.preventDefault(); + first?.focus(); + } + }, + onClick: (event) => { + if (event.target !== event.currentTarget) return; + const rect = event.currentTarget.getBoundingClientRect(); + if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) closePreview(); + }, + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { + id: "dsh-ppt-opening-title", + children: selected.name + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + className: "dsh-ppt-opening-close", + "aria-label": t("opening.close"), + onClick: closePreview, + children: "×" + })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { + className: "dsh-ppt-opening-stage", + children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TemplatePreview, { + template: selected, + page + }, page) + }), pageCount > 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { + className: "dsh-ppt-opening-pages", + children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + "aria-label": t("opening.previous"), + disabled: page === 0, + onClick: () => { + setPage((current) => Math.max(0, current - 1)); + }, + children: "‹" + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { + role: "status", + children: t("opening.page", { page: page + 1, total: pageCount }) + }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { + type: "button", + "aria-label": t("opening.next"), + disabled: page >= pageCount - 1, + onClick: () => { + setPage((current) => Math.min(pageCount - 1, current + 1)); + }, + children: "›" + })] + })] + })] }); + } /** Selected-template reference shown only while the standard Session is blank. */ function OfficePptStandardInputAccessory(props) { if (props.session == null || !props.session.blank) return null; @@ -878,6 +1049,11 @@ button[data-desktop-ppt]:focus-visible{outline:2px solid var(--dsw-alias-label-p "mode.region": "演示文稿模式", "composer.selectedTemplate": "已选模板", "composer.removeTemplate": "取消选择模板", + "opening.preview": "预览模板 {name}", + "opening.close": "关闭预览", + "opening.previous": "上一页", + "opening.next": "下一页", + "opening.page": "第 {page} 页,共 {total} 页", "templates.title": "选择模板", "templates.migrated": "原模板已下架,已切换为工程蓝图。已有文件不受影响。", "templates.categories": "模板分类", @@ -971,6 +1147,11 @@ button[data-desktop-ppt]:focus-visible{outline:2px solid var(--dsw-alias-label-p "mode.region": "Presentation modes", "composer.selectedTemplate": "Selected template", "composer.removeTemplate": "Remove selected template", + "opening.preview": "Preview template {name}", + "opening.close": "Close preview", + "opening.previous": "Previous page", + "opening.next": "Next page", + "opening.page": "Page {page} of {total}", "templates.title": "Select template", "templates.migrated": "The previous template was retired. Engineering Blueprint is selected; existing files are unchanged.", "templates.categories": "Template categories", @@ -1122,6 +1303,13 @@ button[data-desktop-ppt]:focus-visible{outline:2px solid var(--dsw-alias-label-p locale: NS, inject: injectHero }, OfficePptStandardInputAccessory)); + ctx.slots.inject("conversation.chat.userMessageFooter", () => ctx.slots.register({ + name: "conversation.chat.userMessageFooter", + id: "dsh-ppt", + order: 20, + locale: NS, + inject: injectHero + }, OfficePptOpeningTemplateCard)); ctx.slots.inject("conversation.hero.dock", () => ctx.slots.register({ name: "conversation.hero.dock", id: "dsh-ppt", diff --git a/patches/@deepseek-ai+dsh-client-ui-chat+0.2.0-rc.2.patch b/patches/@deepseek-ai+dsh-client-ui-chat+0.2.0-rc.2.patch index c003ad35e..d029773fc 100644 --- a/patches/@deepseek-ai+dsh-client-ui-chat+0.2.0-rc.2.patch +++ b/patches/@deepseek-ai+dsh-client-ui-chat+0.2.0-rc.2.patch @@ -1,5 +1,5 @@ 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 f997683..e59d554 100644 +index f997683..e5482e0 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 @@ -115,6 +115,22 @@ window.__ModuleLoader__.load({ @@ -50,16 +50,23 @@ index f997683..e59d554 100644 return code === "AUTH" ? t("message.failure.auth") : message; } function ModelRetryItem({ node, active, t }) { -@@ -1329,7 +1347,7 @@ window.__ModuleLoader__.load({ +@@ -1329,12 +1347,13 @@ window.__ModuleLoader__.load({ }); } /** Right-aligned bubble shared by user and steering rows. */ - function UserStyleBubble({ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewAttachments, references, t }) { -+ function UserStyleBubble({ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewAttachments, references, onOpenUploadedFile, t }) { ++ function UserStyleBubble({ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], previewAttachments, references, onOpenUploadedFile, footer = null, t }) { const { text, attachments: contentAttachments, rest } = contentParts(content); const attachments = previewAttachments ?? contentAttachments; const compactImages = attachments.length > 1; -@@ -1349,9 +1367,13 @@ window.__ModuleLoader__.load({ + const truncated = (total) => t("json.truncated", { total }); +- const showBubble = text !== "" || rest.length > 0; ++ const hasBody = text !== "" || rest.length > 0; ++ const showBubble = hasBody || footer != null; + return (0, react_jsx_runtime.jsxs)("div", { + className: MessageItem_module_css_default.userRow, + "data-pending-steering": pending || void 0, +@@ -1349,9 +1368,13 @@ window.__ModuleLoader__.load({ images: [attachment.image], align: "end", compact: compactImages @@ -75,50 +82,66 @@ index f997683..e59d554 100644 children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.FileTypeIcon, { path: attachment.file.name, className: MessageItem_module_css_default.fileIcon -@@ -1411,7 +1433,7 @@ window.__ModuleLoader__.load({ +@@ -1369,11 +1392,11 @@ window.__ModuleLoader__.load({ + }), + showBubble && (0, react_jsx_runtime.jsxs)("div", { + className: MessageItem_module_css_default.bubble, +- children: [(0, _deepseek_ai_dsh_client_ui_primitives.projectUserText)(text, referenceLabels, skillNames, "skill", references), rest.map((block, i) => (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.JsonBlock, { ++ children: [hasBody ? (0, _deepseek_ai_dsh_client_ui_primitives.projectUserText)(text, referenceLabels, skillNames, "skill", references) : null, rest.map((block, i) => (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.JsonBlock, { + label: t("message.extraBlock"), + payload: block, + truncatedLabel: truncated +- }, i))] ++ }, i)), footer] + }), + referenceLabels.length > 0 && (0, react_jsx_runtime.jsx)("div", { + className: MessageItem_module_css_default.referenceSummary, +@@ -1411,7 +1434,7 @@ window.__ModuleLoader__.load({ * @param props - the session snapshot's pending submission and render seats. * @returns the echoed user bubble. */ - function PendingSubmissionBubble({ submission, renderMessageImages, t }) { -+ function PendingSubmissionBubble({ submission, renderMessageImages, onOpenUploadedFile, t }) { ++ function PendingSubmissionBubble({ submission, renderMessageImages, onOpenUploadedFile, footer = null, t }) { return (0, react_jsx_runtime.jsx)(UserStyleBubble, { content: (0, react.useMemo)(() => submission.text === "" ? [] : [{ type: "text", -@@ -1430,6 +1452,7 @@ window.__ModuleLoader__.load({ +@@ -1430,6 +1453,8 @@ window.__ModuleLoader__.load({ file: attachment.value }), [submission.attachments]), renderMessageImages, + onOpenUploadedFile, ++ footer, pending: submission.placement === "steering", echo: true, t, -@@ -1443,7 +1466,7 @@ window.__ModuleLoader__.load({ +@@ -1443,7 +1468,7 @@ window.__ModuleLoader__.load({ }); } /** User and admitted-steering keyed Chat renderer. */ - const UserMessageNodeView = (0, react.memo)(function UserMessageNodeView({ node, renderMessageImages, openFile, openSkill, t }) { -+ const UserMessageNodeView = (0, react.memo)(function UserMessageNodeView({ node, renderMessageImages, openFile, openSkill, onOpenUploadedFile, t }) { ++ const UserMessageNodeView = (0, react.memo)(function UserMessageNodeView({ node, renderMessageImages, openFile, openSkill, onOpenUploadedFile, messageFooter = null, t }) { const data = node.data; return (0, react_jsx_runtime.jsx)(UserStyleBubble, { content: data.content, -@@ -1452,6 +1475,7 @@ window.__ModuleLoader__.load({ +@@ -1452,6 +1477,8 @@ window.__ModuleLoader__.load({ openSkill }, renderMessageImages, + onOpenUploadedFile, ++ footer: messageFooter, ...data.referenceLabels === void 0 ? {} : { referenceLabels: data.referenceLabels }, ...data.skillNames === void 0 ? {} : { skillNames: data.skillNames }, t, -@@ -1665,7 +1689,7 @@ window.__ModuleLoader__.load({ +@@ -1665,7 +1692,7 @@ window.__ModuleLoader__.load({ * Policy reads select this seat's own conclusion, so a mode change re-renders * only seats whose visibility actually changes. */ - const ChatNodeSeat = (0, react.memo)(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t }) { -+ const ChatNodeSeat = (0, react.memo)(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, onOpenUploadedFile, t }) { ++ const ChatNodeSeat = (0, react.memo)(function ChatNodeSeat({ nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, onOpenUploadedFile, leadingUserKey = null, messageFooter = null, t }) { const node = useChatNode(nodeKey); const routedNode = node; const turn = turnOf$1(routedNode); -@@ -1729,6 +1753,7 @@ window.__ModuleLoader__.load({ +@@ -1729,24 +1756,30 @@ window.__ModuleLoader__.load({ cwd, openFile, openSkill, @@ -126,7 +149,14 @@ index f997683..e59d554 100644 inspectCall, forkAt, loadImage, -@@ -1741,6 +1766,7 @@ window.__ModuleLoader__.load({ + renderMessageImages, + fileMentions, +- turnProcess ++ turnProcess, ++ ...nodeKey === leadingUserKey && messageFooter != null ? { messageFooter } : {} + }, [ + node, + groupPart, cwd, openFile, openSkill, @@ -134,15 +164,73 @@ index f997683..e59d554 100644 inspectCall, forkAt, loadImage, -@@ -5108,6 +5134,7 @@ window.__ModuleLoader__.load({ + renderMessageImages, + fileMentions, +- turnProcess ++ turnProcess, ++ nodeKey, ++ leadingUserKey, ++ messageFooter + ]); + if (routedNode === void 0 || owner === null) return null; + const routedOwner = { +@@ -5072,6 +5105,28 @@ window.__ModuleLoader__.load({ + * The last input's Turn also distinguishes an empty opening control from + * one whose human input or trigger notice is already present. + */ ++ /** ++ * The opening user message is the footer host only when older history is ++ * fully loaded. A pending transcript echo hosts it until that node exists. ++ * @param hasMore - whether older history remains. ++ * @param order - durable chat node keys. ++ * @param kindAt - kind lookup for one key. ++ * @param pendingInputs - visible echoes and steering rows. ++ * @returns the single host key, or neither when the opening is unknown. ++ */ ++ function openingUserMessageTarget(hasMore, order, kindAt, pendingInputs) { ++ if (hasMore !== false) return { leadingUserKey: null, leadingPendingId: null }; ++ let leadingUserKey = null; ++ for (const key of order) { ++ if (kindAt(key) === "user") { ++ leadingUserKey = key; ++ break; ++ } ++ } ++ if (leadingUserKey != null) return { leadingUserKey, leadingPendingId: null }; ++ const pending = pendingInputs.find((item) => Object.hasOwn(item, "requestId") && item.placement !== "steering"); ++ return { leadingUserKey: null, leadingPendingId: pending?.requestId ?? null }; ++ } + function observedInputs(order, nodes) { + const observed = /* @__PURE__ */ new Set(); + let lastInputTurn; +@@ -5088,13 +5143,15 @@ window.__ModuleLoader__.load({ + lastInputTurn + }; + } +- const ChatNodeList = (0, react.memo)(function ChatNodeList({ entries, useChatGroup, pendingInputs, lastInputTurn, ...seatProps }) { ++ const ChatNodeList = (0, react.memo)(function ChatNodeList({ entries, useChatGroup, pendingInputs, lastInputTurn, leadingUserKey = null, leadingPendingId = null, messageFooter = null, ...seatProps }) { + const rows = entries.map((entry) => { + switch (entry.kind) { + case "node": return (0, react.createElement)(ChatNodeSeat, { + ...seatProps, + key: chatRenderKey(entry), + nodeKey: entry.key, ++ leadingUserKey, ++ messageFooter, + ...entry.groupPart === void 0 ? {} : { groupPart: entry.groupPart } + }); + case "group": return (0, react.createElement)(ChatGroupSeat, { +@@ -5108,7 +5165,9 @@ window.__ModuleLoader__.load({ }); const pendingRows = pendingInputs.map((item) => "requestId" in item ? (0, react_jsx_runtime.jsx)(PendingSubmissionBubble, { submission: item, + onOpenUploadedFile: seatProps.onOpenUploadedFile, renderMessageImages: seatProps.renderMessageImages, ++ footer: item.requestId === leadingPendingId ? messageFooter : null, t: seatProps.t }, item.requestId) : (0, react_jsx_runtime.jsx)(PendingSteeringBubble, { -@@ -5127,7 +5154,7 @@ window.__ModuleLoader__.load({ + content: item.content, +@@ -5127,7 +5186,7 @@ window.__ModuleLoader__.load({ * The chat view slot entry: pure component over the composed props; each * ordered business Node crosses the keyed renderer seat. */ @@ -151,7 +239,7 @@ index f997683..e59d554 100644 const order = useChat((s) => s.order); const groupedEntries = useConversation((snapshot) => snapshot.views.grouped("chat")?.entries); const entries = (0, react.useMemo)(() => groupedEntries ?? order.map((key) => ({ -@@ -5174,11 +5201,29 @@ window.__ModuleLoader__.load({ +@@ -5174,11 +5233,29 @@ window.__ModuleLoader__.load({ if (id !== fileOpenRequest.current) return; setFileOpenError({ path, @@ -181,7 +269,31 @@ index f997683..e59d554 100644 const closeFileOpenError = (0, react.useCallback)(() => { fileOpenRequest.current += 1; setFileOpenError(null); -@@ -5301,6 +5346,7 @@ window.__ModuleLoader__.load({ +@@ -5211,6 +5288,8 @@ window.__ModuleLoader__.load({ + pendingSubmissions, + visibleSubmissions + ]); ++ const openingTarget = openingUserMessageTarget(hasMore, order, (key) => nodeStore.get(key)?.kind, pendingInputs); ++ const openingFooter = openingTarget.leadingUserKey != null || openingTarget.leadingPendingId != null ? renderSlot("conversation.chat.userMessageFooter", { leading: true }) : null; + const renderMessageImages = (0, react.useCallback)((owner) => renderSlot("conversation.message.images", { + ...owner, + loadImage +@@ -5281,10 +5360,13 @@ window.__ModuleLoader__.load({ + openExternalLink, + openFile: requestOpenFile, + fileImages, +- children: (0, react_jsx_runtime.jsx)(ChatNodeList, { ++ children: (0, react_jsx_runtime.jsx)(ChatNodeList, { + entries, + pendingInputs, + lastInputTurn, ++ leadingUserKey: openingTarget.leadingUserKey, ++ leadingPendingId: openingTarget.leadingPendingId, ++ messageFooter: openingFooter, + nodeStore, + useChatGroup, + useChatNode, +@@ -5301,6 +5383,7 @@ window.__ModuleLoader__.load({ renderMessageImages, fileMentions, renderSlot, @@ -189,7 +301,7 @@ index f997683..e59d554 100644 t }) }), -@@ -5327,7 +5373,7 @@ window.__ModuleLoader__.load({ +@@ -5327,7 +5410,7 @@ window.__ModuleLoader__.load({ busy: fileOpenBusy, onClose: closeFileOpenError, onRetry: () => { @@ -198,7 +310,7 @@ index f997683..e59d554 100644 }, t }) -@@ -5522,7 +5568,8 @@ window.__ModuleLoader__.load({ +@@ -5522,7 +5605,8 @@ window.__ModuleLoader__.load({ "message.accountStopped": "任务已停止", "message.failure.accountSignedOut": "任务已因退出 DeepSeek 登录而停止。", "message.failure.accountSignInRequired": "请先登录 DeepSeek,并确认请求地址支持账号认证。", @@ -208,7 +320,7 @@ index f997683..e59d554 100644 "message.turnError": "本轮运行失败", "message.maxTokens": "已达到输出 token 上限", "message.maxTokens.hint": "回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。", -@@ -5711,7 +5758,8 @@ window.__ModuleLoader__.load({ +@@ -5711,7 +5795,8 @@ window.__ModuleLoader__.load({ "message.accountStopped": "Task stopped", "message.failure.accountSignedOut": "Stopped because you signed out of DeepSeek.", "message.failure.accountSignInRequired": "Sign in to DeepSeek and ensure the request destination supports account authentication.", @@ -218,7 +330,7 @@ index f997683..e59d554 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.", -@@ -12265,7 +12313,8 @@ window.__ModuleLoader__.load({ +@@ -12265,7 +12350,8 @@ window.__ModuleLoader__.load({ "configForms", "remote", "remote.session", @@ -228,7 +340,7 @@ index f997683..e59d554 100644 ]; /** * Mount all Chat-owned contributions. -@@ -12355,6 +12404,14 @@ window.__ModuleLoader__.load({ +@@ -12355,6 +12441,14 @@ window.__ModuleLoader__.load({ }, LinkOpeningRow)); }); const transcriptView = new TranscriptViewPolicy(chatSettings, "dshDesktop" in globalThis ? "standard" : DEFAULT_TRANSCRIPT_VIEW_MODE); @@ -243,7 +355,18 @@ index f997683..e59d554 100644 const presentation = derivePresentationPolicy(transcriptView.mode); const performancePolicy = new PerformanceUsagePolicy(chatSettings); ctx.effect(() => () => { -@@ -12422,10 +12479,22 @@ window.__ModuleLoader__.load({ +@@ -12403,6 +12497,10 @@ window.__ModuleLoader__.load({ + "conversation.message.images": { + kind: "single", + scope: "session" ++ }, ++ "conversation.chat.userMessageFooter": { ++ kind: "list", ++ scope: "session" + } + }, + store: chatStore, +@@ -12422,10 +12520,22 @@ window.__ModuleLoader__.load({ fileMentions: (owner) => ctx.get("chatFileMentions")?.forClosing(owner, sessionId), openFile: async (path, options) => { const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd; @@ -271,7 +394,7 @@ index f997683..e59d554 100644 openSkill: (name) => { const scope = ctx.sessions.scope(sessionId); 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 a3f4a42..46cc5a2 100644 +index a3f4a42..576bab3 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 @@ -28,12 +28,18 @@ export type UsePresentation = SnapshotSelectorHook; @@ -294,3 +417,35 @@ index a3f4a42..46cc5a2 100644 } /** Owner currency of finalized-assistant actions. */ export interface AssistantActionOwnerProps { +@@ -213,8 +219,13 @@ export interface ChatViewInjected { + forkAt: (seq: number) => void; + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined; + } ++/** Owner currency of the opening user-message footer. */ ++export interface UserMessageFooterOwnerProps { ++ /** True only for the loaded conversation's first user message. */ ++ readonly leading: true; ++} + /** Full Chat view props. */ +-export type ChatViewSlotProps = PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> & PropsStore & InjectFace & PropsLocale<'chat'>; ++export type ChatViewSlotProps = PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images' | 'conversation.chat.userMessageFooter'> & PropsStore & InjectFace & PropsLocale<'chat'>; + /** Full props of the durable-message image renderer. */ + export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'>; + declare module '@deepseek-ai/dsh-client-ui-slots' { +@@ -254,6 +265,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { + scope: 'session'; + owner: MessageImagesOwnerProps; + }; ++ /** ++ * Ordered contributions under the loaded conversation's first user message. ++ * The host renders nothing when older history is still paged out, and mounts ++ * the same slot on the opening echo until the durable user node arrives. ++ */ ++ 'conversation.chat.userMessageFooter': { ++ kind: 'list'; ++ scope: 'session'; ++ owner: UserMessageFooterOwnerProps; ++ }; + /** + * Command row keyed by the command name. The component receives the folded + * command lifecycle and linked compaction when present. Reusing a key diff --git a/test/ppt-integration.test.ts b/test/ppt-integration.test.ts index 21592337a..df9bd6431 100644 --- a/test/ppt-integration.test.ts +++ b/test/ppt-integration.test.ts @@ -288,3 +288,68 @@ describe('DSH PPT built-in plugin', () => { expect(profilePatch).not.toContain('workbuddy') }) }) + +function sourceSlice(source: string, start: string, end: string): string { + const from = source.indexOf(start) + const to = source.indexOf(end, from) + if (from < 0 || to < 0) throw new Error(`missing ${start}`) + return source.slice(from, to) +} + +describe('opening PPT template card', () => { + it('keeps the footer on the first loaded user message and its send echo', async () => { + const chat = await readFile(path.join(projectRoot, 'node_modules', '@deepseek-ai', 'dsh-client-ui-chat', 'lib', 'client.js'), 'utf8') + const openingUserMessageTarget = new Function(`${sourceSlice(chat, 'function openingUserMessageTarget', 'function observedInputs')}\nreturn openingUserMessageTarget;`)() as ( + hasMore: boolean, + order: readonly string[], + kindAt: (key: string) => string | undefined, + pendingInputs: readonly { requestId?: string; placement?: string }[] + ) => { leadingUserKey: string | null; leadingPendingId: string | null } + const kindAt = (key: string) => ({ a: 'context', b: 'user', c: 'user' })[key] + + expect(openingUserMessageTarget(true, ['b'], kindAt, [])).toEqual({ leadingUserKey: null, leadingPendingId: null }) + expect(openingUserMessageTarget(false, ['a', 'b', 'c'], kindAt, [{ requestId: 'echo', placement: 'transcript' }])).toEqual({ + leadingUserKey: 'b', + leadingPendingId: null + }) + expect(openingUserMessageTarget(false, ['a'], kindAt, [ + { id: 'steer' } as { requestId?: string }, + { requestId: 'echo', placement: 'transcript' } + ])).toEqual({ leadingUserKey: null, leadingPendingId: 'echo' }) + expect(openingUserMessageTarget(false, [], () => undefined, [{ requestId: 'steer', placement: 'steering' }])).toEqual({ + leadingUserKey: null, + leadingPendingId: null + }) + expect(chat).toContain('renderSlot("conversation.chat.userMessageFooter", { leading: true })') + expect(chat).toContain('footer: item.requestId === leadingPendingId ? messageFooter : null') + expect(chat).toContain('const showBubble = hasBody || footer != null') + expect(chat).toContain('i)), footer]') + expect(chat).toMatch(/"conversation\.chat\.userMessageFooter": \{\s*kind: "list",\s*scope: "session"/) + }) + + it('publishes the footer slot and restores the persisted template into a preview card', async () => { + const types = await readFile(path.join(projectRoot, 'node_modules', '@deepseek-ai', 'dsh-client-ui-chat', 'lib', 'types', 'client', 'contract', 'slots.d.ts'), 'utf8') + const adapter = await readFile(path.join(projectRoot, 'packages', 'ppt-runtime', 'adapter', 'lib', 'client.js'), 'utf8') + const card = sourceSlice(adapter, 'function OfficePptOpeningTemplateCard', 'function OfficePptStandardInputAccessory') + + expect(types).toMatch(/'conversation\.chat\.userMessageFooter': \{\s*kind: 'list';\s*scope: 'session';/) + expect(adapter).toContain('name: "conversation.chat.userMessageFooter"') + expect(card).toContain('loadTemplateState') + expect(card).toContain('applyLoadedTemplates') + expect(card).toContain('showModal()') + expect(card).toContain('data-ppt-opening-template') + expect(card).toContain('className: "dsh-ppt-opening-title"') + expect(card).toContain('OpeningPptFileIcon') + expect(adapter).toContain('dsh-ppt-opening-file{flex:none;color:var(--dsw-static-amber-500)}') + expect(adapter).toContain('dsh-ppt-opening-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border:0;background:transparent;padding:0;color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 68%,var(--dsw-alias-label-primary));font:inherit;font-size:13px;line-height:20px;text-align:left;text-decoration:none;cursor:pointer}') + expect(adapter).not.toContain('text-decoration:underline') + expect(card).not.toContain('dsh-ppt-opening-card') + expect(card).not.toContain('template/select') + expect(card).not.toContain('template/deselect') + expect(card).not.toContain('session.blank') + expect(adapter).toContain('"opening.preview": "预览模板 {name}"') + expect(adapter).toContain('"opening.preview": "Preview template {name}"') + expect(adapter).toContain('"opening.close": "关闭预览"') + expect(adapter).toContain('"opening.close": "Close preview"') + }) +}) diff --git a/test/ppt-unbound-mode.test.mjs b/test/ppt-unbound-mode.test.mjs index e868a4bb2..b753615d4 100644 --- a/test/ppt-unbound-mode.test.mjs +++ b/test/ppt-unbound-mode.test.mjs @@ -212,11 +212,13 @@ describe('unbound PPT mode', () => { }) it('reloads the catalog when the mode revision changes and does not stop because loading is true', () => { - const effect = slice(adapter, 'const catalogRevision = mode.revision(sessionId);', 'const deselect = () =>') + // The opening-template card has its own catalog effect; compare the chooser's. + const chooser = source => source.slice(source.indexOf('function OfficePptChooser(')) + const effect = slice(chooser(adapter), 'const catalogRevision = mode.revision(sessionId);', 'const deselect = () =>') expect(effect).toContain('if (!loadTemplates || state.templates.length > 0 || state.error !== "") return;') expect(effect).not.toContain('state.loading ||') expect(effect).toContain('catalogRevision,') - expect(slice(core, 'const catalogRevision = mode.revision(sessionId);', 'const deselect = () =>')).toBe(effect) + expect(slice(chooser(core), 'const catalogRevision = mode.revision(sessionId);', 'const deselect = () =>')).toBe(effect) }) it('leaves the revision unchanged for loading and error updates', () => { @@ -270,14 +272,17 @@ describe('PPT hero and session dock routing', () => { const dock = () => null const mode = new Store() const injectHero = sessionId => ({ mode, sessionId }) - const apply = new Function('officePptHeroInjection', 'OfficePptStandardModeAction', 'OfficePptStandardInputAccessory', 'OfficePptStandardComposerDock', 'NS', + const openingCard = () => null + const apply = new Function('officePptHeroInjection', 'OfficePptStandardModeAction', 'OfficePptStandardInputAccessory', 'OfficePptOpeningTemplateCard', 'OfficePptStandardComposerDock', 'NS', `${slice(adapter, 'function applyStandard(ctx)', '//#endregion')}\nreturn applyStandard;` - )(() => injectHero, () => null, () => null, dock, 'dsh-ppt') + )(() => injectHero, () => null, () => null, openingCard, dock, 'dsh-ppt') apply({ slots: { inject: (_name, register) => register(), register: (config, component) => registered.set(config.name, { config, component }) } }) for (const name of ['conversation.hero.dock', 'conversation.composer.dock']) { expect(registered.get(name).component).toBe(dock) expect(registered.get(name).config.inject(undefined).mode).toBe(mode) } + expect(registered.get('conversation.chat.userMessageFooter').component).toBe(openingCard) + expect(registered.get('conversation.chat.userMessageFooter').config.inject(undefined).mode).toBe(mode) mode.setMode(undefined, 'ppt') expect(registered.get('conversation.hero.modeActions').config.inject(undefined).mode).toBe(mode) })