文章目录
后台一键优化文章功能实现
最近给博客后台加了个功能:写文章时,可以一键让 AI 优化文章,包括修正错别字、优化语句表达等。优化过程是流式显示的,优化完成后可以预览对比,支持全部替换或追加到末尾。
一、功能需求
- 后台写文章时,点击「AI 优化」按钮
- AI 流式优化文章,实时显示优化进度
- 优化完成后可以预览对比(原始内容 vs 优化后内容)
- 支持全部替换或追加到末尾(方便对比)
二、技术选型
- 后端:Spring Boot + OkHttp SSE + DeepSeek/硅基流动 API
- 前端:Vue 3 + TypeScript + Fetch API + ReadableStream
三、后端实现
1. 添加依赖
如果你的项目还没有 OkHttp SSE 依赖,需要在 pom.xml 中添加:
xml
<!-- OkHttp (用于 AI 流式请求) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- OkHttp SSE 支持 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-sse</artifactId>
<version>4.12.0</version>
</dependency>
2. 配置文件
在 application-dev.yml 中添加 AI 配置(如果还没有):
yaml
# AI 对话配置
ai:
deepseek:
api-key: ${AI_API_KEY:sk-你的API密钥}
api-url: ${AI_API_URL:https://api.siliconflow.cn/v1/chat/completions}
model: ${AI_MODEL:deepseek-ai/DeepSeek-V3-0324}
system-prompt: ${AI_SYSTEM_PROMPT:你是一个博客智能助手,帮助用户解答技术问题。请用简洁、专业的中文回答,支持 Markdown 格式。}
3. AI 服务类
在 AiService.java 中添加优化文章方法:
java
package com.ican.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ican.config.properties.DeepSeekProperties;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okhttp3.sse.EventSource;
import okhttp3.sse.EventSourceListener;
import okhttp3.sse.EventSources;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class AiService {
@Autowired
private DeepSeekProperties deepSeekProperties;
private final OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
/**
* 一键优化文章(流式输出)
*
* @param content 文章内容
* @return SseEmitter 用于向前端推送流式数据
*/
public SseEmitter optimizeArticle(String content) {
// 截取前 5000 字,避免 token 过长
String truncated = content != null && content.length() > 5000 ? content.substring(0, 5000) : content;
JSONArray messagesArray = new JSONArray();
// 系统提示词
JSONObject systemMsg = new JSONObject();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一个专业的文章优化助手。请对用户提供的文章进行优化,包括:\n" +
"1. 修正错别字和语法错误\n" +
"2. 优化语句表达,使其更加流畅\n" +
"3. 保持原文的 Markdown 格式\n" +
"4. 保持原文的核心内容和结构不变\n" +
"5. 直接输出优化后的完整文章,不要加任何解释或前缀");
messagesArray.add(systemMsg);
// 用户消息
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", "请优化以下文章:\n\n" + truncated);
messagesArray.add(userMsg);
JSONObject requestBody = new JSONObject();
requestBody.put("model", deepSeekProperties.getModel());
requestBody.put("messages", messagesArray);
requestBody.put("stream", true); // 开启流式输出
requestBody.put("temperature", 0.5); // 平衡创造性和稳定性
requestBody.put("max_tokens", 4096); // 允许较长的输出
return doStreamCall(requestBody);
}
/**
* 通用流式 AI 调用
*
* @param requestBody 请求体
* @return SseEmitter 用于向前端推送流式数据
*/
private SseEmitter doStreamCall(JSONObject requestBody) {
SseEmitter emitter = new SseEmitter(180_000L); // 3分钟超时
Request request = new Request.Builder()
.url(deepSeekProperties.getApiUrl())
.addHeader("Authorization", "Bearer " + deepSeekProperties.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(requestBody.toJSONString(), MediaType.parse("application/json")))
.build();
EventSource.Factory factory = EventSources.createFactory(httpClient);
factory.newEventSource(request, new EventSourceListener() {
@Override
public void onEvent(EventSource eventSource, String id, String type, String data) {
try {
if ("[DONE]".equals(data)) {
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
return;
}
// 解析 AI 返回的 SSE 数据
JSONObject jsonData = JSON.parseObject(data);
JSONArray choices = jsonData.getJSONArray("choices");
if (choices != null && !choices.isEmpty()) {
JSONObject delta = choices.getJSONObject(0).getJSONObject("delta");
if (delta != null && delta.containsKey("content")) {
String content = delta.getString("content");
if (content != null) {
// 用 JSON 包装内容,避免换行符被 SSE 拆成多行导致前端丢失
JSONObject chunk = new JSONObject();
chunk.put("content", content);
emitter.send(SseEmitter.event().data(chunk.toJSONString()));
}
}
}
} catch (IOException e) {
log.error("SSE 发送失败", e);
emitter.completeWithError(e);
}
}
@Override
public void onFailure(EventSource eventSource, Throwable t, Response response) {
String errorMsg = "AI 服务调用失败";
if (response != null) {
try {
errorMsg = "AI 服务返回错误: " + response.code() + " " + (response.body() != null ? response.body().string() : "");
} catch (Exception e) {
errorMsg = "AI 服务返回错误: " + response.code();
}
}
log.error(errorMsg, t);
try {
emitter.send(SseEmitter.event().data("[ERROR] " + errorMsg));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onClosed(EventSource eventSource) {
// 连接关闭
}
});
emitter.onTimeout(() -> {
log.warn("SSE 连接超时");
emitter.complete();
});
return emitter;
}
}
关键点说明:
stream: true:开启流式输出,实时返回优化内容temperature: 0.5:平衡创造性和稳定性max_tokens: 4096:允许较长的输出- 截取前 5000 字:避免文章太长导致 token 超限
- 用 JSON 包装内容:避免换行符被 SSE 拆行
4. 控制器
在 AiController.java 中添加接口:
java
package com.ican.controller;
import com.ican.service.AiService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
/**
* AI 对话控制器
*
* @author ican
*/
@Api(tags = "AI 模块")
@RestController
public class AiController {
@Autowired
private AiService aiService;
/**
* AI 一键优化文章(流式)
*
* @param body 请求体,包含 content 字段(文章内容)
* @return SSE 事件流
*/
@ApiOperation(value = "AI 一键优化文章")
@PostMapping(value = "/admin/ai/optimize", produces = "text/event-stream;charset=UTF-8")
public SseEmitter optimizeArticle(@RequestBody Map<String, String> body) {
String content = body.get("content");
return aiService.optimizeArticle(content);
}
}
接口放在 /admin/ 路径下,需要登录才能访问(后台管理功能)。返回类型是 SseEmitter,用于流式传输。
四、前端实现
1. 添加优化对话框
在 views/blog/article/write.vue 中添加:
vue
<template>
<div class="app-container">
<!-- 文章标题和操作按钮 -->
<div class="operation-container">
<el-input v-model="articleForm.articleTitle" placeholder="请输入文章标题"></el-input>
<el-button type="warning" style="margin-left: 10px" @click="handleOptimize">
<el-icon style="margin-right: 4px"><MagicStick /></el-icon>AI 优化
</el-button>
<el-button type="danger" style="margin-left: 10px" @click="openModel">发布文章</el-button>
</div>
<!-- 文章内容编辑器 -->
<md-editor
ref="editorRef"
v-model="articleForm.articleContent"
:theme="isDark ? 'dark' : 'light'"
class="md-container"
:toolbars="toolbars"
@on-upload-img="uploadImg"
placeholder="请输入文章内容..."
>
<template #defToolbars>
<emoji-extension :on-insert="insert" />
</template>
</md-editor>
<!-- AI 优化对话框 -->
<el-dialog title="AI 一键优化文章" v-model="optimizeVisible" width="800px" top="2vh" append-to-body destroy-on-close>
<div class="optimize-container">
<div class="optimize-status" v-if="optimizeLoading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>AI 正在优化文章,请稍候...</span>
</div>
<div class="optimize-status optimize-done" v-else-if="optimizedContent">
<el-icon><CircleCheckFilled /></el-icon>
<span>优化完成!请选择替换方式</span>
</div>
<div class="optimize-preview" v-if="optimizedContent">
<el-tabs v-model="optimizeTab">
<el-tab-pane label="优化后内容" name="optimized">
<div class="preview-scroll">
<md-editor v-model="optimizedContent" :theme="isDark ? 'dark' : 'light'" preview-only />
</div>
</el-tab-pane>
<el-tab-pane label="原始内容" name="original">
<div class="preview-scroll">
<md-editor v-model="articleForm.articleContent" :theme="isDark ? 'dark' : 'light'" preview-only />
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<template #footer>
<el-button @click="optimizeVisible = false">取消</el-button>
<el-button type="primary" :disabled="!optimizedContent || optimizeLoading" @click="handleReplaceAll">
全部替换
</el-button>
<el-button type="success" :disabled="!optimizedContent || optimizeLoading" @click="handleAppendOptimized">
追加到末尾(对比)
</el-button>
</template>
</el-dialog>
<!-- 发布文章对话框 -->
<!-- ... 原有发布对话框 ... -->
</div>
</template>
<script setup lang="ts">
import { getToken, token_prefix } from "@/utils/token";
import { useDark } from "@vueuse/core";
import { ElMessage } from "element-plus";
import { MagicStick, Loading, CircleCheckFilled } from "@element-plus/icons-vue";
import MdEditor from "md-editor-v3";
import "md-editor-v3/lib/style.css";
const isDark = useDark();
// 文章表单数据
const articleForm = ref({
articleTitle: "",
articleContent: "",
// ... 其他字段
});
// AI 优化相关
const optimizeVisible = ref(false);
const optimizeLoading = ref(false);
const optimizedContent = ref("");
const optimizeTab = ref("optimized");
const handleOptimize = () => {
const content = articleForm.value.articleContent;
if (!content || content.trim() === "") {
ElMessage.warning("请先编写文章内容");
return;
}
optimizeVisible.value = true;
optimizeLoading.value = true;
optimizedContent.value = "";
optimizeTab.value = "optimized";
const headers: Record<string, string> = {
"Content-Type": "application/json;charset=UTF-8",
};
const token = getToken();
if (token) {
headers["Authorization"] = token_prefix + token;
}
fetch(`/api/admin/ai/optimize`, {
method: "POST",
headers,
body: JSON.stringify({ content }),
})
.then(async (response) => {
if (!response.ok) {
ElMessage.error("AI 优化请求失败");
optimizeLoading.value = false;
return;
}
const reader = response.body?.getReader();
if (!reader) {
optimizeLoading.value = false;
return;
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
optimizeLoading.value = false;
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("data:")) {
const sseData = trimmed.slice(5);
if (!sseData) continue;
if (sseData === "[DONE]") {
optimizeLoading.value = false;
return;
}
if (sseData.startsWith("[ERROR]")) {
ElMessage.error("AI 优化失败");
optimizeLoading.value = false;
return;
}
try {
const parsed = JSON.parse(sseData);
if (parsed && typeof parsed.content === "string") {
optimizedContent.value += parsed.content;
}
} catch {
optimizedContent.value += sseData;
}
}
}
}
})
.catch(() => {
ElMessage.error("AI 优化请求异常");
optimizeLoading.value = false;
});
};
const handleReplaceAll = () => {
articleForm.value.articleContent = optimizedContent.value;
optimizeVisible.value = false;
ElMessage.success("已替换为优化后的内容");
};
const handleAppendOptimized = () => {
articleForm.value.articleContent += "\n\n---\n\n## AI 优化版本\n\n" + optimizedContent.value;
optimizeVisible.value = false;
ElMessage.success("优化内容已追加到文章末尾");
};
</script>
<style scoped>
.operation-container {
display: flex;
align-items: center;
margin-bottom: 1.25rem;
}
.md-container {
min-height: 300px;
height: calc(100vh - 200px);
}
.optimize-container {
min-height: 200px;
}
.optimize-status {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #f0f9ff;
border-radius: 8px;
margin-bottom: 16px;
color: #409eff;
font-size: 14px;
}
.optimize-done {
background: #f0f9eb;
color: #67c23a;
}
.preview-scroll {
max-height: 50vh;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
}
</style>
五、使用效果
- 在后台写文章页面,写好文章内容
- 点击「AI 优化」按钮
- 弹出对话框,显示「AI 正在优化文章,请稍候...」
- 优化内容实时显示(流式)
- 优化完成后,可以切换到「原始内容」标签页对比
- 选择「全部替换」直接替换原文,或「追加到末尾」保留原文并追加优化版本
六、遇到的问题
1. SSE 数据解析
问题 :后端发送的 JSON 数据 {"content":"..."} 在前端解析时,换行符可能丢失。
解决 :后端用 JSON 包装内容,前端解析 JSON 提取 content,换行符完整保留。
2. 流式数据接收
问题 :fetch 接收 SSE 流时,需要逐行解析 data: 前缀的数据。
解决 :用 ReadableStream 读取,用 TextDecoder 解码,按 \n 分割后解析每行的 data: 前缀。
3. 预览对比
问题:需要同时显示原始内容和优化后内容,方便对比。
解决 :用 el-tabs 切换两个标签页,分别显示原始内容和优化后内容。
七、总结
这个功能实现起来不算复杂:
- 后端:新增一个流式接口,调用 AI API 优化文章,实时返回优化内容
- 前端 :用
fetch+ReadableStream接收流式数据,实时显示优化内容
关键是:
- 后端用 JSON 包装内容,避免换行符丢失
- 前端正确解析 SSE 格式的数据
- 提供预览对比功能,让用户决定是否替换
- 支持全部替换或追加到末尾,满足不同需求
现在写文章时,可以一键优化,修正错别字、优化语句表达,提升文章质量。如果优化不满意,也可以选择不替换,不影响原文。
八、完整代码文件清单
后端:
pom.xml- 添加 OkHttp 和 okhttp-sse 依赖application-dev.yml- 添加 AI 配置AiService.java- 添加optimizeArticle和doStreamCall方法AiController.java- 添加/admin/ai/optimize接口
前端:
views/blog/article/write.vue- 添加优化对话框和逻辑
按照上面的步骤,就可以在自己的项目中实现这个功能了。