fix: 修复中危问题:注册接口、模型名、LLM 超时、WebSocket 断线、未公开任务泄露等
服务端 - 删掉没人用的 /account/register,外网不能再批量注册小号 - 模型名只放行豆包和配置的 LLM_MODEL,浏览器里存的旧模型名换成默认模型; 聊天默认模型改读 LLM_MODEL - 流式生成改成 120 秒无数据才超时(另有 15 分钟总上限),思考模型不会被中途掐断; SSE 最后一行没有换行时不再丢内容 - WebSocket:token 无效 401、任务不存在 404,建对话失败时关闭连接; 引导出错时把没有回复的提问移出上下文 - 未公开的教程只有超管能看,未公开的挑战和素材管理员也能看,其他人 404 - Caddy:/media 加 CSP sandbox 和 nosniff,上传的 html/svg 直接打开不能跑脚本; 加 CORS,预览 iframe 里的代码可以读取字体和 fetch 素材 前端 - 新增 ReconnectingSocket:意外断开后退避重连,握手失败先刷新 token; 断线时清掉“生成中”状态,旧连接的事件不再改写新连接 - marked 扩展抽到 utils/markdown.ts:教程用独立实例,不再改全局 marked; 学生内容的渲染保留高亮,不带会渲染原始 HTML 的 preview 扩展 - 挑战页、创作页加载完成前离开时不再建立连接,离开时关闭引导弹窗 - 教程、提交列表、用户管理跟随浏览器前进后退;快速翻页只用最后一次结果, 改筛选条件不再请求两次 - 随机评分弹窗在 AI 回复完之后不再弹出 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
73788c4595
commit
cdeb7c18ef
22 files changed
+489
-210
No files matched your search
@@ -8,6 +8,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle_path /media/* {
|
handle_path /media/* {
|
||||||
|
# 上传的 html/svg 直接打开时不能当成本站页面跑脚本(会读到 localStorage 里的 token);
|
||||||
|
# 作为图片、样式、脚本引用时不受 sandbox 影响。预览 iframe 没有 allow-same-origin,
|
||||||
|
# 读字体或 fetch() 素材要跨域许可
|
||||||
|
header {
|
||||||
|
Content-Security-Policy "sandbox"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
Access-Control-Allow-Origin "*"
|
||||||
|
}
|
||||||
root * /media
|
root * /media
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ const modelOptions = [
|
|||||||
{ label: "DeepSeek", value: "deepseek-flash" },
|
{ label: "DeepSeek", value: "deepseek-flash" },
|
||||||
]
|
]
|
||||||
const selectedModel = useStorage("prompt-model", "deepseek-flash")
|
const selectedModel = useStorage("prompt-model", "deepseek-flash")
|
||||||
|
// 浏览器里可能还存着改版前的模型名
|
||||||
|
if (!modelOptions.some((o) => o.value === selectedModel.value)) {
|
||||||
|
selectedModel.value = "deepseek-flash"
|
||||||
|
}
|
||||||
|
|
||||||
// Group messages into user+assistant pairs
|
// Group messages into user+assistant pairs
|
||||||
const submittingId = ref<number | null>(null)
|
const submittingId = ref<number | null>(null)
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ function clearReshowTimer() {
|
|||||||
async function fetchAndShow() {
|
async function fetchAndShow() {
|
||||||
try {
|
try {
|
||||||
const item = await Submission.randomForRating(current.value?.submission_id)
|
const item = await Submission.randomForRating(current.value?.submission_id)
|
||||||
|
// 请求期间 AI 已经回复完了,就不要再弹出来
|
||||||
|
if (!streaming.value) return
|
||||||
if (item) {
|
if (item) {
|
||||||
current.value = item
|
current.value = item
|
||||||
previewContent.value = buildPreviewDocument({
|
previewContent.value = buildPreviewDocument({
|
||||||
|
|||||||
@@ -170,7 +170,11 @@ function syncRoute(routeName: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
syncRoute(route.name as string)
|
syncRoute(route.name as string)
|
||||||
watch(() => route.name as string, syncRoute)
|
// 只换了 display(比如浏览器后退到上一节教程)也要同步
|
||||||
|
watch(
|
||||||
|
() => [route.name as string, route.params.display] as const,
|
||||||
|
([name]) => syncRoute(name),
|
||||||
|
)
|
||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
import { onMounted, ref, useTemplateRef, watch } from "vue"
|
||||||
import { marked } from "marked"
|
import { Marked } from "marked"
|
||||||
|
import { trustedExtensions } from "../../utils/markdown"
|
||||||
import copyFn from "copy-text-to-clipboard"
|
import copyFn from "copy-text-to-clipboard"
|
||||||
import { css, html, js, tab } from "../../store/editors"
|
import { css, html, js, tab } from "../../store/editors"
|
||||||
import { Tutorial } from "../../api"
|
import { Tutorial } from "../../api"
|
||||||
@@ -11,7 +12,8 @@ import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
|||||||
import { taskId, assetBaseUrl } from "../../store/task"
|
import { taskId, assetBaseUrl } from "../../store/task"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
marked.use({
|
// 独立实例:带“复制/替换”按钮的代码块只用在教程里,不能改全局的 marked
|
||||||
|
const tutorialMarked = new Marked(...trustedExtensions, {
|
||||||
renderer: {
|
renderer: {
|
||||||
code({ text, lang }) {
|
code({ text, lang }) {
|
||||||
const language = lang?.toLowerCase() ?? "html"
|
const language = lang?.toLowerCase() ?? "html"
|
||||||
@@ -36,15 +38,22 @@ const router = useRouter()
|
|||||||
const content = ref("")
|
const content = ref("")
|
||||||
const $content = useTemplateRef<any>("$content")
|
const $content = useTemplateRef<any>("$content")
|
||||||
|
|
||||||
|
// 连点“下一节”时只用最后一次请求的结果
|
||||||
|
let renderSeq = 0
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
|
const seq = ++renderSeq
|
||||||
const data = await Tutorial.get(step.value)
|
const data = await Tutorial.get(step.value)
|
||||||
|
if (seq !== renderSeq) return
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||||
html.value = data.example_html ?? ""
|
html.value = data.example_html ?? ""
|
||||||
css.value = data.example_css ?? ""
|
css.value = data.example_css ?? ""
|
||||||
js.value = data.example_js ?? ""
|
js.value = data.example_js ?? ""
|
||||||
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
const merged = `# ${data.display}. ${data.title}\n${data.content}`
|
||||||
content.value = await marked.parse(merged, { async: true })
|
const rendered = await tutorialMarked.parse(merged, { async: true })
|
||||||
|
if (seq !== renderSeq) return
|
||||||
|
content.value = rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
function flash(btn: HTMLButtonElement, done: string, original: string) {
|
function flash(btn: HTMLButtonElement, done: string, original: string) {
|
||||||
|
|||||||
+2
-32
@@ -8,42 +8,12 @@ import { addAPIProvider } from "@iconify/vue"
|
|||||||
import "github-markdown-css/github-markdown-light.css"
|
import "github-markdown-css/github-markdown-light.css"
|
||||||
|
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import alert from "marked-alert"
|
import { trustedExtensions } from "./utils/markdown"
|
||||||
import { markedHighlight } from "marked-highlight"
|
|
||||||
import preview from "marked-code-preview"
|
|
||||||
import { alertVariants } from "./utils/const"
|
|
||||||
|
|
||||||
import hljs from "highlight.js/lib/core"
|
|
||||||
import xml from "highlight.js/lib/languages/xml"
|
|
||||||
import css from "highlight.js/lib/languages/css"
|
|
||||||
import javascript from "highlight.js/lib/languages/javascript"
|
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext"
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "highlight.js/styles/github.min.css"
|
import "highlight.js/styles/github.min.css"
|
||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
|
|
||||||
hljs.registerLanguage("html", xml)
|
marked.use(...trustedExtensions)
|
||||||
hljs.registerLanguage("css", css)
|
|
||||||
hljs.registerLanguage("js", javascript)
|
|
||||||
hljs.registerLanguage("javascript", javascript)
|
|
||||||
hljs.registerLanguage("plaintext", plaintext)
|
|
||||||
|
|
||||||
marked.use({
|
|
||||||
gfm: true,
|
|
||||||
})
|
|
||||||
marked.use(
|
|
||||||
markedHighlight({
|
|
||||||
langPrefix: "hljs language-",
|
|
||||||
highlight(code, lang) {
|
|
||||||
const language = hljs.getLanguage(lang) ? lang : "plaintext"
|
|
||||||
return hljs.highlight(code, { language }).value
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
marked.use(alert({ variants: alertVariants }))
|
|
||||||
|
|
||||||
const template = `<div class="markedown-body-preview">{preview}</div>`
|
|
||||||
marked.use(preview({ template }))
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const naive = create()
|
const naive = create()
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ import {
|
|||||||
removeMessagePair,
|
removeMessagePair,
|
||||||
markMessageSubmitted,
|
markMessageSubmitted,
|
||||||
} from "../store/prompt"
|
} from "../store/prompt"
|
||||||
|
import { closeGuidance } from "../store/guidance"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -221,6 +222,7 @@ async function loadChallenge() {
|
|||||||
Challenge.get(display),
|
Challenge.get(display),
|
||||||
TaskAssets.listChallenge(display),
|
TaskAssets.listChallenge(display),
|
||||||
])
|
])
|
||||||
|
if (disposed) return
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
challengeAuthor.value = data.author_name ?? ""
|
challengeAuthor.value = data.author_name ?? ""
|
||||||
if (data.example_html || data.example_css || data.example_js) {
|
if (data.example_html || data.example_css || data.example_js) {
|
||||||
@@ -236,6 +238,7 @@ async function loadChallenge() {
|
|||||||
async: true,
|
async: true,
|
||||||
renderer: challengeRenderer,
|
renderer: challengeRenderer,
|
||||||
} as MarkedOptions)
|
} as MarkedOptions)
|
||||||
|
if (disposed) return
|
||||||
assets.value = fetchedAssets
|
assets.value = fetchedAssets
|
||||||
if (!authed.value) return
|
if (!authed.value) return
|
||||||
connectPrompt(data.task_ptr)
|
connectPrompt(data.task_ptr)
|
||||||
@@ -291,11 +294,18 @@ function back() {
|
|||||||
router.push({ name: "home-challenge-list" })
|
router.push({ name: "home-challenge-list" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载还没完成就离开页面时,不要再建立连接
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupCodeCopy()
|
setupCodeCopy()
|
||||||
loadChallenge()
|
loadChallenge()
|
||||||
})
|
})
|
||||||
onUnmounted(disconnectPrompt)
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
disconnectPrompt()
|
||||||
|
closeGuidance()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
setOnCodeComplete,
|
setOnCodeComplete,
|
||||||
streaming,
|
streaming,
|
||||||
} from "../store/prompt"
|
} from "../store/prompt"
|
||||||
|
import { closeGuidance } from "../store/guidance"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -146,6 +147,7 @@ watch(streaming, (val) => {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const data = await Creation.get(display)
|
const data = await Creation.get(display)
|
||||||
|
if (disposed) return
|
||||||
taskId.value = data.task_id
|
taskId.value = data.task_id
|
||||||
title.value = data.title
|
title.value = data.title
|
||||||
content.value = data.content
|
content.value = data.content
|
||||||
@@ -212,8 +214,15 @@ function back() {
|
|||||||
router.push({ name: "creation-list" })
|
router.push({ name: "creation-list" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载还没完成就离开页面时,不要再建立连接
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
onUnmounted(disconnectPrompt)
|
onUnmounted(() => {
|
||||||
|
disposed = true
|
||||||
|
disconnectPrompt()
|
||||||
|
closeGuidance()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -341,16 +341,25 @@ function rowClassName(row: SubmissionOut) {
|
|||||||
return submission.value.id === row.id ? "row-active" : ""
|
return submission.value.id === row.id ? "row-active" : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速翻页时只用最后一次请求的结果
|
||||||
|
let initSeq = 0
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
const seq = ++initSeq
|
||||||
expandedKeys.value = []
|
expandedKeys.value = []
|
||||||
expandedData.clear()
|
expandedData.clear()
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list(query)
|
||||||
|
if (seq !== initSeq) return
|
||||||
data.value = res.items
|
data.value = res.items
|
||||||
count.value = res.count
|
count.value = res.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let detailSeq = 0
|
||||||
|
|
||||||
async function getSubmissionByID(id: string) {
|
async function getSubmissionByID(id: string) {
|
||||||
submission.value = await Submission.get(id)
|
const seq = ++detailSeq
|
||||||
|
const res = await Submission.get(id)
|
||||||
|
if (seq === detailSeq) submission.value = res
|
||||||
}
|
}
|
||||||
|
|
||||||
function afterScore() {
|
function afterScore() {
|
||||||
@@ -374,26 +383,34 @@ watch(
|
|||||||
router.push({ params: { page: v } })
|
router.push({ params: { page: v } })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||||
|
watch(
|
||||||
|
() => route.params.page,
|
||||||
|
(v) => {
|
||||||
|
const page = Number(v) || 1
|
||||||
|
if (page !== query.page) query.page = page
|
||||||
|
},
|
||||||
|
)
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
() => query.username,
|
() => query.username,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page !== 1) query.page = 1
|
||||||
init()
|
else init()
|
||||||
},
|
},
|
||||||
{ debounce: 500, maxWait: 1000 },
|
{ debounce: 500, maxWait: 1000 },
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => query.flag,
|
() => query.flag,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page !== 1) query.page = 1
|
||||||
init()
|
else init()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watch(
|
watch(
|
||||||
() => query.zone,
|
() => query.zone,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page !== 1) query.page = 1
|
||||||
init()
|
else init()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -151,8 +151,13 @@ async function batchCreateUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 快速翻页时只用最后一次请求的结果
|
||||||
|
let initSeq = 0
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
const seq = ++initSeq
|
||||||
const data = await Account.list(query)
|
const data = await Account.list(query)
|
||||||
|
if (seq !== initSeq) return
|
||||||
users.value = data.items
|
users.value = data.items
|
||||||
count.value = data.count
|
count.value = data.count
|
||||||
}
|
}
|
||||||
@@ -160,15 +165,15 @@ async function init() {
|
|||||||
watch(
|
watch(
|
||||||
() => query.role,
|
() => query.role,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page !== 1) query.page = 1
|
||||||
init()
|
else init()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watchDebounced(
|
watchDebounced(
|
||||||
() => query.username,
|
() => query.username,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page !== 1) query.page = 1
|
||||||
init()
|
else init()
|
||||||
},
|
},
|
||||||
{ debounce: 500, maxWait: 1000 },
|
{ debounce: 500, maxWait: 1000 },
|
||||||
)
|
)
|
||||||
@@ -179,6 +184,14 @@ watch(
|
|||||||
router.push({ params: { page: v } })
|
router.push({ params: { page: v } })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||||
|
watch(
|
||||||
|
() => route.params.page,
|
||||||
|
(v) => {
|
||||||
|
const page = Number(v) || 1
|
||||||
|
if (page !== query.page) query.page = page
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { wsUrl } from "../token"
|
import { ReconnectingSocket } from "../utils/socket"
|
||||||
|
|
||||||
export interface GuidanceMessage {
|
export interface GuidanceMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
@@ -15,31 +15,19 @@ export const isReady = ref(false)
|
|||||||
export const isOpen = ref(false)
|
export const isOpen = ref(false)
|
||||||
export const initialPrompt = ref("")
|
export const initialPrompt = ref("")
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
|
||||||
let _taskId = 0
|
let _taskId = 0
|
||||||
// 取 token 要等一下,期间又重连或关闭时作废这次连接
|
// 打开弹窗时带过来的提示词,连上后发一次;重连时不再重发
|
||||||
let connectSeq = 0
|
let unsentPrompt = ""
|
||||||
|
|
||||||
async function connect(taskId: number) {
|
const socket = new ReconnectingSocket({
|
||||||
if (ws) ws.close()
|
onOpen() {
|
||||||
ws = null
|
|
||||||
const seq = ++connectSeq
|
|
||||||
const url = await wsUrl(`/ws/guidance/${taskId}/`)
|
|
||||||
if (seq !== connectSeq) return
|
|
||||||
ws = new WebSocket(url)
|
|
||||||
setupHandlers(ws)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupHandlers(socket: WebSocket) {
|
|
||||||
socket.onopen = () => {
|
|
||||||
connected.value = true
|
connected.value = true
|
||||||
if (initialPrompt.value) {
|
if (unsentPrompt) {
|
||||||
sendToSocket(initialPrompt.value)
|
sendToSocket(unsentPrompt)
|
||||||
|
unsentPrompt = ""
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onMessage(data) {
|
||||||
socket.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
if (data.type === "init") {
|
if (data.type === "init") {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
@@ -57,18 +45,38 @@ function setupHandlers(socket: WebSocket) {
|
|||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
messages.value.push({ role: "assistant", content: data.content })
|
messages.value.push({ role: "assistant", content: data.content })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onDrop() {
|
||||||
socket.onclose = () => {
|
|
||||||
connected.value = false
|
connected.value = false
|
||||||
}
|
if (streaming.value) dropPendingMessage()
|
||||||
|
},
|
||||||
|
onGiveUp() {
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: "与服务器的连接已断开,请刷新页面重试",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function connect(taskId: number) {
|
||||||
|
socket.connect(`/ws/guidance/${taskId}/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendToSocket(content: string) {
|
function sendToSocket(content: string) {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
if (!socket.send({ type: "message", content })) return
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
messages.value.push({ role: "user", content })
|
messages.value.push({ role: "user", content })
|
||||||
ws.send(JSON.stringify({ type: "message", content }))
|
}
|
||||||
|
|
||||||
|
function dropPendingMessage() {
|
||||||
|
if (
|
||||||
|
messages.value.length > 0 &&
|
||||||
|
messages.value[messages.value.length - 1].role === "user"
|
||||||
|
) {
|
||||||
|
messages.value.pop()
|
||||||
|
}
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openGuidance(taskId: number, prompt: string) {
|
export function openGuidance(taskId: number, prompt: string) {
|
||||||
@@ -76,6 +84,7 @@ export function openGuidance(taskId: number, prompt: string) {
|
|||||||
|
|
||||||
_taskId = taskId
|
_taskId = taskId
|
||||||
initialPrompt.value = prompt
|
initialPrompt.value = prompt
|
||||||
|
unsentPrompt = prompt
|
||||||
messages.value = []
|
messages.value = []
|
||||||
isReady.value = false
|
isReady.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
@@ -89,26 +98,17 @@ export function sendGuidance(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stopGuidance() {
|
export function stopGuidance() {
|
||||||
if (
|
dropPendingMessage()
|
||||||
messages.value.length > 0 &&
|
|
||||||
messages.value[messages.value.length - 1].role === "user"
|
|
||||||
) {
|
|
||||||
messages.value.pop()
|
|
||||||
}
|
|
||||||
streaming.value = false
|
|
||||||
streamingContent.value = ""
|
|
||||||
if (_taskId) {
|
if (_taskId) {
|
||||||
initialPrompt.value = ""
|
initialPrompt.value = ""
|
||||||
|
unsentPrompt = ""
|
||||||
connect(_taskId)
|
connect(_taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeGuidance() {
|
export function closeGuidance() {
|
||||||
connectSeq++
|
socket.close()
|
||||||
if (ws) {
|
unsentPrompt = ""
|
||||||
ws.close()
|
|
||||||
ws = null
|
|
||||||
}
|
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
messages.value = []
|
messages.value = []
|
||||||
isReady.value = false
|
isReady.value = false
|
||||||
|
|||||||
+70
-73
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { wsUrl } from "../token"
|
import { ReconnectingSocket } from "../utils/socket"
|
||||||
import { html, css, js } from "./editors"
|
import { html, css, js } from "./editors"
|
||||||
|
|
||||||
export interface PromptMessage {
|
export interface PromptMessage {
|
||||||
@@ -28,81 +28,75 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
|||||||
_onCodeComplete = fn
|
_onCodeComplete = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
function onMessage(data: any) {
|
||||||
// 取 token 要等一下,期间又连了别的题或断开时作废这次连接
|
if (data.type === "init") {
|
||||||
let connectSeq = 0
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
export async function connectPrompt(taskId: number) {
|
const alreadyLoaded = conversationId.value === data.conversation_id
|
||||||
currentTaskId.value = taskId
|
conversationId.value = data.conversation_id
|
||||||
if (ws) ws.close()
|
if (!alreadyLoaded) {
|
||||||
ws = null
|
messages.value = data.messages || []
|
||||||
|
const lastAssistant = [...messages.value]
|
||||||
const seq = ++connectSeq
|
.reverse()
|
||||||
const url = await wsUrl(`/ws/prompt/${taskId}/`)
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
if (seq !== connectSeq) return
|
if (lastAssistant?.code) {
|
||||||
ws = new WebSocket(url)
|
applyCode(lastAssistant.code)
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
connected.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
|
|
||||||
if (data.type === "init") {
|
|
||||||
streaming.value = false
|
|
||||||
streamingContent.value = ""
|
|
||||||
const alreadyLoaded = conversationId.value === data.conversation_id
|
|
||||||
conversationId.value = data.conversation_id
|
|
||||||
if (!alreadyLoaded) {
|
|
||||||
messages.value = data.messages || []
|
|
||||||
const lastAssistant = [...messages.value]
|
|
||||||
.reverse()
|
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
|
||||||
if (lastAssistant?.code) {
|
|
||||||
applyCode(lastAssistant.code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (data.type === "stream") {
|
|
||||||
streaming.value = true
|
|
||||||
streamingContent.value += data.content
|
|
||||||
} else if (data.type === "complete") {
|
|
||||||
streaming.value = false
|
|
||||||
messages.value.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: streamingContent.value,
|
|
||||||
id: data.message_id,
|
|
||||||
code: data.code,
|
|
||||||
submitted: false,
|
|
||||||
})
|
|
||||||
streamingContent.value = ""
|
|
||||||
if (data.code) {
|
|
||||||
applyCode(data.code)
|
|
||||||
if (_onCodeComplete) {
|
|
||||||
_onCodeComplete(data.code, data.message_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (data.type === "error") {
|
|
||||||
streaming.value = false
|
|
||||||
streamingContent.value = ""
|
|
||||||
messages.value.push({
|
|
||||||
role: "assistant",
|
|
||||||
content: data.content,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
} else if (data.type === "stream") {
|
||||||
|
streaming.value = true
|
||||||
ws.onclose = () => {
|
streamingContent.value += data.content
|
||||||
connected.value = false
|
} else if (data.type === "complete") {
|
||||||
|
streaming.value = false
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: streamingContent.value,
|
||||||
|
id: data.message_id,
|
||||||
|
code: data.code,
|
||||||
|
submitted: false,
|
||||||
|
})
|
||||||
|
streamingContent.value = ""
|
||||||
|
if (data.code) {
|
||||||
|
applyCode(data.code)
|
||||||
|
if (_onCodeComplete) {
|
||||||
|
_onCodeComplete(data.code, data.message_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (data.type === "error") {
|
||||||
|
streaming.value = false
|
||||||
|
streamingContent.value = ""
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: data.content,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const socket = new ReconnectingSocket({
|
||||||
|
onOpen() {
|
||||||
|
connected.value = true
|
||||||
|
},
|
||||||
|
onMessage,
|
||||||
|
onDrop() {
|
||||||
|
connected.value = false
|
||||||
|
// 断开时服务端会删掉还没有回复的提问,这边也去掉
|
||||||
|
if (streaming.value) dropPendingPrompt()
|
||||||
|
},
|
||||||
|
onGiveUp() {
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: "与服务器的连接已断开,请刷新页面重试",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function connectPrompt(taskId: number) {
|
||||||
|
currentTaskId.value = taskId
|
||||||
|
socket.connect(`/ws/prompt/${taskId}/`)
|
||||||
|
}
|
||||||
|
|
||||||
export function disconnectPrompt() {
|
export function disconnectPrompt() {
|
||||||
connectSeq++
|
socket.close()
|
||||||
if (ws) {
|
|
||||||
ws.close()
|
|
||||||
ws = null
|
|
||||||
}
|
|
||||||
messages.value = []
|
messages.value = []
|
||||||
conversationId.value = ""
|
conversationId.value = ""
|
||||||
connected.value = false
|
connected.value = false
|
||||||
@@ -113,13 +107,12 @@ export function disconnectPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function sendPrompt(content: string, model: string = "") {
|
export function sendPrompt(content: string, model: string = "") {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
if (!socket.send({ type: "message", content, model })) return
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
messages.value.push({ role: "user", content })
|
messages.value.push({ role: "user", content })
|
||||||
ws.send(JSON.stringify({ type: "message", content, model }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopPrompt() {
|
function dropPendingPrompt() {
|
||||||
if (
|
if (
|
||||||
messages.value.length > 0 &&
|
messages.value.length > 0 &&
|
||||||
messages.value[messages.value.length - 1].role === "user"
|
messages.value[messages.value.length - 1].role === "user"
|
||||||
@@ -128,6 +121,10 @@ export function stopPrompt() {
|
|||||||
}
|
}
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
streamingContent.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopPrompt() {
|
||||||
|
dropPendingPrompt()
|
||||||
if (currentTaskId.value) {
|
if (currentTaskId.value) {
|
||||||
connectPrompt(currentTaskId.value)
|
connectPrompt(currentTaskId.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,36 @@
|
|||||||
import { Marked } from "marked"
|
import { Marked, type MarkedExtension } from "marked"
|
||||||
|
import alert from "marked-alert"
|
||||||
|
import { markedHighlight } from "marked-highlight"
|
||||||
|
import preview from "marked-code-preview"
|
||||||
|
import hljs from "highlight.js/lib/core"
|
||||||
|
import xml from "highlight.js/lib/languages/xml"
|
||||||
|
import css from "highlight.js/lib/languages/css"
|
||||||
|
import javascript from "highlight.js/lib/languages/javascript"
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext"
|
||||||
|
import { alertVariants } from "./const"
|
||||||
|
|
||||||
|
hljs.registerLanguage("html", xml)
|
||||||
|
hljs.registerLanguage("css", css)
|
||||||
|
hljs.registerLanguage("js", javascript)
|
||||||
|
hljs.registerLanguage("javascript", javascript)
|
||||||
|
hljs.registerLanguage("plaintext", plaintext)
|
||||||
|
|
||||||
|
const highlight = markedHighlight({
|
||||||
|
langPrefix: "hljs language-",
|
||||||
|
highlight(code, lang) {
|
||||||
|
const language = hljs.getLanguage(lang) ? lang : "plaintext"
|
||||||
|
return hljs.highlight(code, { language }).value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const alerts = alert({ variants: alertVariants })
|
||||||
|
|
||||||
|
// 老师写的教程、挑战描述用:带 preview 的代码块会渲染成真实的 HTML
|
||||||
|
export const trustedExtensions: MarkedExtension[] = [
|
||||||
|
{ gfm: true },
|
||||||
|
highlight,
|
||||||
|
alerts,
|
||||||
|
preview({ template: `<div class="markedown-body-preview">{preview}</div>` }),
|
||||||
|
]
|
||||||
|
|
||||||
// 提示词和 AI 回复是用户可控的内容,渲染进 v-html 前必须去掉可执行的部分:
|
// 提示词和 AI 回复是用户可控的内容,渲染进 v-html 前必须去掉可执行的部分:
|
||||||
// 原始 HTML 一律转义成文本,链接和图片只放行 http(s)/mailto 和相对地址
|
// 原始 HTML 一律转义成文本,链接和图片只放行 http(s)/mailto 和相对地址
|
||||||
@@ -12,7 +44,8 @@ const escapeHtml = (s: string) =>
|
|||||||
const isSafeUrl = (href: string) =>
|
const isSafeUrl = (href: string) =>
|
||||||
!/^\s*[a-z][a-z0-9+.-]*:/i.test(href) || /^\s*(https?|mailto):/i.test(href)
|
!/^\s*[a-z][a-z0-9+.-]*:/i.test(href) || /^\s*(https?|mailto):/i.test(href)
|
||||||
|
|
||||||
const safeMarked = new Marked({
|
// 独立实例,不带 preview 扩展(它会把代码块原样当 HTML 渲染)
|
||||||
|
const safeMarked = new Marked({ gfm: true }, highlight, alerts, {
|
||||||
renderer: {
|
renderer: {
|
||||||
html({ text }) {
|
html({ text }) {
|
||||||
return escapeHtml(text)
|
return escapeHtml(text)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { getRefreshToken, refreshAccessToken, wsUrl } from "../token"
|
||||||
|
|
||||||
|
const MAX_DELAY_MS = 30_000
|
||||||
|
// 连续这么多次没连上就放弃,让用户刷新页面
|
||||||
|
const MAX_FAILURES = 6
|
||||||
|
|
||||||
|
interface Handlers {
|
||||||
|
onOpen?: () => void
|
||||||
|
onMessage: (data: any) => void
|
||||||
|
// 非主动断开(服务器重启、网络断了),之后会自动重连
|
||||||
|
onDrop?: () => void
|
||||||
|
onGiveUp?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带断线重连的 WebSocket:非主动关闭时按 1s、2s、4s… 退避重连。
|
||||||
|
// 握手就失败多半是 access token 失效(比如服务端重启换了密钥),重连前先强制刷新
|
||||||
|
export class ReconnectingSocket {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private path = ""
|
||||||
|
// 取 token、等重连期间又调用了 connect/close 时,作废旧的那次
|
||||||
|
private seq = 0
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
private failures = 0
|
||||||
|
|
||||||
|
constructor(private handlers: Handlers) {}
|
||||||
|
|
||||||
|
connect(path: string) {
|
||||||
|
this.close()
|
||||||
|
this.path = path
|
||||||
|
this.failures = 0
|
||||||
|
this.open(this.seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.seq++
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
const ws = this.ws
|
||||||
|
this.ws = null
|
||||||
|
ws?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: unknown) {
|
||||||
|
if (this.ws?.readyState !== WebSocket.OPEN) return false
|
||||||
|
this.ws.send(JSON.stringify(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private async open(seq: number) {
|
||||||
|
const url = await wsUrl(this.path)
|
||||||
|
if (seq !== this.seq) return
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
this.ws = socket
|
||||||
|
let opened = false
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (socket !== this.ws) return
|
||||||
|
opened = true
|
||||||
|
this.failures = 0
|
||||||
|
this.handlers.onOpen?.()
|
||||||
|
}
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (socket === this.ws) this.handlers.onMessage(JSON.parse(event.data))
|
||||||
|
}
|
||||||
|
socket.onclose = async () => {
|
||||||
|
if (socket !== this.ws) return
|
||||||
|
this.ws = null
|
||||||
|
this.handlers.onDrop?.()
|
||||||
|
if (++this.failures > MAX_FAILURES || !getRefreshToken()) {
|
||||||
|
this.handlers.onGiveUp?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!opened) await refreshAccessToken()
|
||||||
|
if (seq !== this.seq) return
|
||||||
|
const delay = Math.min(1000 * 2 ** (this.failures - 1), MAX_DELAY_MS)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
if (seq === this.seq) this.open(seq)
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,3 +161,7 @@ export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
|
|||||||
|
|
||||||
export const isStaff = (user: User) =>
|
export const isStaff = (user: User) =>
|
||||||
user.role === Role.ADMIN || user.role === Role.SUPER
|
user.role === Role.ADMIN || user.role === Role.SUPER
|
||||||
|
|
||||||
|
// 未公开的教程只有超管能看,未公开的挑战管理员也能看(和能编辑它们的人一致)
|
||||||
|
export const canSeeUnpublished = (user: User | null | undefined, kind: "tutorial" | "challenge") =>
|
||||||
|
!!user && (kind === "tutorial" ? user.role === Role.SUPER : isStaff(user))
|
||||||
@@ -54,19 +54,6 @@ async function insertUser(u: {
|
|||||||
const userExists = (username: string) =>
|
const userExists = (username: string) =>
|
||||||
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
|
!!get(`SELECT 1 FROM account_user WHERE username = $username`, { username })
|
||||||
|
|
||||||
router.post("/register", async (c) => {
|
|
||||||
const payload = await body(
|
|
||||||
c,
|
|
||||||
z.object({
|
|
||||||
username: z.string(),
|
|
||||||
email: z.email(),
|
|
||||||
password: z.string().min(6).max(20),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await insertUser(payload)
|
|
||||||
return c.json({ message: "创建成功" })
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post("/login", async (c) => {
|
router.post("/login", async (c) => {
|
||||||
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
const payload = await body(c, z.object({ username: z.string(), password: z.string() }))
|
||||||
const user = await authenticate(payload.username, payload.password)
|
const user = await authenticate(payload.username, payload.password)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { existsSync } from "node:fs"
|
|||||||
import { dirname, extname, join, resolve, sep } from "node:path"
|
import { dirname, extname, join, resolve, sep } from "node:path"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { all, get, run } from "../db"
|
import { all, get, run } from "../db"
|
||||||
import { requireAdmin, requireSuper, type User } from "../auth"
|
import { canSeeUnpublished, requireAdmin, requireSuper, type User } from "../auth"
|
||||||
import { HttpError, ValidationError, intParam, orNotFound, type Ctx, type Env } from "../http"
|
import { HttpError, ValidationError, intParam, notFound, orNotFound, type Ctx, type Env } from "../http"
|
||||||
|
|
||||||
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
|
// 与 Django 的 get_valid_filename 一致:空格换成下划线,去掉除字母数字、-、_、. 以外的字符
|
||||||
export function validFilename(name: string) {
|
export function validFilename(name: string) {
|
||||||
@@ -53,8 +53,8 @@ type Kind = "challenge" | "tutorial"
|
|||||||
|
|
||||||
function findTask(kind: Kind, display: number) {
|
function findTask(kind: Kind, display: number) {
|
||||||
return orNotFound(
|
return orNotFound(
|
||||||
get<{ id: number; task_type: string; display: number }>(
|
get<{ id: number; task_type: string; display: number; is_public: number }>(
|
||||||
`SELECT t.id, t.task_type, t.display FROM task_task t
|
`SELECT t.id, t.task_type, t.display, t.is_public FROM task_task t
|
||||||
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
|
JOIN task_${kind} x ON x.task_ptr_id = t.id WHERE t.display = $display`,
|
||||||
{ display },
|
{ display },
|
||||||
),
|
),
|
||||||
@@ -72,6 +72,7 @@ for (const kind of ["challenge", "tutorial"] as const) {
|
|||||||
|
|
||||||
assets.get(`/${kind}/:display`, (c) => {
|
assets.get(`/${kind}/:display`, (c) => {
|
||||||
const task = findTask(kind, intParam(c, "display"))
|
const task = findTask(kind, intParam(c, "display"))
|
||||||
|
if (!task.is_public && !canSeeUnpublished(c.get("user"), kind)) notFound()
|
||||||
const rows = all<{ name: string; file: string }>(
|
const rows = all<{ name: string; file: string }>(
|
||||||
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
|
`SELECT name, file FROM task_taskasset WHERE task_id = $id ORDER BY id`,
|
||||||
{ id: task.id },
|
{ id: task.id },
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { all, get, run, tx, now, iso } from "../db"
|
import { all, get, run, tx, now, iso } from "../db"
|
||||||
import { Role, requireAdmin, requireSuper, requireUser, type User } from "../auth"
|
import { Role, canSeeUnpublished, requireAdmin, requireSuper, requireUser, type User } from "../auth"
|
||||||
import { HttpError, body, bool, intParam, orNotFound, type Env } from "../http"
|
import { HttpError, body, bool, intParam, notFound, orNotFound, type Env } from "../http"
|
||||||
import { deleteTask } from "../services/cascade"
|
import { deleteTask } from "../services/cascade"
|
||||||
|
|
||||||
const TASK_COLUMNS = `t.id, t.created, t.modified, t.display, t.title, t.content, t.task_type,
|
const TASK_COLUMNS = `t.id, t.created, t.modified, t.display, t.title, t.content, t.task_type,
|
||||||
@@ -134,6 +134,7 @@ tutorial.get("/:display", (c) => {
|
|||||||
{ display: intParam(c, "display") },
|
{ display: intParam(c, "display") },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if (!row.is_public && !canSeeUnpublished(c.get("user"), "tutorial")) notFound()
|
||||||
return c.json(taskOut(row))
|
return c.json(taskOut(row))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -227,6 +228,7 @@ challenge.get("/:display", (c) => {
|
|||||||
display: intParam(c, "display"),
|
display: intParam(c, "display"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
if (!r.is_public && !canSeeUnpublished(c.get("user"), "challenge")) notFound()
|
||||||
return c.json({
|
return c.json({
|
||||||
...taskOut(r),
|
...taskOut(r),
|
||||||
score: r.score,
|
score: r.score,
|
||||||
|
|||||||
+59
-21
@@ -46,10 +46,14 @@ export const GUIDANCE_SYSTEM_PROMPT = `你是一个提示词写作教练,帮
|
|||||||
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
5. 如果回复以 [READY] 开头,[READY] 不要加粗,必须保持原始文本
|
||||||
6. 不要生成任何代码`
|
6. 不要生成任何代码`
|
||||||
|
|
||||||
const DEFAULT_MODEL = "deepseek-flash"
|
|
||||||
// 走火山方舟(ARK)接口的模型
|
// 走火山方舟(ARK)接口的模型
|
||||||
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
const ARK_MODELS = new Set(["doubao-seed-2-1-lite-260915"])
|
||||||
|
|
||||||
|
// 客户端可选的模型;不认识的(比如浏览器里存着改版前的模型名)一律换成默认模型
|
||||||
|
export function chatModel(model?: string) {
|
||||||
|
return model && (model === config.llmModel || ARK_MODELS.has(model)) ? model : config.llmModel
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant"
|
role: "system" | "user" | "assistant"
|
||||||
content: string
|
content: string
|
||||||
@@ -63,10 +67,14 @@ interface CompletionOptions {
|
|||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流式生成:超过这么久没收到新数据才算超时,另有一个总时长上限
|
||||||
|
const STREAM_IDLE_MS = 120_000
|
||||||
|
const STREAM_MAX_MS = 15 * 60_000
|
||||||
|
|
||||||
export const llmConfigured = () => !!config.llmApiKey
|
export const llmConfigured = () => !!config.llmApiKey
|
||||||
|
|
||||||
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
function buildRequest(messages: ChatMessage[], opts: CompletionOptions) {
|
||||||
const model = opts.model || DEFAULT_MODEL
|
const model = opts.model || config.llmModel
|
||||||
const ark = ARK_MODELS.has(model)
|
const ark = ARK_MODELS.has(model)
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
@@ -102,30 +110,60 @@ async function* streamCompletion(
|
|||||||
model?: string,
|
model?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): AsyncGenerator<string> {
|
): AsyncGenerator<string> {
|
||||||
const res = await buildRequest(messages, { model, stream: true, signal })
|
// 带思考的模型生成整页代码可能超过两分钟,只要一直有数据就不该中断
|
||||||
await ensureOk(res)
|
const idle = new AbortController()
|
||||||
if (!res.body) return
|
let timer = setTimeout(() => idle.abort(new Error("模型响应超时")), STREAM_IDLE_MS)
|
||||||
const decoder = new TextDecoder()
|
const resetIdle = () => {
|
||||||
let buffer = ""
|
clearTimeout(timer)
|
||||||
for await (const chunk of res.body) {
|
timer = setTimeout(() => idle.abort(new Error("模型响应超时")), STREAM_IDLE_MS)
|
||||||
buffer += decoder.decode(chunk, { stream: true })
|
}
|
||||||
let idx: number
|
const signals = [idle.signal]
|
||||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
if (signal) signals.push(signal)
|
||||||
const line = buffer.slice(0, idx).trim()
|
try {
|
||||||
buffer = buffer.slice(idx + 1)
|
const res = await buildRequest(messages, {
|
||||||
if (!line.startsWith("data:")) continue
|
model,
|
||||||
const data = line.slice(5).trim()
|
stream: true,
|
||||||
if (data === "[DONE]") return
|
signal: AbortSignal.any(signals),
|
||||||
const json = JSON.parse(data)
|
timeoutMs: STREAM_MAX_MS,
|
||||||
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error))
|
})
|
||||||
const content = json.choices?.[0]?.delta?.content
|
await ensureOk(res)
|
||||||
if (content) yield content
|
if (!res.body) return
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
// 返回 true 表示收到了 [DONE]
|
||||||
|
function* parse(lines: string[]): Generator<string, boolean> {
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trim()
|
||||||
|
if (!line.startsWith("data:")) continue
|
||||||
|
const data = line.slice(5).trim()
|
||||||
|
if (data === "[DONE]") return true
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
for await (const chunk of res.body) {
|
||||||
|
resetIdle()
|
||||||
|
buffer += decoder.decode(chunk, { stream: true })
|
||||||
|
const lines = buffer.split("\n")
|
||||||
|
buffer = lines.pop()!
|
||||||
|
if (yield* parse(lines)) return
|
||||||
|
}
|
||||||
|
// 最后一行可能没有换行
|
||||||
|
yield* parse([buffer + decoder.decode()])
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
export function streamChat(history: ChatMessage[], model?: string, signal?: AbortSignal) {
|
||||||
return streamCompletion([{ role: "system", content: SYSTEM_PROMPT }, ...history], model, signal)
|
return streamCompletion(
|
||||||
|
[{ role: "system", content: SYSTEM_PROMPT }, ...history],
|
||||||
|
chatModel(model),
|
||||||
|
signal,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
export function streamGuidance(history: ChatMessage[], signal?: AbortSignal) {
|
||||||
|
|||||||
+20
-4
@@ -1,5 +1,5 @@
|
|||||||
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
|
||||||
import { all, run, iso, uuidOut } from "./db"
|
import { all, get, run, iso, uuidOut } from "./db"
|
||||||
import { userFromAccessToken, type User } from "./auth"
|
import { userFromAccessToken, type User } from "./auth"
|
||||||
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
import { canAccessTask, getOrCreateActiveConversation, insertMessage } from "./services/prompt"
|
||||||
import {
|
import {
|
||||||
@@ -41,8 +41,12 @@ export async function upgrade(
|
|||||||
const match = PATH_RE.exec(url.pathname)
|
const match = PATH_RE.exec(url.pathname)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
const user = await userFromAccessToken(url.searchParams.get("token"))
|
const user = await userFromAccessToken(url.searchParams.get("token"))
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||||
const taskId = Number(match[2])
|
const taskId = Number(match[2])
|
||||||
if (!user || !canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
if (!get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId })) {
|
||||||
|
return new Response("Not Found", { status: 404 })
|
||||||
|
}
|
||||||
|
if (!canAccessTask(user, taskId)) return new Response("Forbidden", { status: 403 })
|
||||||
|
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: {
|
data: {
|
||||||
@@ -155,6 +159,8 @@ async function handleGuidance(ws: WS, data: any) {
|
|||||||
try {
|
try {
|
||||||
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
|
full = await streamToClient(ws, streamGuidance(ws.data.guidanceHistory, ws.data.abort.signal))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 这条提问没有回复,留在上下文里会出现连续两条用户消息
|
||||||
|
ws.data.guidanceHistory.pop()
|
||||||
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
|
send(ws, { type: "error", content: `AI 服务出错:${e instanceof Error ? e.message : e}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -182,7 +188,14 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
|
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const d = ws.data
|
const d = ws.data
|
||||||
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
|
try {
|
||||||
|
d.conversationId = getOrCreateActiveConversation(d.user.id, d.taskId)
|
||||||
|
} catch (e) {
|
||||||
|
// 比如任务刚被删掉;不关掉的话客户端会一直等 init
|
||||||
|
console.error("websocket open failed:", e)
|
||||||
|
ws.close(1011, "conversation unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (d.kind === "guidance") {
|
if (d.kind === "guidance") {
|
||||||
send(ws, { type: "init", messages: [] })
|
send(ws, { type: "init", messages: [] })
|
||||||
return
|
return
|
||||||
@@ -212,7 +225,10 @@ export const websocket: WebSocketHandler<WsData> = {
|
|||||||
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
const handler = ws.data.kind === "prompt" ? handlePrompt : handleGuidance
|
||||||
ws.data.queue = ws.data.queue
|
ws.data.queue = ws.data.queue
|
||||||
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
|
.then(() => (ws.data.closed ? undefined : handler(ws, data)))
|
||||||
.catch((e) => console.error("websocket handler failed:", e))
|
.catch((e) => {
|
||||||
|
console.error("websocket handler failed:", e)
|
||||||
|
send(ws, { type: "error", content: "服务器出错,请刷新页面重试" })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
close(ws) {
|
close(ws) {
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ describe("权限", () => {
|
|||||||
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
|
expect((await adm("GET", "/api/submission/gradebook/?classname=a")).status).toBe(403)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("未公开的教程只有超管能看,未公开的挑战管理员也能看", async () => {
|
||||||
|
makeUser("stu")
|
||||||
|
makeUser("adm", "admin")
|
||||||
|
makeUser("boss", "super")
|
||||||
|
makeTask("草稿教程", "tutorial", 1, false)
|
||||||
|
makeTask("草稿挑战", "challenge", 2, false)
|
||||||
|
makeTask("公开挑战", "challenge", 3, true)
|
||||||
|
const [anon, stu, adm, boss] = await Promise.all([client(), client("stu"), client("adm"), client("boss")])
|
||||||
|
for (const path of ["/api/tutorial/1", "/api/challenge/2", "/api/assets/challenge/2"]) {
|
||||||
|
expect((await anon("GET", path)).status).toBe(404)
|
||||||
|
expect((await stu("GET", path)).status).toBe(404)
|
||||||
|
}
|
||||||
|
expect((await stu("GET", "/api/challenge/3")).status).toBe(200)
|
||||||
|
expect((await adm("GET", "/api/tutorial/1")).status).toBe(404)
|
||||||
|
expect((await adm("GET", "/api/challenge/2")).status).toBe(200)
|
||||||
|
expect((await adm("GET", "/api/assets/challenge/2")).status).toBe(200)
|
||||||
|
expect((await boss("GET", "/api/tutorial/1")).status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("没有公开注册接口", async () => {
|
||||||
|
const res = await (await client())("POST", "/api/account/register", {
|
||||||
|
username: "x",
|
||||||
|
email: "x@x.com",
|
||||||
|
password: "123456",
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
test("封号后 access token 和 refresh token 都失效", async () => {
|
test("封号后 access token 和 refresh token 都失效", async () => {
|
||||||
const id = makeUser("stu")
|
const id = makeUser("stu")
|
||||||
makeUser("boss", "super")
|
makeUser("boss", "super")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { pyRound, pySum } from "../src/services/pyround"
|
import { pyRound, pySum } from "../src/services/pyround"
|
||||||
import { extractCode, parseGuidanceResponse } from "../src/services/llm"
|
import { chatModel, extractCode, parseGuidanceResponse, streamChat } from "../src/services/llm"
|
||||||
|
import { config } from "../src/config"
|
||||||
import { checkPassword, makePassword } from "../src/auth"
|
import { checkPassword, makePassword } from "../src/auth"
|
||||||
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
import { formatMicros, iso, uuidHex, uuidOut } from "../src/db"
|
||||||
import { toCsv } from "../src/services/gradebook"
|
import { toCsv } from "../src/services/gradebook"
|
||||||
@@ -67,3 +68,49 @@ test("时间与 UUID 格式", () => {
|
|||||||
test("CSV 转义", () => {
|
test("CSV 转义", () => {
|
||||||
expect(toCsv([["a", 'b"c', "d,e"], [1, 2.5, ""]])).toBe('a,"b""c","d,e"\r\n1,2.5,\r\n')
|
expect(toCsv([["a", 'b"c', "d,e"], [1, 2.5, ""]])).toBe('a,"b""c","d,e"\r\n1,2.5,\r\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("streamChat", () => {
|
||||||
|
test("不认识的模型换成默认模型,最后一行没有换行也能读到", async () => {
|
||||||
|
let model = ""
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
model = ((await req.json()) as any).model
|
||||||
|
const line = (s: string) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}`
|
||||||
|
// 一行拆在两个分块之间,最后一行不带换行
|
||||||
|
const body = `${line("你好")}\n\n${line("世")}\n${line("界")}`
|
||||||
|
const enc = new TextEncoder()
|
||||||
|
const cut = body.indexOf("世") + 2
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream({
|
||||||
|
async start(ctrl) {
|
||||||
|
ctrl.enqueue(enc.encode(body.slice(0, cut)))
|
||||||
|
await Bun.sleep(5)
|
||||||
|
ctrl.enqueue(enc.encode(body.slice(cut)))
|
||||||
|
ctrl.close()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const saved = { ...config }
|
||||||
|
Object.assign(config, { llmApiKey: "k", llmBaseUrl: `http://localhost:${server.port}` })
|
||||||
|
try {
|
||||||
|
let out = ""
|
||||||
|
for await (const s of streamChat([{ role: "user", content: "hi" }], "deepseek-v4-flash-thinking")) out += s
|
||||||
|
expect(out).toBe("你好世界")
|
||||||
|
expect(model).toBe(config.llmModel)
|
||||||
|
} finally {
|
||||||
|
Object.assign(config, saved)
|
||||||
|
server.stop(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("chatModel 只放行配置里的模型", () => {
|
||||||
|
expect(chatModel("doubao-seed-2-1-lite-260915")).toBe("doubao-seed-2-1-lite-260915")
|
||||||
|
expect(chatModel(config.llmModel)).toBe(config.llmModel)
|
||||||
|
expect(chatModel("gpt-5")).toBe(config.llmModel)
|
||||||
|
expect(chatModel("")).toBe(config.llmModel)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user