ai文章润色
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
# AI 文章润色功能技术实施文档
|
||||
|
||||
## 前提条件
|
||||
|
||||
**数据安全规则**:
|
||||
- 联调和测试过程中如需创建测试数据,测试完成后必须删除
|
||||
- **数据库中存在一篇名为 "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 字符,前端应在选中文本过长时给出提示
|
||||
Reference in New Issue
Block a user