From 94301c81a64f4eebd3d4eec1c1aae7beaac076c4 Mon Sep 17 00:00:00 2001 From: empty Date: Wed, 21 Jan 2026 17:23:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E5=86=99=E4=BD=9C=E5=8A=9F=E8=83=BD=E4=B8=8E=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=AB=AF=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 OutlineWriterPanel 和 OutlineResultPanel 组件 - 重构服务器端数据库接口 (server/db.js) - 添加 LLM 服务模块 (server/llm.js) - 更新配置和设置面板 - 优化文档选择器和素材面板 - 更新部署文档和环境变量示例 --- .env.example | 45 +- README.md | 13 +- docs/DEPLOYMENT.md | 58 +- package.json | 5 +- server/db.js | 851 +++++++++++++++++-- server/index.js | 413 +++++++-- server/llm.js | 140 ++++ src/App.vue | 11 +- src/api/deepseek.js | 76 +- src/components/AnalysisPanel.vue | 13 +- src/components/ArticleRewritePanel.vue | 4 +- src/components/ComparePanel.vue | 8 +- src/components/DocumentSelectorModal.vue | 2 +- src/components/DocumentVersionPanel.vue | 9 +- src/components/DocumentsPanel.vue | 8 +- src/components/GlobalSidebar.vue | 1 + src/components/HomePage.vue | 2 +- src/components/MainContent.vue | 193 ++++- src/components/MaterialsPanel.vue | 8 +- src/components/OutlineResultPanel.vue | 302 +++++++ src/components/OutlineWriterPanel.vue | 845 +++++++++++++++++++ src/components/RequirementParserPanel.vue | 11 +- src/components/SettingsPanel.vue | 54 +- src/db/index.js | 967 ++++------------------ src/stores/app.js | 607 ++++++++++---- src/stores/database.js | 26 +- src/stores/paradigm.js | 2 +- src/utils/config.js | 55 +- src/utils/requirementParser.js | 74 +- 29 files changed, 3430 insertions(+), 1373 deletions(-) create mode 100644 server/llm.js create mode 100644 src/components/OutlineResultPanel.vue create mode 100644 src/components/OutlineWriterPanel.vue diff --git a/.env.example b/.env.example index 6b94f84..1ea3ffc 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,39 @@ -# AI 写作工坊 - 模型服务商配置 -# 复制此文件为 .env 并填入您的 API Key +# AI 写作工坊 - 服务端模型与数据库配置 +# 复制此文件为 .env 并填入服务端 API Key + +# ========== 服务端 API ========== +API_PORT=3001 # ========== DeepSeek (默认) ========== -VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions -VITE_DEEPSEEK_API_KEY=your_deepseek_api_key_here -VITE_DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions +DEEPSEEK_API_KEY=your_deepseek_api_key_here +DEEPSEEK_MODEL=deepseek-chat # ========== 千帆大模型 (百度文心一言) ========== -# VITE_QIANFAN_API_URL=https://qianfan.baidubce.com/v2/chat/completions -# VITE_QIANFAN_API_KEY=your_bearer_token_here -# VITE_QIANFAN_APP_ID=your_app_id_here -# VITE_QIANFAN_MODEL=ernie-4.0-8k +# QIANFAN_API_URL=https://qianfan.baidubce.com/v2/chat/completions +# QIANFAN_API_KEY=your_bearer_token_here +# QIANFAN_APP_ID=your_app_id_here +# QIANFAN_MODEL=ernie-4.0-8k # ========== OpenAI ========== -# VITE_OPENAI_API_URL=https://api.openai.com/v1/chat/completions -# VITE_OPENAI_API_KEY=your_openai_api_key_here -# VITE_OPENAI_MODEL=gpt-4o +# OPENAI_API_URL=https://api.openai.com/v1/chat/completions +# OPENAI_API_KEY=your_openai_api_key_here +# OPENAI_MODEL=gpt-4o # ========== Claude (Anthropic) ========== -# VITE_CLAUDE_API_URL=https://api.anthropic.com/v1/messages -# VITE_CLAUDE_API_KEY=your_claude_api_key_here -# VITE_CLAUDE_MODEL=claude-3-5-sonnet +# CLAUDE_API_URL=https://api.anthropic.com/v1/messages +# CLAUDE_API_KEY=your_claude_api_key_here +# CLAUDE_MODEL=claude-3-5-sonnet # ========== 自定义 API 中继 ========== -# VITE_CUSTOM_API_URL=https://your-api-gateway.com/v1/chat/completions -# VITE_CUSTOM_API_KEY=your_custom_api_key_here +# CUSTOM_API_URL=https://your-api-gateway.com/v1/chat/completions +# CUSTOM_API_KEY=your_custom_api_key_here +# CUSTOM_MODEL=your_model_name + +# ========== 前端配置(仅展示,不含密钥) ========== +VITE_API_BASE=http://localhost:3001/api +# VITE_DEEPSEEK_MODEL=deepseek-chat +# VITE_QIANFAN_MODEL=ernie-4.0-8k +# VITE_OPENAI_MODEL=gpt-4o +# VITE_CLAUDE_MODEL=claude-3-5-sonnet # VITE_CUSTOM_MODEL=your_model_name diff --git a/README.md b/README.md index 13ed01a..360ef64 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ npm run dev # 访问 http://localhost:3001 ``` +如需启用服务端 LLM 代理与 SQLite,请先配置 `.env`,并使用: + +```bash +npm run start +``` + ### 构建生产版本 ```bash @@ -66,8 +72,7 @@ npm run preview - Marked (Markdown 解析) 数据存储: - - SQL.js (浏览器端数据库) - - LocalStorage (范式配置) + - SQLite (服务端数据库) 工具库: - Axios (HTTP 客户端) @@ -81,7 +86,7 @@ npm run preview - IconLibrary (SVG 图标组件库) AI 集成: - - DeepSeek API (可扩展) + - 服务端 LLM 代理(DeepSeek 等,可扩展) ``` --- @@ -137,7 +142,7 @@ src/ │ └── deepseek.js # DeepSeek API 封装 │ ├── db/ # 数据库 -│ └── index.js # SQL.js 封装 +│ └── index.js # 服务端数据库 API 客户端 │ ├── styles/ # 样式文件 ⭐ │ ├── design-tokens.css # 设计变量 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7aafa14..4d56b19 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,14 +2,14 @@ ## 🏗️ 项目架构 -本项目是一个纯前端 Vue 3 应用,使用 Vite 构建,数据存储在浏览器 IndexedDB 中。 +本项目为 **前端 + Node/Express 服务端** 架构:前端使用 Vite 构建,数据存储在服务端 SQLite,LLM 调用通过服务端代理。 ### 技术栈 - **前端框架**: Vue 3 + Composition API - **构建工具**: Vite - **状态管理**: Pinia -- **数据存储**: IndexedDB (sql.js) -- **API 调用**: 直接调用各 LLM 服务商 API +- **数据存储**: SQLite(服务端) +- **API 调用**: 服务端 LLM 代理 ## 📦 构建生产版本 @@ -24,7 +24,7 @@ npm run build npm run preview ``` -构建产物位于 `dist/` 目录,是纯静态文件。 +构建产物位于 `dist/` 目录,前端可静态托管,但仍需单独部署服务端 API。 --- @@ -39,12 +39,8 @@ npm run preview - 导入 Git 仓库 2. **配置环境变量** - 在 Vercel 控制台设置: - ``` - VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions - VITE_DEEPSEEK_API_KEY=sk-xxx - VITE_DEEPSEEK_MODEL=deepseek-chat - ``` + - 前端:仅需设置 API 基地址(指向自建服务端) + - 服务端:配置 LLM 的 API Key 与模型 3. **部署** - 每次 push 到 main 分支自动部署 @@ -56,7 +52,7 @@ npm run preview 1. 在 Netlify 导入仓库 2. 构建命令: `npm run build` 3. 发布目录: `dist` -4. 在 Site settings > Environment 设置环境变量 +4. 在 Site settings > Environment 设置前端环境变量(如 `VITE_API_BASE`) ### 方案三:自托管 Nginx @@ -106,7 +102,7 @@ npm run preview 适合容器化部署。 -1. **Dockerfile** +1. **Dockerfile(前端静态站点)** ```dockerfile # 构建阶段 FROM node:20-alpine as builder @@ -114,8 +110,7 @@ npm run preview COPY package*.json ./ RUN npm ci COPY . . - ARG VITE_DEEPSEEK_API_KEY - ARG VITE_DEEPSEEK_API_URL + ARG VITE_API_BASE RUN npm run build # 运行阶段 @@ -128,12 +123,12 @@ npm run preview 2. **构建并运行** ```bash - docker build \ - --build-arg VITE_DEEPSEEK_API_KEY=sk-xxx \ - --build-arg VITE_DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions \ + docker build \\ + --build-arg VITE_API_BASE=http://your-api-host:3001/api \\ -t ai-writer . docker run -d -p 8080:80 ai-writer +\n+> ⚠️ 注意:服务端 API 需单独部署(Node/Express + SQLite),并配置 `.env` 中的 LLM 密钥。 ``` --- @@ -142,25 +137,17 @@ npm run preview ### API Key 安全 -由于这是纯前端应用,API Key 会被打包到 JS 文件中。有以下解决方案: - -1. **内部使用**:如果只在内网使用,风险可控 - -2. **API 中继**(推荐): - - 部署一个后端代理服务 - - 前端调用代理,代理转发到 LLM API - - API Key 只存在于后端 - -3. **IP 白名单**: - - 在 LLM 服务商配置 API Key 的 IP 白名单 - - 限制只能从部署服务器的 IP 调用 +当前版本默认通过服务端代理调用 LLM,API Key 不再暴露在前端。 ### 环境变量最佳实践 ```bash -# 生产环境 .env.production -VITE_DEEPSEEK_API_URL=https://your-proxy.com/v1/chat/completions -VITE_DEEPSEEK_API_KEY=xxx +# 服务端 .env +DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions +DEEPSEEK_API_KEY=xxx + +# 前端 .env.production +VITE_API_BASE=https://your-api-host/api ``` --- @@ -179,8 +166,8 @@ VITE_DEEPSEEK_API_KEY=xxx ┌────────────┴────────────┐ │ │ ┌──────▼──────┐ ┌──────▼──────┐ - │ 静态资源 │ │ API 中继 │ (可选) - │ (dist/) │ │ (后端代理) │ + │ 静态资源 │ │ Node API │ + │ (dist/) │ │ + SQLite │ └─────────────┘ └──────┬──────┘ │ ┌──────▼──────┐ @@ -217,8 +204,7 @@ jobs: npm ci npm run build env: - VITE_DEEPSEEK_API_KEY: ${{ secrets.VITE_DEEPSEEK_API_KEY }} - VITE_DEEPSEEK_API_URL: ${{ secrets.VITE_DEEPSEEK_API_URL }} + VITE_API_BASE: ${{ secrets.VITE_API_BASE }} - name: Deploy to Server # 根据您的部署目标配置 diff --git a/package.json b/package.json index ab1c5bf..3467359 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,11 @@ "cors": "^2.8.5", "diff-match-patch": "^1.0.5", "docx": "^9.5.1", + "dotenv": "^16.3.1", "express": "^4.18.2", "file-saver": "^2.0.5", "marked": "^9.1.0", "pinia": "^2.1.0", - "sql.js": "^1.13.0", "vue": "^3.4.0" }, "devDependencies": { @@ -32,10 +32,9 @@ "@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", "tailwindcss": "^4.1.18", "vite": "^5.0.0" } -} \ No newline at end of file +} diff --git a/server/db.js b/server/db.js index 79cb315..1d3d5fb 100644 --- a/server/db.js +++ b/server/db.js @@ -7,124 +7,805 @@ 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 }); + fs.mkdirSync(DATA_DIR, { recursive: true }); } -// 创建数据库连接 const db = new Database(DB_PATH); -// 初始化表结构 +db.pragma('journal_mode = WAL'); + db.exec(` + CREATE TABLE IF NOT EXISTS materials ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + source TEXT, + date TEXT, + tags TEXT, + related_dimension_sets TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_default INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS reference_excerpts ( + id TEXT PRIMARY KEY, + reference_id TEXT NOT NULL, + topic TEXT NOT NULL, + content TEXT NOT NULL, + applicable_dimensions TEXT, + use_for TEXT, + FOREIGN KEY (reference_id) REFERENCES materials(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS paradigms ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + icon TEXT, description TEXT, - icon TEXT DEFAULT 'sparkles', - tag_class TEXT DEFAULT 'bg-purple-900/30 text-purple-300', tags TEXT, + tag_class TEXT, + system_constraints TEXT, + dimension_set_id TEXT, + custom_dimensions TEXT, + logic_paradigms TEXT, + auto_match_refs INTEGER DEFAULT 1, + selected_refs TEXT, specialized_prompt TEXT, expert_guidelines TEXT, - is_custom INTEGER DEFAULT 1, - created_at TEXT, - updated_at TEXT - ) + outline_template TEXT, + recommended_tags TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_custom INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS dimension_sets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + applicable_for TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_custom INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS dimensions ( + id TEXT PRIMARY KEY, + dimension_set_id TEXT NOT NULL, + name TEXT NOT NULL, + focus TEXT, + keywords TEXT, + negative_keywords TEXT, + positive_benchmark TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (dimension_set_id) REFERENCES dimension_sets(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_config ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS analysis_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paradigm_id TEXT, + input_text TEXT, + result TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + paradigm_id TEXT, + dimension_set_id TEXT, + selected_refs TEXT, + status TEXT DEFAULT 'draft', + word_count INTEGER DEFAULT 0, + tags TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS document_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + content TEXT, + version_number INTEGER, + change_note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS outline_materials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content TEXT NOT NULL, + word_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); `); +const ensureColumn = (table, column, definition) => { + const columns = db.prepare(`PRAGMA table_info(${table})`).all(); + const exists = columns.some(col => col.name === column); + if (!exists) { + try { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } catch (error) { + console.warn(`⚠️ 字段迁移失败: ${table}.${column}`, error.message); + } + } +}; + +const ensureSchema = () => { + ensureColumn('paradigms', 'system_constraints', 'TEXT'); + ensureColumn('paradigms', 'dimension_set_id', 'TEXT'); + ensureColumn('paradigms', 'custom_dimensions', 'TEXT'); + ensureColumn('paradigms', 'logic_paradigms', 'TEXT'); + ensureColumn('paradigms', 'auto_match_refs', 'INTEGER DEFAULT 1'); + ensureColumn('paradigms', 'selected_refs', 'TEXT'); + ensureColumn('paradigms', 'outline_template', 'TEXT'); + ensureColumn('paradigms', 'recommended_tags', 'TEXT'); + ensureColumn('paradigms', 'tag_class', "TEXT"); + ensureColumn('paradigms', 'tags', 'TEXT'); + ensureColumn('paradigms', 'specialized_prompt', 'TEXT'); + ensureColumn('paradigms', 'expert_guidelines', 'TEXT'); + ensureColumn('paradigms', 'is_custom', 'INTEGER DEFAULT 0'); + ensureColumn('paradigms', 'created_at', 'TEXT'); + ensureColumn('paradigms', 'updated_at', 'TEXT'); +}; + +ensureSchema(); + +const parseJson = (value, fallback) => { + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +}; + +const getExcerptsByReferenceId = (referenceId) => { + const rows = db.prepare('SELECT * FROM reference_excerpts WHERE reference_id = ?').all(referenceId); + return rows.map(row => ({ + ...row, + applicableDimensions: parseJson(row.applicable_dimensions, []), + useFor: row.use_for || null + })); +}; + +const seedDefaultReferences = async () => { + const count = db.prepare('SELECT COUNT(*) as count FROM materials').get().count; + if (count > 0) return; + + try { + const { REFERENCES } = await import('../src/config/references.js'); + const insertMaterial = db.prepare(` + INSERT INTO materials (id, type, title, source, date, tags, related_dimension_sets, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertExcerpt = db.prepare(` + INSERT INTO reference_excerpts (id, reference_id, topic, content, applicable_dimensions, use_for) + VALUES (?, ?, ?, ?, ?, ?) + `); + + const insertMany = db.transaction((items) => { + items.forEach((ref) => { + insertMaterial.run( + ref.id, + ref.type, + ref.title, + ref.source || null, + ref.date || null, + JSON.stringify(ref.tags || []), + JSON.stringify(ref.relatedDimensionSets || []), + 1 + ); + + if (ref.excerpts?.length) { + ref.excerpts.forEach((excerpt, index) => { + insertExcerpt.run( + excerpt.id || `${ref.id}-excerpt-${index}`, + ref.id, + excerpt.topic, + excerpt.content, + JSON.stringify(excerpt.applicableDimensions || []), + excerpt.useFor || null + ); + }); + } + }); + }); + + insertMany(Object.values(REFERENCES)); + console.log('✅ 默认素材导入完成'); + } catch (error) { + console.warn('⚠️ 默认素材导入失败:', error.message); + } +}; + +await seedDefaultReferences(); + console.log('📦 SQLite 数据库初始化完成:', DB_PATH); -// CRUD 方法 +export function getAllReferences() { + const refs = db.prepare('SELECT * FROM materials ORDER BY created_at DESC').all(); + return refs.map(ref => ({ + ...ref, + tags: parseJson(ref.tags, []), + relatedDimensionSets: parseJson(ref.related_dimension_sets, []), + excerpts: getExcerptsByReferenceId(ref.id) + })); +} + +export function getReferenceById(id) { + const ref = db.prepare('SELECT * FROM materials WHERE id = ?').get(id); + if (!ref) return null; + return { + ...ref, + tags: parseJson(ref.tags, []), + relatedDimensionSets: parseJson(ref.related_dimension_sets, []), + excerpts: getExcerptsByReferenceId(ref.id) + }; +} + +export function getReferencesByType(type) { + const refs = db.prepare('SELECT * FROM materials WHERE type = ? ORDER BY created_at DESC').all(type); + return refs.map(ref => ({ + ...ref, + tags: parseJson(ref.tags, []), + relatedDimensionSets: parseJson(ref.related_dimension_sets, []), + excerpts: getExcerptsByReferenceId(ref.id) + })); +} + +export function addReference(reference) { + const id = reference.id || `ref-${Date.now()}`; + db.prepare(` + INSERT INTO materials (id, type, title, source, date, tags, related_dimension_sets, is_default) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + reference.type, + reference.title, + reference.source || null, + reference.date || null, + JSON.stringify(reference.tags || []), + JSON.stringify(reference.relatedDimensionSets || []), + reference.isDefault ? 1 : 0 + ); + + if (reference.excerpts?.length) { + const insertExcerpt = db.prepare(` + INSERT INTO reference_excerpts (id, reference_id, topic, content, applicable_dimensions, use_for) + VALUES (?, ?, ?, ?, ?, ?) + `); + + reference.excerpts.forEach((excerpt, index) => { + insertExcerpt.run( + excerpt.id || `${id}-excerpt-${index}`, + id, + excerpt.topic, + excerpt.content, + JSON.stringify(excerpt.applicableDimensions || []), + excerpt.useFor || null + ); + }); + } + + return id; +} + +export function updateReference(id, updates) { + const setClauses = []; + const params = []; + + if (updates.type !== undefined) { + setClauses.push('type = ?'); + params.push(updates.type); + } + if (updates.title !== undefined) { + setClauses.push('title = ?'); + params.push(updates.title); + } + if (updates.source !== undefined) { + setClauses.push('source = ?'); + params.push(updates.source); + } + if (updates.tags !== undefined) { + setClauses.push('tags = ?'); + params.push(JSON.stringify(updates.tags)); + } + if (updates.relatedDimensionSets !== undefined) { + setClauses.push('related_dimension_sets = ?'); + params.push(JSON.stringify(updates.relatedDimensionSets)); + } + + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + db.prepare(`UPDATE materials SET ${setClauses.join(', ')} WHERE id = ?`).run(...params); + return true; +} + +export function deleteReference(id) { + db.prepare('DELETE FROM reference_excerpts WHERE reference_id = ?').run(id); + db.prepare('DELETE FROM materials WHERE id = ?').run(id); + return true; +} + 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 - })); + const paradigms = db.prepare('SELECT * FROM paradigms ORDER BY is_custom ASC, created_at DESC').all(); + return paradigms.map(p => ({ + ...p, + tagClass: p.tag_class || 'bg-blue-900/30 text-blue-300', + tags: parseJson(p.tags, []), + systemConstraints: parseJson(p.system_constraints, []), + customDimensions: parseJson(p.custom_dimensions, null), + logicParadigms: parseJson(p.logic_paradigms, null), + selectedRefs: parseJson(p.selected_refs, []), + specializedPrompt: p.specialized_prompt || null, + expertGuidelines: parseJson(p.expert_guidelines, null), + outlineTemplate: p.outline_template || null, + recommendedTags: parseJson(p.recommended_tags, []), + isCustom: p.is_custom === 1, + autoMatchRefs: p.auto_match_refs === 1 + })); } 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 - }; + const p = db.prepare('SELECT * FROM paradigms WHERE id = ?').get(id); + if (!p) return null; + return { + ...p, + tagClass: p.tag_class || 'bg-blue-900/30 text-blue-300', + tags: parseJson(p.tags, []), + systemConstraints: parseJson(p.system_constraints, []), + customDimensions: parseJson(p.custom_dimensions, null), + logicParadigms: parseJson(p.logic_paradigms, null), + selectedRefs: parseJson(p.selected_refs, []), + specializedPrompt: p.specialized_prompt || null, + expertGuidelines: parseJson(p.expert_guidelines, null), + outlineTemplate: p.outline_template || null, + recommendedTags: parseJson(p.recommended_tags, []), + isCustom: p.is_custom === 1, + autoMatchRefs: p.auto_match_refs === 1 + }; } -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); +export function addParadigm(paradigm) { + const id = paradigm.id || `paradigm-${Date.now()}`; + db.prepare(` + INSERT INTO paradigms (id, name, icon, description, tags, tag_class, system_constraints, + dimension_set_id, custom_dimensions, logic_paradigms, auto_match_refs, selected_refs, + specialized_prompt, expert_guidelines, outline_template, recommended_tags, is_custom) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + paradigm.name, + paradigm.icon || '📝', + paradigm.description || null, + JSON.stringify(paradigm.tags || []), + paradigm.tagClass || 'bg-blue-900/30 text-blue-300', + JSON.stringify(paradigm.systemConstraints || []), + paradigm.dimensionSetId || null, + paradigm.customDimensions ? JSON.stringify(paradigm.customDimensions) : null, + paradigm.logicParadigms ? JSON.stringify(paradigm.logicParadigms) : null, + paradigm.autoMatchRefs !== false ? 1 : 0, + JSON.stringify(paradigm.selectedRefs || []), + paradigm.specializedPrompt || null, + paradigm.expertGuidelines ? JSON.stringify(paradigm.expertGuidelines) : null, + paradigm.outlineTemplate || null, + paradigm.recommendedTags ? JSON.stringify(paradigm.recommendedTags || []) : null, + paradigm.isCustom ? 1 : 0 + ); - 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); + return id; } export function updateParadigm(id, updates) { - const existing = getParadigmById(id); - if (!existing) return null; + const setClauses = []; + const params = []; - 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 = ? - `); + const fieldMap = { + name: 'name', + icon: 'icon', + description: 'description', + tagClass: 'tag_class', + dimensionSetId: 'dimension_set_id', + autoMatchRefs: 'auto_match_refs' + }; - stmt.run( - merged.name, - merged.description, - merged.icon, - merged.tagClass, - JSON.stringify(merged.tags || []), - merged.specializedPrompt, - JSON.stringify(merged.expertGuidelines || []), - new Date().toISOString(), - id - ); + Object.entries(updates).forEach(([key, value]) => { + if (fieldMap[key]) { + setClauses.push(`${fieldMap[key]} = ?`); + params.push(key === 'autoMatchRefs' ? (value ? 1 : 0) : value); + } + }); - return getParadigmById(id); + if (updates.tags !== undefined) { + setClauses.push('tags = ?'); + params.push(JSON.stringify(updates.tags)); + } + if (updates.systemConstraints !== undefined) { + setClauses.push('system_constraints = ?'); + params.push(JSON.stringify(updates.systemConstraints)); + } + if (updates.customDimensions !== undefined) { + setClauses.push('custom_dimensions = ?'); + params.push(updates.customDimensions ? JSON.stringify(updates.customDimensions) : null); + } + if (updates.logicParadigms !== undefined) { + setClauses.push('logic_paradigms = ?'); + params.push(updates.logicParadigms ? JSON.stringify(updates.logicParadigms) : null); + } + if (updates.selectedRefs !== undefined) { + setClauses.push('selected_refs = ?'); + params.push(JSON.stringify(updates.selectedRefs)); + } + if (updates.specializedPrompt !== undefined) { + setClauses.push('specialized_prompt = ?'); + params.push(updates.specializedPrompt); + } + if (updates.expertGuidelines !== undefined) { + setClauses.push('expert_guidelines = ?'); + params.push(updates.expertGuidelines ? JSON.stringify(updates.expertGuidelines) : null); + } + if (updates.outlineTemplate !== undefined) { + setClauses.push('outline_template = ?'); + params.push(updates.outlineTemplate); + } + if (updates.recommendedTags !== undefined) { + setClauses.push('recommended_tags = ?'); + params.push(updates.recommendedTags ? JSON.stringify(updates.recommendedTags) : null); + } + + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + db.prepare(`UPDATE paradigms SET ${setClauses.join(', ')} WHERE id = ?`).run(...params); + return true; } export function deleteParadigm(id) { - const stmt = db.prepare('DELETE FROM paradigms WHERE id = ?'); - const result = stmt.run(id); - return result.changes > 0; + db.prepare('DELETE FROM paradigms WHERE id = ?').run(id); + return true; +} + +export function getConfig(key, defaultValue = null) { + const result = db.prepare('SELECT value FROM user_config WHERE key = ?').get(key); + if (!result) return defaultValue; + return parseJson(result.value, result.value); +} + +export function setConfig(key, value) { + const valueStr = typeof value === 'string' ? value : JSON.stringify(value); + db.prepare(` + INSERT INTO user_config (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP + `).run(key, valueStr, valueStr); + return true; +} + +export function getAllDocuments() { + const docs = db.prepare('SELECT * FROM documents ORDER BY updated_at DESC').all(); + return docs.map(doc => ({ + ...doc, + tags: parseJson(doc.tags, []), + selectedRefs: parseJson(doc.selected_refs, []) + })); +} + +export function getDocumentById(id) { + const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(id); + if (!doc) return null; + return { + ...doc, + tags: parseJson(doc.tags, []), + selectedRefs: parseJson(doc.selected_refs, []), + versions: getDocumentVersions(id) + }; +} + +export function getDocumentVersions(documentId) { + return db.prepare('SELECT * FROM document_versions WHERE document_id = ? ORDER BY version_number DESC').all(documentId); +} + +export function createDocument(document) { + const id = document.id || `doc-${Date.now()}`; + db.prepare(` + INSERT INTO documents (id, title, content, paradigm_id, dimension_set_id, selected_refs, status, word_count, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + document.title || '未命名文稿', + document.content || '', + document.paradigmId || null, + document.dimensionSetId || null, + JSON.stringify(document.selectedRefs || []), + document.status || 'draft', + document.wordCount || 0, + JSON.stringify(document.tags || []) + ); + + return id; +} + +export function updateDocument(id, updates) { + const setClauses = []; + const params = []; + + if (updates.title !== undefined) { + setClauses.push('title = ?'); + params.push(updates.title); + } + if (updates.content !== undefined) { + setClauses.push('content = ?'); + params.push(updates.content); + setClauses.push('word_count = ?'); + params.push(updates.content.length); + } + if (updates.paradigmId !== undefined) { + setClauses.push('paradigm_id = ?'); + params.push(updates.paradigmId); + } + if (updates.dimensionSetId !== undefined) { + setClauses.push('dimension_set_id = ?'); + params.push(updates.dimensionSetId); + } + if (updates.status !== undefined) { + setClauses.push('status = ?'); + params.push(updates.status); + } + if (updates.tags !== undefined) { + setClauses.push('tags = ?'); + params.push(JSON.stringify(updates.tags)); + } + if (updates.selectedRefs !== undefined) { + setClauses.push('selected_refs = ?'); + params.push(JSON.stringify(updates.selectedRefs)); + } + + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + db.prepare(`UPDATE documents SET ${setClauses.join(', ')} WHERE id = ?`).run(...params); + return true; +} + +export function saveDocumentVersion(documentId, content, changeNote = '') { + const result = db.prepare('SELECT MAX(version_number) as max_version FROM document_versions WHERE document_id = ?').get(documentId); + const nextVersion = (result?.max_version || 0) + 1; + + db.prepare(` + INSERT INTO document_versions (document_id, content, version_number, change_note) + VALUES (?, ?, ?, ?) + `).run(documentId, content, nextVersion, changeNote); + + return nextVersion; +} + +export function deleteDocument(id) { + db.prepare('DELETE FROM document_versions WHERE document_id = ?').run(id); + db.prepare('DELETE FROM documents WHERE id = ?').run(id); + return true; +} + +export function clearDocuments() { + db.prepare('DELETE FROM document_versions').run(); + db.prepare('DELETE FROM documents').run(); + return true; +} + +export function getDocumentsByStatus(status) { + const docs = db.prepare('SELECT * FROM documents WHERE status = ? ORDER BY updated_at DESC').all(status); + return docs.map(doc => ({ + ...doc, + tags: parseJson(doc.tags, []), + selectedRefs: parseJson(doc.selected_refs, []) + })); +} + +export function getAllOutlineMaterials() { + return db.prepare('SELECT * FROM outline_materials ORDER BY created_at DESC').all(); +} + +export function addOutlineMaterial(material) { + const id = material.id || `omat-${Date.now()}`; + db.prepare(` + INSERT INTO outline_materials (id, name, content, word_count) + VALUES (?, ?, ?, ?) + `).run( + id, + material.name, + material.content, + material.content?.length || 0 + ); + return id; +} + +export function updateOutlineMaterial(id, updates) { + const setClauses = []; + const params = []; + + if (updates.name !== undefined) { + setClauses.push('name = ?'); + params.push(updates.name); + } + if (updates.content !== undefined) { + setClauses.push('content = ?'); + params.push(updates.content); + setClauses.push('word_count = ?'); + params.push(updates.content?.length || 0); + } + + setClauses.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + db.prepare(`UPDATE outline_materials SET ${setClauses.join(', ')} WHERE id = ?`).run(...params); + return true; +} + +export function deleteOutlineMaterial(id) { + db.prepare('DELETE FROM outline_materials WHERE id = ?').run(id); + return true; +} + +export function exportDatabase() { + return fs.readFileSync(DB_PATH); +} + +export function exportAsJSON() { + return { + references: getAllReferences(), + paradigms: getAllParadigms(), + documents: getAllDocuments(), + config: db.prepare('SELECT * FROM user_config').all(), + exportedAt: new Date().toISOString() + }; +} + +export async function resetDatabase() { + db.exec(` + DROP TABLE IF EXISTS reference_excerpts; + DROP TABLE IF EXISTS materials; + DROP TABLE IF EXISTS paradigms; + DROP TABLE IF EXISTS dimension_sets; + DROP TABLE IF EXISTS dimensions; + DROP TABLE IF EXISTS user_config; + DROP TABLE IF EXISTS analysis_history; + DROP TABLE IF EXISTS documents; + DROP TABLE IF EXISTS document_versions; + DROP TABLE IF EXISTS outline_materials; + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS materials ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + source TEXT, + date TEXT, + tags TEXT, + related_dimension_sets TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_default INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS reference_excerpts ( + id TEXT PRIMARY KEY, + reference_id TEXT NOT NULL, + topic TEXT NOT NULL, + content TEXT NOT NULL, + applicable_dimensions TEXT, + use_for TEXT, + FOREIGN KEY (reference_id) REFERENCES materials(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS paradigms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT, + description TEXT, + tags TEXT, + tag_class TEXT, + system_constraints TEXT, + dimension_set_id TEXT, + custom_dimensions TEXT, + logic_paradigms TEXT, + auto_match_refs INTEGER DEFAULT 1, + selected_refs TEXT, + specialized_prompt TEXT, + expert_guidelines TEXT, + outline_template TEXT, + recommended_tags TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_custom INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS dimension_sets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + applicable_for TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_custom INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS dimensions ( + id TEXT PRIMARY KEY, + dimension_set_id TEXT NOT NULL, + name TEXT NOT NULL, + focus TEXT, + keywords TEXT, + negative_keywords TEXT, + positive_benchmark TEXT, + sort_order INTEGER DEFAULT 0, + FOREIGN KEY (dimension_set_id) REFERENCES dimension_sets(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_config ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS analysis_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paradigm_id TEXT, + input_text TEXT, + result TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + paradigm_id TEXT, + dimension_set_id TEXT, + selected_refs TEXT, + status TEXT DEFAULT 'draft', + word_count INTEGER DEFAULT 0, + tags TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS document_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + content TEXT, + version_number INTEGER, + change_note TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS outline_materials ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content TEXT NOT NULL, + word_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + await seedDefaultReferences(); + console.log('🔄 数据库已重置并恢复默认数据'); + return true; } export default db; diff --git a/server/index.js b/server/index.js index 130cf7f..d43e47b 100644 --- a/server/index.js +++ b/server/index.js @@ -1,93 +1,344 @@ +import 'dotenv/config'; import express from 'express'; import cors from 'cors'; -import { getAllParadigms, getParadigmById, createParadigm, updateParadigm, deleteParadigm } from './db.js'; +import { + getAllParadigms, + getParadigmById, + updateParadigm, + deleteParadigm, + addParadigm, + getAllReferences, + getReferenceById, + getReferencesByType, + addReference, + updateReference, + deleteReference, + getConfig, + setConfig, + getAllDocuments, + getDocumentById, + getDocumentVersions, + createDocument, + updateDocument, + saveDocumentVersion, + deleteDocument, + clearDocuments, + getDocumentsByStatus, + getAllOutlineMaterials, + addOutlineMaterial, + updateOutlineMaterial, + deleteOutlineMaterial, + exportDatabase, + exportAsJSON, + resetDatabase +} from './db.js'; +import { getProviderSummary, streamChat } from './llm.js'; const app = express(); const PORT = process.env.API_PORT || 3001; -// 中间件 app.use(cors()); -app.use(express.json()); +app.use(express.json({ limit: '2mb' })); -// 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() }); + 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`); +// LLM 代理 +app.get('/api/llm/providers', (req, res) => { + res.json({ success: true, data: getProviderSummary() }); +}); + +app.post('/api/llm/stream', async (req, res) => { + const controller = new AbortController(); + req.on('close', () => controller.abort()); + + try { + const { providerId, model, appId, messages, options } = req.body || {}; + if (!providerId || !Array.isArray(messages)) { + res.status(400).json({ success: false, error: '参数不完整' }); + return; + } + + await streamChat({ + providerId, + model, + appId, + messages, + options, + res, + signal: controller.signal + }); + } catch (error) { + if (!res.headersSent) { + res.status(500).json({ success: false, error: error.message }); + } + } +}); + +// 素材库 +app.get('/api/references', (req, res) => { + try { + const { type } = req.query; + const data = type ? getReferencesByType(type) : getAllReferences(); + res.json({ success: true, data }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/references/:id', (req, res) => { + try { + const data = getReferenceById(req.params.id); + if (!data) return res.status(404).json({ success: false, error: '素材不存在' }); + res.json({ success: true, data }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/references', (req, res) => { + try { + const id = addReference(req.body); + res.status(201).json({ success: true, data: { id } }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.put('/api/references/:id', (req, res) => { + try { + const ok = updateReference(req.params.id, req.body || {}); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.delete('/api/references/:id', (req, res) => { + try { + const ok = deleteReference(req.params.id); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 范式 +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 id = addParadigm(req.body); + res.status(201).json({ success: true, data: { id } }); + } catch (error) { + console.error('创建范式失败:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.put('/api/paradigms/:id', (req, res) => { + try { + const ok = updateParadigm(req.params.id, req.body || {}); + res.json({ success: true, data: ok }); + } catch (error) { + console.error('更新范式失败:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.delete('/api/paradigms/:id', (req, res) => { + try { + const ok = deleteParadigm(req.params.id); + res.json({ success: true, data: ok }); + } catch (error) { + console.error('删除范式失败:', error); + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 用户配置 +app.get('/api/config/:key', (req, res) => { + try { + const value = getConfig(req.params.key, null); + res.json({ success: true, data: value }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.put('/api/config/:key', (req, res) => { + try { + const ok = setConfig(req.params.key, req.body?.value); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 文稿 +app.get('/api/documents', (req, res) => { + try { + const { status } = req.query; + const docs = status ? getDocumentsByStatus(status) : getAllDocuments(); + res.json({ success: true, data: docs }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/documents/:id', (req, res) => { + try { + const doc = getDocumentById(req.params.id); + if (!doc) return res.status(404).json({ success: false, error: '文稿不存在' }); + res.json({ success: true, data: doc }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/documents/:id/versions', (req, res) => { + try { + const versions = getDocumentVersions(req.params.id); + res.json({ success: true, data: versions }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/documents', (req, res) => { + try { + const id = createDocument(req.body); + res.status(201).json({ success: true, data: { id } }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/documents/:id/versions', (req, res) => { + try { + const { content, changeNote } = req.body || {}; + const versionNumber = saveDocumentVersion(req.params.id, content || '', changeNote || ''); + res.json({ success: true, data: { versionNumber } }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.put('/api/documents/:id', (req, res) => { + try { + const ok = updateDocument(req.params.id, req.body || {}); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.delete('/api/documents/:id', (req, res) => { + try { + const ok = deleteDocument(req.params.id); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/documents/clear', (req, res) => { + try { + const ok = clearDocuments(); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 提纲素材 +app.get('/api/outline-materials', (req, res) => { + try { + const data = getAllOutlineMaterials(); + res.json({ success: true, data }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/outline-materials', (req, res) => { + try { + const id = addOutlineMaterial(req.body); + res.status(201).json({ success: true, data: { id } }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.put('/api/outline-materials/:id', (req, res) => { + try { + const ok = updateOutlineMaterial(req.params.id, req.body || {}); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.delete('/api/outline-materials/:id', (req, res) => { + try { + const ok = deleteOutlineMaterial(req.params.id); + res.json({ success: true, data: ok }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +// 数据导出/重置 +app.get('/api/db/export', (req, res) => { + try { + const data = exportDatabase(); + res.setHeader('Content-Type', 'application/octet-stream'); + res.send(data); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.get('/api/db/export-json', (req, res) => { + try { + const data = exportAsJSON(); + res.json({ success: true, data }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.post('/api/db/reset', async (req, res) => { + try { + await resetDatabase(); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + +app.listen(PORT, () => { + console.log(`🚀 API 服务器已启动: http://localhost:${PORT}`); + console.log(' - LLM 代理: POST /api/llm/stream'); + console.log(' - 数据接口: /api/references /api/paradigms /api/documents'); }); diff --git a/server/llm.js b/server/llm.js new file mode 100644 index 0000000..bd179d9 --- /dev/null +++ b/server/llm.js @@ -0,0 +1,140 @@ +import { Readable } from 'stream'; + +const providers = { + deepseek: { + id: 'deepseek', + name: 'DeepSeek', + description: '深度求索', + apiUrl: process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/chat/completions', + apiKey: process.env.DEEPSEEK_API_KEY || '', + model: process.env.DEEPSEEK_MODEL || 'deepseek-chat' + }, + qianfan: { + id: 'qianfan', + name: '千帆大模型', + description: '百度文心一言', + apiUrl: process.env.QIANFAN_API_URL || 'https://qianfan.baidubce.com/v2/chat/completions', + apiKey: process.env.QIANFAN_API_KEY || '', + appId: process.env.QIANFAN_APP_ID || '', + model: process.env.QIANFAN_MODEL || 'ernie-4.0-8k' + }, + openai: { + id: 'openai', + name: 'OpenAI', + description: 'GPT 系列', + apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions', + apiKey: process.env.OPENAI_API_KEY || '', + model: process.env.OPENAI_MODEL || 'gpt-4o' + }, + claude: { + id: 'claude', + name: 'Claude', + description: 'Anthropic Claude', + apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', + apiKey: process.env.CLAUDE_API_KEY || '', + model: process.env.CLAUDE_MODEL || 'claude-3-5-sonnet' + }, + custom: { + id: 'custom', + name: '自定义', + description: '自定义 API 端点', + apiUrl: process.env.CUSTOM_API_URL || '', + apiKey: process.env.CUSTOM_API_KEY || '', + model: process.env.CUSTOM_MODEL || '' + } +}; + +export const getProviderSummary = () => { + return Object.values(providers).map(provider => ({ + id: provider.id, + name: provider.name, + description: provider.description, + model: provider.model, + enabled: Boolean(provider.apiKey) + })); +}; + +const buildHeaders = (provider) => { + const headers = { + 'Content-Type': 'application/json' + }; + + if (provider.apiKey) { + headers['Authorization'] = provider.apiKey.trim().startsWith('Bearer ') + ? provider.apiKey.trim() + : `Bearer ${provider.apiKey.trim()}`; + } + + if (provider.appId) { + headers['appid'] = provider.appId; + } + + return headers; +}; + +export const streamChat = async ({ + providerId, + model, + appId, + messages, + options, + res, + signal +}) => { + const provider = providers[providerId]; + + if (!provider) { + res.status(400).json({ success: false, error: '未知模型服务商' }); + return; + } + + if (!provider.apiKey) { + res.status(400).json({ success: false, error: '模型服务商未配置 API Key' }); + return; + } + + if (!provider.apiUrl) { + res.status(400).json({ success: false, error: '模型服务商未配置 API 地址' }); + return; + } + + const payload = { + model: model || provider.model, + messages, + ...options, + stream: true + }; + + const headers = buildHeaders({ ...provider, appId: appId || provider.appId }); + + const response = await fetch(provider.apiUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal + }); + + if (!response.ok) { + const errorText = await response.text(); + res.status(500).json({ success: false, error: errorText || `上游请求失败: ${response.status}` }); + return; + } + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const readable = Readable.fromWeb(response.body); + readable.on('data', (chunk) => { + res.write(chunk); + }); + readable.on('end', () => { + res.end(); + }); + readable.on('error', (error) => { + console.error('LLM 流式代理错误:', error); + res.end(); + }); +}; diff --git a/src/App.vue b/src/App.vue index c83e02b..617cd47 100644 --- a/src/App.vue +++ b/src/App.vue @@ -18,6 +18,11 @@ + + diff --git a/src/components/MainContent.vue b/src/components/MainContent.vue index a782c97..c5a1d16 100644 --- a/src/components/MainContent.vue +++ b/src/components/MainContent.vue @@ -374,15 +374,110 @@ -

