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
@@ -150,6 +150,10 @@ const modelOptions = [
|
||||
{ label: "DeepSeek", value: "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
|
||||
const submittingId = ref<number | null>(null)
|
||||
|
||||
@@ -65,6 +65,8 @@ function clearReshowTimer() {
|
||||
async function fetchAndShow() {
|
||||
try {
|
||||
const item = await Submission.randomForRating(current.value?.submission_id)
|
||||
// 请求期间 AI 已经回复完了,就不要再弹出来
|
||||
if (!streaming.value) return
|
||||
if (item) {
|
||||
current.value = item
|
||||
previewContent.value = buildPreviewDocument({
|
||||
|
||||
@@ -170,7 +170,11 @@ function syncRoute(routeName: 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"])
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
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 { css, html, js, tab } from "../../store/editors"
|
||||
import { Tutorial } from "../../api"
|
||||
@@ -11,7 +12,8 @@ import { step, tutorialIds, loadTutorials } from "../../store/tutorial"
|
||||
import { taskId, assetBaseUrl } from "../../store/task"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
marked.use({
|
||||
// 独立实例:带“复制/替换”按钮的代码块只用在教程里,不能改全局的 marked
|
||||
const tutorialMarked = new Marked(...trustedExtensions, {
|
||||
renderer: {
|
||||
code({ text, lang }) {
|
||||
const language = lang?.toLowerCase() ?? "html"
|
||||
@@ -36,15 +38,22 @@ const router = useRouter()
|
||||
const content = ref("")
|
||||
const $content = useTemplateRef<any>("$content")
|
||||
|
||||
// 连点“下一节”时只用最后一次请求的结果
|
||||
let renderSeq = 0
|
||||
|
||||
async function render() {
|
||||
const seq = ++renderSeq
|
||||
const data = await Tutorial.get(step.value)
|
||||
if (seq !== renderSeq) return
|
||||
taskId.value = data.task_ptr
|
||||
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
|
||||
html.value = data.example_html ?? ""
|
||||
css.value = data.example_css ?? ""
|
||||
js.value = data.example_js ?? ""
|
||||
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) {
|
||||
|
||||
+2
-32
@@ -8,42 +8,12 @@ import { addAPIProvider } from "@iconify/vue"
|
||||
import "github-markdown-css/github-markdown-light.css"
|
||||
|
||||
import { marked } from "marked"
|
||||
import alert from "marked-alert"
|
||||
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"
|
||||
import { trustedExtensions } from "./utils/markdown"
|
||||
//@ts-ignore
|
||||
import "highlight.js/styles/github.min.css"
|
||||
import { router } from "./router"
|
||||
|
||||
hljs.registerLanguage("html", xml)
|
||||
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 }))
|
||||
marked.use(...trustedExtensions)
|
||||
|
||||
const app = createApp(App)
|
||||
const naive = create()
|
||||
|
||||
@@ -163,6 +163,7 @@ import {
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../store/prompt"
|
||||
import { closeGuidance } from "../store/guidance"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -221,6 +222,7 @@ async function loadChallenge() {
|
||||
Challenge.get(display),
|
||||
TaskAssets.listChallenge(display),
|
||||
])
|
||||
if (disposed) return
|
||||
taskId.value = data.task_ptr
|
||||
challengeAuthor.value = data.author_name ?? ""
|
||||
if (data.example_html || data.example_css || data.example_js) {
|
||||
@@ -236,6 +238,7 @@ async function loadChallenge() {
|
||||
async: true,
|
||||
renderer: challengeRenderer,
|
||||
} as MarkedOptions)
|
||||
if (disposed) return
|
||||
assets.value = fetchedAssets
|
||||
if (!authed.value) return
|
||||
connectPrompt(data.task_ptr)
|
||||
@@ -291,11 +294,18 @@ function back() {
|
||||
router.push({ name: "home-challenge-list" })
|
||||
}
|
||||
|
||||
// 加载还没完成就离开页面时,不要再建立连接
|
||||
let disposed = false
|
||||
|
||||
onMounted(() => {
|
||||
setupCodeCopy()
|
||||
loadChallenge()
|
||||
})
|
||||
onUnmounted(disconnectPrompt)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
disconnectPrompt()
|
||||
closeGuidance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
setOnCodeComplete,
|
||||
streaming,
|
||||
} from "../store/prompt"
|
||||
import { closeGuidance } from "../store/guidance"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -146,6 +147,7 @@ watch(streaming, (val) => {
|
||||
|
||||
async function load() {
|
||||
const data = await Creation.get(display)
|
||||
if (disposed) return
|
||||
taskId.value = data.task_id
|
||||
title.value = data.title
|
||||
content.value = data.content
|
||||
@@ -212,8 +214,15 @@ function back() {
|
||||
router.push({ name: "creation-list" })
|
||||
}
|
||||
|
||||
// 加载还没完成就离开页面时,不要再建立连接
|
||||
let disposed = false
|
||||
|
||||
onMounted(load)
|
||||
onUnmounted(disconnectPrompt)
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
disconnectPrompt()
|
||||
closeGuidance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -341,16 +341,25 @@ function rowClassName(row: SubmissionOut) {
|
||||
return submission.value.id === row.id ? "row-active" : ""
|
||||
}
|
||||
|
||||
// 快速翻页时只用最后一次请求的结果
|
||||
let initSeq = 0
|
||||
|
||||
async function init() {
|
||||
const seq = ++initSeq
|
||||
expandedKeys.value = []
|
||||
expandedData.clear()
|
||||
const res = await Submission.list(query)
|
||||
if (seq !== initSeq) return
|
||||
data.value = res.items
|
||||
count.value = res.count
|
||||
}
|
||||
|
||||
let detailSeq = 0
|
||||
|
||||
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() {
|
||||
@@ -374,26 +383,34 @@ watch(
|
||||
router.push({ params: { page: v } })
|
||||
},
|
||||
)
|
||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||
watch(
|
||||
() => route.params.page,
|
||||
(v) => {
|
||||
const page = Number(v) || 1
|
||||
if (page !== query.page) query.page = page
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watch(
|
||||
() => query.flag,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => query.zone,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -151,8 +151,13 @@ async function batchCreateUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// 快速翻页时只用最后一次请求的结果
|
||||
let initSeq = 0
|
||||
|
||||
async function init() {
|
||||
const seq = ++initSeq
|
||||
const data = await Account.list(query)
|
||||
if (seq !== initSeq) return
|
||||
users.value = data.items
|
||||
count.value = data.count
|
||||
}
|
||||
@@ -160,15 +165,15 @@ async function init() {
|
||||
watch(
|
||||
() => query.role,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
if (query.page !== 1) query.page = 1
|
||||
else init()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
@@ -179,6 +184,14 @@ watch(
|
||||
router.push({ params: { page: v } })
|
||||
},
|
||||
)
|
||||
// 浏览器前进 / 后退时,页码跟着地址栏走
|
||||
watch(
|
||||
() => route.params.page,
|
||||
(v) => {
|
||||
const page = Number(v) || 1
|
||||
if (page !== query.page) query.page = page
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from "vue"
|
||||
import { wsUrl } from "../token"
|
||||
import { ReconnectingSocket } from "../utils/socket"
|
||||
|
||||
export interface GuidanceMessage {
|
||||
role: "user" | "assistant"
|
||||
@@ -15,31 +15,19 @@ export const isReady = ref(false)
|
||||
export const isOpen = ref(false)
|
||||
export const initialPrompt = ref("")
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let _taskId = 0
|
||||
// 取 token 要等一下,期间又重连或关闭时作废这次连接
|
||||
let connectSeq = 0
|
||||
// 打开弹窗时带过来的提示词,连上后发一次;重连时不再重发
|
||||
let unsentPrompt = ""
|
||||
|
||||
async function connect(taskId: number) {
|
||||
if (ws) ws.close()
|
||||
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 = () => {
|
||||
const socket = new ReconnectingSocket({
|
||||
onOpen() {
|
||||
connected.value = true
|
||||
if (initialPrompt.value) {
|
||||
sendToSocket(initialPrompt.value)
|
||||
if (unsentPrompt) {
|
||||
sendToSocket(unsentPrompt)
|
||||
unsentPrompt = ""
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
},
|
||||
onMessage(data) {
|
||||
if (data.type === "init") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
@@ -57,18 +45,38 @@ function setupHandlers(socket: WebSocket) {
|
||||
streamingContent.value = ""
|
||||
messages.value.push({ role: "assistant", content: data.content })
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
},
|
||||
onDrop() {
|
||||
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) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
if (!socket.send({ type: "message", content })) return
|
||||
streaming.value = true
|
||||
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) {
|
||||
@@ -76,6 +84,7 @@ export function openGuidance(taskId: number, prompt: string) {
|
||||
|
||||
_taskId = taskId
|
||||
initialPrompt.value = prompt
|
||||
unsentPrompt = prompt
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
streamingContent.value = ""
|
||||
@@ -89,26 +98,17 @@ export function sendGuidance(content: string) {
|
||||
}
|
||||
|
||||
export function stopGuidance() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
dropPendingMessage()
|
||||
if (_taskId) {
|
||||
initialPrompt.value = ""
|
||||
unsentPrompt = ""
|
||||
connect(_taskId)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeGuidance() {
|
||||
connectSeq++
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
socket.close()
|
||||
unsentPrompt = ""
|
||||
isOpen.value = false
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
|
||||
+70
-73
@@ -1,5 +1,5 @@
|
||||
import { ref } from "vue"
|
||||
import { wsUrl } from "../token"
|
||||
import { ReconnectingSocket } from "../utils/socket"
|
||||
import { html, css, js } from "./editors"
|
||||
|
||||
export interface PromptMessage {
|
||||
@@ -28,81 +28,75 @@ export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||
_onCodeComplete = fn
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
// 取 token 要等一下,期间又连了别的题或断开时作废这次连接
|
||||
let connectSeq = 0
|
||||
|
||||
export async function connectPrompt(taskId: number) {
|
||||
currentTaskId.value = taskId
|
||||
if (ws) ws.close()
|
||||
ws = null
|
||||
|
||||
const seq = ++connectSeq
|
||||
const url = await wsUrl(`/ws/prompt/${taskId}/`)
|
||||
if (seq !== connectSeq) return
|
||||
ws = new WebSocket(url)
|
||||
|
||||
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)
|
||||
}
|
||||
function onMessage(data: any) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
} 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
connectSeq++
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
socket.close()
|
||||
messages.value = []
|
||||
conversationId.value = ""
|
||||
connected.value = false
|
||||
@@ -113,13 +107,12 @@ export function disconnectPrompt() {
|
||||
}
|
||||
|
||||
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
|
||||
messages.value.push({ role: "user", content })
|
||||
ws.send(JSON.stringify({ type: "message", content, model }))
|
||||
}
|
||||
|
||||
export function stopPrompt() {
|
||||
function dropPendingPrompt() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
@@ -128,6 +121,10 @@ export function stopPrompt() {
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
}
|
||||
|
||||
export function stopPrompt() {
|
||||
dropPendingPrompt()
|
||||
if (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 前必须去掉可执行的部分:
|
||||
// 原始 HTML 一律转义成文本,链接和图片只放行 http(s)/mailto 和相对地址
|
||||
@@ -12,7 +44,8 @@ const escapeHtml = (s: string) =>
|
||||
const isSafeUrl = (href: string) =>
|
||||
!/^\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: {
|
||||
html({ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user