feat: 改版阶段 2:挑战和自由创作共用工作台,学生自己选版本提交
- 新的 Workbench:左边题目(可收起)+ AI 对话,右边预览 / 代码(可编辑),底部版本条; 挑战页和自由创作页都用它,原来的“手动提交”“历史对话”标签换成“粘贴外部代码”和“全部历史” - 不再每轮 AI 回复自动提交:学生点“交这一版”,可交多次,成绩仍取最高分;已交的版本不能重复点 - 手改代码、粘贴外部 AI 代码都存成版本(POST /prompt/versions,source 为 edit / manual), 在版本条和提示词链里标“手改”“外部 AI”;它们进入 AI 上下文,AI 在手改后的代码上继续改; 以前手动提交的回复正文为空时用代码补上;edit 的占位提问不参与提示词层级分类 - 互评从全屏弹窗改成对话里的卡片,打分前不显示作者,每个分数写明含义 - 编辑器代码按任务各存一份(useTaskCode),挑战页“清空”不再清掉教程的代码; 提交列表“复制到编辑器”对挑战写入该任务自己的代码 - 抽出 utils/code.ts(整理代码、拆分整页 HTML);删除 RandomRatingModal、ExternalAIPanel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff1eb2982e
commit
9c6c01eba1
29 files changed
+1748
-1069
No files matched your search
@@ -18,7 +18,7 @@ const { values } = parseArgs({
|
||||
},
|
||||
})
|
||||
|
||||
const where = ["m.role = 'user'"]
|
||||
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (values["task-id"]) {
|
||||
where.push("c.task_id = $task_id")
|
||||
|
||||
@@ -2,14 +2,22 @@ import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { all, get, tx, iso, uuidOut } from "../db"
|
||||
import { Role, isStaff, requireUser } from "../auth"
|
||||
import { HttpError, ValidationError, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||
import { HttpError, ValidationError, body, intParam, orNotFound, qs, query, uuidParam, type Env } from "../http"
|
||||
import { deleteRound, hasRatingsOrAwards } from "../services/cascade"
|
||||
import {
|
||||
classifyConversationMessages,
|
||||
classifyMessage,
|
||||
classifyMessagesBatch,
|
||||
inBackground,
|
||||
} from "../services/classifier"
|
||||
import type { MessageRow } from "../services/prompt"
|
||||
import {
|
||||
EDIT_PROMPT,
|
||||
canAccessTask,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
insertMessage,
|
||||
type MessageRow,
|
||||
} from "../services/prompt"
|
||||
|
||||
const router = new Hono<Env>()
|
||||
|
||||
@@ -133,6 +141,45 @@ router.get("/history/:task_id", (c) => {
|
||||
})
|
||||
|
||||
// 对一个对话里的用户消息做层级分类(管理员,后台执行)
|
||||
// 保存一个不是 AI 生成的版本:学生手改的代码(source=edit),或者粘贴外部 AI 的代码(source=manual,带提示词)。
|
||||
// 存成一问一答放进当前对话,版本条和提示词链里都能看到;之后接着和 AI 对话,AI 也是在这份代码上改
|
||||
router.post("/versions", async (c) => {
|
||||
const user = requireUser(c)
|
||||
const p = await body(
|
||||
c,
|
||||
z.object({
|
||||
task_id: z.number().int(),
|
||||
html: z.string(),
|
||||
css: z.string(),
|
||||
js: z.string(),
|
||||
prompt: z.string().trim().max(5000).optional(),
|
||||
}),
|
||||
)
|
||||
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: p.task_id }), "任务不存在")
|
||||
if (!canAccessTask(user, p.task_id)) throw new HttpError(403, "没有权限")
|
||||
const source = p.prompt ? "manual" : "edit"
|
||||
const code = { code_html: p.html, code_css: p.css, code_js: p.js }
|
||||
const ids = tx(() => {
|
||||
const convId = getOrCreateActiveConversation(user.id, p.task_id)
|
||||
const userMessageId = insertMessage({
|
||||
conversation_id: convId,
|
||||
role: "user",
|
||||
content: p.prompt || EDIT_PROMPT,
|
||||
source,
|
||||
})
|
||||
const messageId = insertMessage({
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: codeAsMarkdown(code),
|
||||
source,
|
||||
...code,
|
||||
})
|
||||
return { userMessageId, messageId }
|
||||
})
|
||||
if (p.prompt) inBackground(classifyMessage(ids.userMessageId))
|
||||
return c.json({ message_id: ids.messageId, user_message_id: ids.userMessageId, source })
|
||||
})
|
||||
|
||||
router.post("/conversations/:conversation_id/classify", (c) => {
|
||||
const user = requireUser(c)
|
||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||
@@ -151,7 +198,7 @@ router.post("/classify-batch", (c) => {
|
||||
c,
|
||||
z.object({ task_id: qs.opt(qs.int), force: qs.opt(qs.bool).default(false) }),
|
||||
)
|
||||
const where = ["m.role = 'user'"]
|
||||
const where = ["m.role = 'user'", "m.source != 'edit'"]
|
||||
const params: Record<string, unknown> = {}
|
||||
if (q.task_id) {
|
||||
where.push("c.task_id = $task_id")
|
||||
|
||||
@@ -39,7 +39,7 @@ async function callLlm(content: string): Promise<number | null> {
|
||||
|
||||
export async function classifyMessage(messageId: number): Promise<number | null> {
|
||||
const msg = get<{ content: string }>(
|
||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user'`,
|
||||
`SELECT content FROM prompt_message WHERE id = $id AND role = 'user' AND source != 'edit'`,
|
||||
{ id: messageId },
|
||||
)
|
||||
if (!msg) return null
|
||||
@@ -56,7 +56,7 @@ export async function classifyMessage(messageId: number): Promise<number | null>
|
||||
export async function classifyConversationMessages(conversationId: string, force = false) {
|
||||
const rows = all<{ id: number }>(
|
||||
`SELECT id FROM prompt_message
|
||||
WHERE conversation_id = $id AND role = 'user' ${force ? "" : "AND prompt_level IS NULL"}
|
||||
WHERE conversation_id = $id AND role = 'user' AND source != 'edit' ${force ? "" : "AND prompt_level IS NULL"}
|
||||
ORDER BY created, id`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
|
||||
@@ -81,6 +81,23 @@ export function insertMessage(m: {
|
||||
return Number(res.lastInsertRowid)
|
||||
}
|
||||
|
||||
// 不是 AI 生成的版本(手改 / 粘贴外部 AI 的代码),AI 回复那一条的正文写成和 AI 输出一样的代码块,
|
||||
// 后面接着和 AI 对话时,它在上下文里看到的就是这份代码
|
||||
export const EDIT_PROMPT = "(手动修改了代码)"
|
||||
|
||||
export function codeAsMarkdown(code: {
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
}) {
|
||||
const parts = [
|
||||
code.code_css ? `<style>\n${code.code_css}\n</style>` : "",
|
||||
code.code_html ?? "",
|
||||
code.code_js ? `<script>\n${code.code_js}\n</script>` : "",
|
||||
].filter(Boolean)
|
||||
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||
}
|
||||
|
||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||
return get<MessageRow>(
|
||||
`SELECT * FROM prompt_message
|
||||
|
||||
+13
-4
@@ -1,7 +1,12 @@
|
||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||
import { all, get, run, iso, uuidOut } from "./db"
|
||||
import { userFromAccessToken, type User } from "./auth"
|
||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||
import {
|
||||
canAccessTask,
|
||||
codeAsMarkdown,
|
||||
getOrCreateActiveConversation,
|
||||
insertMessage,
|
||||
} from "./services/prompt"
|
||||
import {
|
||||
extractCode,
|
||||
parseGuidanceResponse,
|
||||
@@ -80,6 +85,7 @@ function conversationHistory(conversationId: string) {
|
||||
return all<{
|
||||
id: number
|
||||
role: string
|
||||
source: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
@@ -87,8 +93,9 @@ function conversationHistory(conversationId: string) {
|
||||
submission_id: string | null
|
||||
created: string
|
||||
}>(
|
||||
`SELECT id, role, content, code_html, code_css, code_js, submission_id, created
|
||||
FROM prompt_message WHERE conversation_id = $id AND source = 'conversation'
|
||||
// 除了 AI 对话,还有学生手改(edit)和粘贴外部 AI 代码(manual)的版本;引导对话(guidance)不算
|
||||
`SELECT id, role, source, content, code_html, code_css, code_js, submission_id, created
|
||||
FROM prompt_message WHERE conversation_id = $id AND source IN ('conversation', 'edit', 'manual')
|
||||
ORDER BY created, id`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
@@ -117,7 +124,8 @@ async function handlePrompt(ws: WS, data: any) {
|
||||
try {
|
||||
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
||||
role: m.role as ChatMessage["role"],
|
||||
content: m.content,
|
||||
// 以前“手动提交”存的 AI 回复正文是空的,代码只在 code_* 里
|
||||
content: m.role === "assistant" && !m.content ? codeAsMarkdown(m) : m.content,
|
||||
}))
|
||||
let full: string
|
||||
try {
|
||||
@@ -211,6 +219,7 @@ export const websocket: WebSocketHandler<WsData> = {
|
||||
messages: conversationHistory(d.conversationId).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
source: m.source,
|
||||
content: m.content,
|
||||
code:
|
||||
m.role === "assistant" ? { html: m.code_html, css: m.code_css, js: m.code_js } : null,
|
||||
|
||||
@@ -226,6 +226,34 @@ describe("提交与删除", () => {
|
||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||
})
|
||||
|
||||
test("保存手改 / 外部 AI 版本,交这一版时关联到它", async () => {
|
||||
makeUser("stu")
|
||||
const task = makeTask("T")
|
||||
const stu = await client("stu")
|
||||
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
|
||||
expect(edit.json.source).toBe("edit")
|
||||
const ext = await stu("POST", "/api/prompt/versions", {
|
||||
task_id: task,
|
||||
html: "<p>2",
|
||||
css: "",
|
||||
js: "",
|
||||
prompt: "用外部 AI 做的",
|
||||
})
|
||||
expect(ext.json.source).toBe("manual")
|
||||
const history = await stu("GET", `/api/prompt/history/${task}`)
|
||||
expect(history.json.map((h: any) => [h.source, h.prompt])).toEqual([
|
||||
["edit", "(手动修改了代码)"],
|
||||
["manual", "用外部 AI 做的"],
|
||||
])
|
||||
|
||||
const sub = await stu("POST", "/api/submission/", { task_id: task, html: "<p>1", message_id: edit.json.message_id })
|
||||
const linked = get(`SELECT submission_id FROM prompt_message WHERE id = $id`, { id: edit.json.message_id })
|
||||
expect(linked.submission_id).toBe(sub.json.id.replaceAll("-", ""))
|
||||
// 同一个版本再交一次,更新原来的提交,不新建
|
||||
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id })
|
||||
expect(all(`SELECT html FROM submission_submission`)).toEqual([{ html: "<p>1!" }])
|
||||
})
|
||||
|
||||
test("删除一轮对话:连带提问和关联提交;被评过分的不能删", async () => {
|
||||
makeUser("stu")
|
||||
makeUser("other")
|
||||
|
||||
+32
-1
@@ -8,9 +8,12 @@ import { client, freshDb, makeTask, makeUser } from "./helpers"
|
||||
|
||||
// 假的模型接口:每段之间停一下,方便测试中途断开
|
||||
const PIECES = ["好的", "```html\n<p>hi</p>\n```"]
|
||||
// 最近一次发给模型的请求
|
||||
let lastRequest: any = null
|
||||
const llm = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
async fetch(req) {
|
||||
lastRequest = await req.json()
|
||||
const enc = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(ctrl) {
|
||||
@@ -126,6 +129,34 @@ describe("WebSocket 代码生成", () => {
|
||||
expect(all(`SELECT * FROM prompt_message`)).toEqual([])
|
||||
})
|
||||
|
||||
test("手改的版本进入对话,AI 下一轮在它上面改", async () => {
|
||||
makeUser("stu")
|
||||
const task = makeTask("挑战")
|
||||
const stu = await client("stu")
|
||||
const c = (await connect(`/ws/prompt/${task}/?token=${stu.tokens.access_token}`))!
|
||||
await c.next("init")
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||
await c.next("complete")
|
||||
|
||||
const saved = await stu("POST", "/api/prompt/versions", {
|
||||
task_id: task,
|
||||
html: "<p>我改过的</p>",
|
||||
css: "p { color: red }",
|
||||
js: "",
|
||||
})
|
||||
expect(saved.json.source).toBe("edit")
|
||||
|
||||
c.ws.send(JSON.stringify({ type: "message", content: "再加个按钮" }))
|
||||
await c.next("complete")
|
||||
const sent = lastRequest.messages.map((m: any) => `${m.role}:${m.content}`)
|
||||
// system、第一轮、手改那一轮、这一轮的提问
|
||||
expect(sent.at(-3)).toBe("user:(手动修改了代码)")
|
||||
expect(sent.at(-2)).toContain("<p>我改过的</p>")
|
||||
expect(sent.at(-2)).toContain("p { color: red }")
|
||||
expect(sent.at(-1)).toBe("user:再加个按钮")
|
||||
c.ws.close()
|
||||
})
|
||||
|
||||
test("token 无效或任务不存在时握手失败", async () => {
|
||||
const { task, token } = await setup()
|
||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||
|
||||
Reference in new issue
Block a user