Files
ai-write/src/components/ArticleRewritePanel.vue
empty 29bb7e2e87 feat: 范式检查和复检功能支持智能粒度过滤和批量处理
## 核心功能
- 范式检查和复检支持自动分批处理(15句/批)
- 智能粒度过滤:根据选中句子数量自动调整检查粒度
  * 1-5句:仅句子级检查(术语、语气、表达)
  * 6-20句:句子级+段落级检查(逻辑结构、递进关系)
  * 21+句:全文级检查(章节完整性、篇幅占比等)

## 新增文件
- src/stores/paradigm.js:自定义范式管理 store
  * inferScope:智能推断检查项粒度
  * ensureGuidelinesHasScope:为旧范式补充 scope 字段
- src/utils/requirementParser.js:需求文档解析工具
  * buildRequirementParserPrompt:生成解析 prompt
  * validateParadigm:范式验证(兼容字符串/对象格式)
- src/components/RequirementParserPanel.vue:需求解析面板
  * 支持粘贴文本或选择文件
  * AI 自动生成范式配置(specializedPrompt + expertGuidelines)
  * 可视化编辑检查项(title/description/scope)

## 主要改进
- ArticleRewritePanel.vue:
  * 范式检查支持粒度过滤和批量处理
  * 复检功能支持粒度过滤和批量处理
  * 集成 paradigm store 自动补充 scope 字段
  * 添加批次进度显示
- RequirementParserPanel.vue:
  * 修复对象格式 expertGuidelines 显示问题
  * 支持编辑 title/description/scope 三个字段
  * 添加粒度下拉选择器(句子级/段落级/全文级)

## 技术细节
- 向后兼容:支持字符串和对象两种 expertGuidelines 格式
- 自动转换:旧范式字符串格式自动转换为对象格式并推断 scope
- 错误处理:JSON 解析失败时提供降级方案
- 控制台日志:[复检] 前缀标识复检相关日志

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 13:51:40 +08:00

