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

22 KiB
Raw Permalink Blame History

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:

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
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"):

GUIDANCE_SYSTEM_PROMPT = """你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。

判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)

判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述

规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 不要生成任何代码"""

Add parse_guidance_response and stream_guidance after the existing extract_code function:

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
cd api && uv run python manage.py test prompt.tests.ParseGuidanceResponseTest -v 2

Expected: Ran 4 tests in ...s OK

  • Step 5: Commit
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:

from .llm import stream_chat, extract_code

To:

from .llm import stream_chat, extract_code, stream_guidance, parse_guidance_response
  • Step 2: Add GuidanceConsumer at the bottom of consumers.py
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
cd api && uv run python manage.py check

Expected: System check identified no issues (0 silenced).

  • Step 4: Commit
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

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
cd api && uv run python manage.py check

Expected: System check identified no issues (0 silenced).

  • Step 3: Run the full test suite
cd api && uv run python manage.py test prompt -v 2

Expected: All tests pass.

  • Step 4: Commit
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:

export const streamingContent = ref("")

Add:

export const currentTaskId = ref(0)
  • Step 2: Keep it in sync in connectPrompt

In connectPrompt, after _currentTaskId = taskId, add:

currentTaskId.value = taskId
  • Step 3: Commit
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

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
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

<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
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:

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:

import {
  messages,
  streaming,
  streamingContent,
  connected,
  sendPrompt,
  stopPrompt,
  currentTaskId,
} from "../../store/prompt"
  • Step 2: Add startGuidance and onGuidanceGenerate functions

Add after the send function:

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">:

    <GuidancePanel @generate="onGuidanceGenerate" />
    <div class="input-area">

In the <n-flex> inside .input-area, add the lightbulb button before the model selector. Replace:

      <n-flex justify="flex-end" align="center" style="margin-top: 8px">
        <n-select

With:

      <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
cd client && npm run build 2>&1 | tail -30

Expected: Build succeeds, no TypeScript errors.

  • Step 5: Commit
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:

# 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