15 KiB
15 KiB
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,包含 index、original、polished |
dto/PolishResultVO.java |
返回 VO,包含 original、polished、diffSegments、model、costMillis 等 |
// 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. 实施顺序建议
- 后端子步骤:调整
AiSummaryServiceImpl.callModel可见性(private → public)→ PolishTextDTO → DiffSegmentVO → PolishResultVO → TextPolishService 接口 → TextPolishServiceImpl → Controller 接口 → 单元测试 - 前端子步骤:API 方法 → PolishDiffPanel 组件 → ArticleEditor 按钮和交互
- 最后联调:前后端对接、bug 修复、验收
8. 风险与注意事项
- callModel 可见性:
AiSummaryServiceImpl.callModel()当前为protected,需改为public以便TextPolishServiceImpl调用;或改为通过AiSummaryService接口暴露(推荐后者以保持封装) - 段落对齐准确性:模型可能重组段落结构,对齐逻辑需覆盖段数不匹配的降级场景,建议首版实现简单策略(按 \n\n 拆分 + 全文降级)
- 选中文本格式丢失:采纳润色结果时,原选中区域的富文本格式(加粗、斜体等)会丢失,替换为纯文本。后续版本可考虑保留格式
- 限流共享:润色与摘要、标签推荐共享同一 Rate Limit 配额(每分钟 5 次/用户),高频使用场景需评估是否独立配额
- 模型温度设置:润色功能使用
temperature=0.3(与摘要一致),保证输出稳定;若模型输出过于保守可适当调高 - 超长文本:后端截断至 6000 字符,前端应在选中文本过长时给出提示