Files
web/docs/superpowers/specs/2026-04-14-delete-message-pair-submission-design.md
xuyueandClaude Opus 5.5 47d4679984 chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:12 +08:00

181 lines
5.7 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 对话轮次及关联提交
**日期:** 2026-04-14
**状态:** 已批准
---
## 需求
学生在挑战页的「AI 对话」tab 中,可以删除某一轮对话(一问一答),同时删除该 AI 回答自动生成的提交。反向也成立:在提交列表中删除一条提交,同时删除对应的那对消息。
---
## 数据模型
**`prompt/Message` 新增字段:**
```python
submission = models.OneToOneField(
"submission.Submission",
null=True, blank=True,
on_delete=models.SET_NULL,
related_name="source_message"
)
```
- `OneToOneField`:一条 assistant message 最多对应一个提交
- `SET_NULL`:FK 层面保底,防止外部操作引发 crash
- 业务级联删除在接口层显式处理(不依赖 `CASCADE`)
需新建 migration。
---
## 后端
### Schema 变更
`api/submission/schemas.py` 中 `SubmissionIn` 新增:
```python
message_id: Optional[UUID] = None
```
### 接口变更
**`POST /submission/`(create_submission)**
创建 Submission 后,若 `payload.message_id` 不为空:
1. 查找对应的 assistant Message(`source="conversation"`,属于当前 user+task)
2. 设置 `message.submission = submission` 并保存
**`DELETE /submission/{id}`(delete_submission)**
删除 Submission 前:
1. 通过 `submission.source_message`(`related_name`)找到关联的 assistant message(若有)
2. 找到该 assistant message 在同一 conversation 中前一条 user message(按 `created` 排序取紧邻的上一条)
3. 依次删除:user message → assistant message → submission
**新增 `DELETE /prompt/messages/{message_id}/pair`**
- 鉴权:`message.conversation.user == request.user`,否则 403
- 操作:
1. 找到 assistant message(`id=message_id`,`role="assistant"`)
2. 找到同 conversation 中紧邻的前一条 user message
3. 若 assistant message 有关联 submission,删除它
4. 删除 assistant message 和 user message
- 返回 `{"deleted": true, "submission_deleted": bool}`
> **为何用 assistant message id 而非 user message id:**
> `complete` 事件由后端发出,assistant message 在那一刻刚被保存,id 唯一确定;user message 已在之前保存,前端也持有,但以 assistant message 为锚点更自然。
### WebSocket 变更(`prompt/consumers.py`)
**`complete` 事件新增 `message_id`:**
```python
await self.send(text_data=json.dumps({
"type": "complete",
"code": code,
"message_id": str(assistant_message.id), # 新增
}))
```
**`init` 事件的消息列表每条带 `id`:**
```python
{
"id": str(m.id), # 新增
"role": m.role,
"content": m.content,
"code": {...} if m.role == "assistant" else None,
"created": m.created.isoformat(),
}
```
---
## 前端
### `store/prompt.ts`
`PromptMessage` 接口新增:
```typescript
id?: string // assistant message 的后端 id,用于删除
```
`complete` 事件处理:从事件中提取 `message_id`,存入 push 进 `messages` 的那条 assistant message。
`_onCodeComplete` 回调签名扩展,传入 `messageId`:
```typescript
type OnCodeComplete = (code: { html: string | null; css: string | null; js: string | null }, messageId: string) => void
```
`init` 事件处理:将后端返回的 `id` 存入每条消息。
### `api.ts`
**`Submission.create()`** 新增可选参数:
```typescript
async create(taskId, code, messageId?: string)
// 请求体加上 message_id: messageId ?? null
```
**`Prompt` 新增方法:**
```typescript
async deleteMessagePair(messageId: string): Promise<{ deleted: boolean; submission_deleted: boolean }>
```
### `ChallengeDetail.vue`
`setOnCodeComplete` 的回调改为接收 `messageId`,并将其传给 `Submission.create()`:
```typescript
setOnCodeComplete(async (code, messageId) => {
await Submission.create(taskId.value, { ...code }, messageId)
})
```
### `PromptPanel.vue`
**消息分组:** 将 `messages` 按 user+assistant 顺序两两分组,形成 `pairs`(computed)。不完整的末尾单条消息(仍在等待 AI 回复)不显示删除按钮。
**删除 UI:** 每个 pair 右上角显示一个垃圾桶图标(`lucide:trash-2`),hover 时显现。点击后:
1. 调用 `Prompt.deleteMessagePair(pair.assistantMessage.id)`
2. 成功后从本地 `messages` 中移除这两条
**交互细节:**
- 使用 Naive UI 的 `useMessage()` 给出成功/失败通知
- streaming 期间禁用所有删除按钮(`disabled` when `streaming`)
- 没有 `id` 的消息(历史消息加载前的边缘情况)不显示删除按钮
---
## 不在本次范围内
- 手动提交(ExternalAIPanel)产生的消息:它们以 `source="manual"` 存储,不在 PromptPanel 显示,无需处理
- 提交列表页的实时刷新:删除后消息已从后端移除,下次打开挑战页重连 WebSocket 时自然反映
- 管理员代替学生删除:本次仅限学生删自己的
---
## 文件改动清单
| 文件 | 类型 |
|------|------|
| `api/prompt/models.py` | 新增 `submission` 字段 |
| `api/prompt/migrations/0005_*.py` | 新建 migration |
| `api/prompt/api.py` | 新增 `DELETE /messages/{id}/pair` 端点 |
| `api/prompt/consumers.py` | `complete`/`init` 事件带 `message_id` |
| `api/submission/schemas.py` | `SubmissionIn` 加 `message_id` |
| `api/submission/api.py` | create 关联消息;delete 级联删消息 |
| `client/src/store/prompt.ts` | `PromptMessage` 加 `id`;回调传 `messageId` |
| `client/src/api.ts` | `Submission.create` 加 `messageId`;`Prompt.deleteMessagePair` |
| `client/src/pages/ChallengeDetail.vue` | `setOnCodeComplete` 传 `messageId` |
| `client/src/components/ai/PromptPanel.vue` | 消息分组 + 删除按钮 |