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

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

816 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Prompt Guidance Feature 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:** Add a "调教提示词" lightbulb button to PromptPanel that opens a modal where the AI coaches students to improve their prompt before generating code.
**Architecture:** New `GuidanceConsumer` WebSocket at `ws/guidance/<task_id>/` handles coaching conversations, saving messages with `source="guidance"` to the existing DB. Frontend adds `store/guidance.ts` (mirrors `store/prompt.ts` pattern) and `GuidancePanel.vue` (Naive UI modal). `PromptPanel.vue` gets a lightbulb button and wires the generate event.
**Tech Stack:** Django Channels (AsyncWebsocketConsumer), OpenAI-compatible streaming API, Vue 3, Naive UI, TypeScript
---
## File Map
**Create:**
- `client/src/store/guidance.ts` — WebSocket state for guidance sessions
- `client/src/components/ai/GuidancePanel.vue` — Guidance coaching modal
**Modify:**
- `api/prompt/llm.py` — Add `GUIDANCE_SYSTEM_PROMPT`, `parse_guidance_response()`, `stream_guidance()`
- `api/prompt/tests.py` — Add unit tests for `parse_guidance_response`
- `api/prompt/consumers.py` — Add `GuidanceConsumer`, update import
- `api/prompt/url.py` — Register `ws/guidance/<task_id>/`
- `client/src/store/prompt.ts` — Export `currentTaskId` ref
- `client/src/components/ai/PromptPanel.vue` — Add lightbulb button + GuidancePanel
---
### Task 1: Backend — Guidance LLM helpers
**Files:**
- Modify: `api/prompt/llm.py`
- Test: `api/prompt/tests.py`
- [ ] **Step 1: Write failing tests for `parse_guidance_response`**
Add at the bottom of `api/prompt/tests.py`:
```python
from prompt.llm import parse_guidance_response
class ParseGuidanceResponseTest(TestCase):
def test_ready_prefix_with_newline_stripped(self):
content, is_ready = parse_guidance_response("[READY]\n很好,可以生成了!")
self.assertEqual(content, "很好,可以生成了!")
self.assertTrue(is_ready)
def test_ready_prefix_without_newline_stripped(self):
content, is_ready = parse_guidance_response("[READY]很好,可以生成了!")
self.assertEqual(content, "很好,可以生成了!")
self.assertTrue(is_ready)
def test_no_ready_prefix_unchanged(self):
content, is_ready = parse_guidance_response("你的提示词不够具体,请问...")
self.assertEqual(content, "你的提示词不够具体,请问...")
self.assertFalse(is_ready)
def test_empty_string(self):
content, is_ready = parse_guidance_response("")
self.assertEqual(content, "")
self.assertFalse(is_ready)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd api && uv run python manage.py test prompt.tests.ParseGuidanceResponseTest -v 2
```
Expected: `ImportError: cannot import name 'parse_guidance_response' from 'prompt.llm'`
- [ ] **Step 3: Add guidance helpers to `api/prompt/llm.py`**
Add `GUIDANCE_SYSTEM_PROMPT` after the existing `SYSTEM_PROMPT` block (before `DEFAULT_MODEL = "deepseek-v4-flash"`):
```python
GUIDANCE_SYSTEM_PROMPT = """你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 不要生成任何代码"""
```
Add `parse_guidance_response` and `stream_guidance` after the existing `extract_code` function:
```python
def parse_guidance_response(full_response: str) -> tuple[str, bool]:
if full_response.startswith("[READY]"):
return full_response[len("[READY]"):].lstrip("\n"), True
return full_response, False
async def stream_guidance(history: list[dict]):
"""Stream guidance coaching response. Yields content chunks."""
messages = [{"role": "system", "content": GUIDANCE_SYSTEM_PROMPT}]
messages.extend(history)
client, model = _get_client("")
async with client as c:
stream = await c.chat.completions.create(
model=model,
messages=messages,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd api && uv run python manage.py test prompt.tests.ParseGuidanceResponseTest -v 2
```
Expected: `Ran 4 tests in ...s OK`
- [ ] **Step 5: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add api/prompt/llm.py api/prompt/tests.py
git -C /home/xuyue/Projects/webpreview commit -m "feat: add guidance LLM helpers and system prompt"
```
---
### Task 2: Backend — GuidanceConsumer
**Files:**
- Modify: `api/prompt/consumers.py`
- [ ] **Step 1: Update import at top of `consumers.py`**
Change:
```python
from .llm import stream_chat, extract_code
```
To:
```python
from .llm import stream_chat, extract_code, stream_guidance, parse_guidance_response
```
- [ ] **Step 2: Add `GuidanceConsumer` at the bottom of `consumers.py`**
```python
class GuidanceConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope["user"]
if self.user.is_anonymous:
await self.close()
return
self.task_id = int(self.scope["url_route"]["kwargs"]["task_id"])
self.current_user_message = None
self.session_messages = [] # in-memory LLM context, resets each WS connection
await self.accept()
self.conversation = await self.get_or_create_conversation()
await self.send(text_data=json.dumps({"type": "init", "messages": []}))
async def disconnect(self, close_code):
if self.current_user_message:
await self.delete_message(self.current_user_message)
self.current_user_message = None
async def receive(self, text_data):
data = json.loads(text_data)
prompt = data.get("content", "").strip()
if not prompt:
return
self.current_user_message = await self.save_message("user", prompt)
self.session_messages.append({"role": "user", "content": prompt})
try:
full_response = ""
try:
async for chunk in stream_guidance(self.session_messages):
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
clean_response, is_ready = parse_guidance_response(full_response)
self.session_messages.append({"role": "assistant", "content": clean_response})
assistant_msg = await self.save_message("assistant", clean_response)
self.current_user_message = None
await self.send(text_data=json.dumps({
"type": "complete",
"message_id": assistant_msg.id,
"is_ready": is_ready,
}))
finally:
if self.current_user_message:
await self.delete_message(self.current_user_message)
self.current_user_message = None
@database_sync_to_async
def get_or_create_conversation(self):
conv = (
Conversation.objects.filter(user=self.user, task_id=self.task_id)
.annotate(msg_count=Count("messages"))
.order_by("-msg_count", "-created")
.first()
)
if not conv:
conv = Conversation.objects.create(user=self.user, task_id=self.task_id)
return conv
@database_sync_to_async
def delete_message(self, message):
message.delete()
@database_sync_to_async
def save_message(self, role, content):
return Message.objects.create(
conversation=self.conversation,
role=role,
source="guidance",
content=content,
)
```
- [ ] **Step 3: Verify Django imports are clean**
```bash
cd api && uv run python manage.py check
```
Expected: `System check identified no issues (0 silenced).`
- [ ] **Step 4: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add api/prompt/consumers.py
git -C /home/xuyue/Projects/webpreview commit -m "feat: add GuidanceConsumer WebSocket handler"
```
---
### Task 3: Backend — Register WebSocket route
**Files:**
- Modify: `api/prompt/url.py`
- [ ] **Step 1: Replace content of `api/prompt/url.py`**
```python
from django.urls import path
from .consumers import PromptConsumer, GuidanceConsumer
websocket_urlpatterns = [
path("ws/prompt/<int:task_id>/", PromptConsumer.as_asgi()),
path("ws/guidance/<int:task_id>/", GuidanceConsumer.as_asgi()),
]
```
- [ ] **Step 2: Verify routing is clean**
```bash
cd api && uv run python manage.py check
```
Expected: `System check identified no issues (0 silenced).`
- [ ] **Step 3: Run the full test suite**
```bash
cd api && uv run python manage.py test prompt -v 2
```
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add api/prompt/url.py
git -C /home/xuyue/Projects/webpreview commit -m "feat: register ws/guidance/<task_id>/ WebSocket route"
```
---
### Task 4: Frontend — Export `currentTaskId` from prompt store
**Files:**
- Modify: `client/src/store/prompt.ts`
- [ ] **Step 1: Add exported `currentTaskId` ref**
After this line in `client/src/store/prompt.ts`:
```typescript
export const streamingContent = ref("")
```
Add:
```typescript
export const currentTaskId = ref(0)
```
- [ ] **Step 2: Keep it in sync in `connectPrompt`**
In `connectPrompt`, after `_currentTaskId = taskId`, add:
```typescript
currentTaskId.value = taskId
```
- [ ] **Step 3: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add client/src/store/prompt.ts
git -C /home/xuyue/Projects/webpreview commit -m "feat: export currentTaskId from prompt store"
```
---
### Task 5: Frontend — Guidance store
**Files:**
- Create: `client/src/store/guidance.ts`
- [ ] **Step 1: Create `client/src/store/guidance.ts`**
```typescript
import { ref } from "vue"
import { WS_BASE_URL } from "../utils/const"
export interface GuidanceMessage {
role: "user" | "assistant"
content: string
id?: number
}
export const messages = ref<GuidanceMessage[]>([])
export const connected = ref(false)
export const streaming = ref(false)
export const streamingContent = ref("")
export const isReady = ref(false)
export const isOpen = ref(false)
export const initialPrompt = ref("")
let ws: WebSocket | null = null
let _taskId = 0
function _setupHandlers(socket: WebSocket) {
socket.onopen = () => {
connected.value = true
if (initialPrompt.value) {
_send(initialPrompt.value)
}
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "init") {
streaming.value = false
streamingContent.value = ""
} else if (data.type === "stream") {
streaming.value = true
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
const content = streamingContent.value.replace(/^\[READY\]\n?/, "")
messages.value.push({ role: "assistant", content, id: data.message_id })
streamingContent.value = ""
if (data.is_ready) isReady.value = true
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({ role: "assistant", content: data.content })
}
}
socket.onclose = () => {
connected.value = false
}
}
function _send(content: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
streaming.value = true
messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content }))
}
export function openGuidance(taskId: number, prompt: string) {
_taskId = taskId
initialPrompt.value = prompt
messages.value = []
isReady.value = false
streamingContent.value = ""
isOpen.value = true
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${taskId}/`)
_setupHandlers(ws)
}
export function sendGuidance(content: string) {
_send(content)
}
export function stopGuidance() {
if (
messages.value.length > 0 &&
messages.value[messages.value.length - 1].role === "user"
) {
messages.value.pop()
}
streaming.value = false
streamingContent.value = ""
if (_taskId) {
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${_taskId}/`)
initialPrompt.value = "" // don't re-send on reconnect
_setupHandlers(ws)
}
}
export function closeGuidance() {
if (ws) {
ws.close()
ws = null
}
isOpen.value = false
messages.value = []
isReady.value = false
connected.value = false
streaming.value = false
streamingContent.value = ""
initialPrompt.value = ""
_taskId = 0
}
```
- [ ] **Step 2: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add client/src/store/guidance.ts
git -C /home/xuyue/Projects/webpreview commit -m "feat: add guidance WebSocket store"
```
---
### Task 6: Frontend — GuidancePanel component
**Files:**
- Create: `client/src/components/ai/GuidancePanel.vue`
- [ ] **Step 1: Create `client/src/components/ai/GuidancePanel.vue`**
```vue
<template>
<n-modal
:show="isOpen"
preset="card"
title="调教提示词"
style="width: 560px"
:mask-closable="!streaming"
@update:show="onModalClose"
>
<div class="guidance-body">
<div class="guidance-messages" ref="messagesRef">
<div
v-for="(msg, i) in messages"
:key="i"
class="guidance-msg"
:class="msg.role"
>
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div v-if="streaming" class="guidance-msg assistant">
<div class="msg-role">AI 教练</div>
<div v-if="!displayStreamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div
v-else
class="msg-content"
v-html="renderMarkdown(displayStreamingContent)"
></div>
</div>
</div>
<div v-if="isReady" class="ready-hint">
<Icon icon="lucide:check-circle" :width="16" />
提示词已优化,可以生成代码了!
</div>
<div class="guidance-input">
<n-input
v-model:value="draftPrompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="修改你的提示词后发送..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<div>
<n-button
v-if="streaming"
type="error"
size="small"
@click="stopGuidance"
>
停止
</n-button>
<n-button
v-else
size="small"
:disabled="!draftPrompt.trim() || !connected"
@click="send"
>
发送
</n-button>
</div>
<n-button
:type="isReady ? 'primary' : 'default'"
size="small"
:disabled="!draftPrompt.trim()"
@click="generate"
>
<template #icon>
<Icon icon="lucide:play" :width="14" />
</template>
生成代码
</n-button>
</n-flex>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue"
import { marked } from "marked"
import { Icon } from "@iconify/vue"
import {
messages,
streaming,
streamingContent,
isReady,
isOpen,
connected,
initialPrompt,
sendGuidance,
stopGuidance,
closeGuidance,
} from "../../store/guidance"
const emit = defineEmits<{
generate: [prompt: string]
}>()
const draftPrompt = ref("")
const messagesRef = ref<HTMLElement>()
const displayStreamingContent = computed(() =>
streamingContent.value.replace(/^\[READY\]\n?/, "")
)
function renderMarkdown(text: string): string {
return marked.parse(text) as string
}
function send() {
const text = draftPrompt.value.trim()
if (!text || streaming.value) return
sendGuidance(text)
}
function generate() {
const text = draftPrompt.value.trim()
if (!text) return
emit("generate", text)
closeGuidance()
}
function onModalClose(show: boolean) {
if (!show && !streaming.value) closeGuidance()
}
// Pre-fill draft when panel opens
watch(isOpen, (val) => {
if (val) draftPrompt.value = initialPrompt.value
})
watch([() => messages.value.length, streamingContent], () => {
nextTick(() => {
if (messagesRef.value)
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
})
})
</script>
<style scoped>
.guidance-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.guidance-messages {
min-height: 160px;
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.guidance-msg .msg-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #888;
}
.guidance-msg.user .msg-role { color: #2080f0; }
.guidance-msg.assistant .msg-role { color: #18a058; }
.guidance-msg.assistant .msg-content {
background: #f0f7ff;
border-left: 3px solid #2080f0;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
font-size: 14px;
line-height: 1.6;
}
.guidance-msg.user .msg-content {
font-size: 14px;
line-height: 1.6;
color: #333;
}
.ready-hint {
display: flex;
align-items: center;
gap: 6px;
color: #18a058;
font-size: 13px;
font-weight: 500;
padding: 6px 10px;
background: #f0fff4;
border-radius: 6px;
border: 1px solid #b8e8cc;
}
.typing-indicator {
display: flex;
gap: 5px;
padding: 8px 0;
}
.typing-indicator span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2080f0;
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-5px); opacity: 1; }
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add client/src/components/ai/GuidancePanel.vue
git -C /home/xuyue/Projects/webpreview commit -m "feat: add GuidancePanel coaching modal component"
```
---
### Task 7: Frontend — Wire GuidancePanel into PromptPanel
**Files:**
- Modify: `client/src/components/ai/PromptPanel.vue`
- [ ] **Step 1: Add imports to `<script setup>` in `PromptPanel.vue`**
Add this new import line after the other import statements:
```typescript
import GuidancePanel from "./GuidancePanel.vue"
import { openGuidance } from "../../store/guidance"
```
In the existing `import { messages, streaming, ... } from "../../store/prompt"` line, add `currentTaskId` to the list:
```typescript
import {
messages,
streaming,
streamingContent,
connected,
sendPrompt,
stopPrompt,
currentTaskId,
} from "../../store/prompt"
```
- [ ] **Step 2: Add `startGuidance` and `onGuidanceGenerate` functions**
Add after the `send` function:
```typescript
function startGuidance() {
if (!input.value.trim()) return
openGuidance(currentTaskId.value, input.value)
}
function onGuidanceGenerate(finalPrompt: string) {
sendPrompt(finalPrompt, selectedModel.value)
input.value = ""
}
```
- [ ] **Step 3: Add GuidancePanel to template and lightbulb button to input area**
In the template, add `<GuidancePanel @generate="onGuidanceGenerate" />` just before `<div class="input-area">`:
```html
<GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area">
```
In the `<n-flex>` inside `.input-area`, add the lightbulb button before the model selector. Replace:
```html
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
<n-select
```
With:
```html
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
<n-button
secondary
:disabled="!input.trim()"
title="调教提示词"
@click="startGuidance"
>
<template #icon>
<Icon icon="lucide:lightbulb" :width="16" />
</template>
</n-button>
<n-select
```
- [ ] **Step 4: Verify build**
```bash
cd client && npm run build 2>&1 | tail -30
```
Expected: Build succeeds, no TypeScript errors.
- [ ] **Step 5: Commit**
```bash
git -C /home/xuyue/Projects/webpreview add client/src/components/ai/PromptPanel.vue
git -C /home/xuyue/Projects/webpreview commit -m "feat: integrate GuidancePanel into PromptPanel"
```
---
## Manual Smoke Test
After all tasks complete, start both servers and test the full flow:
```bash
# Terminal 1
cd api && uv run manage.py runserver
# Terminal 2
cd client && npm run start
```
1. Open a Challenge page
2. Type a vague prompt like "做一个好看的页面" in the input
3. Click the lightbulb button — guidance modal should open and AI should respond with guiding questions
4. Revise the prompt to include specific details
5. AI should respond with `[READY]` prefix (stripped) and the "生成代码" button should highlight green
6. Click "生成代码" — modal closes, code is generated in the main panel
7. Verify guidance messages are saved in Django admin under `prompt > Messages` with `source=guidance`