import { useEffect, useSyncExternalStore } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ILayout } from '@deepseek-ai/dsh-client-ui-layout/client'
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
import { currentReviewStateFor, qingClientStore, type QingClientSnapshot } from './store.js'
import { markPanelOpenSource } from './telemetry.js'
import {
isFreshnessGateFailure,
sanitizeUserVisibleText,
toolContentText,
} from '../userVisibleText.js'
import styles from './QingWriteToolCard.module.css'
/** 工具卡的具名文案与摘要装配;meta 来自各工具的 presentationMeta(#23)。 */
export interface QingToolCardConfig {
runningTitle: string
doneTitle: (meta: ToolCardMeta) => string
failedTitle: string
/** 完成态摘要;返回空串则不渲染摘要段。 */
summary: (meta: ToolCardMeta, snapshot: QingClientSnapshot) => string
/** 完成态叙述;返回空串则不渲染第二行。 */
narrative?: (meta: ToolCardMeta, snapshot: QingClientSnapshot) => string
/** 运行态摘要(如写作流式字数);缺省为空。 */
runningSummary?: (snapshot: QingClientSnapshot) => string
/** 失败态是否隐藏「查看」按钮以外,默认所有卡都带「查看」入口(与写作卡一致)。 */
showViewButton?: boolean
}
export interface ToolCardMeta {
engineSessionId?: string
title?: string
blocks?: number
words?: number
status?: string
reviewCount?: number
acceptedCount?: number
rejectedCount?: number
adopted?: boolean
count?: number
scope?: string
mode?: string
wholeDocReview?: boolean
patchIds?: string[]
summaries?: string[]
}
interface InjectedProps {
qingLayout: ILayout
}
type Props = PropsRuntime<'tool.call.toolview'> & InjectedProps
export function createQingToolCard(config: QingToolCardConfig) {
return function QingToolCard(props: Props) {
const sessionId = String(props.useSession((session) => session.sessionId))
const snapshot = useSyncExternalStore(
(listener) => qingClientStore.subscribe(sessionId, listener),
() => qingClientStore.getSnapshot(sessionId),
)
useEffect(() => qingClientStore.retain(sessionId), [sessionId])
const settledBlock = 'kind' in props.block ? props.block : undefined
const settled = settledBlock !== undefined
const failed = settledBlock?.isError ?? false
const meta: ToolCardMeta = isMeta(settledBlock?.meta) ? settledBlock.meta : {}
const state = failed ? 'failed' : settled ? 'complete' : 'running'
const summaryText = failed
? failureSummary(settledBlock?.content ?? [])
: settled
? config.summary(meta, snapshot)
: config.runningSummary?.(snapshot) ?? ''
const narrativeText = settled && !failed ? config.narrative?.(meta, snapshot) ?? '' : ''
const handleView = () => openToolCardDocument(
sessionId,
meta.engineSessionId,
snapshot,
() => props.qingLayout.openDetails(),
)
// 新鲜度闸门是模型自恢复协议,不是一次面向用户的编辑失败;结算后整卡消失。
if (failed && isFreshnessGateFailure(settledBlock?.content ?? [])) return null
return (
{failed ? config.failedTitle : settled ? config.doneTitle(meta) : config.runningTitle}
{summaryText ? : null}
{summaryText ? {summaryText} : null}
{!failed && config.showViewButton !== false ? (
) : null}
{narrativeText ?
{narrativeText}
: null}
)
}
}
/** 工具卡「查看」统一入口:旧卡只开面板,具名稿聚焦,已确认删除的稿保留 missing 纸面。 */
export function openToolCardDocument(
sessionId: string,
engineSessionId: string | undefined,
snapshot: QingClientSnapshot,
openDetails: () => void,
): void {
qingClientStore.reopenPanel(sessionId)
markPanelOpenSource(sessionId, 'tool_card')
openDetails()
// docMissing 是集合语义(P68-b):已判定删除的稿一律不跳转,面板自己会显示「该文档已删除」。
if (!engineSessionId || snapshot.docMissing?.engineSessionIds.includes(engineSessionId)) return
void qingClientStore.focus(sessionId, engineSessionId).catch((error) => {
console.error('[qingagent-tool-card] focus failed', error)
window.dispatchEvent(new CustomEvent('qingagent:panel-toast', {
detail: '保存失败 · 未切换文稿',
}))
})
}
function isMeta(value: unknown): value is ToolCardMeta {
return typeof value === 'object' && value !== null
}
const titled = (meta: ToolCardMeta) => (meta.title ? `《${meta.title}》` : '')
export const QingEditToolCard = createQingToolCard({
runningTitle: '正在修改文稿',
doneTitle: (meta) => (meta.status === 'review' ? '修改待审阅' : '修改已生效'),
failedTitle: '修改未完成',
summary: (meta, snapshot) => {
const pendingReview = currentReviewStateFor(snapshot, meta.engineSessionId, meta.patchIds) === 'pending'
return [
titled(meta),
meta.status === 'review' && meta.reviewCount
? pendingReview ? `${meta.reviewCount} 处待裁决` : `${meta.reviewCount} 处修改`
: '',
meta.status === 'review' && pendingReview
? meta.wholeDocReview ? '请在右侧确认是否应用新版' : '请在右侧逐处确认'
: '',
].filter(Boolean).join(' · ')
},
narrative: (meta, snapshot) => {
if (meta.status !== 'review') return ''
const reviewState = currentReviewStateFor(snapshot, meta.engineSessionId, meta.patchIds)
// 标题行已经包含修改数量和待审指引,第二行只补充标题未表达的结算状态。
return reviewState === 'settled' ? '已处理完。' : ''
},
})
export const QingReviewCommitToolCard = createQingToolCard({
runningTitle: '正在处理审阅',
doneTitle: () => '审阅已处理',
failedTitle: '审阅处理未完成',
summary: (meta) => [
titled(meta),
typeof meta.acceptedCount === 'number' || typeof meta.rejectedCount === 'number'
? `采纳 ${meta.acceptedCount ?? 0} · 拒绝 ${meta.rejectedCount ?? 0}`
: '',
].filter(Boolean).join(' · '),
})
export const QingReadToolCard = createQingToolCard({
runningTitle: '正在读取文稿',
doneTitle: () => '已读取文稿',
failedTitle: '读取未完成',
summary: (meta) => [
titled(meta),
typeof meta.words === 'number' && meta.words > 0 ? `约 ${meta.words} 字` : '',
].filter(Boolean).join(' · '),
showViewButton: false,
})
export const QingListMaterialsToolCard = createQingToolCard({
runningTitle: '正在读取素材清单',
doneTitle: (meta) => `已读取素材清单 · ${meta.count ?? 0} 份`,
failedTitle: '素材清单读取未完成',
summary: () => '',
showViewButton: false,
})
export const QingReadMaterialToolCard = createQingToolCard({
runningTitle: '正在读取素材',
doneTitle: (meta) => meta.title ? `已读取素材《${meta.title}》` : '已读取素材',
failedTitle: '素材读取未完成',
summary: () => '',
showViewButton: false,
})
export const QingListDocsToolCard = createQingToolCard({
runningTitle: '正在查看文稿清单',
doneTitle: (meta) => (meta.scope === 'library' ? '文库清单' : '会话文稿清单'),
failedTitle: '文稿清单读取未完成',
summary: (meta) => (typeof meta.count === 'number' ? `${meta.count} 篇` : ''),
showViewButton: false,
})
export const QingFocusToolCard = createQingToolCard({
runningTitle: '正在切换预览',
doneTitle: (meta) => (meta.adopted ? '已收养文库文稿' : '已切换预览'),
failedTitle: '切换预览未完成',
summary: (meta) => titled(meta),
})
export const QingAnnotateToolCard = createQingToolCard({
runningTitle: '正在生成审查批注',
doneTitle: (meta) => `审查批注已生成 · ${meta.count ?? 0} 处`,
failedTitle: '审查批注未完成',
summary: (meta) => titled(meta),
// 事件快照:冻结本次生成的各批注摘要,不随后续裁决变化。
narrative: (meta) => (meta.summaries ?? []).slice(0, 5).join(' · ')
+ ((meta.summaries?.length ?? 0) > 5 ? ` · 等 ${meta.summaries!.length} 处` : ''),
})
export function failureSummary(content: readonly unknown[]): string {
const text = sanitizeUserVisibleText(
toolContentText(content).split(/\r?\n/, 1)[0]?.replace(/^Error:\s*/i, '').trim() ?? '',
)
if (/审阅|REVIEW_PENDING/i.test(text)) return '文稿审阅中'
if (/AGENT_BUSY|正在处理其他任务|引擎忙/i.test(text)) return '引擎忙'
return text.length > 48 ? `${text.slice(0, 47)}…` : text
}