Files
web/docs/superpowers/plans/2026-04-14-delete-message-pair-submission.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

39 KiB
Raw Permalink Blame History

删除 AI 对话轮次及关联提交 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 学生可以在 AI 对话界面删除某一轮对话(一问一答),同时删除该 AI 回答自动生成的提交;反向亦然——从提交列表删提交时,同时删除关联的那对消息。

Architecture: 在 prompt.Message(assistant 消息)上加一个可空的 OneToOneField 指向 submission.Submission,在提交创建时由前端传入 message_id 完成关联。两个方向的级联删除都在接口层显式处理。前端在 PromptPanel 的每个对话轮次上显示删除按钮,点击后同时移除本地消息状态。

Tech Stack: Django Ninja, Django Channels (AsyncWebsocketConsumer), Vue 3, TypeScript, Naive UI


文件改动清单

文件 改动类型
api/prompt/models.py 修改:新增 submission 字段
api/prompt/migrations/0005_message_add_submission_fk.py 新建 migration
api/prompt/consumers.py 修改:complete/init 事件带 message_id
api/prompt/api.py 修改:新增 DELETE /messages/{message_id}/pair 端点
api/prompt/tests.py 新建:后端接口测试
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 修改:消息分组 + 删除按钮

Task 1:Message 模型添加 submission 外键

Files:

  • Modify: api/prompt/models.py

  • Create: api/prompt/migrations/0005_message_add_submission_fk.py(由 makemigrations 生成)

  • Step 1: 修改 api/prompt/models.py,添加 submission 字段

    在 Message 类中,prompt_level 字段之后添加:

    # api/prompt/models.py
    from django.db import models
    from django_extensions.db.models import TimeStampedModel
    from account.models import User
    from task.models import Task
    import uuid
    
    
    class Conversation(TimeStampedModel):
        id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
        user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="conversations")
        task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="conversations")
        is_active = models.BooleanField(default=True, verbose_name="是否活跃")
    
        class Meta:
            ordering = ("-created",)
    
        def __str__(self):
            return f"{self.user.username} - {self.task.title}"
    
    
    class Message(models.Model):
        conversation = models.ForeignKey(
            Conversation, on_delete=models.CASCADE, related_name="messages"
        )
        role = models.CharField(max_length=10)
        source = models.CharField(max_length=12, default="conversation")
        content = models.TextField()
        code_html = models.TextField(null=True, blank=True)
        code_css = models.TextField(null=True, blank=True)
        code_js = models.TextField(null=True, blank=True)
        created = models.DateTimeField(auto_now_add=True)
        prompt_level = models.IntegerField(
            null=True, blank=True, default=None, db_index=True, verbose_name="提示词层级"
        )
        submission = models.OneToOneField(
            "submission.Submission",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="source_message",
        )
    
        class Meta:
            ordering = ("created",)
    
        def __str__(self):
            return f"[{self.role}] {self.content[:50]}"
    
  • Step 2: 生成 migration

    cd api && uv run manage.py makemigrations prompt --name message_add_submission_fk
    

    预期输出:

    Migrations for 'prompt':
      prompt/migrations/0005_message_add_submission_fk.py
        - Add field submission to message
    
  • Step 3: 运行 migration

    uv run manage.py migrate
    

    预期输出:末行包含 Applying prompt.0005_message_add_submission_fk... OK


Task 2:WebSocket Consumer 发送 message_id

