Files
Jog/doc/AI文章润色功能技术实施文档.md
2026-05-24 00:39:10 +08:00

15 KiB
Raw Permalink Blame History

AI 文章润色功能技术实施文档

与其它 AI 功能:润色始终使用 DashScopeProperties.model,与《AI助手模型管理功能需求》中的 ai_assistant_model 无关;实施或联调助手模型管理时 无需改动 本模块。参见润色需求文档 §8。

前提条件

数据安全规则

  • 联调和测试过程中如需创建测试数据,测试完成后必须删除
  • 数据库中存在一篇名为 "hello" 的文章,此文章不允许任何修改或删除操作

1. 功能概述

在博客后台编辑器中增加"AI 润色"按钮,用户选中文本后调用大模型优化语法、表达和标点,返回结果以对比视图展示,支持逐段采纳或全部采纳。

2. 核心实现思路

2.1 复用现有 AI 能力

项目已有 AiSummaryServiceImpl 调用阿里云 DashScope 模型的能力,润色功能将复用该服务的模型调用链路:

  • 复用 DashScopeProperties 配置(API Key、Base URL、模型、超时、限流)
  • 复用 AiSummaryServiceImpl.callModel() 方法(HTTP 请求、认证、错误处理)
  • 复用 Redis Rate Limit 限流机制

2.2 差异化实现

与摘要生成、标签推荐的主要差异:

