后台一键优化文章功能实现,接入deepseek

文章目录

后台一键优化文章功能实现

最近给博客后台加了个功能:写文章时,可以一键让 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>

五、使用效果

  1. 在后台写文章页面,写好文章内容
  2. 点击「AI 优化」按钮
  3. 弹出对话框,显示「AI 正在优化文章,请稍候...」
  4. 优化内容实时显示(流式)
  5. 优化完成后,可以切换到「原始内容」标签页对比
  6. 选择「全部替换」直接替换原文,或「追加到末尾」保留原文并追加优化版本

六、遇到的问题

1. SSE 数据解析

问题 :后端发送的 JSON 数据 {"content":"..."} 在前端解析时,换行符可能丢失。

解决 :后端用 JSON 包装内容,前端解析 JSON 提取 content,换行符完整保留。

2. 流式数据接收

问题fetch 接收 SSE 流时,需要逐行解析 data: 前缀的数据。

解决 :用 ReadableStream 读取,用 TextDecoder 解码,按 \n 分割后解析每行的 data: 前缀。

3. 预览对比

问题:需要同时显示原始内容和优化后内容,方便对比。

解决 :用 el-tabs 切换两个标签页,分别显示原始内容和优化后内容。

七、总结

这个功能实现起来不算复杂:

  1. 后端:新增一个流式接口,调用 AI API 优化文章,实时返回优化内容
  2. 前端 :用 fetch + ReadableStream 接收流式数据,实时显示优化内容

关键是:

  • 后端用 JSON 包装内容,避免换行符丢失
  • 前端正确解析 SSE 格式的数据
  • 提供预览对比功能,让用户决定是否替换
  • 支持全部替换或追加到末尾,满足不同需求

现在写文章时,可以一键优化,修正错别字、优化语句表达,提升文章质量。如果优化不满意,也可以选择不替换,不影响原文。

八、完整代码文件清单

后端

  • pom.xml - 添加 OkHttp 和 okhttp-sse 依赖
  • application-dev.yml - 添加 AI 配置
  • AiService.java - 添加 optimizeArticledoStreamCall 方法
  • AiController.java - 添加 /admin/ai/optimize 接口

前端

  • views/blog/article/write.vue - 添加优化对话框和逻辑

按照上面的步骤,就可以在自己的项目中实现这个功能了。

相关推荐
九.九11 小时前
ops-transformer:AI 处理器上的高性能 Transformer 算子库
人工智能·深度学习·transformer
春日见11 小时前
拉取与合并:如何让个人分支既包含你昨天的修改,也包含 develop 最新更新
大数据·人工智能·深度学习·elasticsearch·搜索引擎
小高不会迪斯科11 小时前
CMU 15445学习心得(二) 内存管理及数据移动--数据库系统如何玩转内存
数据库·oracle
恋猫de小郭11 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
deephub12 小时前
Agent Lightning:微软开源的框架无关 Agent 训练方案,LangChain/AutoGen 都能用
人工智能·microsoft·langchain·大语言模型·agent·强化学习
e***89012 小时前
MySQL 8.0版本JDBC驱动Jar包
数据库·mysql·jar
l1t12 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
大模型RAG和Agent技术实践12 小时前
从零构建本地AI合同审查系统:架构设计与流式交互实战(完整源代码)
人工智能·交互·智能合同审核
老邋遢12 小时前
第三章-AI知识扫盲看这一篇就够了
人工智能
互联网江湖12 小时前
Seedance2.0炸场:长短视频们“修坝”十年,不如AI放水一天?
人工智能