Files:

  • Modify: api/prompt/consumers.py

  • Step 1: 修改 consumers.py,save_message 返回 Message 对象,complete 事件带 message_id,init 历史消息带 id

    完整替换 api/prompt/consumers.py:

    import json
    from channels.generic.websocket import AsyncWebsocketConsumer
    from channels.db import database_sync_to_async
    from django.db.models import Count
    from .models import Conversation, Message
    from .llm import stream_chat, extract_code
    
    
    class PromptConsumer(AsyncWebsocketConsumer):
        async def connect(self):
            self.user = self.scope["user"]
            if self.user.is_anonymous:
                await self.close()
                return
    
            self.task_id = int(self.scope["url_route"]["kwargs"]["task_id"])
            self.current_user_message = None
            await self.accept()
    
            self.conversation = await self.get_or_create_conversation()
            history = await self.get_history()
            await self.send(text_data=json.dumps({
                "type": "init",
                "conversation_id": str(self.conversation.id),
                "messages": history,
            }))
    
        async def disconnect(self, close_code):
            if self.current_user_message:
                await self.delete_message(self.current_user_message)
                self.current_user_message = None
    
        async def receive(self, text_data):
            data = json.loads(text_data)
            msg_type = data.get("type", "message")
    
            if msg_type == "new_conversation":
                return
    
            prompt = data.get("content", "").strip()
            model = data.get("model", "")
            if not prompt:
                return
    
            self.current_user_message = await self.save_message("user", prompt)
    
            try:
                history = await self.get_history_for_llm()
    
                full_response = ""
                try:
                    async for chunk in stream_chat(history, model=model):
                        full_response += chunk
                        await self.send(text_data=json.dumps({
                            "type": "stream",
                            "content": chunk,
                        }))
                except Exception as e:
                    await self.send(text_data=json.dumps({
                        "type": "error",
                        "content": f"AI 服务出错:{str(e)}",
                    }))
                    return
    
                code = extract_code(full_response)
                assistant_msg = await self.save_message("assistant", full_response, code)
                self.current_user_message = None
    
                await self.send(text_data=json.dumps({
                    "type": "complete",
                    "code": code,
                    "message_id": assistant_msg.id,  # 新增
                }))
    
            finally:
                if self.current_user_message:
                    await self.delete_message(self.current_user_message)
                    self.current_user_message = None
    
        @database_sync_to_async
        def get_or_create_conversation(self):
            conv = (
                Conversation.objects.filter(user=self.user, task_id=self.task_id)
                .annotate(msg_count=Count("messages"))
                .order_by("-msg_count", "-created")
                .first()
            )
            if not conv:
                conv = Conversation.objects.create(user=self.user, task_id=self.task_id)
            return conv
    
        @database_sync_to_async
        def delete_message(self, message):
            message.delete()
    
        @database_sync_to_async
        def save_message(self, role, content, code=None):
            return Message.objects.create(
                conversation=self.conversation,
                role=role,
                content=content,
                code_html=code.get("html") if code else None,
                code_css=code.get("css") if code else None,
                code_js=code.get("js") if code else None,
            )
    
        @database_sync_to_async
        def get_history(self):
            messages = self.conversation.messages.filter(source="conversation")
            return [
                {
                    "id": m.id,  # 新增
                    "role": m.role,
                    "content": m.content,
                    "code": {
                        "html": m.code_html,
                        "css": m.code_css,
                        "js": m.code_js,
                    } if m.role == "assistant" else None,
                    "created": m.created.isoformat(),
                }
                for m in messages
            ]
    
        @database_sync_to_async
        def get_history_for_llm(self):
            messages = self.conversation.messages.filter(source="conversation")
            return [{"role": m.role, "content": m.content} for m in messages]
    
  • Step 2: 启动后端,手动验证 WebSocket complete 事件包含 message_id

    cd api && uv run manage.py runserver
    

    在浏览器打开挑战页,发送一条 AI 消息,在 DevTools Network → WS 帧中确认 complete 事件有 message_id 整数字段,init 事件每条消息有 id 字段。


Task 3:Submission 创建时关联 message

