431 lines
15 KiB
Markdown
431 lines
15 KiB
Markdown
# 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 字符,前端应在选中文本过长时给出提示
|