Compare commits

...

2 Commits

Author SHA1 Message Date
7da4becf4f add iconify url
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-09 09:38:44 +08:00
b6dc79b298 add chat 2026-03-04 20:05:37 +08:00
13 changed files with 470 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
PUBLIC_WEB_URL=http://10.13.114.114:91
PUBLIC_ADMIN_URL=http://10.13.114.114:91/admin
PUBLIC_BASE_URL=http://10.13.114.114:91/api
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=df542e305f27dee6
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=df542e305f27dee6
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098

7
components.d.ts vendored
View File

@@ -19,9 +19,7 @@ declare module 'vue' {
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCode: typeof import('naive-ui')['NCode']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NEmpty: typeof import('naive-ui')['NEmpty']
@@ -31,22 +29,19 @@ declare module 'vue' {
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NRate: typeof import('naive-ui')['NRate']
NSelect: typeof import('naive-ui')['NSelect']
NSplit: typeof import('naive-ui')['NSplit']
NSwitch: typeof import('naive-ui')['NSwitch']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
Preview: typeof import('./src/components/Preview.vue')['default']
PromptPanel: typeof import('./src/components/PromptPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Task: typeof import('./src/components/Task.vue')['default']

View File

@@ -32,6 +32,11 @@ export default defineConfig({
target: process.env.PUBLIC_WEB_URL,
changeOrigin: true,
},
"/ws": {
target: process.env.PUBLIC_WEB_URL,
ws: true,
changeOrigin: true,
},
},
},
performance: {

View File

@@ -138,9 +138,15 @@ export const Submission = {
html?: string
css?: string
js?: string
conversationId?: string
},
) {
const data = { task_id: taskId, ...code }
const { conversationId, ...rest } = code
const data = {
task_id: taskId,
...rest,
conversation_id: conversationId || null,
}
const res = await http.post("/submission/", data)
return res.data
},
@@ -163,6 +169,21 @@ export const Submission = {
},
}
export const Prompt = {
async listConversations(taskId?: number, userId?: number) {
const params: Record<string, number> = {}
if (taskId) params.task_id = taskId
if (userId) params.user_id = userId
return (await http.get("/prompt/conversations/", { params })).data
},
async getMessages(conversationId: string) {
return (
await http.get(`/prompt/conversations/${conversationId}/messages/`)
).data
},
}
export const Helper = {
async upload(file: File) {
const form = new window.FormData()

View File

@@ -0,0 +1,131 @@
<template>
<div class="prompt-panel">
<div class="messages" ref="messagesRef">
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
<div class="message-role">{{ msg.role === 'user' ? '我' : 'AI' }}</div>
<div class="message-content" v-html="renderContent(msg)"></div>
</div>
<div v-if="streaming" class="message assistant">
<div class="message-role">AI</div>
<div class="message-content" v-html="renderMarkdown(streamingContent)"></div>
</div>
</div>
<div class="input-area">
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="描述你想要的网页效果..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<n-button text size="small" @click="newConversation" :disabled="streaming">
新对话
</n-button>
<n-button
type="primary"
:loading="streaming"
:disabled="!input.trim() || streaming"
@click="send"
>
发送
</n-button>
</n-flex>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue"
import { marked } from "marked"
import {
messages,
streaming,
streamingContent,
sendPrompt,
newConversation,
} from "../store/prompt"
const input = ref("")
const messagesRef = ref<HTMLElement>()
function send() {
const text = input.value.trim()
if (!text || streaming.value) return
sendPrompt(text)
input.value = ""
}
function renderMarkdown(text: string): string {
return marked.parse(text) as string
}
function renderContent(msg: { role: string; content: string }): string {
return renderMarkdown(msg.content)
}
// Auto-scroll to bottom on new messages
watch(
[() => messages.value.length, streamingContent],
() => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
)
</script>
<style scoped>
.prompt-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.message {
margin-bottom: 16px;
}
.message-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
.message.user .message-role {
color: #2080f0;
}
.message.assistant .message-role {
color: #18a058;
}
.message-content {
font-size: 14px;
line-height: 1.6;
}
.message-content :deep(pre) {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
}
.input-area {
padding: 12px;
border-top: 1px solid #e0e0e0;
}
</style>

1
src/env.d.ts vendored
View File

@@ -3,6 +3,7 @@ interface ImportMetaEnv {
readonly PUBLIC_BASE_URL: string
readonly PUBLIC_ADMIN_URL: string
readonly PUBLIC_MAXKB_URL: string
readonly PUBLIC_ICONIFY_URL: string
}
interface ImportMeta {

View File

@@ -1,6 +1,7 @@
import { createApp } from "vue"
import { create } from "naive-ui"
import App from "./App.vue"
import { addAPIProvider } from "@iconify/vue"
//@ts-ignore
import "github-markdown-css/github-markdown-light.css"
@@ -25,7 +26,6 @@ hljs.registerLanguage("js", javascript)
marked.use({
gfm: true,
async: true,
})
marked.use(
markedHighlight({
@@ -47,3 +47,9 @@ const naive = create()
app.use(naive)
app.use(router)
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
}

149
src/pages/ChallengeHome.vue Normal file
View File

@@ -0,0 +1,149 @@
<template>
<n-split :size="leftSize" min="350px" max="700px">
<template #1>
<div class="left-panel">
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
<template #prefix>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
</template>
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
<div class="markdown-body" style="padding: 12px; overflow-y: auto; height: 100%" v-html="challengeContent" />
</n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel />
</n-tab-pane>
</n-tabs>
</div>
</template>
<template #2>
<div class="right-panel">
<Preview :html="html" :css="css" :js="js" />
<n-flex class="toolbar" align="center" :size="8">
<n-button secondary @click="showCode = true">查看代码</n-button>
<n-button
type="primary"
:disabled="!conversationId"
:loading="submitLoading"
@click="submit"
>
提交作品
</n-button>
</n-flex>
</div>
</template>
</n-split>
<n-modal v-model:show="showCode" preset="card" title="代码" style="width: 700px">
<n-tabs type="line">
<n-tab-pane name="html" tab="HTML">
<n-code :code="html" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<n-code :code="css" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<n-code :code="js" language="javascript" />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { marked } from "marked"
import PromptPanel from "../components/PromptPanel.vue"
import Preview from "../components/Preview.vue"
import { Challenge, Submission } from "../api"
import { html, css, js } from "../store/editors"
import { taskId } from "../store/task"
import { connectPrompt, disconnectPrompt, conversationId, streaming } from "../store/prompt"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const leftSize = ref(0.4)
const activeTab = ref("desc")
const challengeTitle = ref("")
const challengeContent = ref("")
const showCode = ref(false)
const submitLoading = ref(false)
watch(streaming, (val) => {
if (val) activeTab.value = "chat"
})
async function loadChallenge() {
const display = Number(route.params.display)
const data = await Challenge.get(display)
taskId.value = data.task_ptr
challengeTitle.value = `#${data.display} ${data.title}`
challengeContent.value = await marked.parse(data.content, { async: true })
connectPrompt(data.task_ptr)
}
function back() {
disconnectPrompt()
router.push({ name: "home-challenge-list" })
}
async function submit() {
if (!conversationId.value) return
submitLoading.value = true
try {
await Submission.create(taskId.value, {
html: html.value,
css: css.value,
js: js.value,
conversationId: conversationId.value,
})
message.success("提交成功")
} catch {
message.error("提交失败")
} finally {
submitLoading.value = false
}
}
onMounted(loadChallenge)
onUnmounted(disconnectPrompt)
</script>
<style scoped>
.left-panel {
height: 100%;
overflow: hidden;
}
.left-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tabs-pane-wrapper) {
flex: 1;
overflow: hidden;
}
.left-tabs :deep(.n-tab-pane) {
height: 100%;
padding: 0;
}
.right-panel {
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
padding: 8px 12px;
border-top: 1px solid #e0e0e0;
justify-content: flex-end;
}
</style>

View File

@@ -63,11 +63,23 @@
</n-tab-pane>
</n-tabs>
</n-modal>
<n-modal v-model:show="chainModal" preset="card" title="Prompt 思维链" style="max-width: 60%; max-height: 80vh">
<n-spin :show="chainLoading">
<div v-for="msg in chainMessages" :key="msg.id" style="margin-bottom: 16px">
<div :style="{ fontWeight: 'bold', fontSize: '12px', marginBottom: '4px', color: msg.role === 'user' ? '#2080f0' : '#18a058' }">
{{ msg.role === "user" ? "学生" : "AI" }}
</div>
<div v-html="renderMarkdown(msg.content)" style="font-size: 14px; line-height: 1.6" />
</div>
<n-empty v-if="!chainLoading && chainMessages.length === 0" description="暂无对话记录" />
</n-spin>
</n-modal>
</template>
<script setup lang="ts">
import { type DataTableColumn } from "naive-ui"
import { NButton, type DataTableColumn } from "naive-ui"
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { Submission } from "../api"
import { marked } from "marked"
import { Submission, Prompt } from "../api"
import type { SubmissionOut } from "../utils/type"
import { parseTime } from "../utils/helper"
import TaskTitle from "../components/submissions/TaskTitle.vue"
@@ -96,6 +108,23 @@ const css = computed(() => submission.value.css)
const js = computed(() => submission.value.js)
const codeModal = ref(false)
const chainModal = ref(false)
const chainMessages = ref<{ id: number; role: string; content: string }[]>([])
const chainLoading = ref(false)
async function showChain(conversationId: string) {
chainLoading.value = true
chainModal.value = true
try {
chainMessages.value = await Prompt.getMessages(conversationId)
} finally {
chainLoading.value = false
}
}
function renderMarkdown(text: string): string {
return marked.parse(text, { async: false }) as string
}
const columns: DataTableColumn<SubmissionOut>[] = [
{
@@ -131,6 +160,19 @@ const columns: DataTableColumn<SubmissionOut>[] = [
else return "-"
},
},
{
title: "思维链",
key: "conversation_id",
width: 70,
render: (row) => {
if (!row.conversation_id) return "-"
return h(
NButton,
{ text: true, type: "primary", onClick: () => showChain(row.conversation_id!) },
() => "查看",
)
},
},
]
function rowProps(row: SubmissionOut) {

View File

@@ -9,7 +9,11 @@ const routes = [
{ path: "/tutorial", name: "home-tutorial-list", component: Home },
{ path: "/tutorial/:display", name: "home-tutorial", component: Home },
{ path: "/challenge", name: "home-challenge-list", component: Home },
{ path: "/challenge/:display", name: "home-challenge", component: Home },
{
path: "/challenge/:display",
name: "home-challenge",
component: () => import("./pages/ChallengeHome.vue"),
},
{
path: "/submissions/:page",
name: "submissions",

100
src/store/prompt.ts Normal file
View File

@@ -0,0 +1,100 @@
import { ref } from "vue"
import { WS_BASE_URL } from "../utils/const"
import { html, css, js } from "./editors"
export interface PromptMessage {
role: "user" | "assistant"
content: string
code?: { html: string | null; css: string | null; js: string | null }
created?: string
}
export const messages = ref<PromptMessage[]>([])
export const conversationId = ref<string>("")
export const connected = ref(false)
export const streaming = ref(false)
export const streamingContent = ref("")
let ws: WebSocket | null = null
export function connectPrompt(taskId: number) {
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "init") {
conversationId.value = data.conversation_id
messages.value = data.messages || []
// Apply code from last assistant message if exists
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
// Push the full assistant message
messages.value.push({
role: "assistant",
content: streamingContent.value,
code: data.code,
})
streamingContent.value = ""
// Apply code to editors
if (data.code) {
applyCode(data.code)
}
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
}
}
ws.onclose = () => {
connected.value = false
}
}
export function disconnectPrompt() {
if (ws) {
ws.close()
ws = null
}
messages.value = []
conversationId.value = ""
connected.value = false
streaming.value = false
streamingContent.value = ""
}
export function sendPrompt(content: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
messages.value.push({ role: "user", content })
ws.send(JSON.stringify({ type: "message", content }))
}
export function newConversation() {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: "new_conversation" }))
}
function applyCode(code: { html: string | null; css: string | null; js: string | null }) {
if (code.html !== null) html.value = code.html
if (code.css !== null) css.value = code.css
if (code.js !== null) js.value = code.js
}

View File

@@ -39,6 +39,8 @@ export const ADMIN_URL = import.meta.env.PUBLIC_ADMIN_URL
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
export const WS_BASE_URL = import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}`
export enum TASK_TYPE {
Tutorial = "tutorial",
Challenge = "challenge",

View File

@@ -63,6 +63,7 @@ export interface SubmissionOut {
task_title: string
score: number
my_score: number
conversation_id?: string
created: Date
modified: Date
}