Files:

  • Modify: api/submission/schemas.py

  • Modify: api/submission/api.py

  • Create: api/prompt/tests.py

  • Step 1: 在 SubmissionIn 里加 message_id

    修改 api/submission/schemas.py,在 SubmissionIn 末尾添加一行:

    class SubmissionIn(Schema):
        task_id: int
        html: Optional[str] = None
        css: Optional[str] = None
        js: Optional[str] = None
        prompt: Optional[str] = None
        message_id: Optional[int] = None  # 新增:关联的 assistant message pk
    
  • Step 2: 写失败测试

    新建 api/prompt/tests.py:

    from django.test import TestCase
    from django.contrib.auth import get_user_model
    from task.models import Task
    from submission.models import Submission
    from prompt.models import Conversation, Message
    
    User = get_user_model()
    
    
    def _make_user(username="student1"):
        return User.objects.create_user(username=username, password="pw")
    
    
    def _make_task():
        return Task.objects.create(
            title="Test Task", task_type="challenge", display=1, content=""
        )
    
    
    class SubmissionMessageLinkTest(TestCase):
        def setUp(self):
            self.client.login(username="student1", password="pw")
            self.user = _make_user("student1")
            self.task = _make_task()
            self.conv = Conversation.objects.create(user=self.user, task=self.task)
            self.user_msg = Message.objects.create(
                conversation=self.conv, role="user", content="帮我做个按钮"
            )
            self.asst_msg = Message.objects.create(
                conversation=self.conv, role="assistant", content="好的",
                code_html="<button>OK</button>", code_css="", code_js=""
            )
    
        def test_create_submission_links_message(self):
            """POST /submission/ with message_id links the assistant message"""
            self.client.force_login(self.user)
            resp = self.client.post(
                "/api/submission/",
                data={
                    "task_id": self.task.id,
                    "html": "<button>OK</button>",
                    "css": "",
                    "js": "",
                    "message_id": self.asst_msg.id,
                },
                content_type="application/json",
            )
            self.assertEqual(resp.status_code, 200)
            self.asst_msg.refresh_from_db()
            sub = Submission.objects.filter(user=self.user, task=self.task).first()
            self.assertIsNotNone(sub)
            self.assertEqual(self.asst_msg.submission, sub)
    
        def test_create_submission_without_message_id(self):
            """POST /submission/ without message_id still works"""
            self.client.force_login(self.user)
            resp = self.client.post(
                "/api/submission/",
                data={"task_id": self.task.id, "html": "<p>hi</p>", "css": "", "js": ""},
                content_type="application/json",
            )
            self.assertEqual(resp.status_code, 200)
    
  • Step 3: 运行测试,确认失败

    cd api && uv run manage.py test prompt.tests.SubmissionMessageLinkTest -v 2
    

    预期:test_create_submission_links_message FAIL(asst_msg.submission 仍为 None)

  • Step 4: 修改 create_submission 关联消息

    在 api/submission/api.py 的 create_submission 函数末尾,Submission.objects.create(...) 之后添加关联逻辑:

    @router.post("/")
    @login_required
    def create_submission(request, payload: SubmissionIn):
        task = get_object_or_404(Task, id=payload.task_id)
    
        if payload.prompt:
            conversation = (
                Conversation.objects.filter(user=request.user, task=task)
                .annotate(msg_count=Count("messages"))
                .order_by("-msg_count", "-created")
                .first()
            )
            if not conversation:
                conversation = Conversation.objects.create(
                    user=request.user, task=task, is_active=False
                )
            Message.objects.create(
                conversation=conversation, role="user", content=payload.prompt, source="manual"
            )
            Message.objects.create(
                conversation=conversation,
                role="assistant",
                content="",
                code_html=payload.html,
                code_css=payload.css,
                code_js=payload.js,
                source="manual",
            )
            from .classifier import classify_conversation_messages
            threading.Thread(target=classify_conversation_messages, args=(conversation.id,), daemon=True).start()
        else:
            conversation = (
                Conversation.objects.filter(user=request.user, task=task)
                .annotate(msg_count=Count("messages"))
                .order_by("-msg_count", "-created")
                .first()
            )
            if conversation:
                from .classifier import classify_conversation_messages
                threading.Thread(target=classify_conversation_messages, args=(conversation.id,), daemon=True).start()
    
        submission = Submission.objects.create(
            user=request.user,
            task=task,
            html=payload.html,
            css=payload.css,
            js=payload.js,
        )
    
        # 关联 assistant message(若前端传入了 message_id)
        if payload.message_id:
            from prompt.models import Message as PromptMessage
            try:
                msg = PromptMessage.objects.get(
                    id=payload.message_id,
                    role="assistant",
                    conversation__user=request.user,
                )
                msg.submission = submission
                msg.save(update_fields=["submission"])
            except PromptMessage.DoesNotExist:
                pass  # message_id 无效时静默忽略,提交本身已创建
    

    注意:from prompt.models import Message as PromptMessage 用别名是因为 submission/api.py 顶部已从 prompt.models 导入了 Conversation 和 Message。确认文件顶部已有 from prompt.models import Conversation, Message(api.py 第 10 行)——无需重复导入,直接用 Message 即可。实际写代码时检查顶部 import,若已有就直接用 Message。

  • Step 5: 运行测试,确认通过

    cd api && uv run manage.py test prompt.tests.SubmissionMessageLinkTest -v 2
    

    预期:2 tests PASS


Task 4:删除提交时级联删除关联消息对

