Compare commits
5
Commits
9f3f7187e1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489274ba0e | ||
|
|
e9127318cb | ||
|
|
5bdacc438d | ||
|
|
43c18cdb88 | ||
|
|
9fee688d95 |
No files matched your search
@@ -17,9 +17,9 @@ 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/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`
|
- `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
|
- 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`. For challenges the AI also gets the task brief (`challengeBrief`) as a checklist only (`BRIEF_RULES`): it builds just what the student asked for and asks about one missing requirement in “想一想”; creations get no brief. 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
|
- `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`
|
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`, `prompt-stats.ts` (read-only before/after comparison of AI-chat behavior: rounds, levels, external-code share, “出问题了” / reflection usage)
|
||||||
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
- Roles: `super`, `admin`, `normal`. Task model is multi-table: `task_task` + one of `task_tutorial` / `task_challenge` / `task_creation` (`task_ptr_id`)
|
||||||
|
|
||||||
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
|
- **`client/`** — Vue 3 + TypeScript SPA built with **Vite**; Bun is the package manager and runtime (no Node/npm)
|
||||||
@@ -30,7 +30,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
|
||||||
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode
|
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode
|
||||||
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
||||||
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically; `SubmitModal` first asks an optional one-line reflection (最满意的改动和理由), stored in `submission_reflection` (`ON DELETE CASCADE`, returned by `GET /submission/:id` only to staff and the author, shown as “学生说” in `ReviewPanel`). In `PromptPanel`, “出问题了” is a three-field form (做了什么 / 想看到什么 / 实际看到了什么) joined into one prompt; a short vague complaint (“还是不行”) gets a one-time hint offering it. Pasted external-AI prompts need at least 8 characters Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
||||||
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
||||||
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
|
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
|
||||||
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
|
||||||
@@ -62,6 +62,7 @@ bun run typecheck
|
|||||||
bun scripts/create-superuser.ts <username> <password>
|
bun scripts/create-superuser.ts <username> <password>
|
||||||
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
bun scripts/classify-prompts.ts [--task-id N] [--force] [--dry-run]
|
||||||
bun scripts/recalculate-scores.ts [--dry-run]
|
bun scripts/recalculate-scores.ts [--dry-run]
|
||||||
|
bun scripts/prompt-stats.ts <split-date> [--from date] [--to date] # dates in Beijing time
|
||||||
```
|
```
|
||||||
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
|
Config comes from `server/.env` (see `server/.env.example`); dev DB is `server/data/db.sqlite3`, uploads go to the repo-root `media/`.
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export const Submission = {
|
|||||||
prompt?: string
|
prompt?: string
|
||||||
},
|
},
|
||||||
messageId?: number,
|
messageId?: number,
|
||||||
|
reflection?: string,
|
||||||
) {
|
) {
|
||||||
const { prompt, ...rest } = code
|
const { prompt, ...rest } = code
|
||||||
const data = {
|
const data = {
|
||||||
@@ -271,6 +272,7 @@ export const Submission = {
|
|||||||
...rest,
|
...rest,
|
||||||
prompt: prompt || null,
|
prompt: prompt || null,
|
||||||
message_id: messageId ?? null,
|
message_id: messageId ?? null,
|
||||||
|
reflection: reflection || null,
|
||||||
}
|
}
|
||||||
const res = await http.post("/submission/", data)
|
const res = await http.post("/submission/", data)
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
@@ -77,63 +77,105 @@
|
|||||||
</div>
|
</div>
|
||||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<n-flex
|
<!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
|
||||||
class="prompt-input-row"
|
<div v-if="vagueHint" class="vague-hint">
|
||||||
align="flex-start"
|
<span>说清楚哪里不对,AI 更容易一次修好</span>
|
||||||
:wrap="false"
|
<n-flex :size="4" :wrap="false">
|
||||||
:size="8"
|
<n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
|
||||||
>
|
<n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
|
||||||
<n-input
|
</n-flex>
|
||||||
v-model:value="input"
|
</div>
|
||||||
type="textarea"
|
<div v-if="problemOpen" class="problem">
|
||||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
<div class="problem-title">出问题了?说清楚这三件事</div>
|
||||||
placeholder="描述你想要的网页效果..."
|
<label>我做了什么</label>
|
||||||
:disabled="streaming"
|
<n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
|
||||||
style="flex: 1; min-width: 0"
|
<label>想看到什么</label>
|
||||||
@keydown.enter.exact.prevent="send"
|
<n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
|
||||||
/>
|
<label>实际看到了什么</label>
|
||||||
<n-tooltip>
|
<n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
|
||||||
<template #trigger>
|
<n-flex justify="end" :size="8" style="margin-top: 10px">
|
||||||
<n-button
|
<n-button @click="problemOpen = false">取消</n-button>
|
||||||
secondary
|
<n-button
|
||||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
type="primary"
|
||||||
style="flex-shrink: 0"
|
:disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
|
||||||
@click="startGuidance"
|
@click="sendProblem"
|
||||||
>
|
>
|
||||||
<template #icon>
|
发给 AI
|
||||||
<Icon icon="lucide:lightbulb" :width="16" />
|
</n-button>
|
||||||
</template>
|
</n-flex>
|
||||||
帮我写清楚
|
</div>
|
||||||
</n-button>
|
<template v-else>
|
||||||
</template>
|
<n-flex
|
||||||
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
class="prompt-input-row"
|
||||||
</n-tooltip>
|
align="flex-start"
|
||||||
</n-flex>
|
:wrap="false"
|
||||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
:size="8"
|
||||||
<n-select
|
|
||||||
v-model:value="selectedModel"
|
|
||||||
:options="modelOptions"
|
|
||||||
style="width: 150px"
|
|
||||||
:disabled="streaming"
|
|
||||||
/>
|
|
||||||
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
|
||||||
停止
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
v-else
|
|
||||||
type="primary"
|
|
||||||
:disabled="!input.trim() || !connected"
|
|
||||||
@click="send"
|
|
||||||
>
|
>
|
||||||
发送
|
<n-input
|
||||||
</n-button>
|
v-model:value="input"
|
||||||
</n-flex>
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||||
|
placeholder="用你自己的话说说想做成什么样…"
|
||||||
|
:disabled="streaming"
|
||||||
|
style="flex: 1; min-width: 0"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
/>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||||
|
style="flex-shrink: 0"
|
||||||
|
@click="startGuidance"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:lightbulb" :width="16" />
|
||||||
|
</template>
|
||||||
|
帮我写清楚
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
AI 教练会问你几个问题,帮你把想要的效果描述清楚
|
||||||
|
</n-tooltip>
|
||||||
|
</n-flex>
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button quaternary :disabled="!hasVersion || streaming" @click="openProblem">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="lucide:bug" :width="16" />
|
||||||
|
</template>
|
||||||
|
出问题了
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
网页哪里不对?说清楚做了什么、想看到什么、实际看到了什么
|
||||||
|
</n-tooltip>
|
||||||
|
<n-flex align="center" :size="8">
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedModel"
|
||||||
|
:options="modelOptions"
|
||||||
|
style="width: 150px"
|
||||||
|
:disabled="streaming"
|
||||||
|
/>
|
||||||
|
<n-button v-if="streaming" type="error" @click="stopPrompt">
|
||||||
|
停止
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
v-else
|
||||||
|
type="primary"
|
||||||
|
:disabled="!input.trim() || !connected"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, computed, onUnmounted } from "vue"
|
import { ref, reactive, watch, nextTick, computed, onUnmounted } from "vue"
|
||||||
import { useStorage } from "@vueuse/core"
|
import { useStorage } from "@vueuse/core"
|
||||||
import { useMessage } from "naive-ui"
|
import { useMessage } from "naive-ui"
|
||||||
import { Icon } from "../../icons"
|
import { Icon } from "../../icons"
|
||||||
@@ -233,6 +275,46 @@ async function deletePair(assistantMsgId: number) {
|
|||||||
async function send() {
|
async function send() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || streaming.value) return
|
if (!text || streaming.value) return
|
||||||
|
// 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
|
||||||
|
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
|
||||||
|
vagueHint.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.value = ""
|
||||||
|
await props.beforeSend?.()
|
||||||
|
sendPrompt(text, selectedModel.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// “出问题了”:做了什么、想看到什么、实际看到了什么,拼成一句发给 AI
|
||||||
|
const VAGUE =
|
||||||
|
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
|
||||||
|
const vagueHint = ref(false)
|
||||||
|
let skipVagueCheck = false
|
||||||
|
const problemOpen = ref(false)
|
||||||
|
const problem = reactive({ did: "", want: "", saw: "" })
|
||||||
|
|
||||||
|
watch(input, () => (vagueHint.value = false))
|
||||||
|
|
||||||
|
const hasVersion = computed(() => messages.value.some((m) => m.role === "assistant" && hasCode(m)))
|
||||||
|
|
||||||
|
function openProblem() {
|
||||||
|
vagueHint.value = false
|
||||||
|
problemOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAnyway() {
|
||||||
|
skipVagueCheck = true
|
||||||
|
vagueHint.value = false
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendProblem() {
|
||||||
|
const part = (s: string) => s.trim().replace(/[。,,.!!;;]+$/, "")
|
||||||
|
const did = part(problem.did).replace(/^我/, "")
|
||||||
|
const want = part(problem.want)
|
||||||
|
const text = `我${did},${want ? `想看到${want},` : ""}但实际${part(problem.saw)}。`
|
||||||
|
Object.assign(problem, { did: "", want: "", saw: "" })
|
||||||
|
problemOpen.value = false
|
||||||
input.value = ""
|
input.value = ""
|
||||||
await props.beforeSend?.()
|
await props.beforeSend?.()
|
||||||
sendPrompt(text, selectedModel.value)
|
sendPrompt(text, selectedModel.value)
|
||||||
@@ -447,6 +529,31 @@ watch([() => messages.value.length, streamingContent], () => {
|
|||||||
animation: pulse 1.5s ease-in-out infinite;
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vague-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem label {
|
||||||
|
display: block;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.input-area {
|
.input-area {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -119,6 +119,11 @@
|
|||||||
</n-popconfirm>
|
</n-popconfirm>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="sub.reflection">
|
||||||
|
<h4>学生说</h4>
|
||||||
|
<div class="reflection">{{ sub.reflection }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<n-flex justify="space-between" align="center" class="chain-head">
|
<n-flex justify="space-between" align="center" class="chain-head">
|
||||||
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
|
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
|
||||||
@@ -479,6 +484,17 @@ h4 {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 交的时候写的反思 */
|
||||||
|
.reflection {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 3px solid var(--wp-brand);
|
||||||
|
background: var(--wp-brand-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.chain {
|
.chain {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
|
|||||||
@@ -5,12 +5,16 @@
|
|||||||
title="粘贴外部 AI 生成的代码"
|
title="粘贴外部 AI 生成的代码"
|
||||||
style="width: 640px; max-width: 94vw"
|
style="width: 640px; max-width: 94vw"
|
||||||
>
|
>
|
||||||
<n-form-item label="你在外部 AI 里用的提示词" :show-feedback="false">
|
<n-form-item
|
||||||
|
label="你在外部 AI 里说了什么"
|
||||||
|
:validation-status="promptTooShort ? 'warning' : undefined"
|
||||||
|
:feedback="promptTooShort ? `再写具体一点:你让 AI 做了什么、改了什么(至少 ${MIN_PROMPT} 个字)` : ''"
|
||||||
|
>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="prompt"
|
v-model:value="prompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 2, maxRows: 6 }"
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||||
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里"
|
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
|
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
|
||||||
@@ -30,7 +34,7 @@
|
|||||||
<n-button @click="show = false">取消</n-button>
|
<n-button @click="show = false">取消</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="!prompt.trim() || !raw.trim()"
|
:disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
@click="save"
|
@click="save"
|
||||||
>
|
>
|
||||||
@@ -41,7 +45,7 @@
|
|||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue"
|
import { computed, ref } from "vue"
|
||||||
import { splitHtml, type Code } from "../../utils/code"
|
import { splitHtml, type Code } from "../../utils/code"
|
||||||
|
|
||||||
const show = defineModel<boolean>("show", { required: true })
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
@@ -50,6 +54,12 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const prompt = ref("")
|
const prompt = ref("")
|
||||||
|
// 以前四分之一的外部提示词只有“1”“倒计时”这样几个字,看不出学生想了什么
|
||||||
|
const MIN_PROMPT = 8
|
||||||
|
const promptTooShort = computed(() => {
|
||||||
|
const n = [...prompt.value.trim()].length
|
||||||
|
return n > 0 && n < MIN_PROMPT
|
||||||
|
})
|
||||||
const raw = ref("")
|
const raw = ref("")
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
preset="card"
|
||||||
|
:title="title"
|
||||||
|
style="width: 520px; max-width: 94vw"
|
||||||
|
:mask-closable="!saving"
|
||||||
|
>
|
||||||
|
<div class="question">
|
||||||
|
{{ hasEarlier ? "比起第一版,这一版你最满意的改动是什么?为什么这样改?" : "这一版你最满意的地方是什么?为什么?" }}
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="reflection"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||||
|
:maxlength="500"
|
||||||
|
placeholder="比如:我把按钮从五个减到三个,因为按钮太多了看不出先点哪个"
|
||||||
|
/>
|
||||||
|
<n-text depth="3" class="hint">可以不写,直接交。写了的话,老师批改时能看到。</n-text>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button :disabled="saving" @click="show = false">取消</n-button>
|
||||||
|
<n-button type="primary" :loading="saving" @click="save">交</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
// 交作品前问一句反思:最满意的改动和理由。不拦着交,只是让学生回头看一眼自己改了什么
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
// 之前还有别的版本,就问“比起第一版”
|
||||||
|
hasEarlier: boolean
|
||||||
|
onSubmit: (reflection: string) => Promise<void>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const reflection = ref("")
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await props.onSubmit(reflection.value.trim())
|
||||||
|
reflection.value = ""
|
||||||
|
show.value = false
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.question {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--wp-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -103,6 +103,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
|
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
|
||||||
|
<SubmitModal
|
||||||
|
v-model:show="showSubmit"
|
||||||
|
:title="submitTitle"
|
||||||
|
:has-earlier="submitHasEarlier"
|
||||||
|
:on-submit="submitChallenge"
|
||||||
|
/>
|
||||||
<n-drawer v-model:show="showHistory" :width="420" placement="right">
|
<n-drawer v-model:show="showHistory" :width="420" placement="right">
|
||||||
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
|
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
|
||||||
<PromptHistoryPanel
|
<PromptHistoryPanel
|
||||||
@@ -128,6 +134,7 @@ import Preview from "../editor/Preview.vue"
|
|||||||
import CodeTabs from "./CodeTabs.vue"
|
import CodeTabs from "./CodeTabs.vue"
|
||||||
import VersionBar, { type VersionItem } from "./VersionBar.vue"
|
import VersionBar, { type VersionItem } from "./VersionBar.vue"
|
||||||
import PasteCodeModal from "./PasteCodeModal.vue"
|
import PasteCodeModal from "./PasteCodeModal.vue"
|
||||||
|
import SubmitModal from "./SubmitModal.vue"
|
||||||
import { Prompt, Submission } from "../../api"
|
import { Prompt, Submission } from "../../api"
|
||||||
import { useTaskCode } from "../../composables/useTaskCode"
|
import { useTaskCode } from "../../composables/useTaskCode"
|
||||||
import {
|
import {
|
||||||
@@ -174,6 +181,7 @@ const selectedId = ref<number | null>(null)
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const briefOpen = ref(true)
|
const briefOpen = ref(true)
|
||||||
const showPaste = ref(false)
|
const showPaste = ref(false)
|
||||||
|
const showSubmit = ref(false)
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
const historyKey = ref(0)
|
const historyKey = ref(0)
|
||||||
|
|
||||||
@@ -308,21 +316,46 @@ function openHistory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (props.kind === "challenge") {
|
||||||
|
showSubmit.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await props.saveWork?.({ ...code })
|
||||||
|
message.success("作品已保存")
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.detail ?? "保存失败,请重试")
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitTitle = computed(() => {
|
||||||
|
if (modified.value || !selectedId.value) return "交当前修改"
|
||||||
|
const n = versions.value.find((v) => v.id === selectedId.value)?.n
|
||||||
|
return n ? `交 v${n}` : "交这一版"
|
||||||
|
})
|
||||||
|
|
||||||
|
// 要交的这一版之前还有版本(当前修改会存成新的一版)
|
||||||
|
const submitHasEarlier = computed(() => {
|
||||||
|
if (modified.value || !selectedId.value) return versions.value.length > 0
|
||||||
|
return (versions.value.find((v) => v.id === selectedId.value)?.n ?? 1) > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 挑战:先在弹框里写一句反思(可以不写)再交;出错时抛出去,弹框不关
|
||||||
|
async function submitChallenge(reflection: string) {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
if (props.kind === "creation") {
|
|
||||||
await props.saveWork?.({ ...code })
|
|
||||||
message.success("作品已保存")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id =
|
const id =
|
||||||
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
|
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
|
||||||
await Submission.create(props.taskId, { ...code }, id)
|
await Submission.create(props.taskId, { ...code }, id, reflection)
|
||||||
markMessageSubmitted(id)
|
markMessageSubmitted(id)
|
||||||
const n = versions.value.find((v) => v.id === id)?.n
|
const n = versions.value.find((v) => v.id === id)?.n
|
||||||
message.success(n ? `已提交 v${n}` : "已提交")
|
message.success(n ? `已提交 v${n}` : "已提交")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err.response?.data?.detail ?? "提交失败,请重试")
|
message.error(err.response?.data?.detail ?? "提交失败,请重试")
|
||||||
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
Award,
|
Award,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Bug,
|
||||||
ChartNoAxesColumn,
|
ChartNoAxesColumn,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -49,6 +50,7 @@ const registry: Record<string, Component> = {
|
|||||||
"arrow-right": ArrowRight,
|
"arrow-right": ArrowRight,
|
||||||
award: Award,
|
award: Award,
|
||||||
"book-open": BookOpen,
|
"book-open": BookOpen,
|
||||||
|
bug: Bug,
|
||||||
"bar-chart-2": ChartNoAxesColumn,
|
"bar-chart-2": ChartNoAxesColumn,
|
||||||
check: Check,
|
check: Check,
|
||||||
"chevron-down": ChevronDown,
|
"chevron-down": ChevronDown,
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export interface SubmissionAll {
|
|||||||
view_count: number
|
view_count: number
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
|
// 交的时候写的反思,只有老师和作者本人拿得到
|
||||||
|
reflection?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserTag {
|
export interface UserTag {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 对比某个日期前后,学生和 AI 对话的情况(比如改了 AI 的回复方式以后有没有变化)。
|
||||||
|
* 日期按北京时间,分界日期那天算“之后”。只读,不改数据。
|
||||||
|
*
|
||||||
|
* bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]
|
||||||
|
* bun scripts/prompt-stats.ts 2026-10-28 --from 2026-09-01
|
||||||
|
*
|
||||||
|
* 层级是打标签时的标准;分类标准改过以后,先跑 classify-prompts.ts --force 重打,前后才好比。
|
||||||
|
*/
|
||||||
|
import { all, toDbTime } from "../src/db"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const opt = (name: string) => {
|
||||||
|
const i = args.indexOf(name)
|
||||||
|
return i >= 0 ? args[i + 1] : undefined
|
||||||
|
}
|
||||||
|
const split = args[0]
|
||||||
|
if (!split || !/^\d{4}-\d{2}-\d{2}$/.test(split)) {
|
||||||
|
console.log("用法:bun scripts/prompt-stats.ts <分界日期> [--from 日期] [--to 日期]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const day = (d: string) => toDbTime(new Date(`${d}T00:00:00+08:00`))
|
||||||
|
const nextDay = (d: string) => toDbTime(new Date(new Date(`${d}T00:00:00+08:00`).getTime() + 86_400_000))
|
||||||
|
const periods = [
|
||||||
|
{ name: `${opt("--from") ?? "最早"} ~ ${split} 之前`, from: opt("--from") ? day(opt("--from")!) : "", to: day(split) },
|
||||||
|
{ name: `${split} ~ ${opt("--to") ?? "现在"}`, from: day(split), to: opt("--to") ? nextDay(opt("--to")!) : "9999" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 和 PromptPanel 里一样:只说“不行”“没反应”的短抱怨;“出问题了”拼出来的句子
|
||||||
|
const VAGUE =
|
||||||
|
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
|
||||||
|
const PROBLEM = /^我.+,(想看到.+,)?但实际.+。$/s
|
||||||
|
|
||||||
|
type Msg = {
|
||||||
|
conversation_id: string
|
||||||
|
source: string
|
||||||
|
content: string
|
||||||
|
prompt_level: number | null
|
||||||
|
created: string
|
||||||
|
// 在这个对话的 AI 对话里是第几轮(只数 conversation)
|
||||||
|
round: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批处理脚本,整表扫一遍没关系
|
||||||
|
const msgs = all<Msg>(
|
||||||
|
`SELECT conversation_id, source, content, prompt_level, created,
|
||||||
|
CASE WHEN source = 'conversation'
|
||||||
|
THEN ROW_NUMBER() OVER (PARTITION BY conversation_id, source = 'conversation' ORDER BY created, id) END AS round
|
||||||
|
FROM prompt_message WHERE role = 'user'`,
|
||||||
|
)
|
||||||
|
const subs = all<{ created: string; reflected: number }>(
|
||||||
|
`SELECT s.created, r.submission_id IS NOT NULL AS reflected
|
||||||
|
FROM submission_submission s JOIN task_task t ON t.id = s.task_id
|
||||||
|
LEFT JOIN submission_reflection r ON r.submission_id = s.id
|
||||||
|
WHERE t.task_type = 'challenge'`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const pct = (n: number, d: number) => (d ? `${((n / d) * 100).toFixed(1)}%` : "—")
|
||||||
|
const chars = (s: string) => [...s.trim()].length
|
||||||
|
|
||||||
|
function stats(from: string, to: string) {
|
||||||
|
const inRange = (t: string) => t >= from && t < to
|
||||||
|
const ms = msgs.filter((m) => inRange(m.created))
|
||||||
|
const byConv = new Map<string, Msg[]>()
|
||||||
|
for (const m of ms) byConv.set(m.conversation_id, [...(byConv.get(m.conversation_id) ?? []), m])
|
||||||
|
|
||||||
|
const convs = [...byConv.values()]
|
||||||
|
const ai = convs.filter((c) => c.some((m) => m.source === "conversation"))
|
||||||
|
const manualOnly = convs.filter((c) => c.some((m) => m.source === "manual") && !c.some((m) => m.source === "conversation"))
|
||||||
|
const rounds = ai.map((c) => c.filter((m) => m.source === "conversation").length)
|
||||||
|
const bucket = (lo: number, hi: number) => rounds.filter((n) => n >= lo && n <= hi).length
|
||||||
|
|
||||||
|
const asked = ms.filter((m) => m.source === "conversation")
|
||||||
|
const levels = [1, 2, 3, 4, 5, 6].map((l) => asked.filter((m) => m.prompt_level === l).length)
|
||||||
|
const leveled = levels.reduce((a, b) => a + b, 0)
|
||||||
|
const later = asked.filter((m) => (m.round ?? 1) >= 2 && m.prompt_level)
|
||||||
|
const vague = asked.filter((m) => chars(m.content) <= 15 && VAGUE.test(m.content))
|
||||||
|
const problem = asked.filter((m) => PROBLEM.test(m.content.trim()))
|
||||||
|
const manual = ms.filter((m) => m.source === "manual")
|
||||||
|
const s = subs.filter((x) => inRange(x.created))
|
||||||
|
|
||||||
|
return [
|
||||||
|
["有对话或版本的作品", `${convs.length}`],
|
||||||
|
["只粘贴外部代码", `${manualOnly.length}(${pct(manualOnly.length, convs.length)})`],
|
||||||
|
["和平台 AI 对话的作品", `${ai.length}`],
|
||||||
|
[" 平均轮数", ai.length ? (rounds.reduce((a, b) => a + b, 0) / ai.length).toFixed(2) : "—"],
|
||||||
|
[" 1 轮", pct(bucket(1, 1), ai.length)],
|
||||||
|
[" 2–3 轮", pct(bucket(2, 3), ai.length)],
|
||||||
|
[" 4–6 轮", pct(bucket(4, 6), ai.length)],
|
||||||
|
[" 7 轮以上", pct(bucket(7, Infinity), ai.length)],
|
||||||
|
["提问(AI 对话)", `${asked.length},已分级 ${leveled}`],
|
||||||
|
...levels.map((n, i) => [` L${i + 1}`, pct(n, leveled)]),
|
||||||
|
[" L4–L6", pct(levels[3]! + levels[4]! + levels[5]!, leveled)],
|
||||||
|
[" 第 2 轮起的 L1", pct(later.filter((m) => m.prompt_level === 1).length, later.length)],
|
||||||
|
[" 只说“不行”的短抱怨", `${vague.length}(${pct(vague.length, asked.length)})`],
|
||||||
|
[" 用“出问题了”", `${problem.length}(${pct(problem.length, asked.length)})`],
|
||||||
|
["用了“帮我写清楚”的作品", `${convs.filter((c) => c.some((m) => m.source === "guidance")).length}(占和 AI 对话的 ${pct(ai.filter((c) => c.some((m) => m.source === "guidance")).length, ai.length)})`],
|
||||||
|
["外部 AI 提示词", `${manual.length},12 字以内 ${pct(manual.filter((m) => chars(m.content) <= 12).length, manual.length)}`],
|
||||||
|
["挑战提交", `${s.length},写了反思 ${pct(s.filter((x) => x.reflected).length, s.length)}`],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = periods.map((p) => stats(p.from, p.to))
|
||||||
|
// 终端里中文占两格
|
||||||
|
const cols = (k: string) => [...k].reduce((n, ch) => n + (ch.codePointAt(0)! >= 0x2e80 ? 2 : 1), 0)
|
||||||
|
const width = Math.max(...a!.map(([k]) => cols(k!))) + 2
|
||||||
|
const pad = (k: string) => k + " ".repeat(width - cols(k))
|
||||||
|
console.log(`${pad("")}${periods[0]!.name} → ${periods[1]!.name}`)
|
||||||
|
for (const [i, [k, v]] of a!.entries()) console.log(`${pad(k!)}${v} → ${b![i]![1]}`)
|
||||||
@@ -51,6 +51,9 @@ CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_mes
|
|||||||
CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level");
|
CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level");
|
||||||
CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL;
|
||||||
|
|
||||||
|
-- 学生交作品时写的一句反思(最满意的改动和理由);可以不写。删提交时跟着删
|
||||||
|
CREATE TABLE IF NOT EXISTS "submission_reflection" ("submission_id" char(32) NOT NULL PRIMARY KEY REFERENCES "submission_submission" ("id") ON DELETE CASCADE, "content" text NOT NULL, "modified" datetime NOT NULL);
|
||||||
|
|
||||||
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256
|
||||||
DROP TABLE IF EXISTS "auth_session";
|
DROP TABLE IF EXISTS "auth_session";
|
||||||
CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ router.post("/", async (c) => {
|
|||||||
js: z.string().nullable().optional(),
|
js: z.string().nullable().optional(),
|
||||||
prompt: z.string().nullable().optional(),
|
prompt: z.string().nullable().optional(),
|
||||||
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
|
message_id: z.number().int().nullable().optional(), // 关联的 assistant message
|
||||||
|
reflection: z.string().max(1000).nullable().optional(), // 交的时候写的一句反思,可以不写
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const task = orNotFound(
|
const task = orNotFound(
|
||||||
@@ -131,6 +132,8 @@ router.post("/", async (c) => {
|
|||||||
|
|
||||||
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
|
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
|
||||||
|
|
||||||
|
const reflection = p.reflection?.trim() || null
|
||||||
|
|
||||||
const { submissionId, newUserMsgId } = tx(() => {
|
const { submissionId, newUserMsgId } = tx(() => {
|
||||||
let manualAsstMsgId: number | null = null
|
let manualAsstMsgId: number | null = null
|
||||||
let linkedMsg: MessageRow | null = null
|
let linkedMsg: MessageRow | null = null
|
||||||
@@ -203,6 +206,15 @@ router.post("/", async (c) => {
|
|||||||
return { submissionId: id, newUserMsgId }
|
return { submissionId: id, newUserMsgId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 同一版再交时没写就留着上次写的
|
||||||
|
if (reflection) {
|
||||||
|
run(
|
||||||
|
`INSERT INTO submission_reflection (submission_id, content, modified) VALUES ($id, $content, $ts)
|
||||||
|
ON CONFLICT (submission_id) DO UPDATE SET content = excluded.content, modified = excluded.modified`,
|
||||||
|
{ id: submissionId, content: reflection, ts: now() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 只对新增的提示词做层级分类
|
// 只对新增的提示词做层级分类
|
||||||
if (
|
if (
|
||||||
newUserMsgId !== null &&
|
newUserMsgId !== null &&
|
||||||
@@ -540,7 +552,13 @@ router.get("/:id", (c) => {
|
|||||||
const id = uuidParam(c, "id")
|
const id = uuidParam(c, "id")
|
||||||
requireView(user, id)
|
requireView(user, id)
|
||||||
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
||||||
const out = submissionOut(s)
|
// 反思只给老师和作者本人看
|
||||||
|
const reflection =
|
||||||
|
isStaff(user) || s.user_id === user.id
|
||||||
|
? (get<{ content: string }>(`SELECT content FROM submission_reflection WHERE submission_id = $id`, { id })
|
||||||
|
?.content ?? null)
|
||||||
|
: null
|
||||||
|
const out = { ...submissionOut(s), reflection }
|
||||||
// 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名)
|
// 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名)
|
||||||
if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) {
|
if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) {
|
||||||
return c.json({ ...out, userid: null, username: null, score: null })
|
return c.json({ ...out, userid: null, username: null, score: null })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
import { complete, llmConfigured } from "./llm"
|
import { complete, llmConfigured } from "./llm"
|
||||||
|
import { briefOf } from "./prompt"
|
||||||
|
|
||||||
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
|
// 学生在这里是“指挥 AI 做网页”,看的是这句提问里学生自己想了多少,不是要 AI 做的东西有多大:
|
||||||
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
|
// 把题目要求原样贴过去,功能列得再多也只是照着做(L3)
|
||||||
@@ -24,7 +25,6 @@ const CLASSIFY_SYSTEM_PROMPT = `你是一个教育评估专家。学生在网页
|
|||||||
只返回一个数字(1-6),不要解释。`
|
只返回一个数字(1-6),不要解释。`
|
||||||
|
|
||||||
const MIN_PROMPT_CHARS = 4
|
const MIN_PROMPT_CHARS = 4
|
||||||
const BRIEF_CHARS = 800
|
|
||||||
const PROMPT_CHARS = 1500
|
const PROMPT_CHARS = 1500
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
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) {
|
function clip(text: string) {
|
||||||
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
|
return text.length > PROMPT_CHARS ? text.slice(0, PROMPT_CHARS) + "……" : text
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-27
@@ -1,18 +1,29 @@
|
|||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
|
|
||||||
export const SYSTEM_PROMPT = `你是一个网页生成助手。根据用户的需求描述,生成网页代码。
|
// AI 回复里的说明是学生唯一会读的文字,用它引导学生想下一步:
|
||||||
|
// “我替你定的”指出学生没说、AI 自己拿的主意,学生看了才会说“不要这样,因为……”;“想一想”只问不答
|
||||||
|
export const SYSTEM_PROMPT = `你是一个网页生成助手,和学生一起做网页。根据学生的描述生成网页代码。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 使用一个 \`\`\`html 代码块返回所有代码
|
1. 生成或修改网页时,使用一个 \`\`\`html 代码块返回所有代码
|
||||||
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
2. HTML 代码只需要 body 内的内容,不需要完整的 HTML 文档结构
|
||||||
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
3. CSS 样式写在 <style> 标签内,JavaScript 写在 <script> 标签内,都放在代码块里
|
||||||
4. 用中文回复,先简要说明你做了什么,然后给出代码
|
4. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
||||||
5. 在已有代码基础上修改时,返回完整的修改后代码,不要只返回片段
|
5. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
||||||
6. 由于任何外部链接都被屏蔽,使用纯 HTML、CSS 和 JS 实现功能,不要依赖外部库
|
6. 用中文回复,对学生称“你”。先写说明,再给代码。说明分下面三项,标题照抄,每项一两句话,不要写长:
|
||||||
|
- **做了什么**:这次按学生的要求做了哪些改动
|
||||||
|
- **我替你定的**:学生没说、你自己拿主意的地方,比如配色、布局、规则、数值、文字内容;列 1-3 条,只写在网页上看得到、学生可以要求改的。学生这次已经说清楚、没有需要你拿主意的地方,就省略这一项
|
||||||
|
- **想一想**:问学生一个问题,帮他想清楚下一步想要什么。问他自己的想法或判断(如“……的时候你希望它怎样?”“这两种你觉得哪个更适合……,为什么?”),不要列一串功能让他挑,也不要替他回答
|
||||||
|
7. 学生只是在问问题(为什么、是什么意思、怎么做到的),没有要求改网页时,用浅显的话直接回答,不给代码,也不用分三项
|
||||||
|
8. 学生只说“不行”“有 bug”“没反应”,没说清哪里不对时,照样尽力修好,并在“想一想”里请他下次说清楚:做了什么操作、想看到什么、实际看到了什么
|
||||||
|
|
||||||
输出格式示例(必须严格遵守,只用一个代码块):
|
输出格式示例(必须严格遵守,只用一个代码块):
|
||||||
|
|
||||||
好的,我为你创建了一个点击按钮变色的示例。
|
**做了什么**:做了一个按钮,点一下会变成红色。
|
||||||
|
|
||||||
|
**我替你定的**:按钮放在页面左上角;变色后不会再变回来。
|
||||||
|
|
||||||
|
**想一想**:再点一次的时候,你希望它变回原来的颜色,还是换成别的颜色?
|
||||||
|
|
||||||
\`\`\`html
|
\`\`\`html
|
||||||
<style>
|
<style>
|
||||||
@@ -28,23 +39,36 @@ document.getElementById('btn').onclick = function() {
|
|||||||
</script>
|
</script>
|
||||||
\`\`\``
|
\`\`\``
|
||||||
|
|
||||||
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
|
// 挑战题目要求只当对照清单:学生没说的不替他做,挑一项放进“想一想”问他打算怎么做,
|
||||||
|
// 要做成什么样还是学生自己说;整段贴了题目的照做(不拦,拦了只会去用外部 AI)
|
||||||
|
export const BRIEF_RULES = `下面是这道题的题目要求。它是对照清单,不是让你照着做——这是课堂练习,题目要求要学生自己一步步提出来,这正是这节课要练的:
|
||||||
|
- 只做学生在对话里说了的内容,做出来要能打开、能用。学生说的话里包含了题目要求(比如整段贴了题目),就照着做
|
||||||
|
- 题目要求里学生还没提到的功能、规则和细节(如计时方式、对错的颜色、计分、重来按钮),哪怕很常见、顺手就能做,也先不做;也不要照搬题目里的类名、变量名和数据
|
||||||
|
- 从学生没提到的要求里挑一项最重要的放进“想一想”,问他打算怎么做(如“题目还要求标出打错的字,你希望打错的时候是什么样子?”)
|
||||||
|
- 学生要的和题目要求冲突时,按学生说的做,在“想一想”里提醒他题目是怎么要求的
|
||||||
|
- 题目要求都做到了,“想一想”就问他自己的想法,不再提题目`
|
||||||
|
|
||||||
判断标准——满足以下条件视为"够好":
|
// 判断标准和分类器一致:看学生自己想了什么,照搬题目写得再长也不算清楚
|
||||||
- 有明确主题(例如:登录页、计时器、商品卡片列表)
|
export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练。学生在网页制作课上让 AI 生成或修改网页,你帮他把这一次要对 AI 说的话想清楚、说具体。后面会给你题目要求、他之前对 AI 说过的话和 AI 上一次的说明。
|
||||||
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
|
|
||||||
|
判断标准——看学生这一次的话,满足下面一条就算"够好":
|
||||||
|
- 第一次描述作品:除了题目要求,有他自己的想法,至少有一处具体的视觉、交互或内容是他自己定的(如配色风格、玩法规则、页面上放什么)
|
||||||
|
- 修改已有网页:说清楚要改哪里、改成什么样;或者说清楚出了什么问题——做了什么操作、想看到什么、实际看到了什么
|
||||||
|
|
||||||
判断为"不够好"的典型情况:
|
判断为"不够好"的典型情况:
|
||||||
- 目标太泛,如"做一个好看的页面"
|
- 只有作品名称,如"做一个象棋游戏"
|
||||||
- 只有主题,完全没有任何视觉、交互或内容描述
|
- 照搬或改写题目要求,没有自己的想法——哪怕写得很长、功能很多
|
||||||
|
- 只说"不行""有 bug""再好看一点",没说是哪里、要怎样
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
|
1. 如果不够好,用 1-2 个启发性问题引导学生补充,问他自己的想法,不要直接给出答案,也不要列一串功能让他挑
|
||||||
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
|
2. 照搬题目要求时,先肯定他读懂了题目,再问题目之外他想让作品有什么不一样
|
||||||
3. 用中文回复,语气鼓励,简洁明了
|
3. AI 上一次的说明里如果有“我替你定的”,可以问他同不同意这些决定
|
||||||
4. 使用 Markdown 语法高亮关键词,优先突出 **主题**、**视觉**、**交互**、**内容**、**可以生成** 等重点
|
4. 如果已经够好,以 [READY] 开头回复,简短说明好在哪里,告诉他可以生成了;他还给了理由(为什么这样改)的,特别夸一下
|
||||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
5. 用中文回复,语气鼓励,简洁明了
|
||||||
6. 不要生成任何代码`
|
6. 使用 Markdown 语法高亮关键词,优先突出 **视觉**、**交互**、**内容**、**你的想法**、**可以生成** 等重点
|
||||||
|
7. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||||
|
8. 不要生成任何代码`
|
||||||
|
|
||||||
// 走火山方舟(ARK)接口的模型
|
// 走火山方舟(ARK)接口的模型
|
||||||
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
||||||
@@ -165,14 +189,15 @@ async function* streamCompletion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发给模型的对话上下文:只留最近 20 条;除最后一条 AI 回复外,旧回复里的代码块换成占位文字。
|
// 发给模型的对话上下文:只留最近 20 条;除最后一条带代码的 AI 回复外,旧回复里的代码块换成占位文字。
|
||||||
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度
|
// 每次回复都是完整代码,模型只需要最新那份;否则对话越长 token 越多,最后会超出上下文长度。
|
||||||
|
// 只回答问题的回复没有代码,不能拿它当“最后一条”,不然最新的代码也被省略了
|
||||||
const MAX_HISTORY = 20
|
const MAX_HISTORY = 20
|
||||||
const CODE_BLOCK = /```[\s\S]*?```/g
|
const CODE_BLOCK = /```[\s\S]*?```/g
|
||||||
|
|
||||||
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
|
export function trimHistory(history: ChatMessage[]): ChatMessage[] {
|
||||||
const recent = history.slice(-MAX_HISTORY)
|
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) =>
|
return recent.map((m, i) =>
|
||||||
m.role === "assistant" && i !== last
|
m.role === "assistant" && i !== last
|
||||||
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
|
? { ...m, content: m.content.replace(CODE_BLOCK, "```\n(旧版本代码已省略)\n```") }
|
||||||
@@ -192,18 +217,19 @@ export function userFacingError(e: unknown) {
|
|||||||
return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试"
|
return status ? `AI 服务出错(${status}),请稍后重试` : "AI 服务出错,请稍后重试"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成整页代码:开着思考,质量优先
|
// 生成整页代码:开着思考,质量优先。brief 是挑战的题目要求(见 challengeBrief),自由创作没有
|
||||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal, brief?: string | null) {
|
||||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...trimHistory(history)], {
|
const system = brief ? `${SYSTEM_PROMPT}\n\n${BRIEF_RULES}\n\n${brief}` : SYSTEM_PROMPT
|
||||||
|
return streamCompletion([{ role: "system", content: system }, ...trimHistory(history)], {
|
||||||
model: chatModel(model),
|
model: chatModel(model),
|
||||||
thinking: true,
|
thinking: true,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 引导只是简短追问,不用思考,回得快
|
// 引导只是简短追问,不用思考,回得快。context 是题目和学生之前的对话(见 guidanceContext)
|
||||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
export function streamGuidance(history: ChatMessage[], context: string, signal?: AbortSignal) {
|
||||||
return streamCompletion([{ role: "system", content: GUIDANCE_SYSTEM_PROMPT }, ...history], {
|
return streamCompletion([{ role: "system", content: `${GUIDANCE_SYSTEM_PROMPT}\n\n${context}` }, ...history], {
|
||||||
thinking: false,
|
thinking: false,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -99,6 +99,62 @@ export function codeAsMarkdown(code: {
|
|||||||
return "```html\n" + parts.join("\n\n") + "\n```"
|
return "```html\n" + parts.join("\n\n") + "\n```"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 题目要求去掉图片、压成一段,太长的截掉
|
||||||
|
const BRIEF_CHARS = 800
|
||||||
|
|
||||||
|
export function briefOf(content: string, max = BRIEF_CHARS) {
|
||||||
|
const text = content
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
return clipTo(text, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipTo(text: string, n: number) {
|
||||||
|
return text.length > n ? text.slice(0, n) + "……" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挑战的题目要求,给生成网页的 AI 当对照清单(见 llm.ts 的 BRIEF_RULES);要逐条对照,不能截得太短。
|
||||||
|
// 自由创作的题目是学生自己写的介绍,不用对照,返回 null
|
||||||
|
export function challengeBrief(taskId: number) {
|
||||||
|
const task = get<{ title: string; content: string }>(
|
||||||
|
`SELECT title, content FROM task_task WHERE id = $id AND task_type = 'challenge'`,
|
||||||
|
{ id: taskId },
|
||||||
|
)
|
||||||
|
const brief = task && briefOf(task.content, 4000)
|
||||||
|
return brief ? `题目:${task.title}\n\n题目要求:${brief}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// “帮我写清楚”的教练要知道题目和学生已经做到哪了,才分得清照搬题目和自己的想法:
|
||||||
|
// 题目要求、学生最近几次对 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">) {
|
export function getPrecedingUserMessage(asst: Pick<MessageRow, "id" | "conversation_id" | "created">) {
|
||||||
return get<MessageRow>(
|
return get<MessageRow>(
|
||||||
`SELECT * FROM prompt_message
|
`SELECT * FROM prompt_message
|
||||||
|
|||||||
+20
-5
@@ -3,8 +3,10 @@ import { all, get, run, iso, uuidOut } from "./db"
|
|||||||
import { userFromAccessToken, type User } from "./auth"
|
import { userFromAccessToken, type User } from "./auth"
|
||||||
import {
|
import {
|
||||||
canAccessTask,
|
canAccessTask,
|
||||||
|
challengeBrief,
|
||||||
codeAsMarkdown,
|
codeAsMarkdown,
|
||||||
getOrCreateActiveConversation,
|
getOrCreateActiveConversation,
|
||||||
|
guidanceContext,
|
||||||
insertMessage,
|
insertMessage,
|
||||||
} from "./services/prompt"
|
} from "./services/prompt"
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
userFacingError,
|
userFacingError,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "./services/llm"
|
} from "./services/llm"
|
||||||
|
import { classifyMessage, inBackground } from "./services/classifier"
|
||||||
|
|
||||||
type Kind = "prompt" | "guidance"
|
type Kind = "prompt" | "guidance"
|
||||||
|
|
||||||
@@ -116,11 +119,12 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
const model = String(data.model ?? "")
|
const model = String(data.model ?? "")
|
||||||
if (!prompt) return
|
if (!prompt) return
|
||||||
|
|
||||||
ws.data.pendingUserMessageId = insertMessage({
|
const userMessageId = insertMessage({
|
||||||
conversation_id: ws.data.conversationId,
|
conversation_id: ws.data.conversationId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: prompt,
|
content: prompt,
|
||||||
})
|
})
|
||||||
|
ws.data.pendingUserMessageId = userMessageId
|
||||||
try {
|
try {
|
||||||
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
const history: ChatMessage[] = conversationHistory(ws.data.conversationId).map((m) => ({
|
||||||
role: m.role as ChatMessage["role"],
|
role: m.role as ChatMessage["role"],
|
||||||
@@ -129,7 +133,8 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
}))
|
}))
|
||||||
let full: string
|
let full: string
|
||||||
try {
|
try {
|
||||||
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal))
|
const brief = challengeBrief(ws.data.taskId)
|
||||||
|
full = await streamToClient(ws, streamChat(history, model, ws.data.abort.signal, brief))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 客户端自己断开导致的中止不算出错
|
// 客户端自己断开导致的中止不算出错
|
||||||
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
|
if (!ws.data.closed) send(ws, { type: "error", content: userFacingError(e) })
|
||||||
@@ -146,8 +151,12 @@ async function handlePrompt(ws: WS, data: any) {
|
|||||||
code_css: code.css,
|
code_css: code.css,
|
||||||
code_js: code.js,
|
code_js: code.js,
|
||||||
})
|
})
|
||||||
|
// 有了回复,这条提问才留下来;在后台打 L1–L6 层级
|
||||||
|
inBackground(classifyMessage(userMessageId))
|
||||||
ws.data.pendingUserMessageId = null
|
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 {
|
} finally {
|
||||||
dropPendingUserMessage(ws)
|
dropPendingUserMessage(ws)
|
||||||
}
|
}
|
||||||
@@ -157,17 +166,22 @@ async function handleGuidance(ws: WS, data: any) {
|
|||||||
const prompt = String(data.content ?? "").trim()
|
const prompt = String(data.content ?? "").trim()
|
||||||
if (!prompt) return
|
if (!prompt) return
|
||||||
|
|
||||||
ws.data.pendingUserMessageId = insertMessage({
|
const userMessageId = insertMessage({
|
||||||
conversation_id: ws.data.conversationId,
|
conversation_id: ws.data.conversationId,
|
||||||
role: "user",
|
role: "user",
|
||||||
content: prompt,
|
content: prompt,
|
||||||
source: "guidance",
|
source: "guidance",
|
||||||
})
|
})
|
||||||
|
ws.data.pendingUserMessageId = userMessageId
|
||||||
ws.data.guidanceHistory.push({ role: "user", content: prompt })
|
ws.data.guidanceHistory.push({ role: "user", content: prompt })
|
||||||
try {
|
try {
|
||||||
let full: string
|
let full: string
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
||||||
ws.data.guidanceHistory.pop()
|
ws.data.guidanceHistory.pop()
|
||||||
@@ -185,6 +199,7 @@ async function handleGuidance(ws: WS, data: any) {
|
|||||||
content: clean,
|
content: clean,
|
||||||
source: "guidance",
|
source: "guidance",
|
||||||
})
|
})
|
||||||
|
inBackground(classifyMessage(userMessageId))
|
||||||
ws.data.pendingUserMessageId = null
|
ws.data.pendingUserMessageId = null
|
||||||
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
|
send(ws, { type: "complete", message_id: messageId, is_ready: isReady })
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -276,6 +276,34 @@ describe("提交与删除", () => {
|
|||||||
expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }])
|
expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("交的时候写的反思:再交没写就保留,只有老师和作者能看,删提交时一起删", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
makeUser("mate")
|
||||||
|
makeUser("teacher", "admin")
|
||||||
|
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: "" })
|
||||||
|
const sub = await stu("POST", "/api/submission/", {
|
||||||
|
task_id: task,
|
||||||
|
html: "<p>1",
|
||||||
|
message_id: edit.json.message_id,
|
||||||
|
reflection: " 按钮改成三个,因为五个太乱 ",
|
||||||
|
})
|
||||||
|
const id = sub.json.id
|
||||||
|
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id, reflection: "" })
|
||||||
|
expect((await stu("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
|
||||||
|
expect((await (await client("teacher"))("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
|
||||||
|
|
||||||
|
const mate = await client("mate")
|
||||||
|
await mate("POST", "/api/submission/", { task_id: task, html: "<p>m" })
|
||||||
|
const peer = await mate("GET", `/api/submission/${id}`)
|
||||||
|
expect(peer.status).toBe(200)
|
||||||
|
expect(peer.json.reflection).toBeNull()
|
||||||
|
|
||||||
|
expect((await stu("DELETE", `/api/submission/${id}`)).status).toBe(200)
|
||||||
|
expect(all(`SELECT * FROM submission_reflection`)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("被评分的提交只有超管能删", async () => {
|
test("被评分的提交只有超管能删", async () => {
|
||||||
const author = makeUser("author")
|
const author = makeUser("author")
|
||||||
const other = makeUser("other")
|
const other = makeUser("other")
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ describe("streamChat", () => {
|
|||||||
expect(out).toBe("你好世界")
|
expect(out).toBe("你好世界")
|
||||||
expect(model).toBe(config.llmModel)
|
expect(model).toBe(config.llmModel)
|
||||||
expect(thinking).toEqual({ type: "enabled" })
|
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" })
|
expect(thinking).toEqual({ type: "disabled" })
|
||||||
} finally {
|
} finally {
|
||||||
Object.assign(config, saved)
|
Object.assign(config, saved)
|
||||||
@@ -140,6 +140,19 @@ test("trimHistory 只留最近 20 条,旧回复的代码换成占位", () => {
|
|||||||
expect(out.filter((m) => m.content.includes("<p>"))).toHaveLength(1)
|
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 不透出接口原始返回", () => {
|
test("userFacingError 不透出接口原始返回", () => {
|
||||||
const quiet = console.error
|
const quiet = console.error
|
||||||
console.error = () => {}
|
console.error = () => {}
|
||||||
|
|||||||
+89
-5
@@ -2,18 +2,25 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|||||||
import type { Server } from "bun"
|
import type { Server } from "bun"
|
||||||
import { app } from "../src/app"
|
import { app } from "../src/app"
|
||||||
import { config } from "../src/config"
|
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 { upgrade, websocket, type WsData } from "../src/ws"
|
||||||
import { client, freshDb, makeTask, makeUser } from "./helpers"
|
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 lastRequest: any = null
|
||||||
|
let lastClassify: any = null
|
||||||
const llm = Bun.serve({
|
const llm = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
lastRequest = await req.json()
|
const body: any = await req.json()
|
||||||
|
if (!body.stream) {
|
||||||
|
lastClassify = body
|
||||||
|
return Response.json({ choices: [{ message: { content: "3" } }] })
|
||||||
|
}
|
||||||
|
lastRequest = body
|
||||||
const enc = new TextEncoder()
|
const enc = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(ctrl) {
|
async start(ctrl) {
|
||||||
@@ -50,7 +57,10 @@ afterAll(() => {
|
|||||||
llm.stop(true)
|
llm.stop(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(freshDb)
|
beforeEach(() => {
|
||||||
|
freshDb()
|
||||||
|
PIECES = DEFAULT_PIECES
|
||||||
|
})
|
||||||
|
|
||||||
interface Conn {
|
interface Conn {
|
||||||
ws: WebSocket
|
ws: WebSocket
|
||||||
@@ -106,6 +116,19 @@ describe("WebSocket 代码生成", () => {
|
|||||||
c.ws.close()
|
c.ws.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("回复完后在后台给提问打层级", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
const level = () => all(`SELECT prompt_level FROM prompt_message WHERE role = 'user'`)[0]?.prompt_level
|
||||||
|
for (let i = 0; i < 50 && level() == null; i++) await Bun.sleep(20)
|
||||||
|
expect(level()).toBe(3)
|
||||||
|
expect(lastClassify.thinking).toEqual({ type: "disabled" })
|
||||||
|
c.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
test("AI 回复中再发消息会被拒绝", async () => {
|
test("AI 回复中再发消息会被拒绝", async () => {
|
||||||
const { task, token } = await setup()
|
const { task, token } = await setup()
|
||||||
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
@@ -157,9 +180,70 @@ describe("WebSocket 代码生成", () => {
|
|||||||
c.ws.close()
|
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("挑战的题目要求作为对照清单发给 AI,自由创作不发", async () => {
|
||||||
|
const { task, token } = await setup()
|
||||||
|
run(`UPDATE task_task SET content = $c WHERE id = $id`, { c: "做一个计时器,有开始和暂停按钮", id: task })
|
||||||
|
const c = (await connect(`/ws/prompt/${task}/?token=${token}`))!
|
||||||
|
await c.next("init")
|
||||||
|
c.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await c.next("complete")
|
||||||
|
expect(lastRequest.messages[0].content).toContain("它是对照清单")
|
||||||
|
expect(lastRequest.messages[0].content).toContain("题目要求:做一个计时器,有开始和暂停按钮")
|
||||||
|
c.ws.close()
|
||||||
|
|
||||||
|
const creation = makeTask("我的作品", "creation", 2)
|
||||||
|
run(`UPDATE task_task SET content = '我想做一个相册' WHERE id = $id`, { id: creation })
|
||||||
|
const d = (await connect(`/ws/prompt/${creation}/?token=${token}`))!
|
||||||
|
await d.next("init")
|
||||||
|
d.ws.send(JSON.stringify({ type: "message", content: "做个页面" }))
|
||||||
|
await d.next("complete")
|
||||||
|
expect(lastRequest.messages[0].content).not.toContain("题目要求")
|
||||||
|
d.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
test("token 无效或任务不存在时握手失败", async () => {
|
test("token 无效或任务不存在时握手失败", async () => {
|
||||||
const { task, token } = await setup()
|
const { task, token } = await setup()
|
||||||
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
expect(await connect(`/ws/prompt/${task}/?token=bad`)).toBeNull()
|
||||||
expect(await connect(`/ws/prompt/9999/?token=${token}`)).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