md-editor-v3 的 MdPreview 不管正文里有没有代码、公式,都会从 unpkg.com 加载 highlight.js、katex、mermaid、echarts(mermaid 还是另一份 11.x)。而我们只用到最基础的 Markdown:题面 95% 是富文本存的 HTML,教程是标题、表格、图片,AI 输出是标题、列表、 表格、少量代码块,库里一处公式、mermaid 都没有。 - 显示:新组件 shared/components/Markdown.vue,直接用 markdown-it,代码高亮走本地的 highlight.js(和练一练共用 shared/highlight.ts,补了 C++ / SQL)。9 处 MdPreview 全换掉。 正文默认 16px,教程也统一成 16px。 - 编辑:后台教程编辑器换成项目已有的 CodeMirror,加一排格式按钮;图片可以按按钮选、 粘贴、拖进来,都是先上传再插入。md-editor-v3 从依赖里删掉。 - AI 生成的内容(AI 提示、AI 分析、班级报告)不再放行原生 HTML:学生能在代码里诱导 AI 吐出 <img onerror>,老师在后台一打开就执行。库里 1196 条 AI 输出没有一个 HTML 标签,关掉不影响显示。题面、教程照旧放行。 - 长英文改成在单词之间折行,原来 break-all 会把 "I live" 断成 "I l / ive"。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
import type { AiHintDone } from "@oj2/contract"
|
|
import { submitHintFeedback } from "oj/api"
|
|
import { useAIStream } from "shared/composables/aiStream"
|
|
|
|
/**
|
|
* 一次提交的 AI 提示。
|
|
*
|
|
* 原来这些状态长在结果面板(SubmissionResult)里,而结果面板在提交按钮那个组件里 ——
|
|
* 手机上切一下页签、桌面切一下分栏模式,编辑器连同它一起卸载,已经生成的提示没了,
|
|
* 那次 LLM 调用白花。现在由 submission store 持有(见 oj/store/submission.ts)。
|
|
*
|
|
* hintTarget 是后端发来的全文,hintContent 是已经「打」出来的那一截 ——
|
|
* 后端从 2c 起整段生成、过滤通过才推(设计 2.6),一次就把全文发过来,
|
|
* 逐字显示改在这边模拟。
|
|
*/
|
|
export function useSubmissionHint() {
|
|
const hintTarget = ref("")
|
|
const hintContent = ref("")
|
|
const hintStream = useAIStream()
|
|
// 整条流跑完才算结束:提示是一次推全文,没有「第一个字到了」的中间态
|
|
const hintLoading = hintStream.running
|
|
const hintError = ref("")
|
|
// 这条提示在 ai_hint 里的 id,生成完由 done 事件带回来;后端落库失败时没有,就不出评价按钮
|
|
const hintId = ref<number | null>(null)
|
|
// 这条提示是哪一级(-1 = 编译错误那一档,不在阶梯上),以及还能不能再往上要一级。
|
|
// 两个都由后端算好在 done 里给,前端不自己推阶梯
|
|
const hintLevel = ref<number | null>(null)
|
|
const hintCanEscalate = ref(false)
|
|
const hintHelpful = ref<boolean | null>(null)
|
|
const hintFeedbackSending = ref(false)
|
|
|
|
// 打字机:每 24ms 吐 3 个字,约 125 字/秒。步子不敢迈太小 ——
|
|
// 每一帧都要让 Markdown 重渲染一次 Markdown,机房那批机器扛不住逐字
|
|
const TYPE_STEP = 3
|
|
const TYPE_INTERVAL = 24
|
|
let typingTimer: ReturnType<typeof setInterval> | null = null
|
|
const hintTyping = computed(() => hintContent.value.length < hintTarget.value.length)
|
|
|
|
function stopTyping() {
|
|
if (typingTimer === null) return
|
|
clearInterval(typingTimer)
|
|
typingTimer = null
|
|
}
|
|
|
|
function startTyping() {
|
|
if (typingTimer !== null) return
|
|
typingTimer = setInterval(() => {
|
|
if (!hintTyping.value) {
|
|
stopTyping()
|
|
return
|
|
}
|
|
hintContent.value = hintTarget.value.slice(0, hintContent.value.length + TYPE_STEP)
|
|
}, TYPE_INTERVAL)
|
|
}
|
|
|
|
function resetHint() {
|
|
stopTyping()
|
|
hintTarget.value = ""
|
|
hintContent.value = ""
|
|
hintError.value = ""
|
|
hintId.value = null
|
|
hintLevel.value = null
|
|
hintCanEscalate.value = false
|
|
hintHelpful.value = null
|
|
}
|
|
|
|
/** 换了一次提交:还在生成的那条掐掉(不然它会写进新提交的面板里),内容清空 */
|
|
function clearHint() {
|
|
hintStream.abort()
|
|
resetHint()
|
|
}
|
|
|
|
// more = 学生点的是「再多一点提示」。升级只能由学生主动发起,而且后端还要看
|
|
// 「上次开出这一级之后有没有再交过」,所以点了也未必真升 —— 以 done 里的 level 为准
|
|
async function fetchHint(submissionId: string, more = false) {
|
|
resetHint()
|
|
try {
|
|
await hintStream.run<AiHintDone>(
|
|
"ai/hint",
|
|
{ submissionId, more },
|
|
{
|
|
onDelta(content) {
|
|
hintTarget.value += content
|
|
startTyping()
|
|
},
|
|
onDone(data) {
|
|
hintId.value = data.hintId ?? null
|
|
hintLevel.value = data.level ?? null
|
|
hintCanEscalate.value = data.canEscalate === true
|
|
},
|
|
},
|
|
)
|
|
} catch (error) {
|
|
hintError.value = (error as Error).message
|
|
}
|
|
}
|
|
|
|
// 可以改票:点另一个就覆盖。失败了不打扰学生,按钮恢复原样就行 ——
|
|
// 评价是给我们看的,不值得为它弹一条报错
|
|
async function sendHintFeedback(helpful: boolean) {
|
|
if (hintId.value === null || hintFeedbackSending.value) return
|
|
if (hintHelpful.value === helpful) return
|
|
hintFeedbackSending.value = true
|
|
try {
|
|
await submitHintFeedback(hintId.value, helpful)
|
|
hintHelpful.value = helpful
|
|
} catch {
|
|
// 静默
|
|
} finally {
|
|
hintFeedbackSending.value = false
|
|
}
|
|
}
|
|
|
|
if (getCurrentScope()) onScopeDispose(stopTyping)
|
|
|
|
return {
|
|
hintTarget,
|
|
hintContent,
|
|
hintLoading,
|
|
hintError,
|
|
hintId,
|
|
hintLevel,
|
|
hintCanEscalate,
|
|
hintHelpful,
|
|
hintFeedbackSending,
|
|
hintTyping,
|
|
fetchHint,
|
|
sendHintFeedback,
|
|
clearHint,
|
|
}
|
|
}
|