Files:

  • Modify: api/submission/api.py

  • Modify: api/prompt/tests.py

  • Step 1: 写失败测试

    在 api/prompt/tests.py 末尾追加:

    class DeleteSubmissionCascadeTest(TestCase):
        def setUp(self):
            self.user = _make_user("student2")
            self.task = _make_task()
            self.conv = Conversation.objects.create(user=self.user, task=self.task)
            self.user_msg = Message.objects.create(
                conversation=self.conv, role="user", content="问题"
            )
            self.asst_msg = Message.objects.create(
                conversation=self.conv, role="assistant", content="回答",
                code_html="<p>hi</p>", code_css="", code_js=""
            )
            self.sub = Submission.objects.create(
                user=self.user, task=self.task, html="<p>hi</p>", css="", js=""
            )
            self.asst_msg.submission = self.sub
            self.asst_msg.save(update_fields=["submission"])
    
        def test_delete_submission_also_deletes_message_pair(self):
            """DELETE /submission/{id} deletes linked user+assistant messages"""
            self.client.force_login(self.user)
            resp = self.client.delete(f"/api/submission/{self.sub.id}")
            self.assertEqual(resp.status_code, 200)
            self.assertFalse(Submission.objects.filter(id=self.sub.id).exists())
            self.assertFalse(Message.objects.filter(id=self.asst_msg.id).exists())
            self.assertFalse(Message.objects.filter(id=self.user_msg.id).exists())
    
        def test_delete_submission_without_linked_message(self):
            """DELETE /submission/{id} works even with no linked message"""
            sub2 = Submission.objects.create(
                user=self.user, task=self.task, html="", css="", js=""
            )
            self.client.force_login(self.user)
            resp = self.client.delete(f"/api/submission/{sub2.id}")
            self.assertEqual(resp.status_code, 200)
            self.assertFalse(Submission.objects.filter(id=sub2.id).exists())
    
  • Step 2: 运行测试,确认失败

    cd api && uv run manage.py test prompt.tests.DeleteSubmissionCascadeTest -v 2
    

    预期:test_delete_submission_also_deletes_message_pair FAIL(消息未被删除)

  • Step 3: 修改 delete_submission 端点

    将 api/submission/api.py 中 delete_submission 函数替换为:

    @router.delete("/{submission_id}")
    @login_required
    def delete_submission(request, submission_id: UUID):
        submission = get_object_or_404(Submission, id=submission_id)
        if submission.user != request.user:
            raise HttpError(403, "只能删除自己的提交")
    
        # 找到关联的 assistant message 及其前一条 user message,一并删除
        asst_msg = Message.objects.filter(submission=submission).first()
        if asst_msg:
            user_msg = (
                Message.objects.filter(
                    conversation=asst_msg.conversation,
                    created__lt=asst_msg.created,
                    role="user",
                )
                .order_by("-created")
                .first()
            )
            if user_msg:
                user_msg.delete()
            asst_msg.delete()
    
        submission.delete()
        return {"message": "删除成功"}
    

    同时确认文件顶部已导入 Message——当前第 10 行有 from prompt.models import Conversation, Message,无需额外修改。

  • Step 4: 运行测试,确认通过

    cd api && uv run manage.py test prompt.tests.DeleteSubmissionCascadeTest -v 2
    

    预期:2 tests PASS


Task 5:新增「删除消息对」接口

