feat: 一些优化
my-v0 / deploy (push) Successful in 26s

This commit is contained in:
2026-08-30 00:10:55 +08:00
parent 4f69f67f0a
commit b6a1b3b4e1
8 changed files with 195 additions and 53 deletions
+1
View File
@@ -40,3 +40,4 @@ __screenshots__/
AGENTS.md
.agents
skills-lock.json
+2
View File
@@ -95,6 +95,7 @@ const onCommand = (command: keyof typeof commendMap) => {
:scale="scale"
:lines="lines"
@zoomchange="onZoomChange"
@mousedown="onClearSelect"
>
<div
ref="stageRef"
@@ -107,6 +108,7 @@ const onCommand = (command: keyof typeof commendMap) => {
@dragover.prevent
@drop="onDrop"
@mousedown.self="onClearSelect"
@mousedown.stop
>
<el-dropdown
v-for="(node, index) in nodes"
@@ -72,6 +72,13 @@ export const useAgentChat = () => {
if (!assistant.content) assistant.content = text
}
/** 中断后收口:有内容就定稿,空气泡直接丢掉 */
const settleAborted = (assistant: AgentChatMessage) => {
assistant.final = true
if (assistant.content || assistant.patches.length) return
messages.value = messages.value.filter((item) => item.id !== assistant.id)
}
/** token 拼进 content;有 progress 才记;stop 帧根上才 setPage / 收 patches */
const handleFrame = (assistant: AgentChatMessage, frame: StreamFrame) => {
const choice = frame.choices?.[0]
@@ -153,7 +160,7 @@ export const useAgentChat = () => {
}),
})
if (!res.ok) {
let detail = `指令失败 (${res.status})`
let detail = `错误 (${res.status} ${res.statusText})`
try {
const body = await res.json()
if (body.errors?.length) detail = body.errors.join('')
@@ -164,9 +171,13 @@ export const useAgentChat = () => {
}
if (!res.body) throw new Error('没有响应体')
await consumeStream(res.body.getReader(), live)
if (!live.final) failAssistant(live, '没有收到结果')
if (abortController?.signal.aborted) settleAborted(live)
else if (!live.final) failAssistant(live, '没有收到结果')
} catch (err) {
if (isAbortError(err)) return
if (isAbortError(err)) {
settleAborted(live)
return
}
const text = err instanceof Error ? err.message : '指令失败'
failAssistant(live, text)
ElMessage.error(text)
@@ -175,6 +186,12 @@ export const useAgentChat = () => {
}
}
/** 用户点停止:中断 fetch,已输出的 token 保留 */
const stop = () => {
if (!running.value) return
abortController?.abort()
}
const clear = () => {
abortController?.abort()
abortController = null
@@ -186,5 +203,5 @@ export const useAgentChat = () => {
abortController?.abort()
})
return { messages, running, send, clear }
return { messages, running, send, stop, clear }
}
+96 -16
View File
@@ -12,12 +12,13 @@ defineOptions({
const editorStore = useEditorStore()
const { selectedNodeIds } = storeToRefs(editorStore)
const { messages, running, send, clear } = useAgentChat()
const { messages, running, send, stop, clear } = useAgentChat()
// 输入
const draft = ref('')
const scrollRootRef = useTemplateRef<HTMLElement>('scrollRootRef')
const contentRootRef = useTemplateRef<HTMLElement>('contentRootRef')
const livePatchListRef = useTemplateRef<HTMLElement>('livePatchListRef')
const { scheduleScrollToBottom } = useStickToBottom(scrollRootRef, contentRootRef)
const selectedHint = computed(() => {
@@ -27,20 +28,43 @@ const selectedHint = computed(() => {
const canSend = computed(() => Boolean(draft.value.trim()) && !running.value)
/** 正在输出的那条 bot(对话末尾) */
const lastAssistantId = computed(() => {
for (let i = messages.value.length - 1; i >= 0; i--) {
if (messages.value[i]?.role === 'assistant') return messages.value[i]?.id
}
return ''
})
/** 只盯最后一条,避免每个 token 深监听整份对话 */
const latestOutputSignal = computed(() => {
const latest = messages.value[messages.value.length - 1]
if (!latest) return '0'
return `${messages.value.length}:${latest.id}:${latest.content.length}:${latest.final}:${latest.patches.length}`
})
/** 当前回复的补丁列表超出时跟到最底 */
const stickPatchList = () => {
const el = livePatchListRef.value
if (!el || el.scrollHeight <= el.clientHeight) return
el.scrollTop = el.scrollHeight
}
watch(
messages,
latestOutputSignal,
async () => {
await nextTick()
scheduleScrollToBottom()
stickPatchList()
},
{ deep: true },
{ flush: 'post' },
)
/** Enter 发送,Shift+Enter 换行 */
/** Enter 发送,Shift+Enter 换行;输出中 Enter 不打断 */
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSend()
if (!running.value) onSend()
}
}
@@ -50,6 +74,12 @@ const onSend = () => {
draft.value = ''
send(text)
}
/** 输出中点停止,否则发送 */
const onAction = () => {
if (running.value) stop()
else onSend()
}
</script>
<template>
@@ -66,18 +96,18 @@ const onSend = () => {
</span>
</div>
<div ref="scrollRootRef" class="flex-1 min-h-0">
<div ref="scrollRootRef" class="flex-1 min-h-0 overflow-auto overscroll-contain" tabindex="0">
<div
ref="contentRootRef"
class="h-full flex flex-col gap-3 p-3 overflow-auto"
:class="{ 'items-center justify-center': !messages.length }"
class="flex min-h-full flex-col gap-3 p-3"
:class="{ 'h-full 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>用自然语言改画布</p>
<p class="text-xs text-white/35">试试加一张柱状图</p>
</div>
@@ -92,9 +122,12 @@ const onSend = () => {
<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)"
:class="{
glow: running && msg.id === lastAssistantId,
}"
>
<Icon
v-if="!msg.content && !msg.patches.length"
v-if="!msg.final && !msg.content && !msg.patches.length"
icon="svg-spinners:3-dots-fade"
width="20"
class="text-white/50"
@@ -112,7 +145,8 @@ const onSend = () => {
</div>
<div
v-if="msg.patches.length"
class="mt-2 flex flex-col gap-1 font-mono text-xs text-white/45"
:ref="msg.id === lastAssistantId ? 'livePatchListRef' : undefined"
class="mt-2 flex flex-col gap-1 font-mono text-xs text-white/45 max-h-26 overflow-y-auto"
>
<div v-for="(patch, index) in msg.patches" :key="index">
{{ patch.op }}
@@ -131,7 +165,7 @@ const onSend = () => {
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 发送"
placeholder="描述你想改的画布,Enter 发送"
:disabled="running"
@keydown="onKeydown"
/>
@@ -139,11 +173,14 @@ const onSend = () => {
<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"
:class="{ 'opacity-50 cursor-not-allowed': !canSend && !running }"
:title="running ? '停止' : '发送'"
@click="onAction"
>
<Icon icon="solar:plain-2-bold" width="14" />
<span v-if="running" class="flex h-[14px] w-[14px] items-center justify-center">
<span class="block h-2.5 w-2.5 rounded-[2px] bg-current" />
</span>
<Icon v-else icon="solar:plain-2-bold" width="14" />
</span>
</div>
</div>
@@ -155,4 +192,47 @@ const onSend = () => {
:deep(.markdownRender .paragraph-node) {
margin: 0;
}
@property --a {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.glow {
--a: 0deg;
position: relative;
background: color-mix(in oklab, var(--color-white) 5%, transparent);
}
.glow::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px; /* 边框厚度 */
pointer-events: none;
background: conic-gradient(
from var(--a),
transparent 0%,
transparent 58%,
#6b7280 66%,
#d7dde4 73%,
#ffffff 78%,
#b8c0c8 84%,
transparent 92%
);
animation: spin-a 2.4s linear infinite;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
@keyframes spin-a {
to {
--a: 360deg;
}
}
</style>
@@ -87,7 +87,7 @@ const onConfirmEvent = () => {
</script>
<template>
<div class="px-2">
<div class="h-full px-2">
<div class="flex items-center justify-between font-bold p-1">
<span>{{ selectedNode?.name }}</span>
<div class="flex gap-2">
@@ -100,41 +100,43 @@ const onConfirmEvent = () => {
</div>
</div>
<el-tabs v-model="activeTab" type="border-card" stretch>
<el-tab-pane label="属性" name="property">
<el-collapse v-model="activeNames">
<el-collapse-item title="布局属性" name="layout">
<FormCreate :setters="layoutSetters" :form-data="selectedNode" />
</el-collapse-item>
<el-collapse-item title="组件属性" name="component">
<FormCreate :setters="setters" :form-data="selectedNode" />
</el-collapse-item>
</el-collapse>
</el-tab-pane>
<div class="h-full overflow-y-auto">
<el-tabs v-model="activeTab" type="border-card" stretch>
<el-tab-pane label="属性" name="property">
<el-collapse v-model="activeNames">
<el-collapse-item title="布局属性" name="layout">
<FormCreate :setters="layoutSetters" :form-data="selectedNode" />
</el-collapse-item>
<el-collapse-item title="组件属性" name="component">
<FormCreate :setters="setters" :form-data="selectedNode" />
</el-collapse-item>
</el-collapse>
</el-tab-pane>
<el-tab-pane label="数据源" name="data-source">
<DataSource />
</el-tab-pane>
</el-tabs>
<el-tab-pane label="数据源" name="data-source">
<DataSource />
</el-tab-pane>
</el-tabs>
<!-- 编辑 JSON -->
<el-drawer v-model="jsonVisible" title="编辑 JSON" class="json-drawer" destroy-on-close>
<MonacoEditor v-model="jsonContent" lang="json" />
<!-- 编辑 JSON -->
<el-drawer v-model="jsonVisible" title="编辑 JSON" class="json-drawer" destroy-on-close>
<MonacoEditor v-model="jsonContent" lang="json" />
<template #footer>
<el-button @click="jsonVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirm">确认</el-button>
</template>
</el-drawer>
<template #footer>
<el-button @click="jsonVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirm">确认</el-button>
</template>
</el-drawer>
<!-- 事件配置 -->
<el-dialog destroy-on-close title="事件配置" width="800" v-model="eventVisible">
<NodeEvents ref="nodeEvents" />
<template #footer>
<el-button @click="eventVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirmEvent">确认</el-button>
</template>
</el-dialog>
<!-- 事件配置 -->
<el-dialog destroy-on-close title="事件配置" width="800" v-model="eventVisible">
<NodeEvents ref="nodeEvents" />
<template #footer>
<el-button @click="eventVisible = false">取消</el-button>
<el-button type="primary" @click="onConfirmEvent">确认</el-button>
</template>
</el-dialog>
</div>
</div>
</template>
+11 -1
View File
@@ -9,17 +9,27 @@ const props = defineProps<{
schema: MaterialSchema
}>()
const justifyMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
}
const textStyle = computed(() => {
const style = props.schema.style
const textAlign = style?.textAlign ?? 'center'
return {
...style,
fontSize: style?.fontSize ? style.fontSize + 'px' : undefined,
textAlign,
justifyContent: justifyMap[textAlign] ?? 'center',
alignItems: style?.alignItems ?? 'center',
}
})
</script>
<template>
<div :style="textStyle">{{ schema.props.content }}</div>
<div :style="textStyle" class="flex h-full w-full min-w-0">{{ schema.props.content }}</div>
</template>
<style scoped></style>
+26
View File
@@ -22,6 +22,30 @@ const textMaterial: MaterialDefinition = {
label: '字号',
key: 'style.fontSize',
},
{
type: 'select',
label: '水平对齐',
key: 'style.textAlign',
props: {
options: [
{ label: '左对齐', value: 'left' },
{ label: '居中', value: 'center' },
{ label: '右对齐', value: 'right' },
],
},
},
{
type: 'select',
label: '垂直对齐',
key: 'style.alignItems',
props: {
options: [
{ label: '顶部', value: 'flex-start' },
{ label: '居中', value: 'center' },
{ label: '底部', value: 'flex-end' },
],
},
},
],
eventOptions: commonEventOptions,
schema: {
@@ -36,6 +60,8 @@ const textMaterial: MaterialDefinition = {
style: {
color: '#fff',
fontSize: 16,
textAlign: 'center',
alignItems: 'center',
},
props: {
content: '文本内容',
+4
View File
@@ -1,6 +1,10 @@
@import 'tailwindcss';
@import 'markstream-vue/index.css' layer(components);
html {
overflow-y: hidden;
}
@supports not selector(::-webkit-scrollbar) {
* {
scrollbar-width: thin;