- - 提示:这是 AI 调用时的核心指令。完整的 Prompt 应包含角色、目标、步骤和输出格式。 -

+ + +
+ +
+
+
+ ⋮⋮ +
+
+ + +
+ +
+

暂无章节,点击"添加章节"创建

+
+
+ + +
+ +
+
+
+ + + +
+ +
+

暂无检查规范

+
+
+ +

+ + 提示:章节大纲用于"范式分段写作",检查规范用于自动验证生成内容是否合规。 +

@@ -599,6 +694,54 @@ const colorOptions = [ { label: '青色', class: 'bg-cyan-900/30 text-cyan-300' } ] +// 拖拽状态 +const dragState = ref({ + type: null, // 'section' | 'guideline' + dragIndex: null, + overIndex: null +}) + +// 拖拽开始 +const handleDragStart = (event, index, type) => { + dragState.value.type = type + dragState.value.dragIndex = index + event.dataTransfer.effectAllowed = 'move' +} + +// 拖拽经过 +const handleDragOver = (event, index, type) => { + if (dragState.value.type === type) { + dragState.value.overIndex = index + } +} + +// 放置 +const handleDrop = (event, targetIndex, type) => { + if (dragState.value.type !== type) return + + const sourceIndex = dragState.value.dragIndex + if (sourceIndex === targetIndex) return + + const list = type === 'section' + ? paradigmEditState.value.editForm.sections + : paradigmEditState.value.editForm.expertGuidelines + + if (!list) return + + // 移动元素 + const [movedItem] = list.splice(sourceIndex, 1) + list.splice(targetIndex, 0, movedItem) + + handleDragEnd() +} + +// 拖拽结束 +const handleDragEnd = () => { + dragState.value.type = null + dragState.value.dragIndex = null + dragState.value.overIndex = null +} + // 关闭编辑 const closeParadigmEdit = () => { paradigmEditState.value.isEditing = false @@ -606,6 +749,40 @@ const closeParadigmEdit = () => { paradigmEditState.value.editingParadigmId = null } +// 添加章节 +const addSection = () => { + if (!paradigmEditState.value.editForm.sections) { + paradigmEditState.value.editForm.sections = [] + } + paradigmEditState.value.editForm.sections.push({ + title: '', + description: '', + weight: 20 + }) +} + +// 删除章节 +const removeSection = (index) => { + paradigmEditState.value.editForm.sections.splice(index, 1) +} + +// 添加检查规范 +const addGuideline = () => { + if (!paradigmEditState.value.editForm.expertGuidelines) { + paradigmEditState.value.editForm.expertGuidelines = [] + } + paradigmEditState.value.editForm.expertGuidelines.push({ + title: '', + description: '', + scope: 'document' + }) +} + +// 删除检查规范 +const removeGuideline = (index) => { + paradigmEditState.value.editForm.expertGuidelines.splice(index, 1) +} + // 保存范式编辑 const saveParadigmEdit = async () => { const form = paradigmEditState.value.editForm @@ -621,6 +798,8 @@ const saveParadigmEdit = async () => { tags: form.tagsInput.split(',').map(t => t.trim()).filter(t => t), tagClass: form.tagClass, specializedPrompt: form.specializedPrompt, + sections: (form.sections || []).filter(s => s.title), // 过滤空标题 + expertGuidelines: (form.expertGuidelines || []).filter(g => g.title || g.description), isCustom: true, createdAt: paradigmEditState.value.isAddMode ? new Date().toISOString() : undefined } @@ -669,7 +848,7 @@ const saveDocument = async () => { try { const { updateDocument } = await import('../db/index.js') - updateDocument(currentDocument.value.id, { + await updateDocument(currentDocument.value.id, { title: documentTitle.value, content: documentContent.value }) @@ -693,7 +872,7 @@ const changeDocStatus = async (status) => { try { const { updateDocument } = await import('../db/index.js') - updateDocument(currentDocument.value.id, { status }) + await updateDocument(currentDocument.value.id, { status }) currentDocument.value.status = status } catch (error) { console.error('修改状态失败:', error) diff --git a/src/components/MaterialsPanel.vue b/src/components/MaterialsPanel.vue index 4ffc9a9..a52abdf 100644 --- a/src/components/MaterialsPanel.vue +++ b/src/components/MaterialsPanel.vue @@ -371,7 +371,7 @@ const getTypeIcon = (type) => { const loadMaterials = async () => { try { const { getAllReferences } = await import('../db/index.js') - materials.value = getAllReferences() + materials.value = await getAllReferences() } catch (error) { console.error('加载素材失败:', error) } @@ -454,9 +454,9 @@ const saveMaterial = async () => { } if (isAddMode.value) { - addReference(materialData) + await addReference(materialData) } else { - updateReference(editForm.id, materialData) + await updateReference(editForm.id, materialData) } await loadMaterials() @@ -484,7 +484,7 @@ const deleteMaterial = async () => { try { const { deleteReference } = await import('../db/index.js') - deleteReference(selectedId.value) + await deleteReference(selectedId.value) selectedId.value = null showDeleteConfirm.value = false await loadMaterials() diff --git a/src/components/OutlineResultPanel.vue b/src/components/OutlineResultPanel.vue new file mode 100644 index 0000000..9851d55 --- /dev/null +++ b/src/components/OutlineResultPanel.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/src/components/OutlineWriterPanel.vue b/src/components/OutlineWriterPanel.vue new file mode 100644 index 0000000..a9b0425 --- /dev/null +++ b/src/components/OutlineWriterPanel.vue @@ -0,0 +1,845 @@ + + + + + diff --git a/src/components/RequirementParserPanel.vue b/src/components/RequirementParserPanel.vue index 7bfe77c..6b9df3c 100644 --- a/src/components/RequirementParserPanel.vue +++ b/src/components/RequirementParserPanel.vue @@ -275,10 +275,11 @@ diff --git a/src/db/index.js b/src/db/index.js index ceaff69..0dafa46 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -1,546 +1,85 @@ // ============================================ -// SQLite 数据库服务层 -// ============================================ -// 使用 sql.js 在浏览器端运行 SQLite -// 数据持久化到 IndexedDB - -// 数据库实例 -let db = null -let SQL = null - -// IndexedDB 配置 -const DB_NAME = 'AIWriterWorkshop' -const DB_STORE = 'database' -const DB_KEY = 'sqlite_db' - -// ============================================ -// 数据库初始化 +// 服务端 SQLite API 客户端 // ============================================ -/** - * 从 CDN 加载 sql.js - */ -const loadSqlJs = () => { - return new Promise((resolve, reject) => { - // 如果已经加载过 - if (window.initSqlJs) { - resolve(window.initSqlJs) - return - } +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001/api' - // 动态加载脚本 - const script = document.createElement('script') - script.src = 'https://sql.js.org/dist/sql-wasm.js' - script.async = true - - script.onload = () => { - if (window.initSqlJs) { - resolve(window.initSqlJs) - } else { - reject(new Error('sql.js 加载失败')) - } - } - - script.onerror = () => reject(new Error('sql.js 脚本加载失败')) - document.head.appendChild(script) +const requestJson = async (path, options = {}) => { + const response = await fetch(`${API_BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || `请求失败: ${response.status}`) + } + + const result = await response.json() + if (result && result.success === false) { + throw new Error(result.error || '请求失败') + } + + return result?.data } export const initDatabase = async () => { - if (db) return db - - try { - // 从 CDN 加载 sql.js - const initSqlJs = await loadSqlJs() - - // 加载 sql.js WASM - SQL = await initSqlJs({ - locateFile: file => `https://sql.js.org/dist/${file}` - }) - - // 尝试从 IndexedDB 加载现有数据库 - const savedData = await loadFromIndexedDB() - - if (savedData) { - db = new SQL.Database(savedData) - console.log('📦 从 IndexedDB 加载数据库成功') - // 对已有数据库执行迁移以添加新字段 - migrateDatabase() - } else { - db = new SQL.Database() - console.log('🆕 创建新数据库') - // 初始化表结构 - await initTables() - // 导入默认数据 - await importDefaultData() - } - - return db - } catch (error) { - console.error('❌ 数据库初始化失败:', error) - throw error - } + await requestJson('/health') + return true } -/** - * 初始化数据表结构 - */ -const initTables = async () => { - // 素材表 - db.run(` - CREATE TABLE IF NOT EXISTS materials ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - title TEXT NOT NULL, - source TEXT, - date TEXT, - tags TEXT, - related_dimension_sets TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_default INTEGER DEFAULT 0 - ) - `) +export const saveToIndexedDB = async () => true - // 素材摘录表 - db.run(` - CREATE TABLE IF NOT EXISTS reference_excerpts ( - id TEXT PRIMARY KEY, - reference_id TEXT NOT NULL, - topic TEXT NOT NULL, - content TEXT NOT NULL, - applicable_dimensions TEXT, - use_for TEXT, - FOREIGN KEY (reference_id) REFERENCES materials(id) ON DELETE CASCADE - ) - `) - - // 范式表 - db.run(` - CREATE TABLE IF NOT EXISTS paradigms ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - icon TEXT, - description TEXT, - tags TEXT, - tag_class TEXT, - system_constraints TEXT, - dimension_set_id TEXT, - custom_dimensions TEXT, - logic_paradigms TEXT, - auto_match_refs INTEGER DEFAULT 1, - selected_refs TEXT, - specialized_prompt TEXT, - expert_guidelines TEXT, - outline_template TEXT, - recommended_tags TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_custom INTEGER DEFAULT 0 - ) - `) - - // 维度集表 - db.run(` - CREATE TABLE IF NOT EXISTS dimension_sets ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - applicable_for TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_custom INTEGER DEFAULT 0 - ) - `) - - // 维度表 - db.run(` - CREATE TABLE IF NOT EXISTS dimensions ( - id TEXT PRIMARY KEY, - dimension_set_id TEXT NOT NULL, - name TEXT NOT NULL, - focus TEXT, - keywords TEXT, - negative_keywords TEXT, - positive_benchmark TEXT, - sort_order INTEGER DEFAULT 0, - FOREIGN KEY (dimension_set_id) REFERENCES dimension_sets(id) ON DELETE CASCADE - ) - `) - - // 用户配置表 - db.run(` - CREATE TABLE IF NOT EXISTS user_config ( - key TEXT PRIMARY KEY, - value TEXT, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - - // 分析历史表 - db.run(` - CREATE TABLE IF NOT EXISTS analysis_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - paradigm_id TEXT, - input_text TEXT, - result TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - - // 文稿记录表 - db.run(` - CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - content TEXT, - paradigm_id TEXT, - dimension_set_id TEXT, - selected_refs TEXT, - status TEXT DEFAULT 'draft', - word_count INTEGER DEFAULT 0, - tags TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `) - - // 文稿版本历史表 - db.run(` - CREATE TABLE IF NOT EXISTS document_versions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - document_id TEXT NOT NULL, - content TEXT, - version_number INTEGER, - change_note TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE - ) - `) - - console.log('✅ 数据表初始化完成') - - // 运行数据库迁移 - migrateDatabase() +export const query = () => { + throw new Error('服务端模式不支持直接 SQL 查询') } -/** - * 数据库迁移:添加范式表的新字段 - */ -const migrateDatabase = () => { - try { - // 检查是否已存在 specialized_prompt 列 - const columns = query(` - SELECT name FROM pragma_table_info('paradigms') - `).map(row => row.name) - - const neededColumns = ['specialized_prompt', 'expert_guidelines', 'outline_template', 'recommended_tags'] - const missingColumns = neededColumns.filter(col => !columns.includes(col)) - - if (missingColumns.length > 0) { - console.log('🔄 检测到数据库需要迁移,添加新字段...') - - if (missingColumns.includes('specialized_prompt')) { - execute('ALTER TABLE paradigms ADD COLUMN specialized_prompt TEXT') - } - if (missingColumns.includes('expert_guidelines')) { - execute('ALTER TABLE paradigms ADD COLUMN expert_guidelines TEXT') - } - if (missingColumns.includes('outline_template')) { - execute('ALTER TABLE paradigms ADD COLUMN outline_template TEXT') - } - if (missingColumns.includes('recommended_tags')) { - execute('ALTER TABLE paradigms ADD COLUMN recommended_tags TEXT') - } - - console.log('✅ 数据库迁移完成') - } - } catch (error) { - console.error('❌ 数据库迁移失败:', error) - } +export const queryOne = () => { + throw new Error('服务端模式不支持直接 SQL 查询') } -// ============================================ -// IndexedDB 持久化 -// ============================================ - -/** - * 保存数据库到 IndexedDB - */ -export const saveToIndexedDB = async () => { - if (!db) return - - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1) - - request.onerror = () => reject(request.error) - - request.onupgradeneeded = (event) => { - const idb = event.target.result - if (!idb.objectStoreNames.contains(DB_STORE)) { - idb.createObjectStore(DB_STORE) - } - } - - request.onsuccess = (event) => { - const idb = event.target.result - const transaction = idb.transaction([DB_STORE], 'readwrite') - const store = transaction.objectStore(DB_STORE) - - const data = db.export() - const buffer = new Uint8Array(data) - - const putRequest = store.put(buffer, DB_KEY) - putRequest.onsuccess = () => { - console.log('💾 数据库已保存到 IndexedDB') - resolve() - } - putRequest.onerror = () => reject(putRequest.error) - } - }) +export const execute = () => { + throw new Error('服务端模式不支持直接 SQL 执行') } -/** - * 从 IndexedDB 加载数据库 - */ -const loadFromIndexedDB = async () => { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, 1) - - request.onerror = () => reject(request.error) - - request.onupgradeneeded = (event) => { - const idb = event.target.result - if (!idb.objectStoreNames.contains(DB_STORE)) { - idb.createObjectStore(DB_STORE) - } - } - - request.onsuccess = (event) => { - const idb = event.target.result - const transaction = idb.transaction([DB_STORE], 'readonly') - const store = transaction.objectStore(DB_STORE) - - const getRequest = store.get(DB_KEY) - getRequest.onsuccess = () => { - resolve(getRequest.result || null) - } - getRequest.onerror = () => resolve(null) - } - }) -} - -// ============================================ -// 通用 CRUD 操作 -// ============================================ - -/** - * 执行查询并返回结果 - */ -export const query = (sql, params = []) => { - if (!db) throw new Error('数据库未初始化') - - try { - const stmt = db.prepare(sql) - stmt.bind(params) - - const results = [] - while (stmt.step()) { - results.push(stmt.getAsObject()) - } - stmt.free() - - return results - } catch (error) { - console.error('查询失败:', sql, error) - throw error - } -} - -/** - * 执行单条查询 - */ -export const queryOne = (sql, params = []) => { - const results = query(sql, params) - return results.length > 0 ? results[0] : null -} - -/** - * 执行更新/插入操作 - */ -export const execute = (sql, params = []) => { - if (!db) throw new Error('数据库未初始化') - - try { - db.run(sql, params) - // 自动保存到 IndexedDB - saveToIndexedDB() - return true - } catch (error) { - console.error('执行失败:', sql, error) - throw error - } -} - -/** - * 批量执行 - */ -export const executeBatch = (statements) => { - if (!db) throw new Error('数据库未初始化') - - try { - statements.forEach(({ sql, params = [] }) => { - db.run(sql, params) - }) - saveToIndexedDB() - return true - } catch (error) { - console.error('批量执行失败:', error) - throw error - } +export const executeBatch = () => { + throw new Error('服务端模式不支持直接 SQL 批量执行') } // ============================================ // 素材库 CRUD // ============================================ -/** - * 获取所有素材 - */ -export const getAllReferences = () => { - const refs = query('SELECT * FROM materials ORDER BY created_at DESC') - - return refs.map(ref => ({ - ...ref, - tags: ref.tags ? JSON.parse(ref.tags) : [], - relatedDimensionSets: ref.related_dimension_sets ? JSON.parse(ref.related_dimension_sets) : [], - excerpts: getExcerptsByReferenceId(ref.id) - })) +export const getAllReferences = async () => { + return requestJson('/references') } -/** - * 根据ID获取素材 - */ -export const getReferenceById = (id) => { - const ref = queryOne('SELECT * FROM materials WHERE id = ?', [id]) - if (!ref) return null - - return { - ...ref, - tags: ref.tags ? JSON.parse(ref.tags) : [], - relatedDimensionSets: ref.related_dimension_sets ? JSON.parse(ref.related_dimension_sets) : [], - excerpts: getExcerptsByReferenceId(ref.id) - } +export const getReferenceById = async (id) => { + return requestJson(`/references/${id}`) } -/** - * 根据类型获取素材 - */ -export const getReferencesByType = (type) => { - const refs = query('SELECT * FROM materials WHERE type = ? ORDER BY created_at DESC', [type]) - - return refs.map(ref => ({ - ...ref, - tags: ref.tags ? JSON.parse(ref.tags) : [], - relatedDimensionSets: ref.related_dimension_sets ? JSON.parse(ref.related_dimension_sets) : [], - excerpts: getExcerptsByReferenceId(ref.id) - })) +export const getReferencesByType = async (type) => { + return requestJson(`/references?type=${encodeURIComponent(type)}`) } -/** - * 获取素材的摘录 - */ -const getExcerptsByReferenceId = (referenceId) => { - const excerpts = query('SELECT * FROM reference_excerpts WHERE reference_id = ?', [referenceId]) - - return excerpts.map(e => ({ - ...e, - applicableDimensions: e.applicable_dimensions ? JSON.parse(e.applicable_dimensions) : [] - })) +export const addReference = async (reference) => { + const data = await requestJson('/references', { + method: 'POST', + body: JSON.stringify(reference) + }) + return data?.id } -/** - * 添加素材 - */ -export const addReference = (reference) => { - const id = reference.id || `ref-${Date.now()}` - - execute(` - INSERT INTO materials (id, type, title, source, date, tags, related_dimension_sets, is_default) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, [ - id, - reference.type, - reference.title, - reference.source || null, - reference.date || null, - JSON.stringify(reference.tags || []), - JSON.stringify(reference.relatedDimensionSets || []), - reference.isDefault ? 1 : 0 - ]) - - // 添加摘录 - if (reference.excerpts?.length) { - reference.excerpts.forEach((excerpt, index) => { - execute(` - INSERT INTO reference_excerpts (id, reference_id, topic, content, applicable_dimensions, use_for) - VALUES (?, ?, ?, ?, ?, ?) - `, [ - excerpt.id || `${id}-excerpt-${index}`, - id, - excerpt.topic, - excerpt.content, - JSON.stringify(excerpt.applicableDimensions || []), - excerpt.useFor || null - ]) - }) - } - - return id -} - -/** - * 更新素材 - */ -export const updateReference = (id, updates) => { - const setClauses = [] - const params = [] - - if (updates.type !== undefined) { - setClauses.push('type = ?') - params.push(updates.type) - } - if (updates.title !== undefined) { - setClauses.push('title = ?') - params.push(updates.title) - } - if (updates.source !== undefined) { - setClauses.push('source = ?') - params.push(updates.source) - } - if (updates.tags !== undefined) { - setClauses.push('tags = ?') - params.push(JSON.stringify(updates.tags)) - } - if (updates.relatedDimensionSets !== undefined) { - setClauses.push('related_dimension_sets = ?') - params.push(JSON.stringify(updates.relatedDimensionSets)) - } - - setClauses.push('updated_at = CURRENT_TIMESTAMP') - params.push(id) - - execute(`UPDATE materials SET ${setClauses.join(', ')} WHERE id = ?`, params) - +export const updateReference = async (id, updates) => { + await requestJson(`/references/${id}`, { + method: 'PUT', + body: JSON.stringify(updates) + }) return true } -/** - * 删除素材 - */ -export const deleteReference = (id) => { - execute('DELETE FROM reference_excerpts WHERE reference_id = ?', [id]) - execute('DELETE FROM materials WHERE id = ?', [id]) +export const deleteReference = async (id) => { + await requestJson(`/references/${id}`, { method: 'DELETE' }) return true } @@ -548,131 +87,32 @@ export const deleteReference = (id) => { // 范式 CRUD // ============================================ -/** - * 获取所有范式 - */ -export const getAllParadigms = () => { - const paradigms = query('SELECT * FROM paradigms ORDER BY is_custom ASC, created_at DESC') - - return paradigms.map(p => ({ - ...p, - tags: p.tags ? JSON.parse(p.tags) : [], - systemConstraints: p.system_constraints ? JSON.parse(p.system_constraints) : [], - customDimensions: p.custom_dimensions ? JSON.parse(p.custom_dimensions) : null, - logicParadigms: p.logic_paradigms ? JSON.parse(p.logic_paradigms) : null, - selectedRefs: p.selected_refs ? JSON.parse(p.selected_refs) : [], - specializedPrompt: p.specialized_prompt || null, - expertGuidelines: p.expert_guidelines ? JSON.parse(p.expert_guidelines) : null, - outlineTemplate: p.outline_template || null, - recommendedTags: p.recommended_tags ? JSON.parse(p.recommended_tags) : [], - isCustom: p.is_custom === 1, - autoMatchRefs: p.auto_match_refs === 1 - })) +export const getAllParadigms = async () => { + return requestJson('/paradigms') } -/** - * 添加范式 - */ -export const addParadigm = (paradigm) => { - const id = paradigm.id || `paradigm-${Date.now()}` - - execute(` - INSERT INTO paradigms (id, name, icon, description, tags, tag_class, system_constraints, - dimension_set_id, custom_dimensions, logic_paradigms, auto_match_refs, selected_refs, - specialized_prompt, expert_guidelines, outline_template, recommended_tags, is_custom) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ - id, - paradigm.name, - paradigm.icon || '📝', - paradigm.description || null, - JSON.stringify(paradigm.tags || []), - paradigm.tagClass || 'bg-blue-900/30 text-blue-300', - JSON.stringify(paradigm.systemConstraints || []), - paradigm.dimensionSetId || null, - paradigm.customDimensions ? JSON.stringify(paradigm.customDimensions) : null, - paradigm.logicParadigms ? JSON.stringify(paradigm.logicParadigms) : null, - paradigm.autoMatchRefs !== false ? 1 : 0, - JSON.stringify(paradigm.selectedRefs || []), - paradigm.specializedPrompt || null, - paradigm.expertGuidelines ? JSON.stringify(paradigm.expertGuidelines) : null, - paradigm.outlineTemplate || null, - paradigm.recommendedTags ? JSON.stringify(paradigm.recommendedTags || []) : null, - paradigm.isCustom ? 1 : 0 - ]) - - return id +export const getParadigmById = async (id) => { + return requestJson(`/paradigms/${id}`) } -/** - * 更新范式 - */ -export const updateParadigm = (id, updates) => { - const setClauses = [] - const params = [] - - const fieldMap = { - name: 'name', - icon: 'icon', - description: 'description', - tagClass: 'tag_class', - dimensionSetId: 'dimension_set_id', - autoMatchRefs: 'auto_match_refs' - } - - Object.entries(updates).forEach(([key, value]) => { - if (fieldMap[key]) { - setClauses.push(`${fieldMap[key]} = ?`) - params.push(key === 'autoMatchRefs' ? (value ? 1 : 0) : value) - } +export const addParadigm = async (paradigm) => { + const data = await requestJson('/paradigms', { + method: 'POST', + body: JSON.stringify(paradigm) }) + return data?.id +} - if (updates.tags !== undefined) { - setClauses.push('tags = ?') - params.push(JSON.stringify(updates.tags)) - } - if (updates.systemConstraints !== undefined) { - setClauses.push('system_constraints = ?') - params.push(JSON.stringify(updates.systemConstraints)) - } - if (updates.customDimensions !== undefined) { - setClauses.push('custom_dimensions = ?') - params.push(updates.customDimensions ? JSON.stringify(updates.customDimensions) : null) - } - if (updates.selectedRefs !== undefined) { - setClauses.push('selected_refs = ?') - params.push(JSON.stringify(updates.selectedRefs)) - } - if (updates.specializedPrompt !== undefined) { - setClauses.push('specialized_prompt = ?') - params.push(updates.specializedPrompt) - } - if (updates.expertGuidelines !== undefined) { - setClauses.push('expert_guidelines = ?') - params.push(updates.expertGuidelines ? JSON.stringify(updates.expertGuidelines) : null) - } - if (updates.outlineTemplate !== undefined) { - setClauses.push('outline_template = ?') - params.push(updates.outlineTemplate) - } - if (updates.recommendedTags !== undefined) { - setClauses.push('recommended_tags = ?') - params.push(updates.recommendedTags ? JSON.stringify(updates.recommendedTags) : null) - } - - setClauses.push('updated_at = CURRENT_TIMESTAMP') - params.push(id) - - execute(`UPDATE paradigms SET ${setClauses.join(', ')} WHERE id = ?`, params) - +export const updateParadigm = async (id, updates) => { + await requestJson(`/paradigms/${id}`, { + method: 'PUT', + body: JSON.stringify(updates) + }) return true } -/** - * 删除范式 - */ -export const deleteParadigm = (id) => { - execute('DELETE FROM paradigms WHERE id = ?', [id]) +export const deleteParadigm = async (id) => { + await requestJson(`/paradigms/${id}`, { method: 'DELETE' }) return true } @@ -680,32 +120,16 @@ export const deleteParadigm = (id) => { // 用户配置 CRUD // ============================================ -/** - * 获取配置 - */ -export const getConfig = (key, defaultValue = null) => { - const result = queryOne('SELECT value FROM user_config WHERE key = ?', [key]) - if (!result) return defaultValue - - try { - return JSON.parse(result.value) - } catch { - return result.value - } +export const getConfig = async (key, defaultValue = null) => { + const data = await requestJson(`/config/${encodeURIComponent(key)}`) + return data ?? defaultValue } -/** - * 设置配置 - */ -export const setConfig = (key, value) => { - const valueStr = typeof value === 'string' ? value : JSON.stringify(value) - - execute(` - INSERT INTO user_config (key, value, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = CURRENT_TIMESTAMP - `, [key, valueStr, valueStr]) - +export const setConfig = async (key, value) => { + await requestJson(`/config/${encodeURIComponent(key)}`, { + method: 'PUT', + body: JSON.stringify({ value }) + }) return true } @@ -713,217 +137,104 @@ export const setConfig = (key, value) => { // 文稿 CRUD // ============================================ -/** - * 获取所有文稿 - */ -export const getAllDocuments = () => { - const docs = query('SELECT * FROM documents ORDER BY updated_at DESC') - - return docs.map(doc => ({ - ...doc, - tags: doc.tags ? JSON.parse(doc.tags) : [], - selectedRefs: doc.selected_refs ? JSON.parse(doc.selected_refs) : [] - })) +export const getAllDocuments = async () => { + return requestJson('/documents') } -/** - * 根据ID获取文稿 - */ -export const getDocumentById = (id) => { - const doc = queryOne('SELECT * FROM documents WHERE id = ?', [id]) - if (!doc) return null - - return { - ...doc, - tags: doc.tags ? JSON.parse(doc.tags) : [], - selectedRefs: doc.selected_refs ? JSON.parse(doc.selected_refs) : [], - versions: getDocumentVersions(id) - } +export const getDocumentById = async (id) => { + return requestJson(`/documents/${id}`) } -/** - * 获取文稿版本历史 - */ -export const getDocumentVersions = (documentId) => { - return query('SELECT * FROM document_versions WHERE document_id = ? ORDER BY version_number DESC', [documentId]) +export const getDocumentVersions = async (documentId) => { + return requestJson(`/documents/${documentId}/versions`) } -/** - * 创建文稿 - */ -export const createDocument = (document) => { - const id = document.id || `doc-${Date.now()}` - - execute(` - INSERT INTO documents (id, title, content, paradigm_id, dimension_set_id, selected_refs, status, word_count, tags) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ - id, - document.title || '未命名文稿', - document.content || '', - document.paradigmId || null, - document.dimensionSetId || null, - JSON.stringify(document.selectedRefs || []), - document.status || 'draft', - document.wordCount || 0, - JSON.stringify(document.tags || []) - ]) - - return id +export const createDocument = async (document) => { + const data = await requestJson('/documents', { + method: 'POST', + body: JSON.stringify(document) + }) + return data?.id } -/** - * 更新文稿 - */ -export const updateDocument = (id, updates) => { - const setClauses = [] - const params = [] - - if (updates.title !== undefined) { - setClauses.push('title = ?') - params.push(updates.title) - } - if (updates.content !== undefined) { - setClauses.push('content = ?') - params.push(updates.content) - // 自动计算字数 - setClauses.push('word_count = ?') - params.push(updates.content.length) - } - if (updates.paradigmId !== undefined) { - setClauses.push('paradigm_id = ?') - params.push(updates.paradigmId) - } - if (updates.status !== undefined) { - setClauses.push('status = ?') - params.push(updates.status) - } - if (updates.tags !== undefined) { - setClauses.push('tags = ?') - params.push(JSON.stringify(updates.tags)) - } - if (updates.selectedRefs !== undefined) { - setClauses.push('selected_refs = ?') - params.push(JSON.stringify(updates.selectedRefs)) - } - - setClauses.push('updated_at = CURRENT_TIMESTAMP') - params.push(id) - - execute(`UPDATE documents SET ${setClauses.join(', ')} WHERE id = ?`, params) - +export const updateDocument = async (id, updates) => { + await requestJson(`/documents/${id}`, { + method: 'PUT', + body: JSON.stringify(updates) + }) return true } -/** - * 保存文稿版本 - */ -export const saveDocumentVersion = (documentId, content, changeNote = '') => { - // 获取当前最大版本号 - const result = queryOne('SELECT MAX(version_number) as max_version FROM document_versions WHERE document_id = ?', [documentId]) - const nextVersion = (result?.max_version || 0) + 1 - - execute(` - INSERT INTO document_versions (document_id, content, version_number, change_note) - VALUES (?, ?, ?, ?) - `, [documentId, content, nextVersion, changeNote]) - - return nextVersion +export const saveDocumentVersion = async (documentId, content, changeNote = '') => { + const data = await requestJson(`/documents/${documentId}/versions`, { + method: 'POST', + body: JSON.stringify({ content, changeNote }) + }) + return data?.versionNumber } -/** - * 删除文稿 - */ -export const deleteDocument = (id) => { - execute('DELETE FROM document_versions WHERE document_id = ?', [id]) - execute('DELETE FROM documents WHERE id = ?', [id]) +export const deleteDocument = async (id) => { + await requestJson(`/documents/${id}`, { method: 'DELETE' }) return true } -/** - * 根据状态筛选文稿 - */ -export const getDocumentsByStatus = (status) => { - const docs = query('SELECT * FROM documents WHERE status = ? ORDER BY updated_at DESC', [status]) +export const clearDocuments = async () => { + await requestJson('/documents/clear', { method: 'POST' }) + return true +} - return docs.map(doc => ({ - ...doc, - tags: doc.tags ? JSON.parse(doc.tags) : [], - selectedRefs: doc.selected_refs ? JSON.parse(doc.selected_refs) : [] - })) +export const getDocumentsByStatus = async (status) => { + return requestJson(`/documents?status=${encodeURIComponent(status)}`) } // ============================================ // 数据导入/导出 // ============================================ -/** - * 导出数据库为二进制 - */ -export const exportDatabase = () => { - if (!db) throw new Error('数据库未初始化') - return db.export() -} - -/** - * 导入数据库 - */ -export const importDatabase = async (data) => { - if (!SQL) throw new Error('SQL.js 未初始化') - - db = new SQL.Database(new Uint8Array(data)) - await saveToIndexedDB() - - return true -} - -/** - * 导出为 JSON - */ -export const exportAsJSON = () => { - return { - references: getAllReferences(), - paradigms: getAllParadigms(), - documents: getAllDocuments(), - config: query('SELECT * FROM user_config'), - exportedAt: new Date().toISOString() +export const exportDatabase = async () => { + const response = await fetch(`${API_BASE}/db/export`) + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || '导出失败') } + const buffer = await response.arrayBuffer() + return new Uint8Array(buffer) +} + +export const exportAsJSON = async () => { + return requestJson('/db/export-json') } -/** - * 重置数据库 - */ export const resetDatabase = async () => { - if (!SQL) throw new Error('SQL.js 未初始化') - - db = new SQL.Database() - await initTables() - await importDefaultData() - await saveToIndexedDB() - + await requestJson('/db/reset', { method: 'POST' }) return true } // ============================================ -// 默认数据导入 +// 提纲写作素材 CRUD // ============================================ -/** - * 导入默认数据(从静态配置文件) - */ -const importDefaultData = async () => { - // 导入默认素材 - const { REFERENCES } = await import('../config/references.js') - - Object.values(REFERENCES).forEach(ref => { - addReference({ - ...ref, - isDefault: true - }) - }) - - console.log('✅ 默认素材导入完成') +export const getAllOutlineMaterials = async () => { + return requestJson('/outline-materials') } -// 导出数据库实例(用于调试) -export const getDb = () => db +export const addOutlineMaterial = async (material) => { + const data = await requestJson('/outline-materials', { + method: 'POST', + body: JSON.stringify(material) + }) + return data?.id +} + +export const updateOutlineMaterial = async (id, updates) => { + await requestJson(`/outline-materials/${id}`, { + method: 'PUT', + body: JSON.stringify(updates) + }) + return true +} + +export const deleteOutlineMaterial = async (id) => { + await requestJson(`/outline-materials/${id}`, { method: 'DELETE' }) + return true +} diff --git a/src/stores/app.js b/src/stores/app.js index cf0bd1a..222505f 100644 --- a/src/stores/app.js +++ b/src/stores/app.js @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { config, modelProviders, getConfiguredProviders, getDefaultProvider } from '../utils/config.js' +import { modelProviders, getDefaultProvider } from '../utils/config.js' import DeepSeekAPI from '../api/deepseek.js' import { buildPrompt, createStreamParser, parseGhostwriterOutput } from '../utils/promptBuilder.js' import { PARADIGMS, getParadigmById, buildParadigmConstraints } from '../config/paradigms.js' @@ -21,9 +21,14 @@ export const useAppStore = defineStore('app', () => { return modelProviders[selectedProviderId.value] || getDefaultProvider() }) - // API 配置(基于选择的服务商) - const apiUrl = computed(() => currentProvider.value.apiUrl) - const apiKey = computed(() => currentProvider.value.apiKey) + // LLM 客户端(服务端代理) + const createLlmClient = () => { + return new DeepSeekAPI({ + providerId: selectedProviderId.value, + model: currentProvider.value.model, + appId: currentProvider.value.appId + }) + } // 写作相关 const inputTask = ref('请帮我写一篇关于"AI 辅助编程如何改变软件开发流程"的博客文章,面向中级程序员。') @@ -123,6 +128,20 @@ export const useAppStore = defineStore('app', () => { fusionResult: '' }) + // 提纲写作相关 (Outline Writer) + const outlineWriterState = ref({ + rawOutline: '', // 原始大纲文本 + parsedSections: [], // 解析后的章节树 + writingContext: '', // 全局写作背景 + currentSectionIndex: -1, // 当前选中的章节索引 + isGenerating: false, // 是否正在生成 + shouldAbort: false, // 是否应该中断生成 + // 素材管理 + materials: [], // 素材池 [{ id, name, content }] + showMaterialModal: false, // 素材编辑弹窗 + editingMaterial: null // 当前编辑的素材对象 + }) + // UI状态 const showPromptDebug = ref(false) const showRefInput = ref(false) @@ -146,22 +165,12 @@ export const useAppStore = defineStore('app', () => { const ref = references.value[index] if (!ref || !ref.content) return - if (!apiUrl.value || !apiKey.value || apiKey.value === 'YOUR_KEY') { - console.warn('Store: Missing API Key for style analysis') - return - } - // 如果已经有标签,跳过(或者强制刷新?这里假设自动触发仅一次) if (ref.styleTags && ref.styleTags.length > 0) return ref.isAnalyzing = true try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() let fullTags = '' console.log(`Store: Analyzing style for reference [${index}]...`) @@ -182,10 +191,6 @@ export const useAppStore = defineStore('app', () => { // 生成内容动作 const generateContentAction = async () => { - if (!apiUrl.value || !apiKey.value || apiKey.value === 'YOUR_KEY') { - throw new Error('请先配置 API 地址和 API Key') - } - console.log('Store: generateContentAction 启动', { mode: isDeepMode.value ? 'Deep' : 'Standard' }) isGenerating.value = true generatedContent.value = '' @@ -194,12 +199,7 @@ export const useAppStore = defineStore('app', () => { generationStage.value = 'thinking' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const streamParser = createStreamParser() // 构建 Prompt(XML 结构化数据) @@ -379,12 +379,7 @@ ${draft} } try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() let fullContent = '' console.log('Store: 调用 API 分析文章...') @@ -505,12 +500,7 @@ ${draft} // 通用 API 调用方法 const callApi = async (prompt, onContent, options = {}) => { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() return api.generateContent(prompt, onContent, options) } @@ -579,7 +569,25 @@ ${draft} return sections } - // 自定义范式没有 outlineTemplate:根据 expertGuidelines 生成基础章节 + // 自定义范式:优先使用 sections 字段(如存在) + if (paradigm.sections && paradigm.sections.length > 0) { + const sections = paradigm.sections.map((s, index) => ({ + title: s.title, + type: 'h2', + userInput: '', + inputType: 'idea', + corePoint: '', + materialData: '', + supplementNote: '', + placeholder: s.description || '', + weight: s.weight || Math.round(100 / paradigm.sections.length), + generatedContent: '', + isGenerating: false + })) + return sections + } + + // 兼容旧数据:没有 sections 字段,使用默认章节 const sections = [] // 添加默认的开篇章节 @@ -596,64 +604,19 @@ ${draft} isGenerating: false }) - // 如果有 expertGuidelines,根据其内容生成章节 - if (paradigm.expertGuidelines && paradigm.expertGuidelines.length > 0) { - // 分析 expertGuidelines 中是否有结构性建议 - const guidelines = paradigm.expertGuidelines - - // 按 scope 分组,document 级别的可能包含结构要求 - const docLevelGuidelines = guidelines.filter(g => - (typeof g === 'object' && g.scope === 'document') || - (typeof g === 'string' && /章节|结构|部分|篇幅/.test(g)) - ) - - if (docLevelGuidelines.length > 0) { - // 有文档级别指令,尝试提取章节建议 - docLevelGuidelines.forEach((g, i) => { - const title = typeof g === 'object' ? g.title : `核心内容 ${i + 1}` - sections.push({ - title: title, - type: 'h2', - userInput: '', - inputType: 'idea', - corePoint: '', - materialData: '', - supplementNote: '', - placeholder: typeof g === 'object' ? g.description : g, - generatedContent: '', - isGenerating: false - }) - }) - } else { - // 没有明确的结构要求,生成通用章节 - sections.push({ - title: '核心内容', - type: 'h2', - userInput: '', - inputType: 'idea', - corePoint: '', - materialData: '', - supplementNote: '', - placeholder: '请输入主要论点、分析或内容...', - generatedContent: '', - isGenerating: false - }) - } - } else { - // 没有 expertGuidelines,生成最基础的章节 - sections.push({ - title: '主体内容', - type: 'h2', - userInput: '', - inputType: 'idea', - corePoint: '', - materialData: '', - supplementNote: '', - placeholder: '请输入文章的主要内容...', - generatedContent: '', - isGenerating: false - }) - } + // 添加核心内容章节 + sections.push({ + title: '核心内容', + type: 'h2', + userInput: '', + inputType: 'idea', + corePoint: '', + materialData: '', + supplementNote: '', + placeholder: '请输入主要论点、分析或内容...', + generatedContent: '', + isGenerating: false + }) // 添加默认的结尾章节 sections.push({ @@ -702,12 +665,7 @@ ${draft} section.generatedContent = '' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const streamParser = createStreamParser() // 构建上文内容,保持连贯性 @@ -797,6 +755,89 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 section.generatedContent = `[生成失败: ${error.message}]` } finally { section.isGenerating = false + + // 生成完成后自动检查规范 + if (section.generatedContent && !section.generatedContent.startsWith('[生成失败')) { + checkGuidelinesForSection(index) + } + } + } + + /** + * 检查单个章节是否符合专家规范 + */ + const checkGuidelinesForSection = async (sectionIndex) => { + const section = paradigmWriterState.value.sections[sectionIndex] + if (!section || !section.generatedContent) return + + const paradigm = getParadigmById(paradigmWriterState.value.selectedParadigmId, paradigmStore.customParadigms) + if (!paradigm || !paradigm.expertGuidelines || paradigm.expertGuidelines.length === 0) return + + // 初始化检查结果 + section.guidelineChecks = paradigm.expertGuidelines.map(g => ({ + title: typeof g === 'object' ? g.title : g, + description: typeof g === 'object' ? g.description : g, + scope: typeof g === 'object' ? g.scope : 'document', + status: 'pending' // pending | pass | fail + })) + + try { + const api = createLlmClient() + + // 只检查与该章节相关的规范(sentence/paragraph 级别) + const relevantChecks = section.guidelineChecks.filter(c => + c.scope === 'sentence' || c.scope === 'paragraph' + ) + + if (relevantChecks.length === 0) return + + const checkPrompt = `请检查以下内容是否符合给定的写作规范。 + +【待检查内容】 +${section.generatedContent} + +【检查规范】 +${relevantChecks.map((c, i) => `${i + 1}. ${c.title}:${c.description}`).join('\n')} + +请按以下 JSON 格式输出检查结果,每项返回 pass(符合)或 fail(不符合): +\`\`\`json +[ + {"index": 0, "result": "pass"}, + {"index": 1, "result": "fail", "reason": "不符合原因"} +] +\`\`\` +` + + let responseText = '' + await api._streamRequest([ + { role: 'system', content: '你是一名专业的文稿审核专家,请严格按照规范进行检查。' }, + { role: 'user', content: checkPrompt } + ], { temperature: 0.2, max_tokens: 1000 }, (chunk) => { + responseText += chunk + }) + + // 解析检查结果 + const jsonMatch = responseText.match(/```json\n?([\s\S]*?)\n?```/) + if (jsonMatch) { + try { + const results = JSON.parse(jsonMatch[1]) + results.forEach(r => { + if (relevantChecks[r.index]) { + const check = section.guidelineChecks.find(c => c.title === relevantChecks[r.index].title) + if (check) { + check.status = r.result + if (r.reason) check.reason = r.reason + } + } + }) + } catch (e) { + console.error('解析检查结果失败:', e) + } + } + + console.log(`✅ 章节 "${section.title}" 规范检查完成`) + } catch (error) { + console.error('规范检查失败:', error) } } @@ -817,8 +858,6 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 return { // 状态 currentPage, - apiUrl, - apiKey, inputTask, inputType, outlinePoints, @@ -865,12 +904,7 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 mimicWriterState.value.styleAnalysis = '' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() await api.analyzeContent(mimicWriterState.value.sourceArticle, (content) => { mimicWriterState.value.styleAnalysis += content @@ -893,12 +927,7 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 mimicWriterState.value.thinkingContent = '' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const streamParser = createStreamParser() @@ -949,12 +978,7 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 mimicWriterState.value.isGenerating = true try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const streamParser = createStreamParser() @@ -1033,12 +1057,7 @@ ${isMaterialType && materialDataText ? '4. ⚠️ 特别注意:用户提供的 mimicWriterState.value.isAnalyzing = true try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const splitPrompt = `请分析以下文章的结构,将其拆分成语义完整的段落。 @@ -1117,12 +1136,7 @@ ${text} mimicWriterState.value.generatedContent = '' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() const { buildParagraphMimicPrompt } = await import('../utils/promptBuilder.js') @@ -1187,12 +1201,7 @@ ${text} articleFusionState.value.fusionResult = '' try { - const api = new DeepSeekAPI({ - url: apiUrl.value, - key: apiKey.value, - model: currentProvider.value.model, - appId: currentProvider.value.appId - }) + const api = createLlmClient() // 第一阶段:分析优劣 const analysisPrompt = `你是一位专业的文章分析专家。请分析以下两篇关于相同主题的文章,比较它们的优缺点。 @@ -1299,6 +1308,324 @@ ${modeInstruction} await this.startArticleFusionAction({ titleA, titleB, articleA, articleB, fusionMode }) }, + // 提纲写作 + outlineWriterState, + parseOutlineAction: () => { + const raw = outlineWriterState.value.rawOutline + if (!raw.trim()) return + + const lines = raw.split('\n').filter(l => l.trim()) + const sections = [] + let currentStack = [{ children: sections }] + + // 智能检测大纲格式并解析 + const parseLevel = (line) => { + const trimmed = line.trimStart() + const indent = line.length - trimmed.length + + // 格式1: Markdown 标题 (# ## ###) + const mdMatch = trimmed.match(/^(#{1,3})\s*(.+)$/) + if (mdMatch) { + return { level: mdMatch[1].length, title: mdMatch[2].trim() } + } + + // 格式2: 中文数字一级标题 (一、 二、 三、) + const zhL1Match = trimmed.match(/^[一二三四五六七八九十]+[、..]\s*(.+)$/) + if (zhL1Match) { + return { level: 1, title: trimmed } + } + + // 格式3: 括号中文数字二级标题 ((一) (二) (一) (二)) + const zhParenMatch = trimmed.match(/^[((][一二三四五六七八九十]+[))]\s*(.+)$/) + if (zhParenMatch) { + return { level: 2, title: trimmed } + } + + // 格式4: 简单阿拉伯数字三级标题 (1. 2. 3. 4.) + // 注意:这是公文中常见的三级格式 + const simpleNumMatch = trimmed.match(/^(\d+)[.、.]\s*(.+)$/) + if (simpleNumMatch && !trimmed.match(/^\d+\.\d+/)) { + // 纯数字如 1. 2. 3. 是三级标题 + return { level: 3, title: trimmed } + } + + // 格式5: 阿拉伯数字多级 (1.1 1.1.1) + const numMatch = trimmed.match(/^(\d+(?:\.\d+)+)[.、.]?\s*(.+)$/) + if (numMatch) { + const dotCount = (numMatch[1].match(/\./g) || []).length + return { level: Math.min(dotCount + 2, 3), title: trimmed } // 1.1 = level 3 + } + + // 格式6: 缩进层级(Tab 或空格) + if (indent > 0 && trimmed.length > 0) { + // 每 2-4 个空格或 1 个 Tab 为一个层级 + const tabCount = (line.match(/\t/g) || []).length + const spaceLevel = Math.floor((indent - tabCount) / 2) + const level = Math.min(tabCount + spaceLevel + 1, 3) + return { level, title: trimmed } + } + + // 默认作为一级标题 + if (trimmed.length > 0) { + return { level: 1, title: trimmed } + } + + return null + } + + // 检测是否为标题行(返回 true 表示是标题) + const isHeadingLine = (line) => { + const trimmed = line.trimStart() + // Markdown 标题 + if (/^#{1,3}\s+.+$/.test(trimmed)) return true + // 中文数字一级 (一、 二、) + if (/^[一二三四五六七八九十]+[、..]\s*.+$/.test(trimmed)) return true + // 括号中文数字 ((一) (二)) + if (/^[((][一二三四五六七八九十]+[))]\s*.+$/.test(trimmed)) return true + // 阿拉伯数字 (1. 1.1) + if (/^\d+[.、.]\s*.+$/.test(trimmed)) return true + if (/^\d+(\.\d+)+[.、.]?\s*.+$/.test(trimmed)) return true + return false + } + + let lastSection = null + + lines.forEach(line => { + const trimmed = line.trim() + if (!trimmed) return + + // 判断是否为标题行 + if (isHeadingLine(line)) { + const parsed = parseLevel(line) + if (!parsed) return + + const section = { + level: parsed.level, + title: parsed.title, + hint: '', + content: '', + isGenerating: false, + linkedMaterials: [], // 关联的素材 ID + children: [] + } + + // 找到正确的父级 + while (currentStack.length > parsed.level) { + currentStack.pop() + } + + currentStack[currentStack.length - 1].children.push(section) + currentStack.push(section) + lastSection = section + } else { + // 非标题行 → 作为上一个章节的 hint + if (lastSection) { + // 累加到 hint(支持多行说明) + if (lastSection.hint) { + lastSection.hint += '\n' + trimmed + } else { + lastSection.hint = trimmed + } + } + } + }) + + outlineWriterState.value.parsedSections = sections + outlineWriterState.value.currentSectionIndex = -1 + console.log('✅ 解析大纲完成:', sections.length, '个顶级章节') + }, + + generateOutlineSectionAction: async (flatIndex) => { + // 获取扁平化的章节列表 + const flatSections = [] + const flatten = (sections) => { + sections.forEach(s => { + flatSections.push(s) + if (s.children && s.children.length > 0) { + flatten(s.children) + } + }) + } + flatten(outlineWriterState.value.parsedSections) + + const section = flatSections[flatIndex] + if (!section) return + + section.isGenerating = true + outlineWriterState.value.isGenerating = true + + try { + const api = createLlmClient() + + // 收集已生成的前文作为上下文 + const previousContent = flatSections + .slice(0, flatIndex) + .filter(s => s.content) + .map(s => `## ${s.title}\n${s.content}`) + .join('\n\n') + .slice(-2000) // 限制上下文长度 + + // 获取关联的素材内容 + const linkedMaterials = (section.linkedMaterials || []) + .map(matId => outlineWriterState.value.materials.find(m => m.id === matId)) + .filter(m => m) + + const linkedMaterialsContent = linkedMaterials + .map(m => `【${m.name}】\n${m.content}`) + .join('\n\n---\n\n') + + // 调试日志 + console.log('📎 章节:', section.title) + console.log('📎 关联素材 IDs:', section.linkedMaterials) + console.log('📎 素材池:', outlineWriterState.value.materials.map(m => ({ id: m.id, name: m.name, hasContent: !!m.content, contentLength: m.content?.length }))) + console.log('📎 匹配到的素材:', linkedMaterials.map(m => ({ name: m.name, contentLength: m.content?.length }))) + console.log('📎 素材内容前100字:', linkedMaterialsContent.slice(0, 100)) + + // 构建强调素材引用的提示词 + const materialInstruction = linkedMaterialsContent + ? `\n\n【重要】请务必在撰写时: +1. 直接引用和融入上述【参考素材】中的具体内容 +2. 可以直接使用素材中的数据、案例、表述 +3. 如果素材中有相关的政策要求、工作成果,必须体现在文中` + : '' + + const prompt = `你正在撰写一篇文章。 + +${outlineWriterState.value.writingContext ? `【写作背景】\n${outlineWriterState.value.writingContext}\n\n` : ''}${linkedMaterialsContent ? `【参考素材 - 必须引用】\n${linkedMaterialsContent}\n\n` : ''}${previousContent ? `【已完成的前文】\n${previousContent}\n\n---\n\n` : ''}【当前任务】 +请撰写章节:${section.title} +层级:H${section.level} +${section.hint ? `\n【写作提示】\n${section.hint}` : ''}${materialInstruction} + +【要求】 +1. ${linkedMaterialsContent ? '必须充分引用【参考素材】中的具体内容、数据和表述' : '根据主题展开论述'} +2. 内容要与前文衔接自然,避免重复 +3. 不要输出标题,只输出该章节的正文内容 +4. 语言流畅,论述清晰 +5. 根据层级控制篇幅(H1 较长,H3 较短) + +开始撰写:` + + let responseText = '' + outlineWriterState.value.shouldAbort = false + + await api._streamRequest([ + { role: 'system', content: '你是一位专业的写作助手,擅长根据大纲展开撰写高质量的内容。' }, + { role: 'user', content: prompt } + ], { temperature: 0.7, max_tokens: 2000 }, (chunk) => { + // 检查是否应该中断 + if (outlineWriterState.value.shouldAbort) { + throw new Error('用户已中断生成') + } + responseText += chunk + section.content = responseText.trim() + }) + + console.log(`✅ 章节 "${section.title}" 生成完成`) + } catch (error) { + if (error.message === '用户已中断生成') { + console.log(`⏹️ 章节 "${section.title}" 生成已中断`) + section.content = section.content || '[已中断]' + } else { + console.error('生成失败:', error) + section.content = `[生成失败: ${error.message}]` + } + } finally { + section.isGenerating = false + outlineWriterState.value.isGenerating = false + outlineWriterState.value.shouldAbort = false + } + }, + + generateAllOutlineSectionsAction: async () => { + const flatSections = [] + const flatten = (sections) => { + sections.forEach(s => { + flatSections.push(s) + if (s.children && s.children.length > 0) { + flatten(s.children) + } + }) + } + flatten(outlineWriterState.value.parsedSections) + + if (flatSections.length === 0) return + + outlineWriterState.value.isGenerating = true + outlineWriterState.value.shouldAbort = false + + try { + for (let i = 0; i < flatSections.length; i++) { + // 检查是否应该中断 + if (outlineWriterState.value.shouldAbort) { + console.log('⏹️ 全文生成已中断') + break + } + + outlineWriterState.value.currentSectionIndex = i + // 调用单节生成(需要使用 appStore 的方法) + const section = flatSections[i] + section.isGenerating = true + + try { + const api = createLlmClient() + + const previousContent = flatSections + .slice(0, i) + .filter(s => s.content) + .map(s => `## ${s.title}\n${s.content}`) + .join('\n\n') + .slice(-2000) + + const prompt = `你正在撰写一篇文章。 + +${outlineWriterState.value.writingContext ? `【写作背景】\n${outlineWriterState.value.writingContext}\n\n` : ''}${previousContent ? `【已完成的前文】\n${previousContent}\n\n---\n\n` : ''}【当前任务】 +请撰写章节:${section.title} +层级:H${section.level} +${section.hint ? `\n【写作提示】\n${section.hint}` : ''} + +【要求】 +1. 内容要与前文衔接自然,避免重复 +2. 不要输出标题,只输出该章节的正文内容 +3. 语言流畅,论述清晰 + +开始撰写:` + + let responseText = '' + await api._streamRequest([ + { role: 'system', content: '你是一位专业的写作助手。' }, + { role: 'user', content: prompt } + ], { temperature: 0.7, max_tokens: 2000 }, (chunk) => { + // 检查是否应该中断 + if (outlineWriterState.value.shouldAbort) { + throw new Error('用户已中断生成') + } + responseText += chunk + section.content = responseText.trim() + }) + } catch (error) { + if (error.message === '用户已中断生成') { + section.content = section.content || '[已中断]' + break + } + section.content = `[生成失败: ${error.message}]` + } finally { + section.isGenerating = false + } + } + console.log('✅ 全文生成完成') + } finally { + outlineWriterState.value.isGenerating = false + outlineWriterState.value.currentSectionIndex = -1 + outlineWriterState.value.shouldAbort = false + } + }, + + // 中断生成 + abortOutlineGenerationAction: () => { + outlineWriterState.value.shouldAbort = true + console.log('🛑 请求中断生成...') + }, + // 方法 switchPage, setCurrentPage, diff --git a/src/stores/database.js b/src/stores/database.js index c5bc295..558e2ac 100644 --- a/src/stores/database.js +++ b/src/stores/database.js @@ -7,8 +7,6 @@ import { ref, computed } from 'vue' import { initDatabase, getAllReferences, - getReferenceById as dbGetReferenceById, - getReferencesByType as dbGetReferencesByType, addReference as dbAddReference, updateReference as dbUpdateReference, deleteReference as dbDeleteReference, @@ -72,11 +70,11 @@ export const useDatabaseStore = defineStore('database', () => { // ============================================ const refreshReferences = async () => { - references.value = getAllReferences() + references.value = await getAllReferences() } const getReferenceById = (id) => { - return references.value.find(r => r.id === id) || dbGetReferenceById(id) + return references.value.find(r => r.id === id) || null } const getReferencesByType = (type) => { @@ -105,18 +103,18 @@ export const useDatabaseStore = defineStore('database', () => { } const addReference = async (reference) => { - const id = dbAddReference(reference) + const id = await dbAddReference(reference) await refreshReferences() return id } const updateReference = async (id, updates) => { - dbUpdateReference(id, updates) + await dbUpdateReference(id, updates) await refreshReferences() } const deleteReference = async (id) => { - dbDeleteReference(id) + await dbDeleteReference(id) await refreshReferences() } @@ -125,7 +123,7 @@ export const useDatabaseStore = defineStore('database', () => { // ============================================ const refreshParadigms = async () => { - paradigms.value = getAllParadigms() + paradigms.value = await getAllParadigms() } const getParadigmById = (id) => { @@ -133,18 +131,18 @@ export const useDatabaseStore = defineStore('database', () => { } const addParadigm = async (paradigm) => { - const id = dbAddParadigm(paradigm) + const id = await dbAddParadigm(paradigm) await refreshParadigms() return id } const updateParadigm = async (id, updates) => { - dbUpdateParadigm(id, updates) + await dbUpdateParadigm(id, updates) await refreshParadigms() } const deleteParadigm = async (id) => { - dbDeleteParadigm(id) + await dbDeleteParadigm(id) await refreshParadigms() } @@ -152,11 +150,11 @@ export const useDatabaseStore = defineStore('database', () => { // 配置操作 // ============================================ - const getUserConfig = (key, defaultValue = null) => { + const getUserConfig = async (key, defaultValue = null) => { return getConfig(key, defaultValue) } - const setUserConfig = (key, value) => { + const setUserConfig = async (key, value) => { return setConfig(key, value) } @@ -164,7 +162,7 @@ export const useDatabaseStore = defineStore('database', () => { // 数据导入导出 // ============================================ - const exportData = () => { + const exportData = async () => { return exportAsJSON() } diff --git a/src/stores/paradigm.js b/src/stores/paradigm.js index c579881..a133eb1 100644 --- a/src/stores/paradigm.js +++ b/src/stores/paradigm.js @@ -2,7 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' // API 基础 URL -const API_BASE = 'http://localhost:3001/api' +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:3001/api' /** * 自定义范式管理 Store diff --git a/src/utils/config.js b/src/utils/config.js index 88c54ea..3291484 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -1,52 +1,45 @@ -// 环境变量配置 -// 模型服务商配置列表 +// 模型服务商配置列表(服务端托管) export const modelProviders = { deepseek: { id: 'deepseek', name: 'DeepSeek', description: '深度求索', - apiUrl: import.meta.env.VITE_DEEPSEEK_API_URL || 'https://api.deepseek.com/chat/completions', - apiKey: import.meta.env.VITE_DEEPSEEK_API_KEY || '', - model: import.meta.env.VITE_DEEPSEEK_MODEL || 'deepseek-chat' + model: import.meta.env.VITE_DEEPSEEK_MODEL || 'deepseek-chat', + serverManaged: true }, qianfan: { id: 'qianfan', name: '千帆大模型', description: '百度文心一言', - apiUrl: import.meta.env.VITE_QIANFAN_API_URL || 'https://qianfan.baidubce.com/v2/chat/completions', - apiKey: import.meta.env.VITE_QIANFAN_API_KEY || '', - appId: import.meta.env.VITE_QIANFAN_APP_ID || '', - model: import.meta.env.VITE_QIANFAN_MODEL || 'ernie-4.0-8k' + model: import.meta.env.VITE_QIANFAN_MODEL || 'ernie-4.0-8k', + serverManaged: true }, openai: { id: 'openai', name: 'OpenAI', description: 'GPT 系列', - apiUrl: import.meta.env.VITE_OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions', - apiKey: import.meta.env.VITE_OPENAI_API_KEY || '', - model: import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o' + model: import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o', + serverManaged: true }, claude: { id: 'claude', name: 'Claude', description: 'Anthropic Claude', - apiUrl: import.meta.env.VITE_CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', - apiKey: import.meta.env.VITE_CLAUDE_API_KEY || '', - model: import.meta.env.VITE_CLAUDE_MODEL || 'claude-3-5-sonnet' + model: import.meta.env.VITE_CLAUDE_MODEL || 'claude-3-5-sonnet', + serverManaged: true }, custom: { id: 'custom', name: '自定义', description: '自定义 API 端点', - apiUrl: import.meta.env.VITE_CUSTOM_API_URL || '', - apiKey: import.meta.env.VITE_CUSTOM_API_KEY || '', - model: import.meta.env.VITE_CUSTOM_MODEL || '' + model: import.meta.env.VITE_CUSTOM_MODEL || '', + serverManaged: true } } -// 获取已配置的服务商列表(有 API Key 的) +// 服务端托管模式下,前端仅展示所有服务商 export const getConfiguredProviders = () => { - return Object.values(modelProviders).filter(p => p.apiKey && p.apiKey.length > 0) + return Object.values(modelProviders) } // 获取默认服务商 @@ -55,32 +48,16 @@ export const getDefaultProvider = () => { return configured.length > 0 ? configured[0] : modelProviders.deepseek } -// 兼容旧版配置 +// 应用配置 export const config = { - // 向后兼容:使用第一个已配置的服务商 - get apiUrl() { - return getDefaultProvider().apiUrl - }, - get apiKey() { - return getDefaultProvider().apiKey - }, - - // 应用配置 appVersion: '1.0.0', isDev: import.meta.env.DEV, mode: import.meta.env.MODE } -// 验证必需的环境变量 +// 验证配置(服务端托管时仅返回空错误) export const validateConfig = () => { - const errors = [] - const configured = getConfiguredProviders() - - if (configured.length === 0) { - errors.push('未配置任何模型服务商的 API Key') - } - - return errors + return [] } // 获取配置摘要(用于调试) diff --git a/src/utils/requirementParser.js b/src/utils/requirementParser.js index 5264e83..d9dad49 100644 --- a/src/utils/requirementParser.js +++ b/src/utils/requirementParser.js @@ -10,32 +10,37 @@ export function buildRequirementParserPrompt(requirementText) { return `你是一位专业的文档分析专家,擅长提取文档中的核心要求并将其转化为结构化的写作范式配置。 【任务】 -分析以下需求文档,提取关键要求并生成"范式配置",用于指导AI检查和润色文稿。 +分析以下需求文档,提取关键要求并生成"范式配置",用于指导AI写作和检查文稿。 【需求文档】 ${requirementText} 【输出要求】 -请生成以下三个部分,使用JSON格式输出: +请生成以下四个部分,使用JSON格式输出: 1. **specializedPrompt** (string): 系统提示词,包含: - 会议主题/文档目标 - 核心要求(列表形式) - 论述规范(结构要求、术语规范、语气要求等) - - 维度要求(如五维度溯源等) 长度:300-500字 -2. **expertGuidelines** (array): 专家检查指令,每条指令应该: - - 针对需求文档中的具体要求 - - 可直接用于检查文稿是否符合标准 - - 清晰、可执行 - - **包含 scope 字段**(指定检查粒度): - * "sentence" - 句子级检查(如术语规范、语气分寸、表达方式) - * "paragraph" - 段落级检查(如逻辑结构、递进关系、论述层次) - * "document" - 全文级检查(如章节完整性、篇幅占比、结构要求) - 数量:8-12条 +2. **sections** (array): 文章章节大纲,每个章节是实际需要撰写的内容部分: + - title (string): 章节标题(如"开篇引言"、"问题查摆"、"整改措施") + - description (string): 该章节应包含的内容说明 + - weight (number): 该章节在全文中的建议篇幅占比(0-100,所有章节总和=100) + 数量:3-6个章节 -3. **metadata** (object): 元数据,包含: +3. **expertGuidelines** (array): 专家检查指令(用于检验生成内容是否合规,不是章节): + - title (string): 检查项名称(如"五维度覆盖"、"术语规范") + - description (string): 具体检查标准 + - scope (string): 检查粒度 - "sentence" | "paragraph" | "document" + 数量:8-12条 + + **重要**:expertGuidelines 是检查规范,不是章节标题! + - 正确示例:"五维度覆盖" → 检查文稿是否涵盖五个维度 + - 错误示例:把"问题查摆"作为 expertGuideline(这应该放在 sections) + +4. **metadata** (object): 元数据,包含: - name (string): 范式名称(简短,10字以内) - description (string): 范式描述(30字以内) - keyRequirements (array): 核心要求关键词(3-5个) @@ -44,11 +49,15 @@ ${requirementText} \`\`\`json { "specializedPrompt": "你是一位资深的...", + "sections": [ + {"title": "开篇引言", "description": "说明会议背景、主题和目的", "weight": 15}, + {"title": "问题查摆", "description": "从五个维度进行深入查摆分析", "weight": 60}, + {"title": "整改措施", "description": "针对问题提出具体整改方向", "weight": 25} + ], "expertGuidelines": [ - {"title": "党内术语规范", "description": "检查是否使用...", "scope": "sentence"}, - {"title": "递进式结构", "description": "检查段落是否符合...", "scope": "paragraph"}, - {"title": "章节完整性", "description": "检查是否包含会前准备...", "scope": "document"}, - ... + {"title": "五维度覆盖", "description": "检查问题查摆是否涵盖政治忠诚、党性修养等五个维度", "scope": "document"}, + {"title": "核心篇幅占比", "description": "检查问题查摆部分是否占全文60%以上", "scope": "document"}, + {"title": "党内术语规范", "description": "检查是否正确使用中央文件表述", "scope": "sentence"} ], "metadata": { "name": "范式名称", @@ -59,14 +68,12 @@ ${requirementText} \`\`\` 【注意事项】 -1. specializedPrompt 应该完整、系统,涵盖所有关键要求 -2. expertGuidelines 应该具体、可操作,每条针对一个检查点 +1. **sections 与 expertGuidelines 的区别**: + - sections = 需要撰写的章节(如"开篇引言"、"问题分析") + - expertGuidelines = 检验标准(如"篇幅占比"、"术语规范") +2. specializedPrompt 应该完整、系统,涵盖所有关键要求 3. 保留原文中的专业术语和标准表述 -4. 如果需求文档提到篇幅要求、格式要求等,务必在 specializedPrompt 中明确体现 -5. **scope 分配原则**: - - sentence 级:适用于任何句子片段(1-5句) - - paragraph 级:需要段落上下文(6-20句) - - document 级:需要完整文档或大段落(21+句) +4. scope 分配原则:sentence(1-5句)、paragraph(6-20句)、document(21+句) 请开始分析并生成范式配置。`; } @@ -100,15 +107,29 @@ export function parseParadigmConfig(aiResponse) { const config = JSON.parse(jsonText); // 验证必需字段 - if (!config.specializedPrompt || !Array.isArray(config.expertGuidelines) || !config.metadata) { + if (!config.specializedPrompt || !config.metadata) { console.error('配置缺少必需字段:', { hasPrompt: !!config.specializedPrompt, - hasGuidelines: Array.isArray(config.expertGuidelines), hasMetadata: !!config.metadata }); throw new Error('缺少必需字段'); } + // sections 可选,如果没有则生成默认章节 + if (!Array.isArray(config.sections) || config.sections.length === 0) { + console.warn('配置中缺少 sections,将使用默认章节'); + config.sections = [ + { title: '开篇引言', description: '文章背景与目的', weight: 20 }, + { title: '核心内容', description: '主要论述', weight: 60 }, + { title: '总结与展望', description: '总结与下一步', weight: 20 } + ]; + } + + // expertGuidelines 可选 + if (!Array.isArray(config.expertGuidelines)) { + config.expertGuidelines = []; + } + return config; } catch (error) { console.error('解析范式配置失败:', error); @@ -142,6 +163,7 @@ export function buildParadigmObject(parsedConfig, sourceDocPath = null) { tags: parsedConfig.metadata.keyRequirements || [], // 使用关键要求作为标签 specializedPrompt: parsedConfig.specializedPrompt, + sections: parsedConfig.sections || [], // 章节大纲 expertGuidelines: parsedConfig.expertGuidelines, // 可选:继承默认的逻辑范式和维度集