refactor: 后端从 Django 换成 Bun + SQLite
Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB, 同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。 - server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja 保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用 - 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum) - scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数 - 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗; 非超管的挑战公开按钮置灰 - 权限不足返回 JSON 403(原来是重定向到不存在的登录页) - docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md - 服务器切换步骤见 docs/migrate-to-bun.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47d4679984
commit
26f05d1d76
297 files changed
+4659
-42956
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { run } from "../db"
|
||||
|
||||
// Django 的 on_delete=CASCADE 是在应用层做的,这里手动按依赖顺序删除。
|
||||
// 调用方负责包在事务里。
|
||||
|
||||
export function deleteSubmissions(where: string, params: Record<string, unknown>) {
|
||||
const ids = `SELECT id FROM submission_submission WHERE ${where}`
|
||||
run(`DELETE FROM submission_rating WHERE submission_id IN (${ids})`, params)
|
||||
run(`DELETE FROM submission_submissionaward WHERE submission_id IN (${ids})`, params)
|
||||
run(`DELETE FROM prompt_message WHERE submission_id IN (${ids})`, params)
|
||||
run(`DELETE FROM submission_submission WHERE ${where}`, params)
|
||||
}
|
||||
|
||||
export function deleteTask(taskId: number) {
|
||||
const p = { task_id: taskId }
|
||||
deleteSubmissions("task_id = $task_id", p)
|
||||
run(
|
||||
`DELETE FROM prompt_message WHERE conversation_id IN
|
||||
(SELECT id FROM prompt_conversation WHERE task_id = $task_id)`,
|
||||
p,
|
||||
)
|
||||
run(`DELETE FROM prompt_conversation WHERE task_id = $task_id`, p)
|
||||
run(`DELETE FROM task_taskasset WHERE task_id = $task_id`, p)
|
||||
run(`DELETE FROM task_tutorial WHERE task_ptr_id = $task_id`, p)
|
||||
run(`DELETE FROM task_challenge WHERE task_ptr_id = $task_id`, p)
|
||||
run(`DELETE FROM task_creation WHERE task_ptr_id = $task_id`, p)
|
||||
run(`DELETE FROM task_task WHERE id = $task_id`, p)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { all, get, run } from "../db"
|
||||
import { complete, llmConfigured } from "./llm"
|
||||
|
||||
const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。根据布鲁姆认知分类学,分析以下学生在前端学习中发送给AI助手的一条提示词,判断该提示词所体现的认知层级。
|
||||
|
||||
层级定义:
|
||||
- L1 记忆:能背诵HTML标签语法(例:"帮我写一个按钮")
|
||||
- L2 理解:能解释flex布局原理(例:"为什么这里不居中?")
|
||||
- L3 应用:能独立搭建页面结构(例:"用flex做导航栏,间距16px")
|
||||
- L4 分析:能定位跨浏览器兼容性bug(例:"Safari中margin失效,原因?")
|
||||
- L5 评价:能对比并选择方案(例:"对比Grid与Flex方案优劣")
|
||||
- L6 创造:能设计并实现原创交互作品(例:"设计夜间/日间切换效果")
|
||||
|
||||
只返回一个数字(1-6),不要解释。`
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
async function callLlm(content: string): Promise<number | null> {
|
||||
if (!llmConfigured()) return null
|
||||
try {
|
||||
const text = await complete(
|
||||
[
|
||||
{ role: "system", content: CLASSIFY_SYSTEM_PROMPT },
|
||||
{ role: "user", content },
|
||||
],
|
||||
{ maxTokens: 10, timeoutMs: 30_000 },
|
||||
)
|
||||
const match = /[1-6]/.exec(text)
|
||||
if (!match) {
|
||||
console.warn(`classify: unexpected LLM response '${text}'`)
|
||||
return null
|
||||
}
|
||||
return Number(match[0])
|
||||
} catch (e) {
|
||||
console.error("classify LLM call failed:", e)
|
||||
return 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'`,
|
||||
{ id: messageId },
|
||||
)
|
||||
if (!msg) return null
|
||||
const level = await callLlm(msg.content)
|
||||
if (level !== null) {
|
||||
run(`UPDATE prompt_message SET prompt_level = $level WHERE id = $id`, {
|
||||
level,
|
||||
id: messageId,
|
||||
})
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
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"}
|
||||
ORDER BY created, id`,
|
||||
{ id: conversationId },
|
||||
)
|
||||
for (const { id } of rows) {
|
||||
await classifyMessage(id)
|
||||
await sleep(300)
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyMessagesBatch(ids: number[]) {
|
||||
for (const id of ids) {
|
||||
await classifyMessage(id)
|
||||
await sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
// 后台执行,不阻塞请求
|
||||
export function inBackground(p: Promise<unknown>) {
|
||||
p.catch((e) => console.error("background task failed:", e))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { all } from "../db"
|
||||
import { uuidOut } from "../db"
|
||||
import { HttpError } from "../http"
|
||||
import { pyRound, pySum } from "./pyround"
|
||||
|
||||
export type GradebookTaskType = "tutorial" | "challenge"
|
||||
type Grade = "A" | "B" | "C" | "D" | "E"
|
||||
|
||||
export interface GradebookFilters {
|
||||
classname: string
|
||||
task_type?: GradebookTaskType
|
||||
username?: string
|
||||
include_all_tasks?: boolean
|
||||
}
|
||||
|
||||
interface GradebookTask {
|
||||
id: number
|
||||
display: number
|
||||
title: string
|
||||
task_type: string
|
||||
submitted_count: number
|
||||
coverage: number
|
||||
included: boolean
|
||||
}
|
||||
|
||||
interface GradebookRow {
|
||||
user_id: number
|
||||
username: string
|
||||
classname: string
|
||||
rank: number
|
||||
grade: Grade
|
||||
assessment_grade: Grade
|
||||
assessment_score: number
|
||||
top3_score: number
|
||||
scores: Record<number, { score: number; submitted: boolean; submission_id: string | null }>
|
||||
tutorial_total: number
|
||||
challenge_total: number
|
||||
total_score: number
|
||||
average_score: number | null
|
||||
submitted_task_count: number
|
||||
missing_task_count: number
|
||||
}
|
||||
|
||||
export interface Gradebook {
|
||||
classname: string
|
||||
classes: string[]
|
||||
task_count: number
|
||||
included_task_count: number
|
||||
student_count: number
|
||||
coverage_threshold_count: number
|
||||
tasks: GradebookTask[]
|
||||
rows: GradebookRow[]
|
||||
}
|
||||
|
||||
const score = (value: number | null | undefined) => pyRound(value || 0, 2)
|
||||
|
||||
// Python 字符串比较(按码点),不能用 localeCompare
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function gradeForRank(rank: number, n: number): Grade {
|
||||
if (rank <= Math.ceil(0.3 * n)) return "A"
|
||||
if (rank <= Math.ceil(0.7 * n)) return "B"
|
||||
if (rank <= Math.ceil(0.9 * n)) return "C"
|
||||
if (rank <= Math.ceil(0.95 * n)) return "D"
|
||||
return "E"
|
||||
}
|
||||
|
||||
// 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
|
||||
function assessmentGradeForRank(rank: number, n: number): Grade {
|
||||
if (rank <= Math.ceil(0.1 * n)) return "A"
|
||||
if (rank <= Math.ceil(0.3 * n)) return "B"
|
||||
if (rank <= Math.ceil(0.7 * n)) return "C"
|
||||
if (rank <= Math.ceil(0.9 * n)) return "D"
|
||||
return "E"
|
||||
}
|
||||
|
||||
// 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开;最高分封顶 99
|
||||
const ASSESSMENT_SCORE_BANDS: Record<Grade, [number, number]> = {
|
||||
A: [92, 99],
|
||||
B: [84, 91],
|
||||
C: [76, 83],
|
||||
D: [68, 75],
|
||||
E: [60, 67],
|
||||
}
|
||||
|
||||
function assessmentScore(indexInGrade: number, gradeSize: number, grade: Grade) {
|
||||
const [low, high] = ASSESSMENT_SCORE_BANDS[grade]
|
||||
if (gradeSize <= 1) return high
|
||||
// 段内第 1 名拿段顶,最后 1 名拿段底
|
||||
return pyRound(high - (indexInGrade / (gradeSize - 1)) * (high - low))
|
||||
}
|
||||
|
||||
export function buildGradebook(filters: GradebookFilters): Gradebook {
|
||||
const classname = filters.classname?.trim() ?? ""
|
||||
if (!classname) throw new HttpError(400, "请选择班级")
|
||||
|
||||
const classes = all<{ classname: string }>(
|
||||
`SELECT DISTINCT classname FROM account_user
|
||||
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
|
||||
).map((r) => r.classname)
|
||||
|
||||
const students = all<{ id: number; username: string; classname: string }>(
|
||||
`SELECT id, username, classname FROM account_user
|
||||
WHERE role IN ('normal', 'admin') AND classname = $classname
|
||||
ORDER BY username, id`,
|
||||
{ classname },
|
||||
)
|
||||
const studentCount = students.length
|
||||
const coverageThreshold = studentCount ? Math.ceil(studentCount * 0.5) : 0
|
||||
|
||||
const typeFilter = filters.task_type ? `AND t.task_type = $task_type` : ""
|
||||
const typeParams = filters.task_type ? { task_type: filters.task_type } : {}
|
||||
|
||||
const taskRows = all<{ id: number; display: number; title: string; task_type: string; submitted_count: number }>(
|
||||
`SELECT t.id, t.display, t.title, t.task_type, COUNT(DISTINCT s.user_id) AS submitted_count
|
||||
FROM submission_submission s
|
||||
JOIN task_task t ON t.id = s.task_id
|
||||
JOIN account_user u ON u.id = s.user_id
|
||||
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname ${typeFilter}
|
||||
GROUP BY t.id`,
|
||||
{ classname, ...typeParams },
|
||||
)
|
||||
taskRows.sort(
|
||||
(a, b) =>
|
||||
(a.task_type === "tutorial" ? 0 : 1) - (b.task_type === "tutorial" ? 0 : 1) ||
|
||||
a.display - b.display ||
|
||||
a.id - b.id,
|
||||
)
|
||||
|
||||
const allTasks: GradebookTask[] = taskRows.map((t) => ({
|
||||
id: t.id,
|
||||
display: t.display,
|
||||
title: t.title,
|
||||
task_type: t.task_type,
|
||||
submitted_count: t.submitted_count,
|
||||
coverage: studentCount ? score(t.submitted_count / studentCount) : 0,
|
||||
included: studentCount > 0 && t.submitted_count >= coverageThreshold,
|
||||
}))
|
||||
|
||||
const tasks = allTasks.filter((t) => filters.include_all_tasks || t.included)
|
||||
const visibleTaskIds = new Set(tasks.map((t) => t.id))
|
||||
const includedTaskCount = tasks.filter((t) => t.included).length
|
||||
|
||||
// 每个 (学生, 任务) 取最高分的提交;同分取最新
|
||||
const best = new Map<string, { id: string; score: number }>()
|
||||
if (studentCount && visibleTaskIds.size) {
|
||||
const subs = all<{ id: string; user_id: number; task_id: number; score: number }>(
|
||||
`SELECT s.id, s.user_id, s.task_id, s.score
|
||||
FROM submission_submission s JOIN account_user u ON u.id = s.user_id
|
||||
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname
|
||||
ORDER BY s.user_id, s.task_id, s.score DESC, s.created DESC, s.id DESC`,
|
||||
{ classname },
|
||||
)
|
||||
for (const s of subs) {
|
||||
if (!visibleTaskIds.has(s.task_id)) continue
|
||||
const key = `${s.user_id}:${s.task_id}`
|
||||
if (!best.has(key)) best.set(key, s)
|
||||
}
|
||||
}
|
||||
|
||||
let rows: GradebookRow[] = students.map((student) => {
|
||||
const scores: GradebookRow["scores"] = {}
|
||||
let tutorialTotal = 0
|
||||
let challengeTotal = 0
|
||||
const submittedScores: number[] = []
|
||||
let submittedTaskCount = 0
|
||||
let missingTaskCount = 0
|
||||
|
||||
for (const task of tasks) {
|
||||
const sub = best.get(`${student.id}:${task.id}`)
|
||||
const cellScore = sub ? score(sub.score) : 0
|
||||
scores[task.id] = {
|
||||
score: cellScore,
|
||||
submitted: !!sub,
|
||||
submission_id: sub ? uuidOut(sub.id) : null,
|
||||
}
|
||||
if (!task.included) continue
|
||||
if (sub) {
|
||||
submittedTaskCount++
|
||||
submittedScores.push(cellScore)
|
||||
} else {
|
||||
missingTaskCount++
|
||||
}
|
||||
if (task.task_type === "tutorial") tutorialTotal += cellScore
|
||||
else challengeTotal += cellScore
|
||||
}
|
||||
|
||||
tutorialTotal = score(tutorialTotal)
|
||||
challengeTotal = score(challengeTotal)
|
||||
const top3 = submittedScores.sort((a, b) => b - a).slice(0, 3)
|
||||
const top3Score = top3.length ? score(pySum(top3) / top3.length) : 0
|
||||
const totalScore = score(tutorialTotal + challengeTotal)
|
||||
return {
|
||||
user_id: student.id,
|
||||
username: student.username,
|
||||
classname: student.classname,
|
||||
rank: 0,
|
||||
grade: "E",
|
||||
assessment_grade: "E",
|
||||
assessment_score: 60,
|
||||
top3_score: top3Score,
|
||||
scores,
|
||||
tutorial_total: tutorialTotal,
|
||||
challenge_total: challengeTotal,
|
||||
total_score: totalScore,
|
||||
average_score: includedTaskCount ? score(totalScore / includedTaskCount) : null,
|
||||
submitted_task_count: submittedTaskCount,
|
||||
missing_task_count: missingTaskCount,
|
||||
}
|
||||
})
|
||||
|
||||
rows.sort((a, b) => b.total_score - a.total_score || cmp(a.username, b.username))
|
||||
rows.forEach((row, i) => {
|
||||
row.rank = i + 1
|
||||
row.grade = gradeForRank(i + 1, studentCount)
|
||||
})
|
||||
|
||||
// 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
|
||||
const assessmentOrder = [...rows].sort(
|
||||
(a, b) => b.top3_score - a.top3_score || cmp(a.username, b.username),
|
||||
)
|
||||
assessmentOrder.forEach((row, i) => {
|
||||
row.assessment_grade = assessmentGradeForRank(i + 1, studentCount)
|
||||
})
|
||||
|
||||
// 考核分数:在各等级段内按名次线性铺开到对应分数段
|
||||
const groups = new Map<Grade, GradebookRow[]>()
|
||||
for (const row of assessmentOrder) {
|
||||
const g = groups.get(row.assessment_grade) ?? []
|
||||
g.push(row)
|
||||
groups.set(row.assessment_grade, g)
|
||||
}
|
||||
for (const [grade, group] of groups) {
|
||||
group.forEach((row, i) => {
|
||||
row.assessment_score = assessmentScore(i, group.length, grade)
|
||||
})
|
||||
}
|
||||
|
||||
const username = filters.username?.trim().toLowerCase() ?? ""
|
||||
if (username) rows = rows.filter((r) => r.username.toLowerCase().includes(username))
|
||||
|
||||
return {
|
||||
classname,
|
||||
classes,
|
||||
task_count: tasks.length,
|
||||
included_task_count: includedTaskCount,
|
||||
student_count: studentCount,
|
||||
coverage_threshold_count: coverageThreshold,
|
||||
tasks,
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
function csvNumber(value: number) {
|
||||
if (Number.isInteger(value)) return String(value)
|
||||
return pyRound(value, 2).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
|
||||
}
|
||||
|
||||
const taskTypeLabel = (t: string) => (t === "tutorial" ? "教程" : "挑战")
|
||||
|
||||
export function gradebookCsvRows(gb: Gradebook): (string | number)[][] {
|
||||
const rows: (string | number)[][] = [
|
||||
[
|
||||
"排名",
|
||||
"平时等级",
|
||||
"考核等级",
|
||||
"考核分数",
|
||||
"用户名",
|
||||
"班级",
|
||||
...gb.tasks.map((t) => `${taskTypeLabel(t.task_type)}${t.display}-${t.title}`),
|
||||
"教程合计",
|
||||
"挑战合计",
|
||||
"总分",
|
||||
"平均分",
|
||||
"已提交任务数",
|
||||
"未提交任务数",
|
||||
],
|
||||
]
|
||||
for (const row of gb.rows) {
|
||||
rows.push([
|
||||
row.rank,
|
||||
row.grade,
|
||||
row.assessment_grade,
|
||||
row.assessment_score,
|
||||
row.username,
|
||||
row.classname,
|
||||
...gb.tasks.map((t) => csvNumber(row.scores[t.id]!.score)),
|
||||
csvNumber(row.tutorial_total),
|
||||
csvNumber(row.challenge_total),
|
||||
csvNumber(row.total_score),
|
||||
row.average_score === null ? "" : csvNumber(row.average_score),
|
||||
row.submitted_task_count,
|
||||
row.missing_task_count,
|
||||
])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// 与 Python csv 模块默认行为一致:含逗号/引号/换行时加引号,行尾 \r\n
|
||||
export function toCsv(rows: (string | number)[][]) {
|
||||
const cell = (v: string | number) => {
|
||||
const s = String(v)
|
||||
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
||||
}
|
||||
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { config } from "../config"
|
||||
|
||||
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
|
||||
|
||||
规则:
|
||||
1. 使用一个 \`\`\`html 代码块返回所有代码
|
||||
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
||||
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
||||
4. 用中文回复,先简要说明你做了什么,然后给出代码
|
||||
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
||||
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
||||
|
||||
输出格式示例(必须严格遵守,只用一个代码块):
|
||||
|
||||
好的,我为你创建了一个点击按钮变色的示例。
|
||||
|
||||
\`\`\`html
|
||||
<style>
|
||||
button { padding: 8px 16px; }
|
||||
</style>
|
||||
|
||||
<button id="btn">点击我</button>
|
||||
|
||||
<script>
|
||||
document.getElementById('btn').onclick = function() {
|
||||
this.style.background = 'red';
|
||||
};
|
||||
</script>
|
||||
\`\`\``
|
||||
|
||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
|
||||
|
||||
判断标准——满足以下条件视为"够好":
|
||||
- 有明确主题(例如:登录页、计时器、商品卡片列表)
|
||||
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
|
||||
|
||||
判断为"不够好"的典型情况:
|
||||
- 目标太泛,如"做一个好看的页面"
|
||||
- 只有主题,完全没有任何视觉、交互或内容描述
|
||||
|
||||
规则:
|
||||
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
|
||||
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
|
||||
3. 用中文回复,语气鼓励,简洁明了
|
||||
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
|
||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||
6. 不要生成任何代码`
|
||||
|
||||
const DEFAULT_MODEL = "deepseek-v4-flash"
|
||||
const MODEL_ALIASES: Record<string, string> = {
|
||||
"deepseek-v4-flash-thinking": DEFAULT_MODEL,
|
||||
}
|
||||
const NON_THINKING_MODELS = new Set(["deepseek-v4-flash"])
|
||||
// 走火山方舟(ARK)接口的模型
|
||||
const ARK_MODELS = new Set(["doubao-seed-2-0-lite-260215"])
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
interface CompletionOptions {
|
||||
model?: string
|
||||
stream: boolean
|
||||
maxTokens?: number
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export const llmConfigured = () => !!config.llmApiKey
|
||||
|
||||
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
||||
const requested = opts.model || DEFAULT_MODEL
|
||||
const resolved = MODEL_ALIASES[requested] ?? requested
|
||||
const ark = ARK_MODELS.has(resolved)
|
||||
const body: Record<string, unknown> = {
|
||||
model: resolved,
|
||||
messages,
|
||||
stream: opts.stream,
|
||||
}
|
||||
if (opts.maxTokens) body.max_tokens = opts.maxTokens
|
||||
if (NON_THINKING_MODELS.has(resolved) && !(requested in MODEL_ALIASES)) {
|
||||
body.thinking = { type: "disabled" }
|
||||
}
|
||||
const apiKey = ark ? config.arkApiKey : config.llmApiKey
|
||||
if (!apiKey) throw new Error("未配置模型接口密钥")
|
||||
const baseUrl = (ark ? config.arkBaseUrl : config.llmBaseUrl).replace(/\/$/, "")
|
||||
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 120_000)]
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
return fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.any(signals),
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureOk(res: Response) {
|
||||
if (res.ok) return
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new Error(`Error code: ${res.status} - ${text.slice(0, 500)}`)
|
||||
}
|
||||
|
||||
// 读取 OpenAI 兼容接口的 SSE 流,逐段产出文本
|
||||
async function* streamCompletion(
|
||||
messages: ChatMessage[],
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string> {
|
||||
const res = await buildRequest(messages, { model, stream: true, signal })
|
||||
await ensureOk(res)
|
||||
if (!res.body) return
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
for await (const chunk of res.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
let idx: number
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith("data:")) continue
|
||||
const data = line.slice(5).trim()
|
||||
if (data === "[DONE]") return
|
||||
const json = JSON.parse(data)
|
||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
||||
const content = json.choices?.[0]?.delta?.content
|
||||
if (content) yield content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
|
||||
}
|
||||
|
||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
||||
return streamCompletion(
|
||||
[{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history],
|
||||
undefined,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
export async function complete(
|
||||
messages: ChatMessage[],
|
||||
opts: { model?: string; maxTokens?: number; timeoutMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const res = await buildRequest(messages, {
|
||||
model: opts.model ?? config.llmModel,
|
||||
stream: false,
|
||||
maxTokens: opts.maxTokens,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
})
|
||||
await ensureOk(res)
|
||||
const json: any = await res.json()
|
||||
return json.choices?.[0]?.message?.content ?? ""
|
||||
}
|
||||
|
||||
export interface ExtractedCode {
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
}
|
||||
|
||||
// 从 AI 回复中提取代码:支持单个 html 代码块(新)或分开的 html/css/js 代码块(旧)
|
||||
export function extractCode(text: string): ExtractedCode {
|
||||
const result: ExtractedCode = { html: null, css: null, js: null }
|
||||
const pattern = /```(html|css|js|javascript|typescript|ts|jsx|tsx)\s*\n([\s\S]*?)```/gi
|
||||
for (const [, rawLang, code] of text.matchAll(pattern)) {
|
||||
let lang = rawLang!.toLowerCase()
|
||||
if (["javascript", "typescript", "ts", "jsx", "tsx"].includes(lang)) lang = "js"
|
||||
const key = lang as keyof ExtractedCode
|
||||
if (key in result && result[key] === null) result[key] = code!.trim()
|
||||
}
|
||||
|
||||
// 单个 HTML 代码块:把 <style>/<script> 的内容拆出来,并从 html 中去掉
|
||||
if (result.html && result.css === null && result.js === null) {
|
||||
let html = result.html
|
||||
const style = /<style[^>]*>([\s\S]*?)<\/style>/i.exec(html)
|
||||
if (style) {
|
||||
result.css = style[1]!.trim()
|
||||
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
||||
}
|
||||
const script = /<script[^>]*>([\s\S]*?)<\/script>/i.exec(html)
|
||||
if (script) {
|
||||
result.js = script[1]!.trim()
|
||||
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
}
|
||||
result.html = html.trim()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseGuidanceResponse(full: string): [string, boolean] {
|
||||
if (full.startsWith("[READY]")) {
|
||||
return [full.slice("[READY]".length).replace(/^\n+/, ""), true]
|
||||
}
|
||||
return [full, false]
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { all, get, run, now, newUuidHex } from "../db"
|
||||
import { Role, type User } from "../auth"
|
||||
|
||||
export interface MessageRow {
|
||||
id: number
|
||||
conversation_id: string
|
||||
role: string
|
||||
source: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
created: string
|
||||
prompt_level: number | null
|
||||
submission_id: string | null
|
||||
}
|
||||
|
||||
// 自由创作只有创作者本人(和超管)能对话,其他任务对所有登录用户开放
|
||||
export function canAccessTask(user: User, taskId: number) {
|
||||
const row = get<{ owner_id: number }>(
|
||||
`SELECT owner_id FROM task_creation WHERE task_ptr_id = $id`,
|
||||
{ id: taskId },
|
||||
)
|
||||
if (!row) return true
|
||||
return row.owner_id === user.id || user.role === Role.SUPER
|
||||
}
|
||||
|
||||
// 当前用户在该任务下消息最多的那个对话
|
||||
export function getActiveConversation(userId: number, taskId: number) {
|
||||
return get<{ id: string }>(
|
||||
`SELECT c.id FROM prompt_conversation c
|
||||
WHERE c.user_id = $user_id AND c.task_id = $task_id
|
||||
ORDER BY (SELECT COUNT(*) FROM prompt_message m WHERE m.conversation_id = c.id) DESC,
|
||||
c.created DESC
|
||||
LIMIT 1`,
|
||||
{ user_id: userId, task_id: taskId },
|
||||
)?.id
|
||||
}
|
||||
|
||||
export function createConversation(userId: number, taskId: number, isActive = true) {
|
||||
const id = newUuidHex()
|
||||
const ts = now()
|
||||
run(
|
||||
`INSERT INTO prompt_conversation (id, created, modified, is_active, task_id, user_id)
|
||||
VALUES ($id, $ts, $ts, $active, $task_id, $user_id)`,
|
||||
{ id, ts, active: isActive, task_id: taskId, user_id: userId },
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
export function getOrCreateActiveConversation(userId: number, taskId: number) {
|
||||
return getActiveConversation(userId, taskId) ?? createConversation(userId, taskId)
|
||||
}
|
||||
|
||||
export function insertMessage(m: {
|
||||
conversation_id: string
|
||||
role: string
|
||||
content: string
|
||||
source?: string
|
||||
code_html?: string | null
|
||||
code_css?: string | null
|
||||
code_js?: string | null
|
||||
submission_id?: string | null
|
||||
}): number {
|
||||
const res = run(
|
||||
`INSERT INTO prompt_message
|
||||
(conversation_id, role, source, content, code_html, code_css, code_js, created, submission_id)
|
||||
VALUES ($conversation_id, $role, $source, $content, $code_html, $code_css, $code_js, $created, $submission_id)`,
|
||||
{
|
||||
conversation_id: m.conversation_id,
|
||||
role: m.role,
|
||||
source: m.source ?? "conversation",
|
||||
content: m.content,
|
||||
code_html: m.code_html ?? null,
|
||||
code_css: m.code_css ?? null,
|
||||
code_js: m.code_js ?? null,
|
||||
created: now(),
|
||||
submission_id: m.submission_id ?? null,
|
||||
},
|
||||
)
|
||||
return Number(res.lastInsertRowid)
|
||||
}
|
||||
|
||||
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||
return get<MessageRow>(
|
||||
`SELECT * FROM prompt_message
|
||||
WHERE conversation_id = $conv AND role = 'user'
|
||||
AND (created < $created OR (created = $created AND id < $id))
|
||||
ORDER BY created DESC, id DESC LIMIT 1`,
|
||||
{ conv: asst.conversation_id, created: asst.created, id: asst.id },
|
||||
)
|
||||
}
|
||||
|
||||
export function buildPromptRounds(source: MessageRow) {
|
||||
let messages = all<MessageRow>(
|
||||
`SELECT * FROM prompt_message WHERE conversation_id = $conv ORDER BY created, id`,
|
||||
{ conv: source.conversation_id },
|
||||
)
|
||||
let idx = messages.findIndex((m) => m.id === source.id)
|
||||
if (idx < 0) idx = messages.length - 1
|
||||
messages = messages.slice(0, idx + 1)
|
||||
|
||||
const rounds = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]!
|
||||
if (msg.role !== "user") continue
|
||||
let reply: MessageRow | undefined
|
||||
for (const r of messages.slice(i + 1)) {
|
||||
if (r.role === "user") break
|
||||
if (r.role === "assistant") {
|
||||
reply = r
|
||||
break
|
||||
}
|
||||
}
|
||||
rounds.push({
|
||||
question: msg.content,
|
||||
source: msg.source,
|
||||
prompt_level: msg.prompt_level,
|
||||
assistant_msg_id: reply?.id ?? null,
|
||||
html: reply?.code_html ?? null,
|
||||
css: reply?.code_css ?? null,
|
||||
js: reply?.code_js ?? null,
|
||||
})
|
||||
}
|
||||
return rounds
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 与 Python 的 round(x, n) 结果一致:按浮点数的精确值舍入,正好在中间时取偶数(银行家舍入)。
|
||||
* Number#toFixed 同样按精确值舍入,只是“正好在中间”时向上取,所以只需单独处理这种情况。
|
||||
* 十进制 n 位小数的精确中点必然形如 奇数 / 2^(n+1),据此判断。
|
||||
*/
|
||||
export function pyRound(x: number, ndigits = 0): number {
|
||||
const t = x * 2 ** (ndigits + 1)
|
||||
if (Number.isInteger(t) && Math.abs(t) % 2 === 1) {
|
||||
const scale = 10 ** ndigits
|
||||
const floor = Math.floor(x * scale)
|
||||
return (floor % 2 === 0 ? floor : floor + 1) / scale
|
||||
}
|
||||
return Number(x.toFixed(ndigits))
|
||||
}
|
||||
|
||||
/** 与 Python 3.12+ 的 sum() 一致:对浮点数做 Neumaier 补偿求和 */
|
||||
export function pySum(values: Iterable<number>): number {
|
||||
let sum = 0
|
||||
let c = 0
|
||||
for (const x of values) {
|
||||
const t = sum + x
|
||||
if (Math.abs(sum) >= Math.abs(x)) c += sum - t + x
|
||||
else c += x - t + sum
|
||||
sum = t
|
||||
}
|
||||
return c && Number.isFinite(c) ? sum + c : sum
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { all, get, run } from "../db"
|
||||
import { Role } from "../auth"
|
||||
import { pySum } from "./pyround"
|
||||
|
||||
const ROLE_WEIGHT: Record<string, number> = {
|
||||
[Role.SUPER]: 0.5,
|
||||
[Role.ADMIN]: 0.3,
|
||||
}
|
||||
|
||||
function zoneFor(n: number, score: number): string | null {
|
||||
if (n < 5) return "pending"
|
||||
if (score >= 4.0) return "featured"
|
||||
if (score < 3.0) return "low"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算提交的分数:按角色加权平均得到 raw_score,
|
||||
* 再用贝叶斯平均(C=3,全站平均分做先验)平滑得到 score,并更新分区。
|
||||
*/
|
||||
export function updateSubmissionScore(submissionId: string) {
|
||||
const ratings = all<{ score: number; role: string }>(
|
||||
`SELECT r.score, u.role FROM submission_rating r
|
||||
JOIN account_user u ON u.id = r.user_id
|
||||
WHERE r.submission_id = $id ORDER BY r.id`,
|
||||
{ id: submissionId },
|
||||
)
|
||||
const n = ratings.length
|
||||
let rawScore = 0
|
||||
let score = 0
|
||||
|
||||
if (n > 0) {
|
||||
const weights = ratings.map((r) => ROLE_WEIGHT[r.role] ?? 0.2)
|
||||
rawScore = pySum(ratings.map((r, i) => r.score * weights[i]!)) / pySum(weights)
|
||||
|
||||
const C = 3
|
||||
const mean = get<{ avg: number | null }>(
|
||||
`SELECT AVG(raw_score) AS avg FROM submission_submission
|
||||
WHERE raw_score > 0 AND id != $id`,
|
||||
{ id: submissionId },
|
||||
)?.avg
|
||||
const globalMean = mean || rawScore
|
||||
score = (C * globalMean + n * rawScore) / (C + n)
|
||||
}
|
||||
|
||||
run(
|
||||
`UPDATE submission_submission
|
||||
SET raw_score = $raw, score = $score, zone = $zone WHERE id = $id`,
|
||||
{ raw: rawScore, score, zone: zoneFor(n, score), id: submissionId },
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user