## 核心功能 - 范式检查和复检支持自动分批处理(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>
960 lines
37 KiB
Vue
960 lines
37 KiB
Vue
<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>
|