feat: 添加部分替换功能及优化重写流程

- 新增部分替换视图模式,支持选择性替换原文句子
- 过滤 AI 返回的 <thinking> 标签内容
- 为 applySelectedChanges 添加边界检查防止返回空结果

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-09 01:15:39 +08:00
parent 1a1d7dabdf
commit 5a22477075
2 changed files with 254 additions and 6 deletions

View File

@@ -405,6 +405,10 @@
@click="rewriteViewMode = 'review'; currentReviewIdx = 0"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'review' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>逐句审核</button>
<button
@click="switchToPartialReplace"
:class="['text-xs px-2 py-1 rounded transition', rewriteViewMode === 'partial' ? 'bg-indigo-600 text-white' : 'text-slate-400 hover:text-white']"
>部分替换</button>
</div>
</div>
@@ -556,12 +560,59 @@
没有需要审核的修改
</div>
</div>
<!-- 部分替换视图 -->
<div v-else-if="rewriteViewMode === 'partial'" class="p-4">
<div class="flex gap-4">
<!-- 左边原文句子 -->
<div class="flex-1">
<div class="text-xs text-amber-400 mb-2 font-medium flex items-center justify-between">
<span>📄 原文点击选中要被替换的句子</span>
<span class="text-slate-500">已选 {{ partialLeftSelected.size }} </span>
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 max-h-[400px] overflow-y-auto">
<template v-for="(sentence, idx) in originalSentences" :key="'partial-orig-' + idx">
<span
@click="togglePartialLeftSentence(idx)"
:class="[
'text-sm px-0.5 rounded cursor-pointer transition-all inline',
partialLeftSelected.has(idx)
? 'bg-amber-500/40 text-amber-100 ring-2 ring-amber-400'
: 'text-slate-300 hover:bg-amber-900/30 hover:text-amber-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
</div>
<!-- 右边重写后句子 -->
<div class="flex-1">
<div class="text-xs text-blue-400 mb-2 font-medium flex items-center justify-between">
<span> 重写后点击选中用于替换的句子</span>
<span class="text-slate-500">已选 {{ partialRightSelected.size }} </span>
</div>
<div class="bg-slate-900 rounded-lg p-3 border border-slate-700 max-h-[400px] overflow-y-auto">
<template v-for="(sentence, idx) in rewrittenSentences" :key="'partial-new-' + idx">
<span
@click="togglePartialRightSentence(idx)"
:class="[
'text-sm px-0.5 rounded cursor-pointer transition-all inline',
partialRightSelected.has(idx)
? 'bg-blue-500/40 text-blue-100 ring-2 ring-blue-400'
: 'text-slate-300 hover:bg-blue-900/30 hover:text-blue-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
</div>
</div>
<p class="text-xs text-slate-500 mt-3 text-center">在左边选中要被替换的句子在右边选中用于替换的内容点击"应用选中"完成替换</p>
</div>
</div>
<!-- 浮窗底部操作 -->
<div class="p-4 border-t border-slate-700 flex items-center justify-between">
<!-- 左侧全选/取消全选 -->
<div v-if="rewritingSuggestionIdx === null && diffSegments.length > 0" class="flex gap-2">
<!-- 左侧全选/取消全选差异对比模式 -->
<div v-if="rewritingSuggestionIdx === null && rewriteViewMode !== 'partial' && diffSegments.length > 0" class="flex gap-2">
<button
@click="toggleAllChanges(true)"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
@@ -571,6 +622,25 @@
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消全选</button>
</div>
<!-- 左侧部分替换模式的操作按钮 -->
<div v-else-if="rewriteViewMode === 'partial'" class="flex gap-2">
<button
@click="partialLeftSelected = new Set([...Array(originalSentences.length).keys()])"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>全选左侧</button>
<button
@click="partialLeftSelected = new Set()"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消左侧</button>
<button
@click="partialRightSelected = new Set([...Array(rewrittenSentences.length).keys()])"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>全选右侧</button>
<button
@click="partialRightSelected = new Set()"
class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
>取消右侧</button>
</div>
<div v-else></div>
<!-- 右侧主操作按钮 -->
@@ -579,12 +649,20 @@
@click="resetRewriteModal"
class="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg hover:bg-slate-600"
>取消</button>
<!-- 差异对比模式的应用选中按钮 -->
<button
v-if="diffSegments.length > 0 && acceptedChanges.size < reviewableChanges.length"
v-if="rewriteViewMode !== 'partial' && diffSegments.length > 0 && acceptedChanges.size < reviewableChanges.length"
@click="applySelectedChangesToContent"
:disabled="rewritingSuggestionIdx !== null || acceptedChanges.size === 0"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>应用选中 ({{ acceptedChanges.size }})</button>
<!-- 部分替换模式的应用按钮 -->
<button
v-if="rewriteViewMode === 'partial'"
@click="applyPartialReplace"
:disabled="partialLeftSelected.size === 0 || partialRightSelected.size === 0"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-500 disabled:opacity-50 disabled:cursor-not-allowed"
>应用选中 ({{ partialLeftSelected.size }} {{ partialRightSelected.size }})</button>
<button
@click="confirmRewrite"
:disabled="rewritingSuggestionIdx !== null || !rewritePreview"
@@ -604,7 +682,7 @@ import { useAppStore } from '../stores/app'
import { useDatabaseStore } from '../stores/database'
import { SECTION_TYPES, getSectionTypeById, getSectionTypeClasses } from '../config/sectionTypes'
import { getLogicParadigmById, buildLogicPrompt } from '../config/logicParadigms'
import { computeDiff, applySelectedChanges as applyDiffChanges, getDiffStats } from '../utils/textDiff'
import { computeDiff, applySelectedChanges as applyDiffChanges, getDiffStats, splitIntoSentencesWithPosition } from '../utils/textDiff'
import MaterialSelectorModal from './MaterialSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.vue'
@@ -655,6 +733,12 @@ const currentReviewIdx = ref(0) // 当前审核的片段索引
const acceptedChanges = ref(new Set()) // 已接受的修改索引集合
const originalContent = ref('') // 原始内容(用于对比)
// 部分替换相关状态
const partialLeftSelected = ref(new Set()) // 左侧选中的句子索引
const partialRightSelected = ref(new Set()) // 右侧选中的句子索引
const originalSentences = ref([]) // 原文句子列表
const rewrittenSentences = ref([]) // 重写后句子列表
// 范式相关
const showParadigmRules = ref(false)
const checkMode = ref('paragraph') // 'paragraph' | 'document'
@@ -928,10 +1012,37 @@ const executeRewrite = async (suggestionIdx, suggestion) => {
try {
const prompt = buildRewritePrompt(originalContent.value, suggestion, paragraphTypeInfo)
// 用于过滤 <thinking> 内容的缓冲区
let rawBuffer = ''
await appStore.callApi(prompt, (chunk) => {
rewritePreview.value += chunk
rawBuffer += chunk
// 过滤掉 <thinking> 标签内的内容
// 1. 如果还在 thinking 阶段,不显示
// 2. 如果 thinking 结束了,只显示 </thinking> 之后的内容
// 检查是否有完整的 </thinking> 标签
const thinkingEndIdx = rawBuffer.indexOf('</thinking>')
if (thinkingEndIdx !== -1) {
// thinking 已结束,显示 </thinking> 之后的内容
const afterThinking = rawBuffer.slice(thinkingEndIdx + '</thinking>'.length).trim()
rewritePreview.value = afterThinking
} else if (!rawBuffer.includes('<thinking>')) {
// 没有 thinking 标签,直接显示(可能 AI 没有使用结构化输出)
rewritePreview.value = rawBuffer
}
// 如果有 <thinking> 但还没有 </thinking>,暂时不显示,等待完成
}, { temperature: 0.5 })
// 流式完成后,最终清理结果
let finalContent = rawBuffer
// 移除 <thinking>...</thinking> 内容
finalContent = finalContent.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim()
// 也移除可能残留的单独标签
finalContent = finalContent.replace(/<\/?thinking>/g, '').trim()
rewritePreview.value = finalContent
// 重写完成后自动计算差异
computeDiffSegments()
@@ -1048,12 +1159,25 @@ const prevReviewItem = () => {
// 应用选中的修改
const applySelectedChangesToContent = () => {
if (diffSegments.value.length === 0) return
// 如果没有选中任何修改,提示用户并返回
if (acceptedChanges.value.size === 0) {
alert('请先选中需要应用的修改')
return
}
const finalContent = applyDiffChanges(
originalContent.value,
diffSegments.value,
acceptedChanges.value
)
// 防止结果为空(可能是差异计算的边界情况)
if (!finalContent || finalContent.trim() === '') {
console.error('applySelectedChanges 返回空结果,保持原内容不变')
alert('应用修改失败,请尝试使用"全部替换"功能')
return
}
// 执行替换
updateRightContent(targetParagraphIdxs.value, finalContent)
@@ -1073,8 +1197,110 @@ const resetRewriteModal = () => {
currentReviewIdx.value = 0
acceptedChanges.value = new Set()
originalContent.value = ''
// 重置部分替换状态
partialLeftSelected.value = new Set()
partialRightSelected.value = new Set()
originalSentences.value = []
rewrittenSentences.value = []
}
// 切换到部分替换视图模式
const switchToPartialReplace = () => {
rewriteViewMode.value = 'partial'
// 初始化句子列表
originalSentences.value = splitIntoSentencesWithPosition(originalContent.value)
rewrittenSentences.value = splitIntoSentencesWithPosition(rewritePreview.value)
// 重置选择状态
partialLeftSelected.value = new Set()
partialRightSelected.value = new Set()
}
// 切换左侧句子选择状态
const togglePartialLeftSentence = (idx) => {
if (partialLeftSelected.value.has(idx)) {
partialLeftSelected.value.delete(idx)
} else {
partialLeftSelected.value.add(idx)
}
partialLeftSelected.value = new Set(partialLeftSelected.value)
}
// 切换右侧句子选择状态
const togglePartialRightSentence = (idx) => {
if (partialRightSelected.value.has(idx)) {
partialRightSelected.value.delete(idx)
} else {
partialRightSelected.value.add(idx)
}
partialRightSelected.value = new Set(partialRightSelected.value)
}
// 应用部分替换
const applyPartialReplace = () => {
// 验证必须选中内容
if (partialLeftSelected.value.size === 0) {
alert('请在左侧选中需要被替换的句子')
return
}
if (partialRightSelected.value.size === 0) {
alert('请在右侧选中用于替换的句子')
return
}
// 获取右侧选中的句子文本(按顺序拼接)
const sortedRightIdxs = [...partialRightSelected.value].sort((a, b) => a - b)
const replacementText = sortedRightIdxs.map(idx => rewrittenSentences.value[idx].text).join('')
// 构建最终内容:未选中的原文句子保留,选中的部分用右侧替换
const sortedLeftIdxs = [...partialLeftSelected.value].sort((a, b) => a - b)
const firstSelectedIdx = sortedLeftIdxs[0]
const lastSelectedIdx = sortedLeftIdxs[sortedLeftIdxs.length - 1]
let finalContent = ''
// 添加第一个选中句子之前的内容
if (firstSelectedIdx > 0) {
const firstSentence = originalSentences.value[firstSelectedIdx]
finalContent += originalContent.value.slice(0, firstSentence.start)
}
// 添加替换内容
finalContent += replacementText
// 添加最后一个选中句子之后的内容
if (lastSelectedIdx < originalSentences.value.length - 1) {
const lastSentence = originalSentences.value[lastSelectedIdx]
finalContent += originalContent.value.slice(lastSentence.end)
}
// 添加选中句子之间的未选中内容(如果有)
// 重新计算:遍历所有句子,未选中的保留,选中的用替换内容
let result = ''
let replacementInserted = false
for (let i = 0; i < originalSentences.value.length; i++) {
const sentence = originalSentences.value[i]
if (partialLeftSelected.value.has(i)) {
// 选中的句子:在第一个选中位置插入替换内容
if (!replacementInserted) {
result += replacementText
replacementInserted = true
}
// 其他选中的句子跳过(已被替换)
} else {
// 未选中的句子:保留原文
result += sentence.text
}
}
// 执行替换
updateRightContent(targetParagraphIdxs.value, result)
// 关闭浮窗并重置状态
resetRewriteModal()
}
// 构建段落类型识别 Prompt
const buildParagraphTypeDetectionPrompt = (paragraphs) => {
return `你是一名专业的公文写作分析专家。请分析以下段落,识别每个段落属于哪种章节类型。

View File

@@ -211,6 +211,16 @@ export const computeDiff = (original, rewritten) => {
* @returns {string} - 精确替换后的文本
*/
export const applySelectedChanges = (original, diffSegments, acceptedChanges) => {
// 边界检查:如果没有选中任何修改,直接返回原文
if (!acceptedChanges || acceptedChanges.size === 0) {
return original
}
// 边界检查:如果没有差异片段,直接返回原文
if (!diffSegments || diffSegments.length === 0) {
return original
}
// 收集所有需要执行的替换操作
const operations = []
@@ -248,6 +258,11 @@ export const applySelectedChanges = (original, diffSegments, acceptedChanges) =>
}
}
// 如果没有有效的操作,返回原文
if (operations.length === 0) {
return original
}
// 按位置从后往前排序(避免位置偏移)
operations.sort((a, b) => {
const posA = a.start !== undefined ? a.start : a.position
@@ -267,9 +282,16 @@ export const applySelectedChanges = (original, diffSegments, acceptedChanges) =>
}
}
// 最终检查:如果结果为空,返回原文(保护性措施)
if (!result || result.trim() === '') {
console.warn('applySelectedChanges: 结果为空,返回原文')
return original
}
return result
}
/**
* 获取差异统计
* @param {Array} diffSegments - 差异片段