960 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-screen w-full flex flex-col bg-slate-900">
<!-- 头部 -->
<header class="p-4 border-b border-slate-700 bg-slate-800 flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<h1 class="font-bold text-lg text-white flex items-center gap-2">
<span class="text-2xl">🎨</span> 范式润色
</h1>
<span class="text-xs text-slate-500">基于范式规则检查并重写文章句子</span>
</div>
<button
@click="goBack"
class="text-xs px-3 py-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
返回写作
</button>
</header>
<!-- 范式选择条 -->
<div class="px-4 py-2 bg-indigo-900/30 border-b border-indigo-700/50 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<span class="text-indigo-400 text-sm">📚 当前范式</span>
<span v-if="selectedParadigm" class="text-white text-sm font-medium flex items-center gap-1">
{{ selectedParadigm.icon }} {{ selectedParadigm.name }}
</span>
<span v-else class="text-slate-400 text-sm">未选择</span>
</div>
<button
@click="showParadigmSelector = true"
class="text-xs px-3 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-500 transition"
>
{{ selectedParadigm ? '切换范式' : '选择范式' }}
</button>
</div>
<!-- 主体内容区三栏布局 -->
<div class="flex-1 flex overflow-hidden">
<!-- 左侧原文区 -->
<div class="w-[35%] flex flex-col border-r border-slate-700 min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-amber-400 flex items-center gap-2">
📄 原文区
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">{{ sentences.length }} </span>
<span v-if="selectedSentenceIdxs.length > 0" class="text-xs text-amber-400">
已选 {{ selectedSentenceIdxs.length }}
</span>
<!-- 全选/反选按钮 -->
<div v-if="sentences.length > 0" class="flex gap-1 ml-2">
<button
@click="selectAllOriginal"
class="text-xs px-2 py-0.5 rounded bg-amber-700/50 text-amber-200 hover:bg-amber-600/50 transition"
>全选</button>
<button
@click="invertOriginalSelection"
class="text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>反选</button>
</div>
<!-- 保存按钮 -->
<button
v-if="articleSourceType === 'document' && sourceDocId && hasContentChanged"
@click="saveToDocument"
:disabled="isSaving"
class="text-xs px-2 py-0.5 rounded bg-green-600 text-white hover:bg-green-500 transition flex items-center gap-1 disabled:opacity-50"
>
<span v-if="isSaving" class="animate-spin"></span>
<span>💾 {{ isSaving ? '保存中...' : '保存' }}</span>
</button>
</div>
</div>
<!-- 来源选择器 -->
<div class="px-3 py-2 bg-slate-800/50 border-b border-slate-700 flex items-center gap-2">
<span class="text-xs text-slate-500">来源</span>
<div class="flex bg-slate-900 rounded p-0.5 border border-slate-700">
<button
@click="articleSourceType = 'paste'"
:class="['text-xs px-2 py-0.5 rounded transition',
articleSourceType === 'paste' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>粘贴</button>
<button
@click="showDocSelector = true"
:class="['text-xs px-2 py-0.5 rounded transition',
articleSourceType === 'document' ? 'bg-amber-600 text-white' : 'text-slate-400 hover:text-white']"
>文稿库</button>
</div>
<span v-if="articleSourceType === 'document' && sourceDocTitle" class="text-xs text-amber-400 ml-2">
📄 {{ sourceDocTitle }}
</span>
</div>
<!-- 连续文本流 -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
<div v-if="sentences.length === 0" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-4xl opacity-20 block mb-2">📝</span>
<p class="text-slate-600 text-sm">请在下方输入或粘贴文章内容</p>
</div>
</div>
<!-- 连续文本展示句子用 inline span -->
<div v-else class="bg-slate-800/30 rounded-lg p-4 border border-slate-700 leading-relaxed">
<template v-for="(sentence, idx) in sentences" :key="'s-' + idx">
<span
@click="toggleSentence(idx)"
:class="[
'cursor-pointer transition-all rounded px-0.5 py-0.5 hover:bg-amber-900/30',
selectedSentenceIdxs.includes(idx)
? 'bg-amber-500/40 text-amber-100 ring-1 ring-amber-400'
: 'text-slate-300 hover:text-amber-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
<!-- 提示文字 -->
<p v-if="sentences.length > 0" class="text-xs text-slate-500 mt-3 text-center">
💡 点击句子可选中/取消选中的句子将被检查
</p>
</div>
<!-- 输入框 -->
<div class="p-3 border-t border-slate-700 bg-slate-800">
<textarea
v-model="articleContent"
class="w-full h-24 bg-slate-900 border border-slate-700 rounded-lg p-3 text-sm focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none"
placeholder="粘贴或输入文章内容..."
@input="splitToSentences"
></textarea>
</div>
</div>
<!-- 中间检查结果 -->
<div class="w-[30%] flex flex-col border-r border-slate-700 min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700">
<h2 class="text-sm font-medium text-blue-400 flex items-center gap-2">
🔍 检查结果
</h2>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div v-if="!isChecking && checkResults.length === 0" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-4xl opacity-20 block mb-2">📋</span>
<p class="text-slate-600 text-sm">选中句子后点击范式检查</p>
</div>
</div>
<!-- 检查中状态 -->
<div v-if="isChecking" class="flex flex-col gap-2 text-blue-300">
<div class="flex items-center gap-2">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在检查中...</span>
</div>
<div v-if="checkProgress" class="text-xs text-slate-400 pl-6">
{{ checkProgress }}
</div>
</div>
<!-- 检查结果列表 -->
<div
v-for="(result, idx) in checkResults"
:key="'check-' + idx"
:class="[
'p-3 rounded-lg border',
result.status === 'pass' ? 'bg-green-900/20 border-green-700/50' :
result.status === 'warning' ? 'bg-yellow-900/20 border-yellow-700/50' :
'bg-red-900/20 border-red-700/50'
]"
>
<div class="flex items-start gap-2">
<span class="text-lg">
{{ result.status === 'pass' ? '✅' : result.status === 'warning' ? '⚠️' : '❌' }}
</span>
<div class="flex-1 min-w-0">
<p class="text-xs text-slate-400 mb-1">句子 {{ result.sentenceIdx + 1 }}</p>
<p class="text-sm text-slate-200">{{ result.message }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧重写预览 -->
<div class="flex-1 flex flex-col min-w-0">
<div class="p-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-sm font-medium text-emerald-400 flex items-center gap-2">
重写预览
</h2>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500">{{ rewrittenSentences.length }} </span>
<span v-if="selectedRewriteIdxs.length > 0" class="text-xs text-emerald-400">
已选 {{ selectedRewriteIdxs.length }}
</span>
<!-- 全选/反选按钮 -->
<div v-if="rewrittenSentences.length > 0" class="flex gap-1 ml-2">
<button
@click="selectAllRewrite"
class="text-xs px-2 py-0.5 rounded bg-emerald-700/50 text-emerald-200 hover:bg-emerald-600/50 transition"
>全选</button>
<button
@click="invertRewriteSelection"
class="text-xs px-2 py-0.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>反选</button>
<!-- 复检按钮对重写内容再次检查 -->
<button
@click="recheckRewrittenContent"
:disabled="selectedRewriteIdxs.length === 0 || isChecking"
class="text-xs px-2 py-0.5 rounded bg-purple-600 text-white hover:bg-purple-500 transition disabled:opacity-50"
title="对选中的重写内容再次进行范式检查"
>🔄 复检</button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 min-h-0">
<div v-if="!isRewriting && rewrittenSentences.length === 0" class="h-full flex items-center justify-center">
<div class="text-center">
<span class="text-4xl opacity-20 block mb-2"></span>
<p class="text-slate-600 text-sm">检查完成后点击AI 重写</p>
</div>
</div>
<!-- 重写中状态 -->
<div v-if="isRewriting" class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-emerald-300">
<span class="animate-spin"></span>
<span class="text-sm">AI 正在重写中...</span>
</div>
<div v-if="rewriteProgress" class="text-xs text-slate-400 pl-6">
{{ rewriteProgress }}
</div>
<div v-if="rewriteStreamContent" class="bg-slate-800/50 rounded-lg p-3 border border-slate-700">
<p class="text-sm text-slate-200 whitespace-pre-wrap">{{ rewriteStreamContent }}</p>
</div>
</div>
<!-- 连续文本展示句子用 inline span -->
<div v-else-if="rewrittenSentences.length > 0" class="bg-slate-800/30 rounded-lg p-4 border border-slate-700 leading-relaxed">
<template v-for="(sentence, idx) in rewrittenSentences" :key="'rw-' + idx">
<span
@click="toggleRewriteSentence(idx)"
:class="[
'cursor-pointer transition-all rounded px-0.5 py-0.5 hover:bg-emerald-900/30',
selectedRewriteIdxs.includes(idx)
? 'bg-emerald-500/40 text-emerald-100 ring-1 ring-emerald-400'
: 'text-slate-200 hover:text-emerald-200'
]"
>{{ sentence.text }}</span>
</template>
</div>
<!-- 提示文字 -->
<p v-if="rewrittenSentences.length > 0" class="text-xs text-slate-500 mt-3 text-center">
💡 点击句子可选中/取消选中的句子将替换原文
</p>
</div>
</div>
</div>
<!-- 底部操作区 -->
<footer class="p-4 bg-slate-800 border-t border-slate-700 shrink-0">
<div class="flex items-center justify-between gap-4">
<!-- 左侧状态提示 -->
<div class="flex-1 text-sm text-slate-500">
<span v-if="selectedSentenceIdxs.length === 0">请先在左侧选择要检查的句子</span>
<span v-else-if="!selectedParadigm">请选择一个写作范式</span>
<span v-else-if="checkResults.length === 0">准备就绪点击范式检查开始</span>
<span v-else-if="rewrittenSentences.length === 0">检查完成可点击AI 重写</span>
<span v-else>选择满意的重写句子点击应用替换</span>
</div>
<!-- 右侧操作按钮 -->
<div class="flex gap-2 shrink-0">
<button
@click="clearSelection"
class="px-4 py-2 rounded-lg text-sm bg-slate-700 text-slate-300 hover:bg-slate-600 transition"
>
清空选择
</button>
<button
@click="runParadigmCheck"
:disabled="!canCheck || isChecking"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-blue-600 hover:bg-blue-500"
>
<span v-if="isChecking" class="flex items-center gap-2">
<span class="animate-spin"></span> 检查中...
</span>
<span v-else>🔍 范式检查</span>
</button>
<button
@click="runAIRewrite"
:disabled="!canRewrite || isRewriting"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-purple-600 hover:bg-purple-500"
>
<span v-if="isRewriting" class="flex items-center gap-2">
<span class="animate-spin"></span> 重写中...
</span>
<span v-else> AI 重写</span>
</button>
<button
@click="applySelectedRewrites"
:disabled="!canApply"
class="px-4 py-2 rounded-lg text-sm font-medium text-white transition disabled:opacity-50 disabled:cursor-not-allowed bg-gradient-to-r from-amber-600 to-emerald-600 hover:from-amber-500 hover:to-emerald-500"
>
🔄 应用替换
</button>
</div>
</div>
</footer>
<!-- 范式选择弹窗 -->
<ParadigmSelectorModal
:visible="showParadigmSelector"
@close="showParadigmSelector = false"
@select="handleParadigmSelect"
@create-custom="handleCreateCustomParadigm"
/>
<!-- 文稿选择弹窗 -->
<DocumentSelectorModal
:visible="showDocSelector"
@close="showDocSelector = false"
@select="handleDocSelect"
/>
<!-- 需求解析面板 -->
<RequirementParserPanel
v-if="showRequirementParser"
@close="showRequirementParser = false"
@paradigm-created="handleParadigmCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAppStore } from '../stores/app'
import { useParadigmStore } from '../stores/paradigm'
import { splitIntoSentencesWithPosition, computeDiff } from '../utils/textDiff'
import { updateDocument, saveDocumentVersion } from '../db/index.js'
import ParadigmSelectorModal from './ParadigmSelectorModal.vue'
import DocumentSelectorModal from './DocumentSelectorModal.vue'
import RequirementParserPanel from './RequirementParserPanel.vue'
const appStore = useAppStore()
const paradigmStore = useParadigmStore()
// ========== 数据状态 ==========
// 原文相关
const articleContent = ref('')
const articleSourceType = ref('paste') // 'paste' | 'document'
const sourceDocId = ref(null)
const sourceDocTitle = ref('')
const sentences = ref([]) // 拆分后的句子 {text, start, end}
const selectedSentenceIdxs = ref([])
// 范式相关
const selectedParadigm = ref(null)
const showParadigmSelector = ref(false)
const showRequirementParser = ref(false) // 需求解析面板
// 文稿选择
const showDocSelector = ref(false)
// 检查相关
const isChecking = ref(false)
const checkProgress = ref('') // 批次进度信息
const checkResults = ref([]) // {sentenceIdx, status, message}
// 重写相关
const isRewriting = ref(false)
const rewriteProgress = ref('') // 批次进度信息
const rewriteStreamContent = ref('')
const rewrittenSentences = ref([]) // {text, start, end}
const selectedRewriteIdxs = ref([])
// 保存相关
const isSaving = ref(false)
const originalContent = ref('') // 初始内容,用于判断是否有更改
// ========== 计算属性 ==========
const canCheck = computed(() => {
return selectedSentenceIdxs.value.length > 0 && selectedParadigm.value !== null
})
const canRewrite = computed(() => {
return checkResults.value.length > 0 && selectedParadigm.value !== null
})
const canApply = computed(() => {
return selectedRewriteIdxs.value.length > 0 && selectedSentenceIdxs.value.length > 0
})
// 判断内容是否有更改
const hasContentChanged = computed(() => {
return articleContent.value !== originalContent.value && originalContent.value !== ''
})
// ========== 方法 ==========
// 返回上一页
const goBack = () => {
appStore.setCurrentPage('writer')
}
// 拆分文章为句子
const splitToSentences = () => {
sentences.value = splitIntoSentencesWithPosition(articleContent.value)
// 清空之前的选择和结果
selectedSentenceIdxs.value = []
checkResults.value = []
rewrittenSentences.value = []
selectedRewriteIdxs.value = []
}
// 切换句子选中状态
const toggleSentence = (idx) => {
const index = selectedSentenceIdxs.value.indexOf(idx)
if (index === -1) {
selectedSentenceIdxs.value.push(idx)
} else {
selectedSentenceIdxs.value.splice(index, 1)
}
}
// 切换重写句子选中状态
const toggleRewriteSentence = (idx) => {
const index = selectedRewriteIdxs.value.indexOf(idx)
if (index === -1) {
selectedRewriteIdxs.value.push(idx)
} else {
selectedRewriteIdxs.value.splice(index, 1)
}
}
// 处理范式选择
const handleParadigmSelect = (paradigm) => {
// 确保 expertGuidelines 有 scope 字段
const paradigmWithScope = paradigmStore.ensureGuidelinesHasScope(paradigm)
selectedParadigm.value = paradigmWithScope
showParadigmSelector.value = false
console.log('选择范式后的 expertGuidelines:', paradigmWithScope.expertGuidelines)
}
// 处理文稿选择
const handleDocSelect = (doc) => {
if (doc && doc.content) {
articleContent.value = doc.content
originalContent.value = doc.content // 记录原始内容用于差异检测
articleSourceType.value = 'document'
sourceDocId.value = doc.id
sourceDocTitle.value = doc.title
splitToSentences()
}
showDocSelector.value = false
}
// 处理创建自定义范式(打开需求解析面板)
const handleCreateCustomParadigm = () => {
showParadigmSelector.value = false
showRequirementParser.value = true
}
// 处理范式创建完成
const handleParadigmCreated = (paradigm) => {
selectedParadigm.value = paradigm
showRequirementParser.value = false
// 可选:显示成功提示
console.log('自定义范式已创建:', paradigm)
}
// 粒度判断函数:根据句子数量确定允许的检查粒度
const getCheckScope = (sentenceCount) => {
if (sentenceCount <= 5) return ['sentence'] // 1-5 句:仅句子级
if (sentenceCount <= 20) return ['sentence', 'paragraph'] // 6-20 句:句子级+段落级
return ['sentence', 'paragraph', 'document'] // 21+ 句:所有级别
}
// 执行范式检查
const runParadigmCheck = async () => {
if (!canCheck.value) return
isChecking.value = true
checkProgress.value = ''
checkResults.value = []
try {
// 获取选中的句子索引(排序)
const sortedIdxs = [...selectedSentenceIdxs.value].sort((a, b) => a - b)
const sentenceCount = sortedIdxs.length
// 自动分批处理每批最多15个句子
const BATCH_SIZE = 15
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
// 确定当前检查粒度
const allowedScopes = getCheckScope(sentenceCount)
console.log(`总共选中 ${sentenceCount} 个句子,将分 ${totalBatches} 批处理,允许粒度:`, allowedScopes)
// 构建公共的检查参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
// expertGuidelines 可能是字符串数组或对象数组,需要兼容处理并过滤
const guidelines = selectedParadigm.value.expertGuidelines || []
const filteredGuidelines = guidelines.filter(g => {
// 兼容字符串和对象格式
if (typeof g === 'string') {
// 字符串格式:默认为 sentence 级别
return allowedScopes.includes('sentence')
} else {
// 对象格式:检查 scope 字段
const scope = g.scope || 'sentence' // 默认句子级
return allowedScopes.includes(scope)
}
})
console.log(`过滤前 expertGuidelines: ${guidelines.length} 条,过滤后: ${filteredGuidelines.length}`)
const guidelinesText = filteredGuidelines
.map((g, i) => {
if (typeof g === 'string') {
return `${i + 1}. ${g}`
} else {
return `${i + 1}. 【${g.title || '检查项'}${g.description || g}`
}
})
.join('\n')
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => sentences.value[idx])
checkProgress.value = `正在检查第 ${batchIndex + 1}/${totalBatches} 批(句子 ${batchStart + 1}-${batchEnd}...`
console.log(`检查批次 ${batchIndex + 1}/${totalBatches}(句子 ${batchStart + 1}-${batchEnd}`)
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
【检查上下文】
本次检查的是 ${batchSentences.length} 个句子的片段(共选中 ${sentenceCount} 句)。
${sentenceCount <= 5 ? '⚠️ 这是句子级检查,请忽略文档结构要求,专注于句子质量(如术语、语气、表达)。' : ''}${sentenceCount <= 20 ? '⚠️ 这是段落级检查,请检查局部逻辑和结构,忽略全文结构要求(如篇幅占比、章节完整性)。' : ''}
# 范式规则
${paradigmPrompt}
# 专家检查项(已根据检查粒度自动过滤)
${guidelinesText || '(无特定检查项)'}
# 待检查的句子
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下JSON格式输出只输出JSON不要markdown代码块标记
{
"results": [
{"sentenceIdx": 0, "status": "pass", "message": "符合要求"},
{"sentenceIdx": 1, "status": "warning", "message": "建议改进"},
{"sentenceIdx": 2, "status": "fail", "message": "不符合要求"}
]
}
注意:
1. status只能是 pass/warning/fail 三个值之一
2. message要简洁明了不超过50字
3. sentenceIdx从0开始对应上面句子的序号
4. 必须检查所有${batchSentences.length}个句子
5. 确保JSON格式完全正确不要有多余的逗号或换行`
let fullResponse = ''
await appStore.callApi(checkPrompt, (chunk) => {
fullResponse += chunk
}, { temperature: 0.3 })
console.log(`批次 ${batchIndex + 1} - AI完整响应长度:`, fullResponse.length)
// 解析本批次结果
try {
// 1. 尝试提取JSON代码块
let jsonText = fullResponse
const jsonBlockMatch = fullResponse.match(/```json\s*([\s\S]*?)\s*```/)
if (jsonBlockMatch) {
jsonText = jsonBlockMatch[1]
}
// 2. 提取JSON对象
const jsonMatch = jsonText.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('未找到有效的JSON对象')
}
const parsed = JSON.parse(jsonMatch[0])
if (!parsed.results || !Array.isArray(parsed.results)) {
throw new Error('JSON格式错误缺少results数组')
}
// 映射回原始句子索引并添加到总结果
const batchResults = parsed.results.map((r, i) => ({
sentenceIdx: batchIdxs[r.sentenceIdx] ?? batchIdxs[i],
status: r.status || 'warning',
message: r.message || '检查完成'
}))
checkResults.value.push(...batchResults)
console.log(`批次 ${batchIndex + 1} 解析成功,本批 ${batchResults.length} 个结果`)
} catch (e) {
console.error(`批次 ${batchIndex + 1} 解析失败:`, e)
console.error('完整响应内容:', fullResponse)
// 降级:为本批次的每个句子生成通用结果
const fallbackResults = batchIdxs.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '解析异常,建议人工复核'
}))
checkResults.value.push(...fallbackResults)
}
}
console.log(`所有批次检查完成,共 ${checkResults.value.length} 个结果`)
checkProgress.value = `检查完成!共检查 ${checkResults.value.length} 个句子`
} catch (error) {
console.error('范式检查失败:', error)
alert('检查失败: ' + error.message)
} finally {
isChecking.value = false
}
}
// 执行 AI 重写
const runAIRewrite = async () => {
if (!canRewrite.value) return
isRewriting.value = true
rewriteProgress.value = ''
rewriteStreamContent.value = ''
rewrittenSentences.value = []
selectedRewriteIdxs.value = []
try {
// 获取选中的句子索引(排序)
const sortedIdxs = [...selectedSentenceIdxs.value].sort((a, b) => a - b)
// 自动分批处理每批最多10个句子重写比检查需要更多tokens所以批次更小
const BATCH_SIZE = 10
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
console.log(`总共需要重写 ${sortedIdxs.length} 个句子,将分 ${totalBatches} 批处理`)
// 构建公共的重写参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => sentences.value[idx])
rewriteProgress.value = `正在重写第 ${batchIndex + 1}/${totalBatches} 批(句子 ${batchStart + 1}-${batchEnd}...`
console.log(`重写批次 ${batchIndex + 1}/${totalBatches}(句子 ${batchStart + 1}-${batchEnd}`)
// 获取本批次对应的检查结果
const batchCheckResults = checkResults.value
.filter(r => batchIdxs.includes(r.sentenceIdx))
.map(r => {
const localIdx = batchIdxs.indexOf(r.sentenceIdx)
return `句子${localIdx + 1}: ${r.status} - ${r.message}`
})
.join('\n')
const rewritePrompt = `你是一个专业的写作润色专家。请基于以下范式规则和检查结果,重写给定的句子。
# 范式规则
${paradigmPrompt}
# 检查结果
${batchCheckResults || '(无检查结果)'}
# 原句
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请直接按照顺序输出重写后的句子,每句一行,格式如下(仅输出重写内容,不要其他说明):
1. 重写后的句子1
2. 重写后的句子2
...`
let fullResponse = ''
await appStore.callApi(rewritePrompt, (chunk) => {
fullResponse += chunk
// 实时显示时尝试提取 draft 内容
const draftMatch = fullResponse.match(/<draft>([\s\S]*?)(?:<\/draft>|$)/)
if (draftMatch) {
rewriteStreamContent.value = draftMatch[1].trim()
} else {
// 如果还没有 draft 标签,显示完整内容(可能在 thinking 阶段)
rewriteStreamContent.value = fullResponse
}
}, { temperature: 0.7 })
console.log(`批次 ${batchIndex + 1} - AI完整响应长度:`, fullResponse.length)
// 解析重写结果:提取 <draft> 标签内的内容
let contentToParse = fullResponse
const draftMatch = fullResponse.match(/<draft>([\s\S]*?)<\/draft>/)
if (draftMatch) {
contentToParse = draftMatch[1].trim()
}
// 按行拆分并处理
const lines = contentToParse.split('\n').filter(line => line.trim())
const batchRewritten = lines.map((line, i) => {
// 移除行首的序号
const text = line.replace(/^\d+\.\s*/, '').trim()
return { text, start: 0, end: text.length }
}).filter(s => s.text) // 过滤空行
rewrittenSentences.value.push(...batchRewritten)
console.log(`批次 ${batchIndex + 1} 重写完成,本批 ${batchRewritten.length} 个句子`)
}
console.log(`所有批次重写完成,共 ${rewrittenSentences.value.length} 个句子`)
rewriteProgress.value = `重写完成!共重写 ${rewrittenSentences.value.length} 个句子`
} catch (error) {
console.error('AI 重写失败:', error)
alert('重写失败: ' + error.message)
} finally {
isRewriting.value = false
rewriteStreamContent.value = ''
}
}
// 应用选中的重写
const applySelectedRewrites = () => {
if (!canApply.value) return
// 获取选中的原句索引(排序)
const sortedOrigIdxs = [...selectedSentenceIdxs.value].sort((a, b) => b - a)
// 获取选中的重写索引(排序)
const sortedRewriteIdxs = [...selectedRewriteIdxs.value].sort((a, b) => a - b)
// 从后往前替换,避免位置偏移
let newContent = articleContent.value
const minLen = Math.min(sortedOrigIdxs.length, sortedRewriteIdxs.length)
for (let i = 0; i < minLen; i++) {
const origIdx = sortedOrigIdxs[i]
const rewriteIdx = sortedRewriteIdxs[minLen - 1 - i]
const origSentence = sentences.value[origIdx]
const rewriteSentence = rewrittenSentences.value[rewriteIdx]
if (origSentence && rewriteSentence) {
newContent = newContent.slice(0, origSentence.start) +
rewriteSentence.text +
newContent.slice(origSentence.end)
}
}
// 更新内容并重新拆分
articleContent.value = newContent
splitToSentences()
// 提示完成
alert(`已替换 ${minLen} 个句子`)
}
// 清空选择
const clearSelection = () => {
selectedSentenceIdxs.value = []
selectedRewriteIdxs.value = []
checkResults.value = []
rewrittenSentences.value = []
}
// 监听内容变化自动拆分
watch(articleContent, () => {
if (articleSourceType.value === 'paste') {
splitToSentences()
}
}, { immediate: false })
// ========== 全选/反选方法 ==========
// 原文区全选
const selectAllOriginal = () => {
selectedSentenceIdxs.value = sentences.value.map((_, idx) => idx)
}
// 原文区反选
const invertOriginalSelection = () => {
const allIdxs = sentences.value.map((_, idx) => idx)
selectedSentenceIdxs.value = allIdxs.filter(idx => !selectedSentenceIdxs.value.includes(idx))
}
// 重写预览区全选
const selectAllRewrite = () => {
selectedRewriteIdxs.value = rewrittenSentences.value.map((_, idx) => idx)
}
// 重写预览区反选
const invertRewriteSelection = () => {
const allIdxs = rewrittenSentences.value.map((_, idx) => idx)
selectedRewriteIdxs.value = allIdxs.filter(idx => !selectedRewriteIdxs.value.includes(idx))
}
// ========== 保存方法 ==========
// 保存到文稿库
const saveToDocument = async () => {
if (!sourceDocId.value || !hasContentChanged.value) return
isSaving.value = true
try {
// 计算差异
const diff = computeDiff(originalContent.value, articleContent.value)
const changedCount = diff.filter(d => d.type !== 'unchanged').length
// 保存版本历史
const changeNote = `范式润色:修改了 ${changedCount}`
saveDocumentVersion(sourceDocId.value, articleContent.value, changeNote)
// 更新文稿内容
updateDocument(sourceDocId.value, {
content: articleContent.value
})
// 更新原始内容基准
originalContent.value = articleContent.value
alert(`保存成功!修改了 ${changedCount} 处,已创建新版本。`)
} catch (error) {
console.error('保存失败:', error)
alert('保存失败: ' + error.message)
} finally {
isSaving.value = false
}
}
// ========== 复检方法 ==========
// 对重写内容再次进行范式检查
const recheckRewrittenContent = async () => {
if (selectedRewriteIdxs.value.length === 0 || !selectedParadigm.value) {
alert('请先在重写预览区选中要复检的句子')
return
}
isChecking.value = true
checkResults.value = []
try {
// 获取选中的重写句子索引(排序)
const sortedIdxs = [...selectedRewriteIdxs.value].sort((a, b) => a - b)
const sentenceCount = sortedIdxs.length
// 自动分批处理每批最多15个句子
const BATCH_SIZE = 15
const totalBatches = Math.ceil(sortedIdxs.length / BATCH_SIZE)
// 确定当前检查粒度
const allowedScopes = getCheckScope(sentenceCount)
console.log(`[复检] 总共选中 ${sentenceCount} 个句子,将分 ${totalBatches} 批处理,允许粒度:`, allowedScopes)
// 构建公共的检查参数
const paradigmPrompt = selectedParadigm.value.specializedPrompt || ''
// expertGuidelines 可能是字符串数组或对象数组,需要兼容处理并过滤
const guidelines = selectedParadigm.value.expertGuidelines || []
const filteredGuidelines = guidelines.filter(g => {
// 兼容字符串和对象格式
if (typeof g === 'string') {
// 字符串格式:默认为 sentence 级别
return allowedScopes.includes('sentence')
} else {
// 对象格式:检查 scope 字段
const scope = g.scope || 'sentence' // 默认句子级
return allowedScopes.includes(scope)
}
})
console.log(`[复检] 过滤前 expertGuidelines: ${guidelines.length} 条,过滤后: ${filteredGuidelines.length}`)
const guidelinesText = filteredGuidelines
.map((g, i) => {
if (typeof g === 'string') {
return `${i + 1}. ${g}`
} else {
return `${i + 1}. 【${g.title || '检查项'}${g.description || g}`
}
})
.join('\n')
// 逐批处理
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
const batchStart = batchIndex * BATCH_SIZE
const batchEnd = Math.min(batchStart + BATCH_SIZE, sortedIdxs.length)
const batchIdxs = sortedIdxs.slice(batchStart, batchEnd)
const batchSentences = batchIdxs.map(idx => rewrittenSentences.value[idx])
console.log(`[复检] 批次 ${batchIndex + 1}/${totalBatches}: 处理句子 ${batchStart + 1}-${batchEnd}`)
const checkPrompt = `你是一个写作质量检查专家。请基于以下范式规则和专家指令,逐条检查给定的句子。
【检查上下文】
本次检查的是 ${batchSentences.length} 个句子的片段(共选中 ${sentenceCount} 句,这是重写后的内容复检)。
${sentenceCount <= 5 ? '⚠️ 这是句子级检查,请忽略文档结构要求,专注于句子质量(如术语、语气、表达)。' : ''}${sentenceCount <= 20 ? '⚠️ 这是段落级检查,请检查局部逻辑和结构,忽略全文结构要求(如篇幅占比、章节完整性)。' : ''}
# 范式规则
${paradigmPrompt}
# 专家检查项(已根据检查粒度自动过滤)
${guidelinesText || '(无特定检查项)'}
# 待检查的句子(重写后的内容)
${batchSentences.map((s, i) => `${i + 1}. ${s.text}`).join('\n')}
# 输出要求
请严格按照以下 JSON 格式输出检查结果(仅输出 JSON不要其他内容
{
"results": [
{"sentenceIdx": 0, "status": "pass|warning|fail", "message": "评价说明"}
]
}`
let fullResponse = ''
await appStore.callApi(checkPrompt, (chunk) => {
fullResponse += chunk
}, { temperature: 0.3 })
// 解析结果
try {
const jsonMatch = fullResponse.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
// 映射回重写句子索引,并标记为「复检」
const batchResults = (parsed.results || []).map((r, i) => ({
sentenceIdx: batchIdxs[r.sentenceIdx] ?? batchIdxs[i],
status: r.status || 'warning',
message: `[复检] ${r.message || '检查完成'}`,
isRecheck: true // 标记为复检结果
}))
checkResults.value.push(...batchResults)
}
} catch (e) {
console.warn(`[复检] 批次 ${batchIndex + 1} 结果解析失败:`, e)
// 为当前批次的句子添加警告结果
const fallbackResults = batchIdxs.map((idx) => ({
sentenceIdx: idx,
status: 'warning',
message: '[复检] 解析异常,建议人工复核',
isRecheck: true
}))
checkResults.value.push(...fallbackResults)
}
}
console.log(`[复检] 所有批次处理完成,共 ${checkResults.value.length} 条结果`)
} catch (error) {
console.error('复检失败:', error)
alert('复检失败: ' + error.message)
} finally {
isChecking.value = false
}
}
</script>