diff --git a/client/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml similarity index 54% rename from client/.github/workflows/deploy.yaml rename to .github/workflows/deploy.yaml index 8f710f9..3fc9d69 100644 --- a/client/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -28,17 +28,35 @@ jobs: with: node-version: 24 cache: npm - - run: npm install - - run: npm run ${{ matrix.build_command }} + cache-dependency-path: client/package-lock.json + + - name: Build client + working-directory: client + run: | + npm install + npm run ${{ matrix.build_command }} env: CI: false - - uses: easingthemes/ssh-deploy@main + - name: Upload client to ${{ matrix.name }} + uses: easingthemes/ssh-deploy@main with: SSH_PRIVATE_KEY: ${{ secrets.KEY }} REMOTE_HOST: ${{ secrets.HOST }} REMOTE_PORT: ${{ matrix.remote_port }} ARGS: "-avzr --delete" - SOURCE: dist/ + SOURCE: client/dist/ REMOTE_USER: root TARGET: /root/WebPreview/build + + - name: Update server on ${{ matrix.name }} + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.HOST }} + port: ${{ matrix.remote_port }} + username: root + key: ${{ secrets.KEY }} + script: | + cd /root/WebPreview + git pull --ff-only + docker compose up -d --build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c032a7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# 运行时数据(服务器上由 docker compose 挂载) +.env +/data/ +/build/ +/media/ +/redis/ +/sqlite/ + +# 本地工具目录 +.agents/ +.codex +.deepseek/ +.superpowers/ +.worktrees/ + +node_modules/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0465b0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +WebPreview is a Django API plus Vue 3 client. + +- `api/` contains the Django project. `api/api/` holds settings and URLs; feature apps live in `account/`, `task/`, `submission/`, `prompt/`, and `utils/`. +- `client/` contains the Vue 3 + TypeScript SPA. Route views are in `client/src/pages/`, shared UI in `client/src/components/`, state in `client/src/store/`, and helpers/types in `client/src/utils/`. +- `client/public/` stores browser assets. `build/`, `data/`, `media/`, and `redis/` are generated or runtime directories. +- `docs/` stores design and implementation plans. + +## Build and Development Commands + +- `./dev.sh`: starts the Django API and Rsbuild frontend together for local development. +- `cd api && uv run manage.py runserver`: run only the backend. +- `cd api && uv run manage.py migrate`: apply Django migrations. +- `cd client && npm run start`: start the frontend dev server. +- `cd client && npm run build`: create the production client build in `build/`. +- `cd client && npm run fmt`: format frontend source with Prettier. +- `docker compose up -d --build`: rebuild and start the Docker stack. + +## Coding Style & Naming Conventions + +Use 4-space indentation for Python and follow Django app conventions: models in `models.py`, handlers in `api.py` or router files, schemas in `schemas.py`, and migrations in `migrations/`. Use `snake_case` for functions and variables. + +Frontend code uses Vue single-file components, TypeScript strict mode, and Prettier. Name components/pages in `PascalCase.vue`, and keep store modules domain-based, such as `submission.ts`. + +## Commit & Pull Request Guidelines + +This checkout lacks readable Git history, so use concise imperative commit messages such as `Add showcase management view`. Pull requests should describe the behavior change, list build/manual verification steps, link issues or plans, and include screenshots for visible UI changes. + +## Security & Configuration Tips + +Keep secrets in `.env` and do not commit credentials or generated runtime data. The API uses environment-driven settings for database, Redis, and LLM provider configuration. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d38d0c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +WebPreview is a web-based tutorial/challenge platform where users view tutorials and submit HTML/CSS/JS code for challenges. It has a Django API backend and a Vue 3 frontend, deployed via Docker Compose with Caddy as reverse proxy. + +## Architecture + +- **`api/`** — Django backend using **django-ninja** for REST APIs and **Django Channels** (Daphne/ASGI) for WebSocket support + - `api/api/` — Django project config (settings, urls, asgi) + - `api/account/` — User auth (login/logout, profiles, batch creation). Three roles: `super`, `admin`, `normal` — affects scoring weights + - `api/task/` — Tutorial and Challenge models (single `Task` base model with `task_type`), with separate `tutorial.py` and `challenge.py` router files + - `api/submission/` — Code submission + `Rating` model. Submission score is a weighted average of ratings by role: super×0.5, admin×0.3, normal×0.2. `Profile.recalculate_total_score()` uses best-score-per-challenge + - `api/prompt/` — LLM chat feature: WebSocket consumer streams DeepSeek responses, `Conversation`/`Message` models store history, code blocks extracted from responses + - `api/utils/upload.py` — Image upload endpoint + - API routers are registered in `api/api/urls.py` under `/api/` prefix + - Uses `AUTH_USER_MODEL = "account.User"` (custom user model) + - Dev mode uses SQLite (`ENV != "production"`); production uses PostgreSQL + Redis (sessions, caching, channel layers) + +- **`client/`** — Vue 3 + TypeScript SPA built with **Rsbuild** + - UI framework: **Naive UI** (auto-imported via `unplugin-vue-components`) + - `src/api.ts` — Axios-based API client with CSRF and auth interceptors; exports `Account`, `Tutorial`, `Challenge`, `Submission`, `Prompt`, `Helper` objects + - `src/router.ts` — Vue Router with auth guards; routes under `/dashboard` require login + - `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia) + - `src/pages/` — Route-level views (Home, Dashboard, ChallengeHome, TutorialEditor, ChallengeEditor, Submissions, Leaderboard, MyScores) + - `src/components/` — Shared components: `Task.vue`, `Tutorial.vue`, `Challenge.vue`, `Editors.vue` (CodeMirror), `Preview.vue` (live HTML preview), `Toolbar.vue`, `PromptPanel.vue` (LLM chat) + +- **`Caddyfile`** — Routes `/api*`, `/admin*`, `/ws*` to backend; serves SPA and static/media files +- **`docker-compose.yaml`** — Services: caddy, api, postgres, redis + +## Development Commands + +### Client (from `client/`) +```bash +npm run start # Dev server (Rsbuild) +npm run build # Production build +npm run fmt # Format with Prettier +``` + +Frontend env vars used at build time: `PUBLIC_BASE_URL`, `PUBLIC_ADMIN_URL`, `PUBLIC_WS_URL`. + +### API (from `api/`) +```bash +uv run manage.py runserver # Dev server +uv run manage.py migrate # Run migrations +uv run manage.py createsuperuser # Create admin user +``` +Uses `uv` for Python dependency management (`pyproject.toml` + `uv.lock`). Set `ENV=dev` in `.env` (default) for SQLite. + +### Docker (from project root) +```bash +docker compose up -d # Start all services +docker compose up -d --build # Rebuild and start +``` + +## Key Technical Details + +- **Backend API pattern**: each Django app has `api.py` (or named router files like `task/tutorial.py`, `task/challenge.py`) with django-ninja routers, plus `schemas.py` for Pydantic models. Routers mount under `/api//` +- **WebSocket**: `prompt/consumers.py` handles LLM chat at `ws:///ws/prompt//`. Django Channels is configured; in dev, channel layer is in-memory (no Redis needed) +- **LLM config**: `LLM_API_KEY`, `LLM_BASE_URL` (default: DeepSeek), `LLM_MODEL` set via `.env` +- **Task `display` field**: integer used as URL slug for tutorials/challenges (unique per task type), not the DB `id` +- **Submission IDs**: UUIDs (not integers) +- Frontend dev server proxies `/media` to `PUBLIC_WEB_URL` env var; API calls go to `BASE_URL` defined in `src/utils/const.ts` +- Environment config via `.env` file in `api/` (referenced by docker-compose and Django's `python-dotenv`) +- Language/locale is Chinese (`zh-hans`, `Asia/Shanghai` timezone) +- Client build output goes to `build/` directory, served by Caddy in production diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..dc1b886 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,32 @@ +:80 { + encode gzip zstd + + handle_path /* { + root * /usr/share/caddy + file_server + try_files {path} / + } + + + handle_path /django_static/* { + root * /static + file_server + } + + handle_path /media/* { + root * /media + file_server + } + + handle /admin* { + reverse_proxy webpreview-api:8000 + } + + handle /api* { + reverse_proxy webpreview-api:8000 + } + + handle /ws* { + reverse_proxy webpreview-api:8000 + } +} diff --git a/api/.github/workflows/depley.yaml b/api/.github/workflows/depley.yaml deleted file mode 100644 index 189621c..0000000 --- a/api/.github/workflows/depley.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Deploy - -on: - push: - branches: - - main - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - name: debian - remote_port: 22 - - name: school - remote_port: 8822 - steps: - - name: Deploy to ${{ matrix.name }} - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.HOST }} - port: ${{ matrix.remote_port }} - username: root - key: ${{ secrets.KEY }} - script: sh /root/WebPreview/backend.sh diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..5c6fc1e --- /dev/null +++ b/dev.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +ROOT="$(cd "$(dirname "$0")" && pwd)" + +# 启动后端 +cd "$ROOT/api" || exit 1 +uv run manage.py runserver & +BACKEND_PID=$! + +# 启动前端 +cd "$ROOT/client" || exit 1 +npm run start & +FRONTEND_PID=$! + +# 捕获退出信号,同时终止两个进程 +trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM + +wait diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..870d43c --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,41 @@ +services: + webpreview-web: + image: caddy:alpine + container_name: webpreview-web + volumes: + - ./build:/usr/share/caddy:ro + - ./api/static:/static:ro + - ./media:/media + - ./Caddyfile:/etc/caddy/Caddyfile:ro + ports: + - 8081:80 + depends_on: + - webpreview-api + restart: unless-stopped + + webpreview-api: + build: api + container_name: webpreview-api + env_file: + - .env + volumes: + - ./media:/app/media + restart: unless-stopped + depends_on: + - webpreview-db + + webpreview-db: + image: postgres:17-alpine + container_name: webpreview-db + volumes: + - ./data:/var/lib/postgresql/data + env_file: + - .env + restart: unless-stopped + + webpreview-redis: + image: redis:7-alpine + container_name: webpreview-redis + restart: unless-stopped + volumes: + - ./redis:/data diff --git a/docs/plans/2026-03-04-prompt-chain-design.md b/docs/plans/2026-03-04-prompt-chain-design.md new file mode 100644 index 0000000..32856b7 --- /dev/null +++ b/docs/plans/2026-03-04-prompt-chain-design.md @@ -0,0 +1,121 @@ +# Prompt Chain Feature Design + +## Overview + +在挑战模式下,学生通过平台内置 AI 对话生成网页代码,平台记录完整的 prompt 链条(思维链),提交时代码与 prompt 链条一起关联。老师和学生都能回看思维过程。 + +## 技术方案 + +WebSocket 实时对话(基于现有 Django Channels + Redis 基础设施),流式输出 AI 回复。调用国内大模型 API(如深度求索、通义千问)。 + +## 数据模型 + +### 新增 `prompt` app(替代现有 `chat` app) + +**Conversation** +- `id`: UUIDField (PK) +- `user`: FK → User +- `task`: FK → Task(关联的挑战任务) +- `is_active`: BooleanField(提交后锁定为 False) +- `created` / `modified`: 时间戳 + +**Message** +- `id`: AutoField (PK) +- `conversation`: FK → Conversation +- `role`: CharField("user" | "assistant") +- `content`: TextField(消息文本) +- `code_html`: TextField nullable(AI 回复中提取的 HTML) +- `code_css`: TextField nullable(AI 回复中提取的 CSS) +- `code_js`: TextField nullable(AI 回复中提取的 JS) +- `created`: 时间戳 + +### 修改 Submission 模型 + +- 新增 `conversation`: FK → Conversation nullable + +## 后端设计 + +### WebSocket Consumer (`prompt/consumers.py`) + +路由:`ws/prompt/{task_id}/` + +**连接流程:** +1. 验证用户已登录 +2. 查找该用户+任务的活跃 Conversation,没有则创建 +3. 推送历史消息恢复上下文 + +**消息处理:** +1. 收到学生 prompt → 存入 Message (role=user) +2. 组装上下文:系统 prompt + 挑战描述 + 历史消息 → 调用大模型 API +3. 流式接收 AI 回复 → 逐片段推送(打字机效果) +4. 回复完成 → 提取代码块 → 存入 Message (role=assistant, 含 code 字段) +5. 推送 `code_update` 事件 → 前端自动填入编辑器并刷新预览 + +**系统 prompt:** +- 角色:网页生成助手 +- 要求用 ```html / ```css / ```js 格式返回代码 +- 注入当前挑战任务描述作为上下文 + +### REST API (`prompt/api.py`) + +- `GET /api/prompt/conversations/?task_id=X` — 当前用户的对话列表 +- `GET /api/prompt/conversation/{id}/messages/` — 完整消息历史 +- `POST /api/prompt/conversations/new?task_id=X` — 新建对话 +- 老师端:`GET /api/prompt/conversations/?user_id=X&task_id=Y` — 查看学生对话 + +## 前端设计 + +### 挑战模式新布局 + +``` +┌─────────────────────────┬──────────────────────────┐ +│ 挑战描述 (Markdown) │ │ +│ ──────────────────── │ │ +│ │ 预览 (iframe) │ +│ AI 对话区域 │ │ +│ ┌───────────────────┐ │ │ +│ │ 用户: 做一个... │ │ │ +│ │ AI: 好的... │ │ [查看代码] 按钮 │ +│ └───────────────────┘ │ │ +│ [输入 prompt...] [发送]│ │ +└─────────────────────────┴──────────────────────────┘ +``` + +- 左侧上方:挑战任务描述(复用 Challenge 组件) +- 左侧下方:AI 对话面板,可拖拽调整比例 +- 右侧:预览 iframe 占满,右上角"查看代码"按钮弹窗查看代码 +- 教程模式保持现有布局不变 + +### 新增组件 + +- `PromptPanel.vue` — AI 对话面板(消息列表 + 输入框 + 发送按钮) +- `ChallengeHome.vue` — 挑战模式布局容器 + +### 提交流程 + +- 提交时附带 `conversation_id`,关联当前对话 +- 提交后对话锁定,学生可新建对话重新开始 + +### 查看 prompt 链条 + +- Submissions 页面新增"查看思维链"按钮 +- 点击展开完整 prompt 对话历史(只读) +- 老师可按学生、按挑战筛选查看所有对话记录 + +## 影响范围 + +### 新增 +- `prompt/` Django app(models, consumers, api, schemas) +- `PromptPanel.vue`, `ChallengeHome.vue` 前端组件 +- `prompt` store(WebSocket 连接管理、消息状态) + +### 修改 +- `submission/models.py` — 新增 conversation FK +- `submission/schemas.py` — 提交 schema 新增 conversation_id +- `api/urls.py` — 注册 prompt router +- `api/asgi.py` — 更新 WebSocket 路由 +- `Home.vue` — 挑战模式下切换到新布局 +- `Submissions.vue` / `Preview.vue` — 新增查看 prompt 链条入口 + +### 删除 +- `chat/` app(被 `prompt/` 替代) diff --git a/docs/plans/2026-03-04-prompt-chain.md b/docs/plans/2026-03-04-prompt-chain.md new file mode 100644 index 0000000..5017262 --- /dev/null +++ b/docs/plans/2026-03-04-prompt-chain.md @@ -0,0 +1,1257 @@ +# Prompt Chain Feature Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an AI-powered prompt chain system where students interact with a built-in AI to generate web pages for challenges, and the full prompt history is recorded and viewable. + +**Architecture:** New `prompt` Django app replaces the stub `chat` app. An `AsyncWebsocketConsumer` handles real-time AI conversation via WebSocket, calling a domestic LLM API (OpenAI-compatible). The frontend adds a `PromptPanel` chat UI and a `ChallengeHome` layout that replaces editors with the AI conversation panel in challenge mode. + +**Tech Stack:** Django Channels (async consumer), `openai` Python SDK (for OpenAI-compatible domestic LLM APIs like DeepSeek/Qwen), Vue 3 composables for WebSocket state management. + +--- + +### Task 1: Create `prompt` Django app with models + +**Files:** +- Create: `api/prompt/__init__.py` +- Create: `api/prompt/apps.py` +- Create: `api/prompt/models.py` +- Create: `api/prompt/admin.py` +- Modify: `api/api/settings.py` — add `"prompt"` to INSTALLED_APPS + +**Step 1: Create the app directory and files** + +```bash +cd /home/xuyue/Projects/webpreview/api +mkdir -p prompt +touch prompt/__init__.py +``` + +**Step 2: Write `prompt/apps.py`** + +```python +from django.apps import AppConfig + + +class PromptConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "prompt" + verbose_name = "Prompt" +``` + +**Step 3: Write `prompt/models.py`** + +```python +import uuid +from django.db import models +from django_extensions.db.models import TimeStampedModel +from account.models import User +from task.models import Task + + +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) # "user" or "assistant" + 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) + + class Meta: + ordering = ("created",) + + def __str__(self): + return f"[{self.role}] {self.content[:50]}" +``` + +**Step 4: Write `prompt/admin.py`** + +```python +from django.contrib import admin +from .models import Conversation, Message + + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ("role", "content", "code_html", "code_css", "code_js", "created") + + +@admin.register(Conversation) +class ConversationAdmin(admin.ModelAdmin): + list_display = ("user", "task", "is_active", "created") + list_filter = ("is_active",) + inlines = [MessageInline] +``` + +**Step 5: Add `"prompt"` to INSTALLED_APPS in `api/api/settings.py`** + +Find the `INSTALLED_APPS` list and add `"prompt"` after `"chat"` (or replace `"chat"`). Keep `"chat"` for now until migration is done. + +**Step 6: Run migrations** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run manage.py makemigrations prompt +uv run manage.py migrate +``` + +**Step 7: Commit** + +```bash +git add api/prompt/ +git commit -m "feat: add prompt app with Conversation and Message models" +``` + +--- + +### Task 2: Add `conversation` FK to Submission model + +**Files:** +- Modify: `api/submission/models.py` — add `conversation` field +- Modify: `api/submission/schemas.py` — add `conversation_id` to input/output +- Modify: `api/submission/api.py` — wire `conversation_id` on create + +**Step 1: Add field to `api/submission/models.py`** + +Add this import at the top: +```python +from prompt.models import Conversation +``` + +Add this field to the `Submission` class, after the `js` field: +```python + conversation = models.ForeignKey( + Conversation, on_delete=models.SET_NULL, null=True, blank=True, + related_name="submissions", verbose_name="对话" + ) +``` + +**Step 2: Update `api/submission/schemas.py`** + +Add to `SubmissionIn`: +```python + conversation_id: Optional[UUID] = None +``` + +Add to the `SubmissionOut` schema and the `list`/`get` static methods: +```python + conversation_id: Optional[UUID] = None +``` + +In `SubmissionOut.list()`, add: +```python + "conversation_id": submission.conversation_id, +``` + +In `SubmissionOut.get()`, add: +```python + "conversation_id": submission.conversation_id, +``` + +**Step 3: Update `api/submission/api.py` `create_submission`** + +```python +@router.post("/") +@login_required +def create_submission(request, payload: SubmissionIn): + task = get_object_or_404(Task, id=payload.task_id) + conversation = None + if payload.conversation_id: + from prompt.models import Conversation + conversation = get_object_or_404( + Conversation, id=payload.conversation_id, user=request.user + ) + conversation.is_active = False + conversation.save(update_fields=["is_active"]) + Submission.objects.create( + user=request.user, task=task, + html=payload.html, css=payload.css, js=payload.js, + conversation=conversation, + ) +``` + +**Step 4: Run migrations** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run manage.py makemigrations submission +uv run manage.py migrate +``` + +**Step 5: Commit** + +```bash +git add api/submission/ +git commit -m "feat: link Submission to Conversation via FK" +``` + +--- + +### Task 3: Add `openai` dependency for LLM API calls + +**Files:** +- Modify: `api/pyproject.toml` — add `openai` dependency +- Create: `api/prompt/llm.py` — LLM client helper + +**Step 1: Add dependency** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv add openai +``` + +**Step 2: Add LLM config to `api/api/settings.py`** + +Add at the bottom of settings: +```python +# LLM Configuration +LLM_API_KEY = os.environ.get("LLM_API_KEY", "") +LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.deepseek.com") +LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-chat") +``` + +**Step 3: Write `api/prompt/llm.py`** + +```python +import re +from django.conf import settings +from openai import AsyncOpenAI + + +client = AsyncOpenAI( + api_key=settings.LLM_API_KEY, + base_url=settings.LLM_BASE_URL, +) + +SYSTEM_PROMPT = """你是一个网页生成助手。根据用户的需求描述,生成 HTML、CSS 和 JavaScript 代码。 + +规则: +1. 始终使用三个独立的代码块返回代码,分别用 ```html、```css、```js 标记 +2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构 +3. CSS 和 JS 可以为空,但仍然需要返回空的代码块 +4. 用中文回复,先简要说明你做了什么,然后给出代码 +5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段""" + + +def build_messages(task_content: str, history: list[dict]) -> list[dict]: + """Build the message list for the LLM API call.""" + system = SYSTEM_PROMPT + f"\n\n当前挑战任务要求:\n{task_content}" + messages = [{"role": "system", "content": system}] + messages.extend(history) + return messages + + +async def stream_chat(task_content: str, history: list[dict]): + """Stream chat completion from the LLM. Yields content chunks.""" + messages = build_messages(task_content, history) + stream = await client.chat.completions.create( + model=settings.LLM_MODEL, + messages=messages, + stream=True, + ) + async for chunk in stream: + delta = chunk.choices[0].delta + if delta.content: + yield delta.content + + +def extract_code(text: str) -> dict: + """Extract HTML, CSS, JS code blocks from AI response text.""" + result = {"html": None, "css": None, "js": None} + pattern = r"```(html|css|js|javascript)\s*\n(.*?)```" + matches = re.findall(pattern, text, re.DOTALL) + for lang, code in matches: + lang = lang.lower() + if lang == "javascript": + lang = "js" + if lang in result: + result[lang] = code.strip() + return result +``` + +**Step 4: Commit** + +```bash +git add api/pyproject.toml api/uv.lock api/prompt/llm.py api/api/settings.py +git commit -m "feat: add LLM client with streaming support and code extraction" +``` + +--- + +### Task 4: Implement WebSocket consumer + +**Files:** +- Create: `api/prompt/consumers.py` +- Create: `api/prompt/url.py` +- Modify: `api/api/asgi.py` — update WebSocket routing + +**Step 1: Write `api/prompt/consumers.py`** + +```python +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +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"]) + await self.accept() + + # Load or create conversation, send history + 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): + pass + + async def receive(self, text_data): + data = json.loads(text_data) + msg_type = data.get("type", "message") + + if msg_type == "new_conversation": + self.conversation = await self.create_conversation() + await self.send(text_data=json.dumps({ + "type": "init", + "conversation_id": str(self.conversation.id), + "messages": [], + })) + return + + prompt = data.get("content", "").strip() + if not prompt: + return + + # Save user message + await self.save_message("user", prompt) + + # Build history for LLM + history = await self.get_history_for_llm() + task_content = await self.get_task_content() + + # Stream AI response + full_response = "" + try: + async for chunk in stream_chat(task_content, history): + 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 + + # Extract code and save assistant message + code = extract_code(full_response) + await self.save_message("assistant", full_response, code) + + # Send completion with extracted code + await self.send(text_data=json.dumps({ + "type": "complete", + "code": code, + })) + + @database_sync_to_async + def get_or_create_conversation(self): + conv = Conversation.objects.filter( + user=self.user, task_id=self.task_id, is_active=True + ).first() + if not conv: + conv = Conversation.objects.create(user=self.user, task_id=self.task_id) + return conv + + @database_sync_to_async + def create_conversation(self): + # Deactivate old conversations for this user+task + Conversation.objects.filter( + user=self.user, task_id=self.task_id, is_active=True + ).update(is_active=False) + return Conversation.objects.create(user=self.user, task_id=self.task_id) + + @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.all() + return [ + { + "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.all() + return [{"role": m.role, "content": m.content} for m in messages] + + @database_sync_to_async + def get_task_content(self): + from task.models import Task + task = Task.objects.get(id=self.task_id) + return task.content +``` + +**Step 2: Write `api/prompt/url.py`** + +```python +from django.urls import path +from .consumers import PromptConsumer + +websocket_urlpatterns = [ + path("ws/prompt//", PromptConsumer.as_asgi()), +] +``` + +**Step 3: Update `api/api/asgi.py`** + +Replace the import and routing: + +```python +""" +ASGI config for api project. +""" + +import os + +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.auth import AuthMiddlewareStack +from channels.security.websocket import AllowedHostsOriginValidator + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings") + +from prompt.url import websocket_urlpatterns + +application = ProtocolTypeRouter( + { + "http": get_asgi_application(), + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) + ), + } +) +``` + +**Step 4: Commit** + +```bash +git add api/prompt/consumers.py api/prompt/url.py api/api/asgi.py +git commit -m "feat: add PromptConsumer with streaming AI and code extraction" +``` + +--- + +### Task 5: Create REST API for prompt history + +**Files:** +- Create: `api/prompt/schemas.py` +- Create: `api/prompt/api.py` +- Modify: `api/api/urls.py` — register prompt router + +**Step 1: Write `api/prompt/schemas.py`** + +```python +from typing import Optional +from uuid import UUID +from ninja import Schema + + +class MessageOut(Schema): + id: int + role: str + content: str + code_html: Optional[str] = None + code_css: Optional[str] = None + code_js: Optional[str] = None + created: str + + +class ConversationOut(Schema): + id: UUID + user_id: int + username: str + task_id: int + task_title: str + is_active: bool + message_count: int + created: str + + @staticmethod + def from_conv(conv): + return { + "id": conv.id, + "user_id": conv.user_id, + "username": conv.user.username, + "task_id": conv.task_id, + "task_title": conv.task.title, + "is_active": conv.is_active, + "message_count": conv.messages.count(), + "created": conv.created.isoformat(), + } +``` + +**Step 2: Write `api/prompt/api.py`** + +```python +from typing import List +from uuid import UUID +from ninja import Router +from django.shortcuts import get_object_or_404 +from django.contrib.auth.decorators import login_required + +from .models import Conversation, Message +from .schemas import ConversationOut, MessageOut + +router = Router() + + +@router.get("/conversations/", response=List[ConversationOut]) +@login_required +def list_conversations(request, task_id: int = None, user_id: int = None): + convs = Conversation.objects.select_related("user", "task") + # Normal users can only see their own + if request.user.role == "normal": + convs = convs.filter(user=request.user) + elif user_id: + convs = convs.filter(user_id=user_id) + if task_id: + convs = convs.filter(task_id=task_id) + return [ConversationOut.from_conv(c) for c in convs] + + +@router.get("/conversations/{conversation_id}/messages/", response=List[MessageOut]) +@login_required +def list_messages(request, conversation_id: UUID): + conv = get_object_or_404(Conversation, id=conversation_id) + # Normal users can only see their own + if request.user.role == "normal" and conv.user != request.user: + return [] + messages = conv.messages.all() + return [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "code_html": m.code_html, + "code_css": m.code_css, + "code_js": m.code_js, + "created": m.created.isoformat(), + } + for m in messages + ] +``` + +**Step 3: Update `api/api/urls.py`** + +Add the prompt router: + +```python +api.add_router("prompt/", "prompt.api.router") +``` + +**Step 4: Commit** + +```bash +git add api/prompt/schemas.py api/prompt/api.py api/api/urls.py +git commit -m "feat: add REST API for prompt conversation history" +``` + +--- + +### Task 6: Add LLM env vars and update docker-compose + +**Files:** +- Modify: `docker-compose.yaml` — add LLM env vars to api service +- Create/Modify: `.env.example` — document new env vars + +**Step 1: Add env vars to `docker-compose.yaml`** + +In the `api` service `environment` section, add: +```yaml + - LLM_API_KEY=${LLM_API_KEY} + - LLM_BASE_URL=${LLM_BASE_URL:-https://api.deepseek.com} + - LLM_MODEL=${LLM_MODEL:-deepseek-chat} +``` + +**Step 2: Add to `.env` file (local)** + +``` +LLM_API_KEY=your-api-key-here +LLM_BASE_URL=https://api.deepseek.com +LLM_MODEL=deepseek-chat +``` + +**Step 3: Commit** + +```bash +git add docker-compose.yaml +git commit -m "feat: add LLM configuration env vars" +``` + +--- + +### Task 7: Frontend — Create prompt store and WebSocket client + +**Files:** +- Create: `client/src/store/prompt.ts` +- Modify: `client/src/utils/const.ts` — add WS URL constant +- Modify: `client/src/utils/type.ts` — add prompt types (if it exists) + +**Step 1: Add WebSocket base URL to `client/src/utils/const.ts`** + +Add after the existing constants: +```typescript +export const WS_BASE_URL = import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}` +``` + +**Step 2: Write `client/src/store/prompt.ts`** + +```typescript +import { ref, reactive } from "vue" +import { WS_BASE_URL } from "../utils/const" +import { html, css, js } from "./editors" + +export interface PromptMessage { + role: "user" | "assistant" + content: string + code?: { html: string | null; css: string | null; js: string | null } + created?: string +} + +export const messages = ref([]) +export const conversationId = ref("") +export const connected = ref(false) +export const streaming = ref(false) +export const streamingContent = ref("") + +let ws: WebSocket | null = null + +export function connectPrompt(taskId: number) { + 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") { + conversationId.value = data.conversation_id + messages.value = data.messages || [] + // Apply code from last assistant message if exists + 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 + // Push the full assistant message + messages.value.push({ + role: "assistant", + content: streamingContent.value, + code: data.code, + }) + streamingContent.value = "" + // Apply code to editors + if (data.code) { + applyCode(data.code) + } + } 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 = "" +} + +export function sendPrompt(content: string) { + if (!ws || ws.readyState !== WebSocket.OPEN) return + messages.value.push({ role: "user", content }) + ws.send(JSON.stringify({ type: "message", content })) +} + +export function newConversation() { + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type: "new_conversation" })) +} + +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 3: Commit** + +```bash +git add client/src/store/prompt.ts client/src/utils/const.ts +git commit -m "feat: add prompt store with WebSocket client and editor integration" +``` + +--- + +### Task 8: Frontend — Create PromptPanel component + +**Files:** +- Create: `client/src/components/PromptPanel.vue` + +**Step 1: Write `client/src/components/PromptPanel.vue`** + +```vue + + + + + +``` + +**Step 2: Commit** + +```bash +git add client/src/components/PromptPanel.vue +git commit -m "feat: add PromptPanel component with chat UI" +``` + +--- + +### Task 9: Frontend — Create ChallengeHome layout + +**Files:** +- Create: `client/src/pages/ChallengeHome.vue` + +**Step 1: Write `client/src/pages/ChallengeHome.vue`** + +This page replaces `Home.vue` when in challenge mode with a selected challenge. + +```vue + + + + + +``` + +**Step 2: Commit** + +```bash +git add client/src/pages/ChallengeHome.vue +git commit -m "feat: add ChallengeHome page with AI conversation and preview layout" +``` + +--- + +### Task 10: Frontend — Update router and API client + +**Files:** +- Modify: `client/src/router.ts` — add ChallengeHome route +- Modify: `client/src/api.ts` — add `conversation_id` to Submission.create, add Prompt API + +**Step 1: Update `client/src/router.ts`** + +Add the ChallengeHome import and change the challenge/:display route: + +```typescript +const ChallengeHome = () => import("./pages/ChallengeHome.vue") +``` + +Change the route for `home-challenge` from using `Home` to `ChallengeHome`: +```typescript +{ path: "/challenge/:display", name: "home-challenge", component: ChallengeHome }, +``` + +Keep all other routes unchanged. + +**Step 2: Update `client/src/api.ts`** + +Update `Submission.create` to accept `conversationId`: +```typescript + async create(taskId: number, data: { html: string; css: string; js: string; conversationId?: string }) { + return http.post("/submission/", { + task_id: taskId, + html: data.html, + css: data.css, + js: data.js, + conversation_id: data.conversationId || null, + }) + }, +``` + +Add a new `Prompt` export: +```typescript +export const Prompt = { + async listConversations(taskId?: number, userId?: number) { + const params: any = {} + if (taskId) params.task_id = taskId + if (userId) params.user_id = userId + return (await http.get("/prompt/conversations/", { params })).data + }, + async getMessages(conversationId: string) { + return (await http.get(`/prompt/conversations/${conversationId}/messages/`)).data + }, +} +``` + +**Step 3: Commit** + +```bash +git add client/src/router.ts client/src/api.ts +git commit -m "feat: update router for ChallengeHome and add prompt API client" +``` + +--- + +### Task 11: Frontend — Add prompt chain viewer to Submissions page + +**Files:** +- Modify: `client/src/pages/Submissions.vue` — add "查看思维链" button and modal + +**Step 1: Study `Submissions.vue` current structure** + +Read the file to understand current table columns and layout. + +**Step 2: Add prompt chain viewer** + +Add a new column to the submissions table: +```typescript +{ + title: "思维链", + key: "conversation_id", + render(row) { + if (!row.conversation_id) return "-" + return h(NButton, { text: true, type: "primary", onClick: () => showChain(row.conversation_id) }, () => "查看") + } +} +``` + +Add a modal that displays the prompt chain conversation: +```vue + +
+
+ {{ msg.role === 'user' ? '学生' : 'AI' }} +
+
+
+ +``` + +Add the `showChain` function that calls `Prompt.getMessages(conversationId)`. + +**Step 3: Commit** + +```bash +git add client/src/pages/Submissions.vue +git commit -m "feat: add prompt chain viewer to submissions page" +``` + +--- + +### Task 12: Remove old `chat` app + +**Files:** +- Delete: `api/chat/` directory +- Modify: `api/api/settings.py` — remove `"chat"` from INSTALLED_APPS + +**Step 1: Remove `chat` from INSTALLED_APPS** + +Edit `api/api/settings.py`, remove `"chat"` from the `INSTALLED_APPS` list. + +**Step 2: Delete `api/chat/` directory** + +```bash +rm -rf /home/xuyue/Projects/webpreview/api/chat/ +``` + +**Step 3: Run migrations to clean up** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run manage.py migrate +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "chore: remove old chat app, replaced by prompt" +``` + +--- + +### Task 13: Update Caddyfile for WebSocket routing + +**Files:** +- Check: `Caddyfile` — verify `ws*` routing already covers `ws/prompt/` + +**Step 1: Read `Caddyfile` and verify** + +The existing Caddyfile routes `/ws*` to the backend. Since the new WebSocket path is `ws/prompt/{task_id}/`, it should already be covered. Verify and update if needed. + +**Step 2: Commit (only if changed)** + +--- + +### Task 14: End-to-end manual testing + +**Step 1: Set up LLM API key** + +Add `LLM_API_KEY` to `.env` with a valid API key for the chosen provider. + +**Step 2: Run backend** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run manage.py migrate +uv run manage.py runserver +``` + +**Step 3: Run frontend** + +```bash +cd /home/xuyue/Projects/webpreview/client +npm run start +``` + +**Step 4: Test flow** + +1. Login as a student +2. Go to challenge list, click a challenge +3. Verify ChallengeHome layout loads (challenge desc top-left, prompt panel bottom-left, preview right) +4. Type a prompt, verify AI streams response and code fills preview +5. Send follow-up prompts, verify conversation history +6. Click "提交作品", verify submission includes conversation_id +7. Go to Submissions page, verify "查看思维链" button shows the prompt history +8. Login as teacher (admin/super), verify can view student conversations diff --git a/docs/plans/2026-03-09-challenge-ai-chat-improvements-design.md b/docs/plans/2026-03-09-challenge-ai-chat-improvements-design.md new file mode 100644 index 0000000..095d540 --- /dev/null +++ b/docs/plans/2026-03-09-challenge-ai-chat-improvements-design.md @@ -0,0 +1,51 @@ +# 挑战 AI 对话改进设计文档 + +**日期:** 2026-03-09 + +## 背景 + +挑战页面有 AI 对话功能(思维链),目前存在两个问题: +1. AI 回复中的代码块占用大量空间,影响对话可读性 +2. 用户需要手动点击"提交作品",导致中间过程的代码版本无法被记录 + +## 需求 + +1. **AI 消息中的代码块默认折叠**,用户可点击展开查看 +2. **AI 生成代码后自动静默提交**,每轮生成代码的回复都触发一次提交,保留手动提交按钮 + +## 方案 + +### 需求一:代码块折叠 + +**修改文件:** `client/src/components/PromptPanel.vue` + +- 在 `renderMarkdown()` 中使用自定义 marked renderer,覆盖 `code` 渲染逻辑 +- 代码块输出为原生 `
/` HTML,summary 显示语言名称 +- 流式输出的 `streamingContent` 使用相同 renderer +- 加 scoped CSS 美化折叠组件样式 + +```html +
+ 查看代码(javascript) +
...
+
+``` + +### 需求二:自动提交 + +**修改文件:** `client/src/store/prompt.ts`、`client/src/pages/ChallengeHome.vue` + +**prompt.ts:** +- 暴露 `onCodeComplete: ((code: {...}) => void) | null` 回调 +- `complete` 事件中,当 `data.code` 存在时调用该回调 + +**ChallengeHome.vue:** +- `loadChallenge()` 中注册 `onCodeComplete` 回调,执行静默提交(不显示 loading,失败不弹提示) +- `onUnmounted` 中将 `onCodeComplete` 置为 `null` +- 保留现有手动"提交作品"按钮 + +## 影响范围 + +- `client/src/components/PromptPanel.vue`:代码块折叠渲染 +- `client/src/store/prompt.ts`:新增 `onCodeComplete` 回调 +- `client/src/pages/ChallengeHome.vue`:注册回调、自动提交逻辑 diff --git a/docs/plans/2026-03-09-challenge-ai-chat-improvements.md b/docs/plans/2026-03-09-challenge-ai-chat-improvements.md new file mode 100644 index 0000000..16a15f3 --- /dev/null +++ b/docs/plans/2026-03-09-challenge-ai-chat-improvements.md @@ -0,0 +1,209 @@ +# Challenge AI Chat Improvements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 折叠 AI 对话中的代码块,并在 AI 生成代码后自动静默提交作品。 + +**Architecture:** 在 `PromptPanel.vue` 中用自定义 marked renderer 将代码块渲染为 `
/`;在 `prompt.ts` 中暴露 `onCodeComplete` 回调,`ChallengeHome.vue` 注册回调触发静默提交。 + +**Tech Stack:** Vue 3, TypeScript, marked (custom renderer), Naive UI, WebSocket (already in use) + +--- + +### Task 1: 代码块折叠 — 自定义 marked renderer + +**Files:** +- Modify: `client/src/components/PromptPanel.vue` + +**Step 1: 在 ` + + +``` + +**Step 2: 添加路由** + +打开 `client/src/router.ts`,在 `routes` 数组里(submission 路由之后)追加: + +```typescript +{ + path: "/leaderboard", + name: "leaderboard", + component: () => import("./pages/Leaderboard.vue"), +}, +``` + +**Step 3: Commit** + +```bash +git add client/src/pages/Leaderboard.vue client/src/router.ts +git commit -m "feat: add leaderboard page and route" +``` + +--- + +### Task 8: 我的成绩页面和路由 + +**Files:** +- Create: `client/src/pages/MyScores.vue` +- Modify: `client/src/router.ts` + +**Step 1: 创建 MyScores.vue** + +```vue + + + + + +``` + +**Step 2: 添加路由(需要登录)** + +在 `client/src/router.ts` 里追加: + +```typescript +{ + path: "/my-scores", + name: "my-scores", + component: () => import("./pages/MyScores.vue"), + meta: { auth: true }, +}, +``` + +**Step 3: Commit** + +```bash +git add client/src/pages/MyScores.vue client/src/router.ts +git commit -m "feat: add my-scores page and route" +``` + +--- + +### Task 9: 挑战卡片显示用户最高得分 + +**Files:** +- Modify: `client/src/components/Challenge.vue` + +**Step 1: 加载 my-scores 并映射** + +打开 `client/src/components/Challenge.vue`,在 ` + + +``` + +- [ ] **Step 2: Verify no TypeScript errors** + +```bash +npm run build 2>&1 | grep -E "error TS|Error" | head -20 +``` + +Expected: no TypeScript errors in `Ranking.vue`. + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/Ranking.vue +git commit -m "feat: add Ranking page with four-zone display" +``` + +--- + +### Task 8: Register route and add nav entry + +**Files:** +- Modify: `client/src/router.ts` +- Modify: `client/src/components/Toolbar.vue` + +- [ ] **Step 1: Add route to `router.ts`** + +After the `leaderboard` route entry, add: + +```ts +{ + path: "/ranking", + name: "ranking", + component: () => import("./pages/Ranking.vue"), + meta: { auth: true }, +}, +``` + +- [ ] **Step 2: Add "排名" menu item to `Toolbar.vue`** + +In the `menu` computed array, after the `"我的提交"` entry, add: + +```ts +{ + label: "排名榜", + key: "ranking", + icon: () => + h(Icon, { + icon: "streamline-emojis:trophy", + }), +}, +``` + +- [ ] **Step 3: Handle the new menu key in `clickMenu`** + +In the `clickMenu` switch, after the `submissions` case, add: + +```ts +case "ranking": + router.push({ name: "ranking" }) + break +``` + +- [ ] **Step 4: End-to-end verification** + +Start both servers (`uv run manage.py runserver` + `npm run start`). + +Checklist: +- [ ] Log in, open user menu → "排名榜" appears +- [ ] Clicking "排名榜" navigates to `/ranking` +- [ ] All four zone tabs render with count badges +- [ ] 待评 tab shows submissions with score = 0 +- [ ] Click a row → right panel shows the preview +- [ ] On Submissions page, own submissions show ☆ / 🏅 in "排名" column +- [ ] Clicking ☆ calls nominate, row shows 🏅, previous nomination clears +- [ ] After nominating, go to Ranking page → submission appears in correct zone + +- [ ] **Step 5: Commit** + +```bash +git add src/router.ts src/components/Toolbar.vue +git commit -m "feat: register /ranking route and add nav entry" +``` diff --git a/docs/superpowers/plans/2026-03-18-load-chat-history-on-challenge-entry.md b/docs/superpowers/plans/2026-03-18-load-chat-history-on-challenge-entry.md new file mode 100644 index 0000000..b34b3b0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-load-chat-history-on-challenge-entry.md @@ -0,0 +1,344 @@ +# Load Chat History on Challenge Entry — 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:** When a user opens a challenge's AI chat tab, their previous conversation messages load immediately via HTTP API before the WebSocket connects. + +**Architecture:** Call `Prompt.listConversations(taskId)` + `Prompt.getMessages(id)` in `ChallengeHome.vue` alongside `connectPrompt()`. The HTTP response populates the store immediately; the WebSocket `init` handler skips overwriting messages if the conversation ID already matches (preventing stale overwrite without breaking "新对话"). A cancellation token prevents in-flight HTTP responses from writing to the store after the user navigates away. + +**Tech Stack:** Vue 3, TypeScript, Naive UI (`n-spin`), existing `Prompt` API client + +--- + +## File Map + +| File | Change | +|------|--------| +| `client/src/utils/type.ts` | Add `created?: string` to `PromptMessage` interface | +| `client/src/store/prompt.ts` | Add `historyLoading` ref, cancellation token, `loadHistory()` function; update `init` handler and `disconnectPrompt` | +| `client/src/pages/ChallengeHome.vue` | Call `loadHistory()` alongside `connectPrompt()` in `loadChallenge()` | +| `client/src/components/PromptPanel.vue` | Import `historyLoading`; show `n-spin` while loading | + +--- + +### Task 1: Add `created` to `PromptMessage` in `utils/type.ts` + +**Files:** +- Modify: `client/src/utils/type.ts` + +The `PromptMessage` interface represents the raw HTTP response shape from +`GET /prompt/conversations/{id}/messages/`. The backend already returns a `created` field +but the frontend type is missing it, which would cause a TypeScript error when mapping +the HTTP response in `loadHistory`. + +- [ ] **Step 1: Add `created` field** + + In `client/src/utils/type.ts`, change: + + ```typescript + export interface PromptMessage { + id: number + role: string + content: string + code_html: string | null + code_css: string | null + code_js: string | null + } + ``` + + to: + + ```typescript + export interface PromptMessage { + id: number + role: string + content: string + code_html: string | null + code_css: string | null + code_js: string | null + created?: string + } + ``` + +- [ ] **Step 2: Verify TypeScript compiles** + + ```bash + cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit + ``` + + Expected: no new errors + +- [ ] **Step 3: Commit** + + ```bash + cd /home/xuyue/Projects/webpreview/client + git add src/utils/type.ts + git commit -m "fix: add created field to PromptMessage type" + ``` + +--- + +### Task 2: Add `historyLoading` and `loadHistory` to `prompt.ts` + +**Files:** +- Modify: `client/src/store/prompt.ts` + +Key design decisions: +- Use a `_historyLoadId` counter as a cancellation token: `disconnectPrompt` increments it, + and `loadHistory` checks it before writing to the store — preventing in-flight responses + from corrupting state after the user navigates away. +- Filter conversations by `conv.username === user.username` so admin/super users only load + their own chat history (the API returns all users' conversations for non-normal roles). +- No new local type needed: `Prompt.getMessages` already returns `PromptMessage[]` + (from `utils/type.ts`) which now includes `created`. + +- [ ] **Step 1: Add import for `user` store and `PromptMessage` type** + + At the top of `client/src/store/prompt.ts`, add to the existing imports: + + ```typescript + import { Prompt } from "../api" + import type { PromptMessage as RawMessage } from "../utils/type" + import { user } from "./user" + ``` + + > Note: `RawMessage` is an alias to avoid collision with the store's own `PromptMessage` + > interface (which uses the nested `code: {html, css, js}` shape). + +- [ ] **Step 2: Add `historyLoading` export and cancellation token** + + After the existing `export const streaming = ref(false)` line, add: + + ```typescript + export const historyLoading = ref(false) + let _historyLoadId = 0 + ``` + +- [ ] **Step 3: Add `loadHistory` function** + + Add this function after `disconnectPrompt`: + + ```typescript + export async function loadHistory(taskId: number) { + const loadId = ++_historyLoadId + historyLoading.value = true + try { + const convs = await Prompt.listConversations(taskId) + if (loadId !== _historyLoadId) return // navigated away, abort + const active = convs.find( + (c: { is_active: boolean; message_count: number; username: string }) => + c.is_active && c.message_count > 0 && c.username === user.username + ) + if (!active) return + const raw: RawMessage[] = await Prompt.getMessages(active.id) + if (loadId !== _historyLoadId) return // navigated away, abort + conversationId.value = active.id + messages.value = raw.map((m) => ({ + role: m.role as "user" | "assistant", + content: m.content, + code: + m.role === "assistant" + ? { html: m.code_html, css: m.code_css, js: m.code_js } + : undefined, + created: m.created, + })) + } catch { + // 静默失败,不影响 WebSocket 正常流程 + } finally { + if (loadId === _historyLoadId) historyLoading.value = false + } + } + ``` + +- [ ] **Step 4: Fix `disconnectPrompt` to reset `historyLoading` and cancel in-flight loads** + + Find `disconnectPrompt`. Add two lines at the top of the function body: + + ```typescript + export function disconnectPrompt() { + _historyLoadId++ // cancel any in-flight loadHistory + historyLoading.value = false + if (ws) { + ws.close() + ws = null + } + messages.value = [] + conversationId.value = "" + connected.value = false + streaming.value = false + streamingContent.value = "" + _onCodeComplete = null + } + ``` + +- [ ] **Step 5: Fix the `init` handler guard** + + Find the `if (data.type === "init")` block inside `connectPrompt`. Replace the whole block: + + ```typescript + if (data.type === "init") { + // Skip overwriting messages if HTTP preload already loaded this conversation. + // If conversation_id differs (e.g. after "新对话"), always overwrite. + const alreadyLoaded = conversationId.value === data.conversation_id + conversationId.value = data.conversation_id + if (!alreadyLoaded) { + messages.value = data.messages || [] + } + // Apply code from last assistant message if exists + const lastAssistant = [...messages.value] + .reverse() + .find((m) => m.role === "assistant" && m.code) + if (lastAssistant?.code) { + applyCode(lastAssistant.code) + } + } + ``` + +- [ ] **Step 6: Verify TypeScript compiles** + + ```bash + cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit + ``` + + Expected: no errors in `src/store/prompt.ts` + +- [ ] **Step 7: Commit** + + ```bash + cd /home/xuyue/Projects/webpreview/client + git add src/store/prompt.ts + git commit -m "feat: add loadHistory for HTTP-based chat history preload" + ``` + +--- + +### Task 3: Call `loadHistory` in `ChallengeHome.vue` + +**Files:** +- Modify: `client/src/pages/ChallengeHome.vue` + +- [ ] **Step 1: Add `loadHistory` to the import** + + In the existing prompt import line, add `loadHistory`: + + ```typescript + import { + connectPrompt, + disconnectPrompt, + conversationId, + streaming, + setOnCodeComplete, + loadHistory, + } from "../store/prompt" + ``` + +- [ ] **Step 2: Call `loadHistory` alongside `connectPrompt` in `loadChallenge`** + + In `loadChallenge()`, after `taskId.value = data.task_ptr`, find the line: + + ```typescript + connectPrompt(data.task_ptr) + ``` + + Replace with: + + ```typescript + loadHistory(data.task_ptr) // HTTP preload — async, non-blocking + connectPrompt(data.task_ptr) // WebSocket — synchronous open + ``` + + Both fire without `await` so they run concurrently. HTTP is typically faster than + WebSocket handshake + backend DB query, so the user usually sees history before + the `init` message arrives. The `init` guard handles deduplication if WS wins the race. + +- [ ] **Step 3: Verify TypeScript compiles** + + ```bash + cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit + ``` + + Expected: no errors + +- [ ] **Step 4: Commit** + + ```bash + cd /home/xuyue/Projects/webpreview/client + git add src/pages/ChallengeHome.vue + git commit -m "feat: preload chat history via HTTP on challenge entry" + ``` + +--- + +### Task 4: Show loading indicator in `PromptPanel.vue` + +**Files:** +- Modify: `client/src/components/PromptPanel.vue` + +- [ ] **Step 1: Import `historyLoading`** + + In the script block, add `historyLoading` to the existing import: + + ```typescript + import { + messages, + streaming, + streamingContent, + sendPrompt, + newConversation, + historyLoading, + } from "../store/prompt" + ``` + +- [ ] **Step 2: Add loading indicator to template** + + Inside `
`, add as first child: + + ```html +
+
+ + 加载历史记录… +
+
+ ``` + +- [ ] **Step 3: Add loading style** + + In ` +``` + +- [ ] **Step 2: 启动前端开发服务器验证** + +```bash +cd client && npm run start +``` + +打开浏览器,点击登录按钮,检查: +- [ ] 显示两个 Tab:学生登录 / 管理员登录 +- [ ] 学生登录 Tab:班级下拉加载数据(若无数据则为空列表) +- [ ] 选择班级后,姓名下拉变为可用并加载数据 +- [ ] 三个字段未全填时,登录按钮 disabled +- [ ] 管理员登录 Tab:用户名+密码表单,两个字段未填时按钮 disabled +- [ ] 切换 Tab 时,两个 Tab 的所有字段清空 + +- [ ] **Step 3: Commit** + +```bash +git add client/src/components/Login.vue +git commit -m "feat: refactor Login.vue with class/name dropdown for students and admin tab" +``` + +--- + +## Task 6: 端到端验证 + +- [ ] **Step 1: 通过 Django admin 或 batch_create 创建测试用户** + +```bash +cd api && uv run manage.py shell -c " +from account.models import User +u = User(username='web测试班张三', classname='测试班') +u.set_password('123456') +print('created:', u.username, u.classname) +" +``` + +- [ ] **Step 2: 验证 /classes 返回新班级** + +```bash +curl http://localhost:8000/api/account/classes +# 期望:["测试班"] +``` + +- [ ] **Step 3: 验证 /names 返回学生** + +```bash +curl "http://localhost:8000/api/account/names?classname=测试班" +# 期望:[{"name": "张三", "username": "web测试班张三"}] +``` + +- [ ] **Step 4: 在浏览器中完整登录流程测试** + +1. 打开登录框,选学生登录 Tab +2. 班级下拉选"测试班" +3. 姓名下拉选"张三" +4. 输入密码 `123456` +5. 点击登录,验证成功跳转 + +- [ ] **Step 5: 测试管理员 Tab 登录** + +用现有管理员账号在管理员 Tab 登录,验证正常。 + +- [ ] **Step 6: 清理测试数据** + +```bash +cd api && uv run manage.py shell -c " +from account.models import User +User.objects.filter(username='web测试班张三').delete() +print('cleaned') +" +``` diff --git a/docs/superpowers/plans/2026-03-30-submission-view-count.md b/docs/superpowers/plans/2026-03-30-submission-view-count.md new file mode 100644 index 0000000..c48c7be --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-submission-view-count.md @@ -0,0 +1,244 @@ +# Submission View Count 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:** 在 `GET /api/submission/{id}` 时原子递增 `view_count` 字段,并在响应中返回该值,供管理员了解哪些提交热度最高。 + +**Architecture:** 在 `Submission` 模型新增 `view_count` 整数字段;`get_submission` 视图在查询后、返回前执行一次 `UPDATE ... SET view_count = view_count + 1`(使用 `F()` 表达式保证原子性);`SubmissionOut` schema 暴露该字段。 + +**Tech Stack:** Django 6, django-ninja, SQLite(开发)/ PostgreSQL(生产),uv + +--- + +## 文件变更概览 + +| 文件 | 操作 | +|------|------| +| `api/submission/models.py` | 新增 `view_count` 字段 | +| `api/submission/migrations/0009_add_view_count.py` | 新建 migration | +| `api/submission/schemas.py` | `SubmissionOut` 新增 `view_count: int` | +| `api/submission/api.py` | `get_submission` 中递增 `view_count` | +| `api/submission/tests.py` | 新建测试文件 | + +--- + +### Task 1: 写失败测试 + +**Files:** +- Create: `api/submission/tests.py` + +- [ ] **Step 1: 创建测试文件** + +```python +# api/submission/tests.py +import uuid +from django.test import TestCase +from django.contrib.auth import get_user_model +from ninja.testing import TestClient + +from task.models import Task +from submission.models import Submission +from api.api import api # django-ninja 的 NinjaAPI 实例 + +User = get_user_model() + + +class ViewCountTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", password="pass" + ) + self.task = Task.objects.create( + title="Test Task", + task_type="challenge", + display=1, + ) + self.submission = Submission.objects.create( + user=self.user, + task=self.task, + html="

