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

431 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
```java
// 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` 等 |
```java
// dto/DiffSegmentVO.java
@Data
@Builder
public class DiffSegmentVO {
private int index;
private String original;
private String polished;
}
```
```java
// 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)` 方法 |
```js
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 后端核心逻辑
```java
// 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 编辑器工具栏新增按钮
```vue
<!-- 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 获取选中文本
```js
// 通过 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 对比面板组件
```vue
<!-- 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 采纳替换逻辑
```js
// 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 字符,前端应在选中文本过长时给出提示