add prompt assistant
This commit is contained in:
244
src/components/ai/GuidancePanel.vue
Normal file
244
src/components/ai/GuidancePanel.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="isOpen"
|
||||
preset="card"
|
||||
title="调教提示词"
|
||||
style="width: min(560px, 92vw)"
|
||||
:mask-closable="!streaming"
|
||||
@update:show="onModalClose"
|
||||
>
|
||||
<div class="guidance-body">
|
||||
<div class="guidance-messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="msg.id ?? i"
|
||||
class="guidance-msg"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
|
||||
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="streaming" class="guidance-msg assistant">
|
||||
<div class="msg-role">AI 教练</div>
|
||||
<div v-if="!displayStreamingContent" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="msg-content"
|
||||
v-html="renderMarkdown(displayStreamingContent)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="ready-hint">
|
||||
<Icon icon="lucide:check-circle" :width="16" />
|
||||
提示词已优化,可以生成代码了!
|
||||
</div>
|
||||
|
||||
<div class="guidance-input">
|
||||
<n-input
|
||||
v-model:value="draftPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
placeholder="修改你的提示词后发送..."
|
||||
:disabled="streaming"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||
<div>
|
||||
<n-button
|
||||
v-if="streaming"
|
||||
type="error"
|
||||
size="small"
|
||||
@click="stopGuidance"
|
||||
>
|
||||
停止
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim() || !connected"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button
|
||||
:type="isReady ? 'primary' : 'default'"
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim()"
|
||||
@click="generate"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:play" :width="14" />
|
||||
</template>
|
||||
生成代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue"
|
||||
import { marked } from "marked"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
isReady,
|
||||
isOpen,
|
||||
connected,
|
||||
initialPrompt,
|
||||
sendGuidance,
|
||||
stopGuidance,
|
||||
closeGuidance,
|
||||
} from "../../store/guidance"
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [prompt: string]
|
||||
}>()
|
||||
|
||||
const draftPrompt = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
const displayStreamingContent = computed(() =>
|
||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
)
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
sendGuidance(text)
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text) return
|
||||
emit("generate", text)
|
||||
closeGuidance()
|
||||
}
|
||||
|
||||
function onModalClose(show: boolean) {
|
||||
if (!show && !streaming.value) closeGuidance()
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) draftPrompt.value = initialPrompt.value
|
||||
})
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guidance-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-messages {
|
||||
min-height: 160px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-msg .msg-role {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-role {
|
||||
color: #2080f0;
|
||||
}
|
||||
|
||||
.guidance-msg.assistant .msg-role {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.guidance-msg.assistant .msg-content {
|
||||
background: #f0f7ff;
|
||||
border-left: 3px solid #2080f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.msg-content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ready-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #18a058;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
background: #f0fff4;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b8e8cc;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,7 @@
|
||||
<div class="streaming-hint">AI 正在思考中…</div>
|
||||
</div>
|
||||
</div>
|
||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||
<div class="input-area">
|
||||
<n-input
|
||||
v-model:value="input"
|
||||
@@ -54,6 +55,16 @@
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||
<n-button
|
||||
secondary
|
||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||
title="调教提示词"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-select
|
||||
v-model:value="selectedModel"
|
||||
:options="modelOptions"
|
||||
@@ -82,6 +93,8 @@ import { useStorage } from "@vueuse/core"
|
||||
import { marked, Renderer } from "marked"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
import { openGuidance } from "../../store/guidance"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
@@ -89,6 +102,7 @@ import {
|
||||
connected,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
} from "../../store/prompt"
|
||||
import { Prompt } from "../../api"
|
||||
|
||||
@@ -143,6 +157,17 @@ function send() {
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
function startGuidance() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !currentTaskId.value) return
|
||||
openGuidance(currentTaskId.value, text)
|
||||
}
|
||||
|
||||
function onGuidanceGenerate(finalPrompt: string) {
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
const renderer = new Renderer()
|
||||
renderer.code = function ({ lang }: { text: string; lang?: string }) {
|
||||
const label = lang ? lang.toUpperCase() : "CODE"
|
||||
|
||||
@@ -11,21 +11,7 @@
|
||||
vertical
|
||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||
>
|
||||
<n-flex justify="space-between" style="flex-shrink: 0">
|
||||
<n-button
|
||||
secondary
|
||||
@click="
|
||||
() =>
|
||||
goHome(
|
||||
$router,
|
||||
taskTab,
|
||||
taskTab === TASK_TYPE.Challenge ? challengeDisplay : step,
|
||||
)
|
||||
"
|
||||
>
|
||||
首页
|
||||
</n-button>
|
||||
<n-flex align="center">
|
||||
<n-flex align="center" justify="end">
|
||||
<n-select
|
||||
:value="query.flag"
|
||||
style="width: 100px"
|
||||
@@ -64,7 +50,6 @@
|
||||
>
|
||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
flex-height
|
||||
|
||||
111
src/store/guidance.ts
Normal file
111
src/store/guidance.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ref } from "vue"
|
||||
import { WS_BASE_URL } from "../utils/const"
|
||||
|
||||
export interface GuidanceMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export const messages = ref<GuidanceMessage[]>([])
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
export const isReady = ref(false)
|
||||
export const isOpen = ref(false)
|
||||
export const initialPrompt = ref("")
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let _taskId = 0
|
||||
|
||||
function setupHandlers(socket: WebSocket) {
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
if (initialPrompt.value) {
|
||||
sendToSocket(initialPrompt.value)
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === "init") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
} else if (data.type === "stream") {
|
||||
streaming.value = true
|
||||
streamingContent.value += data.content
|
||||
} else if (data.type === "complete") {
|
||||
streaming.value = false
|
||||
const content = streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
messages.value.push({ role: "assistant", content, id: data.message_id })
|
||||
streamingContent.value = ""
|
||||
if (data.is_ready) isReady.value = true
|
||||
} else if (data.type === "error") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
messages.value.push({ role: "assistant", content: data.content })
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function sendToSocket(content: string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
streaming.value = true
|
||||
messages.value.push({ role: "user", content })
|
||||
ws.send(JSON.stringify({ type: "message", content }))
|
||||
}
|
||||
|
||||
export function openGuidance(taskId: number, prompt: string) {
|
||||
if (!taskId) return
|
||||
|
||||
_taskId = taskId
|
||||
initialPrompt.value = prompt
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
streamingContent.value = ""
|
||||
isOpen.value = true
|
||||
|
||||
if (ws) ws.close()
|
||||
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${taskId}/`)
|
||||
setupHandlers(ws)
|
||||
}
|
||||
|
||||
export function sendGuidance(content: string) {
|
||||
sendToSocket(content)
|
||||
}
|
||||
|
||||
export function stopGuidance() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
if (_taskId) {
|
||||
if (ws) ws.close()
|
||||
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${_taskId}/`)
|
||||
initialPrompt.value = ""
|
||||
setupHandlers(ws)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeGuidance() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
isOpen.value = false
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
initialPrompt.value = ""
|
||||
_taskId = 0
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const conversationId = ref<string>("")
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
export const currentTaskId = ref(0)
|
||||
let _onCodeComplete:
|
||||
| ((
|
||||
code: { html: string | null; css: string | null; js: string | null },
|
||||
@@ -31,6 +32,7 @@ let _currentTaskId = 0
|
||||
|
||||
export function connectPrompt(taskId: number) {
|
||||
_currentTaskId = taskId
|
||||
currentTaskId.value = taskId
|
||||
if (ws) ws.close()
|
||||
|
||||
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
|
||||
@@ -99,6 +101,8 @@ export function disconnectPrompt() {
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
currentTaskId.value = 0
|
||||
_currentTaskId = 0
|
||||
_onCodeComplete = null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user