feat: enhance docx handling with style text support and review highlights

This commit is contained in:
hy committed 2026-04-23 17:41:08 +08:00
1 parent 7815a9fb21
commit 1e2009387d
8 files changed
+452 -16

No files matched your search

@@ -13,6 +13,7 @@ export const buildMouthPrompt = (input: { state: HumanState; ctx?: AiRunContext
- 你的职责是根据context_information的信息,面向用户交付最终结果,而不是复述系统内部过程。
- 优先回答用户真正关心的内容,优先使用更自然、更容易理解的表达。
- your_notes、history、delivery_guidance 和 runtime_artifacts 主要用于核对事实和补充细节,不要求按原样转述。
- 严格按照当前上下文信息回答,不得编造、补写或改写为上下文中未明确给出的已执行结果。
- 如果提供了额外的交付说明,优先将其转化为用户容易理解的最终答复,并确保相关状态被明确说明。
- 回答必须忠实于当前已经成立的信息;信息不足时直接说明,不补写未确认的结果。
- 如果需要使用不来自当前上下文的通用知识,在结尾单独写“补充说明:...”,明确说明来源不是当前本地资料。
@@ -340,7 +340,58 @@ const summarizeArrayTexts = (value: JsonValue | undefined, key: string): string[
.filter((item): item is string => Boolean(item && item.length > 0))
}
const summarizeToolResultForMouth = (result: HumanHistoryToolResult): string => {
const MOUTH_DOCX_TEXT_LIMIT = 420
const MOUTH_DOCX_TABLE_MAX_ROWS = 4
const clipText = (value: string, maxChars: number): string => {
const normalized = value.replace(/\s+/g, ' ').trim()
if (!normalized) return ''
if (normalized.length <= maxChars) return normalized
return `${normalized.slice(0, maxChars - 1).trim()}…`
}
const renderMouthToolArgValue = (value: JsonValue | undefined): string => {
if (value === undefined) return 'none'
return JSON.stringify(value, null, 2)
}
const buildDocxWriteFact = (input: {
toolName: HumanHistoryToolResult['toolName']
action?: string
target?: string
contentLabel: string
content: JsonValue | undefined
extraLines?: string[]
}): string => {
const header = [`\`${input.toolName}\` 已完成实际写入。`]
if (input.action) header.push(`action=${input.action}`)
if (input.target) header.push(`target=${input.target}`)
const contentText =
typeof input.content === 'string'
? clipText(input.content, MOUTH_DOCX_TEXT_LIMIT)
: renderMouthToolArgValue(input.content)
return [
header.join(';'),
` - ${input.contentLabel}:`,
contentText
.split('\n')
.map((line) => ` ${line}`)
.join('\n'),
...(input.extraLines || []).map((line) => ` - ${line}`)
].join('\n')
}
const buildTableRowsPreview = (rows: JsonValue | undefined): JsonValue | undefined => {
if (!Array.isArray(rows)) return rows
if (rows.length <= MOUTH_DOCX_TABLE_MAX_ROWS) return rows
return [...rows.slice(0, MOUTH_DOCX_TABLE_MAX_ROWS), `... 其余 ${String(rows.length - MOUTH_DOCX_TABLE_MAX_ROWS)} 行省略`]
}
const summarizeToolResultForMouth = (input: {
result: HumanHistoryToolResult
call?: ToolCallParam
}): string => {
const { result, call } = input
if (result.diagnosticText) {
return `\`${result.toolName}\` 未完成:${result.diagnosticText}`
}
@@ -371,17 +422,67 @@ const summarizeToolResultForMouth = (result: HumanHistoryToolResult): string =>
? `\`${result.toolName}\` 概览到 ${String(count)} 个块:${blocks.join(' / ')}`
: `\`${result.toolName}\` 概览到 ${String(count)} 个块。`
}
case 'docx_text_edit':
case 'docx_paragraph_edit':
case 'docx_heading_edit':
case 'docx_table_edit':
case 'docx_heading_edit': {
return buildDocxWriteFact({
toolName: result.toolName,
action: typeof call?.args.action === 'string' ? call.args.action : undefined,
target:
typeof call?.args.blockId === 'string'
? `blockId=${call.args.blockId}`
: typeof call?.args.anchorBlockId === 'string'
? `anchorBlockId=${call.args.anchorBlockId}`
: undefined,
contentLabel: '实际写入文本',
content: typeof call?.args.text === 'string' ? call.args.text : undefined
})
}
case 'docx_table_edit': {
const rawRows = Array.isArray(call?.args.rows) ? call.args.rows : undefined
const rows = buildTableRowsPreview(rawRows)
return buildDocxWriteFact({
toolName: result.toolName,
action: typeof call?.args.action === 'string' ? call.args.action : undefined,
target:
typeof call?.args.blockId === 'string'
? `blockId=${call.args.blockId}`
: typeof call?.args.anchorBlockId === 'string'
? `anchorBlockId=${call.args.anchorBlockId}`
: undefined,
contentLabel: '实际写入 rows',
content: rows,
extraLines: Array.isArray(rawRows) && rawRows.length > MOUTH_DOCX_TABLE_MAX_ROWS
? [`原始表格总行数=${String(rawRows.length)}`]
: undefined
})
}
case 'docx_text_edit': {
return buildDocxWriteFact({
toolName: result.toolName,
target:
Array.isArray(call?.args.blockIds) && call.args.blockIds.length > 0
? `blockIds=${renderMouthToolArgValue(call.args.blockIds)}`
: undefined,
contentLabel: '实际替换参数',
content: {
query: typeof call?.args.query === 'string' ? call.args.query : undefined,
replace: typeof call?.args.replace === 'string' ? call.args.replace : undefined
}
})
}
case 'docx_text_style_edit': {
const summary = readStringField(result.data, 'summary')
const changedBlocks = Array.isArray(result.data.changedBlocks) ? result.data.changedBlocks.length : 0
if (summary) return `\`${result.toolName}\`:${summary}`
return changedBlocks > 0
? `\`${result.toolName}\` 已形成 ${String(changedBlocks)} 处修改。`
: `\`${result.toolName}\` 已完成。`
return buildDocxWriteFact({
toolName: result.toolName,
target:
typeof call?.args.blockId === 'string'
? `blockId=${call.args.blockId}`
: undefined,
contentLabel: '实际样式参数',
content: {
query: typeof call?.args.query === 'string' ? call.args.query : undefined,
style: isJsonObject(call?.args.style) ? call.args.style : undefined
}
})
}
default: {
const summary =
@@ -410,7 +511,12 @@ const renderMouthRoundFacts = (record: HumanRoundRecord): string[] => {
return facts
}
case 'hand': {
const facts = record.execution.toolResults.map(summarizeToolResultForMouth)
const facts = record.execution.toolResults.map((result, index) =>
summarizeToolResultForMouth({
result,
call: record.execution.toolUsePlan.calls[index]
})
)
if (record.execution.watchHint) {
facts.push(`收敛提示:${summarizeWatchHint(record.execution.watchHint)}`)
}
@@ -98,6 +98,7 @@ const buildNode = (input: {
spec: DocxBlockSpec
createdRevisionRef: string
changedRevisionRef?: string
reviewQuery?: string
}): DocxReplayNode => ({
nodeId: `${input.reviewState}:${input.blockId}:${input.createdRevisionRef}:${input.changedRevisionRef || 'active'}`,
blockId: input.blockId,
@@ -110,7 +111,8 @@ const buildNode = (input: {
currentBlockId: input.currentBlockId,
spec: input.spec,
createdRevisionRef: input.createdRevisionRef,
changedRevisionRef: input.changedRevisionRef
changedRevisionRef: input.changedRevisionRef,
reviewQuery: input.reviewQuery
})
const reindexBodyNodes = (session: DocxReplaySession): void => {
@@ -255,7 +257,8 @@ const cloneNodeAsOldRevision = (
nodeId: `${node.nodeId}:${reviewState}:${revisionRef}`,
currentBlockId: undefined,
reviewState,
changedRevisionRef: revisionRef
changedRevisionRef: revisionRef,
reviewQuery: undefined
})
const buildCurrentNodeFromSnapshot = (
@@ -427,10 +430,34 @@ export const applyRevisionToReplaySession = (input: {
return
}
if (op.type === 'style_text') {
const snapshotBlock = resolveSnapshotBlockForMutation({
session,
nextSnapshot,
createdBlocks,
targetBlockId
})
if (!snapshotBlock) {
targetNode.reviewState = 'formatted'
targetNode.changedRevisionRef = revisionRef
targetNode.reviewQuery = op.query
session.activeSnapshot = nextSnapshot
return
}
targetNode.reviewState = 'formatted'
targetNode.currentBlockId = snapshotBlock.blockId
targetNode.bodyIndex = snapshotBlock.bodyIndex
targetNode.spec = snapshotBlock.spec
targetNode.changedRevisionRef = revisionRef
targetNode.reviewQuery = op.query
session.activeSnapshot = nextSnapshot
return
}
const oldState =
op.type === 'replace_block' ||
op.type === 'replace_text' ||
op.type === 'style_text' ||
op.type === 'update_table_cell'
? 'replaced-old'
: 'deleted'
@@ -478,6 +505,7 @@ export const projectReplayNodes = (
if (kind === 'review') return true
return (
node.reviewState === 'normal' ||
node.reviewState === 'formatted' ||
node.reviewState === 'inserted' ||
node.reviewState === 'replaced-new'
)
@@ -16,7 +16,7 @@ import { projectCommittedView, projectReviewView } from './viewProjector'
const WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
type RevisionKind = 'inserted' | 'deleted'
type RevisionKind = 'inserted' | 'deleted' | 'formatted'
const ensureDirForFile = async (filePath: string): Promise<void> => {
await fs.mkdir(path.dirname(filePath), { recursive: true })
@@ -56,6 +56,14 @@ const styleRunForRevision = (run: Element, kind: RevisionKind): void => {
return
}
if (kind === 'formatted') {
const shade = firstChildElement(rPr, 'shd') || run.ownerDocument.createElementNS(WORD_NS, 'w:shd')
setWordAttribute(shade, 'val', 'clear')
setWordAttribute(shade, 'fill', 'CFFAFE')
if (!shade.parentNode) rPr.appendChild(shade)
return
}
const color = run.ownerDocument.createElementNS(WORD_NS, 'w:color')
const shade = run.ownerDocument.createElementNS(WORD_NS, 'w:shd')
setWordAttribute(color, 'val', '166534')
@@ -79,6 +87,48 @@ const applyRevisionToRuns = (element: Element, kind: RevisionKind): void => {
})
}
const getRunText = (run: Element): string =>
Array.from({ length: run.childNodes.length })
.map((_, index) => run.childNodes.item(index))
.filter(Boolean)
.map((child) => {
if (!isElementNode(child)) return child.textContent || ''
if (child.localName === 't') return child.textContent || ''
return getRunText(child)
})
.join('')
const collectRunElements = (element: Element): Element[] => {
const result: Element[] = []
const stack: Element[] = [element]
while (stack.length > 0) {
const current = stack.pop()
if (!current) continue
if (current.localName === 'r') {
result.push(current)
continue
}
Array.from({ length: current.childNodes.length })
.map((_, index) => current.childNodes.item(index))
.filter(isElementNode)
.forEach((child) => stack.push(child))
}
return result
}
const applyFormattedMarker = (element: Element, query?: string): void => {
const normalizedQuery = String(query || '').trim()
const runs = collectRunElements(element)
if (runs.length === 0) return
const matchedRuns =
normalizedQuery.length > 0
? runs.filter((run) => getRunText(run).includes(normalizedQuery))
: runs
const targetRuns = matchedRuns.length > 0 ? matchedRuns : runs
targetRuns.forEach((run) => styleRunForRevision(run, 'formatted'))
}
const createTextRun = (
doc: Document,
text: string,
@@ -293,6 +343,10 @@ const resolveElementForNode = (input: {
}
const applyReviewMarker = (element: Element, node: DocxReplayNode): void => {
if (node.reviewState === 'formatted') {
applyFormattedMarker(element, node.reviewQuery)
return
}
if (node.reviewState === 'inserted' || node.reviewState === 'replaced-new') {
applyRevisionToRuns(element, 'inserted')
return
@@ -45,6 +45,10 @@ export const getDocxChangeKind = (op: DocxOp): FileReviewChange['change_kind'] =
op.type === 'update_table_cell' ? 'table_cell' : 'block'
export const buildDocxChangeTitle = (op: DocxOp, change: DocxChangedBlock): string => {
if (op.type === 'style_text') {
const query = String(op.query || '').trim()
return query ? `更新文本样式: ${query}` : '更新文本样式'
}
const baseText = String(change.afterText || change.beforeText).trim()
const previewRaw = clipText(baseText, 28).trim()
const preview = previewRaw.length < baseText.length ? `${previewRaw}...` : previewRaw
@@ -52,7 +56,9 @@ export const buildDocxChangeTitle = (op: DocxOp, change: DocxChangedBlock): stri
}
export const buildDocxChangeSummary = (op: DocxOp, change: DocxChangedBlock): string =>
`${describeDocxOp(op)} ${change.blockId}`
op.type === 'style_text'
? `${describeDocxOp(op)} ${change.blockId}${String(op.query || '').trim() ? `: ${String(op.query).trim()}` : ''}`
: `${describeDocxOp(op)} ${change.blockId}`
export const buildDocxChangeLocator = (change: DocxChangedBlock): JsonObject => ({
blockId: change.blockId,
@@ -267,6 +267,7 @@ export type DocxReplaySession = {
export type DocxReviewState =
| 'normal'
| 'formatted'
| 'inserted'
| 'deleted'
| 'replaced-old'
@@ -285,6 +286,7 @@ export type DocxReplayNode = {
spec: DocxBlockSpec
createdRevisionRef: string
changedRevisionRef?: string
reviewQuery?: string
}
export type DocxViewArtifact = {
@@ -0,0 +1,122 @@
import fs from 'node:fs/promises'
import { XMLSerializer } from '@xmldom/xmldom'
import { describe, it } from 'vitest'
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/blockIndex'
import {
createDocxToolHarness,
writeRichTestDocxFile
} from '../../../harness/backend/aiToolHarness'
import { runBackendCase } from '../../../harness/backend/run-backend-case'
import type { DocxMutationResult, DocxOutlineResult } from './testUtils'
describe('ai tool docx backend', () => {
it('docx-style-text-review-highlight', async () => {
await runBackendCase({
caseId: 'docx-style-text-review-highlight',
harnessMode: 'tool-real',
domain: 'ai',
suite: 'tool',
groups: ['docx'],
description: '验证 style_text 会在 review artifact 中以独立浅青色高亮显示,并且 committed artifact 不保留该高亮。',
fixtures: [],
runMode: 'single',
execute: async (runtime) => {
await writeRichTestDocxFile(runtime.workspaceRoot, 'style-highlight.docx', [
{
type: 'paragraph',
paragraph: {
runs: [{ text: '负责人:Alice 将在周五完成复核。' }]
}
}
])
runtime.fileReviewService.beginRun({
runId: runtime.runId,
sessionId: 'docx-style-highlight-session',
inputText: '把 Alice 标成重点'
})
const harness = createDocxToolHarness(runtime)
try {
const outline = await harness.invoke<DocxOutlineResult>('docx_outline', {
sourcePath: 'style-highlight.docx'
})
const paragraphBlockId =
outline.blocks.find(
(block) => block.type === 'paragraph' && block.preview.includes('Alice')
)?.blockId || ''
const styled = await harness.invoke<DocxMutationResult>('docx_text_style_edit', {
sourcePath: 'style-highlight.docx',
blockId: paragraphBlockId,
query: 'Alice',
style: {
bold: true,
color: '2563EB'
}
})
const details = await runtime.fileReviewService.finalizeRunReview({
runId: runtime.runId,
workspaceRootPath: runtime.workspaceRoot
})
const detail = details[0]
if (
!detail ||
!detail.review.draft_artifact_path ||
!detail.review.committed_artifact_path
) {
throw new Error('expected generated review and committed artifacts')
}
const reviewBuffer = await fs.readFile(detail.review.draft_artifact_path)
const committedBuffer = await fs.readFile(detail.review.committed_artifact_path)
const reviewBundle = await loadDocxBundle(reviewBuffer)
const committedBundle = await loadDocxBundle(committedBuffer)
return {
styled,
reviewPath: detail.review.draft_artifact_path,
committedPath: detail.review.committed_artifact_path,
reviewXml: new XMLSerializer().serializeToString(reviewBundle.documentDom),
committedXml: new XMLSerializer().serializeToString(committedBundle.documentDom),
changeTitles: detail.changes.map((change) => change.title),
changeSummaries: detail.changes.map((change) => change.summary)
}
} finally {
await harness.release()
}
},
assert: async (result, runtime) => {
runtime.assertThat(
result.styled.changedBlocks.some((block) => block.action === 'style_text') &&
result.changeTitles.includes('更新文本样式: Alice') &&
result.changeSummaries.some((summary) => summary.includes('更新文本样式') && summary.includes('Alice')),
{
name: 'style-text-review-summary-uses-style-specific-copy',
message: 'style_text 进入 review 记录后,应使用独立的样式修改标题和摘要。',
data: {
changedBlocks: result.styled.changedBlocks,
changeTitles: result.changeTitles,
changeSummaries: result.changeSummaries
}
}
)
runtime.assertThat(
result.reviewXml.includes('CFFAFE') && !result.committedXml.includes('CFFAFE'),
{
name: 'style-text-review-highlight-stays-review-only',
message: 'style_text 应只在 review artifact 中带浅青色高亮,committed artifact 不应保留该显示层标记。',
data: {
reviewPath: result.reviewPath,
committedPath: result.committedPath,
reviewHasHighlight: result.reviewXml.includes('CFFAFE'),
committedHasHighlight: result.committedXml.includes('CFFAFE'),
reviewXmlSnippet: result.reviewXml.slice(0, 1200),
committedXmlSnippet: result.committedXml.slice(0, 1200)
}
}
)
}
})
})
})
@@ -119,6 +119,7 @@ describe('ai unit mouth checklist guard', () => {
expect(prompt).toContain('只输出最终 markdown 正文')
expect(prompt).toContain('如果需要使用不来自当前上下文的通用知识')
expect(prompt).toContain('补充说明')
expect(prompt).toContain('严格按照当前上下文信息回答,不得编造')
})
it('keeps mouth user-context blocks data-only', () => {
@@ -324,6 +325,122 @@ describe('ai unit mouth checklist guard', () => {
expect(prompt.user).not.toContain('fromBlockId')
})
it('renders actual docx write args in mouth history for paragraph and table edits', () => {
const initialState = createInitialHumanState({
taskId: 'task-6',
userInput: '把主稿补充完整并插入表格'
})
const history = appendRoundRecord({
state: initialState,
decision: {
decision: {
summary: '写入主稿段落和表格',
reason: '当前已经明确知道要写入的正文和表格内容',
expectedOutcome: '让 mouth 严格依据真实写入内容回答'
},
nextAction: {
action: 'hand',
toolUsePlan: {
mode: 'serial',
calls: [
{
toolName: 'docx_paragraph_edit',
args: {
sourcePath: 'prd.docx',
action: 'replace_text',
blockId: 'b:31',
text: '每个探究环节应包含四个核心设计要素:探究目标、探究素材、学生活动设计、环节推进条件。'
}
},
{
toolName: 'docx_table_edit',
args: {
sourcePath: 'prd.docx',
action: 'insert_after',
anchorBlockId: 'b:32',
rows: [
['设计要素', '关键内容', '互动形式示例'],
['探究目标', '明确学生需达成的认知目标', '情境引入、问题聚焦、概念生成'],
['探究素材', '提供视频、图片、数据等支撑材料', '耐克迁移视频、生产地图、成本数据表']
]
}
}
]
}
}
},
execution: buildHandExecution({
toolUsePlan: {
mode: 'serial',
calls: [
{
toolName: 'docx_paragraph_edit',
args: {
sourcePath: 'prd.docx',
action: 'replace_text',
blockId: 'b:31',
text: '每个探究环节应包含四个核心设计要素:探究目标、探究素材、学生活动设计、环节推进条件。'
}
},
{
toolName: 'docx_table_edit',
args: {
sourcePath: 'prd.docx',
action: 'insert_after',
anchorBlockId: 'b:32',
rows: [
['设计要素', '关键内容', '互动形式示例'],
['探究目标', '明确学生需达成的认知目标', '情境引入、问题聚焦、概念生成'],
['探究素材', '提供视频、图片、数据等支撑材料', '耐克迁移视频、生产地图、成本数据表']
]
}
}
]
},
toolResults: [
{
toolName: 'docx_paragraph_edit',
ok: true,
code: 'ok',
data: {
ok: true,
code: 'ok',
summary: '段落replace_text 完成,共 1 处变更'
}
},
{
toolName: 'docx_table_edit',
ok: true,
code: 'ok',
data: {
ok: true,
code: 'ok',
summary: '表格insert_after 完成,共 1 处变更'
}
}
]
}),
createdAt: 3
})
const prompt = buildMouthPrompt({
state: {
...initialState,
memory: {
...initialState.memory,
history
}
}
})
expect(prompt.user).toContain('`docx_paragraph_edit` 已完成实际写入。')
expect(prompt.user).toContain('每个探究环节应包含四个核心设计要素')
expect(prompt.user).toContain('`docx_table_edit` 已完成实际写入。')
expect(prompt.user).toContain('设计要素')
expect(prompt.user).toContain('互动形式示例')
expect(prompt.user).toContain('耐克迁移视频')
})
it('renders delivery guidance when present', () => {
const initialState = createInitialHumanState({
taskId: 'task-5',