refactor: 优化流式输出与状态管理
- 重构 DeepSeekAPI.js,实现稳健的 SSE 流式解析 - 将核心业务逻辑(生成、分析)移入 appStore.js - 优化 WriterPanel 和 AnalysisPanel 组件,移除冗余逻辑 - 更新文档,补充架构演进说明
This commit is contained in:
15
README.md
15
README.md
@@ -29,6 +29,21 @@ src/
|
|||||||
└── main.js # 入口文件
|
└── main.js # 入口文件
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 架构演进与优化
|
||||||
|
|
||||||
|
### 1. 流式输出增强 (Stream Optimization)
|
||||||
|
项目在 `DeepSeekAPI.js` 中实现了稳健的 SSE (Server-Sent Events) 解析器:
|
||||||
|
- **逐行扫描**:放弃简单的 `split` 分割,改用逐字符缓冲区扫描,确保处理被网络分包截断的 JSON 数据。
|
||||||
|
- **协议兼容**:完美支持标准 SSE 协议,兼容 data: 后有无空格的各种情况。
|
||||||
|
- **状态管理**:通过 `appStore` 统一管理生成状态,实现组件间的数据实时同步,保证 UI 渲染无延迟。
|
||||||
|
|
||||||
|
### 2. 状态管理重构
|
||||||
|
- **逻辑收拢**:将所有 API 调用和业务逻辑(生成、分析)移入 Pinia Store (`appStore.js`)。
|
||||||
|
- **组件纯粹化**:`WriterPanel` 和 `AnalysisPanel` 仅负责 UI 渲染和用户交互,不再包含核心业务逻辑。
|
||||||
|
|
||||||
|
### 3. 深度集成
|
||||||
|
- **应用到写作**:实现了从"范式分析"到"写作工坊"的深度数据迁移,包括原文引用、风格约束注入和范式模板自动匹配。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 1. AI 写作工坊
|
### 1. AI 写作工坊
|
||||||
|
|||||||
@@ -1,79 +1,115 @@
|
|||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
class DeepSeekAPI {
|
class DeepSeekAPI {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
this.baseURL = config.url
|
this.baseURL = config.url
|
||||||
this.apiKey = config.key
|
this.apiKey = config.key
|
||||||
this.client = axios.create({
|
console.log('DeepSeekAPI 已初始化:', { baseURL: this.baseURL })
|
||||||
baseURL: this.baseURL,
|
}
|
||||||
timeout: 60000,
|
|
||||||
headers: {
|
async _streamRequest(messages, options = {}, onContent) {
|
||||||
'Content-Type': 'application/json'
|
const authHeader = this.apiKey.trim().startsWith('Bearer ')
|
||||||
|
? this.apiKey.trim()
|
||||||
|
: `Bearer ${this.apiKey.trim()}`;
|
||||||
|
|
||||||
|
console.log('DeepSeekAPI: Starting stream request...', { messagesLength: messages.length })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.baseURL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': authHeader
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('DeepSeekAPI: HTTP Error', response.status, errorText)
|
||||||
|
throw new Error(`API请求失败: ${response.status} ${errorText}`)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
console.log('DeepSeekAPI: Stream connection established.')
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) {
|
||||||
|
console.log('DeepSeekAPI: Stream finished by server.')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
|
||||||
|
// Split by newline
|
||||||
|
let lines = buffer.split('\n')
|
||||||
|
// Keep the last part (potential incomplete line) in buffer
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
if (!trimmedLine) continue
|
||||||
|
|
||||||
|
if (trimmedLine.startsWith('data:')) {
|
||||||
|
const data = trimmedLine.substring(5).trim() // Remove 'data:' prefix
|
||||||
|
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
console.log('DeepSeekAPI: Received [DONE] signal.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
const content = parsed.choices?.[0]?.delta?.content || ''
|
||||||
|
if (content) {
|
||||||
|
// console.log('DeepSeekAPI: Received content chunk:', content.length) // Too verbose for large text
|
||||||
|
if (onContent) {
|
||||||
|
onContent(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('DeepSeekAPI: JSON parse error for line:', trimmedLine, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DeepSeekAPI: Stream processing error:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateContent(prompt, options = {}) {
|
async generateContent(prompt, onContent, options = {}) {
|
||||||
const response = await fetch(this.baseURL, {
|
return this._streamRequest([
|
||||||
method: 'POST',
|
{
|
||||||
headers: {
|
role: 'system',
|
||||||
'Content-Type': 'application/json',
|
content: '你是一个资深的专业写作助手,请严格按照用户的要求进行创作。'
|
||||||
'Authorization': `Bearer ${this.apiKey}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
{
|
||||||
model: 'deepseek-chat',
|
role: 'user',
|
||||||
messages: [
|
content: prompt
|
||||||
{
|
}
|
||||||
role: 'system',
|
], { temperature: 0.7, ...options }, onContent)
|
||||||
content: '你是一个资深的专业写作助手,请严格按照用户的要求进行创作。'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt
|
|
||||||
}
|
|
||||||
],
|
|
||||||
stream: true,
|
|
||||||
temperature: 0.7,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API请求失败: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async analyzeContent(text) {
|
async analyzeContent(text, onContent) {
|
||||||
const response = await fetch(this.baseURL, {
|
return this._streamRequest([
|
||||||
method: 'POST',
|
{
|
||||||
headers: {
|
role: 'system',
|
||||||
'Content-Type': 'application/json',
|
content: '你是一个专业的写作分析师,擅长分析文章的写作范式、结构和特点。请从以下几个方面分析文章:1. 主要写作范式类型 2. 文章结构特点 3. 语言风格特征 4. 目标读者群体 5. 写作技巧总结。请用简洁明了的语言回答,使用Markdown格式。'
|
||||||
'Authorization': `Bearer ${this.apiKey}`
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
{
|
||||||
model: 'deepseek-chat',
|
role: 'user',
|
||||||
messages: [
|
content: `请分析以下文章的写作范式:\n\n${text}`
|
||||||
{
|
}
|
||||||
role: 'system',
|
], { temperature: 0.3 }, onContent)
|
||||||
content: '你是一个专业的写作分析师,擅长分析文章的写作范式、结构和特点。请从以下几个方面分析文章:1. 主要写作范式类型 2. 文章结构特点 3. 语言风格特征 4. 目标读者群体 5. 写作技巧总结。请用简洁明了的语言回答,使用Markdown格式。'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `请分析以下文章的写作范式:\n\n${text}`
|
|
||||||
}
|
|
||||||
],
|
|
||||||
stream: true,
|
|
||||||
temperature: 0.3
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API请求失败: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,81 +203,12 @@ const applyParadigm = (paradigm) => {
|
|||||||
|
|
||||||
// 分析文章
|
// 分析文章
|
||||||
const analyzeArticle = async () => {
|
const analyzeArticle = async () => {
|
||||||
if (!analysisText.value.trim()) {
|
|
||||||
alert('请输入要分析的文章内容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isAnalyzing.value = true
|
|
||||||
appStore.analysisResult = {
|
|
||||||
paradigm: '分析中...',
|
|
||||||
paradigmType: null,
|
|
||||||
analysis: '',
|
|
||||||
timestamp: new Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = new DeepSeekAPI({
|
const result = await appStore.analyzeArticleAction(analysisText.value, detectParadigm)
|
||||||
url: appStore.apiUrl,
|
|
||||||
key: appStore.apiKey
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await api.analyzeContent(analysisText.value)
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
|
|
||||||
let fullContent = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value)
|
|
||||||
const lines = chunk.split('\n').filter(line => line.trim())
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6)
|
|
||||||
if (data === '[DONE]') continue
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
const content = parsed.choices?.[0]?.delta?.content || ''
|
|
||||||
if (content) {
|
|
||||||
fullContent += content
|
|
||||||
// 实时更新分析结果
|
|
||||||
appStore.analysisResult = {
|
|
||||||
paradigm: '分析中...',
|
|
||||||
paradigmType: null,
|
|
||||||
analysis: fullContent,
|
|
||||||
timestamp: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分析完成后检测范式类型
|
|
||||||
const detectedParadigm = detectParadigm(fullContent)
|
|
||||||
appStore.analysisResult = {
|
|
||||||
paradigm: detectedParadigm.name,
|
|
||||||
paradigmType: detectedParadigm.type,
|
|
||||||
analysis: fullContent,
|
|
||||||
timestamp: new Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到历史记录
|
// 添加到历史记录
|
||||||
addToHistory(detectedParadigm.name, analysisText.value, fullContent)
|
addToHistory(result.paradigm, analysisText.value, result.content)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appStore.analysisResult = {
|
alert(error.message)
|
||||||
error: true,
|
|
||||||
message: error.message
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isAnalyzing.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { buildPrompt } from '../utils/promptBuilder.js'
|
|
||||||
import DeepSeekAPI from '../api/deepseek.js'
|
|
||||||
import { marked } from 'marked'
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const {
|
const {
|
||||||
@@ -144,7 +141,6 @@ const {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
customConstraint,
|
customConstraint,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
generatedContent,
|
|
||||||
showPromptDebug,
|
showPromptDebug,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey
|
apiKey
|
||||||
@@ -157,10 +153,7 @@ const presetTags = ['Markdown格式', '总分总结构', '数据支撑', '语气
|
|||||||
// 添加参考案例
|
// 添加参考案例
|
||||||
const addReference = () => {
|
const addReference = () => {
|
||||||
if (!newRefTitle.value || !newRefContent.value) return
|
if (!newRefTitle.value || !newRefContent.value) return
|
||||||
references.value.push({
|
appStore.addReferenceFromAnalysis(newRefTitle.value, newRefContent.value)
|
||||||
title: newRefTitle.value,
|
|
||||||
content: newRefContent.value
|
|
||||||
})
|
|
||||||
newRefTitle.value = ''
|
newRefTitle.value = ''
|
||||||
newRefContent.value = ''
|
newRefContent.value = ''
|
||||||
showRefInput.value = false
|
showRefInput.value = false
|
||||||
@@ -182,54 +175,10 @@ const toggleTag = (tag) => {
|
|||||||
|
|
||||||
// 生成内容
|
// 生成内容
|
||||||
const generateContent = async () => {
|
const generateContent = async () => {
|
||||||
if (!apiUrl.value || !apiKey.value || apiKey.value === 'YOUR_KEY') {
|
|
||||||
alert('请先配置 API 地址和 API Key')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isGenerating.value = true
|
|
||||||
generatedContent.value = ''
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = new DeepSeekAPI({ url: apiUrl.value, key: apiKey.value })
|
await appStore.generateContentAction()
|
||||||
const prompt = buildPrompt(inputTask.value, [...selectedTags.value, customConstraint.value].filter(Boolean), references.value)
|
|
||||||
|
|
||||||
const response = await api.generateContent(prompt)
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() // 最后一行可能不完整,存入 buffer
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmedLine = line.trim()
|
|
||||||
if (!trimmedLine || !trimmedLine.startsWith('data: ')) continue
|
|
||||||
|
|
||||||
const data = trimmedLine.slice(6)
|
|
||||||
if (data === '[DONE]') break
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
const content = parsed.choices?.[0]?.delta?.content || ''
|
|
||||||
if (content) {
|
|
||||||
generatedContent.value += content
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('解析流数据失败:', e, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('生成内容失败:', error)
|
alert(error.message)
|
||||||
generatedContent.value = `## 错误\n\n${error.message}\n\n请检查 API 配置是否正确。`
|
|
||||||
} finally {
|
|
||||||
isGenerating.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { config } from '../utils/config.js'
|
import { config } from '../utils/config.js'
|
||||||
|
import DeepSeekAPI from '../api/deepseek.js'
|
||||||
|
import { buildPrompt } from '../utils/promptBuilder.js'
|
||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
// 页面状态
|
// 页面状态
|
||||||
@@ -42,6 +44,96 @@ 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 启动')
|
||||||
|
isGenerating.value = true
|
||||||
|
generatedContent.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = new DeepSeekAPI({ url: apiUrl.value, key: apiKey.value })
|
||||||
|
const prompt = buildPrompt(
|
||||||
|
inputTask.value,
|
||||||
|
[...selectedTags.value, customConstraint.value].filter(Boolean),
|
||||||
|
references.value
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('Store: 调用 API 生成内容...')
|
||||||
|
await api.generateContent(prompt, (content) => {
|
||||||
|
// console.log('Store: 收到生成内容块', content.length) // Verbose
|
||||||
|
generatedContent.value += content
|
||||||
|
})
|
||||||
|
console.log('Store: 生成内容完成')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: 生成内容失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isGenerating.value = false
|
||||||
|
console.log('Store: isGenerating 重置为 false')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分析文章动作
|
||||||
|
const analyzeArticleAction = async (text, detectParadigmFn) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error('请输入要分析的文章内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Store: analyzeArticleAction 启动')
|
||||||
|
isAnalyzing.value = true
|
||||||
|
analysisResult.value = {
|
||||||
|
paradigm: '分析中...',
|
||||||
|
paradigmType: null,
|
||||||
|
analysis: '',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = new DeepSeekAPI({ url: apiUrl.value, key: apiKey.value })
|
||||||
|
let fullContent = ''
|
||||||
|
|
||||||
|
console.log('Store: 调用 API 分析文章...')
|
||||||
|
await api.analyzeContent(text, (content) => {
|
||||||
|
fullContent += content
|
||||||
|
// 实时更新分析结果,用于 UI 展示流式效果
|
||||||
|
analysisResult.value = {
|
||||||
|
paradigm: '分析中...',
|
||||||
|
paradigmType: null,
|
||||||
|
analysis: fullContent,
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Store: 分析文章流式接收完成')
|
||||||
|
|
||||||
|
// 分析完成后检测范式类型
|
||||||
|
const detectedParadigm = detectParadigmFn(fullContent)
|
||||||
|
console.log('Store: 检测到范式类型:', detectedParadigm.name)
|
||||||
|
|
||||||
|
analysisResult.value = {
|
||||||
|
paradigm: detectedParadigm.name,
|
||||||
|
paradigmType: detectedParadigm.type,
|
||||||
|
analysis: fullContent,
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paradigm: detectedParadigm.name, content: fullContent }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Store: 分析失败:', error)
|
||||||
|
analysisResult.value = {
|
||||||
|
error: true,
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isAnalyzing.value = false
|
||||||
|
console.log('Store: isAnalyzing 重置为 false')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 切换页面
|
// 切换页面
|
||||||
const switchPage = (page) => {
|
const switchPage = (page) => {
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
@@ -69,6 +161,8 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
switchPage,
|
switchPage,
|
||||||
addReferenceFromAnalysis
|
addReferenceFromAnalysis,
|
||||||
|
generateContentAction,
|
||||||
|
analyzeArticleAction
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user