Files:

  • Modify: api/prompt/api.py

  • Modify: api/prompt/tests.py

  • Step 1: 写失败测试

    在 api/prompt/tests.py 末尾追加:

    class DeleteMessagePairTest(TestCase):
        def setUp(self):
            self.user = _make_user("student3")
            self.task = _make_task()
            self.conv = Conversation.objects.create(user=self.user, task=self.task)
            self.user_msg = Message.objects.create(
                conversation=self.conv, role="user", content="问题"
            )
            self.asst_msg = Message.objects.create(
                conversation=self.conv, role="assistant", content="回答",
                code_html="<p>ok</p>", code_css="", code_js=""
            )
            self.sub = Submission.objects.create(
                user=self.user, task=self.task, html="<p>ok</p>", css="", js=""
            )
            self.asst_msg.submission = self.sub
            self.asst_msg.save(update_fields=["submission"])
    
        def test_delete_message_pair_also_deletes_submission(self):
            self.client.force_login(self.user)
            resp = self.client.delete(f"/api/prompt/messages/{self.asst_msg.id}/pair")
            self.assertEqual(resp.status_code, 200)
            self.assertFalse(Message.objects.filter(id=self.asst_msg.id).exists())
            self.assertFalse(Message.objects.filter(id=self.user_msg.id).exists())
            self.assertFalse(Submission.objects.filter(id=self.sub.id).exists())
            data = resp.json()
            self.assertTrue(data["deleted"])
            self.assertTrue(data["submission_deleted"])
    
        def test_delete_message_pair_without_submission(self):
            asst2 = Message.objects.create(
                conversation=self.conv, role="assistant", content="另一条"
            )
            user2 = Message.objects.create(
                conversation=self.conv, role="user", content="另一问"
            )
            # 确保 asst2.created > user2.created(auto_now_add 按创建顺序)
            self.client.force_login(self.user)
            resp = self.client.delete(f"/api/prompt/messages/{asst2.id}/pair")
            self.assertEqual(resp.status_code, 200)
            data = resp.json()
            self.assertFalse(data["submission_deleted"])
    
        def test_delete_message_pair_forbidden_for_other_user(self):
            other = _make_user("other")
            self.client.force_login(other)
            resp = self.client.delete(f"/api/prompt/messages/{self.asst_msg.id}/pair")
            self.assertEqual(resp.status_code, 403)
    
  • Step 2: 运行测试,确认失败

    cd api && uv run manage.py test prompt.tests.DeleteMessagePairTest -v 2
    

    预期:全部 FAIL(端点不存在,返回 404)

  • Step 3: 在 api/prompt/api.py 末尾添加新端点

    @router.delete("/messages/{message_id}/pair")
    @login_required
    def delete_message_pair(request, message_id: int):
        """
        删除一对消息(该 assistant message + 前一条 user message)及关联提交。
        只有消息所属对话的拥有者才能操作。
        """
        asst_msg = get_object_or_404(Message, id=message_id, role="assistant")
    
        if asst_msg.conversation.user != request.user:
            raise HttpError(403, "只能删除自己的消息")
    
        # 找前一条 user message
        user_msg = (
            Message.objects.filter(
                conversation=asst_msg.conversation,
                created__lt=asst_msg.created,
                role="user",
            )
            .order_by("-created")
            .first()
        )
    
        # 删关联提交
        submission_deleted = False
        if asst_msg.submission_id:
            try:
                asst_msg.submission.delete()
                submission_deleted = True
            except Exception:
                pass
    
        if user_msg:
            user_msg.delete()
        asst_msg.delete()
    
        return {"deleted": True, "submission_deleted": submission_deleted}
    

    asst_msg.submission 在 Submission 被 SET_NULL 规则处理前需先删除——因为我们调用 asst_msg.submission.delete() 会触发 Submission 的删除,而 Submission 删除不再会尝试删 asst_msg(此时 asst_msg 还在,SET_NULL 会把它的 submission 字段置 null,但我们马上就要删它所以无妨)。

  • Step 4: 运行全部测试

    cd api && uv run manage.py test prompt.tests -v 2
    

    预期:所有测试 PASS


Task 6:前端 store 和 API 客户端

