import '../../../client/configure-client-runtime'; import { isTextUIPart, isToolOrDynamicToolUIPart, type UIMessage, } from 'ai'; import type { AiChatPageContext } from '../AiChatPageContext'; import { AiChatController, type AiChatViewState } from './AiChatController'; import { AiChatHistoryStore } from './AiChatHistoryStore'; import { renderSafeMarkdown } from './SafeMarkdownRenderer'; export interface AssistantWidgetHandle { destroy(): void; open(): void; close(): void; } export interface AssistantWidgetContext { currentUser: { userId?: string; name?: string }; endpoint?: string; pageHref: () => string; siteDomain?: string; icon?: string; /** * 每次对话随请求上报的「当前页面上下文」(可操作控件清单 + 最近几步操作结果)。 * 不接就没有——模型只知道页面地址,不知道页面上有什么。 * 接了 page-operate 的宿主直接把它的 `readPageContext` 传进来即可。 */ pageContext?: () => AiChatPageContext | null | undefined; } export function readAssistantWidgetIcon(appConfig: unknown): string | undefined { const aiHost = isRecord(appConfig) ? appConfig.aiHost : undefined; const assistant = isRecord(aiHost) ? aiHost.assistant : undefined; const icon = isRecord(assistant) ? assistant.icon : undefined; return typeof icon === 'string' && icon.trim() ? icon.trim() : undefined; } export function applyAssistantWidgetIcon(doc: Document, icon?: string): void { const value = typeof icon === 'string' ? icon.trim() : ''; if (!value) { return; } for (const image of doc.querySelectorAll('[data-ai-assistant-icon]')) { image.src = value; } } /** 工具结果一到达就在 document 上广播的事件名。宿主页面监听它,把助手工具接到页面效果上。 */ export const ASSISTANT_TOOL_RESULT_EVENT = 'soma-assistant:tool-result'; export interface AssistantToolResultDetail { /** 工具名(`tool-<名>` 前缀已剥掉;dynamic-tool 取 toolName) */ tool: string; toolCallId: string; input: unknown; output: unknown; } /** * 从视图态挑出「新到达」的工具结果:state=output-available 且 toolCallId 未见过。 * 只有本会话直播流会进到这里——恢复的历史已被 AiChatController.restore 剥成纯文本, * 没有工具 part,所以切历史会话不会把旧的页面效果重放一遍。 */ export function collectNewToolResults( messages: readonly UIMessage[], seen: Set, ): AssistantToolResultDetail[] { const fresh: AssistantToolResultDetail[] = []; for (const message of messages) { if (message.role !== 'assistant') { continue; } for (const part of message.parts) { if (!isToolOrDynamicToolUIPart(part) || part.state !== 'output-available') { continue; } const named = part as { type: string; toolName?: string }; const tool = named.toolName ?? (named.type.startsWith('tool-') ? named.type.slice('tool-'.length) : ''); if (!tool || !part.toolCallId || seen.has(part.toolCallId)) { continue; } seen.add(part.toolCallId); fresh.push({ tool, toolCallId: part.toolCallId, input: part.input, output: part.output }); } } return fresh; } export function mountAssistantWidget( doc: Document, context: AssistantWidgetContext, ): AssistantWidgetHandle | null { const uid = (context.currentUser.userId || context.currentUser.name || '').trim(); const root = doc.getElementById('ai-assistant-widget'); if (!uid || !root || root.dataset.mounted === 'true') { return null; } const elements = readElements(doc); if (!elements) { return null; } applyAssistantWidgetIcon(doc, context.icon); root.dataset.mounted = 'true'; const history = new AiChatHistoryStore( context.siteDomain || doc.location?.hostname || 'unknown', uid, doc.defaultView?.localStorage, ); let lastState: AiChatViewState = { messages: [], status: 'ready' }; const seenToolResults = new Set(); let persistTimer: ReturnType | undefined; let destroyed = false; let readyToPersist = false; let persistSuspended = false; let historyPanelOpen = false; // 挂件启动期的两条只读拉取(推荐提问、工具文案)共用一个中断源,destroy 时一起收。 const startupAbort = new AbortController(); let suggestedPrompts: string[] = []; let suggestedStatus: 'loading' | 'ready' | 'empty' | 'error' = 'loading'; let suggestedRequest: Promise | undefined; let toolDisplays: Record = {}; const rerender = () => { if (destroyed) { return; } renderState( elements, lastState, { prompts: suggestedPrompts, status: suggestedStatus }, toolDisplays, ); }; const refreshWelcomeIfNeeded = () => { if (destroyed || lastState.messages.length > 0) { return; } rerender(); }; // 工具文案与工具面同源:宿主新增一个工具,Widget 不用改代码就能正确显示它。 const loadToolDisplays = async () => { try { const response = await globalThis.fetch('/api/ai-assistant/tools-meta', { credentials: 'include', headers: { Accept: 'application/json' }, signal: startupAbort.signal, }); if (!response?.ok) { return; } const body = await response.json() as { data?: { tools?: unknown } }; const displays = normalizeToolDisplays(body.data?.tools); if (Object.keys(displays).length === 0) { return; } toolDisplays = displays; if (lastState.messages.length > 0) { rerender(); } } catch { // 取不到就用通用文案,助手主链路不受影响。 } }; const loadSuggestedPrompts = () => { if (suggestedRequest) { return suggestedRequest; } suggestedStatus = 'loading'; suggestedRequest = (async () => { try { const response = await globalThis.fetch('/api/ai-assistant/suggested-prompts', { credentials: 'include', headers: { Accept: 'application/json', 'x-admin-page-href': normalizeWidgetPageHref(context.pageHref()), }, signal: startupAbort.signal, }); if (!response?.ok) { throw new Error(`suggested-prompts HTTP ${response?.status ?? 'missing'}`); } const body = await response.json() as { success?: boolean; data?: { prompts?: unknown }; }; const prompts = Array.isArray(body.data?.prompts) ? body.data.prompts .filter((item): item is string => typeof item === 'string') .map((item) => item.trim()) .filter(Boolean) .slice(0, 3) : []; if (body.success === false) { throw new Error('suggested-prompts failed'); } suggestedPrompts = prompts; suggestedStatus = prompts.length > 0 ? 'ready' : 'empty'; } catch { if (startupAbort.signal.aborted) { return; } suggestedPrompts = []; suggestedStatus = 'error'; } refreshWelcomeIfNeeded(); })(); return suggestedRequest; }; const controller = new AiChatController({ endpoint: context.endpoint, pageHref: context.pageHref, pageContext: context.pageContext, onChange: (state) => { if (destroyed) { return; } lastState = state; rerender(); for (const detail of collectNewToolResults(state.messages, seenToolResults)) { doc.dispatchEvent(new CustomEvent(ASSISTANT_TOOL_RESULT_EVENT, { detail })); } if (state.messages.length === 0) { void loadSuggestedPrompts(); } if (!readyToPersist || persistSuspended) { return; } if (state.status !== 'ready' && state.status !== 'error') { return; } clearTimeout(persistTimer); persistTimer = setTimeout(() => { if (persistSuspended) { return; } history.save(controller.snapshot()); if (historyPanelOpen) { renderHistoryList(elements, history); } }, 150); }, }); controller.restore(history.load()); readyToPersist = true; void loadSuggestedPrompts(); void loadToolDisplays(); // 宿主若带工具类 CSS,其 `.flex{display:flex}` 会盖掉 UA 对 [hidden] 的 display:none, // 所以显隐必须用内联 style 强制——挂件挂在别人的页面里,管不了对方的样式表。 setBoxVisible(elements.panel, false, 'flex'); setBoxVisible(elements.historyPanel, false, 'flex'); const isOpen = () => root.dataset.open === 'true'; const withPersistSuspended = (action: () => void) => { persistSuspended = true; clearTimeout(persistTimer); try { action(); } finally { queueMicrotask(() => { persistSuspended = false; }); } }; const open = () => { root.dataset.open = 'true'; setBoxVisible(elements.panel, true, 'flex'); elements.fab.setAttribute('aria-expanded', 'true'); elements.fab.setAttribute('aria-label', 'AI 助手已打开'); queueMicrotask(() => elements.input.focus()); }; const close = (event?: Event) => { event?.preventDefault(); event?.stopPropagation(); root.dataset.open = 'false'; setBoxVisible(elements.panel, false, 'flex'); setHistoryPanel(false); elements.fab.setAttribute('aria-expanded', 'false'); elements.fab.setAttribute('aria-label', '打开 AI 助手'); }; const onFabClick = (event: Event) => { event.preventDefault(); event.stopPropagation(); if (isOpen()) { return; } open(); }; const setHistoryPanel = (openPanel: boolean) => { historyPanelOpen = openPanel; setBoxVisible(elements.historyPanel, openPanel, 'flex'); elements.historyButton.setAttribute('aria-expanded', openPanel ? 'true' : 'false'); if (openPanel) { renderHistoryList(elements, history); } }; const toggleHistory = (event: Event) => { event.preventDefault(); event.stopPropagation(); setHistoryPanel(!historyPanelOpen); }; const newChat = (event: Event) => { event.preventDefault(); event.stopPropagation(); if (lastState.status === 'submitted' || lastState.status === 'streaming') { void controller.stop(); } withPersistSuspended(() => { history.createSession(); controller.clear(); }); setHistoryPanel(false); elements.input.focus(); }; const onHistoryListClick = (event: Event) => { const target = event.target; if (!(target instanceof Element)) { return; } const deleteButton = target.closest('[data-role="session-delete"]'); if (deleteButton) { event.preventDefault(); event.stopPropagation(); const sessionId = deleteButton.dataset.sessionId?.trim(); if (!sessionId) { return; } const wasActive = history.getActiveSessionId() === sessionId; withPersistSuspended(() => { history.deleteSession(sessionId); if (wasActive) { controller.restore(history.load()); } }); renderHistoryList(elements, history); return; } const openButton = target.closest('[data-role="session-open"]'); if (!openButton) { return; } event.preventDefault(); const sessionId = openButton.dataset.sessionId?.trim(); if (!sessionId) { return; } if (lastState.status === 'submitted' || lastState.status === 'streaming') { void controller.stop(); } withPersistSuspended(() => { controller.restore(history.openSession(sessionId)); }); setHistoryPanel(false); elements.input.focus(); }; const closeHistoryPanel = (event: Event) => { event.preventDefault(); event.stopPropagation(); setHistoryPanel(false); }; const submit = (event: Event) => { event.preventDefault(); const text = elements.input.value; if (!text.trim()) { return; } elements.input.value = ''; void controller.send(text).catch(() => undefined); }; // 部分浏览器(尤其 macOS 中文输入法)确认选词的 Enter 会在 compositionend 之后 // 再冒泡一次,且 isComposing=false / keyCode=13,仅靠事件字段挡不住。 let imeComposing = false; let suppressEnterAfterImeUntil = 0; const markImeComposing = () => { imeComposing = true; }; const markImeCompositionEnd = () => { imeComposing = false; suppressEnterAfterImeUntil = Date.now() + 120; }; const shouldIgnoreEnterForIme = (event: KeyboardEvent): boolean => ( event.isComposing || imeComposing || event.keyCode === 229 || Date.now() < suppressEnterAfterImeUntil ); const keydown = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen()) { if (historyPanelOpen) { setHistoryPanel(false); return; } close(); elements.fab.focus(); return; } if (event.target !== elements.input || event.key !== 'Enter' || event.shiftKey) { return; } if (shouldIgnoreEnterForIme(event)) { return; } event.preventDefault(); elements.form.requestSubmit(); }; const retry = () => void controller.retry().catch(() => undefined); const stop = () => void controller.stop(); const quickPrompt = (event: Event) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const button = target.closest('[data-role="quick-prompt"]'); if (!button || !elements.messages.contains(button)) { return; } const prompt = (button.dataset.prompt || button.textContent || '').trim(); if (!prompt) { return; } elements.input.value = prompt; void controller.send(prompt).catch(() => undefined); elements.input.value = ''; }; elements.fab.addEventListener('click', onFabClick); elements.closeButton.addEventListener('click', close); elements.newButton.addEventListener('click', newChat); elements.historyButton.addEventListener('click', toggleHistory); elements.historyCloseButton.addEventListener('click', closeHistoryPanel); elements.historyList.addEventListener('click', onHistoryListClick); elements.form.addEventListener('submit', submit); elements.stopButton.addEventListener('click', stop); elements.status.addEventListener('click', retry); elements.messages.addEventListener('click', quickPrompt); elements.input.addEventListener('compositionstart', markImeComposing); elements.input.addEventListener('compositionupdate', markImeComposing); elements.input.addEventListener('compositionend', markImeCompositionEnd); doc.addEventListener('keydown', keydown); return { open, close, destroy() { destroyed = true; startupAbort.abort(); clearTimeout(persistTimer); elements.fab.removeEventListener('click', onFabClick); elements.closeButton.removeEventListener('click', close); elements.newButton.removeEventListener('click', newChat); elements.historyButton.removeEventListener('click', toggleHistory); elements.historyCloseButton.removeEventListener('click', closeHistoryPanel); elements.historyList.removeEventListener('click', onHistoryListClick); elements.form.removeEventListener('submit', submit); elements.stopButton.removeEventListener('click', stop); elements.status.removeEventListener('click', retry); elements.messages.removeEventListener('click', quickPrompt); elements.input.removeEventListener('compositionstart', markImeComposing); elements.input.removeEventListener('compositionupdate', markImeComposing); elements.input.removeEventListener('compositionend', markImeCompositionEnd); doc.removeEventListener('keydown', keydown); controller.destroy(); root.dataset.mounted = 'false'; }, }; } interface WidgetElements { root: HTMLElement; fab: HTMLButtonElement; panel: HTMLElement; closeButton: HTMLButtonElement; newButton: HTMLButtonElement; historyButton: HTMLButtonElement; historyCloseButton: HTMLButtonElement; historyPanel: HTMLElement; historyList: HTMLElement; messages: HTMLElement; status: HTMLElement; form: HTMLFormElement; input: HTMLTextAreaElement; sendButton: HTMLButtonElement; stopButton: HTMLButtonElement; badge: HTMLElement; welcomeTemplate: HTMLTemplateElement; messageTemplate: HTMLTemplateElement; toolTemplate: HTMLTemplateElement; } function readElements(doc: Document): WidgetElements | null { const root = doc.getElementById('ai-assistant-widget'); const fab = doc.getElementById('ai-assistant-fab'); const panel = doc.getElementById('ai-assistant-panel'); const closeButton = doc.getElementById('ai-assistant-close'); const newButton = doc.getElementById('ai-assistant-new'); const historyButton = doc.getElementById('ai-assistant-history'); const historyCloseButton = doc.getElementById('ai-assistant-history-close'); const historyPanel = doc.getElementById('ai-assistant-history-panel'); const historyList = doc.getElementById('ai-assistant-history-list'); const messages = doc.getElementById('ai-assistant-messages'); const status = doc.getElementById('ai-assistant-status'); const form = doc.getElementById('ai-assistant-form'); const input = doc.getElementById('ai-assistant-input'); const sendButton = doc.getElementById('ai-assistant-send'); const stopButton = doc.getElementById('ai-assistant-stop'); const badge = doc.getElementById('ai-assistant-badge'); const welcomeTemplate = doc.getElementById('ai-assistant-welcome-tpl'); const messageTemplate = doc.getElementById('ai-assistant-msg-tpl'); const toolTemplate = doc.getElementById('ai-assistant-tool-tpl'); if ( !isHtmlElement(root) || !isElementTag(fab, 'BUTTON') || !isHtmlElement(panel) || !isElementTag(closeButton, 'BUTTON') || !isElementTag(newButton, 'BUTTON') || !isElementTag(historyButton, 'BUTTON') || !isElementTag(historyCloseButton, 'BUTTON') || !isHtmlElement(historyPanel) || !isHtmlElement(historyList) || !isHtmlElement(messages) || !isHtmlElement(status) || !isElementTag(form, 'FORM') || !isElementTag(input, 'TEXTAREA') || !isElementTag(sendButton, 'BUTTON') || !isElementTag(stopButton, 'BUTTON') || !isHtmlElement(badge) || !isElementTag(welcomeTemplate, 'TEMPLATE') || !isElementTag(messageTemplate, 'TEMPLATE') || !isElementTag(toolTemplate, 'TEMPLATE') ) { return null; } return { root, fab: fab as HTMLButtonElement, panel, closeButton: closeButton as HTMLButtonElement, newButton: newButton as HTMLButtonElement, historyButton: historyButton as HTMLButtonElement, historyCloseButton: historyCloseButton as HTMLButtonElement, historyPanel, historyList, messages, status, form: form as HTMLFormElement, input: input as HTMLTextAreaElement, sendButton: sendButton as HTMLButtonElement, stopButton: stopButton as HTMLButtonElement, badge, welcomeTemplate: welcomeTemplate as HTMLTemplateElement, messageTemplate: messageTemplate as HTMLTemplateElement, toolTemplate: toolTemplate as HTMLTemplateElement, }; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function isHtmlElement(value: Element | null | undefined): value is HTMLElement { return Boolean(value) && typeof (value as HTMLElement).dataset === 'object'; } function isElementTag(value: Element | null, tagName: string): value is HTMLElement { return isHtmlElement(value) && value.tagName === tagName; } /** 内联 display 压过宿主样式表的 display 规则,避免 hidden 属性失效。 */ function setBoxVisible( element: HTMLElement, visible: boolean, displayWhenVisible: string, ): void { element.hidden = !visible; element.style.display = visible ? displayWhenVisible : 'none'; } function renderHistoryList(elements: WidgetElements, history: AiChatHistoryStore): void { const sessions = history.listSessions(); const activeId = history.getActiveSessionId(); elements.historyList.replaceChildren(); if (sessions.length === 0) { const empty = elements.root.ownerDocument.createElement('p'); empty.className = 'px-1 py-6 text-center text-sm text-[#9ca3af]'; empty.textContent = '暂无会话记录'; elements.historyList.append(empty); return; } for (const session of sessions) { const row = elements.root.ownerDocument.createElement('div'); row.className = [ 'flex items-center gap-1 rounded-xl px-2 py-1.5', session.id === activeId ? 'bg-[#eff6ff]' : 'hover:bg-[#f9fafb]', ].join(' '); const openButton = elements.root.ownerDocument.createElement('button'); openButton.type = 'button'; openButton.dataset.role = 'session-open'; openButton.dataset.sessionId = session.id; openButton.className = 'min-w-0 flex-1 rounded-lg px-1 py-1 text-left'; const title = elements.root.ownerDocument.createElement('div'); title.className = 'truncate text-sm font-medium text-[#111827]'; title.textContent = session.title; const meta = elements.root.ownerDocument.createElement('div'); meta.className = 'mt-0.5 text-[11px] text-[#9ca3af]'; meta.textContent = `${formatSessionTime(session.updatedAt)} · ${session.messageCount} 条`; openButton.append(title, meta); const deleteButton = elements.root.ownerDocument.createElement('button'); deleteButton.type = 'button'; deleteButton.dataset.role = 'session-delete'; deleteButton.dataset.sessionId = session.id; deleteButton.title = '删除会话'; deleteButton.setAttribute('aria-label', '删除会话'); deleteButton.className = 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[#9ca3af] hover:bg-[#fee2e2] hover:text-[#dc2626]'; deleteButton.innerHTML = ''; row.append(openButton, deleteButton); elements.historyList.append(row); } } function formatSessionTime(timestamp: number): string { try { return new Date(timestamp).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch { return ''; } } function renderState( elements: WidgetElements, state: AiChatViewState, suggested?: { prompts: string[]; status: 'loading' | 'ready' | 'empty' | 'error' }, toolDisplays: Record = {}, ): void { const stickToBottom = elements.messages.scrollHeight - elements.messages.scrollTop - elements.messages.clientHeight < 80; elements.messages.replaceChildren(); if (state.messages.length === 0) { const welcome = elements.welcomeTemplate.content.cloneNode(true) as DocumentFragment; fillSuggestedPrompts(welcome, suggested ?? { prompts: [], status: 'loading' }, elements.root.ownerDocument); elements.messages.append(welcome); } else { for (const message of state.messages) { elements.messages.append(renderMessage(elements, message, toolDisplays)); } } if (stickToBottom) { elements.messages.scrollTop = elements.messages.scrollHeight; } const busy = state.status === 'submitted' || state.status === 'streaming'; elements.input.disabled = busy; elements.sendButton.hidden = busy; elements.stopButton.hidden = !busy; elements.badge.hidden = !busy; elements.status.hidden = !state.error && !busy; elements.status.textContent = state.error ? `${state.error}(点击重试)` : state.status === 'submitted' ? '正在连接 AI 服务…' : busy ? 'AI 正在回答…' : ''; } function fillSuggestedPrompts( root: ParentNode, suggested: { prompts: string[]; status: 'loading' | 'ready' | 'empty' | 'error' }, doc: Document, ): void { const container = root.querySelector('[data-role="suggested-prompts"]'); if (!container) { return; } container.replaceChildren(); if (suggested.status === 'loading') { const status = doc.createElement('p'); status.dataset.role = 'suggested-prompts-status'; status.className = 'text-sm text-[#9ca3af]'; status.textContent = '正在生成推荐提问…'; container.append(status); return; } if (suggested.prompts.length === 0) { const status = doc.createElement('p'); status.dataset.role = 'suggested-prompts-status'; status.className = 'text-sm text-[#9ca3af]'; status.textContent = suggested.status === 'empty' ? '暂无可推荐内容,可直接在下方输入问题。' : '推荐提问暂不可用,可直接在下方输入问题。'; container.append(status); return; } for (const prompt of suggested.prompts) { const button = doc.createElement('button'); button.type = 'button'; button.dataset.role = 'quick-prompt'; button.dataset.prompt = prompt; button.className = 'rounded-xl border border-[#e5e7eb] bg-[#f9fafb] px-3 py-2.5 text-left text-sm text-[#374151] transition hover:border-[#bfdbfe] hover:bg-[#eff6ff] hover:text-[#1d4ed8]'; button.textContent = prompt; container.append(button); } } function normalizeWidgetPageHref(value: string): string { const normalized = value.trim(); return normalized.startsWith('/') && !normalized.startsWith('//') ? normalized : '/'; } function renderMessage( elements: WidgetElements, message: UIMessage, toolDisplays: Record, ): HTMLElement { const fragment = elements.messageTemplate.content.cloneNode(true) as DocumentFragment; const article = fragment.querySelector('.ai-msg'); const textRoot = fragment.querySelector('.ai-msg-text'); const toolRoot = fragment.querySelector('.ai-msg-tools'); if (!article || !textRoot || !toolRoot) { return elements.root.ownerDocument.createElement('div'); } article.dataset.role = message.role; article.classList.add(...( message.role === 'user' ? ['ml-10', 'bg-[#2563eb]', 'text-white', 'shadow-sm'] : ['mr-6', 'border', 'border-[#eef2f7]', 'bg-[#f8fafc]', 'text-[#1f2937]'] )); const markdown = message.parts.filter(isTextUIPart).map((part) => part.text).join(''); if (message.role === 'assistant') { textRoot.innerHTML = renderSafeMarkdown(markdown, elements.root.ownerDocument); textRoot.classList.add('ai-assistant-markdown'); } else { textRoot.textContent = markdown; textRoot.classList.add('whitespace-pre-wrap'); } for (const part of message.parts) { if (isToolOrDynamicToolUIPart(part)) { toolRoot.append(renderTool(elements, part, toolDisplays)); } } return article; } export interface AssistantToolDisplayText { pending: string; done: string; error: string; } /** 工具面是动态的,文案查不到时用通用三态兜底——宁可笼统,也不能显示错的能力名。 */ const GENERIC_TOOL_DISPLAY: AssistantToolDisplayText = { pending: '正在处理…', done: '已完成处理', error: '处理失败', }; /** part.type 形如 `tool-<工具名>`;dynamic-tool 走 part.toolName。 */ export function readToolDisplayText( part: { type: string; toolName?: string }, displays: Record, ): AssistantToolDisplayText { const name = part.toolName ?? (part.type.startsWith('tool-') ? part.type.slice('tool-'.length) : ''); return displays[name] ?? GENERIC_TOOL_DISPLAY; } function renderTool( elements: WidgetElements, part: UIMessage['parts'][number], displays: Record, ): DocumentFragment { const fragment = elements.toolTemplate.content.cloneNode(true) as DocumentFragment; const label = fragment.querySelector('[data-role="label"]'); const detail = fragment.querySelector('[data-role="detail"]'); if (!isToolOrDynamicToolUIPart(part) || !label || !detail) { return fragment; } // 工具细节含知识库路径与业务参数,不对用户展示具体实现信息。 const display = readToolDisplayText(part as { type: string; toolName?: string }, displays); label.textContent = part.state === 'output-error' ? display.error : part.state === 'output-available' ? display.done : display.pending; detail.hidden = true; detail.textContent = ''; return fragment; } export function normalizeToolDisplays(value: unknown): Record { if (!isRecord(value)) { return {}; } const displays: Record = {}; for (const [name, raw] of Object.entries(value)) { if (!isRecord(raw)) { continue; } const pending = readDisplayText(raw.pending); const done = readDisplayText(raw.done); const error = readDisplayText(raw.error); if (pending && done && error) { displays[name] = { pending, done, error }; } } return displays; } function readDisplayText(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim().slice(0, 40) : undefined; }