feat: 接入agent
my-v0 / deploy (push) Successful in 56s

This commit is contained in:
2026-08-29 19:44:11 +08:00
parent 0648e3742e
commit 4f69f67f0a
10 changed files with 521 additions and 9 deletions
+1
View File
@@ -39,3 +39,4 @@ __screenshots__/
*.timestamp-*-*.mjs
AGENTS.md
.agents
+1
View File
@@ -19,6 +19,7 @@
"axios": "^1.19.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.4",
"markstream-vue": "^2.0.6",
"mockjs": "^1.1.0",
"monaco-editor": "0.55.1",
"pinia": "^4.0.2",
+145 -7
View File
@@ -13,13 +13,16 @@ importers:
version: 4.3.3
axios:
specifier: ^1.19.0
version: 1.19.0
version: 1.19.0(debug@4.4.3)
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus:
specifier: ^2.14.4
version: 2.14.4(vue@3.5.41(typescript@6.0.3))
markstream-vue:
specifier: ^2.0.6
version: 2.0.6(vue@3.5.41(typescript@6.0.3))
mockjs:
specifier: ^1.1.0
version: 1.1.0
@@ -291,6 +294,9 @@ packages:
'@cfcs/core@0.0.6':
resolution: {integrity: sha512-FxfJMwoLB8MEMConeXUCqtMGqxdtePQxRBOiGip9ULcYYam3WfCgoY6xdnMaSkYvRvmosp5iuG+TiPofm65+Pw==}
'@chenglou/pretext@0.0.8':
resolution: {integrity: sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==}
'@ctrl/tinycolor@4.2.0':
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
engines: {node: '>=14'}
@@ -845,6 +851,11 @@ packages:
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue-macros/common@3.1.4':
resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==}
@@ -1149,6 +1160,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
@@ -1622,6 +1637,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
@@ -1656,15 +1674,67 @@ packages:
magic-string@1.2.0:
resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==}
markdown-it-container@4.0.0:
resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
markdown-it-footnote@4.0.0:
resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
markdown-it-ins@4.0.0:
resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==}
markdown-it-mark@4.0.0:
resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==}
markdown-it-sup@2.0.0:
resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==}
markdown-it-task-checkbox@1.0.6:
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
markdown-it-ts@1.1.0:
resolution: {integrity: sha512-ycYoj3jeP0zHW9u4vxtSuuq9JaitIRxxYbRtns9D4fDR5mzSpFnb855KclFBGiKFJxVi+8EkMB938oZpdDVc0A==}
engines: {node: '>=20.19'}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
markstream-core@2.0.6:
resolution: {integrity: sha512-kSGNAoTArTu9LxwyfZI+fuxanjhxwPtlW+yJM+5IJhW2jtsH9p7mSpApHC6YtJLnzeXCGS2XlqIoivzIdzy6iA==}
markstream-vue@2.0.6:
resolution: {integrity: sha512-87skNadimBbQFZbb6kNgIrCpXr5x63YW4MYkRbWHh/g/1+BHJ2t3iIzEsSIIES1yn29STf4ZLw1ruepZqXZeXg==}
peerDependencies:
'@antv/infographic': ^0.2.3
'@terrastruct/d2': '>=0.1.33'
katex: '>=0.16.22'
mermaid: '>=11'
stream-diffs: '>=0.0.2'
vue: '>=3.0.0'
vue-i18n: '>=9'
peerDependenciesMeta:
'@antv/infographic':
optional: true
'@terrastruct/d2':
optional: true
katex:
optional: true
mermaid:
optional: true
stream-diffs:
optional: true
vue-i18n:
optional: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -1862,6 +1932,10 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1940,6 +2014,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stream-markdown-parser@1.2.12:
resolution: {integrity: sha512-gT6GWlOfNTVGBEM66Me1DZ1cZZ/Mm4tcpJ4br8Zvz2CQhiWueEv+xFBBHCwls/XG4E63DygTd890rDdV+D6JxA==}
strip-literal@4.0.0:
resolution: {integrity: sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw==}
@@ -1987,6 +2064,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@3.0.0:
resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@@ -2101,6 +2181,7 @@ packages:
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
vue: '*'
vite-plugin-vue-inspector@6.0.0:
resolution: {integrity: sha512-OpyITJLgZNibxlrik1EmRtvXHDjLRxNPsWkGFTERZs2LgMEdG4W0WoFt5GIgp3a3jRou+eJR8U1zOBk/XQgEbw==}
@@ -2473,6 +2554,8 @@ snapshots:
dependencies:
'@egjs/component': 3.0.5
'@chenglou/pretext@0.0.8': {}
'@ctrl/tinycolor@4.2.0': {}
'@daybrush/utils@1.13.0': {}
@@ -2907,11 +2990,13 @@ snapshots:
'@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28':
'@volar/typescript@2.4.28(typescript@6.0.3)':
dependencies:
'@volar/language-core': 2.4.28
path-browserify: 1.0.1
vscode-uri: 3.1.0
optionalDependencies:
typescript: 6.0.3
'@vue-macros/common@3.1.4(vue@3.5.41(typescript@6.0.3))':
dependencies:
@@ -3106,9 +3191,9 @@ snapshots:
asynckit@0.4.0: {}
axios@1.19.0:
axios@1.19.0(debug@4.4.3):
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.16.0(debug@4.4.3)
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
@@ -3281,6 +3366,8 @@ snapshots:
entities@7.0.1: {}
entities@8.0.0: {}
error-stack-parser-es@1.0.5: {}
es-define-property@1.0.1: {}
@@ -3442,7 +3529,9 @@ snapshots:
flatted@3.4.4: {}
follow-redirects@1.16.0: {}
follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
form-data@4.0.6:
dependencies:
@@ -3683,6 +3772,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
linkify-it@6.1.0:
dependencies:
uc.micro: 3.0.0
local-pkg@1.2.1:
dependencies:
mlly: 1.8.2
@@ -3719,10 +3812,41 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it-container@4.0.0: {}
markdown-it-footnote@4.0.0: {}
markdown-it-ins@4.0.0: {}
markdown-it-mark@4.0.0: {}
markdown-it-sup@2.0.0: {}
markdown-it-task-checkbox@1.0.6: {}
markdown-it-ts@1.1.0:
dependencies:
entities: 8.0.0
linkify-it: 6.1.0
mdurl: 2.1.0
punycode.js: 2.3.1
marked@14.0.0: {}
markstream-core@2.0.6: {}
markstream-vue@2.0.6(vue@3.5.41(typescript@6.0.3)):
dependencies:
'@chenglou/pretext': 0.0.8
'@floating-ui/dom': 1.8.0
markstream-core: 2.0.6
stream-markdown-parser: 1.2.12
vue: 3.5.41(typescript@6.0.3)
math-intrinsics@1.1.0: {}
mdurl@2.1.0: {}
memoize-one@6.0.0: {}
memorystream@0.3.1: {}
@@ -3916,6 +4040,8 @@ snapshots:
proxy-from-env@2.1.0: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {}
quansync@0.2.11: {}
@@ -4017,6 +4143,16 @@ snapshots:
source-map-js@1.2.1: {}
stream-markdown-parser@1.2.12:
dependencies:
markdown-it-container: 4.0.0
markdown-it-footnote: 4.0.0
markdown-it-ins: 4.0.0
markdown-it-mark: 4.0.0
markdown-it-sup: 2.0.0
markdown-it-task-checkbox: 1.0.6
markdown-it-ts: 1.1.0
strip-literal@4.0.0:
dependencies:
js-tokens: 10.0.0
@@ -4059,6 +4195,8 @@ snapshots:
typescript@6.0.3: {}
uc.micro@3.0.0: {}
ufo@1.6.4: {}
undici-types@7.18.2: {}
@@ -4194,10 +4332,10 @@ snapshots:
vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
vite-plugin-inspect: 11.4.1(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
vite-plugin-vue-inspector: 6.0.0(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
vue: 3.5.41(typescript@6.0.3)
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@6.0.0(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
@@ -4284,7 +4422,7 @@ snapshots:
vue-tsc@3.3.9(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
'@volar/typescript': 2.4.28(typescript@6.0.3)
'@vue/language-core': 3.3.9
typescript: 6.0.3
+8 -1
View File
@@ -6,6 +6,7 @@ import MaterialPanel from '@/editor/panels/material/index.vue'
import LayerPanel from '@/editor/panels/layer/index.vue'
import CanvasRoot from '@/editor/canvas/index.vue'
import PropertyPanel from '@/editor/panels/property/index.vue'
import ChatPanel from '@/editor/panels/chat/index.vue'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { getPublishedPage } from '@/utils/publish'
@@ -32,6 +33,7 @@ provide('dataSource', dataSource)
const materialWidth = computed(() => (panelVisible.value.material ? '260px' : '0'))
const propertyWidth = computed(() => (panelVisible.value.property ? '160px' : '0'))
const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
const chatWidth = computed(() => (panelVisible.value.chat ? '360px' : '0'))
</script>
<template>
@@ -39,7 +41,7 @@ const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
<header class="flex items-center justify-between h-14 border-b border-white/20 px-5">
<div class="w-75"><ToolbarLeft /></div>
<div class="">工作区</div>
<div class="w-75"><ToolbarRight /></div>
<div class="w-80"><ToolbarRight /></div>
</header>
<main class="h-[calc(100%-56px)] flex">
<!-- 物料 -->
@@ -64,6 +66,11 @@ const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
>
属性
</PropertyPanel>
<!-- AI 对话 -->
<ChatPanel
class="w-[360px] min-h-0 border-l border-white/20 overflow-hidden transition-all"
:style="{ width: chatWidth }"
/>
</main>
</div>
</template>
@@ -0,0 +1,190 @@
import type { PageSchema } from '@/schema/page'
import { ElMessage } from 'element-plus'
import { useEditorStore } from '@/stores/editor'
export type AgentPatch = {
op: string
node_id?: string
payload?: object
}
export type AgentChatMessage = {
id: string
role: 'user' | 'assistant'
content: string
final: boolean
error?: boolean
patches: AgentPatch[]
}
type InstructPlan = {
summary?: string
patches?: AgentPatch[]
warnings?: string[]
}
type StreamFrame = {
choices?: Array<{
delta?: { content?: string }
finish_reason?: string | null
}>
progress?: {
type?: string
step?: string
status?: string
patch_count?: number
}
schema?: PageSchema
plan?: InstructPlan
warnings?: string[]
ok?: boolean
errors?: string[]
}
/** 拆一帧 SSE`data: ...` 或 `[DONE]` */
const parseSseBlock = (block: string) => {
const line = block.split('\n').find((item) => item.startsWith('data:'))
if (!line) return null
const raw = line.slice('data:'.length).trim()
if (raw === '[DONE]') return '[DONE]' as const
try {
return JSON.parse(raw) as StreamFrame
} catch {
return null
}
}
const isAbortError = (err: unknown) =>
(err instanceof DOMException && err.name === 'AbortError') ||
(err instanceof Error && err.name === 'AbortError')
export const useAgentChat = () => {
const editor = useEditorStore()
// 对话
const messages = ref<AgentChatMessage[]>([])
const running = ref(false)
let abortController: AbortController | null = null
const failAssistant = (assistant: AgentChatMessage, text: string) => {
assistant.error = true
assistant.final = true
if (!assistant.content) assistant.content = text
}
/** token 拼进 content;有 progress 才记;stop 帧根上才 setPage / 收 patches */
const handleFrame = (assistant: AgentChatMessage, frame: StreamFrame) => {
const choice = frame.choices?.[0]
const token = choice?.delta?.content
if (token) assistant.content += token
if (choice?.finish_reason !== 'stop') return
if (frame.ok === false) {
const text = frame.errors?.join('') || '指令失败'
failAssistant(assistant, text)
ElMessage.error(text)
return
}
assistant.patches = frame.plan?.patches ?? []
assistant.final = true
if (frame.schema) editor.setPage(frame.schema)
const warnings = frame.warnings?.length ? frame.warnings : frame.plan?.warnings
if (warnings?.length) ElMessage.warning(warnings.join(''))
}
const consumeStream = async (
reader: ReadableStreamDefaultReader<Uint8Array>,
assistant: AgentChatMessage,
) => {
const decoder = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop() || ''
for (const block of parts) {
const frame = parseSseBlock(block)
if (frame == null || frame === '[DONE]') continue
handleFrame(assistant, frame)
}
}
if (buf.trim()) {
const frame = parseSseBlock(buf)
if (frame != null && frame !== '[DONE]') handleFrame(assistant, frame)
}
}
/** POST /agent/instruct:只渲染 content token 和 stop 里的 patches */
const send = async (instruction: string) => {
const text = instruction.trim()
if (!text || running.value) return
abortController?.abort()
abortController = new AbortController()
const userMsg: AgentChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: text,
final: true,
patches: [],
}
const live = reactive<AgentChatMessage>({
id: crypto.randomUUID(),
role: 'assistant',
content: '',
final: false,
patches: [],
})
messages.value.push(userMsg, live)
running.value = true
try {
const res = await fetch('/agent/instruct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
instruction: text,
schema: editor.page,
selected_node_ids: editor.selectedNodeIds,
stream: true,
}),
})
if (!res.ok) {
let detail = `指令失败 (${res.status})`
try {
const body = await res.json()
if (body.errors?.length) detail = body.errors.join('')
} catch {
/* 非 JSON 错误体 */
}
throw new Error(detail)
}
if (!res.body) throw new Error('没有响应体')
await consumeStream(res.body.getReader(), live)
if (!live.final) failAssistant(live, '没有收到结果')
} catch (err) {
if (isAbortError(err)) return
const text = err instanceof Error ? err.message : '指令失败'
failAssistant(live, text)
ElMessage.error(text)
} finally {
running.value = false
}
}
const clear = () => {
abortController?.abort()
abortController = null
messages.value = []
running.value = false
}
onUnmounted(() => {
abortController?.abort()
})
return { messages, running, send, clear }
}
+158
View File
@@ -0,0 +1,158 @@
<!-- AI 对话面板 -->
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { storeToRefs } from 'pinia'
import { useStickToBottom } from 'markstream-vue/utils'
import { useEditorStore } from '@/stores/editor'
import { useAgentChat } from '@/editor/panels/chat/composables/useAgentChat'
defineOptions({
name: 'ChatPanel',
})
const editorStore = useEditorStore()
const { selectedNodeIds } = storeToRefs(editorStore)
const { messages, running, send, clear } = useAgentChat()
// 输入
const draft = ref('')
const scrollRootRef = useTemplateRef<HTMLElement>('scrollRootRef')
const contentRootRef = useTemplateRef<HTMLElement>('contentRootRef')
const { scheduleScrollToBottom } = useStickToBottom(scrollRootRef, contentRootRef)
const selectedHint = computed(() => {
const count = selectedNodeIds.value.length
return count ? `已选 ${count} 个节点` : '未选中节点'
})
const canSend = computed(() => Boolean(draft.value.trim()) && !running.value)
watch(
messages,
async () => {
await nextTick()
scheduleScrollToBottom()
},
{ deep: true },
)
/** Enter 发送,Shift+Enter 换行 */
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSend()
}
}
const onSend = () => {
if (!canSend.value) return
const text = draft.value
draft.value = ''
send(text)
}
</script>
<template>
<div class="h-full min-h-0 min-w-0 flex flex-col select-text">
<div class="h-10 shrink-0 flex items-center justify-between px-3 border-b border-white/20">
<span class="text-sm text-(--el-text-color-regular)">AI 助手</span>
<span
v-if="messages.length"
class="border p-1 rounded-lg cursor-pointer"
title="清空对话"
@click="clear"
>
<Icon icon="solar:trash-bin-minimalistic-linear" width="14" />
</span>
</div>
<div ref="scrollRootRef" class="flex-1 min-h-0">
<div
ref="contentRootRef"
class="h-full flex flex-col gap-3 p-3 overflow-auto"
:class="{ 'items-center justify-center': !messages.length }"
>
<div
v-if="!messages.length"
class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-white/50"
>
<Icon icon="solar:chat-round-dots-bold" width="28" />
<p>用自然语言改画布</p>
<p class="text-xs text-white/35">试试加一张柱状图</p>
</div>
<template v-for="msg in messages" :key="msg.id">
<div v-if="msg.role === 'user'" class="flex justify-end">
<div
class="max-w-[90%] rounded-lg bg-white/10 px-2.5 py-2 text-sm text-(--el-text-color-regular) whitespace-pre-wrap break-words"
>
{{ msg.content }}
</div>
</div>
<div v-else class="flex justify-start">
<div
class="max-w-[90%] min-w-0 rounded-lg bg-white/5 px-2.5 py-2 text-sm text-(--el-text-color-regular)"
>
<Icon
v-if="!msg.content && !msg.patches.length"
icon="svg-spinners:3-dots-fade"
width="20"
class="text-white/50"
/>
<div v-if="msg.content" class="markdownRender">
<MarkdownRender
mode="chat"
:content="msg.content"
:final="msg.final"
:is-dark="true"
:fade="false"
:typewriter="false"
:smooth-streaming="msg.final ? false : 'auto'"
/>
</div>
<div
v-if="msg.patches.length"
class="mt-2 flex flex-col gap-1 font-mono text-xs text-white/45"
>
<div v-for="(patch, index) in msg.patches" :key="index">
{{ patch.op }}
<span v-if="patch.node_id" class="text-white/25"> {{ patch.node_id }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<div class="shrink-0 border-t border-white/20 p-2.5">
<div class="rounded-lg border border-white/20 px-2 py-1.5">
<textarea
v-model="draft"
rows="3"
class="w-full resize-none bg-transparent text-sm text-(--el-text-color-regular) outline-none placeholder:text-white/35"
placeholder="描述你想改的画布,Enter 发送"
:disabled="running"
@keydown="onKeydown"
/>
<div class="flex items-center justify-between pt-1">
<span class="text-xs text-white/35">{{ selectedHint }}</span>
<span
class="border p-1 rounded-lg cursor-pointer"
:class="{ 'opacity-50 cursor-not-allowed': !canSend }"
title="发送"
@click="onSend"
>
<Icon icon="solar:plain-2-bold" width="14" />
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.markdownRender .paragraph-node) {
margin: 0;
}
</style>
+10 -1
View File
@@ -1,6 +1,7 @@
<!-- 右侧工具栏 -->
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { storeToRefs } from 'pinia'
import { useEditorStore } from '@/stores/editor'
import { ElMessage } from 'element-plus'
@@ -16,7 +17,7 @@ const MonacoEditor = defineAsyncComponent({
const router = useRouter()
const editorStore = useEditorStore()
const { page } = storeToRefs(editorStore)
const { page, panelVisible } = storeToRefs(editorStore)
// 全局数据源
const dataSourceVisible = ref(false)
const visible = ref(false)
@@ -125,6 +126,14 @@ const onPublish = () => {
<span class="border p-1 rounded-lg cursor-pointer" @click="onExport" title="导出">
<Icon icon="lets-icons:export"> </Icon>
</span>
<span
class="border p-1 rounded-lg cursor-pointer"
:class="{ 'bg-white/20': panelVisible.chat }"
title="AI 对话"
@click="panelVisible.chat = !panelVisible.chat"
>
<Icon icon="solar:chat-round-dots-bold" />
</span>
<input ref="inputRef" type="file" class="hidden" @change="onFileChange" />
+1
View File
@@ -11,6 +11,7 @@ export const useEditorStore = defineStore('editor', () => {
material: true,
layer: true,
property: true,
chat: true,
})
// 页面数据
+1
View File
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@import 'markstream-vue/index.css' layer(components);
@supports not selector(::-webkit-scrollbar) {
* {
+6
View File
@@ -44,5 +44,11 @@ export default defineConfig({
},
server: {
allowedHosts: ['www.qflink.xyz'],
proxy: {
'/agent': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})