Files:

  • Modify: client/src/store/prompt.ts

  • Modify: client/src/api.ts

  • Step 1: 修改 client/src/store/prompt.ts

    完整替换文件内容:

    import { ref } from "vue"
    import { WS_BASE_URL } from "../utils/const"
    import { html, css, js } from "./editors"
    
    export interface PromptMessage {
      role: "user" | "assistant"
      content: string
      id?: number          // assistant message 的后端 pk(用于删除)
      code?: { html: string | null; css: string | null; js: string | null }
      created?: string
    }
    
    export const messages = ref<PromptMessage[]>([])
    export const conversationId = ref<string>("")
    export const connected = ref(false)
    export const streaming = ref(false)
    export const streamingContent = ref("")
    let _onCodeComplete:
      | ((
          code: { html: string | null; css: string | null; js: string | null },
          messageId: number
        ) => void)
      | null = null
    
    export function setOnCodeComplete(fn: typeof _onCodeComplete) {
      _onCodeComplete = fn
    }
    
    let ws: WebSocket | null = null
    let _currentTaskId = 0
    
    export function connectPrompt(taskId: number) {
      _currentTaskId = taskId
      if (ws) ws.close()
    
      ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
    
      ws.onopen = () => {
        connected.value = true
      }
    
      ws.onmessage = (event) => {
        const data = JSON.parse(event.data)
    
        if (data.type === "init") {
          streaming.value = false
          streamingContent.value = ""
          const alreadyLoaded = conversationId.value === data.conversation_id
          conversationId.value = data.conversation_id
          if (!alreadyLoaded) {
            messages.value = data.messages || []
            const lastAssistant = [...messages.value]
              .reverse()
              .find((m) => m.role === "assistant" && m.code)
            if (lastAssistant?.code) {
              applyCode(lastAssistant.code)
            }
          }
        } else if (data.type === "stream") {
          streaming.value = true
          streamingContent.value += data.content
        } else if (data.type === "complete") {
          streaming.value = false
          messages.value.push({
            role: "assistant",
            content: streamingContent.value,
            id: data.message_id,       // 新增
            code: data.code,
          })
          streamingContent.value = ""
          if (data.code) {
            applyCode(data.code)
            if (_onCodeComplete) {
              _onCodeComplete(data.code, data.message_id)  // 新增 messageId 参数
            }
          }
        } else if (data.type === "error") {
          streaming.value = false
          streamingContent.value = ""
          messages.value.push({
            role: "assistant",
            content: data.content,
          })
        }
      }
    
      ws.onclose = () => {
        connected.value = false
      }
    }
    
    export function disconnectPrompt() {
      if (ws) {
        ws.close()
        ws = null
      }
      messages.value = []
      conversationId.value = ""
      connected.value = false
      streaming.value = false
      streamingContent.value = ""
      _onCodeComplete = null
    }
    
    export function sendPrompt(content: string, model: string = "") {
      if (!ws || ws.readyState !== WebSocket.OPEN) return
      streaming.value = true
      messages.value.push({ role: "user", content })
      ws.send(JSON.stringify({ type: "message", content, model }))
    }
    
    export function stopPrompt() {
      if (
        messages.value.length > 0 &&
        messages.value[messages.value.length - 1].role === "user"
      ) {
        messages.value.pop()
      }
      streaming.value = false
      streamingContent.value = ""
      if (_currentTaskId) {
        connectPrompt(_currentTaskId)
      }
    }
    
    function applyCode(code: {
      html: string | null
      css: string | null
      js: string | null
    }) {
      if (code.html !== null) html.value = code.html
      if (code.css !== null) css.value = code.css
      if (code.js !== null) js.value = code.js
    }
    
  • Step 2: 修改 client/src/api.ts

    修改 Submission.create: 加 messageId 可选参数:

    async create(
      taskId: number,
      code: {
        html?: string
        css?: string
        js?: string
        prompt?: string
      },
      messageId?: number,   // 新增
    ) {
      const { prompt, ...rest } = code
      const data = {
        task_id: taskId,
        ...rest,
        prompt: prompt || null,
        message_id: messageId ?? null,   // 新增
      }
      const res = await http.post("/submission/", data)
      return res.data
    },
    

    在 Prompt 对象末尾新增 deleteMessagePair:

    export const Prompt = {
      async listConversations(taskId?: number, userId?: number) {
        const params: Record<string, number> = {}
        if (taskId) params.task_id = taskId
        if (userId) params.user_id = userId
        return (await http.get("/prompt/conversations/", { params })).data
      },
    
      async getMessages(conversationId: string): Promise<PromptMessage[]> {
        return (
          await http.get<PromptMessage[]>(
            `/prompt/conversations/${conversationId}/messages/`,
          )
        ).data
      },
    
      async getMessagesByUserTask(
        taskId: number,
        userId: number,
      ): Promise<PromptMessage[]> {
        const convs = await this.listConversations(taskId, userId)
        if (!convs.length) return []
        return this.getMessages(convs[0].id)
      },
    
      async deleteMessagePair(
        messageId: number,
      ): Promise<{ deleted: boolean; submission_deleted: boolean }> {
        const res = await http.delete(`/prompt/messages/${messageId}/pair`)
        return res.data
      },
    }
    

    api.ts 顶部已有 import type { ..., PromptMessage, ... } from "./utils/type",确认 PromptMessage 类型也在 type.ts 中——若没有,store/prompt.ts 里的接口定义已够用,api.ts 的方法签名用 any 或直接不加返回类型注解都可以。

  • Step 3: 确认 TypeScript 无报错

    cd client && npm run build 2>&1 | head -30
    

    预期:构建成功,无 TS 错误


Task 7:ChallengeDetail.vue 传递 messageId

Files:

  • Modify: client/src/pages/ChallengeDetail.vue

  • Step 1: 修改 setOnCodeComplete 回调,接收并传递 messageId

    找到 ChallengeDetail.vue 中 setOnCodeComplete 的调用(约第 186 行),将:

    setOnCodeComplete(async (code) => {
      try {
        await Submission.create(taskId.value, {
          html: code.html ?? "",
          css: code.css ?? "",
          js: code.js ?? "",
        })
        message.success("已自动提交本次对话生成的代码")
      } catch {
        // 静默失败,不打扰用户
      }
    })
    

    替换为:

    setOnCodeComplete(async (code, messageId) => {
      try {
        await Submission.create(
          taskId.value,
          {
            html: code.html ?? "",
            css: code.css ?? "",
            js: code.js ?? "",
          },
          messageId,
        )
        message.success("已自动提交本次对话生成的代码")
      } catch {
        // 静默失败,不打扰用户
      }
    })
    
  • Step 2: 确认构建无报错

    cd client && npm run build 2>&1 | head -30
    