特性 摘要生成 标签推荐 文章润色
System Prompt 生成摘要 提取标签 润色文本
User Prompt 标题 + 正文 标题 + 正文 纯文本段落
输入 全文(≤8000字符) 全文(≤8000字符) 选中文本(≤6000字符)
响应解析 截取摘要文本 解析逗号分隔标签 段落拆分 + 对比
返回格式 SummaryResultVO TagRecommendResultVO PolishResultVO(含 diffSegments
前端交互 回填输入框 勾选标签确认 对比视图逐段采纳

2.3 实现方案

后端:新建 TextPolishService 服务类,调用 AiSummaryServiceImpl.callModel(),对返回结果做段落对齐拆分,返回 diffSegments 数组。

前端:在富文本工具栏增加"AI 润色"按钮,新建 PolishDiffPanel.vue 对比面板组件,实现逐段采纳交互。


3. 改动项清单

3.1 后端改动

3.1.1 新增 DTO

文件 说明
dto/PolishTextDTO.java 请求 DTO,包含 text 字段(@NotBlank,最长10000
// dto/PolishTextDTO.java
@Data
public class PolishTextDTO {
    @NotBlank(message = "文本不能为空")
    @Size(max = 10000, message = "文本长度不能超过10000")
    private String text;
}

3.1.2 新增 VO

文件 说明
dto/DiffSegmentVO.java 段落对比 VO,包含 indexoriginalpolished
dto/PolishResultVO.java 返回 VO,包含 originalpolisheddiffSegmentsmodelcostMillis
// dto/DiffSegmentVO.java
@Data
@Builder
public class DiffSegmentVO {
    private int index;
    private String original;
    private String polished;
}
// dto/PolishResultVO.java
@Data
@Builder
public class PolishResultVO {
    private String original;
    private String polished;
    private List<DiffSegmentVO> diffSegments;
    private String model;
    private Integer promptTokens;
    private Integer completionTokens;
    private long costMillis;
}

3.1.3 新增服务接口与实现

文件 说明
service/TextPolishService.java 润色服务接口
service/impl/TextPolishServiceImpl.java 调用 AiSummaryServiceImpl.callModel(),实现润色 + 段落对齐拆分

3.1.4 修改 Controller

文件 变更说明
controller/ArticleController.java 新增 POST /api/app/article/polish 接口,注入 TextPolishService

3.1.5 错误码

复用现有错误码,无需新增:

错误码 常量名 触发场景
5004 AI_CONTENT_TOO_SHORT text < 20 字符
5005 AI_API_KEY_MISSING api-key 未配置
5001 AI_SERVICE_UNAVAILABLE DashScope 返回异常
5002 AI_SERVICE_TIMEOUT 调用超时
429 RATE_LIMITED 超出频率限制

3.2 前端改动

3.2.1 新增 API 方法

文件 说明
src/api/article.js 新增 polishArticleText(data) 方法
export function polishArticleText(data) {
  return request.post('/api/app/article/polish', data, { timeout: 35000 })
}

3.2.2 修改文章编辑器

文件 说明
src/views/app/ArticleEditor.vue 工具栏新增"AI 润色"按钮、引入对比面板、选中文本获取逻辑

3.2.3 新增对比面板组件

文件 说明
src/components/PolishDiffPanel.vue 原文 vs 润色后对比面板,支持逐段采纳 / 全部采纳 / 关闭

4. 核心逻辑说明

4.1 后端核心逻辑

// TextPolishServiceImpl.java

// 专用提示词
private static final String SYSTEM_PROMPT =
    "你是一名专业的文字编辑,请润色用户提供的中文文本。" +
    "修正语法错误、优化表达方式、规范标点符号,保持原意不变,使文本更流畅专业。" +
    "直接输出润色后的文本,不要添加任何解释或标记。";

private static final int POLISH_MIN_LENGTH = 20;
private static final int POLISH_MAX_INPUT = 6000;
private static final String RATE_LIMIT_KEY_PREFIX = "text_polish:rate:";

// 1. 构建请求
Map<String, Object> requestBody = Map.of(
    "model", properties.getModel(),
    "messages", List.of(
        Map.of("role", "system", "content", SYSTEM_PROMPT),
        Map.of("role", "user", "content", "请润色下面这段中文文本:\n" + plainText)
    ),
    "temperature", 0.3  // 低温度保证输出稳定
);

// 2. 调用模型(复用 AiSummaryServiceImpl.callModel
Map<String, Object> response = aiSummaryService.callModel(requestBody);

// 3. 段落对齐:按原文换行符拆分,将润色结果对齐到对应段落
List<DiffSegmentVO> segments = alignParagraphs(originalText, polishedText);

// 4. 返回对比结果
return PolishResultVO.builder()
    .original(originalText)
    .polished(polishedText)
    .diffSegments(segments)
    .model(model)
    .costMillis(costMillis)
    .build();

4.2 段落对齐逻辑

模型可能合并或拆分段落,需要做对齐处理:

输入:原文按 \n\n 拆分为段落数组 [P1, P2, P3]
输出:润色文本按 \n\n 拆分

对齐策略:
- 如果段落数相等 → 一一对应
- 如果润色段落多 → 按顺序匹配,多余段落合并到最后一段
- 如果润色段落少 → 原文多余段落合并,原文和润色文本保持相同的 diffSegments 长度
- 段数差异过大(>2倍) → 降级为全文对比(单个 diffSegment

4.3 前端核心逻辑

4.3.1 编辑器工具栏新增按钮

<!-- ArticleEditor.vue 工具栏区域 -->
<div v-if="editor" class="toolbar">
  <el-button-group>
    <!-- 现有按钮B I H2 H3 列表 引用 分割线 -->
  </el-button-group>
  <div class="toolbar-right">
    <el-button
      type="warning"
      size="small"
      :loading="polishing"
      @click="handlePolishText"
    >
       AI 润色
    </el-button>
  </div>
</div>

4.3.2 获取选中文本

// 通过 Tiptap editor 获取选中文本
const handlePolishText = () => {
  const { from, to } = editor.state.selection
  const selectedText = editor.state.doc.textBetween(from, to, '\n')
  
  if (!selectedText || selectedText.trim().length < 20) {
    ElMessage.warning('请先选中至少 20 个字符的文本')
    return
  }
  
  polishing.value = true
  polishArticleText({ text: selectedText })
    .then(res => {
      polishResult.value = res.data.data
      showDiffPanel.value = true
    })
    .catch(err => {
      ElMessage.error(err.response?.data?.message || '润色失败')
    })
    .finally(() => {
      polishing.value = false
    })
}

4.3.3 对比面板组件

<!-- PolishDiffPanel.vue -->
<template>
  <div class="polish-diff-panel">
    <div class="panel-header">
      <span>AI 润色结果对比</span>
      <div>
        <el-button type="success" size="small" @click="acceptAll">全部采纳</el-button>
        <el-button size="small" @click="handleClose">✕</el-button>
      </div>
    </div>
    <div class="diff-content">
      <div
        v-for="seg in segments"
        :key="seg.index"
        class="diff-row"
        :class="{ accepted: seg.accepted }"
      >
        <div class="diff-original">{{ seg.original }}</div>
        <div class="diff-polished">{{ seg.polished }}</div>
        <el-button
          v-if="!seg.accepted"
          type="primary"
          size="small"
          @click="acceptSegment(seg)"
        >
          采纳
        </el-button>
        <span v-else class="accepted-tag">已采纳</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  result: Object,  // PolishResultVO
  visible: Boolean
})

const emit = defineEmits(['accept-segment', 'accept-all', 'close'])

const segments = ref([])

watch(() => props.result, (val) => {
  if (val) {
    segments.value = val.diffSegments.map(s => ({ ...s, accepted: false }))
  }
})

const acceptSegment = (seg) => {
  seg.accepted = true
  emit('accept-segment', seg.index, seg.polished)
}

const acceptAll = () => {
  segments.value.forEach(s => { s.accepted = true })
  emit('accept-all')
}

const handleClose = () => {
  const hasUnaccepted = segments.value.some(s => !s.accepted)
  if (hasUnaccepted) {
    ElMessageBox.confirm('有未采纳的润色结果,确定关闭?', '提示')
      .then(() => emit('close'))
      .catch(() => {})
  } else {
    emit('close')
  }
}
</script>

4.3.4 采纳替换逻辑

// ArticleEditor.vue 中处理采纳
const handleAcceptSegment = (index, polishedText) => {
  // 通过 Tiptap editor 替换对应段落
  const { from, to } = polishSelection.value  // 保存的选中范围
  // 按段落拆分原文,定位到第 index 段在文档中的位置
  // 使用 editor.chain().setTextSelection(...).insertContent(polishedText).run()
}

const handleAcceptAll = () => {
  // 用完整润色文本替换整个选中区域
  editor.chain()
    .setTextSelection(polishSelection.value)
    .insertContent(polishResult.value.polished)
    .run()
  showDiffPanel.value = false
  ElMessage.success('已全部采纳')
}

5. 控件说明

5.1 编辑器选中文本处理

由于编辑器使用 Tiptap(富文本),选中的内容可能是带格式的 HTML。润色处理流程:

1. editor.state.selection → 获取选中范围 { from, to }
2. editor.state.doc.textBetween(from, to, '\n') → 提取纯文本(保留段落分隔)
3. 提交纯文本到后端 → 后端调用 AI 润色
4. 前端接收 diffSegments → 渲染对比视图
5. 用户采纳时 → 用润色后纯文本替换选中区域(保留原格式较复杂,首版用纯文本替换)

5.2 对比面板布局

┌───────────────────────────────────────────────────────────┐
│  AI 润色结果对比                         [全部采纳] [✕]   │
├───────────────────────────────────────────────────────────┤
│  ┌──────────────┬──────────────┬──────┐                  │
│  │ 原文         │ 润色后        │ 操作  │                  │
│  ├──────────────┼──────────────┼──────┤                  │
│  │ 原文段落1    │ 润色段落1    │ 采纳  │                  │
│  ├──────────────┼──────────────┼──────┤                  │
│  │ 原文段落2    │ 润色段落2    │ 已采纳│                  │
│  └──────────────┴──────────────┴──────┘                  │
└───────────────────────────────────────────────────────────┘

6. 待办事项

序号 待办项 状态
1 后端:创建 PolishTextDTO.java 待开始
2 后端:创建 DiffSegmentVO.java 待开始
3 后端:创建 PolishResultVO.java 待开始
4 后端:创建 TextPolishService 接口 待开始
5 后端:创建 TextPolishServiceImpl 实现类 待开始
6 后端:修改 AiSummaryServiceImpl,提升 callModel 为 public 待开始
7 后端:在 ArticleController 添加 POST /api/app/article/polish 待开始
8 后端:单元测试(TextPolishServiceImplTest 待开始
9 前端:在 article.js 添加 polishArticleText API 方法 待开始
10 前端:创建 PolishDiffPanel.vue 对比面板组件 待开始
11 前端:在 ArticleEditor.vue 添加"AI 润色"按钮和交互 待开始
12 前后端联调 + 功能验证 待开始

7. 实施顺序建议

  1. 后端子步骤:调整 AiSummaryServiceImpl.callModel 可见性(private → public)→ PolishTextDTO → DiffSegmentVO → PolishResultVO → TextPolishService 接口 → TextPolishServiceImpl → Controller 接口 → 单元测试
  2. 前端子步骤API 方法 → PolishDiffPanel 组件 → ArticleEditor 按钮和交互
  3. 最后联调:前后端对接、bug 修复、验收

8. 风险与注意事项

  1. callModel 可见性AiSummaryServiceImpl.callModel() 当前为 protected,需改为 public 以便 TextPolishServiceImpl 调用;或改为通过 AiSummaryService 接口暴露(推荐后者以保持封装)
  2. 段落对齐准确性:模型可能重组段落结构,对齐逻辑需覆盖段数不匹配的降级场景,建议首版实现简单策略(按 \n\n 拆分 + 全文降级)
  3. 选中文本格式丢失:采纳润色结果时,原选中区域的富文本格式(加粗、斜体等)会丢失,替换为纯文本。后续版本可考虑保留格式
  4. 限流共享:润色与摘要、标签推荐共享同一 Rate Limit 配额(每分钟 5 次/用户),高频使用场景需评估是否独立配额
  5. 模型温度设置:润色功能使用 temperature=0.3(与摘要一致),保证输出稳定;若模型输出过于保守可适当调高
  6. 超长文本:后端截断至 6000 字符,前端应在选中文本过长时给出提示