feat: AI 回复说明替学生定了什么并问一个问题,“帮我写清楚”能看到题目和之前的对话
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 生成回复分“做了什么 / 我替你定的 / 想一想”三项;只问问题时直接回答、不给代码 - 没有代码的回复发 code: null,不再清空网页;上下文保留最近一份带代码的回复 - 教练带上题目要求、最近的提问和 AI 上一次的说明,照搬题目不算写清楚 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fee688d95
commit
43c18cdb88
7 files changed
+155
-41
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { all, get, run } from "../db"
|
||||
import { complete, llmConfigured } from "./llm"
|
||||
import { briefOf } from "./prompt"
|
||||
|
||||
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
|
||||
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
|
||||
@@ -24,7 +25,6 @@ const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页
|
||||
只返回一个数字(1-6),不要解释。`
|
||||
|
||||
const MIN_PROMPT_CHARS = 4
|
||||
const BRIEF_CHARS = 800
|
||||
const PROMPT_CHARS = 1500
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
@@ -52,15 +52,6 @@ async function callLlm(content: string): Promise<number | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 题目要求去掉图片、压成一段,太长的截掉
|
||||
function briefOf(content: string) {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return text.length > BRIEF_CHARS ? text.slice(0, BRIEF_CHARS) + "……" : text
|
||||
}
|
||||
|
||||
function clip(text: string) {
|
||||
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
|
||||
}
|
||||
|
||||
+40
-24
@@ -1,18 +1,29 @@
|
||||
import { config } from "../config"
|
||||
|
||||
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
|
||||
// AI 回复里的说明是学生唯一会读的文字,用它引导学生想下一步:
|
||||
// “我替你定的”指出学生没说、AI 自己拿的主意,学生看了才会说“不要这样,因为……”;“想一想”只问不答
|
||||
export const SYSTEM_PROMPT = `你是一个网页生成助手,和学生一起做网页。根据学生的描述生成网页代码。
|
||||
|
||||
规则:
|
||||
1. 使用一个 \`\`\`html 代码块返回所有代码
|
||||
1. 生成或修改网页时,使用一个 \`\`\`html 代码块返回所有代码
|
||||
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
||||
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
||||
4. 用中文回复,先简要说明你做了什么,然后给出代码
|
||||
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
||||
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
||||
4. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
||||
5. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
||||
6. 用中文回复,对学生称“你”。先写说明,再给代码。说明分下面三项,标题照抄,每项一两句话,不要写长:
|
||||
- **做了什么**:这次按学生的要求做了哪些改动
|
||||
- **我替你定的**:学生没说、你自己拿主意的地方,比如配色、布局、规则、数值、文字内容;列 1-3 条,只写在网页上看得到、学生可以要求改的。学生这次已经说清楚、没有需要你拿主意的地方,就省略这一项
|
||||
- **想一想**:问学生一个问题,帮他想清楚下一步想要什么。问他自己的想法或判断(如“……的时候你希望它怎样?”“这两种你觉得哪个更适合……,为什么?”),不要列一串功能让他挑,也不要替他回答
|
||||
7. 学生只是在问问题(为什么、是什么意思、怎么做到的),没有要求改网页时,用浅显的话直接回答,不给代码,也不用分三项
|
||||
8. 学生只说“不行”“有 bug”“没反应”,没说清哪里不对时,照样尽力修好,并在“想一想”里请他下次说清楚:做了什么操作、想看到什么、实际看到了什么
|
||||
|
||||
输出格式示例(必须严格遵守,只用一个代码块):
|
||||
|
||||
好的,我为你创建了一个点击按钮变色的示例。
|
||||
**做了什么**:做了一个按钮,点一下会变成红色。
|
||||
|
||||
**我替你定的**:按钮放在页面左上角;变色后不会再变回来。
|
||||
|
||||
**想一想**:再点一次的时候,你希望它变回原来的颜色,还是换成别的颜色?
|
||||
|
||||
\`\`\`html
|
||||
<style>
|
||||
@@ -28,23 +39,27 @@ document.getElementById('btn').onclick = function() {
|
||||
</script>
|
||||
\`\`\``
|
||||
|
||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
|
||||
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
|
||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
|
||||
|
||||
判断标准——满足以下条件视为"够好":
|
||||
- 有明确主题(例如:登录页、计时器、商品卡片列表)
|
||||
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
|
||||
判断标准——看学生这一次的话,满足下面一条就算"够好":
|
||||
- 第一次描述作品:除了题目要求,有他自己的想法,至少有一处具体的视觉、交互或内容是他自己定的(如配色风格、玩法规则、页面上放什么)
|
||||
- 修改已有网页:说清楚要改哪里、改成什么样;或者说清楚出了什么问题——做了什么操作、想看到什么、实际看到了什么
|
||||
|
||||
判断为"不够好"的典型情况:
|
||||
- 目标太泛,如"做一个好看的页面"
|
||||
- 只有主题,完全没有任何视觉、交互或内容描述
|
||||
- 只有作品名称,如"做一个象棋游戏"
|
||||
- 照搬或改写题目要求,没有自己的想法——哪怕写得很长、功能很多
|
||||
- 只说"不行""有 bug""再好看一点",没说是哪里、要怎样
|
||||
|
||||
规则:
|
||||
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
|
||||
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
|
||||
3. 用中文回复,语气鼓励,简洁明了
|
||||
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
|
||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||
6. 不要生成任何代码`
|
||||
1. 如果不够好,用 1-2 个启发性问题引导学生补充,问他自己的想法,不要直接给出答案,也不要列一串功能让他挑
|
||||
2. 照搬题目要求时,先肯定他读懂了题目,再问题目之外他想让作品有什么不一样
|
||||
3. AI 上一次的说明里如果有“我替你定的”,可以问他同不同意这些决定
|
||||
4. 如果已经够好,以 [READY] 开头回复,简短说明好在哪里,告诉他可以生成了;他还给了理由(为什么这样改)的,特别夸一下
|
||||
5. 用中文回复,语气鼓励,简洁明了
|
||||
6. 使用 Markdown 语法高亮关键词,优先突出 **视觉**、**交互**、**内容**、**你的想法**、**可以生成** 等重点
|
||||
7. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||
8. 不要生成任何代码`
|
||||
|
||||
// 走火山方舟(ARK)接口的模型
|
||||
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
||||
@@ -165,14 +180,15 @@ async function* streamCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// 发给模型的对话上下文:只留最近 20 条;除最后一条 AI 回复外,旧回复里的代码块换成占位文字。
|
||||
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度
|
||||
// 发给模型的对话上下文:只留最近 20 条;除最后一条带代码的 AI 回复外,旧回复里的代码块换成占位文字。
|
||||
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度。
|
||||
// 只回答问题的回复没有代码,不能拿它当“最后一条”,不然最新的代码也被省略了
|
||||
const MAX_HISTORY = 20
|
||||
const CODE_BLOCK = /```[\s\S]*?```/g
|
||||
|
||||
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
|
||||
const recent = history.slice(-MAX_HISTORY)
|
||||
const last = recent.findLastIndex((m) => m.role === "assistant")
|
||||
const last = recent.findLastIndex((m) => m.role === "assistant" && m.content.includes("```"))
|
||||
return recent.map((m, i) =>
|
||||
m.role === "assistant" && i !== last
|
||||
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
|
||||
@@ -201,9 +217,9 @@ export function streamChat(history: ChatMessage[], model?: string, signal?: Abor
|
||||
})
|
||||
}
|
||||
|
||||
// 引导只是简短追问,不用思考,回得快
|
||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
||||
return streamCompletion([{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history], {
|
||||
// 引导只是简短追问,不用思考,回得快。context 是题目和学生之前的对话(见 guidanceContext)
|
||||
export function streamGuidance(history: ChatMessage[], context: string, signal?: AbortSignal) {
|
||||
return streamCompletion([{ role: "system", content: `${GUIDANCE_SYSTEM_PROMPT}\n\n${context}` }, ...history], {
|
||||
thinking: false,
|
||||
signal,
|
||||
})
|
||||
|
||||
@@ -99,6 +99,49 @@ export function codeAsMarkdown(code: {
|
||||
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||
}
|
||||
|
||||
// 题目要求去掉图片、压成一段,太长的截掉
|
||||
const BRIEF_CHARS = 800
|
||||
|
||||
export function briefOf(content: string) {
|
||||
const text = content
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return text.length > BRIEF_CHARS ? text.slice(0, BRIEF_CHARS) + "……" : text
|
||||
}
|
||||
|
||||
const clipTo = (text: string, n: number) => (text.length > n ? text.slice(0, n) + "……" : text)
|
||||
|
||||
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
|
||||
// 题目要求、学生最近几次对 AI 说的话、AI 上一次的说明(代码去掉)
|
||||
export function guidanceContext(conversationId: string, taskId: number) {
|
||||
const task = get<{ title: string; content: string }>(
|
||||
`SELECT title, content FROM task_task WHERE id = $id`,
|
||||
{ id: taskId },
|
||||
)
|
||||
const asked = all<{ content: string }>(
|
||||
`SELECT content FROM prompt_message
|
||||
WHERE conversation_id = $id AND role = 'user' AND source IN ('conversation', 'manual')
|
||||
ORDER BY created DESC, id DESC LIMIT 5`,
|
||||
{ id: conversationId },
|
||||
).reverse()
|
||||
const reply = get<{ content: string }>(
|
||||
`SELECT content FROM prompt_message
|
||||
WHERE conversation_id = $id AND role = 'assistant' AND source = 'conversation'
|
||||
ORDER BY created DESC, id DESC LIMIT 1`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
const replyText = reply?.content.replace(/```[\s\S]*?(```|$)/g, "").trim()
|
||||
return [
|
||||
`题目:${task?.title ?? "(未知)"}`,
|
||||
`题目要求:${task ? briefOf(task.content) || "(无)" : "(无)"}`,
|
||||
`学生之前对 AI 说过的话:${
|
||||
asked.length ? asked.map((m, i) => `\n${i + 1}. ${clipTo(m.content.trim(), 300)}`).join("") : "(还没有,这是第一次描述作品)"
|
||||
}`,
|
||||
`AI 上一次的说明:${replyText ? clipTo(replyText, 600) : "(无)"}`,
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||
return get<MessageRow>(
|
||||
`SELECT * FROM prompt_message
|
||||
|
||||
+9
-2
@@ -5,6 +5,7 @@ import {
|
||||
canAccessTask,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
guidanceContext,
|
||||
insertMessage,
|
||||
} from "./services/prompt"
|
||||
import {
|
||||
@@ -151,7 +152,9 @@ async function handlePrompt(ws: WS, data: any) {
|
||||
// 有了回复,这条提问才留下来;在后台打 L1–L6 层级
|
||||
inBackground(classifyMessage(userMessageId))
|
||||
ws.data.pendingUserMessageId = null
|
||||
send(ws, { type: "complete", code, message_id: messageId })
|
||||
// 只回答问题、没给代码的回复不是新版本;发 null,免得工作台把网页清空
|
||||
const hasCode = !!(code.html || code.css || code.js)
|
||||
send(ws, { type: "complete", code: hasCode ? code : null, message_id: messageId })
|
||||
} finally {
|
||||
dropPendingUserMessage(ws)
|
||||
}
|
||||
@@ -172,7 +175,11 @@ async function handleGuidance(ws: WS, data: any) {
|
||||
try {
|
||||
let full: string
|
||||
try {
|
||||
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
|
||||
const context = guidanceContext(ws.data.conversationId, ws.data.taskId)
|
||||
full = await streamToClient(
|
||||
ws,
|
||||
streamGuidance(ws.data.guidanceHistory, context, ws.data.abort.signal),
|
||||
)
|
||||
} catch (e) {
|
||||
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
||||
ws.data.guidanceHistory.pop()
|
||||
|
||||
Reference in new issue
Block a user