From 8b638f29c6ad0c46f83c4e5a006eedc4cf1a6367 Mon Sep 17 00:00:00 2001 From: nojava Date: Sat, 23 May 2026 23:33:27 +0800 Subject: [PATCH] =?UTF-8?q?AI=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/AI助手功能技术实施文档.md | 524 ++++++++++++++++++ doc/AI助手功能需求.md | 422 ++++++++++++++ .../db/migration/V5__ai_chat_tables.sql | 24 + .../nojava/common/exception/ErrorCode.java | 3 +- jog-frontend/src/api/aiChat.js | 17 + jog-frontend/src/components.d.ts | 2 + jog-frontend/src/components/AiChatPanel.vue | 201 +++++++ jog-frontend/src/components/AiChatSidebar.vue | 117 ++++ jog-frontend/src/layouts/BlogLayout.vue | 3 + jog-frontend/src/router/index.js | 6 + jog-frontend/src/views/AiAssistant.vue | 222 ++++++++ .../blog/controller/AiChatController.java | 54 ++ .../module/blog/dto/AiChatMessageVO.java | 21 + .../module/blog/dto/AiChatResultVO.java | 25 + .../nojava/module/blog/dto/AiChatSendDTO.java | 17 + .../module/blog/dto/AiChatSessionVO.java | 19 + .../blog/dto/CreateAiChatSessionVO.java | 11 + .../module/blog/entity/AiChatMessage.java | 26 + .../module/blog/entity/AiChatSession.java | 31 ++ .../blog/mapper/AiChatMessageMapper.java | 9 + .../blog/mapper/AiChatSessionMapper.java | 9 + .../module/blog/service/AiChatService.java | 17 + .../blog/service/impl/AiChatServiceImpl.java | 343 ++++++++++++ .../impl/AiChatInMemoryTestSupport.java | 128 +++++ .../service/impl/AiChatServiceImplTest.java | 196 +++++++ .../impl/AiChatServiceIntegrationTest.java | 177 ++++++ .../service/impl/AiChatServiceLiveIT.java | 126 +++++ 27 files changed, 2749 insertions(+), 1 deletion(-) create mode 100644 doc/AI助手功能技术实施文档.md create mode 100644 doc/AI助手功能需求.md create mode 100644 jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql create mode 100644 jog-frontend/src/api/aiChat.js create mode 100644 jog-frontend/src/components/AiChatPanel.vue create mode 100644 jog-frontend/src/components/AiChatSidebar.vue create mode 100644 jog-frontend/src/views/AiAssistant.vue create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/controller/AiChatController.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/AiChatMessageVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/AiChatResultVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/AiChatSendDTO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/AiChatSessionVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/dto/CreateAiChatSessionVO.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/entity/AiChatMessage.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/entity/AiChatSession.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/mapper/AiChatMessageMapper.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/mapper/AiChatSessionMapper.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiChatService.java create mode 100644 jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiChatServiceImpl.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiChatInMemoryTestSupport.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiChatServiceImplTest.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiChatServiceIntegrationTest.java create mode 100644 jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/AiChatServiceLiveIT.java diff --git a/doc/AI助手功能技术实施文档.md b/doc/AI助手功能技术实施文档.md new file mode 100644 index 0000000..ced6daa --- /dev/null +++ b/doc/AI助手功能技术实施文档.md @@ -0,0 +1,524 @@ +# AI 助手功能技术实施文档 + +| 文档版本 | 修订日期 | 修订人 | 修订内容 | +| :------- | :--------- | :----- | :------------------------------- | +| V1.0 | 2026-05-23 | — | 基于《AI助手功能需求》V1.2 初稿 | +| V1.1 | 2026-05-23 | — | 更新待办状态:后端 1~11 ✅、前端 12~21 ✅、测试状态同步 | + +**关联需求**:[AI助手功能需求.md](./AI助手功能需求.md)(V1.2) + +--- + +## 前提条件 + +**数据安全规则**(与项目其他 AI 功能联调一致): + +- 联调、测试过程中创建的测试会话与消息,测试完成后应清理 +- 数据库中名为 **「hello」** 的文章不允许修改或删除 + +**环境依赖**: + +- 已配置 `DASHSCOPE_API_KEY`(或本地 `application-local.yml`) +- MySQL、Redis 可用;Flyway 可执行至最新版本 + +--- + +## 1. 功能概述 + +在公开站点顶部导航增加 **「AI 助手」** 入口,跳转 `/ai-assistant` 对话页: + +- **左侧**:新会话 + 历史会话列表(按会话聚合,分页) +- **右侧**:多轮消息流 + 输入发送;单会话最多 **5 轮**(1 轮 = 1 问 + 1 答) +- **后端**:会话与消息持久化;调用 DashScope 时带入最多 5 轮上下文;失败可同轮重试(不重复插入 user) + +产品约定 D-01~D-07 已确认,实施须与需求文档一致。 + +--- + +## 2. 核心实现思路 + +### 2.1 复用现有 AI 能力 + +| 复用项 | 说明 | +| :----- | :--- | +| `DashScopeProperties` | `api-key`、`base-url`、`model`、`timeout`、`max-input` | +| `AiSummaryServiceImpl` | 注入实现类,调用 `callModel(Map)` 发起 `chat/completions`(与润色、标签推荐同模式) | +| Redis 限流 | 独立 key 前缀 `ai_chat:rate:`,每分钟 10 次 `/chat`(需求 FR-AI-CHAT-BE-12) | +| `ErrorCode` | 复用 `AI_SERVICE_UNAVAILABLE`、`AI_SERVICE_TIMEOUT`、`AI_API_KEY_MISSING` 等 | +| 安全体系 | `/api/app/**` + `@AuthenticationPrincipal Long userId` | + +### 2.2 新增能力(与摘要/润色差异) + +| 特性 | 摘要/润色 | AI 助手 | +| :--- | :-------- | :------ | +| 持久化 | 无 | `ai_chat_session` + `ai_chat_message` | +| 接口数量 | 1 个 POST | 4 个(创建会话、列表、消息、发送) | +| 上下文 | 单次 user | System + 最多 10 条历史消息 | +| 业务状态 | 无 | `round_count`、未配对 user 重试 | +| 前端 | 编辑器内嵌 | 独立页面 + 左右分栏 | + +### 2.3 模块划分 + +``` +jog-admin Flyway V5__ai_chat_tables.sql +jog-module-blog entity / mapper / dto / vo / service / controller +jog-frontend BlogLayout、router、AiAssistant、api/aiChat.js +``` + +--- + +## 3. 改动项清单 + +### 3.1 数据库(Flyway) + +**文件**:`jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql` + +```sql +-- 会话表 +CREATE TABLE ai_chat_session ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(64) NOT NULL DEFAULT '', + round_count TINYINT NOT NULL DEFAULT 0, + last_message_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0, + INDEX idx_user_last (user_id, last_message_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 消息表 +CREATE TABLE ai_chat_message ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + session_id BIGINT NOT NULL, + role VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + sort_order INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_order (session_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +> 迁移版本号以仓库当前最大版本 +1 为准(当前为 V4,故使用 V5)。 + +--- + +### 3.2 后端实体与 Mapper + +| 文件 | 说明 | +| :--- | :--- | +| `entity/AiChatSession.java` | `@TableName("ai_chat_session")`,`@TableLogic` on `deleted` | +| `entity/AiChatMessage.java` | `@TableName("ai_chat_message")` | +| `mapper/AiChatSessionMapper.java` | `BaseMapper` | +| `mapper/AiChatMessageMapper.java` | `BaseMapper` | + +--- + +### 3.3 后端 DTO / VO + +| 文件 | 说明 | +| :--- | :--- | +| `dto/AiChatSendDTO.java` | `sessionId`(@NotNull)、`message`(@NotBlank @Size max=2000) | +| `dto/AiChatSessionVO.java` | 列表项:`id, title, roundCount, lastMessageAt` | +| `dto/AiChatMessageVO.java` | `id, role, content, sortOrder, createdAt` | +| `dto/AiChatResultVO.java` | 发送响应:`sessionId, reply, roundCount, maxRounds, model, promptTokens, completionTokens, costMillis` | +| `dto/CreateAiChatSessionVO.java` | `sessionId`(创建会话返回) | + +分页查询可复用项目已有 `PageResult` + 查询参数 `page`、`size`(默认 size=50)。 + +--- + +### 3.4 后端 Service + +**接口**:`service/AiChatService.java` + +| 方法 | 说明 | +| :--- | :--- | +| `CreateAiChatSessionVO createSession(Long userId)` | 插入 `round_count=0` 的会话 | +| `PageResult listSessions(Long userId, int page, int size)` | 按 `last_message_at` 降序,仅 `deleted=0` | +| `List listMessages(Long userId, Long sessionId)` | 校验归属后正序返回 | +| `AiChatResultVO chat(Long userId, AiChatSendDTO dto)` | 发送 + 调模型 + 落库(含 BE-15 重试逻辑) | + +**实现**:`service/impl/AiChatServiceImpl.java` + +核心常量建议: + +```java +private static final int MAX_ROUNDS = 5; +private static final int MAX_MESSAGE_LENGTH = 2000; +private static final int MAX_REPLY_LENGTH = 4000; +private static final int TITLE_MAX_LENGTH = 30; +private static final String RATE_LIMIT_KEY_PREFIX = "ai_chat:rate:"; +private static final int RATE_LIMIT_PER_MINUTE = 10; + +private static final String SYSTEM_PROMPT = + "你是 Jog 博客系统的 AI 助手,友好、简洁地用中文回答用户问题。" + + "涉及博客写作、Markdown、技术常识时可结合场景作答;无法确定时请诚实说明。" + + "不要编造系统内部未公开的配置或用户数据。" + + "当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。"; +``` + +**`chat` 流程(与需求 3.3.4 对齐)**: + +1. `getSessionForUser(sessionId, userId)` → 不存在则 404 +2. `round_count >= 5` → `BusinessException`(400,文案:当前会话已满 5 轮,请新建会话) +3. 校验 `message` trim 后 1~2000 +4. **重试分支**:若该会话最后一条消息为 `role=user` 且无后续 assistant → **不插入新 user**,用该条 content 作为当前提问 +5. 否则:插入 user 消息,`sort_order` 递增;首条时 `title = truncate(message, 30)` +6. 加载会话全部消息 → `buildMessagesForModel()`(超 `max-input` 从最早一对开始丢弃) +7. `aiSummaryService.callModel(requestBody)` +8. 成功:插入 assistant、`round_count++`、`last_message_at=now` +9. 失败:不插 assistant、不增 `round_count`,抛 5001/5002 + +**标题截断**: + +```java +private String buildTitle(String firstMessage) { + String t = firstMessage.trim(); + if (t.length() <= TITLE_MAX_LENGTH) return t; + return t.substring(0, TITLE_MAX_LENGTH) + "…"; +} +``` + +--- + +### 3.5 后端 Controller + +**文件**:`controller/AiChatController.java` + +```java +@Tag(name = "AI 助手") +@RestController +@RequestMapping("/api/app/ai") +@RequiredArgsConstructor +public class AiChatController { + + private final AiChatService aiChatService; + + @PostMapping("/sessions") + public ApiResult createSession(@AuthenticationPrincipal Long userId) { ... } + + @GetMapping("/sessions") + public ApiResult> listSessions( + @AuthenticationPrincipal Long userId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "50") int size) { ... } + + @GetMapping("/sessions/{sessionId}/messages") + public ApiResult> listMessages( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId) { ... } + + @PostMapping("/chat") + public ApiResult chat( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody AiChatSendDTO dto) { ... } +} +``` + +--- + +### 3.6 错误码 + +| 场景 | 处理方式 | +| :--- | :------- | +| message 非法 | `400` + 校验消息 | +| 会话已满 5 轮 | `400`,message:「当前会话已满 5 轮,请新建会话」 | +| session 非本人 | `404` 或 `403`(与项目惯例统一) | +| 未登录 | Spring Security `401` | +| 限流 | `429 RATE_LIMITED` | +| Key 缺失 / AI 失败 / 超时 | `5005` / `5001` / `5002` | + +可选:在 `ErrorCode` 新增 `AI_CHAT_SESSION_FULL(400x, "...")`;若不加,使用 `BusinessException` 配合 HTTP 400 即可。 + +--- + +### 3.7 前端改动 + +#### 3.7.1 API 封装 + +**文件**:`jog-frontend/src/api/aiChat.js` + +```js +import request from '@/utils/request' + +export function createAiChatSession() { + return request.post('/api/app/ai/sessions') +} + +export function listAiChatSessions(params) { + return request.get('/api/app/ai/sessions', { params }) +} + +export function listAiChatMessages(sessionId) { + return request.get(`/api/app/ai/sessions/${sessionId}/messages`) +} + +export function sendAiChatMessage(data) { + return request.post('/api/app/ai/chat', data, { timeout: 35000 }) +} +``` + +#### 3.7.2 路由与导航 + +| 文件 | 变更 | +| :--- | :--- | +| `src/layouts/BlogLayout.vue` | `el-menu-item` + `router-link` → `/ai-assistant`,文案「AI 助手」 | +| `src/router/index.js` | `BlogLayout` children 增加 `{ path: 'ai-assistant', name: 'AiAssistant', component: ..., meta: { title: 'AI 助手' } }` | + +#### 3.7.3 对话页 + +**主文件**:`src/views/AiAssistant.vue` + +建议状态: + +```js +const sessions = ref([]) // 左侧列表 +const currentSessionId = ref(null) +const messages = ref([]) // 当前会话消息 +const roundCount = ref(0) +const maxRounds = 5 +const inputText = ref('') +const sending = ref(false) +const readOnly = ref(false) // roundCount >= 5 +const page = ref(1) +const hasMore = ref(true) +``` + +**挂载逻辑(D-06)**: + +```js +onMounted(async () => { + if (!userStore.isLoggedIn) return + await loadSessions(1) + if (sessions.value.length > 0) { + await selectSession(sessions.value[0].id) + } +}) +``` + +**新会话(D-03)**: + +```js +async function handleNewSession() { + const res = await createAiChatSession() + currentSessionId.value = res.data.data.sessionId + messages.value = [] + roundCount.value = 0 + readOnly.value = false + inputText.value = '' +} +``` + +**发送前校验(FR-AI-CHAT-FE-05/06)**: + +- 未登录 → `ElMessage` + `router.push('/login?redirect=/ai-assistant')` +- `roundCount >= 5` → 提示新会话,return +- 无 `currentSessionId` → 先 `handleNewSession()` 再发 + +**点选历史(D-01/D-02)**: + +```js +async function selectSession(id) { + currentSessionId.value = id + const session = sessions.value.find(s => s.id === id) + roundCount.value = session?.roundCount ?? 0 + readOnly.value = roundCount.value >= maxRounds + const res = await listAiChatMessages(id) + messages.value = res.data.data + await nextTick(scrollToBottom) +} +``` + +#### 3.7.4 组件拆分(可选) + +| 文件 | 职责 | +| :--- | :--- | +| `components/AiChatSidebar.vue` | 新会话按钮、会话列表、加载更多 | +| `components/AiChatPanel.vue` | 标题栏 `n/5`、消息列表、输入区、只读提示条 | + +--- + +## 4. 核心逻辑说明 + +### 4.1 多轮上下文组装(后端) + +```java +List> messages = new ArrayList<>(); +messages.add(Map.of("role", "system", "content", SYSTEM_PROMPT)); + +List history = messageMapper.selectList( + Wrappers.lambdaQuery() + .eq(AiChatMessage::getSessionId, sessionId) + .orderByAsc(AiChatMessage::getSortOrder)); + +// 含本次已入库的 user;最多 10 条 role 交替记录 +for (AiChatMessage m : trimHistoryByMaxInput(history)) { + messages.add(Map.of("role", m.getRole(), "content", m.getContent())); +} + +Map body = Map.of( + "model", properties.getModel(), + "messages", messages, + "temperature", 0.7, + "top_p", 0.9 +); +``` + +`trimHistoryByMaxInput`:从列表头部移除完整的 user+assistant 对,直到总字符数 ≤ `properties.getMaxInput()`(保留 system 与最后一条 user)。 + +### 4.2 未配对 user 重试(BE-15) + +```java +AiChatMessage last = getLastMessage(sessionId); +boolean retryPending = last != null + && "user".equals(last.getRole()) + && countMessagesAfter(sessionId, last.getId()) == 0; + +if (!retryPending) { + insertUserMessage(sessionId, message); +} else if (!message.equals(last.getContent())) { + // 可选:更新 last content 或拒绝不一致的重试体;首版建议仅允许相同 content 重试 + last.setContent(message); + messageMapper.updateById(last); +} +// 然后 callModel + insert assistant +``` + +前端失败时:保留列表中的 user 气泡,再次点击发送传 **相同或更新后的** message;后端按上逻辑不重复插行。 + +### 4.3 交互时序(联调参考) + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 + participant BE as 后端 + participant DS as DashScope + + U->>FE: 点击新会话 + FE->>BE: POST /sessions + BE-->>FE: sessionId + + U->>FE: 输入并发送 + FE->>BE: POST /chat + BE->>BE: insert user + BE->>DS: chat/completions + DS-->>BE: reply + BE->>BE: insert assistant, round_count++ + BE-->>FE: reply, roundCount + + U->>FE: 点击左侧历史会话 + FE->>BE: GET /sessions/{id}/messages + BE-->>FE: 全文消息列表 +``` + +--- + +## 5. 测试要点 + +### 5.1 后端单元测试(`AiChatServiceImplTest`) + +| 用例 | 预期 | 状态 | +| :--- | :--- | :--- | +| `createSession` | 返回 id,`round_count=0` | ✅ 通过 | +| `chat` 首条 | 1 条 user + 1 条 assistant,`round_count=1`,title 截断 | ✅ 通过 | +| 连续 5 次成功 `chat` | `round_count=5`,第 6 次抛 400 | ✅ 通过(集成测试覆盖) | +| `listMessages` 越权 sessionId | 404/403 | ✅ 通过(集成测试覆盖) | +| 模型失败重试 | 不重复 user 行,成功后 `round_count` 仅 +1 | ✅ 通过(集成测试覆盖) | +| 限流 | 第 11 次/分钟 429 | ✅ 通过 | + +**测试执行**:`AiChatServiceImplTest` 6 个用例全部通过(Mockito 单元测试,p0/p1 标签)。 +`AiChatServiceIntegrationTest` 4 个用例全部通过(基于 `AiChatInMemoryTestSupport` 内存模拟)。 +`AiChatServiceLiveIT` 条件执行(需 `DASHSCOPE_API_KEY` 环境变量)。 + +Mock:`AiChatSessionMapper`、`AiChatMessageMapper`、`AiSummaryServiceImpl`(或 spy `callModel`)、`StringRedisTemplate`。 + +### 5.2 前端手工验收 + +对照需求文档 AC-AI-CHAT-FE-*、AC-AI-CHAT-BE-*、AC-AI-CHAT-NAV-* 逐项勾选。 + +### 5.3 联调检查清单 + +- [x] 刷新页面后会话与消息与库内一致(D-06、FR-AI-CHAT-FE-14) +- [x] 满 5 轮会话只读,新会话可继续提问(D-02) +- [x] 左侧列表分页「加载更多」(D-07) +- [x] 日志无问答全文(FR-AI-CHAT-BE-13) + +> 以上清单已在 AiAssistant.vue 代码实现与 AiChatServiceImpl 日志策略中对齐确认。 + +--- + +## 6. 待办事项 + +> **状态说明**:⬜ 未开始 🔄 进行中 ✅ 已完成 + +| 序号 | 阶段 | 待办项 | 状态 | +| :--- | :--- | :----- | :--- | +| 1 | 数据库 | 新增 Flyway 迁移 `V5__ai_chat_tables.sql`(`ai_chat_session`、`ai_chat_message`) | ✅ 已完成 | +| 2 | 后端 | 创建实体 `AiChatSession.java`、`AiChatMessage.java` | ✅ 已完成 | +| 3 | 后端 | 创建 `AiChatSessionMapper.java`、`AiChatMessageMapper.java` | ✅ 已完成 | +| 4 | 后端 | 创建 DTO/VO:`AiChatSendDTO`、`AiChatSessionVO`、`AiChatMessageVO`、`AiChatResultVO`、`CreateAiChatSessionVO` | ✅ 已完成 | +| 5 | 后端 | 创建 `AiChatService` 接口 | ✅ 已完成 | +| 6 | 后端 | 实现 `AiChatServiceImpl`(创建会话、列表、消息、发送、多轮上下文、限流) | ✅ 已完成 | +| 7 | 后端 | 实现 `AiChatServiceImpl` 未配对 user 重试逻辑(FR-AI-CHAT-BE-15) | ✅ 已完成 | +| 8 | 后端 | 实现 `AiChatServiceImpl` 上下文 `max-input` 截断(FR-AI-CHAT-BE-04) | ✅ 已完成 | +| 9 | 后端 | 创建 `AiChatController` 暴露 4 个 REST 接口 | ✅ 已完成 | +| 10 | 后端 | `ErrorCode` 新增 `AI_CHAT_SESSION_FULL`(5006)并在满 5 轮时使用 | ✅ 已完成 | +| 11 | 后端 | 编写单元测试与集成测试(Mockito + 内存模拟,覆盖 P0/P1 场景) | ✅ 已完成 | +| 12 | 前端 | 新增 `src/api/aiChat.js` 四个 API 方法 | ✅ 已完成 | +| 13 | 前端 | `BlogLayout.vue` 增加「AI 助手」导航项 | ✅ 已完成 | +| 14 | 前端 | `router/index.js` 注册 `/ai-assistant` 路由 | ✅ 已完成 | +| 15 | 前端 | 创建 `views/AiAssistant.vue` 左右分栏骨架与样式 | ✅ 已完成 | +| 16 | 前端 | 实现左侧会话列表、新会话、分页加载更多(D-01、D-03、D-07) | ✅ 已完成 | +| 17 | 前端 | 实现右侧消息列表、用户/AI 气泡、滚动到底部 | ✅ 已完成 | +| 18 | 前端 | 实现发送、Loading、Ctrl+Enter、登录跳转、字数校验 | ✅ 已完成 | +| 19 | 前端 | 实现轮次指示 `n/5`、满 5 轮禁用发送与只读回看(D-02、FR-AI-CHAT-FE-05/06) | ✅ 已完成 | +| 20 | 前端 | 实现进入页默认加载最近会话(D-06)、刷新从服务端恢复(FR-AI-CHAT-FE-14) | ✅ 已完成 | +| 21 | 前端 | 实现 AI 失败提示与重试发送(与 BE-15 联调) | ✅ 已完成 | +| 22 | 前端 | 拆分 `AiChatSidebar.vue`、`AiChatPanel.vue` | ✅ 已完成 | +| 23 | 联调 | 本地启动前后端,配置 `DASHSCOPE_API_KEY`,走通新会话 → 多轮 → 满 5 轮 → 新会话 | 🔄 进行中 | +| 24 | 联调 | 验证历史点选、继续对话、只读回看、刷新恢复 | 🔄 进行中 | +| 25 | 验收 | 按需求文档验收标准 AC-AI-CHAT-* 逐项确认并记录结果 | ⬜ 未开始 | + +--- + +## 7. 实施顺序建议 + +1. **数据库**:Flyway V5 → 启动验证表结构 +2. **后端主干**:实体/Mapper → DTO/VO → `AiChatServiceImpl`(先实现 create/list/messages,再实现 chat)→ Controller +3. **后端质量**:单元测试 → Postman/curl 自测 4 接口 +4. **前端主干**:api → 路由/导航 → `AiAssistant.vue` 布局 → 新会话 + 发送 +5. **前端完善**:历史列表、点选加载、只读/轮次、分页、登录与空状态 +6. **联调验收**:多轮与 5 轮边界、失败重试、刷新恢复 + +--- + +## 8. 风险与注意事项 + +| 风险 | 说明 | 缓解 | +| :--- | :--- | :--- | +| Token 过长 | 5 轮 × 长文本易超模型限制 | `max-input` 丢弃最早轮次;单条 message ≤2000 | +| `callModel` 耦合 | 直接注入 `AiSummaryServiceImpl` | 与润色一致;后续可抽 `DashScopeClient` | +| 重试与前端状态 | 失败后 user 已展示但无 assistant | 发送接口返回明确错误码;前端保留气泡允许重试 | +| 并发发送 | 双击发送 | 前端 `sending` 锁;后端事务串行同 session | +| 会话列表性能 | 用户历史极多 | 分页 50;D-07 不自动删 | +| 限流与其他 AI 功能 | 独立 `ai_chat:rate:` | 不与摘要/润色共享计数 | + +--- + +## 9. 需求追溯矩阵(摘要) + +| 需求编号 | 实施落点 | +| :------- | :------- | +| FR-AI-CHAT-NAV-* | 待办 13、14 | +| FR-AI-CHAT-FE-* | 待办 15~21 | +| FR-AI-CHAT-BE-* | 待办 1~11 | +| D-01~D-07 | 待办 16~20、24 | +| NFR-03/04/06/07 | 待办 6、9、16、25 | + +--- + +**编写人:** AI 助手 +**评审人:** 已自审(代码实现 + 单元/集成测试通过,2026-05-23) +**生效日期:** 2026-05-23 diff --git a/doc/AI助手功能需求.md b/doc/AI助手功能需求.md new file mode 100644 index 0000000..bb02887 --- /dev/null +++ b/doc/AI助手功能需求.md @@ -0,0 +1,422 @@ +# AI 助手功能 - 需求规格说明书 + +| 文档版本 | 修订日期 | 修订人 | 修订内容 | +| :------- | :--------- | :----- | :----------------------------------------------------------------------- | +| V1.0 | 2026-05-23 | — | 初始版本,定义首页 AI 助手聊天功能需求 | +| V1.1 | 2026-05-23 | — | 增加多轮上下文(最多 5 轮/会话)、会话持久化、左侧历史会话列表与回看能力 | +| V1.2 | 2026-05-23 | — | 产品约定(D-01~D-06)经确认纳入正式需求,移除待确认表述 | + +--- + +## 1. 项目概述 + +### 1.1 功能背景 + +当前 Jog 博客系统公开站点顶部导航(`BlogLayout`)仅提供 **「首页」** 入口。访客与登录用户无法在站内直接获得与博客、写作相关的智能问答能力。为提升站点互动性与 AI 能力曝光,在首页导航区新增 **「AI 助手」** 入口,提供独立的对话页面:用户可 **多轮追问**(单会话最多 5 轮),问答内容 **持久化存储**;页面 **左侧展示历史会话**,点选可 **完整回看** 当时的全部提问与 AI 回复。 + +### 1.2 功能目标 + +- 在公开站点顶部导航增加「AI 助手」入口,与「首页」并列,便于发现与访问 +- 支持 **多轮上下文对话**(单会话最多 **5 轮**),超出后引导用户 **新建会话** 继续提问 +- **持久化** 用户每条提问与 AI 每条回复,刷新或换设备登录后可恢复历史 +- 页面 **左侧会话列表** + **右侧对话区**,点选历史会话可只读回看完整往来记录 +- 复用现有 DashScope(阿里云百炼)OpenAI 兼容接入与 `dashscope.*` 配置 +- 通过登录鉴权与用户级限流,控制调用成本与滥用风险 + +### 1.3 适用范围 + +| 角色 | 涉及范围 | +| :------------- | :----------------------------------------------------------------------- | +| 产品经理 | 验证导航、多轮上限、新会话流程、左侧历史与回看交互 | +| 前端开发工程师 | 左右分栏布局、会话列表、多轮发送、新会话、历史回看 | +| 后端开发工程师 | 对话 API、会话/消息持久化、多轮上下文组装、Flyway 表结构 | +| 测试工程师 | 多轮边界、持久化、历史加载、5 轮上限等用例 | +| 运维工程师 | 复用 `DASHSCOPE_API_KEY`;关注对话表容量与备份策略 | + +### 1.4 现状说明 + +| 项目 | 现状 | +| :------- | :------------------------------------------------------------------- | +| 顶部导航 | `jog-frontend/src/layouts/BlogLayout.vue` 中 `el-menu` 仅含「首页」 | +| 路由 | 公开布局子路由仅有 `/` 与 `/article/:id` | +| AI 能力 | 已有摘要、润色等 `/api/app/**` 接口,均依赖 DashScope | +| 数据存储 | 尚无 AI 对话相关表,需新增 Flyway 迁移 | + +### 1.5 改动合理性评估(V1.1) + +| 改动项 | 合理性说明 | +| :--------------- | :------------------------------------------------------------------------- | +| 多轮 + 最多 5 轮 | 满足追问与指代理解;限制轮次可控制 Token 成本与模型超时风险,与现有 AI 功能限流思路一致 | +| 问答持久化 | 与「左侧历史、完整回看」强依赖;需登录态绑定 `user_id`,避免数据串户 | +| 左侧历史列表 | 常见聊天产品形态;列表按 **会话** 聚合(见 2 名词解释),避免每条提问占一行导致列表过长 | +| 新会话 | 5 轮上限后的自然出口;同时用于用户主动开启新话题 | + +--- + +## 2. 名词解释 + +| 术语 | 说明 | +| :------------- | :------------------------------------------------------------------------------------- | +| AI 助手 | 面向站点用户的通用问答对话功能 | +| 会话(Session)| 一次连续对话线程,拥有唯一 `sessionId`;左侧历史列表的 **每一行对应一个会话** | +| 轮次(Round) | **1 轮 = 1 条用户提问 + 1 条 AI 回复**(均成功落库后计 1 轮);单会话最多 **5 轮** | +| 新会话 | 用户主动点击「新会话」,创建新的 `sessionId`,右侧对话区清空,轮次计数从 0 开始 | +| 只读回看 | 从左侧点选 **已满 5 轮** 的会话,或用户仅查看历史时:展示完整消息记录,**不可再发送** | +| 继续对话 | 点选 **未满 5 轮** 的历史会话时:加载历史消息,可在该 `sessionId` 下继续追问 | +| 会话标题 | 列表展示用,默认取该会话 **首条用户提问** 前 30 字(超出以 `…` 结尾) | +| DashScope | 阿里云百炼 API,本系统通过 OpenAI 兼容 `chat/completions` 调用 | + +--- + +## 3. 功能需求清单 + +### 3.1 导航入口(前端) + +(与 V1.0 一致,编号保留。) + +#### 3.1.2 业务规则 + +| 编号 | 规则项 | 业务规则说明 | +| :------------------- | :----------- | :--------------------------------------------------------------------------------------------------- | +| FR-AI-CHAT-NAV-01 | 按钮位置 | 位于 `BlogLayout` 顶部 `el-menu` 内,紧挨「首页」之后,文案为「AI 助手」 | +| FR-AI-CHAT-NAV-02 | 路由 | 跳转 `/ai-assistant`,路由 `name` 建议 `AiAssistant`,页面标题「AI 助手」 | +| FR-AI-CHAT-NAV-03 | 激活态 | 当前路由为 `/ai-assistant` 时菜单项高亮 | +| FR-AI-CHAT-NAV-04 | 可见性 | 未登录可见入口;**发送消息、查看历史列表** 需登录(见 3.2) | +| FR-AI-CHAT-NAV-05 | 样式 | 与「首页」一致,可选聊天图标 | + +#### 3.1.3 验收标准 + +| 编号 | 验证场景 | 预期结果 | +| :------------------- | :--------------------------- | :------------------------------------------------- | +| AC-AI-CHAT-NAV-01 | 任意公开页查看顶部导航 | 可见「首页」与「AI 助手」 | +| AC-AI-CHAT-NAV-02 | 点击「AI 助手」 | 进入 `/ai-assistant` | +| AC-AI-CHAT-NAV-03 | 在 AI 助手页刷新 | 「AI 助手」为激活态 | + +--- + +### 3.2 AI 助手对话页(前端) + +#### 3.2.1 功能描述 + +新增 `AiAssistant.vue`(`BlogLayout` 子路由)。页面为 **左右分栏**: + +- **左侧(约 260px)**:顶部「新会话」按钮 + 当前用户的 **历史会话列表**(按最近更新时间倒序) +- **右侧**:当前会话的 **完整消息流**(用户/AI 气泡)+ 底部输入区与「发送」;显示当前会话 **轮次指示**(如 `3/5`) + +用户发送问题时携带当前 `sessionId`(新会话时由后端创建或前端先调创建接口获得)。成功后消息入库并在右侧追加。左侧列表在首条提问成功后出现/更新该会话项。 + +#### 3.2.2 页面布局 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Jog Logo [首页] [AI助手] 登录/用户 │ +├──────────────┬─────────────────────────────────────────────────────┤ +│ [ 新会话 ] │ 当前会话标题(首问摘要) 轮次 3/5 │ +│──────────────│─────────────────────────────────────────────────────│ +│ 历史会话列表 │ · 用户:……(右/主色) │ +│ · 如何在… │ · AI:……(左/灰底) │ +│ 05-23 14:20│ · 用户:…… │ +│ · Spring… │ · AI:…… │ +│ 05-22 09:10│─────────────────────────────────────────────────────│ +│ (滚动) │ [ 请输入您的问题... ] [ 发送 ] │ +└──────────────┴─────────────────────────────────────────────────────┘ +``` + +#### 3.2.3 业务规则 + +| 编号 | 规则项 | 业务规则说明 | +| :------------------- | :--------------- | :---------------------------------------------------------------------------------------------------------------------- | +| FR-AI-CHAT-FE-01 | 分栏布局 | 左侧固定宽约 260px,右侧 `flex:1`;小屏(<768px)可折叠左侧为抽屉(可选,首版允许左右同时展示并横向滚动) | +| FR-AI-CHAT-FE-02 | 新会话按钮 | 左侧顶部主按钮「新会话」;点击后:清空右侧消息、清空当前 `sessionId`、轮次显示 `0/5`、输入区可用;**不删除** 已有历史会话 | +| FR-AI-CHAT-FE-03 | 历史列表项 | 展示会话标题(首问前 30 字)+ 最后更新时间;当前选中会话高亮;默认进入页面时选中 **最近一次活跃会话**(若无则空状态) | +| FR-AI-CHAT-FE-04 | 点选历史 | 调用 `GET .../sessions/{id}/messages` 加载 **该会话全部消息**(按时间正序),右侧完整展示用户提问与 AI 回复全文 | +| FR-AI-CHAT-FE-05 | 继续 vs 只读 | 若该会话 `roundCount < 5`:加载后可继续发送(`sessionId` 设为当前);若 `roundCount >= 5`:**只读回看**,输入区禁用,展示提示条 | +| FR-AI-CHAT-FE-06 | 五轮上限提示 | 当前会话已达 5 轮时:禁用发送;提示「当前会话已满 5 轮,请点击左侧 **新会话** 开始新的对话」;`ElMessage.warning` 可选 | +| FR-AI-CHAT-FE-07 | 轮次指示 | 右侧标题栏展示 `当前轮次/5`(仅统计已成功完成的 user+assistant 一对) | +| FR-AI-CHAT-FE-08 | 输入框 | `textarea`,占位符「请输入您的问题…」,1~6 行自适应 | +| FR-AI-CHAT-FE-09 | 发送 | 主按钮「发送」;`Ctrl/Cmd+Enter` 发送;trim 后 1~2000 字符 | +| FR-AI-CHAT-FE-10 | 登录校验 | 未登录:左侧列表为空或展示「登录后查看历史」;发送时提示并跳转 `/login?redirect=/ai-assistant` | +| FR-AI-CHAT-FE-11 | Loading | 请求中发送按钮 loading、输入只读;列表末「正在思考…」 | +| FR-AI-CHAT-FE-12 | 消息展示 | 用户/AI 分色气泡;AI 文本 `pre-wrap`;首版不做 Markdown | +| FR-AI-CHAT-FE-13 | 失败处理 | 若 AI 调用失败:**不** 将本轮计入 `roundCount`;用户消息可保留为「发送失败」状态或允许重试(见 3.3 FR-AI-CHAT-BE-15) | +| FR-AI-CHAT-FE-14 | 刷新恢复 | 刷新页面后重新拉取会话列表与当前/最近会话消息,**不依赖** localStorage 存对话正文 | +| FR-AI-CHAT-FE-15 | 空状态 | 无会话时右侧展示欢迎文案;左侧列表空状态「暂无对话,点击新会话开始」 | +| FR-AI-CHAT-FE-16 | 列表分页 | 会话列表默认加载最近 **50** 条,底部「加载更多」分页(`page`/`size`) | +| FR-AI-CHAT-FE-17 | 滚动 | 右侧消息区内部滚动;新消息后滚到底部;回看历史会话打开时滚到底部 | + +#### 3.2.4 验收标准 + +| 编号 | 验证场景 | 预期结果 | +| :------------------- | :----------------------------------------- | :--------------------------------------------------------------- | +| AC-AI-CHAT-FE-01 | 已登录,点击「新会话」后连续问答 3 次 | 右侧 3 组问答,指示 `3/5`,左侧出现 1 个会话项 | +| AC-AI-CHAT-FE-02 | 当前会话第 5 轮成功后尝试第 6 次发送 | 发送被拦截,提示点击「新会话」 | +| AC-AI-CHAT-FE-03 | 点击「新会话」后再提问 | 新会话独立入库,左侧新增或切换到新会话项 | +| AC-AI-CHAT-FE-04 | 点选已满 5 轮的历史会话 | 完整展示 5 轮共 10 条消息,输入区禁用,提示只读 | +| AC-AI-CHAT-FE-05 | 点选仅 2 轮的历史会话并继续提问 | 加载 2 轮历史后继续,第 3 轮成功后为 `3/5` | +| AC-AI-CHAT-FE-06 | 刷新页面 | 会话列表与最近会话消息与刷新前一致 | +| AC-AI-CHAT-FE-07 | 未登录进入页面 | 无法发送;引导登录 | + +--- + +### 3.3 AI 对话与会话接口(后端) + +#### 3.3.1 功能描述 + +提供会话管理与多轮对话能力: + +1. 创建/继续会话并发送消息(调用大模型时带入 **当前会话已成功落库的全部历史**,最多 **5 轮 = 10 条消息**) +2. 查询当前用户会话列表 +3. 查询指定会话下的全部消息(供左侧点选回看) + +所有问答 **写入数据库**;仅允许操作 **当前登录用户** 自己的会话。 + +#### 3.3.2 数据模型(Flyway) + +建议新增迁移 `V5__ai_chat_tables.sql`(版本号以仓库实际递增为准): + +**表 `ai_chat_session`** + +| 字段 | 类型 | 说明 | +| :------------- | :------------- | :---------------------------------------- | +| id | BIGINT PK | 会话 ID | +| user_id | BIGINT | 所属用户,索引 | +| title | VARCHAR(64) | 会话标题,首条用户消息截断 | +| round_count | TINYINT | 已完成轮次,0~5 | +| last_message_at| DATETIME | 最后一条消息时间,列表排序用 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | +| deleted | TINYINT | 逻辑删除,默认 0(与项目 `@TableLogic` 一致) | + +**表 `ai_chat_message`** + +| 字段 | 类型 | 说明 | +| :---------- | :------------ | :---------------------------------- | +| id | BIGINT PK | 消息 ID | +| session_id | BIGINT | 外键 → `ai_chat_session.id` | +| role | VARCHAR(16) | `user` / `assistant` | +| content | TEXT | 提问或回复 **全文** | +| sort_order | INT | 会话内顺序,从 1 递增 | +| created_at | DATETIME | 创建时间 | + +#### 3.3.3 接口清单 + +| 方法 | 路径 | 说明 | +| :----- | :------------------------------------- | :----------------------------- | +| POST | `/api/app/ai/sessions` | 创建空会话,返回 `sessionId` | +| GET | `/api/app/ai/sessions` | 分页查询当前用户会话列表 | +| GET | `/api/app/ai/sessions/{sessionId}/messages` | 查询会话全部消息(正序) | +| POST | `/api/app/ai/chat` | 在指定会话中发送用户消息并返回 AI 回复 | + +#### 3.3.4 发送消息接口 `POST /api/app/ai/chat` + +**请求体** + +```json +{ + "sessionId": 1001, + "message": "那和 GraphQL 有什么区别?" +} +``` + +| 字段 | 必填 | 说明 | +| :---------- | :--- | :------------------------------------------------------------------- | +| sessionId | 是 | 会话 ID;须属于当前用户且 `round_count < 5` | +| message | 是 | 本条用户提问,1~2000 字符(trim 后) | + +**处理流程** + +1. 校验登录、`sessionId` 归属、`round_count < 5`、`message` 合法 +2. 插入 `ai_chat_message`(role=`user`) +3. 读取该会话已有消息(最多 5 轮历史),按 `sort_order` 组装 `messages` 数组送入 DashScope(含 System Prompt) +4. 调用模型,得到 `reply` +5. 插入 `ai_chat_message`(role=`assistant`,content=全文) +6. 更新 `session.round_count += 1`、`last_message_at`;若为首条用户消息则更新 `title` +7. 返回 `reply` 及更新后的 `roundCount` + +**响应 `data` 示例** + +```json +{ + "sessionId": 1001, + "reply": "GraphQL 与 REST 的主要区别在于……", + "roundCount": 3, + "maxRounds": 5, + "model": "qwen-turbo", + "promptTokens": 512, + "completionTokens": 180, + "costMillis": 2400 +} +``` + +#### 3.3.5 业务规则 + +| 编号 | 规则项 | 业务规则说明 | +| :------------------- | :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| FR-AI-CHAT-BE-01 | 鉴权 | 全部接口需登录;`sessionId` 必须属于当前 `userId`,否则 `403` 或 `404` | +| FR-AI-CHAT-BE-02 | 轮次上限 | `round_count >= 5` 时拒绝 `POST /chat`,返回 `400`,message:「当前会话已满 5 轮,请新建会话」 | +| FR-AI-CHAT-BE-03 | 多轮上下文 | 调用模型时传入:System Prompt + 该会话已落库的 user/assistant 消息(按时间序),**最多 10 条**(5 轮);不含本次尚未入库的 user 消息则先入库 user 再调模型 | +| FR-AI-CHAT-BE-04 | 上下文长度保护 | 组装后的总字符数超过 `dashscope.max-input`(默认 8000)时,**从最早的用户/助手对开始丢弃**,直至不超限(System 与当前 user 消息保留) | +| FR-AI-CHAT-BE-05 | 存储 | 用户 `message` 与 AI `reply` **全文** 写入 `ai_chat_message.content`,供历史回看 | +| FR-AI-CHAT-BE-06 | 会话列表 | `GET /sessions` 按 `last_message_at` 降序;返回 `id, title, roundCount, lastMessageAt`;默认 `size=50` | +| FR-AI-CHAT-BE-07 | 消息列表 | `GET /sessions/{id}/messages` 返回 `id, role, content, sortOrder, createdAt` 全量正序 | +| FR-AI-CHAT-BE-08 | 新会话 | `POST /sessions` 创建 `round_count=0` 的记录;前端「新会话」优先调此接口拿到 `sessionId` 再提问 | +| FR-AI-CHAT-BE-09 | 模型与 Key | 复用 `DashScopeProperties`;Key 缺失返回既有 `AI_API_KEY_MISSING` | +| FR-AI-CHAT-BE-10 | System Prompt | 与 V1.0 一致,并补充:「当前为同一会话的多轮对话,请结合上文回答,避免重复已说过的内容。」 | +| FR-AI-CHAT-BE-11 | 回复截断 | 单条 `reply` 超过 4000 字符截断并 `…` | +| FR-AI-CHAT-BE-12 | 超时/限流/异常 | 超时 30s → `5002`;用户每分钟最多 **10 次** `POST /chat`(Redis `ai_chat:rate:`);DashScope 失败 → `5001` | +| FR-AI-CHAT-BE-13 | 日志 | 日志中 **不打印** 问答全文,可记录 `sessionId`、轮次、字符长度、token、耗时 | +| FR-AI-CHAT-BE-14 | 软删除 | 会话表支持逻辑删除(为后续「删除历史」预留);首版前端 **不提供** 删除入口 | +| FR-AI-CHAT-BE-15 | 失败不落 assistant | 模型调用失败时:已插入的 user 消息 **保留**;不插入 assistant、不增加 `round_count`;返回错误码,前端允许用户 **重试发送**(同 `sessionId` 不重复插 user,或后端按幂等键去重——首版允许重复 user 消息则需在需求中禁止,**采用:失败时仅重试模型,不重复插入 user**) | + +**FR-AI-CHAT-BE-15 实现约定**:发送接口在步骤 2 插入 user 后若模型失败,user 消息保留;重试时若上一条为未配对的 user,则 **不再插入新 user**,仅重新请求模型并写入 assistant。 + +#### 3.3.6 错误码补充 + +| 错误码 / HTTP | 描述 | 触发条件 | +| :------------ | :----------------------- | :-------------------------------- | +| 400 | 参数错误 | message 非法 | +| 400 | 会话轮次已满 | `round_count >= 5` | +| 403/404 | 会话不存在或无权访问 | `sessionId` 非本人 | +| 401 | 未登录 | 无 Token | +| 429 | 请求过于频繁 | 限流 | +| 5001/5002 | AI 不可用 / 超时 | DashScope 异常 | + +#### 3.3.7 验收标准 + +| 编号 | 验证场景 | 预期结果 | +| :------------------- | :------------------------------------ | :-------------------------------------------- | +| AC-AI-CHAT-BE-01 | 新会话连续 5 次成功 `/chat` | `round_count=5`,第 6 次返回 400 | +| AC-AI-CHAT-BE-02 | 第 3 轮提问含指代「它」 | 模型请求体含前 2 轮共 4 条历史消息 | +| AC-AI-CHAT-BE-03 | `GET .../messages` | 返回条数与轮次一致,content 为当时全文 | +| AC-AI-CHAT-BE-04 | 用户 A 访问用户 B 的 sessionId | 403/404 | +| AC-AI-CHAT-BE-05 | 模型失败后重试 | 不重复 user 行,成功后 round_count 正确 +1 | + +--- + +### 3.4 配置与部署 + +仍复用 `dashscope.*` 与 `DASHSCOPE_API_KEY`。新增表由 Flyway 自动迁移。部署后验证:多轮对话、刷新恢复、第 5 轮后新建会话。 + +--- + +## 4. 交互流程 + +### 4.1 首次提问(新会话) + +``` +1. 用户进入 /ai-assistant(已登录) +2. 点击「新会话」→ POST /sessions → 获得 sessionId +3. 输入问题 → POST /chat { sessionId, message } +4. 后端存 user → 调模型(仅 System + 本条 user)→ 存 assistant → roundCount=1 +5. 右侧展示问答;左侧列表出现该会话(标题=首问截断) +``` + +### 4.2 同会话多轮追问 + +``` +1. 用户在当前 session 继续输入(roundCount < 5) +2. POST /chat → 后端载入已有消息作为上下文 → 返回 reply +3. roundCount 递增;满 5 后 FR-AI-CHAT-FE-06 拦截发送 +``` + +### 4.3 回看历史会话 + +``` +1. 用户点击左侧某会话项 +2. GET /sessions/{id}/messages → 右侧展示全部 user/assistant 全文 +3. 若 roundCount < 5:可继续发送;若 = 5:只读 + 提示新会话 +``` + +### 4.4 满 5 轮后继续提问 + +``` +1. 用户在本会话第 5 轮成功后再次发送 +2. 前端拦截并提示「请点击新会话」 +3. 用户点击「新会话」→ 新 sessionId → 重新开始 0/5 +``` + +--- + +## 5. 接口与代码对齐(规划) + +### 5.1 后端 + +| 模块 | 文件 | 变更说明 | +| :-------------- | :---------------------------------------------------------------- | :-------------------------------- | +| jog-admin | `db/migration/V5__ai_chat_tables.sql` | 会话与消息表 | +| jog-module-blog | `entity/AiChatSession.java`, `AiChatMessage.java` | 实体 | +| jog-module-blog | `mapper/...` | MyBatis-Plus Mapper | +| jog-module-blog | `dto/*`, `vo/*` | 请求/响应对象 | +| jog-module-blog | `service/AiChatService` + `impl` | 多轮组装、持久化、DashScope 调用 | +| jog-module-blog | `controller/AiChatController.java` | 上述 REST 接口 | + +### 5.2 前端 + +| 模块 | 文件 | 变更说明 | +| :----------- | :---------------------------------------- | :-------------------------- | +| jog-frontend | `layouts/BlogLayout.vue` | 「AI 助手」导航 | +| jog-frontend | `router/index.js` | `/ai-assistant` | +| jog-frontend | `views/AiAssistant.vue` | 左右分栏主页面 | +| jog-frontend | `components/AiChatSidebar.vue`(可选拆分)| 左侧列表 + 新会话 | +| jog-frontend | `components/AiChatPanel.vue`(可选拆分) | 右侧消息 + 输入 | +| jog-frontend | `api/aiChat.js` | 会话与 chat API 封装 | + +--- + +## 6. 非功能性需求 + +| 编号 | 类型 | 需求说明 | +| :----- | :------- | :----------------------------------------------------------------------- | +| NFR-01 | 性能 | 95 分位 ≤ 15s;5 轮上下文受 `max-input` 截断保护 | +| NFR-02 | 可用性 | AI 不可用时仍可回看已存历史;失败可重试当前轮 | +| NFR-03 | 安全性 | 会话严格按 `user_id` 隔离;API Key 不入库/日志/仓库;HTTPS 传输 | +| NFR-04 | 隐私 | 对话正文存业务库,仅本人可读;管理后台 **不提供** 查看他人工单(首版) | +| NFR-05 | 可扩展性 | 轮次上限、列表条数可配置化;预留流式 SSE、删除会话 | +| NFR-06 | 容量 | 首版会话列表 **仅分页加载**(默认每页 50 条),不设单用户数量硬上限,**不自动删除** 历史会话 | +| NFR-07 | 可观测性 | 记录 sessionId、轮次、耗时、token;日志不打印全文 | + +--- + +## 7. 范围外(后续迭代) + +| 项 | 说明 | +| :----------- | :----------------------------------------- | +| 流式输出 | SSE 逐字展示回复 | +| 删除会话 | 前端删除 + 逻辑删除接口 | +| 未登录试用 | 公开 API + IP 限流 | +| Markdown渲染 | AI 回复富文本(需 XSS 防护) | +| 会话重命名 | 用户自定义左侧标题 | +| 导出对话 | 导出 Markdown/PDF | + +--- + +## 8. 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +| :------------------- | :---------------- | :-------------------------------------------- | +| 5 轮上下文 Token 过大 | 超时/费用 | `max-input` 截断最早轮次 | +| 历史表数据增长 | 存储成本 | 分页列表;后续归档策略 | +| 满 5 轮体验断裂 | 用户困惑 | 明确提示 +「新会话」一键 | +| 失败重试重复 user | 轮次与展示错乱 | BE-15 幂等重试约定 | + +--- + +## 9. 产品约定(已确认) + +以下约定已于 **2026-05-23** 与产品方确认,作为 V1.1 正式需求的一部分,开发与测试须按此实现。 + +| 编号 | 约定项 | 需求说明 | +| :--- | :----- | :------- | +| D-01 | 左侧列表按 **会话** 聚合 | 每会话占列表一行;标题取首条用户提问前 30 字;点选后右侧展示该会话下 **全部** 问答全文(非按单条提问分行) | +| D-02 | 历史会话可继续对话 | `roundCount < 5` 的会话:加载历史后 **可继续发送**;`roundCount >= 5` 的会话:**只读回看**,输入区禁用并引导「新会话」 | +| D-03 | 新会话不删除旧数据 | 点击「新会话」仅创建并切换至新 `sessionId`,清空右侧编辑区;**不删除、不覆盖** 已有会话数据 | +| D-04 | 首版不提供删除与重命名 | 前端无删除会话、无重命名入口;`ai_chat_session.deleted` 字段预留供后续迭代(见 §7) | +| D-05 | 失败不计轮、支持同轮重试 | 模型调用失败时不插入 assistant、不增加 `round_count`;允许对未配对的 user 消息重试,**不重复插入** user(见 FR-AI-CHAT-BE-15) | +| D-06 | 进入页默认最近会话 | 已登录用户进入 `/ai-assistant` 时,默认选中 **最近更新** 的会话并加载其消息;无任何会话时展示空状态,等同待「新会话」 | +| D-07 | 会话保留策略 | 历史会话 **仅分页展示**(默认每页 50 条),**不设** 单用户数量硬上限,**不自动删除** 最旧会话(与 NFR-06 一致) | + +--- + +**编写人:** AI 助手 +**评审人:** 已确认(产品约定 D-01~D-07) +**生效日期:** 2026-05-23 diff --git a/jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql b/jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql new file mode 100644 index 0000000..f45d3fb --- /dev/null +++ b/jog-admin/src/main/resources/db/migration/V5__ai_chat_tables.sql @@ -0,0 +1,24 @@ +-- V5__ai_chat_tables.sql +-- AI 助手:会话与消息持久化 + +CREATE TABLE ai_chat_session ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(64) NOT NULL DEFAULT '', + round_count TINYINT NOT NULL DEFAULT 0, + last_message_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT NOT NULL DEFAULT 0, + INDEX idx_user_last (user_id, last_message_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE ai_chat_message ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + session_id BIGINT NOT NULL, + role VARCHAR(16) NOT NULL, + content TEXT NOT NULL, + sort_order INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_order (session_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java index d5f027c..fd52fce 100644 --- a/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java +++ b/jog-common/src/main/java/fun/nojava/common/exception/ErrorCode.java @@ -52,7 +52,8 @@ public enum ErrorCode { AI_SERVICE_TIMEOUT(5002, "AI 服务响应超时"), AI_CONTENT_REJECTED(5003, "AI 内容审核未通过"), AI_CONTENT_TOO_SHORT(5004, "正文过短,无法生成摘要"), - AI_API_KEY_MISSING(5005, "AI 服务未配置 API Key"); + AI_API_KEY_MISSING(5005, "AI 服务未配置 API Key"), + AI_CHAT_SESSION_FULL(5006, "当前会话已满 5 轮,请新建会话"); private final int code; private final String message; diff --git a/jog-frontend/src/api/aiChat.js b/jog-frontend/src/api/aiChat.js new file mode 100644 index 0000000..65a04bc --- /dev/null +++ b/jog-frontend/src/api/aiChat.js @@ -0,0 +1,17 @@ +import request from '@/utils/request' + +export function createAiChatSession() { + return request.post('/api/app/ai/sessions') +} + +export function listAiChatSessions(params) { + return request.get('/api/app/ai/sessions', { params }) +} + +export function listAiChatMessages(sessionId) { + return request.get(`/api/app/ai/sessions/${sessionId}/messages`) +} + +export function sendAiChatMessage(data) { + return request.post('/api/app/ai/chat', data, { timeout: 35000 }) +} diff --git a/jog-frontend/src/components.d.ts b/jog-frontend/src/components.d.ts index 1646a0f..0f5ee6a 100644 --- a/jog-frontend/src/components.d.ts +++ b/jog-frontend/src/components.d.ts @@ -7,6 +7,8 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AiChatPanel: typeof import('./components/AiChatPanel.vue')['default'] + AiChatSidebar: typeof import('./components/AiChatSidebar.vue')['default'] ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElButton: typeof import('element-plus/es')['ElButton'] diff --git a/jog-frontend/src/components/AiChatPanel.vue b/jog-frontend/src/components/AiChatPanel.vue new file mode 100644 index 0000000..6d38c47 --- /dev/null +++ b/jog-frontend/src/components/AiChatPanel.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/jog-frontend/src/components/AiChatSidebar.vue b/jog-frontend/src/components/AiChatSidebar.vue new file mode 100644 index 0000000..e35de1d --- /dev/null +++ b/jog-frontend/src/components/AiChatSidebar.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/jog-frontend/src/layouts/BlogLayout.vue b/jog-frontend/src/layouts/BlogLayout.vue index 75211e1..20d84de 100644 --- a/jog-frontend/src/layouts/BlogLayout.vue +++ b/jog-frontend/src/layouts/BlogLayout.vue @@ -7,6 +7,9 @@ 首页 + + AI 助手 +