feat: 新增以稿写稿和文章融合功能

- 新增以稿写稿 (MimicWriter) 功能:支持分析文章风格并仿写,包含风格分析、逐段仿写等模式
- 新增文章融合 (ArticleFusion) 功能:支持智能分析两篇文章优劣并生成融合版本
- 新增后端 API 服务器 (Express + SQLite) 用于范式管理
- 更新 .gitignore 忽略运行时数据文件 (data/, *.db)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-20 13:45:02 +08:00
parent 4e1c2776c6
commit d7f1664766
22 changed files with 4059 additions and 238 deletions

6
.gitignore vendored
View File

@@ -45,3 +45,9 @@ test_ui_design.py
# 个人文档和测试数据
docs/my.md
# 运行时数据
data/
*.db
*.sqlite
*.sqlite3

1442
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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`);
});

View File

@@ -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'" />
<!-- 右侧核心内容区comparerewrite diffAnnotation 页面使用自己的内部布局 -->
<MainContent v-if="currentPage !== 'compare' && currentPage !== 'rewrite' && currentPage !== 'diffAnnotation'" />
<!-- 右侧核心内容区comparerewritediffAnnotation 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)

View File

@@ -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) {

View 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>

View 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>

View File

@@ -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' },

View File

@@ -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
// 获取文稿和素材数量

View File

@@ -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()

View 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>

View File

@@ -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]

View File

@@ -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]
}
// 已生成章节数

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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('🆕 创建新数据库')

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>`
}

View File

@@ -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,