hi

", + ) + + def test_get_submission_increments_view_count(self): + """每次 GET /{id} 后 view_count 应加 1""" + client = TestClient(api) + client.force_login(self.user) # ninja TestClient auth + + assert self.submission.view_count == 0 + + client.get(f"/submission/{self.submission.id}") + self.submission.refresh_from_db() + assert self.submission.view_count == 1 + + client.get(f"/submission/{self.submission.id}") + self.submission.refresh_from_db() + assert self.submission.view_count == 2 + + def test_get_submission_response_includes_view_count(self): + """响应 JSON 中应包含 view_count 字段""" + client = TestClient(api) + client.force_login(self.user) + + response = client.get(f"/submission/{self.submission.id}") + assert response.status_code == 200 + data = response.json() + assert "view_count" in data + assert data["view_count"] == 1 +``` + +- [ ] **Step 2: 运行测试,确认失败** + +```bash +cd api && uv run manage.py test submission.tests.ViewCountTest -v 2 +``` + +预期:`AttributeError: 'Submission' object has no attribute 'view_count'`(字段不存在) + +--- + +### Task 2: 新增模型字段与 migration + +**Files:** +- Modify: `api/submission/models.py` +- Create: `api/submission/migrations/0009_add_view_count.py` + +- [ ] **Step 1: 在 `Submission` 模型添加字段** + +在 `api/submission/models.py` 的 `zone` 字段后面插入: + +```python + view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数") +``` + +完整的字段列表变为(只展示新增行的上下文): + +```python + zone = models.CharField( + max_length=10, + choices=ZoneChoices.choices, + null=True, + blank=True, + default=None, + db_index=True, + verbose_name="分区", + ) + view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数") +``` + +- [ ] **Step 2: 生成 migration** + +```bash +cd api && uv run manage.py makemigrations submission --name add_view_count +``` + +预期输出:`Migrations for 'submission': submission/migrations/0009_add_view_count.py` + +- [ ] **Step 3: 执行 migration** + +```bash +cd api && uv run manage.py migrate +``` + +预期:`Applying submission.0009_add_view_count... OK` + +--- + +### Task 3: 更新 schema + +**Files:** +- Modify: `api/submission/schemas.py` + +- [ ] **Step 1: 在 `SubmissionOut` 中添加 `view_count` 字段** + +在 `submit_count: int = 0` 这行之后添加: + +```python + view_count: int = 0 +``` + +同时在 `resolve_submit_count` 方法之后添加解析器: + +```python + @staticmethod + def resolve_view_count(obj): + return getattr(obj, "view_count", 0) or 0 +``` + +- [ ] **Step 2: 运行测试,确认仍失败(API 还未递增)** + +```bash +cd api && uv run manage.py test submission.tests.ViewCountTest -v 2 +``` + +预期:`AssertionError: assert 0 == 1`(字段存在但没有递增) + +--- + +### Task 4: 在 API 中递增 view_count + +**Files:** +- Modify: `api/submission/api.py` + +- [ ] **Step 1: 在 `get_submission` 中添加原子递增** + +找到 `api/submission/api.py` 的 `get_submission` 函数(第 311 行附近),将其改为: + +```python +@router.get("/{submission_id}", response=SubmissionOut) +@login_required +def get_submission(request, submission_id: UUID): + """ + 获取单个提交的详细信息 + """ + user_rating_subquery = Subquery( + Rating.objects.filter(user=request.user, submission=OuterRef("pk")).values( + "score" + )[:1], + output_field=IntegerField(), + ) + submission = get_object_or_404( + Submission.objects.select_related("task", "user").annotate( + my_score=user_rating_subquery + ), + id=submission_id, + ) + Submission.objects.filter(pk=submission.pk).update( + view_count=F("view_count") + 1 + ) + submission.view_count += 1 # 更新内存中的值,避免再次查询 + return submission +``` + +同时将 `api/submission/api.py` 顶部的导入行: + +```python +from django.db.models import Avg, Count, IntegerField, Max, OuterRef, Q, Subquery +``` + +改为: + +```python +from django.db.models import Avg, Count, F, IntegerField, Max, OuterRef, Q, Subquery +``` + +- [ ] **Step 2: 运行全部测试,确认通过** + +```bash +cd api && uv run manage.py test submission.tests.ViewCountTest -v 2 +``` + +预期: +``` +test_get_submission_increments_view_count ... ok +test_get_submission_response_includes_view_count ... ok +---------------------------------------------------------------------- +Ran 2 tests in X.XXXs +OK +``` + +- [ ] **Step 3: 提交** + +```bash +cd api && git add submission/models.py submission/migrations/0009_add_view_count.py submission/schemas.py submission/api.py submission/tests.py +git commit -m "feat: track view_count on Submission, increment on GET /{id}" +``` diff --git a/docs/superpowers/plans/2026-03-31-top-viewed-stats.md b/docs/superpowers/plans/2026-03-31-top-viewed-stats.md new file mode 100644 index 0000000..c841545 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-top-viewed-stats.md @@ -0,0 +1,304 @@ +# 人气前五 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:** 在提交统计弹窗中新增"人气前五"区块,展示当前 task 下 view_count 最高的前 5 条提交,跟随班级筛选。 + +**Architecture:** 后端在现有 `get_task_stats` 端点中新增 `top_viewed` 查询,前端在 `TaskStatsModal` 底部新增对应 UI 区块。全程复用已有数据流,不新增 API 路由。 + +**Tech Stack:** Python/Django-Ninja (backend schema + query), TypeScript (type definition), Vue 3 + Naive UI (frontend component) + +--- + +### Task 1: 后端 Schema — 新增 `TopViewedItem`,更新 `TaskStatsOut` + +**Files:** +- Modify: `api/submission/schemas.py:134-151` + +- [ ] **Step 1: 在 `FlagStats` 之前插入 `TopViewedItem` class** + +在 `api/submission/schemas.py` 第 134 行(`class FlagStats` 之前)插入: + +```python +class TopViewedItem(Schema): + username: str + classname: str + view_count: int + submission_id: str +``` + +- [ ] **Step 2: 在 `TaskStatsOut` 末尾新增 `top_viewed` 字段** + +将 `TaskStatsOut`(当前第 141-151 行)更新为: + +```python +class TaskStatsOut(Schema): + submitted_count: int + unsubmitted_count: int + average_score: Optional[float] + unrated_count: int + unsubmitted_users: list[UserTag] + unrated_users: list[UserTag] + submission_count_distribution: SubmissionCountBucket + flag_stats: FlagStats + classes: list[str] + top_viewed: list[TopViewedItem] +``` + +- [ ] **Step 3: 验证 Schema 无语法错误** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run python -c "from submission.schemas import TaskStatsOut, TopViewedItem; print('OK')" +``` + +Expected output: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add api/submission/schemas.py +git commit -m "feat: add TopViewedItem schema and top_viewed field to TaskStatsOut" +``` + +--- + +### Task 2: 后端查询 — 在 `get_task_stats` 中计算并返回 `top_viewed` + +**Files:** +- Modify: `api/submission/api.py:13-23` (imports) +- Modify: `api/submission/api.py:306-330` (flag_stats 之后 → return 之前,以及 return 语句) + +- [ ] **Step 1: 在 import 中新增 `TopViewedItem`** + +将 `api/submission/api.py` 顶部的 import(第 13-23 行)更新为: + +```python +from .schemas import ( + FlagIn, + FlagStats, + SubmissionCountBucket, + SubmissionFilter, + SubmissionIn, + SubmissionOut, + RatingScoreIn, + TaskStatsOut, + TopViewedItem, + UserTag, +) +``` + +- [ ] **Step 2: 在 `flag_stats =` 赋值之后、`return TaskStatsOut(...)` 之前插入 top_viewed 查询** + +在 `api/submission/api.py` 第 318 行(`return TaskStatsOut(` 之前)插入: + +```python + # Top 5 submissions by view_count (within filtered student_ids) + top_viewed_qs = ( + Submission.objects + .filter(task=task, user_id__in=student_ids) + .select_related("user") + .order_by("-view_count")[:5] + ) + top_viewed = [ + TopViewedItem( + username=s.user.username, + classname=s.user.classname, + view_count=s.view_count, + submission_id=str(s.id), + ) + for s in top_viewed_qs + ] +``` + +- [ ] **Step 3: 在 `return TaskStatsOut(...)` 中新增 `top_viewed=top_viewed`** + +将返回语句(当前第 320-330 行)更新为: + +```python + return TaskStatsOut( + submitted_count=submitted_count, + unsubmitted_count=unsubmitted_count, + average_score=average_score, + unrated_count=unrated_count, + unsubmitted_users=unsubmitted_users, + unrated_users=unrated_users, + submission_count_distribution=SubmissionCountBucket(**dist), + flag_stats=flag_stats, + classes=all_classes, + top_viewed=top_viewed, + ) +``` + +- [ ] **Step 4: 启动开发服务器,验证接口返回** + +```bash +cd /home/xuyue/Projects/webpreview/api +uv run manage.py runserver +``` + +另开终端,用已有管理员账号 cookie 或 session 测试(替换 `` 和 ``): + +```bash +curl -s -H "Cookie: sessionid=" \ + http://127.0.0.1:8000/api/submission/stats/ \ + | python3 -m json.tool | grep -A 20 '"top_viewed"' +``` + +Expected: 看到 `"top_viewed": [...]`,每项含 `username`、`classname`、`view_count`、`submission_id`。 + +- [ ] **Step 5: Commit** + +```bash +git add api/submission/api.py +git commit -m "feat: query top 5 by view_count in get_task_stats" +``` + +--- + +### Task 3: 前端类型 — 新增 `TopViewedItem` 接口,更新 `TaskStatsOut` + +**Files:** +- Modify: `client/src/utils/type.ts:136-148` + +- [ ] **Step 1: 在 `TaskStatsOut` 之前插入 `TopViewedItem` 接口** + +在 `client/src/utils/type.ts` 第 136 行(`export interface TaskStatsOut` 之前)插入: + +```ts +export interface TopViewedItem { + username: string + classname: string + view_count: number + submission_id: string +} +``` + +- [ ] **Step 2: 在 `TaskStatsOut` 末尾新增 `top_viewed` 字段** + +将 `TaskStatsOut`(当前第 137-148 行)更新为: + +```ts +export interface TaskStatsOut { + submitted_count: number + unsubmitted_count: number + average_score: number | null + unrated_count: number + unsubmitted_users: UserTag[] + unrated_users: UserTag[] + submission_count_distribution: SubmissionCountBucket + score_distribution: ScoreBucket + flag_stats: FlagStats + classes: string[] + top_viewed: TopViewedItem[] +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add client/src/utils/type.ts +git commit -m "feat: add TopViewedItem type and top_viewed to TaskStatsOut" +``` + +--- + +### Task 4: 前端 UI — 在 `TaskStatsModal` 中新增"人气前五"区块 + +**Files:** +- Modify: `client/src/components/TaskStatsModal.vue` + +- [ ] **Step 1: 在模板的"标记统计"区块(``,第 282 行附近)之后新增"人气前五"区块** + +找到 `` 之前的 `` 区块末尾的 `
`(第 325 行附近),在它后面、`` 之前插入: + +```html + +
+
+ 人气前五 +
+
+ 暂无 +
+
+
+ {{ i + 1 }} + + {{ displayName(item.username, item.classname) }} + + + {{ item.view_count }} 次 + +
+
+
+``` + +- [ ] **Step 2: 在 ` + + +``` + +- [ ] **Step 2: Start dev server and verify page loads** + +```bash +cd client && npm run start +``` + +Navigate to `http://localhost:3000/showcase` — should show loading spinner then "暂无展示作品" (no data in dev DB yet). No console errors expected. + +- [ ] **Step 3: Commit** + +```bash +git add client/src/pages/Showcase.vue +git commit -m "feat: add Showcase page" +``` + +--- + +## Task 10: Create ShowcaseDetail.vue page + +**Files:** +- Create: `client/src/pages/ShowcaseDetail.vue` + +- [ ] **Step 1: Create ShowcaseDetail.vue** + +Create `client/src/pages/ShowcaseDetail.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add client/src/components/ai/RandomRatingModal.vue +git commit -m "Add RandomRatingModal component" +``` + +--- + +### Task 4: 前端 — 在 ChallengeDetail 中挂载弹窗 + +**Files:** +- Modify: `client/src/pages/ChallengeDetail.vue` + +- [ ] **Step 1: 导入组件** + +在现有 import 列表中加入(放在 `PromptHistoryPanel` 导入之后): + +```ts +import RandomRatingModal from "../components/ai/RandomRatingModal.vue" +``` + +- [ ] **Step 2: 在模板中挂载组件** + +在 `` 之后添加: + +```html + +``` + +- [ ] **Step 3: 启动开发服务器并手动验证** + +```bash +./dev.sh +``` + +在浏览器中: +1. 以普通学生账号登录,打开任意挑战页面 +2. 切换到「AI 对话」标签,发送一条消息 +3. 观察:AI 开始流式生成时("AI 正在生成中…"),应弹出居中弹窗,显示其他同学的作品预览 + 星级评分 +4. 点击星级打分,确认出现"感谢评分!"提示,并自动加载下一个作品(弹窗保持打开) +5. 点击弹窗右上角关闭,确认弹窗消失;若 AI 仍在生成,约 30 秒后应再次弹出新作品 +6. 等待 AI 回复完成,确认弹窗自动关闭 + +- [ ] **Step 4: Commit** + +```bash +git add client/src/pages/ChallengeDetail.vue +git commit -m "Show random rating popup while waiting for AI response" +``` diff --git a/docs/superpowers/specs/2026-03-16-ranking-zones-design.md b/docs/superpowers/specs/2026-03-16-ranking-zones-design.md new file mode 100644 index 0000000..c454c6a --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-ranking-zones-design.md @@ -0,0 +1,255 @@ +# Ranking Zones Page — Design Spec + +**Date:** 2026-03-16 +**Status:** Approved + +## Overview + +A new standalone page that displays challenge submissions that students have explicitly nominated for ranking. Each nominated submission is placed into one of four score-based zones (Bayesian weighted score). Students choose which of their submissions enters the ranking; each student may nominate at most one submission per challenge. + +--- + +## 1. Scoring: Bayesian Weighted Score (global — replaces existing `score`) + +The existing `score` field is replaced with a Bayesian weighted score to prevent extreme scores from small numbers of raters distorting rankings. This affects the Submissions page, Leaderboard, My Scores, and the new Ranking page — all use Bayesian `score`. + +### Formula + +``` +bayesian_score = (C × m + n × raw_score) / (C + n) +``` + +- `raw_score` = role-weighted average: `(super×0.5 + admin×0.3 + normal×0.2) / n` +- `m` = global mean of `raw_score` across all submissions with at least one rating, excluding the current submission +- `n` = number of raters on this submission +- `C` = 3 (confidence parameter) + +When `n = 0`: both `score` and `raw_score` are 0. + +### Model changes (`api/submission/models.py`) + +Add `raw_score` field and add `Avg` import: + +```python +from django.db.models import Avg # add to imports + +raw_score = models.FloatField(default=0.0, verbose_name="原始加权分") +``` + +### `update_score()` replacement + +```python +def update_score(self): + ratings = list(self.ratings.select_related("user").all()) + n = len(ratings) + + if n == 0: + self.raw_score = 0.0 + self.score = 0.0 + self.save(update_fields=["raw_score", "score"]) + return + + weighted_sum = sum( + r.score * (0.5 if r.user.role == RoleChoices.SUPER + else 0.3 if r.user.role == RoleChoices.ADMIN + else 0.2) + for r in ratings + ) + self.raw_score = weighted_sum / n + + C = 3 + # Exclude self to avoid stale DB value skewing the mean + global_mean = ( + Submission.objects.filter(raw_score__gt=0) + .exclude(pk=self.pk) + .aggregate(Avg("raw_score"))["raw_score__avg"] + ) or self.raw_score # fallback if this is the only ever-rated submission + + self.score = (C * global_mean + n * self.raw_score) / (C + n) + self.save(update_fields=["raw_score", "score"]) +``` + +**Downstream note:** `Profile.recalculate_total_score()` (called after every rating via signal) uses best-score-per-challenge. After this change it will use Bayesian `score` values — leaderboard totals will reflect Bayesian scores. This is the intended global effect. + +Migration required: adds `raw_score` column (default `0.0`, no backfill needed for existing rows). + +--- + +## 2. Nomination: student opts one submission per challenge into ranking + +### Model changes (`api/submission/models.py`) + +```python +nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名") +``` + +**Constraint:** at most one nominated submission per (user, task) pair — enforced in the API. + +### New endpoint (`api/submission/api.py`) + +``` +PUT /api/submission/{submission_id}/nominate +``` + +- Login required; only the submission's owner may call it +- Sets `nominated=True` on this submission +- Sets `nominated=False` on all other submissions by the same user for the same task +- Returns `{"nominated": true}` + +### Schema changes (`api/submission/schemas.py`) + +Add `nominated: bool = False` to `SubmissionOut`: + +```python +nominated: bool = False +``` + +No resolver needed — `nominated` maps directly from the model field. + +### Frontend type (`client/src/utils/type.ts`) + +Add to `SubmissionOut` interface: + +```ts +nominated: boolean +``` + +### Nomination UI (`client/src/pages/Submissions.vue` and/or submission result) + +- Add a "参与排名" button visible only to the submission's owner (`row.userid === currentUser.id`) +- Nominated submission: show a 🏅 indicator on the row +- Clicking calls `Submission.nominate(id)`, then toggles `nominated` on the affected rows in `data.value` (set clicked row to `true`, others with same `task_id` and same user to `false`) + +--- + +## 3. Zones + +Only `nominated=True` submissions appear on the Ranking page. Zones by Bayesian `score`: + +| Zone | Score Condition | Sort Order | +|------|----------------|------------| +| 🏆 精华 | score ≥ 4.5 | score DESC | +| ⭐ 优秀 | 3.5 ≤ score < 4.5 | score DESC | +| 📝 普通 | 0 < score < 3.5 | score DESC | +| ⏳ 待评 | score = 0 | created DESC | + +待评 zone uses `score__lt=0.001` (not exact float equality) to safely capture unrated submissions. +Minimum non-zero Bayesian score is well above 0.001 (single normal-role rater scoring 1 → raw=0.2 → Bayesian ≈ (3×m+0.2)/4). + +--- + +## 4. Ranking Page layout + +Split-pane (same as `Submissions.vue`): left 33% list, right 67% preview. + +**Left panel:** +- Zone tabs with count badges (fetched on mount, not refreshed after scoring) +- Empty zone: `n-empty` placeholder +- Columns: `#` · `得分` · `提交者` · `任务` · `时间` +- Pagination 10/page; resets to page 1 on zone switch + +**Right panel:** +- `Preview` component; `@after-score` updates only `my_score` on the row — zone reassignment waits for full reload (accepted) + +--- + +## 5. Backend filter changes (`api/submission/`) + +### `schemas.py` — `SubmissionFilter` additions + +```python +score_min: Optional[float] = None +score_max_exclusive: Optional[float] = None +score_lt_threshold: Optional[float] = None # replaces score_exact_zero; use 0.001 for 待评 +nominated: Optional[bool] = None +ordering: Optional[str] = None # "-score" | "score" | "-created" +``` + +### `api.py` — `list_submissions` additions + +```python +if filters.nominated is not None: + submissions = submissions.filter(nominated=filters.nominated) +if filters.score_lt_threshold is not None: + submissions = submissions.filter(score__lt=filters.score_lt_threshold) +else: + if filters.score_min is not None: + submissions = submissions.filter(score__gte=filters.score_min) + if filters.score_max_exclusive is not None: + submissions = submissions.filter(score__lt=filters.score_max_exclusive) +if filters.ordering in ("-score", "score", "-created"): + submissions = submissions.order_by(filters.ordering) +``` + +Existing callers pass none of these params — unaffected. + +### Zone → API param mapping + +| Zone | Params | +|------|--------| +| 精华 | `task_type=challenge, nominated=true, score_min=4.5, ordering=-score` | +| 优秀 | `task_type=challenge, nominated=true, score_min=3.5, score_max_exclusive=4.5, ordering=-score` | +| 普通 | `task_type=challenge, nominated=true, score_min=0.001, score_max_exclusive=3.5, ordering=-score` | +| 待评 | `task_type=challenge, nominated=true, score_lt_threshold=0.001, ordering=-created` | + +--- + +## 6. Frontend API changes (`client/src/api.ts`) + +Updated `Submission.list` signature (adds new params, preserves existing ones): + +```ts +async list(query: { + page: number + username?: string + flag?: string | null + task_type?: string + score_min?: number + score_max_exclusive?: number + score_lt_threshold?: number + nominated?: boolean + ordering?: string +}) +``` + +New method: + +```ts +async nominate(id: string) { + const res = await http.put(`/submission/${id}/nominate`) + return res.data +} +``` + +--- + +## 7. New file: `client/src/pages/Ranking.vue` + +- Auth-guarded (`meta: { auth: true }`) +- Zone tab + per-zone page state; default zone: 精华 +- 4 parallel count-badge calls on mount +- Selected submission → Preview + +## 8. Router & nav + +```ts +// router.ts +{ path: '/ranking', component: () => import('./pages/Ranking.vue'), meta: { auth: true } } +``` + +Add "排名" link in existing nav. + +--- + +## 9. Database migration + +Adds `raw_score` (FloatField, default=0.0) and `nominated` (BooleanField, default=False) to `submission_submission`. + +--- + +## Out of Scope + +- Per-challenge filtering on Ranking page +- Withdrawing from ranking without re-nominating +- Refreshing zone counts after inline scoring +- Tutorial submissions (fixed `task_type=challenge` on all Ranking page requests) diff --git a/docs/superpowers/specs/2026-03-18-login-class-select-design.md b/docs/superpowers/specs/2026-03-18-login-class-select-design.md new file mode 100644 index 0000000..c309a1f --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-login-class-select-design.md @@ -0,0 +1,153 @@ +# 登录框重构:班级+姓名选择登录 + +**日期:** 2026-03-18 +**状态:** 已批准 + +--- + +## 背景 + +当前登录框只有用户名+密码两个字段。用户(学生)的用户名格式为 `web{classname}{name}`(如 `web301张三`),输入完整用户名对学生不友好。需要重构为班级→姓名下拉选择方式登录,同时为管理员保留用户名+密码入口。 + +--- + +## 需求 + +- 学生通过"选班级 → 选姓名 → 输密码"方式登录 +- 管理员通过原有用户名+密码方式登录 +- 两种模式通过 Tab 切换 + +--- + +## 后端改动 + +### 1. User 模型新增字段 + +```python +# api/account/models.py +classname = models.CharField(max_length=50, blank=True, default="", verbose_name="班级") +``` + +执行数据库迁移。迁移后,**现有数据库中的所有用户(包括历史批量创建的用户)`classname` 均为空字符串**,因此不会出现在新端点的班级列表中。这是已知且可接受的行为——历史用户需重新通过新版批量创建接口创建,或手动更新 `classname`。 + +### 2. batch_create 接口保存 classname + +在 `User(...)` 构造函数中设置 `classname`,在调用 `set_password()` 之前(`set_password` 内部调用 `self.save()`,必须保证字段在首次持久化时已赋值): + +```python +user = User(username=username, classname=payload.classname) +user.set_password(password) # 触发 save(),此时 classname 已在实例上 +users_to_create.append(user) +``` + +> 注:现有代码中 `set_password` 会触发 `save()`,之后的 `bulk_create(..., ignore_conflicts=True)` 对已存在行为空操作,无需改动该逻辑。 + +### 3. 新增两个无鉴权 GET 端点 + +> **安全说明:** 两个端点均不需要登录。`/names` 会返回某班级所有学生的用户名,存在账号枚举风险。考虑到平台面向内部学生,此为可接受的设计权衡。 + +**`GET /api/account/classes`** + +返回所有不重复的非空班级名列表,按字典序升序排列。 + +```json +["301", "302", "高一1班"] +``` + +实现: +```python +User.objects.filter(classname__gt="").values_list("classname", flat=True).distinct().order_by("classname") +``` + +**`GET /api/account/names?classname=301`** + +`classname` 为必填查询参数(缺省时 django-ninja 返回 422)。 + +返回该班级所有用户的姓名和用户名,按 `username` 升序排列(等价于按姓名升序,因 classname 前缀相同)。仅返回 `username` 以 `"web" + classname` 开头的用户,防止异常数据干扰。 + +```json +[ + {"name": "张三", "username": "web301张三"}, + {"name": "李四", "username": "web301李四"} +] +``` + +实现: +```python +prefix = "web" + classname +users = User.objects.filter(classname=classname, username__startswith=prefix).order_by("username") +return [{"name": u.username[len(prefix):], "username": u.username} for u in users] +``` + +如果 `classname` 不存在或该班无用户,返回空列表 `[]`。 + +--- + +## 前端改动 + +### Login.vue 重构 + +用 `n-tabs` 分为两个 Tab: + +**Tab 1 - 学生登录** + +| 字段 | 组件 | 说明 | +|------|------|------| +| 班级 | `n-select` | 挂载时调 `/classes` 加载,结果缓存在组件内,切换 Tab 不重新请求 | +| 姓名 | `n-select` | 选完班级后调 `/names?classname=X` 加载;班级未选时 disabled | +| 密码 | `n-input` type=password | 手动输入 | + +**`n-select` 数据绑定:** +- 班级下拉:`label = classname, value = classname` +- 姓名下拉:`label = item.name, value = item.username`(即 `selectedUsername` 存储完整 username) + +**登录按钮 disabled 条件:** +学生 Tab:班级、姓名、密码三者均已填写才可点击。 + +**级联状态管理:** +- 切换到其他 Tab 时,**清空**班级、姓名、密码的已选值(重置为初始状态) +- 重新选择班级时,**清空**已选姓名(`selectedUsername = ""`)并重新调 `/names` 加载 + +**API 错误处理:** +- `/classes` 加载失败:班级下拉显示为空,展示通用错误提示 +- `/names` 加载失败:姓名下拉显示为空并保持 disabled,展示通用错误提示 + +登录时发送 `{ username: selectedUsername, password }` 到现有 `/api/account/login` 端点。 + +**Tab 2 - 管理员登录** + +保持现有用户名+密码表单,无改动。管理员 Tab 登录按钮 disabled 条件:用户名和密码均非空。 + +### api.ts 新增 + +```typescript +Account.listClasses(): Promise +Account.listNamesByClass(classname: string): Promise<{name: string, username: string}[]> +``` + +--- + +## 数据流 + +``` +挂载 Login.vue + → GET /api/account/classes → 缓存班级列表,填充班级下拉 + +用户选择班级 + → 清空 selectedUsername + → GET /api/account/names?classname=X → 填充姓名下拉 + (每个 option: label=name, value=username) + +用户选姓名 → selectedUsername = item.username(完整 username) +用户输密码 + → POST /api/account/login { username: selectedUsername, password } + → 登录成功 → 关闭 Modal,更新 user store +``` + +--- + +## 不在此次范围内 + +- 历史用户(非 batch 创建)的 classname 回填(现有用户 classname 为空,不出现在班级列表中) +- `UserListSchema` 和用户管理页不展示 classname 字段(可后续迭代) +- 学生忘记密码流程 diff --git a/docs/superpowers/specs/2026-03-30-submission-view-count-design.md b/docs/superpowers/specs/2026-03-30-submission-view-count-design.md new file mode 100644 index 0000000..953db8d --- /dev/null +++ b/docs/superpowers/specs/2026-03-30-submission-view-count-design.md @@ -0,0 +1,37 @@ +# 提交打开次数统计 设计文档 + +**日期:** 2026-03-30 + +## 目标 + +统计每个 Submission 被打开(查看详情)的次数,供管理员判断哪些作品被查看最多(热度指标)。 + +## 触发条件 + +每次调用 `GET /api/submission/{submission_id}` 时计数加一,无论访问者身份。 + +## 数据模型变更 + +在 `Submission` 模型新增字段: + +```python +view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数") +``` + +新建对应 migration。 + +## API 变更 + +### `GET /api/submission/{submission_id}` + +- 在返回响应前,执行原子递增: + ```python + Submission.objects.filter(pk=submission_id).update(view_count=F("view_count") + 1) + ``` +- `SubmissionOut` schema 新增 `view_count: int` 字段,暴露给前端。 + +## 不在本次范围内 + +- 去重(同一用户多次访问分别计数) +- 时序数据 / 按时间段查询 +- 前端展示(管理员可通过 Django Admin 或 API 查看) diff --git a/docs/superpowers/specs/2026-03-31-top-viewed-stats-design.md b/docs/superpowers/specs/2026-03-31-top-viewed-stats-design.md new file mode 100644 index 0000000..58f7732 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-top-viewed-stats-design.md @@ -0,0 +1,88 @@ +# 提交统计"人气前五"功能设计 + +**日期**: 2026-03-31 + +## 需求 + +在提交统计弹窗(`TaskStatsModal`)中新增"人气前五"区块,展示当前 task 下 view_count 最高的前 5 条提交记录,跟随班级筛选。 + +## 后端变更 + +### 新增 Schema(`api/submission/schemas.py`) + +```python +class TopViewedItem(Schema): + username: str + classname: str + view_count: int + submission_id: str # UUID,用于跳转 +``` + +### `TaskStatsOut` 新增字段 + +```python +top_viewed: list[TopViewedItem] +``` + +### `get_task_stats` 查询逻辑(`api/submission/api.py`) + +在已有的 `submitted_ids` / `student_ids` 过滤基础上,取 view_count 最高的前 5 条提交: + +```python +top_viewed_qs = ( + Submission.objects + .filter(task=task, user_id__in=student_ids) + .select_related("user") + .order_by("-view_count")[:5] +) +top_viewed = [ + TopViewedItem( + username=s.user.username, + classname=s.user.classname, + view_count=s.view_count, + submission_id=str(s.id), + ) + for s in top_viewed_qs +] +``` + +`student_ids` 已经根据 `classname` 参数过滤,因此自动跟随班级筛选。 + +每个用户可能有多条提交,取 view_count 最高的那条(不按用户去重,直接取整体前 5)。 + +## 前端变更 + +### `client/src/utils/type.ts` + +新增接口: + +```ts +export interface TopViewedItem { + username: string + classname: string + view_count: number + submission_id: string +} +``` + +`TaskStatsOut` 新增字段: + +```ts +top_viewed: TopViewedItem[] +``` + +### `client/src/components/TaskStatsModal.vue` + +在"标记统计"区块下方新增"人气前五"区块: + +- 标题:"人气前五",风格与"标记统计"/"提交次数分布"一致 +- 每项展示:排名序号(1-5)+ 用户显示名 + view_count(如"128 次") +- 点击可跳转到该提交详情(复用现有 `viewSubmission(id)` 方法) +- 若 `top_viewed` 为空则显示"暂无" +- view_count 为 0 的项仍展示(表示真实数据) + +## 不涉及范围 + +- 不修改 `view_count` 的计算/更新逻辑 +- 不新增 API 端点,复用现有 `/submission/stats/{task_id}` +- 不添加分页或更多排名 diff --git a/docs/superpowers/specs/2026-04-14-delete-message-pair-submission-design.md b/docs/superpowers/specs/2026-04-14-delete-message-pair-submission-design.md new file mode 100644 index 0000000..7c57756 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-delete-message-pair-submission-design.md @@ -0,0 +1,180 @@ +# 设计文档:学生删除 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` | 消息分组 + 删除按钮 | diff --git a/docs/superpowers/specs/2026-04-29-showcase-design.md b/docs/superpowers/specs/2026-04-29-showcase-design.md new file mode 100644 index 0000000..dd7a716 --- /dev/null +++ b/docs/superpowers/specs/2026-04-29-showcase-design.md @@ -0,0 +1,316 @@ +# 作品广场功能设计 + +**日期**: 2026-04-29 +**状态**: 待审阅 + +## 背景 + +WebPreview 目前已有挑战、提交、评分、分区、人气统计和 AI 对话生成代码能力。现有机制偏向提交管理和评分结果展示,还缺少一个面向学生的优秀作品展示入口。作品广场用于把老师选出的优秀提交集中展示,让学生通过最终效果和创作提示词链学习同伴作品。 + +## 目标 + +- 学生登录后可以进入全站统一的作品广场,浏览获奖作品效果。 +- 老师可以在后台维护奖项,并把提交作品授予一个或多个奖项。 +- 每个奖项可以配置自己的作品排序方式。 +- 作品广场只展示运行效果,不开放代码查看。 +- 如果获奖作品有关联 AI 提示词链,所有登录学生都可以查看创作过程。 + +## 不在本期范围 + +- 学生提名、投票、评论、收藏。 +- 对外公开展示。 +- 作品代码公开。 +- 复杂拖拽排序界面。初版用后台排序字段实现手动排序。 +- 为奖项做单独的前端管理页。初版通过 Django 后台维护奖项和授奖关系。 + +## 核心规则 + +### 访问权限 + +- `/showcase` 需要登录。 +- 普通学生只能浏览作品广场。 +- 管理员和超级管理员可以通过 Django 后台维护奖项和授奖关系。 + +### 奖项 + +奖项由后台配置,不写死在前端。每个奖项包含: + +- 名称,例如“最佳游戏”“最佳设计”。 +- 简介,用于解释该专区的评选标准。 +- 排序值,用于控制奖项专区在作品广场中的顺序。 +- 是否启用。停用后不在前台展示。 +- 作品排序方式。 + +作品排序方式初版支持: + +- `manual`: 手动排序,按授奖关系上的排序值升序。 +- `awarded_at`: 按授奖时间倒序。 +- `score`: 按提交评分倒序。 +- `view_count`: 按浏览量倒序。 + +默认排序方式为 `manual`。 + +### 授奖 + +- 一个提交作品可以获得多个奖项。 +- 一个奖项可以包含多个提交作品。 +- 同一个提交和同一个奖项只能建立一条授奖关系。 +- 授奖关系包含手动排序值和授奖时间。 + +### 作品展示 + +作品广场按奖项分区展示,全站统一混合所有挑战作品,不按单个挑战拆分。 + +每个作品卡片展示: + +- 作品运行效果。 +- 作者用户名。 +- 挑战标题。 +- 奖项标签。 +- 评分。 +- 浏览量。 + +点击作品进入详情页,只展示运行效果和作品信息,不提供查看代码入口。 + +### 提示词链展示 + +如果提交作品关联到 `prompt.Message.submission`,作品详情展示“创作过程”区域。 + +展示内容: + +- 每一轮学生提示词。 +- 提示词来源:对话或手动。 +- L1-L6 提示词层级,如果已分类。 +- 每一轮 assistant 消息提取出的网页效果。 + +不展示内容: + +- 系统提示词。 +- 模型配置。 +- API 信息。 +- 代码文本。 + +创作过程默认折叠,学生点击后展开。 + +## 数据模型 + +### Award + +建议放在 `submission/models.py`,因为奖项直接服务于提交作品展示。 + +字段: + +- `name`: 奖项名称,唯一。 +- `description`: 奖项简介,可为空。 +- `sort_order`: 奖项排序值,默认 0。 +- `is_active`: 是否启用,默认 true。 +- `item_ordering`: 作品排序方式,默认 `manual`。 +- `created` / `modified`: 复用 `TimeStampedModel`。 + +### SubmissionAward + +授奖关系表。 + +字段: + +- `submission`: 外键到 `Submission`。 +- `award`: 外键到 `Award`。 +- `sort_order`: 手动排序值,默认 0。 +- `awarded_at`: 授奖时间。 +- `created` / `modified`: 复用 `TimeStampedModel`。 + +约束: + +- `(submission, award)` 唯一。 + +查询优化: + +- `award`, `submission`, `sort_order`, `awarded_at` 建索引或通过外键索引满足主要查询。 +- API 查询时 `select_related("submission", "submission__user", "submission__task", "award")`。 + +## 后端接口 + +### 作品广场列表 + +``` +GET /api/submission/showcase/ +``` + +登录后可访问。 + +返回启用奖项及其作品列表。无获奖作品的奖项默认不返回,避免前台出现空专区。 + +响应结构建议: + +```json +[ + { + "id": 1, + "name": "最佳设计", + "description": "视觉完成度高、布局清晰的作品", + "item_ordering": "manual", + "items": [ + { + "submission_id": "uuid", + "username": "student01", + "task_title": "挑战标题", + "task_display": 3, + "score": 4.6, + "view_count": 28, + "html": "...", + "css": "...", + "js": "...", + "has_prompt_chain": true + } + ] + } +] +``` + +### 作品详情 + +``` +GET /api/submission/showcase/{submission_id}/ +``` + +登录后可访问。仅允许访问已授奖的提交作品。 + +返回: + +- 提交作品运行所需的 `html/css/js`。 +- 作者、挑战、评分、浏览量。 +- 当前作品获得的奖项列表。 +- 是否存在提示词链。 + +### 作品提示词链 + +``` +GET /api/submission/showcase/{submission_id}/prompt-chain/ +``` + +登录后可访问。仅允许访问已授奖且有关联提示词链的提交作品。 + +后端通过 `Submission.source_message` 找到关联 assistant 消息,再回到同一 `Conversation`,按时间顺序构造用户提示词和 assistant 生成效果的轮次。 + +返回结构可复用 `ChainModal.vue` 当前使用的数据概念,但需要针对作品广场隐藏代码文本,只返回提示词内容、层级、来源和可预览的生成结果。 + +如果提交没有关联提示词链,作品详情接口返回 `has_prompt_chain: false`,该接口返回 404。 + +## 后台管理 + +在 Django admin 注册: + +- `Award` +- `SubmissionAward` + +`SubmissionAward` 后台字段: + +- submission +- award +- sort_order +- awarded_at + +列表页显示: + +- 奖项名称 +- 提交作者 +- 挑战标题 +- 评分 +- 浏览量 +- 手动排序值 +- 授奖时间 + +筛选: + +- award +- submission.task +- submission.user.classname + +搜索: + +- award.name +- submission.user.username +- submission.task.title + +## 前端页面 + +### 路由 + +新增: + +``` +/showcase +``` + +路由 `meta.auth = true`。 + +### 入口 + +在主页/任务面板中新增“作品广场”入口,学生登录后可见。 + +### 作品广场页面 + +布局: + +- 顶部标题和简短说明。 +- 奖项专区纵向排列。 +- 每个专区展示奖项名称、简介和作品网格。 +- 每个作品卡片内嵌预览效果,显示作者、挑战、分数、浏览量。 + +空状态: + +- 如果没有任何获奖作品,显示“暂无展示作品”。 + +### 作品详情页 + +新增或复用现有提交详情页,但需要隐藏代码查看能力。 + +内容: + +- 作品运行效果。 +- 作者、挑战、奖项、评分、浏览量。 +- “创作过程”折叠区域。 + +创作过程: + +- 有提示词链才显示入口。 +- 默认折叠。 +- 展开后显示提示词轮次和每轮生成效果。 + +## 安全与隐私 + +- 作品广场需要登录。 +- 仅展示被授奖的提交。 +- 不展示代码文本。 +- 提示词链公开范围限定为登录用户。 +- 提示词链只取用户消息和 assistant 生成效果,不展示系统提示词或接口配置。 + +## 测试计划 + +后端: + +- 普通未登录用户访问作品广场接口返回 401/403。 +- 登录用户可以查看已授奖作品。 +- 未授奖提交不能通过详情接口访问。 +- 一个提交可以获得多个奖项。 +- 同一提交同一奖项不能重复授奖。 +- 奖项停用后不在广场接口返回。 +- 不同排序方式返回顺序正确。 +- 有 `source_message` 的提交可以返回提示词链。 +- 无 `source_message` 的提交在详情中返回 `has_prompt_chain: false`,请求提示词链接口返回 404。 + +前端: + +- 未登录访问 `/showcase` 触发登录逻辑。 +- 登录后能看到作品广场入口。 +- 作品卡片只展示效果和元信息,不出现查看代码入口。 +- 详情页能展示作品效果。 +- 有提示词链时显示创作过程入口,无提示词链时隐藏。 + +## 后续扩展 + +- 学生点赞、收藏、评论。 +- 奖项专题封面图。 +- 老师在提交列表中直接授奖。 +- 作品广场按班级、挑战、作者筛选。 +- “本周精选”或“最近上榜”专区。 diff --git a/docs/superpowers/specs/2026-05-01-gradebook-design.md b/docs/superpowers/specs/2026-05-01-gradebook-design.md new file mode 100644 index 0000000..4d6f5bc --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-gradebook-design.md @@ -0,0 +1,262 @@ +# 平时成绩册设计 + +**日期**: 2026-05-01 +**状态**: 待用户复核 + +## 背景 + +期末需要在管理后台查看单个班级学生的平时成绩。现有系统已有学生班级字段、教程/挑战任务、提交记录、提交评分和单题统计弹窗,但缺少跨教程和挑战的班级成绩册。 + +本功能在管理后台新增“平时成绩”页面,面向管理员按班级查看、排序、分级和导出学生平时成绩。 + +## 目标 + +- 管理员可以选择一个班级查看该班平时成绩。 +- 教程和挑战都计入成绩,不只统计挑战。 +- 同一学生同一任务按最高分提交计算。 +- 任务是否计入成绩由该班提交覆盖率决定,不依赖 `Task.is_public`。 +- 页面显示排名、等级、每个任务得分、教程合计、挑战合计、总分、平均分、已提交任务数和未提交任务数。 +- 支持导出当前班级成绩 CSV。 + +## 不在本期范围 + +- 不提供“全部班级”视图。 +- 不做教程和挑战权重配置。 +- 不做人工调整等级。 +- 不新增持久化成绩快照,成绩册按当前提交和评分实时计算。 +- 不修改现有 `Submission.score` 或 `Rating` 计算逻辑。 + +## 成绩计算规则 + +### 学生范围 + +成绩册必须选择一个班级。学生范围为: + +- `User.role = normal` +- `User.classname = classname` + +如果未传班级,接口返回 `400`,提示“请选择班级”。 + +### 任务范围 + +教程和挑战都可以纳入成绩。页面提供任务类型筛选: + +- 全部 +- 教程 +- 挑战 + +任务纳入成绩的默认规则: + +- 只看当前班级学生。 +- 某任务只要该班至少 50% 学生有过提交,就计入成绩。 +- 覆盖率门槛人数为 `ceil(班级学生数 * 0.5)`。 +- 不使用 `Task.is_public` 判断是否计入,因为它只表示公开状态。 + +页面提供“显示所有有提交任务”的开关: + +- 默认关闭,只显示计入成绩的任务。 +- 开启后显示当前班级有提交记录的所有任务,用于检查任务是否未达到覆盖率门槛。 +- 开启后每个任务仍返回 `included` 标记;汇总字段只计算 `included = true` 的任务。 + +### 单元格分数 + +每个学生每个任务的单元格分数为: + +- 该学生在该任务所有提交中最高的 `Submission.score`。 +- 如果没有提交,显示缺交,汇总时按 `0` 计入。 + +如果多个提交同分,提交详情链接指向最近创建的一条最高分提交。 + +### 汇总字段 + +每个学生行包含: + +- `tutorial_total`: 教程最高分合计。 +- `challenge_total`: 挑战最高分合计。 +- `total_score`: 教程合计 + 挑战合计。 +- `average_score`: `total_score / included_task_count`,没有计入任务时为 `null`。 +- `submitted_task_count`: 有提交的计入任务数。 +- `missing_task_count`: 没有提交的计入任务数。 + +## 等级规则 + +等级只在当前班级内计算。按 `total_score` 降序排名,同分按 `username` 升序稳定排序。 + +设班级人数为 `N`,排名从 1 开始: + +- `A`: `rank <= ceil(0.30 * N)` +- `B`: `rank <= ceil(0.70 * N)` +- `C`: `rank <= ceil(0.90 * N)` +- `D`: `rank <= ceil(0.95 * N)` +- `E`: 其余 + +返回字段包含: + +- `rank` +- `grade` + +## 后端设计 + +### API + +新增管理员接口: + +```http +GET /api/submission/gradebook/ +``` + +参数: + +- `classname`: 必填,班级名。 +- `task_type`: 可选,`tutorial` / `challenge`。 +- `username`: 可选,按用户名片段搜索学生。 +- `include_all_tasks`: 可选布尔值,默认 `false`。 + +权限: + +- 仅 `admin` 和 `super` 可以访问。 + +### 返回结构 + +```ts +interface GradebookTask { + id: number + display: number + title: string + task_type: "tutorial" | "challenge" + submitted_count: number + coverage: number + included: boolean +} + +interface GradebookCell { + score: number + submitted: boolean + submission_id: string | null +} + +interface GradebookRow { + user_id: number + username: string + classname: string + rank: number + grade: "A" | "B" | "C" | "D" | "E" + scores: Record + tutorial_total: number + challenge_total: number + total_score: number + average_score: number | null + submitted_task_count: number + missing_task_count: number +} + +interface GradebookOut { + classname: string + classes: string[] + task_count: number + included_task_count: number + student_count: number + coverage_threshold_count: number + tasks: GradebookTask[] + rows: GradebookRow[] +} +``` + +### 查询策略 + +1. 查询班级列表,用于前端选择器。 +2. 查询当前班级普通学生,得到 `student_ids` 和 `student_count`。 +3. 查询这些学生所有提交涉及的任务,按 `task_type` 过滤。 +4. 对每个任务统计有提交的学生数,判断是否达到 50% 覆盖率。 +5. 根据 `include_all_tasks` 决定返回任务集合。 +6. 查询 `(user_id, task_id)` 的最高分提交。 +7. 组装每个学生的任务分数和汇总字段。 +8. 按 `total_score desc, username asc` 排名并分配等级。 + +## 前端设计 + +### 路由和入口 + +在管理后台 `/dashboard` 下新增页面: + +```text +/dashboard/gradebook +``` + +侧边栏或管理入口新增“平时成绩”。 + +### 页面布局 + +顶部筛选区: + +- 班级选择,默认选择第一个班级,不提供“全部班级”。 +- 任务类型:全部 / 教程 / 挑战。 +- 学生搜索。 +- “显示所有有提交任务”开关,默认关闭。 +- 导出 CSV 按钮。 + +主体表格: + +- 左侧固定列:排名、等级、学生、班级。 +- 中间动态列:每个任务一列,显示最高分。 +- 右侧固定汇总列:教程合计、挑战合计、总分、平均分、已提交、缺交。 + +显示规则: + +- 缺交单元格在页面显示 `缺交`,使用轻微警示样式;CSV 中按 `0` 导出。 +- 未计入成绩但被显示的任务列,用较弱样式并在表头标注未计入。 +- 点击有提交的分数,打开该最高分提交详情页。 + +## 导出设计 + +新增导出接口: + +```http +GET /api/submission/gradebook/export/ +``` + +参数与成绩册接口一致。 + +CSV 列: + +- 排名 +- 等级 +- 用户名 +- 班级 +- 每个任务得分 +- 教程合计 +- 挑战合计 +- 总分 +- 平均分 +- 已提交任务数 +- 未提交任务数 + +导出内容与当前页面筛选保持一致。 + +## 错误处理 + +- 未登录:沿用现有登录拦截。 +- 非管理员访问:返回 `403`。 +- 未选择班级:返回 `400`。 +- 班级不存在或没有学生:返回空表格,并保留班级选择列表。 +- 当前班级没有达到覆盖率门槛的任务:表格仍显示学生和汇总列,任务列为空,汇总为 0 或 `null`。 + +## 测试计划 + +后端测试: + +- 任务覆盖率达到 50% 时被计入,低于 50% 时不计入。 +- 教程和挑战都能参与计算。 +- 同一学生同一任务取最高分。 +- 缺交任务按 0 计入,并增加缺交数。 +- A/B/C/D/E 按累计 `ceil` 边界分配。 +- `classname` 缺失返回 `400`。 +- 普通用户访问返回 `403`。 + +前端检查: + +- 页面默认选择第一个班级并加载成绩。 +- 任务类型筛选正确刷新表格。 +- “显示所有有提交任务”能显示未计入任务。 +- 点击分数能打开对应提交详情。 +- CSV 导出与当前筛选一致。 diff --git a/docs/superpowers/specs/2026-05-01-showcase-management-design.md b/docs/superpowers/specs/2026-05-01-showcase-management-design.md new file mode 100644 index 0000000..ed7cf3f --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-showcase-management-design.md @@ -0,0 +1,229 @@ +# 作品广场管理页设计 + +## 背景 + +作品广场当前已有学生浏览页 `/showcase` 和详情页 `/showcase/:id`。奖项与获奖作品的数据模型已经存在: + +- `Award`:奖项名称、简介、启用状态、奖项排序、作品排序方式。 +- `SubmissionAward`:提交作品与奖项的关联、手动排序值、授奖时间。 + +目前日常管理只能通过 Django Admin 完成,不适合老师在前端工作流中维护作品广场。 + +## 目标 + +在现有 Dashboard 中新增“作品广场”管理页,让管理员可以完成作品广场的日常维护: + +- 创建、编辑、启停、删除奖项。 +- 配置奖项在广场中的顺序。 +- 配置奖项内作品排序方式。 +- 通过复制来的 `submission_id` 查找提交作品,并把作品加入指定奖项。 +- 查看、移除、调整奖项内作品。 + +## 非目标 + +- 不做公开代码查看。学生侧仍只展示运行效果和创作过程。 +- 不做拖拽排序,第一版使用数字排序值。 +- 不做批量导入或批量授奖。 +- 不改变现有学生广场接口返回结构,避免影响 `/showcase`。 + +## 权限 + +管理页只对 `admin` 和 `super` 可见。 + +后端管理 API 使用 `admin_required` 限制。普通学生访问管理 API 返回 403,未登录用户返回登录态错误。 + +学生浏览页保持现状:登录用户可访问 `/showcase` 和 `/showcase/:id`。 + +## 前端入口 + +在 `Dashboard.vue` 侧栏新增菜单: + +- 标签:`作品广场` +- 路由:`/dashboard/showcase` +- 可见角色:`admin`、`super` + +新增页面文件: + +- `client/src/pages/ShowcaseManage.vue` + +## 页面结构 + +页面采用左右分栏,延续现有 Dashboard 的工具型界面风格。 + +左侧为奖项列表: + +- 新建奖项按钮。 +- 奖项名称。 +- 启用状态。 +- 展示排序值。 +- 作品排序方式。 +- 当前作品数量。 + +右侧为选中奖项详情: + +- 奖项编辑表单:名称、简介、排序值、是否启用、作品排序方式。 +- 保存按钮。 +- 删除奖项按钮。 +- 已授奖作品表格:作者、任务、分数、浏览量、手动排序值、授奖时间。 +- 每行支持修改手动排序值和移除作品。 +- 添加作品按钮:打开添加作品弹窗,粘贴 `submission_id` 后查找对应提交,确认无误后加入当前奖项。 + +当没有选中奖项时,右侧显示空状态。创建第一个奖项后自动选中。 + +## 后端 API + +新增管理接口,放在 `submission/api.py`,路径位于现有 `/submission/showcase/` 下。 + +### 奖项管理 + +`GET /api/submission/showcase/manage/awards` + +返回所有奖项,包括未启用和空奖项。每个奖项包含: + +- `id` +- `name` +- `description` +- `sort_order` +- `is_active` +- `item_ordering` +- `item_count` + +`POST /api/submission/showcase/manage/awards` + +创建奖项。请求字段: + +- `name` +- `description` +- `sort_order` +- `is_active` +- `item_ordering` + +`PUT /api/submission/showcase/manage/awards/{award_id}` + +更新奖项。字段同创建接口。 + +`DELETE /api/submission/showcase/manage/awards/{award_id}` + +删除奖项。级联删除该奖项下的 `SubmissionAward` 关联,不删除提交作品本身。 + +### 作品查找 + +`GET /api/submission/showcase/manage/submissions/{submission_id}` + +按提交 ID 查找一条提交,用于添加作品前确认。该接口只返回管理页需要的摘要,不返回 HTML/CSS/JS 源码。返回字段: + +- `submission_id` +- `username` +- `task_title` +- `task_display` +- `score` +- `view_count` +- `has_prompt_chain` + +提交不存在时返回 404。普通用户不能访问该接口。 + +### 奖项作品管理 + +`GET /api/submission/showcase/manage/awards/{award_id}/items` + +返回该奖项下所有获奖作品。每项包含: + +- `id`:`SubmissionAward.id` +- `submission_id` +- `username` +- `task_title` +- `task_display` +- `score` +- `view_count` +- `sort_order` +- `awarded_at` +- `has_prompt_chain` + +排序规则沿用奖项的 `item_ordering`: + +- `manual`:按 `SubmissionAward.sort_order`。 +- `awarded_at`:按授奖时间倒序。 +- `score`:按提交评分倒序。 +- `view_count`:按浏览量倒序。 + +`POST /api/submission/showcase/manage/awards/{award_id}/items` + +把提交作品加入奖项。请求字段: + +- `submission_id` +- `sort_order` + +如果同一作品已在该奖项中,返回现有关联,不重复创建。 + +`PUT /api/submission/showcase/manage/items/{item_id}` + +更新获奖作品关联,目前只更新 `sort_order`。 + +`DELETE /api/submission/showcase/manage/items/{item_id}` + +移除获奖作品关联,不删除提交作品。 + +## 前端 API 客户端 + +在 `client/src/api.ts` 中扩展 `Showcase`: + +- `listManageAwards()` +- `createAward(payload)` +- `updateAward(id, payload)` +- `deleteAward(id)` +- `listAwardItems(id)` +- `findSubmissionForAward(submissionId)` +- `addAwardItem(id, payload)` +- `updateAwardItem(itemId, payload)` +- `deleteAwardItem(itemId)` + +在 `client/src/utils/type.ts` 中新增管理页使用的类型: + +- `AwardManageOut` +- `AwardManageIn` +- `AwardItemManageOut` +- `AwardItemIn` +- `AwardItemUpdateIn` + +## 数据流 + +1. 进入 `/dashboard/showcase` 后加载奖项列表。 +2. 自动选中第一个奖项,并加载该奖项作品。 +3. 保存奖项后刷新奖项列表,并保留当前选中项。 +4. 点击“添加作品”后打开弹窗,粘贴 `submission_id` 并查找提交。 +5. 找到提交后展示提交者、任务、分数、浏览量和提示词状态。 +6. 确认添加后刷新当前奖项作品,同时刷新左侧作品数量。 +7. 修改作品手动排序值后刷新当前奖项作品。 + +## 错误处理 + +- 奖项名称重复时后端返回 400,前端显示错误消息。 +- 奖项、作品关联或待添加提交不存在时返回 404,前端显示错误消息。 +- 普通用户访问管理 API 返回 403。 +- 网络或未知错误沿用现有 Axios 拦截器,同时管理页局部停止 loading 状态。 + +## 测试 + +后端测试覆盖: + +- 普通用户不能访问作品广场管理接口。 +- 管理员可以创建、编辑、启停奖项。 +- 管理员可以通过 `submission_id` 查找提交作品摘要。 +- 管理员可以给提交作品授奖。 +- 重复授奖不会创建重复关联。 +- 移除授奖后,学生广场列表不再返回该作品。 +- 奖项停用后,学生广场列表不返回该奖项。 + +前端验证覆盖: + +- Dashboard 中管理员能看到“作品广场”菜单,普通学生看不到。 +- 奖项列表、奖项编辑、作品列表能正常加载。 +- 粘贴 `submission_id` 查找作品并加入奖项后,已授奖作品列表更新。 +- 删除或停用奖项后,学生 `/showcase` 表现符合预期。 + +## 后续增强 + +- 拖拽排序。 +- 批量授奖。 +- 按班级、挑战、作者筛选广场。 +- 在提交列表中提供“加入作品广场”快捷入口。 diff --git a/docs/superpowers/specs/2026-05-07-prompt-guidance-design.md b/docs/superpowers/specs/2026-05-07-prompt-guidance-design.md new file mode 100644 index 0000000..b04f2fa --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-prompt-guidance-design.md @@ -0,0 +1,129 @@ +# 提示词引导功能设计文档 + +**日期:** 2026-05-07 +**状态:** 已审批 + +## 功能概述 + +在现有 AI 对话(PromptPanel)旁新增"调教提示词"入口,学生可选择性地进入多轮引导流程,由 AI 以苏格拉底式提问帮助其优化提示词,满意后再生成代码。原有"发送"行为完全不变。 + +## 用户流程 + +``` +学生在 PromptPanel 输入框写提示词 + ↓ +点"调教提示词"按钮(灯泡图标) + ↓ +GuidancePanel 弹出,携带初始提示词 + ↓ +AI 分析提示词质量 + ├─ 不够好 → 返回 1-2 个启发性问题,学生修改后继续发 + └─ 够好 → 响应首部加 [READY],"生成代码"按钮高亮 + ↓ +学生点"生成代码" + ↓ +GuidancePanel 关闭,最终提示词直接通过 sendPrompt() 发送 +PromptPanel 走原有 ws/prompt// 生成代码(流程不变) +``` + +**备注:** 学生对自己的提示词有信心时,可直接点"发送",完全跳过引导流程。 + +## 后端设计 + +### llm.py + +新增 `GUIDANCE_SYSTEM_PROMPT`: + +```python +GUIDANCE_SYSTEM_PROMPT = """你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。 + +判断标准——满足以下条件视为"够好": +- 有明确主题(例如:登录页、计时器、商品卡片列表) +- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标) + +判断为"不够好"的典型情况: +- 目标太泛,如"做一个好看的页面" +- 只有主题,完全没有任何视觉、交互或内容描述 + +规则: +1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案 +2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了 +3. 用中文回复,语气鼓励,简洁明了 +4. 不要生成任何代码""" +``` + +新增 `stream_guidance(history)` 函数(使用 `GUIDANCE_SYSTEM_PROMPT`,默认 DeepSeek 模型)。 + +### consumers.py + +新增 `GuidanceConsumer(AsyncWebsocketConsumer)`: + +- 复用 `PromptConsumer` 的连接/断开/历史加载逻辑 +- `save_message` 时 `source="guidance"` +- streaming 完成后检测 `full_response` 是否以 `[READY]` 开头: + - 是:剥离 `[READY]\n`,保存清理后内容,`complete` 事件加 `"is_ready": true` + - 否:正常保存,`complete` 事件 `"is_ready": false` + +### url.py / asgi.py + +新增 WebSocket 路由: + +```python +re_path(r"ws/guidance/(?P\d+)/$", GuidanceConsumer.as_asgi()) +``` + +### 数据库 + +无新 migration。引导消息使用现有 `Message` 模型,`source="guidance"`(现有字段,现有值为 `"conversation"` / `"manual"`)。同一个 `Conversation` 下,通过 `source` 区分引导轮次与代码生成轮次。 + +## 前端设计 + +### 新增:src/store/guidance.ts + +状态: +- `messages` — 引导对话消息列表 +- `streaming` — 是否正在流式输出 +- `streamingContent` — 当前流式内容 +- `connected` — WebSocket 是否已连接 +- `isReady` — AI 是否判断提示词已足够好 +- `isOpen` — 面板是否展开 + +方法: +- `openGuidance(taskId, initialPrompt)` — 建立 WebSocket,发送初始提示词 +- `sendGuidance(text)` — 发送后续消息 +- `stopGuidance()` — 中止当前流 +- `closeGuidance()` — 关闭面板,断开 WebSocket + +### 新增:src/components/ai/GuidancePanel.vue + +UI 形式:`` 或右侧抽屉。 + +结构: +- **对话区**:显示引导轮次消息 + - 引导消息样式:蓝色左边框 + 灯泡图标,与普通代码生成消息视觉区分 + - streaming 指示器(与 PromptPanel 一致) +- **输入区**:学生修改提示词并继续对话,Enter 发送 +- **底部按钮**:"生成代码" + - 默认:可点击但不高亮 + - `isReady === true`:高亮(primary 样式)+ 提示文字"提示词已优化,可以生成了!" + +事件: +- `@generate(finalPrompt: string)` — 学生点"生成代码"时 emit,携带输入框当前内容作为最终提示词 + +### 改动:src/components/ai/PromptPanel.vue + +- 输入框旁加"调教提示词"按钮(`lucide:lightbulb` 图标,secondary 样式) +- 点击:调用 `openGuidance(taskId, input.value)` +- 监听 `GuidancePanel` 的 `@generate` 事件: + - 收到最终提示词 → 填入 `input.value` → 调用 `sendPrompt()` 直接发送 + +## 错误处理 + +- GuidancePanel WebSocket 断开:显示重连提示,与 PromptPanel 保持一致 +- AI 服务出错:`error` 事件在引导对话中显示,不影响主对话 + +## 不在范围内 + +- 引导功能的任务级开关(全局开启) +- 引导质量的统计/分析面板(可后续迭代) +- 引导轮次上限(由 AI 自行判断,不做硬性限制) diff --git a/docs/superpowers/specs/2026-06-11-random-rating-popup-design.md b/docs/superpowers/specs/2026-06-11-random-rating-popup-design.md new file mode 100644 index 0000000..3cef5fb --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-random-rating-popup-design.md @@ -0,0 +1,156 @@ +# 等待 AI 回复时随机作品打分弹窗 设计文档 + +**日期:** 2026-06-11 + +## 背景与目标 + +当前评分系统数据量偏少,学生积极性不高;同时在 ChallengeDetail 页面等待 AI 流式回复期间,学生处于"无事可做"的等待状态。希望利用这段等待时间,弹出一个随机的其他同学的提交,让学生打分,从而: + +- 增加评分数据量,让评分系统更有效(n 越大,`update_score` 的统计意义越强) +- 提升等待期间的参与感 + +仅在 `ChallengeDetail.vue`(含 AI 对话面板的页面)生效。 + +## 触发逻辑 + +监听 `store/prompt.ts` 的 `streaming` 状态: + +``` +streaming: false → true + → clearTimeout(reshowTimer) + → fetchAndShow() // 请求随机作品,有结果则弹窗 + +streaming: true → false + → clearTimeout(reshowTimer) + → show = false // 立即关闭弹窗 +``` + +弹窗内交互: + +- **打分**(点击星级)→ 调用现有 `PUT /submission/{id}/score` → 提示"感谢评分!" → 立即 `fetchAndShow()` 换下一个作品,弹窗保持打开 +- **关闭**(右上角 ✕)→ `show = false`;若此时 `streaming === true`,30 秒后 `fetchAndShow()` 重新弹出一个新作品 +- **无可评作品**(接口返回空)→ 不弹窗 / 不重弹,等下一次 `streaming: false → true` 再尝试 + +组件卸载(离开页面)时清理 `reshowTimer` 并关闭弹窗。 + +无"跳过"按钮——星级评分是唯一主操作,关闭仅是临时隐藏(30 秒后会带新作品再弹出)。 + +## 后端 API 设计 + +新增端点:`GET /api/submission/random-for-rating/`(`login_required`) + +**查询参数**: +- `exclude_id: Optional[UUID]` — 排除指定提交(避免连续两次抽到同一作品) + +**筛选逻辑**(按顺序短路): + +1. 普通用户(`role == RoleChoices.NORMAL`)若今日 `Rating` 记录数已达 30(与 `Rating.clean()` 的上限一致)→ 直接返回 `null`,避免弹窗后打分必定失败 +2. 候选集:`Submission.objects.exclude(user=request.user).exclude(ratings__user=request.user)` + - 若提供 `exclude_id`,再 `.exclude(pk=exclude_id)` +3. 优先从候选集中 `zone=ZoneChoices.PENDING`(评分人数 < 5)的子集里 `order_by("?")` 取一条 +4. 若 pending 子集为空,则从候选集整体 `order_by("?")` 取一条 +5. 若候选集本身为空 → 返回空 + +不区分 `task_type`(tutorial / challenge 均可能出现),不排除"当前正在做的这个挑战"的其他提交。 + +**响应 Schema**: + +```python +class RandomRatingOut(Schema): + submission_id: UUID + username: str + task_title: str + task_display: int + task_type: Literal["tutorial", "challenge"] + html: Optional[str] = None + css: Optional[str] = None + js: Optional[str] = None +``` + +响应类型为 `Optional[RandomRatingOut]`;无候选时返回 `null`(HTTP 200),前端据此判断不弹窗。 + +打分仍复用现有 `PUT /api/submission/{submission_id}/score`,不新增打分接口。 + +## 前端设计 + +新建组件 `client/src/components/ai/RandomRatingModal.vue`,挂载在 `ChallengeDetail.vue` 模板内(与现有 `` 并列)。 + +**内部状态**: +- `show: boolean` +- `current: RandomRatingItem | null` +- `reshowTimer: number | null` + +**核心方法**: + +```ts +async function fetchAndShow() { + const res = await Submission.randomForRating(current.value?.submission_id) + if (res) { + current.value = res + show.value = true + } else { + show.value = false + } +} + +async function onRate(score: number) { + await Submission.updateScore(current.value!.submission_id, score) + message.success("感谢评分!") + await fetchAndShow() +} + +function onClose() { + show.value = false + if (streaming.value) { + reshowTimer = window.setTimeout(fetchAndShow, 30_000) + } +} + +watch(streaming, (val) => { + if (reshowTimer) clearTimeout(reshowTimer) + if (val) fetchAndShow() + else show.value = false +}) + +onUnmounted(() => { + if (reshowTimer) clearTimeout(reshowTimer) +}) +``` + +**弹窗内容**(`n-modal` `preset="card"`,宽度约 480px): + +- 标题:「等待 AI 回复时,给同学的作品打个分吧」 +- 副标题:`{task_title} · 提交者 {username}` +- 中间:`