add chat
This commit is contained in:
23
src/api.ts
23
src/api.ts
@@ -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()
|
||||
|
||||
131
src/components/PromptPanel.vue
Normal file
131
src/components/PromptPanel.vue
Normal 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>
|
||||
@@ -25,7 +25,6 @@ hljs.registerLanguage("js", javascript)
|
||||
|
||||
marked.use({
|
||||
gfm: true,
|
||||
async: true,
|
||||
})
|
||||
marked.use(
|
||||
markedHighlight({
|
||||
|
||||
149
src/pages/ChallengeHome.vue
Normal file
149
src/pages/ChallengeHome.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
100
src/store/prompt.ts
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface SubmissionOut {
|
||||
task_title: string
|
||||
score: number
|
||||
my_score: number
|
||||
conversation_id?: string
|
||||
created: Date
|
||||
modified: Date
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user