feat: 添加部分替换功能及优化重写流程
- 新增部分替换视图模式,支持选择性替换原文句子 - 过滤 AI 返回的 <thinking> 标签内容 - 为 applySelectedChanges 添加边界检查防止返回空结果 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 `你是一名专业的公文写作分析专家。请分析以下段落,识别每个段落属于哪种章节类型。
|
||||
|
||||
@@ -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 - 差异片段
|
||||
|
||||
Reference in New Issue
Block a user