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
@@ -17,7 +17,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
||||
- `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403)
|
||||
- `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall — no `classname` means all classes —, students' peer works with `scope=class|all`), `prompt`
|
||||
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or any student who has submitted the same task (any class). Students' `GET /submission/` only lists their own. Peer works and the random-rating item are anonymous until the viewer rates them (`PUT /:id/score` returns the author). `flag` now only carries `present` (teacher's in-class presentation queue); the old four colors are no longer shown
|
||||
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
|
||||
- `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with code kept only in the newest reply that has code. Chat replies are written as **做了什么 / 我替你定的 / 想一想** — the decisions the AI made for the student and one open question, to prompt the next round; a reply to a plain question has no code and the WebSocket sends `code: null`. The “帮我写清楚” coach gets the task brief, recent prompts and the last AI explanation from `guidanceContext` in `services/prompt.ts`, and judges by what the student added beyond the brief, like the classifier), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background; the model sees the task brief and the previous prompt, temperature 0, prompts under 4 chars get no level), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit)
|
||||
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted
|
||||
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("streamChat", () => {
|
||||
expect(out).toBe("你好世界")
|
||||
expect(model).toBe(config.llmModel)
|
||||
expect(thinking).toEqual({ type: "enabled" })
|
||||
for await (const _ of streamGuidance([{ role: "user", content: "hi" }]));
|
||||
for await (const _ of streamGuidance([{ role: "user", content: "hi" }], "题目:x"));
|
||||
expect(thinking).toEqual({ type: "disabled" })
|
||||
} finally {
|
||||
Object.assign(config, saved)
|
||||
@@ -140,6 +140,19 @@ test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => {
|
||||
expect(out.filter((m) => m.content.includes("<p>"))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("trimHistory 最后一条回复没有代码时,保留之前最新的那份代码", () => {
|
||||
const out = trimHistory([
|
||||
{ role: "user", content: "做个页面" },
|
||||
{ role: "assistant", content: "第1版\n```html\n<p>1</p>\n```" },
|
||||
{ role: "user", content: "加个按钮" },
|
||||
{ role: "assistant", content: "第2版\n```html\n<p>2</p>\n```" },
|
||||
{ role: "user", content: "为什么按钮是圆的?" },
|
||||
{ role: "assistant", content: "因为加了 border-radius。" },
|
||||
])
|
||||
expect(out[3]!.content).toContain("<p>2</p>")
|
||||
expect(out[1]!.content).not.toContain("<p>1</p>")
|
||||
})
|
||||
|
||||
test("userFacingError 不透出接口原始返回", () => {
|
||||
const quiet = console.error
|
||||
console.error = () => {}
|
||||
|
||||
+47
-3
@@ -2,12 +2,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
||||
import type { Server } from "bun"
|
||||
import { app } from "../src/app"
|
||||
import { config } from "../src/config"
|
||||
import { all } from "../src/db"
|
||||
import { all, run } from "../src/db"
|
||||
import { upgrade, websocket, type WsData } from "../src/ws"
|
||||
import { client, freshDb, makeTask, makeUser } from "./helpers"
|
||||
|
||||
// 假的模型接口:每段之间停一下,方便测试中途断开
|
||||
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||
const DEFAULT_PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||
let PIECES = DEFAULT_PIECES
|
||||
// 最近一次发给模型的请求;分类(非流式)单独记,固定回“3”
|
||||
let lastRequest: any = null
|
||||
let lastClassify: any = null
|
||||
@@ -56,7 +57,10 @@ afterAll(() => {
|
||||
llm.stop(true)
|
||||
})
|
||||
|
||||
beforeEach(freshDb)
|
||||
beforeEach(() => {
|
||||
freshDb()
|
||||
PIECES = DEFAULT_PIECES
|
||||
})
|
||||
|
||||
interface Conn {
|
||||
ws: WebSocket
|
||||
@@ -176,9 +180,49 @@ describe("WebSocket 代码生成", () => {
|
||||
c.ws.close()
|
||||
})
|
||||
|
||||
test("只回答问题、没给代码时 code 是 null", async () => {
|
||||
const { task, token } = await setup()
|
||||
PIECES = ["因为加了 ", "border-radius。"]
|
||||
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||
await c.next("init")
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "为什么按钮是圆的?" }))
|
||||
const done = await c.next("complete")
|
||||
expect(done.code).toBeNull()
|
||||
expect(done.message_id).toBeGreaterThan(0)
|
||||
c.ws.close()
|
||||
})
|
||||
|
||||
test("token 无效或任务不存在时握手失败", async () => {
|
||||
const { task, token } = await setup()
|
||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||
expect(await connect(`/ws/prompt/9999/?token=${token}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("WebSocket 帮我写清楚", () => {
|
||||
test("教练能看到题目要求、之前的提问和 AI 上一次的说明", async () => {
|
||||
const { task, token } = await setup()
|
||||
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器, 有开始和暂停按钮", id: task })
|
||||
const p = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||
await p.next("init")
|
||||
p.ws.send(JSON.stringify({ type: "message", content: "做个计时器" }))
|
||||
await p.next("complete")
|
||||
p.ws.close()
|
||||
|
||||
const g = (await connect(`/ws/guidance/${task}/?token=${token}`))!
|
||||
await g.next("init")
|
||||
g.ws.send(JSON.stringify({ type: "message", content: "改成粉色" }))
|
||||
await g.next("complete")
|
||||
const system = lastRequest.messages[0]
|
||||
expect(system.role).toBe("system")
|
||||
expect(system.content).toContain("题目要求:做一个计时器, 有开始和暂停按钮")
|
||||
expect(system.content).toContain("1. 做个计时器")
|
||||
expect(system.content).toContain("AI 上一次的说明:好的")
|
||||
expect(system.content).not.toContain("<p>hi</p>")
|
||||
expect(lastRequest.messages.at(-1)).toEqual({ role: "user", content: "改成粉色" })
|
||||
// 等后台打完层级再结束,不然假接口关了它会报错
|
||||
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE source = 'guidance' AND role = 'user'`)[0]?.prompt_level
|
||||
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
|
||||
g.ws.close()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user