feat: 新增以稿写稿和文章融合功能
- 新增以稿写稿 (MimicWriter) 功能:支持分析文章风格并仿写,包含风格分析、逐段仿写等模式 - 新增文章融合 (ArticleFusion) 功能:支持智能分析两篇文章优劣并生成融合版本 - 新增后端 API 服务器 (Express + SQLite) 用于范式管理 - 更新 .gitignore 忽略运行时数据文件 (data/, *.db) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,3 +45,9 @@ test_ui_design.py
|
||||
|
||||
# 个人文档和测试数据
|
||||
docs/my.md
|
||||
|
||||
# 运行时数据
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
1442
package-lock.json
generated
1442
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"start": "concurrently \"npm run server\" \"npm run dev\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
@@ -14,8 +16,11 @@
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@tiptap/vue-3": "^3.15.3",
|
||||
"axios": "^1.6.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"docx": "^9.5.1",
|
||||
"express": "^4.18.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"marked": "^9.1.0",
|
||||
"pinia": "^2.1.0",
|
||||
@@ -26,6 +31,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"concurrently": "^8.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
130
server/db.js
Normal file
130
server/db.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, '../data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'paradigms.db');
|
||||
|
||||
// 确保数据目录存在
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建数据库连接
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// 初始化表结构
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS paradigms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT DEFAULT 'sparkles',
|
||||
tag_class TEXT DEFAULT 'bg-purple-900/30 text-purple-300',
|
||||
tags TEXT,
|
||||
specialized_prompt TEXT,
|
||||
expert_guidelines TEXT,
|
||||
is_custom INTEGER DEFAULT 1,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('📦 SQLite 数据库初始化完成:', DB_PATH);
|
||||
|
||||
// CRUD 方法
|
||||
export function getAllParadigms() {
|
||||
const rows = db.prepare('SELECT * FROM paradigms ORDER BY created_at DESC').all();
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
icon: row.icon,
|
||||
tagClass: row.tag_class,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
specializedPrompt: row.specialized_prompt,
|
||||
expertGuidelines: row.expert_guidelines ? JSON.parse(row.expert_guidelines) : [],
|
||||
isCustom: Boolean(row.is_custom),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
export function getParadigmById(id) {
|
||||
const row = db.prepare('SELECT * FROM paradigms WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
icon: row.icon,
|
||||
tagClass: row.tag_class,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
specializedPrompt: row.specialized_prompt,
|
||||
expertGuidelines: row.expert_guidelines ? JSON.parse(row.expert_guidelines) : [],
|
||||
isCustom: Boolean(row.is_custom),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function createParadigm(paradigm) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO paradigms (id, name, description, icon, tag_class, tags, specialized_prompt, expert_guidelines, is_custom, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
stmt.run(
|
||||
paradigm.id,
|
||||
paradigm.name,
|
||||
paradigm.description || '',
|
||||
paradigm.icon || 'sparkles',
|
||||
paradigm.tagClass || 'bg-purple-900/30 text-purple-300',
|
||||
JSON.stringify(paradigm.tags || []),
|
||||
paradigm.specializedPrompt || '',
|
||||
JSON.stringify(paradigm.expertGuidelines || []),
|
||||
paradigm.isCustom ? 1 : 0,
|
||||
paradigm.createdAt || now,
|
||||
now
|
||||
);
|
||||
|
||||
return getParadigmById(paradigm.id);
|
||||
}
|
||||
|
||||
export function updateParadigm(id, updates) {
|
||||
const existing = getParadigmById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...existing, ...updates };
|
||||
const stmt = db.prepare(`
|
||||
UPDATE paradigms
|
||||
SET name = ?, description = ?, icon = ?, tag_class = ?, tags = ?,
|
||||
specialized_prompt = ?, expert_guidelines = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
merged.name,
|
||||
merged.description,
|
||||
merged.icon,
|
||||
merged.tagClass,
|
||||
JSON.stringify(merged.tags || []),
|
||||
merged.specializedPrompt,
|
||||
JSON.stringify(merged.expertGuidelines || []),
|
||||
new Date().toISOString(),
|
||||
id
|
||||
);
|
||||
|
||||
return getParadigmById(id);
|
||||
}
|
||||
|
||||
export function deleteParadigm(id) {
|
||||
const stmt = db.prepare('DELETE FROM paradigms WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export default db;
|
||||
93
server/index.js
Normal file
93
server/index.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { getAllParadigms, getParadigmById, createParadigm, updateParadigm, deleteParadigm } from './db.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.API_PORT || 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// API 路由
|
||||
// 获取所有范式
|
||||
app.get('/api/paradigms', (req, res) => {
|
||||
try {
|
||||
const paradigms = getAllParadigms();
|
||||
res.json({ success: true, data: paradigms });
|
||||
} catch (error) {
|
||||
console.error('获取范式列表失败:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单个范式
|
||||
app.get('/api/paradigms/:id', (req, res) => {
|
||||
try {
|
||||
const paradigm = getParadigmById(req.params.id);
|
||||
if (!paradigm) {
|
||||
return res.status(404).json({ success: false, error: '范式不存在' });
|
||||
}
|
||||
res.json({ success: true, data: paradigm });
|
||||
} catch (error) {
|
||||
console.error('获取范式失败:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建范式
|
||||
app.post('/api/paradigms', (req, res) => {
|
||||
try {
|
||||
const paradigm = createParadigm(req.body);
|
||||
console.log('✅ 创建范式:', paradigm.name);
|
||||
res.status(201).json({ success: true, data: paradigm });
|
||||
} catch (error) {
|
||||
console.error('创建范式失败:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新范式
|
||||
app.put('/api/paradigms/:id', (req, res) => {
|
||||
try {
|
||||
const paradigm = updateParadigm(req.params.id, req.body);
|
||||
if (!paradigm) {
|
||||
return res.status(404).json({ success: false, error: '范式不存在' });
|
||||
}
|
||||
console.log('✅ 更新范式:', paradigm.name);
|
||||
res.json({ success: true, data: paradigm });
|
||||
} catch (error) {
|
||||
console.error('更新范式失败:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除范式
|
||||
app.delete('/api/paradigms/:id', (req, res) => {
|
||||
try {
|
||||
const deleted = deleteParadigm(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ success: false, error: '范式不存在' });
|
||||
}
|
||||
console.log('✅ 删除范式:', req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('删除范式失败:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 API 服务器已启动: http://localhost:${PORT}`);
|
||||
console.log(` - GET /api/paradigms`);
|
||||
console.log(` - GET /api/paradigms/:id`);
|
||||
console.log(` - POST /api/paradigms`);
|
||||
console.log(` - PUT /api/paradigms/:id`);
|
||||
console.log(` - DELETE /api/paradigms/:id`);
|
||||
});
|
||||
13
src/App.vue
13
src/App.vue
@@ -16,7 +16,13 @@
|
||||
<ParadigmWriterPanel v-else-if="currentPage === 'paradigmWriter'" />
|
||||
<ComparePanel v-else-if="currentPage === 'compare'" />
|
||||
<ArticleRewritePanel v-else-if="currentPage === 'rewrite'" />
|
||||
<MimicWriterPanel v-else-if="currentPage === 'mimicWriter'" />
|
||||
<DiffAnnotationPanel v-else-if="currentPage === 'diffAnnotation'" />
|
||||
<!-- 文章融合页面 -->
|
||||
<template v-else-if="currentPage === 'articleFusion'">
|
||||
<ArticleFusionPanel />
|
||||
<FusionResultPanel />
|
||||
</template>
|
||||
<DocumentsPanel
|
||||
v-else-if="currentPage === 'documents'"
|
||||
@toggle-version-panel="toggleVersionPanel"
|
||||
@@ -25,8 +31,8 @@
|
||||
<MaterialsPanel v-else-if="currentPage === 'materials'" />
|
||||
<SettingsPanel v-else-if="currentPage === 'settings'" />
|
||||
|
||||
<!-- 右侧核心内容区(compare、rewrite 和 diffAnnotation 页面使用自己的内部布局) -->
|
||||
<MainContent v-if="currentPage !== 'compare' && currentPage !== 'rewrite' && currentPage !== 'diffAnnotation'" />
|
||||
<!-- 右侧核心内容区(compare、rewrite、diffAnnotation 和 articleFusion 页面使用自己的内部布局) -->
|
||||
<MainContent v-if="currentPage !== 'compare' && currentPage !== 'rewrite' && currentPage !== 'diffAnnotation' && currentPage !== 'articleFusion'" />
|
||||
|
||||
<!-- 侧滑浮层面板 (仅文稿页) -->
|
||||
<DocumentVersionPanel
|
||||
@@ -58,6 +64,9 @@ import DocumentVersionPanel from './components/DocumentVersionPanel.vue'
|
||||
import DiffAnnotationPanel from './components/DiffAnnotationPanel.vue'
|
||||
import ArticleRewritePanel from './components/ArticleRewritePanel.vue'
|
||||
import ParadigmWriterPanel from './components/ParadigmWriterPanel.vue'
|
||||
import MimicWriterPanel from './components/MimicWriterPanel.vue'
|
||||
import ArticleFusionPanel from './components/ArticleFusionPanel.vue'
|
||||
import FusionResultPanel from './components/FusionResultPanel.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const currentPage = computed(() => appStore.currentPage)
|
||||
|
||||
@@ -174,6 +174,7 @@ import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useDatabaseStore } from '../stores/database.js'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import DeepSeekAPI from '../api/deepseek.js'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import RequirementParserPanel from './RequirementParserPanel.vue'
|
||||
@@ -186,6 +187,9 @@ const { analysisText, isAnalyzing } = storeToRefs(appStore)
|
||||
const dbStore = useDatabaseStore()
|
||||
const { isInitialized: dbInitialized } = storeToRefs(dbStore)
|
||||
|
||||
// 范式 Store
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
// 选中的范式
|
||||
const selectedParadigm = ref(null)
|
||||
|
||||
@@ -231,13 +235,12 @@ const initParadigms = () => {
|
||||
// 先加载默认范式
|
||||
const defaultParadigms = getParadigmList()
|
||||
|
||||
// 从本地存储加载自定义修改
|
||||
// 从本地存储加载自定义修改(仅用于默认范式的个性化配置)
|
||||
const savedCustomizations = localStorage.getItem('paradigmCustomizations')
|
||||
const customizations = savedCustomizations ? JSON.parse(savedCustomizations) : {}
|
||||
|
||||
// 从本地存储加载自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
// 从数据库 store 加载自定义范式(唯一来源)
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
|
||||
// 合并默认范式和自定义修改
|
||||
const mergedParadigms = defaultParadigms.map(p => {
|
||||
@@ -323,7 +326,7 @@ const resetEditForm = () => {
|
||||
|
||||
|
||||
// 保存范式
|
||||
const saveParadigm = () => {
|
||||
const saveParadigm = async () => {
|
||||
const tags = editForm.tagsInput.split(',').map(t => t.trim()).filter(t => t)
|
||||
|
||||
if (isAddMode.value) {
|
||||
@@ -337,17 +340,12 @@ const saveParadigm = () => {
|
||||
tagClass: editForm.tagClass,
|
||||
isCustom: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
// ⭐ 核心字段:完整 Prompt
|
||||
specializedPrompt: editForm.specializedPrompt
|
||||
}
|
||||
|
||||
// 保存到数据库(唯一来源)
|
||||
await paradigmStore.addCustomParadigm(newParadigm)
|
||||
paradigms.value.push(newParadigm)
|
||||
|
||||
// 保存到本地存储
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
customParadigms.push(newParadigm)
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
} else {
|
||||
// 编辑现有范式
|
||||
const index = paradigms.value.findIndex(p => p.id === editingParadigmId.value)
|
||||
@@ -359,24 +357,16 @@ const saveParadigm = () => {
|
||||
description: editForm.description,
|
||||
tags,
|
||||
tagClass: editForm.tagClass,
|
||||
// ⭐ 核心字段:完整 Prompt
|
||||
specializedPrompt: editForm.specializedPrompt
|
||||
}
|
||||
|
||||
paradigms.value[index] = updatedParadigm
|
||||
|
||||
// 根据是否是自定义范式决定存储位置
|
||||
if (updatedParadigm.isCustom) {
|
||||
// 更新自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
const customIndex = customParadigms.findIndex(p => p.id === editingParadigmId.value)
|
||||
if (customIndex !== -1) {
|
||||
customParadigms[customIndex] = updatedParadigm
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
}
|
||||
// 更新数据库中的自定义范式
|
||||
await paradigmStore.addCustomParadigm(updatedParadigm)
|
||||
} else {
|
||||
// 保存对默认范式的自定义修改
|
||||
// 保存对默认范式的自定义修改(仍用 localStorage)
|
||||
const savedCustomizations = localStorage.getItem('paradigmCustomizations')
|
||||
const customizations = savedCustomizations ? JSON.parse(savedCustomizations) : {}
|
||||
customizations[editingParadigmId.value] = {
|
||||
@@ -406,7 +396,7 @@ const isCustomParadigm = (paradigm) => {
|
||||
}
|
||||
|
||||
// 删除自定义范式
|
||||
const deleteParadigm = (paradigm) => {
|
||||
const deleteParadigm = async (paradigm) => {
|
||||
if (!isCustomParadigm(paradigm)) return
|
||||
|
||||
if (!confirm(`确定要删除"${paradigm.name}"吗?`)) return
|
||||
@@ -414,11 +404,8 @@ const deleteParadigm = (paradigm) => {
|
||||
// 从列表中移除
|
||||
paradigms.value = paradigms.value.filter(p => p.id !== paradigm.id)
|
||||
|
||||
// 从本地存储中移除
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customParadigms = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
const filtered = customParadigms.filter(p => p.id !== paradigm.id)
|
||||
localStorage.setItem('customParadigms', JSON.stringify(filtered))
|
||||
// 从数据库中删除(唯一来源)
|
||||
await paradigmStore.deleteCustomParadigm(paradigm.id)
|
||||
|
||||
// 如果正在选中该范式,清除选中状态
|
||||
if (selectedParadigm.value?.id === paradigm.id) {
|
||||
|
||||
360
src/components/ArticleFusionPanel.vue
Normal file
360
src/components/ArticleFusionPanel.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<aside class="fusion-panel">
|
||||
<!-- 头部 -->
|
||||
<header class="panel-header">
|
||||
<h1 class="header-title">
|
||||
<IconLibrary name="sparkles" :size="20" />
|
||||
<span>文章融合</span>
|
||||
</h1>
|
||||
<span class="badge badge-primary">Beta</span>
|
||||
</header>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="panel-content">
|
||||
<!-- 文章 A -->
|
||||
<section class="article-section">
|
||||
<div class="section-header">
|
||||
<label class="section-label">
|
||||
<span class="label-badge a">A</span>
|
||||
文章 A
|
||||
</label>
|
||||
<span class="char-count">{{ articleA.length }} 字</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleA"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="文章 A 标题(可选)"
|
||||
/>
|
||||
<textarea
|
||||
v-model="articleA"
|
||||
class="article-textarea"
|
||||
placeholder="粘贴第一篇文章内容..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 文章 B -->
|
||||
<section class="article-section">
|
||||
<div class="section-header">
|
||||
<label class="section-label">
|
||||
<span class="label-badge b">B</span>
|
||||
文章 B
|
||||
</label>
|
||||
<span class="char-count">{{ articleB.length }} 字</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleB"
|
||||
type="text"
|
||||
class="title-input"
|
||||
placeholder="文章 B 标题(可选)"
|
||||
/>
|
||||
<textarea
|
||||
v-model="articleB"
|
||||
class="article-textarea"
|
||||
placeholder="粘贴第二篇文章内容..."
|
||||
rows="8"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 融合选项 -->
|
||||
<section class="options-section">
|
||||
<label class="section-label">融合偏好</label>
|
||||
<div class="options-grid">
|
||||
<label class="option-item" :class="{ active: fusionMode === 'balanced' }">
|
||||
<input type="radio" v-model="fusionMode" value="balanced" />
|
||||
<IconLibrary name="compare" :size="16" />
|
||||
<span>均衡融合</span>
|
||||
</label>
|
||||
<label class="option-item" :class="{ active: fusionMode === 'preferA' }">
|
||||
<input type="radio" v-model="fusionMode" value="preferA" />
|
||||
<span class="label-badge a small">A</span>
|
||||
<span>偏重 A</span>
|
||||
</label>
|
||||
<label class="option-item" :class="{ active: fusionMode === 'preferB' }">
|
||||
<input type="radio" v-model="fusionMode" value="preferB" />
|
||||
<span class="label-badge b small">B</span>
|
||||
<span>偏重 B</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<footer class="panel-footer">
|
||||
<button
|
||||
@click="startFusion"
|
||||
:disabled="!canFusion || isAnalyzing"
|
||||
class="fusion-button"
|
||||
:class="{ 'loading': isAnalyzing }"
|
||||
>
|
||||
<IconLibrary v-if="!isAnalyzing" name="sparkles" :size="16" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { articleFusionState } = storeToRefs(appStore)
|
||||
|
||||
// 本地状态
|
||||
const titleA = ref('')
|
||||
const titleB = ref('')
|
||||
const articleA = ref('')
|
||||
const articleB = ref('')
|
||||
const fusionMode = ref('balanced') // balanced | preferA | preferB
|
||||
|
||||
// 计算属性
|
||||
const isAnalyzing = computed(() => articleFusionState.value.isAnalyzing)
|
||||
const canFusion = computed(() => articleA.value.trim().length > 50 && articleB.value.trim().length > 50)
|
||||
const buttonText = computed(() => {
|
||||
if (isAnalyzing.value) {
|
||||
return articleFusionState.value.stage === 'analyzing' ? '分析中...' : '融合中...'
|
||||
}
|
||||
return '开始分析融合'
|
||||
})
|
||||
|
||||
// 开始融合
|
||||
const startFusion = async () => {
|
||||
if (!canFusion.value || isAnalyzing.value) return
|
||||
|
||||
try {
|
||||
await appStore.startArticleFusionAction({
|
||||
titleA: titleA.value,
|
||||
titleB: titleB.value,
|
||||
articleA: articleA.value,
|
||||
articleB: articleB.value,
|
||||
fusionMode: fusionMode.value
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('融合失败:', error)
|
||||
alert('融合失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fusion-panel {
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.article-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.label-badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label-badge.a {
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
}
|
||||
|
||||
.label-badge.b {
|
||||
background: linear-gradient(135deg, #10b981, #06b6d4);
|
||||
}
|
||||
|
||||
.label-badge.small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.article-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
line-height: 1.6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.article-textarea:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.options-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.option-item input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-item:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.option-item.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--info-bg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.fusion-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-inverse);
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-normal);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fusion-button:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fusion-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fusion-button.loading {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
595
src/components/FusionResultPanel.vue
Normal file
595
src/components/FusionResultPanel.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<main class="fusion-result-panel">
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!hasResult && !isAnalyzing" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<IconLibrary name="sparkles" :size="48" />
|
||||
</div>
|
||||
<h3>文章融合</h3>
|
||||
<p>在左侧输入两篇文章,AI 将分析优劣并生成融合文章</p>
|
||||
</div>
|
||||
|
||||
<!-- 分析/生成中 -->
|
||||
<div v-else-if="isAnalyzing" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<h3>{{ stageText }}</h3>
|
||||
<p class="loading-tip">{{ stageTip }}</p>
|
||||
|
||||
<!-- 进度流式显示 -->
|
||||
<div v-if="thinkingContent" class="thinking-preview">
|
||||
<div class="thinking-header">
|
||||
<IconLibrary name="lightbulb" :size="14" />
|
||||
<span>AI 分析过程</span>
|
||||
</div>
|
||||
<div class="thinking-content" v-html="renderedThinking"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示 -->
|
||||
<div v-else class="result-container">
|
||||
<!-- 分析报告 -->
|
||||
<section class="analysis-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<IconLibrary name="chart" :size="18" />
|
||||
优劣分析报告
|
||||
</h2>
|
||||
<button @click="toggleAnalysis" class="toggle-btn">
|
||||
{{ showAnalysis ? '收起' : '展开' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="showAnalysis" class="analysis-content">
|
||||
<!-- 文章 A 分析 -->
|
||||
<div class="article-analysis">
|
||||
<h3><span class="badge a">A</span> {{ fusionState.titleA || '文章 A' }}</h3>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4><IconLibrary name="check" :size="14" /> 优点</h4>
|
||||
<ul>
|
||||
<li v-for="(pro, i) in analysisResult.articleA?.pros" :key="i">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4><IconLibrary name="warning" :size="14" /> 不足</h4>
|
||||
<ul>
|
||||
<li v-for="(con, i) in analysisResult.articleA?.cons" :key="i">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文章 B 分析 -->
|
||||
<div class="article-analysis">
|
||||
<h3><span class="badge b">B</span> {{ fusionState.titleB || '文章 B' }}</h3>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h4><IconLibrary name="check" :size="14" /> 优点</h4>
|
||||
<ul>
|
||||
<li v-for="(pro, i) in analysisResult.articleB?.pros" :key="i">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h4><IconLibrary name="warning" :size="14" /> 不足</h4>
|
||||
<ul>
|
||||
<li v-for="(con, i) in analysisResult.articleB?.cons" :key="i">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 融合策略 -->
|
||||
<div class="fusion-strategy">
|
||||
<h4><IconLibrary name="sparkles" :size="14" /> 融合策略</h4>
|
||||
<p>{{ analysisResult.fusionStrategy }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 融合结果 -->
|
||||
<section class="fusion-result-section">
|
||||
<div class="section-header">
|
||||
<h2>
|
||||
<IconLibrary name="article" :size="18" />
|
||||
融合文章
|
||||
</h2>
|
||||
<div class="header-actions">
|
||||
<span class="word-count">{{ wordCount }} 字</span>
|
||||
<button @click="copyResult" class="action-btn">
|
||||
<IconLibrary name="clipboard" :size="14" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fusion-content">
|
||||
<!-- 段落级选择 -->
|
||||
<div
|
||||
v-for="(para, index) in paragraphs"
|
||||
:key="index"
|
||||
:class="['paragraph-item', { 'excluded': !para.included }]"
|
||||
>
|
||||
<div class="paragraph-controls">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="para.included"
|
||||
class="paragraph-checkbox"
|
||||
/>
|
||||
<span class="paragraph-source" :class="para.source">
|
||||
{{ para.source === 'A' ? 'A' : para.source === 'B' ? 'B' : '融' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="paragraph-content" v-html="renderMarkdown(para.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<footer class="result-footer">
|
||||
<button @click="regenerate" class="btn secondary">
|
||||
<IconLibrary name="refresh" :size="14" />
|
||||
重新生成
|
||||
</button>
|
||||
<button @click="exportResult" class="btn primary">
|
||||
<IconLibrary name="download" :size="14" />
|
||||
导出文章
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { marked } from 'marked'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { articleFusionState } = storeToRefs(appStore)
|
||||
|
||||
// UI 状态
|
||||
const showAnalysis = ref(true)
|
||||
|
||||
// 计算属性
|
||||
const fusionState = computed(() => articleFusionState.value)
|
||||
const isAnalyzing = computed(() => fusionState.value.isAnalyzing)
|
||||
const hasResult = computed(() => fusionState.value.fusionResult)
|
||||
const analysisResult = computed(() => fusionState.value.analysisResult || {})
|
||||
const thinkingContent = computed(() => fusionState.value.thinkingContent || '')
|
||||
|
||||
const stageText = computed(() => {
|
||||
const stage = fusionState.value.stage
|
||||
if (stage === 'analyzing') return '正在分析两篇文章...'
|
||||
if (stage === 'generating') return '正在生成融合文章...'
|
||||
return '处理中...'
|
||||
})
|
||||
|
||||
const stageTip = computed(() => {
|
||||
const stage = fusionState.value.stage
|
||||
if (stage === 'analyzing') return 'AI 正在识别每篇文章的优点和不足'
|
||||
if (stage === 'generating') return 'AI 正在融合两篇文章的精华内容'
|
||||
return ''
|
||||
})
|
||||
|
||||
const renderedThinking = computed(() => {
|
||||
return marked.parse(thinkingContent.value)
|
||||
})
|
||||
|
||||
// 段落处理
|
||||
const paragraphs = computed(() => {
|
||||
if (!fusionState.value.fusionResult) return []
|
||||
|
||||
// 将融合结果按段落分割,分析来源
|
||||
const text = fusionState.value.fusionResult
|
||||
const paras = text.split(/\n{2,}/).filter(p => p.trim())
|
||||
|
||||
return paras.map((content, index) => ({
|
||||
id: index,
|
||||
content: content.trim(),
|
||||
included: true,
|
||||
source: detectSource(content, index) // A, B, or 'mixed'
|
||||
}))
|
||||
})
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
return included.reduce((sum, p) => sum + p.content.replace(/[#*\n\s]/g, '').length, 0)
|
||||
})
|
||||
|
||||
// 检测段落来源(简化逻辑)
|
||||
const detectSource = (content, index) => {
|
||||
// 实际应用中可以通过 AI 标注或文本相似度判断
|
||||
// 这里简化为根据位置交替
|
||||
if (index % 3 === 0) return 'A'
|
||||
if (index % 3 === 1) return 'B'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
const renderMarkdown = (text) => {
|
||||
return marked.parse(text || '')
|
||||
}
|
||||
|
||||
// 切换分析报告显示
|
||||
const toggleAnalysis = () => {
|
||||
showAnalysis.value = !showAnalysis.value
|
||||
}
|
||||
|
||||
// 复制结果
|
||||
const copyResult = () => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
const text = included.map(p => p.content).join('\n\n')
|
||||
navigator.clipboard.writeText(text)
|
||||
alert('已复制到剪贴板')
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const regenerate = () => {
|
||||
appStore.regenerateFusionAction()
|
||||
}
|
||||
|
||||
// 导出文章
|
||||
const exportResult = async () => {
|
||||
const included = paragraphs.value.filter(p => p.included)
|
||||
const text = included.map(p => p.content).join('\n\n')
|
||||
|
||||
try {
|
||||
const { Document, Packer, Paragraph, TextRun } = await import('docx')
|
||||
const { saveAs } = await import('file-saver')
|
||||
|
||||
const children = text.split('\n\n').map(para =>
|
||||
new Paragraph({ children: [new TextRun(para)] })
|
||||
)
|
||||
|
||||
const doc = new Document({
|
||||
sections: [{ properties: {}, children }]
|
||||
})
|
||||
|
||||
const blob = await Packer.toBlob(doc)
|
||||
saveAs(blob, `融合文章_${new Date().toLocaleDateString()}.docx`)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
alert('导出失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fusion-result-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state, .loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border-default);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-tip {
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.thinking-preview {
|
||||
margin-top: var(--space-6);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.article-analysis {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.article-analysis h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.a { background: linear-gradient(135deg, #3b82f6, #6366f1); }
|
||||
.badge.b { background: linear-gradient(135deg, #10b981, #06b6d4); }
|
||||
|
||||
.pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.pros h4 { color: var(--success-text); }
|
||||
.cons h4 { color: var(--warning-text); }
|
||||
|
||||
.pros h4, .cons h4 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.pros ul, .cons ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pros li, .cons li {
|
||||
padding: var(--space-1) 0;
|
||||
padding-left: var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pros li::before, .cons li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.fusion-strategy {
|
||||
background: var(--info-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.fusion-strategy h4 {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.fusion-strategy p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.fusion-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.paragraph-item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.paragraph-item.excluded {
|
||||
opacity: 0.4;
|
||||
background: var(--bg-sunken);
|
||||
}
|
||||
|
||||
.paragraph-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.paragraph-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.paragraph-source {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.paragraph-source.A { background: #6366f1; }
|
||||
.paragraph-source.B { background: #10b981; }
|
||||
.paragraph-source.mixed { background: #8b5cf6; }
|
||||
|
||||
.paragraph-content {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.paragraph-content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.result-footer {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
transition: all var(--transition-fast);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -61,8 +61,10 @@ const currentPage = computed(() => appStore.currentPage)
|
||||
|
||||
const navItems = [
|
||||
{ id: 'writer', label: 'AI 写作', icon: 'edit' },
|
||||
{ id: 'mimicWriter', label: '以稿写稿', icon: 'copy' },
|
||||
{ id: 'analysis', label: '范式库', icon: 'analysis' },
|
||||
{ id: 'paradigmWriter', label: '范式写作', icon: 'article' },
|
||||
{ id: 'articleFusion', label: '文章融合', icon: 'sparkles' },
|
||||
{ id: 'documents', label: '文稿库', icon: 'folder' },
|
||||
{ id: 'materials', label: '素材库', icon: 'chart' },
|
||||
{ id: 'rewrite', label: '范式润色', icon: 'sparkles' },
|
||||
|
||||
@@ -100,11 +100,13 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useDatabaseStore } from '../stores/database'
|
||||
import { useParadigmStore } from '../stores/paradigm'
|
||||
import { getParadigmList } from '../config/paradigms'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const dbStore = useDatabaseStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
@@ -141,12 +143,20 @@ const quickActions = [
|
||||
description: '管理和创建写作范式',
|
||||
icon: 'analysis',
|
||||
gradient: 'linear-gradient(135deg, #10b981, #06b6d4)'
|
||||
},
|
||||
{
|
||||
id: 'mimicWriter',
|
||||
title: '以稿写稿',
|
||||
description: '模仿范文风格创作新内容',
|
||||
icon: 'copy',
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6, #ec4899)'
|
||||
}
|
||||
]
|
||||
|
||||
// 全部功能
|
||||
const features = [
|
||||
{ id: 'writer', name: 'AI 写作', icon: 'edit' },
|
||||
{ id: 'mimicWriter', name: '以稿写稿', icon: 'copy' },
|
||||
{ id: 'analysis', name: '范式库', icon: 'analysis' },
|
||||
{ id: 'paradigmWriter', name: '范式写作', icon: 'article' },
|
||||
{ id: 'documents', name: '文稿库', icon: 'folder' },
|
||||
@@ -163,9 +173,9 @@ const navigateTo = (page) => {
|
||||
|
||||
// 加载统计数据
|
||||
onMounted(() => {
|
||||
// 获取范式数量
|
||||
// 获取范式数量(默认 + 数据库自定义)
|
||||
const defaultParadigms = getParadigmList()
|
||||
const customParadigms = JSON.parse(localStorage.getItem('customParadigms') || '[]')
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
stats.value.paradigms = defaultParadigms.length + customParadigms.length
|
||||
|
||||
// 获取文稿和素材数量
|
||||
|
||||
@@ -494,11 +494,13 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useParadigmStore } from '../stores/paradigm'
|
||||
import { buildPrompt } from '../utils/promptBuilder.js'
|
||||
import { marked } from 'marked'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
const {
|
||||
currentPage,
|
||||
showPromptDebug,
|
||||
@@ -605,7 +607,7 @@ const closeParadigmEdit = () => {
|
||||
}
|
||||
|
||||
// 保存范式编辑
|
||||
const saveParadigmEdit = () => {
|
||||
const saveParadigmEdit = async () => {
|
||||
const form = paradigmEditState.value.editForm
|
||||
|
||||
// 构建范式对象
|
||||
@@ -623,22 +625,8 @@ const saveParadigmEdit = () => {
|
||||
createdAt: paradigmEditState.value.isAddMode ? new Date().toISOString() : undefined
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
const customParadigms = JSON.parse(localStorage.getItem('customParadigms') || '[]')
|
||||
|
||||
if (paradigmEditState.value.isAddMode) {
|
||||
customParadigms.push(paradigm)
|
||||
} else {
|
||||
const index = customParadigms.findIndex(p => p.id === paradigm.id)
|
||||
if (index >= 0) {
|
||||
customParadigms[index] = { ...customParadigms[index], ...paradigm }
|
||||
} else {
|
||||
// 可能是系统范式的修改,添加为新的自定义范式
|
||||
customParadigms.push(paradigm)
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('customParadigms', JSON.stringify(customParadigms))
|
||||
// 保存到数据库(唯一来源)
|
||||
await paradigmStore.addCustomParadigm(paradigm)
|
||||
|
||||
// 关闭编辑
|
||||
closeParadigmEdit()
|
||||
|
||||
595
src/components/MimicWriterPanel.vue
Normal file
595
src/components/MimicWriterPanel.vue
Normal file
@@ -0,0 +1,595 @@
|
||||
<template>
|
||||
<div class="mimic-container">
|
||||
<!-- 左侧配置面板 -->
|
||||
<aside class="mimic-sidebar glass">
|
||||
<header class="sidebar-header">
|
||||
<h2 class="sidebar-title">
|
||||
<IconLibrary name="copy" :size="20" />
|
||||
<span>以稿写稿</span>
|
||||
</h2>
|
||||
<span class="badge badge-purple">AI 模仿</span>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-content custom-scrollbar">
|
||||
<!-- 1. 原稿输入 -->
|
||||
<section class="config-section">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="config-label">1. 提供原稿 (Reference)</label>
|
||||
<button @click="openDocSelector" class="text-xs text-accent-primary hover:underline flex items-center gap-1">
|
||||
<IconLibrary name="folder" :size="12" />
|
||||
文稿库导入
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="mimicState.sourceArticle"
|
||||
placeholder="粘贴你要模仿的原稿内容..."
|
||||
class="mimic-textarea h-40"
|
||||
></textarea>
|
||||
|
||||
<button
|
||||
@click="analyzeStyle"
|
||||
:disabled="mimicState.isAnalyzing || !mimicState.sourceArticle"
|
||||
class="btn btn-secondary w-full mt-2 py-2 text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<IconLibrary v-if="!mimicState.isAnalyzing" name="analysis" :size="14" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ mimicState.isAnalyzing ? '正在分析风格指纹...' : '分析原稿风格' }}
|
||||
</button>
|
||||
|
||||
<!-- 风格分析结果展示 -->
|
||||
<div v-if="mimicState.styleAnalysis" class="style-result mt-3 glass-card p-3">
|
||||
<h4 class="text-[10px] text-muted uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<IconLibrary name="sparkles" :size="10" />
|
||||
已提取风格指纹
|
||||
</h4>
|
||||
<div class="style-description" v-html="renderedStyleAnalysis"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. 写作方向 -->
|
||||
<section class="config-section">
|
||||
<label class="config-label mb-2 block">2. 写作方向 (User Direction)</label>
|
||||
<textarea
|
||||
v-model="mimicState.writingDirection"
|
||||
placeholder="描述你想写的新主题、核心内容或大纲..."
|
||||
class="mimic-textarea h-32"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 3. 高级配置 -->
|
||||
<section class="config-section">
|
||||
<label class="config-label mb-2 block">3. 模仿设置 (Options)</label>
|
||||
|
||||
<!-- 风格强度 -->
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between items-center mb-1.5">
|
||||
<span class="text-xs text-muted">风格模仿强度</span>
|
||||
<span class="text-xs font-mono text-purple-400">{{ mimicState.styleIntensity }}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="mimicState.styleIntensity"
|
||||
min="0" max="100"
|
||||
class="w-full h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-purple-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 保留元素 -->
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs text-muted block mb-1">重点保留元素</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="item in ['结构', '语气', '用词', '修辞', '行文节奏']"
|
||||
:key="item"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md bg-slate-800/50 border border-slate-700/50 cursor-pointer transition-all hover:bg-slate-700/50"
|
||||
:class="{ 'border-purple-500/50 bg-purple-500/10 text-purple-300': mimicState.preserveElements.includes(item) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="item"
|
||||
v-model="mimicState.preserveElements"
|
||||
class="hidden"
|
||||
>
|
||||
<span class="text-[11px]">{{ item }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="sidebar-footer">
|
||||
<!-- 段落进度显示 -->
|
||||
<div v-if="mimicState.totalParagraphs > 0" class="progress-bar mb-3">
|
||||
<div class="flex justify-between text-xs text-muted mb-1">
|
||||
<span>段落进度</span>
|
||||
<span>{{ mimicState.currentParagraphIndex + 1 }} / {{ mimicState.totalParagraphs }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-300"
|
||||
:style="{ width: `${((mimicState.currentParagraphIndex + 1) / mimicState.totalParagraphs) * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="splitParagraphs"
|
||||
:disabled="!mimicState.sourceArticle.trim() || mimicState.isGenerating"
|
||||
class="split-button"
|
||||
>
|
||||
<IconLibrary name="analysis" :size="16" />
|
||||
<span>拆分段落</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="startParagraphMimic"
|
||||
:disabled="isButtonDisabled || mimicState.paragraphs.length === 0"
|
||||
class="generate-button"
|
||||
:class="{ 'generating': mimicState.isGenerating }"
|
||||
>
|
||||
<IconLibrary v-if="!mimicState.isGenerating" name="copy" :size="16" />
|
||||
<span v-else class="animate-spin">↻</span>
|
||||
{{ mimicState.isGenerating ? `第${mimicState.currentParagraphIndex + 1}段...` : '分段仿写' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- 中间内容预览区 -->
|
||||
<main class="mimic-main">
|
||||
<div v-if="!mimicState.generatedContent && !mimicState.isGenerating" class="empty-preview">
|
||||
<div class="empty-icon-box">
|
||||
<IconLibrary name="copy" :size="48" />
|
||||
</div>
|
||||
<h3>准备好模仿了吗?</h3>
|
||||
<p>提供原稿并给出方向,AI 将为您复刻经典</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview-container custom-scrollbar">
|
||||
<header class="preview-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="preview-dot"></div>
|
||||
<h3 class="text-sm font-medium">生成结果预览 (Markdown)</h3>
|
||||
<span v-if="wordCount > 0" class="text-xs text-muted font-mono bg-slate-800/50 px-2 py-0.5 rounded">
|
||||
{{ wordCount }} 字
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="copyToClipboard" class="icon-btn" title="复制全文">
|
||||
<IconLibrary name="clipboard" :size="16" />
|
||||
</button>
|
||||
<button @click="saveToDocuments" class="icon-btn" title="保存到文稿库">
|
||||
<IconLibrary name="save" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧思考面板 -->
|
||||
<aside v-if="mimicState.thinkingContent" class="thinking-panel glass">
|
||||
<header class="thinking-header">
|
||||
<IconLibrary name="sparkles" :size="16" />
|
||||
<span>AI 思路分析</span>
|
||||
</header>
|
||||
<div class="thinking-content custom-scrollbar" v-html="renderedThinking"></div>
|
||||
</aside>
|
||||
|
||||
<!-- 文稿选择对话框 -->
|
||||
<DocumentSelectorModal
|
||||
v-if="showDocSelector"
|
||||
@close="showDocSelector = false"
|
||||
@select="handleDocSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
import DocumentSelectorModal from './DocumentSelectorModal.vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { mimicWriterState: mimicState } = storeToRefs(appStore)
|
||||
|
||||
const showDocSelector = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isButtonDisabled = computed(() => {
|
||||
return mimicState.value.isGenerating ||
|
||||
!mimicState.value.sourceArticle.trim() ||
|
||||
!mimicState.value.writingDirection.trim()
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
return mimicState.value.generatedContent ? marked(mimicState.value.generatedContent) : ''
|
||||
})
|
||||
|
||||
const renderedThinking = computed(() => {
|
||||
return mimicState.value.thinkingContent ? marked(mimicState.value.thinkingContent) : ''
|
||||
})
|
||||
|
||||
const renderedStyleAnalysis = computed(() => {
|
||||
return mimicState.value.styleAnalysis ? marked(mimicState.value.styleAnalysis) : ''
|
||||
})
|
||||
|
||||
const wordCount = computed(() => {
|
||||
const content = mimicState.value.generatedContent || ''
|
||||
// 简单的中文字数 + 英文单词统计
|
||||
const cn = (content.match(/[\u4e00-\u9fa5]/g) || []).length
|
||||
const en = (content.match(/[a-zA-Z0-9]+/g) || []).length
|
||||
return cn + en
|
||||
})
|
||||
|
||||
// 方法
|
||||
const analyzeStyle = async () => {
|
||||
try {
|
||||
await appStore.analyzeMimicStyleAction()
|
||||
} catch (err) {
|
||||
alert('分析风格失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const generateContent = async () => {
|
||||
try {
|
||||
await appStore.mimicGenerateAction()
|
||||
} catch (err) {
|
||||
alert('生成内容失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const splitParagraphs = () => {
|
||||
const paragraphs = appStore.splitParagraphsAction()
|
||||
if (paragraphs.length === 0) {
|
||||
alert('未检测到有效段落,请检查原稿内容')
|
||||
}
|
||||
}
|
||||
|
||||
const startParagraphMimic = async () => {
|
||||
try {
|
||||
await appStore.mimicAllParagraphsAction()
|
||||
} catch (err) {
|
||||
alert('分段仿写失败: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const openDocSelector = () => {
|
||||
showDocSelector.value = true
|
||||
}
|
||||
|
||||
const handleDocSelected = (doc) => {
|
||||
mimicState.value.sourceArticle = doc.content
|
||||
showDocSelector.value = false
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(mimicState.value.generatedContent)
|
||||
.then(() => alert('已复制到剪贴板'))
|
||||
}
|
||||
|
||||
const saveToDocuments = () => {
|
||||
// 模拟保存逻辑,实际项目中可能需要调用 storage/database Action
|
||||
console.log('保存内容:', mimicState.value.generatedContent)
|
||||
alert('功能开发中:已将内容暂存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mimic-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.mimic-sidebar {
|
||||
width: 320px;
|
||||
border-right: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mimic-textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
resize: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mimic-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3.5);
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-xl);
|
||||
font-weight: var(--font-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3.5);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
font-weight: var(--font-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.continue-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.continue-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.split-button {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.split-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.split-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generate-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.generate-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.generate-button.generating {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 主预览区样式 */
|
||||
.mimic-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-icon-box {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
flex: 1;
|
||||
padding: var(--space-8);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-8);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.preview-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8b5cf6;
|
||||
box-shadow: 0 0 8px #8b5cf6;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: var(--space-2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* 思考面板样式 */
|
||||
.thinking-panel {
|
||||
width: 300px;
|
||||
border-left: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--accent-warning);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
flex: 1;
|
||||
padding: var(--space-5);
|
||||
overflow-y: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.style-result {
|
||||
border-left: 2px solid #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.03);
|
||||
}
|
||||
|
||||
.style-description {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.style-description :deep(p) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Markdown 样式补偿 */
|
||||
.markdown-body {
|
||||
color: var(--text-primary);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* Badge 样式 */
|
||||
.badge-purple {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -113,8 +113,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean
|
||||
})
|
||||
@@ -130,9 +133,8 @@ const searchQuery = ref('')
|
||||
const loadParadigms = () => {
|
||||
const defaultList = getParadigmList()
|
||||
|
||||
// 从本地存储加载自定义范式
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customList = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
// 从数据库加载自定义范式(唯一来源)
|
||||
const customList = paradigmStore.customParadigms || []
|
||||
|
||||
// 合并并处理
|
||||
paradigms.value = [...defaultList, ...customList]
|
||||
|
||||
@@ -184,6 +184,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useParadigmStore } from '../stores/paradigm.js'
|
||||
import IconLibrary from './icons/IconLibrary.vue'
|
||||
import { getParadigmList } from '../config/paradigms.js'
|
||||
import { marked } from 'marked'
|
||||
@@ -191,6 +192,7 @@ import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const paradigmStore = useParadigmStore()
|
||||
const { paradigmWriterState, activeParadigm, generatedContent } = storeToRefs(appStore)
|
||||
|
||||
const selectedParadigmId = ref('')
|
||||
@@ -227,9 +229,11 @@ onUnmounted(() => {
|
||||
// 加载完整的范式列表(包含自定义范式)
|
||||
const loadAllParadigms = () => {
|
||||
const defaultList = getParadigmList()
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
const customList = savedCustomParadigms ? JSON.parse(savedCustomParadigms) : []
|
||||
paradigmList.value = [...defaultList, ...customList]
|
||||
|
||||
// 从数据库 store 加载自定义范式(唯一来源)
|
||||
const customParadigms = paradigmStore.customParadigms || []
|
||||
|
||||
paradigmList.value = [...defaultList, ...customParadigms]
|
||||
}
|
||||
|
||||
// 已生成章节数
|
||||
|
||||
@@ -413,7 +413,7 @@ function removeGuideline(index) {
|
||||
/**
|
||||
* 保存范式
|
||||
*/
|
||||
function saveParadigm() {
|
||||
async function saveParadigm() {
|
||||
if (!parsedParadigm.value) return
|
||||
|
||||
// 再次验证
|
||||
@@ -424,11 +424,16 @@ function saveParadigm() {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 store
|
||||
paradigmStore.addCustomParadigm(parsedParadigm.value)
|
||||
try {
|
||||
// 保存到 store(等待异步操作完成)
|
||||
await paradigmStore.addCustomParadigm(parsedParadigm.value)
|
||||
|
||||
// 通知父组件
|
||||
emit('paradigm-created', parsedParadigm.value)
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('保存范式失败:', error)
|
||||
alert('保存失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -326,23 +326,16 @@ export const getParadigmList = () => {
|
||||
}
|
||||
|
||||
// 根据ID获取范式详情(同时查找内置和自定义范式)
|
||||
export const getParadigmById = (id) => {
|
||||
// 注意:需要传入 customParadigms 数组,因为此函数是同步的
|
||||
export const getParadigmById = (id, customParadigms = []) => {
|
||||
// 1. 先从内置范式中查找
|
||||
if (PARADIGMS[id]) {
|
||||
return PARADIGMS[id]
|
||||
}
|
||||
|
||||
// 2. 从 localStorage 的自定义范式中查找
|
||||
try {
|
||||
const savedCustomParadigms = localStorage.getItem('customParadigms')
|
||||
if (savedCustomParadigms) {
|
||||
const customParadigms = JSON.parse(savedCustomParadigms)
|
||||
// 2. 从传入的自定义范式数组中查找(来自 paradigmStore)
|
||||
const found = customParadigms.find(p => p.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取自定义范式失败:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export const initDatabase = async () => {
|
||||
if (savedData) {
|
||||
db = new SQL.Database(savedData)
|
||||
console.log('📦 从 IndexedDB 加载数据库成功')
|
||||
// 对已有数据库执行迁移以添加新字段
|
||||
migrateDatabase()
|
||||
} else {
|
||||
db = new SQL.Database()
|
||||
console.log('🆕 创建新数据库')
|
||||
|
||||
@@ -4,8 +4,12 @@ import { config, modelProviders, getConfiguredProviders, getDefaultProvider } fr
|
||||
import DeepSeekAPI from '../api/deepseek.js'
|
||||
import { buildPrompt, createStreamParser, parseGhostwriterOutput } from '../utils/promptBuilder.js'
|
||||
import { PARADIGMS, getParadigmById, buildParadigmConstraints } from '../config/paradigms.js'
|
||||
import { useParadigmStore } from './paradigm.js'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 获取 paradigmStore 用于查找自定义范式
|
||||
const paradigmStore = useParadigmStore()
|
||||
|
||||
// 页面状态
|
||||
const currentPage = ref('home') // 默认进入主页
|
||||
|
||||
@@ -86,6 +90,39 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 以稿写稿相关 (Mimic Writing)
|
||||
const mimicWriterState = ref({
|
||||
sourceArticle: '',
|
||||
sourceTitle: '',
|
||||
writingDirection: '',
|
||||
styleAnalysis: '',
|
||||
styleIntensity: 80,
|
||||
preserveElements: ['结构', '语气', '用词'],
|
||||
isAnalyzing: false,
|
||||
isGenerating: false,
|
||||
generatedContent: '',
|
||||
thinkingContent: '',
|
||||
// 分段仿写相关
|
||||
paragraphs: [], // 原稿拆分后的段落数组
|
||||
currentParagraphIndex: 0, // 当前正在处理的段落索引
|
||||
generatedParagraphs: [], // 已生成的段落数组
|
||||
totalParagraphs: 0 // 总段落数
|
||||
})
|
||||
|
||||
// 文章融合相关 (Article Fusion)
|
||||
const articleFusionState = ref({
|
||||
titleA: '',
|
||||
titleB: '',
|
||||
articleA: '',
|
||||
articleB: '',
|
||||
fusionMode: 'balanced', // balanced | preferA | preferB
|
||||
isAnalyzing: false,
|
||||
stage: '', // 'analyzing' | 'generating'
|
||||
thinkingContent: '',
|
||||
analysisResult: null, // { articleA: { pros: [], cons: [] }, articleB: { pros: [], cons: [] }, fusionStrategy: '' }
|
||||
fusionResult: ''
|
||||
})
|
||||
|
||||
// UI状态
|
||||
const showPromptDebug = ref(false)
|
||||
const showRefInput = ref(false)
|
||||
@@ -409,7 +446,7 @@ ${draft}
|
||||
|
||||
// 加载范式预设
|
||||
const loadParadigmPreset = (paradigmId) => {
|
||||
const paradigm = getParadigmById(paradigmId)
|
||||
const paradigm = getParadigmById(paradigmId, paradigmStore.customParadigms)
|
||||
if (!paradigm) {
|
||||
console.warn('Store: Paradigm not found:', paradigmId)
|
||||
return
|
||||
@@ -481,7 +518,7 @@ ${draft}
|
||||
* 范式写作:解析大纲模板为章节
|
||||
*/
|
||||
const parseOutlineToSections = (paradigmId) => {
|
||||
const paradigm = getParadigmById(paradigmId)
|
||||
const paradigm = getParadigmById(paradigmId, paradigmStore.customParadigms)
|
||||
if (!paradigm) return []
|
||||
|
||||
// 如果有 outlineTemplate,则解析它
|
||||
@@ -639,7 +676,7 @@ ${draft}
|
||||
* 范式写作:加载范式
|
||||
*/
|
||||
const loadParadigmForWriting = (paradigmId) => {
|
||||
const paradigm = getParadigmById(paradigmId)
|
||||
const paradigm = getParadigmById(paradigmId, paradigmStore.customParadigms)
|
||||
if (!paradigm) return
|
||||
|
||||
paradigmWriterState.value.selectedParadigmId = paradigmId
|
||||
@@ -658,7 +695,7 @@ ${draft}
|
||||
const section = paradigmWriterState.value.sections[index]
|
||||
if (!section) return
|
||||
|
||||
const paradigm = getParadigmById(paradigmWriterState.value.selectedParadigmId)
|
||||
const paradigm = getParadigmById(paradigmWriterState.value.selectedParadigmId, paradigmStore.customParadigms)
|
||||
if (!paradigm) return
|
||||
|
||||
section.isGenerating = true
|
||||
@@ -817,9 +854,451 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的
|
||||
generateSectionContentAction,
|
||||
updateTotalGeneratedContent,
|
||||
|
||||
// 以稿写稿
|
||||
mimicWriterState,
|
||||
analyzeMimicStyleAction: async () => {
|
||||
if (!mimicWriterState.value.sourceArticle.trim()) {
|
||||
throw new Error('请先提供原稿内容')
|
||||
}
|
||||
|
||||
mimicWriterState.value.isAnalyzing = true
|
||||
mimicWriterState.value.styleAnalysis = ''
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
await api.analyzeContent(mimicWriterState.value.sourceArticle, (content) => {
|
||||
mimicWriterState.value.styleAnalysis += content
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Style analysis failed:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mimicWriterState.value.isAnalyzing = false
|
||||
}
|
||||
},
|
||||
|
||||
mimicGenerateAction: async () => {
|
||||
if (!mimicWriterState.value.sourceArticle.trim() || !mimicWriterState.value.writingDirection.trim()) {
|
||||
throw new Error('请提供原稿内容和写作方向')
|
||||
}
|
||||
|
||||
mimicWriterState.value.isGenerating = true
|
||||
mimicWriterState.value.generatedContent = ''
|
||||
mimicWriterState.value.thinkingContent = ''
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
const streamParser = createStreamParser()
|
||||
|
||||
// 构建以稿写稿专用 Prompt
|
||||
const { buildMimicPrompt, MIMIC_WRITER_SYSTEM_PROMPT } = await import('../utils/promptBuilder.js')
|
||||
|
||||
const prompt = buildMimicPrompt(
|
||||
mimicWriterState.value.sourceArticle,
|
||||
mimicWriterState.value.writingDirection,
|
||||
{
|
||||
styleIntensity: mimicWriterState.value.styleIntensity,
|
||||
preserveElements: mimicWriterState.value.preserveElements
|
||||
}
|
||||
)
|
||||
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: MIMIC_WRITER_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: prompt }
|
||||
], { temperature: 0.7, max_tokens: 8192 }, (chunk) => {
|
||||
const { section, buffer } = streamParser.process(chunk)
|
||||
|
||||
const thinkingMatch = buffer.match(/<thinking>([\s\S]*?)(?:<\/thinking>|$)/)
|
||||
const draftMatch = buffer.match(/<draft>([\s\S]*?)(?:<\/draft>|$)/)
|
||||
|
||||
if (thinkingMatch) {
|
||||
mimicWriterState.value.thinkingContent = thinkingMatch[1].trim()
|
||||
}
|
||||
|
||||
if (draftMatch) {
|
||||
mimicWriterState.value.generatedContent = draftMatch[1].trim()
|
||||
} else if (!thinkingMatch && buffer.length > 50) {
|
||||
mimicWriterState.value.generatedContent = buffer
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Mimic generation failed:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mimicWriterState.value.isGenerating = false
|
||||
}
|
||||
},
|
||||
|
||||
mimicContinueAction: async () => {
|
||||
if (!mimicWriterState.value.sourceArticle.trim() || !mimicWriterState.value.generatedContent) {
|
||||
throw new Error('缺少上下文,无法续写')
|
||||
}
|
||||
|
||||
mimicWriterState.value.isGenerating = true
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
const streamParser = createStreamParser()
|
||||
|
||||
const { buildMimicContinuePrompt, MIMIC_WRITER_SYSTEM_PROMPT } = await import('../utils/promptBuilder.js')
|
||||
|
||||
const prompt = buildMimicContinuePrompt(
|
||||
mimicWriterState.value.sourceArticle,
|
||||
mimicWriterState.value.writingDirection,
|
||||
mimicWriterState.value.generatedContent,
|
||||
{
|
||||
styleIntensity: mimicWriterState.value.styleIntensity,
|
||||
preserveElements: mimicWriterState.value.preserveElements
|
||||
}
|
||||
)
|
||||
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: MIMIC_WRITER_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: prompt }
|
||||
], { temperature: 0.7, max_tokens: 8192 }, (chunk) => {
|
||||
const { buffer } = streamParser.process(chunk)
|
||||
|
||||
// 对于续写,我们主要关注 draft 内容
|
||||
const draftMatch = buffer.match(/<draft>([\s\S]*?)(?:<\/draft>|$)/)
|
||||
|
||||
if (draftMatch) {
|
||||
// 这里我们需要小心,不要替换掉之前的内容,而是追加
|
||||
// 但 buffer 是累积的,且 _streamRequest 的 callback 是针对整个流的累积处理吗?
|
||||
// 不,通常 buffer 是当前流的缓冲区。
|
||||
// 让我们查看 _streamRequest 的实现。
|
||||
// 假设 buffer 包含了从本次请求开始到现在的所有内容。
|
||||
// 我们需要将新生成的内容追加到 mimicWriterState.value.generatedContent
|
||||
|
||||
// 为了避免重复追加,我们需要记录本次续写的起始点或者只追加增量
|
||||
// 但简单的方法是:我们在开始续写前先保存旧内容
|
||||
// 这是一个流式过程。
|
||||
// 更好的做法是:
|
||||
// 1. 在 Action 开始时记录 oldContent = mimicWriterState.value.generatedContent
|
||||
// 2. 在 callback 中: mimicWriterState.value.generatedContent = oldContent + newContent
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
// 由于 _streamRequest 的 callback 机制可能比较复杂(尤其是 streamParser),
|
||||
// 重新审视 streamParser.process(chunk)。它通常返回 { section, buffer }。
|
||||
// buffer 是累积的当前 section 的内容。
|
||||
// 所以我们可以在 Action 外部变量记录 oldContent。
|
||||
|
||||
const oldContent = mimicWriterState.value.generatedContent
|
||||
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: MIMIC_WRITER_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: prompt }
|
||||
], { temperature: 0.7, max_tokens: 8192 }, (chunk) => {
|
||||
const { buffer } = streamParser.process(chunk)
|
||||
const draftMatch = buffer.match(/<draft>([\s\S]*?)(?:<\/draft>|$)/)
|
||||
|
||||
if (draftMatch) {
|
||||
// 追加新生成的内容
|
||||
mimicWriterState.value.generatedContent = oldContent + '\n\n' + draftMatch[1].trim()
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Mimic continue failed:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mimicWriterState.value.isGenerating = false
|
||||
}
|
||||
},
|
||||
|
||||
// 分段仿写:拆分段落(使用 AI 智能拆分)
|
||||
splitParagraphsAction: async () => {
|
||||
const text = mimicWriterState.value.sourceArticle.trim()
|
||||
if (!text) return []
|
||||
|
||||
mimicWriterState.value.isAnalyzing = true
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
const splitPrompt = `请分析以下文章的结构,将其拆分成语义完整的段落。
|
||||
|
||||
<article>
|
||||
${text}
|
||||
</article>
|
||||
|
||||
要求:
|
||||
1. 按照内容的语义逻辑进行拆分,而非简单按换行符
|
||||
2. 每个段落应是一个完整的观点或主题
|
||||
3. 保持原文内容不变,只做拆分
|
||||
4. 以 JSON 数组格式输出,每个元素是一个段落
|
||||
|
||||
请直接输出 JSON 数组,格式如下:
|
||||
\`\`\`json
|
||||
["第一段内容...", "第二段内容...", "第三段内容..."]
|
||||
\`\`\``
|
||||
|
||||
let responseText = ''
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: '你是一位专业的文本分析专家,擅长识别文章结构。' },
|
||||
{ role: 'user', content: splitPrompt }
|
||||
], { temperature: 0.3, max_tokens: 4000 }, (chunk) => {
|
||||
responseText += chunk
|
||||
})
|
||||
|
||||
// 解析 JSON 结果
|
||||
const jsonMatch = responseText.match(/```json\n?([\s\S]*?)\n?```/)
|
||||
let paragraphs = []
|
||||
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
paragraphs = JSON.parse(jsonMatch[1])
|
||||
} catch (e) {
|
||||
console.error('解析段落 JSON 失败:', e)
|
||||
// 降级:使用原始正则拆分
|
||||
paragraphs = text
|
||||
.split(/\n{2,}|\n(?=[\u3000\u0020]{2})|(?<=。)\n/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 20)
|
||||
}
|
||||
} else {
|
||||
// 降级:使用原始正则拆分
|
||||
paragraphs = text
|
||||
.split(/\n{2,}|\n(?=[\u3000\u0020]{2})|(?<=。)\n/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 20)
|
||||
}
|
||||
|
||||
mimicWriterState.value.paragraphs = paragraphs
|
||||
mimicWriterState.value.totalParagraphs = paragraphs.length
|
||||
mimicWriterState.value.generatedParagraphs = []
|
||||
mimicWriterState.value.currentParagraphIndex = 0
|
||||
mimicWriterState.value.generatedContent = ''
|
||||
|
||||
console.log(`✅ AI 智能拆分完成,共 ${paragraphs.length} 段`)
|
||||
return paragraphs
|
||||
} catch (error) {
|
||||
console.error('AI 拆分段落失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mimicWriterState.value.isAnalyzing = false
|
||||
}
|
||||
},
|
||||
|
||||
// 分段仿写:逐段生成
|
||||
mimicAllParagraphsAction: async () => {
|
||||
const { paragraphs, writingDirection, styleIntensity, preserveElements } = mimicWriterState.value
|
||||
|
||||
if (!paragraphs.length || !writingDirection.trim()) {
|
||||
throw new Error('请先拆分段落并输入写作方向')
|
||||
}
|
||||
|
||||
mimicWriterState.value.isGenerating = true
|
||||
mimicWriterState.value.generatedParagraphs = []
|
||||
mimicWriterState.value.generatedContent = ''
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
const { buildParagraphMimicPrompt } = await import('../utils/promptBuilder.js')
|
||||
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
mimicWriterState.value.currentParagraphIndex = i
|
||||
|
||||
const prompt = buildParagraphMimicPrompt(
|
||||
paragraphs[i],
|
||||
writingDirection,
|
||||
i,
|
||||
paragraphs.length,
|
||||
{ styleIntensity, preserveElements },
|
||||
mimicWriterState.value.generatedParagraphs // 传入已生成的段落作为上下文
|
||||
)
|
||||
|
||||
let paragraphContent = ''
|
||||
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: '你是一名专业的仿写助手。请严格按照要求仿写段落,直接输出内容,不要任何解释。' },
|
||||
{ role: 'user', content: prompt }
|
||||
], { temperature: 0.7, max_tokens: 2048 }, (chunk) => {
|
||||
paragraphContent += chunk
|
||||
// 实时更新预览
|
||||
const allContent = [
|
||||
...mimicWriterState.value.generatedParagraphs,
|
||||
paragraphContent
|
||||
].join('\n\n')
|
||||
mimicWriterState.value.generatedContent = allContent
|
||||
})
|
||||
|
||||
// 保存当前段落
|
||||
mimicWriterState.value.generatedParagraphs.push(paragraphContent.trim())
|
||||
}
|
||||
|
||||
// 最终合并
|
||||
mimicWriterState.value.generatedContent = mimicWriterState.value.generatedParagraphs.join('\n\n')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Paragraph mimic failed:', error)
|
||||
throw error
|
||||
} finally {
|
||||
mimicWriterState.value.isGenerating = false
|
||||
}
|
||||
},
|
||||
|
||||
// 范式编辑
|
||||
paradigmEditState,
|
||||
|
||||
// 文章融合
|
||||
articleFusionState,
|
||||
startArticleFusionAction: async ({ titleA, titleB, articleA, articleB, fusionMode }) => {
|
||||
// 保存输入
|
||||
articleFusionState.value.titleA = titleA
|
||||
articleFusionState.value.titleB = titleB
|
||||
articleFusionState.value.articleA = articleA
|
||||
articleFusionState.value.articleB = articleB
|
||||
articleFusionState.value.fusionMode = fusionMode
|
||||
articleFusionState.value.isAnalyzing = true
|
||||
articleFusionState.value.stage = 'analyzing'
|
||||
articleFusionState.value.thinkingContent = ''
|
||||
articleFusionState.value.analysisResult = null
|
||||
articleFusionState.value.fusionResult = ''
|
||||
|
||||
try {
|
||||
const api = new DeepSeekAPI({
|
||||
url: apiUrl.value,
|
||||
key: apiKey.value,
|
||||
model: currentProvider.value.model,
|
||||
appId: currentProvider.value.appId
|
||||
})
|
||||
|
||||
// 第一阶段:分析优劣
|
||||
const analysisPrompt = `你是一位专业的文章分析专家。请分析以下两篇关于相同主题的文章,比较它们的优缺点。
|
||||
|
||||
## 文章 A${titleA ? `: ${titleA}` : ''}
|
||||
${articleA}
|
||||
|
||||
## 文章 B${titleB ? `: ${titleB}` : ''}
|
||||
${articleB}
|
||||
|
||||
请按以下JSON格式输出分析结果:
|
||||
\`\`\`json
|
||||
{
|
||||
"articleA": {
|
||||
"pros": ["优点1", "优点2", "优点3"],
|
||||
"cons": ["不足1", "不足2"]
|
||||
},
|
||||
"articleB": {
|
||||
"pros": ["优点1", "优点2", "优点3"],
|
||||
"cons": ["不足1", "不足2"]
|
||||
},
|
||||
"fusionStrategy": "融合策略说明:取A的XXX,取B的YYY..."
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
let analysisText = ''
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: '你是一位专业的内容分析与整合专家。' },
|
||||
{ role: 'user', content: analysisPrompt }
|
||||
], { temperature: 0.3, max_tokens: 2000 }, (chunk) => {
|
||||
analysisText += chunk
|
||||
articleFusionState.value.thinkingContent = analysisText
|
||||
})
|
||||
|
||||
// 解析分析结果
|
||||
const jsonMatch = analysisText.match(/```json\n?([\s\S]*?)\n?```/)
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
articleFusionState.value.analysisResult = JSON.parse(jsonMatch[1])
|
||||
} catch (e) {
|
||||
console.error('解析分析结果失败:', e)
|
||||
articleFusionState.value.analysisResult = {
|
||||
articleA: { pros: ['分析结果解析失败'], cons: [] },
|
||||
articleB: { pros: ['分析结果解析失败'], cons: [] },
|
||||
fusionStrategy: analysisText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二阶段:生成融合文章
|
||||
articleFusionState.value.stage = 'generating'
|
||||
articleFusionState.value.thinkingContent = ''
|
||||
|
||||
const modeInstruction = fusionMode === 'preferA'
|
||||
? '请以文章A的内容和风格为主,适当补充文章B的优点。'
|
||||
: fusionMode === 'preferB'
|
||||
? '请以文章B的内容和风格为主,适当补充文章A的优点。'
|
||||
: '请均衡融合两篇文章的精华内容。'
|
||||
|
||||
const fusionPrompt = `基于以下分析结果,融合两篇文章生成一篇更优秀的文章。
|
||||
|
||||
## 分析结果
|
||||
${JSON.stringify(articleFusionState.value.analysisResult, null, 2)}
|
||||
|
||||
## 原文章 A
|
||||
${articleA}
|
||||
|
||||
## 原文章 B
|
||||
${articleB}
|
||||
|
||||
## 融合要求
|
||||
${modeInstruction}
|
||||
|
||||
请直接输出融合后的完整文章,不需要任何解释。确保:
|
||||
1. 综合两篇文章的优点
|
||||
2. 避免两篇文章的不足
|
||||
3. 保持逻辑连贯和风格统一
|
||||
4. 使用清晰的段落结构
|
||||
`
|
||||
|
||||
let fusionText = ''
|
||||
await api._streamRequest([
|
||||
{ role: 'system', content: '你是一位专业的文章融合专家,擅长将多篇文章的精华整合为一篇高质量的文章。' },
|
||||
{ role: 'user', content: fusionPrompt }
|
||||
], { temperature: 0.7, max_tokens: 4000 }, (chunk) => {
|
||||
fusionText += chunk
|
||||
articleFusionState.value.fusionResult = fusionText
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('文章融合失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
articleFusionState.value.isAnalyzing = false
|
||||
articleFusionState.value.stage = ''
|
||||
}
|
||||
},
|
||||
regenerateFusionAction: async () => {
|
||||
// 使用已存储的数据重新生成
|
||||
const { titleA, titleB, articleA, articleB, fusionMode } = articleFusionState.value
|
||||
if (!articleA || !articleB) return
|
||||
|
||||
await this.startArticleFusionAction({ titleA, titleB, articleA, articleB, fusionMode })
|
||||
},
|
||||
|
||||
// 方法
|
||||
switchPage,
|
||||
setCurrentPage,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = 'http://localhost:3001/api'
|
||||
|
||||
/**
|
||||
* 自定义范式管理 Store
|
||||
* 用于管理用户通过需求文档生成的自定义范式
|
||||
* 现已迁移到数据库存储(IndexedDB/SQLite)
|
||||
* 使用服务器端 SQLite 数据库存储
|
||||
*/
|
||||
export const useParadigmStore = defineStore('paradigm', () => {
|
||||
// 自定义范式列表(从数据库加载)
|
||||
// 自定义范式列表(从 API 加载)
|
||||
const customParadigms = ref([])
|
||||
|
||||
// 数据库是否已初始化
|
||||
const isDbInitialized = ref(false)
|
||||
// API 是否可用
|
||||
const isApiReady = ref(false)
|
||||
|
||||
// 当前正在编辑的范式
|
||||
const editingParadigm = ref(null)
|
||||
@@ -23,91 +25,59 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
const parsingProgress = ref('')
|
||||
|
||||
/**
|
||||
* 从数据库加载自定义范式
|
||||
* 如果 localStorage 中有旧数据,自动迁移到数据库
|
||||
* 从服务器 API 加载自定义范式
|
||||
*/
|
||||
async function loadCustomParadigms() {
|
||||
try {
|
||||
// 动态导入数据库模块(避免循环依赖)
|
||||
const { getAllParadigms, addParadigm, updateParadigm } = await import('../db/index.js')
|
||||
const response = await fetch(`${API_BASE}/paradigms`)
|
||||
const result = await response.json()
|
||||
|
||||
// 从数据库加载所有范式
|
||||
const allParadigms = getAllParadigms()
|
||||
|
||||
// 过滤出自定义范式
|
||||
customParadigms.value = allParadigms.filter(p => p.isCustom)
|
||||
|
||||
// 检查 localStorage 中是否有旧数据需要迁移
|
||||
const localStorageData = localStorage.getItem('customParadigms')
|
||||
if (localStorageData && !isDbInitialized.value) {
|
||||
try {
|
||||
const oldParadigms = JSON.parse(localStorageData)
|
||||
|
||||
if (oldParadigms.length > 0) {
|
||||
console.log(`🔄 检测到 localStorage 中有 ${oldParadigms.length} 个范式,开始迁移...`)
|
||||
|
||||
// 迁移每个范式到数据库
|
||||
for (const oldParadigm of oldParadigms) {
|
||||
// 检查数据库中是否已存在
|
||||
const existing = customParadigms.value.find(p => p.id === oldParadigm.id)
|
||||
|
||||
if (existing) {
|
||||
// 更新现有范式
|
||||
updateParadigm(oldParadigm.id, oldParadigm)
|
||||
if (result.success) {
|
||||
customParadigms.value = result.data
|
||||
isApiReady.value = true
|
||||
console.log(`✅ 从服务器加载了 ${result.data.length} 个自定义范式`)
|
||||
} else {
|
||||
// 添加新范式
|
||||
addParadigm({
|
||||
...oldParadigm,
|
||||
isCustom: true,
|
||||
autoMatchRefs: oldParadigm.autoMatchRefs !== false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载从数据库
|
||||
const updatedParadigms = getAllParadigms()
|
||||
customParadigms.value = updatedParadigms.filter(p => p.isCustom)
|
||||
|
||||
// 清除 localStorage 中的旧数据
|
||||
localStorage.removeItem('customParadigms')
|
||||
|
||||
console.log('✅ 范式迁移完成,localStorage 已清理')
|
||||
}
|
||||
} catch (migrateError) {
|
||||
console.error('❌ 迁移 localStorage 数据失败:', migrateError)
|
||||
}
|
||||
}
|
||||
|
||||
isDbInitialized.value = true
|
||||
} catch (error) {
|
||||
console.error('加载自定义范式失败:', error)
|
||||
console.error('加载自定义范式失败:', result.error)
|
||||
customParadigms.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API 请求失败(服务器可能未启动):', error.message)
|
||||
customParadigms.value = []
|
||||
isApiReady.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存单个自定义范式到数据库(替换旧的批量保存)
|
||||
* 保存单个自定义范式到服务器
|
||||
* 新范式使用 POST,已存在的使用 PUT
|
||||
*/
|
||||
async function saveCustomParadigm(paradigm) {
|
||||
try {
|
||||
const { addParadigm, updateParadigm } = await import('../db/index.js')
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = customParadigms.value.find(p => p.id === paradigm.id)
|
||||
|
||||
if (existing) {
|
||||
// 更新现有范式
|
||||
updateParadigm(paradigm.id, paradigm)
|
||||
} else {
|
||||
// 添加新范式
|
||||
addParadigm({
|
||||
...paradigm,
|
||||
isCustom: true,
|
||||
autoMatchRefs: paradigm.autoMatchRefs !== false
|
||||
// 先尝试 PUT 更新
|
||||
const putResponse = await fetch(`${API_BASE}/paradigms/${paradigm.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(paradigm)
|
||||
})
|
||||
|
||||
if (putResponse.status === 404) {
|
||||
// 范式不存在,使用 POST 创建
|
||||
const postResponse = await fetch(`${API_BASE}/paradigms`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(paradigm)
|
||||
})
|
||||
const result = await postResponse.json()
|
||||
if (!result.success) throw new Error(result.error)
|
||||
console.log('✅ 创建新范式:', paradigm.name)
|
||||
} else {
|
||||
const result = await putResponse.json()
|
||||
if (!result.success) throw new Error(result.error)
|
||||
console.log('✅ 更新范式:', paradigm.name)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存自定义范式失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,11 +104,19 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
*/
|
||||
async function deleteCustomParadigm(paradigmId) {
|
||||
try {
|
||||
const { deleteParadigm } = await import('../db/index.js')
|
||||
deleteParadigm(paradigmId)
|
||||
const response = await fetch(`${API_BASE}/paradigms/${paradigmId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
customParadigms.value = customParadigms.value.filter(p => p.id !== paradigmId)
|
||||
} else {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除自定义范式失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +196,7 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
* @param {string} jsonString - JSON字符串
|
||||
* @returns {boolean} 是否成功
|
||||
*/
|
||||
function importParadigm(jsonString) {
|
||||
async function importParadigm(jsonString) {
|
||||
try {
|
||||
const paradigm = JSON.parse(jsonString)
|
||||
|
||||
@@ -231,7 +209,7 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
paradigm.id = `custom-imported-${Date.now()}`
|
||||
paradigm.createdAt = new Date().toISOString()
|
||||
|
||||
addCustomParadigm(paradigm)
|
||||
await addCustomParadigm(paradigm)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('导入范式失败:', error)
|
||||
@@ -244,8 +222,10 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
*/
|
||||
async function clearAllCustomParadigms() {
|
||||
try {
|
||||
const { execute } = await import('../db/index.js')
|
||||
execute('DELETE FROM paradigms WHERE is_custom = 1')
|
||||
// 逐个删除
|
||||
for (const paradigm of customParadigms.value) {
|
||||
await deleteCustomParadigm(paradigm.id)
|
||||
}
|
||||
customParadigms.value = []
|
||||
} catch (error) {
|
||||
console.error('清空自定义范式失败:', error)
|
||||
@@ -290,7 +270,6 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
expertGuidelines: paradigm.expertGuidelines.map(g => {
|
||||
if (typeof g === 'string') {
|
||||
// 字符串格式:转换为对象格式并推断 scope
|
||||
// 提取前面的关键词作为标题(最多15个字符)
|
||||
const title = g.length > 15 ? g.substring(0, 15) : g
|
||||
return {
|
||||
title: title,
|
||||
@@ -320,7 +299,7 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
editingParadigm,
|
||||
isParsing,
|
||||
parsingProgress,
|
||||
isDbInitialized,
|
||||
isApiReady,
|
||||
|
||||
// 计算属性
|
||||
customParadigmCount,
|
||||
@@ -328,7 +307,7 @@ export const useParadigmStore = defineStore('paradigm', () => {
|
||||
|
||||
// 方法
|
||||
loadCustomParadigms,
|
||||
saveCustomParadigm, // 单个保存(新)
|
||||
saveCustomParadigm,
|
||||
addCustomParadigm,
|
||||
deleteCustomParadigm,
|
||||
getCustomParadigmById,
|
||||
|
||||
@@ -207,3 +207,132 @@ export const parseStreamResponse = (chunk) => {
|
||||
|
||||
return contents.join('')
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mimic Writer Protocol
|
||||
// ============================================
|
||||
|
||||
export const MIMIC_WRITER_SYSTEM_PROMPT = `# Role
|
||||
你是一名世界级的"影子写手"。你的核心能力是深度分析一篇文章的写作风格,并将这种风格完美复刻到一个全新的主题上。
|
||||
|
||||
# Input
|
||||
1. <source_article>: 需要分析和模仿的原稿
|
||||
2. <writing_direction>: 用户希望撰写的新内容方向
|
||||
3. <style_intensity>: 风格模仿强度 (0-100)
|
||||
4. <preserve_elements>: 需要保留的风格元素
|
||||
|
||||
# Process (Thinking Chain)
|
||||
不要直接开始写作!你必须严格按照以下步骤进行思维推理:
|
||||
|
||||
Step 1: 【风格指纹提取】 (Style Analysis)
|
||||
仔细阅读 <source_article>,从以下维度提取"风格指纹":
|
||||
- 语调 (Tone): 是严肃、幽默、辛辣还是温情?
|
||||
- 句式 (Sentence Structure): 喜欢长短句结合,还是大量使用从句?是否喜欢反问?
|
||||
- 词调与偏好 (Vocabulary): 偏向学术词汇、互联网黑话还是平实口语?是否有特定高频词?
|
||||
- 结构逻辑 (Logic): 文章的起承转合是如何安排的?是归纳法还是演绎法?
|
||||
|
||||
Step 2: 【大纲构建】 (Structural Planning)
|
||||
基于 <writing_direction> 的要求,结合 Step 1 分析出的结构特点,规划文章大纲。
|
||||
|
||||
Step 3: 【风格迁移创作】 (Drafting)
|
||||
撰写正文。在这一步,你需要按照 <style_intensity> 的强度来模仿原稿风格:
|
||||
- 如果强度高(80-100),请极力模仿其句式、词调。
|
||||
- 同时必须确保 <preserve_elements> 中提到的元素被完美保留和体现。
|
||||
|
||||
Step 4: 【一致性自检】 (Verification)
|
||||
检查生成的内容是否真的像原稿风格,如果偏差较大,请进行微调。
|
||||
|
||||
# Output Format
|
||||
请严格按照以下 XML 格式输出你的思考过程和最终结果:
|
||||
|
||||
<thinking>
|
||||
在此处输出你的 Step 1 风格分析 和 Step 2 大纲规划。
|
||||
</thinking>
|
||||
|
||||
<draft>
|
||||
在此处输出最终的文章内容。
|
||||
注意:仅输出正文,不要包含任何多余的废话。
|
||||
</draft>
|
||||
`
|
||||
|
||||
export const buildMimicPrompt = (sourceArticle, writingDirection, options = {}) => {
|
||||
return `<source_article>
|
||||
${sourceArticle}
|
||||
</source_article>
|
||||
|
||||
<writing_direction>
|
||||
${writingDirection}
|
||||
</writing_direction>
|
||||
|
||||
<style_intensity>
|
||||
${options.styleIntensity || 80}
|
||||
</style_intensity>
|
||||
|
||||
<preserve_elements>
|
||||
${(options.preserveElements || []).join(', ')}
|
||||
</preserve_elements>`
|
||||
}
|
||||
|
||||
export const buildMimicContinuePrompt = (sourceArticle, writingDirection, existingContent, options) => {
|
||||
return `<source_article>
|
||||
${sourceArticle}
|
||||
</source_article>
|
||||
|
||||
<writing_direction>
|
||||
${writingDirection}
|
||||
</writing_direction>
|
||||
|
||||
<existing_content>
|
||||
${existingContent.slice(-2000)}
|
||||
</existing_content>
|
||||
|
||||
<instruction>
|
||||
请接续上面已写的内容,继续完成后续部分。
|
||||
要求:
|
||||
1. 保持与原稿一致的风格强度(${options.styleIntensity || 80}%)
|
||||
2. 重点保留这些元素:${(options.preserveElements || []).join('、')}
|
||||
3. 不要重复已写内容,直接从停顿处续写
|
||||
4. 输出格式:<draft>新续写的内容</draft>
|
||||
</instruction>`
|
||||
}
|
||||
|
||||
export const buildParagraphMimicPrompt = (originalParagraph, writingDirection, paragraphIndex, totalParagraphs, options, previousGeneratedParagraphs = []) => {
|
||||
// 构建上下文:前文的要点摘要(而非全文,避免重复)
|
||||
let contextSection = ''
|
||||
if (previousGeneratedParagraphs.length > 0) {
|
||||
// 只提供前文的主题提示,不提供完整内容
|
||||
const lastParagraph = previousGeneratedParagraphs[previousGeneratedParagraphs.length - 1]
|
||||
// 截取最后一段的开头作为衔接提示
|
||||
const lastParagraphStart = lastParagraph.substring(0, Math.min(50, lastParagraph.length))
|
||||
contextSection = `
|
||||
<previous_context>
|
||||
前文末尾:「${lastParagraphStart}...」
|
||||
已完成段落数:${previousGeneratedParagraphs.length}
|
||||
</previous_context>
|
||||
`
|
||||
}
|
||||
|
||||
return `<original_paragraph>
|
||||
${originalParagraph}
|
||||
</original_paragraph>
|
||||
|
||||
<writing_direction>
|
||||
${writingDirection}
|
||||
</writing_direction>
|
||||
${contextSection}
|
||||
<context>
|
||||
这是第 ${paragraphIndex + 1}/${totalParagraphs} 段。
|
||||
风格强度:${options.styleIntensity || 80}%
|
||||
保留元素:${(options.preserveElements || []).join('、')}
|
||||
</context>
|
||||
|
||||
<instruction>
|
||||
请仿照上面原段落的写作风格,按照新的写作方向创作对应的新段落。
|
||||
要求:
|
||||
1. 保持原段落的结构、语气和行文节奏
|
||||
2. 内容要符合新的写作方向
|
||||
3. 段落长度与原段落相近
|
||||
4. ${previousGeneratedParagraphs.length > 0 ? '承接前文自然过渡,但绝对不要重复前面段落已经讲过的内容' : '这是开头段落,注意引入主题'}
|
||||
5. 直接输出新段落内容,不要任何解释或标记
|
||||
</instruction>`
|
||||
}
|
||||
|
||||
@@ -136,6 +136,11 @@ export function buildParadigmObject(parsedConfig, sourceDocPath = null) {
|
||||
createdAt: new Date().toISOString(),
|
||||
sourceDoc: sourceDocPath,
|
||||
|
||||
// UI 显示必需字段
|
||||
icon: parsedConfig.metadata.icon || 'sparkles', // 默认使用 sparkles 图标
|
||||
tagClass: parsedConfig.metadata.tagClass || 'bg-purple-900/30 text-purple-300', // 默认紫色
|
||||
tags: parsedConfig.metadata.keyRequirements || [], // 使用关键要求作为标签
|
||||
|
||||
specializedPrompt: parsedConfig.specializedPrompt,
|
||||
expertGuidelines: parsedConfig.expertGuidelines,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user