Task 8:PromptPanel.vue 添加删除按钮

Files:

  • Modify: client/src/components/ai/PromptPanel.vue

  • Step 1: 修改 <script setup> 部分

    将 <script setup> 中的 import 和逻辑替换为:

    import { ref, watch, nextTick, computed } from "vue"
    import { useStorage } from "@vueuse/core"
    import { marked, Renderer } from "marked"
    import { useMessage } from "naive-ui"
    import { Icon } from "@iconify/vue"
    import {
      messages,
      streaming,
      streamingContent,
      connected,
      sendPrompt,
      stopPrompt,
    } from "../../store/prompt"
    import { Prompt } from "../../api"
    
    const input = ref("")
    const messagesRef = ref<HTMLElement>()
    const naiveMessage = useMessage()
    
    const modelOptions = [
      { label: "豆包", value: "doubao-seed-2-0-lite-260215" },
      { label: "DeepSeek", value: "deepseek-chat" },
    ]
    const selectedModel = useStorage("prompt-model", "deepseek-chat")
    
    // 将消息列表分组为 [{user, assistant}] 对
    const pairs = computed(() => {
      const result: Array<{
        userMsg: { role: string; content: string; id?: number }
        assistantMsg: { role: string; content: string; id?: number; code?: any } | null
        index: number
      }> = []
      const msgs = messages.value
      let i = 0
      while (i < msgs.length) {
        if (msgs[i].role === "user") {
          const assistantMsg = msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
          result.push({ userMsg: msgs[i], assistantMsg, index: i })
          i += assistantMsg ? 2 : 1
        } else {
          i++
        }
      }
      return result
    })
    
    async function deletePair(assistantMsgId: number, pairIndex: number) {
      try {
        await Prompt.deleteMessagePair(assistantMsgId)
        // 从本地 messages 中移除这两条(user + assistant)
        const idx = pairs.value[pairIndex].index
        messages.value.splice(idx, 2)
        naiveMessage.success("已删除")
      } catch {
        naiveMessage.error("删除失败,请重试")
      }
    }
    
    function send() {
      const text = input.value.trim()
      if (!text || streaming.value) return
      sendPrompt(text, selectedModel.value)
      input.value = ""
    }
    
    const renderer = new Renderer()
    renderer.code = function ({ lang }: { text: string; lang?: string }) {
      const label = lang ? lang.toUpperCase() : "CODE"
      const colors: Record<
        string,
        { bg: string; fg: string; dot: string; border: string; shimmer: string }
      > = {
        html: {
          bg: "#f0fff4",
          fg: "#18a058",
          dot: "#18a058",
          border: "#b8e8cc",
          shimmer: "#f0fff4, #e0f7ea, #f0fff4",
        },
        css: {
          bg: "#f0f0ff",
          fg: "#6060d0",
          dot: "#6060d0",
          border: "#d0d0f0",
          shimmer: "#f0f0ff, #e8e8fa, #f0f0ff",
        },
        js: {
          bg: "#fffbf0",
          fg: "#c0960a",
          dot: "#c0960a",
          border: "#f0e0b0",
          shimmer: "#fffbf0, #fff5e0, #fffbf0",
        },
        javascript: {
          bg: "#fffbf0",
          fg: "#c0960a",
          dot: "#c0960a",
          border: "#f0e0b0",
          shimmer: "#fffbf0, #fff5e0, #fffbf0",
        },
      }
      const c = colors[(lang ?? "").toLowerCase()] ?? {
        bg: "#f0f7ff",
        fg: "#2080f0",
        dot: "#2080f0",
        border: "#e0eaf5",
        shimmer: "#f0f7ff, #e8f4f8, #f0f7ff",
      }
      return `<div class="code-placeholder" style="background: linear-gradient(90deg, ${c.shimmer}); background-size: 200% 100%; border-color: ${c.border}"><span class="code-placeholder-dot" style="background: ${c.dot}"></span><span class="code-placeholder-label" style="color: ${c.fg}; background: ${c.fg}18">${label}</span><span class="code-placeholder-text">代码正在生成中,结束后会自动应用到预览区</span></div>`
    }
    
    function renderMarkdown(text: string): string {
      return marked.parse(text, { renderer }) as string
    }
    
    function renderContent(msg: { role: string; content: string }): string {
      return renderMarkdown(msg.content)
    }
    
    watch([() => messages.value.length, streamingContent], () => {
      nextTick(() => {
        if (messagesRef.value) {
          messagesRef.value.scrollTop = messagesRef.value.scrollHeight
        }
      })
    })
    
  • Step 2: 替换 <template> 部分

    将整个 <template> 替换为:

    <template>
      <div class="prompt-panel">
        <div class="messages" ref="messagesRef">
          <div
            v-for="(pair, pi) in pairs"
            :key="pi"
            class="message-pair"
            :class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
          >
            <!-- 删除按钮(只在有完整 assistant message id 且非流式时显示) -->
            <button
              v-if="pair.assistantMsg?.id && !streaming"
              class="pair-delete-btn"
              @click="deletePair(pair.assistantMsg!.id!, pi)"
              title="删除这轮对话及关联提交"
            >
              <Icon icon="lucide:trash-2" :width="14" />
            </button>
    
            <!-- User message -->
            <div class="message user">
              <div class="message-role">我</div>
              <div class="message-content" v-html="renderContent(pair.userMsg)"></div>
            </div>
    
            <!-- Assistant message -->
            <div v-if="pair.assistantMsg" class="message assistant">
              <div class="message-role">AI</div>
              <div class="message-content" v-html="renderContent(pair.assistantMsg)"></div>
            </div>
          </div>
    
          <!-- Streaming message -->
          <div v-if="streaming" class="message assistant">
            <div class="message-role">AI</div>
            <div v-if="!streamingContent" class="typing-indicator">
              <span></span><span></span><span></span>
            </div>
            <div
              v-else
              class="message-content"
              v-html="renderMarkdown(streamingContent)"
            ></div>
            <div class="streaming-hint">AI 正在思考中…</div>
          </div>
        </div>
        <div class="input-area">
          <n-input
            v-model:value="input"
            type="textarea"
            :autosize="{ minRows: 1, maxRows: 4 }"
            placeholder="描述你想要的网页效果..."
            :disabled="streaming"
            @keydown.enter.exact.prevent="send"
          />
          <n-flex justify="flex-end" align="center" style="margin-top: 8px">
            <n-select
              v-model:value="selectedModel"
              :options="modelOptions"
              style="width: 120px"
              :disabled="streaming"
            />
            <n-button v-if="streaming" type="error" @click="stopPrompt">
              停止
            </n-button>
            <n-button
              v-else
              type="primary"
              :disabled="!input.trim() || !connected"
              @click="send"
            >
              发送
            </n-button>
          </n-flex>
        </div>
      </div>
    </template>
    
  • Step 3: 在 <style scoped> 中追加删除按钮样式

    在原有 </style> 之前追加:

    .message-pair {
      position: relative;
      margin-bottom: 4px;
    }
    
    .message-pair .message {
      margin-bottom: 16px;
    }
    
    .pair-delete-btn {
      display: none;
      position: absolute;
      top: 0;
      right: 0;
      background: none;
      border: 1px solid #e0e0e0;
      border-radius: 4px;
      padding: 3px 6px;
      cursor: pointer;
      color: #bbb;
      line-height: 1;
      transition: color 0.15s, border-color 0.15s;
    }
    
    .pair-delete-btn:hover {
      color: #e03e3e;
      border-color: #e03e3e;
    }
    
    .message-pair.has-delete:hover .pair-delete-btn {
      display: inline-flex;
      align-items: center;
    }
    
  • Step 4: 启动前端 dev server 并手动验证

    cd client && npm run start
    

    验证流程:

    1. 打开挑战页,发送一条 AI 消息,等待 AI 回复完成
    2. hover 到该对话轮次右上角,确认出现垃圾桶图标
    3. 点击删除,确认:消息消失、弹出「已删除」提示
    4. 刷新页面,确认该轮消息不再出现(已从后端删除)
    5. 进入「我的提交」列表,确认该次自动提交已被删除
    6. 在提交列表中删除另一条 AI 生成的提交,回到挑战页重连,确认对应消息对也消失
  • Step 5: 运行后端全量测试

    cd api && uv run manage.py test prompt.tests -v 2
    

    预期:所有测试 PASS