add delete button
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled

This commit is contained in:
2026-04-15 19:16:28 -06:00
parent 266f042fb2
commit e9781fdada
4 changed files with 128 additions and 25 deletions

View File

@@ -160,12 +160,14 @@ export const Submission = {
js?: string js?: string
prompt?: string prompt?: string
}, },
messageId?: number,
) { ) {
const { prompt, ...rest } = code const { prompt, ...rest } = code
const data = { const data = {
task_id: taskId, task_id: taskId,
...rest, ...rest,
prompt: prompt || null, prompt: prompt || null,
message_id: messageId ?? null,
} }
const res = await http.post("/submission/", data) const res = await http.post("/submission/", data)
return res.data return res.data
@@ -260,6 +262,13 @@ export const Prompt = {
if (!convs.length) return [] if (!convs.length) return []
return this.getMessages(convs[0].id) return this.getMessages(convs[0].id)
}, },
async deleteMessagePair(
messageId: number,
): Promise<{ deleted: boolean; submission_deleted: boolean }> {
const res = await http.delete(`/prompt/messages/${messageId}/pair`)
return res.data
},
} }
export const Helper = { export const Helper = {

View File

@@ -1,10 +1,36 @@
<template> <template>
<div class="prompt-panel"> <div class="prompt-panel">
<div class="messages" ref="messagesRef"> <div class="messages" ref="messagesRef">
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]"> <div
<div class="message-role">{{ msg.role === "user" ? "我" : "AI" }}</div> v-for="(pair, pi) in pairs"
<div class="message-content" v-html="renderContent(msg)"></div> :key="pair.assistantMsg?.id ?? 'user-' + pair.index"
class="message-pair"
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
>
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
<button
v-if="pair.assistantMsg?.id && !streaming"
class="pair-delete-btn"
@click="deletePair(pair.assistantMsg!.id!)"
title="删除这轮对话及关联提交"
>
<Icon icon="lucide:trash-2" :width="14" />
</button>
<!-- User message -->
<div class="message user">
<div class="message-role"></div>
<div class="message-content" v-html="renderContent(pair.userMsg)"></div>
</div> </div>
<!-- Assistant message -->
<div v-if="pair.assistantMsg" class="message assistant">
<div class="message-role">AI</div>
<div class="message-content" v-html="renderContent(pair.assistantMsg)"></div>
</div>
</div>
<!-- Streaming indicator -->
<div v-if="streaming" class="message assistant"> <div v-if="streaming" class="message assistant">
<div class="message-role">AI</div> <div class="message-role">AI</div>
<div v-if="!streamingContent" class="typing-indicator"> <div v-if="!streamingContent" class="typing-indicator">
@@ -51,9 +77,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick } from "vue" import { ref, watch, nextTick, computed } from "vue"
import { useStorage } from "@vueuse/core" import { useStorage } from "@vueuse/core"
import { marked, Renderer } from "marked" import { marked, Renderer } from "marked"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { import {
messages, messages,
streaming, streaming,
@@ -62,9 +90,11 @@ import {
sendPrompt, sendPrompt,
stopPrompt, stopPrompt,
} from "../../store/prompt" } from "../../store/prompt"
import { Prompt } from "../../api"
const input = ref("") const input = ref("")
const messagesRef = ref<HTMLElement>() const messagesRef = ref<HTMLElement>()
const naiveMessage = useMessage()
const modelOptions = [ const modelOptions = [
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" }, { label: "豆包", value: "doubao-seed-2-0-lite-260215" },
@@ -72,6 +102,40 @@ const modelOptions = [
] ]
const selectedModel = useStorage("prompt-model", "deepseek-chat") const selectedModel = useStorage("prompt-model", "deepseek-chat")
// Group messages into user+assistant pairs
const pairs = computed(() => {
const result: Array<{
userMsg: { role: string; content: string; id?: number }
assistantMsg: { role: string; content: string; id?: number; code?: any } | null
index: number
}> = []
const msgs = messages.value
let i = 0
while (i < msgs.length) {
if (msgs[i].role === "user") {
const assistantMsg = msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
result.push({ userMsg: msgs[i], assistantMsg, index: i })
i += assistantMsg ? 2 : 1
} else {
i++
}
}
return result
})
async function deletePair(assistantMsgId: number) {
try {
await Prompt.deleteMessagePair(assistantMsgId)
const msgIdx = messages.value.findIndex((m) => m.id === assistantMsgId)
if (msgIdx >= 1) {
messages.value.splice(msgIdx - 1, 2)
}
naiveMessage.success("已删除")
} catch {
naiveMessage.error("删除失败,请重试")
}
}
function send() { function send() {
const text = input.value.trim() const text = input.value.trim()
if (!text || streaming.value) return if (!text || streaming.value) return
@@ -133,7 +197,6 @@ function renderContent(msg: { role: string; content: string }): string {
return renderMarkdown(msg.content) return renderMarkdown(msg.content)
} }
// Auto-scroll to bottom on new messages
watch([() => messages.value.length, streamingContent], () => { watch([() => messages.value.length, streamingContent], () => {
nextTick(() => { nextTick(() => {
if (messagesRef.value) { if (messagesRef.value) {
@@ -289,4 +352,38 @@ watch([() => messages.value.length, streamingContent], () => {
padding: 12px; padding: 12px;
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
} }
.message-pair {
position: relative;
margin-bottom: 4px;
}
.message-pair .message {
margin-bottom: 16px;
}
.pair-delete-btn {
display: none;
position: absolute;
top: 0;
right: 0;
background: none;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
color: #bbb;
line-height: 1;
transition: color 0.15s, border-color 0.15s;
}
.pair-delete-btn:hover {
color: #e03e3e;
border-color: #e03e3e;
}
.message-pair.has-delete:hover .pair-delete-btn {
display: inline-flex;
align-items: center;
}
</style> </style>

View File

@@ -183,13 +183,17 @@ async function loadChallenge() {
assets.value = await TaskAssets.listChallenge(display) assets.value = await TaskAssets.listChallenge(display)
if (!authed.value) return if (!authed.value) return
connectPrompt(data.task_ptr) connectPrompt(data.task_ptr)
setOnCodeComplete(async (code) => { setOnCodeComplete(async (code, messageId) => {
try { try {
await Submission.create(taskId.value, { await Submission.create(
taskId.value,
{
html: code.html ?? "", html: code.html ?? "",
css: code.css ?? "", css: code.css ?? "",
js: code.js ?? "", js: code.js ?? "",
}) },
messageId,
)
message.success("已自动提交本次对话生成的代码") message.success("已自动提交本次对话生成的代码")
} catch { } catch {
// 静默失败,不打扰用户 // 静默失败,不打扰用户

View File

@@ -5,6 +5,7 @@ import { html, css, js } from "./editors"
export interface PromptMessage { export interface PromptMessage {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
id?: number // assistant message backend pk (for deletion)
code?: { html: string | null; css: string | null; js: string | null } code?: { html: string | null; css: string | null; js: string | null }
created?: string created?: string
} }
@@ -15,11 +16,10 @@ export const connected = ref(false)
export const streaming = ref(false) export const streaming = ref(false)
export const streamingContent = ref("") export const streamingContent = ref("")
let _onCodeComplete: let _onCodeComplete:
| ((code: { | ((
html: string | null code: { html: string | null; css: string | null; js: string | null },
css: string | null messageId: number
js: string | null ) => void)
}) => void)
| null = null | null = null
export function setOnCodeComplete(fn: typeof _onCodeComplete) { export function setOnCodeComplete(fn: typeof _onCodeComplete) {
@@ -45,14 +45,10 @@ export function connectPrompt(taskId: number) {
if (data.type === "init") { if (data.type === "init") {
streaming.value = false streaming.value = false
streamingContent.value = "" streamingContent.value = ""
// Skip overwriting messages if HTTP preload already loaded this conversation.
// If conversation_id differs (e.g. after "新对话"), always overwrite.
const alreadyLoaded = conversationId.value === data.conversation_id const alreadyLoaded = conversationId.value === data.conversation_id
conversationId.value = data.conversation_id conversationId.value = data.conversation_id
if (!alreadyLoaded) { if (!alreadyLoaded) {
messages.value = data.messages || [] messages.value = data.messages || []
// Apply code from last assistant message if exists
// (skipped when HTTP preload already loaded and applied)
const lastAssistant = [...messages.value] const lastAssistant = [...messages.value]
.reverse() .reverse()
.find((m) => m.role === "assistant" && m.code) .find((m) => m.role === "assistant" && m.code)
@@ -65,18 +61,17 @@ export function connectPrompt(taskId: number) {
streamingContent.value += data.content streamingContent.value += data.content
} else if (data.type === "complete") { } else if (data.type === "complete") {
streaming.value = false streaming.value = false
// Push the full assistant message
messages.value.push({ messages.value.push({
role: "assistant", role: "assistant",
content: streamingContent.value, content: streamingContent.value,
id: data.message_id,
code: data.code, code: data.code,
}) })
streamingContent.value = "" streamingContent.value = ""
// Apply code to editors
if (data.code) { if (data.code) {
applyCode(data.code) applyCode(data.code)
if (_onCodeComplete) { if (_onCodeComplete) {
_onCodeComplete(data.code) _onCodeComplete(data.code, data.message_id)
} }
} }
} else if (data.type === "error") { } else if (data.type === "error") {
@@ -115,7 +110,6 @@ export function sendPrompt(content: string, model: string = "") {
} }
export function stopPrompt() { export function stopPrompt() {
// Remove the user message added to UI (not yet saved in DB)
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"
@@ -124,7 +118,6 @@ export function stopPrompt() {
} }
streaming.value = false streaming.value = false
streamingContent.value = "" streamingContent.value = ""
// connectPrompt closes old WS (triggering backend disconnect cleanup) then reconnects
if (_currentTaskId) { if (_currentTaskId) {
connectPrompt(_currentTaskId) connectPrompt(_currentTaskId)
} }