公司三个页面都要塞 AI 对话框:客服页、文档问答页、后台运营页。我先是复制粘贴了两遍,第三遍实在受不了------改个流式逻辑要三处同步,漏一处就出 bug。狠下心抽了个可复用的 <AiChat> 组件,过程里对「该抽什么、不该抽什么」有了新认识。复盘一下。
第一版抽错了:把 UI 焊死了
我的第一版把消息气泡的样式、头像、布局全写死在组件里,参数只暴露一个 apiUrl。结果客服页要圆角气泡、文档页要纯文本流、后台要紧凑表格风,三个设计稿一来,我的「通用组件」当场报废,又开始堆 props.theme === 'xxx' 的分支,越堆越烂。
教训:UI 是最不该硬抽的部分。第二版我把渲染权用 slot 交还给调用方,组件只管「对话这件事的逻辑」:
xml
<!-- AiChat.vue 暴露 slot,UI 由外部决定 -->
<template>
<div class="ai-chat">
<slot name="message" v-for="m in messages" :key="m.id" :message="m" />
<slot name="input" :send="send" :stop="stop" :loading="isStreaming" />
</div>
</template>
调用方爱怎么画怎么画,组件不插手长相。这一刀切对了之后,三个页面复用率才真上来。
逻辑用 composable,组件只是壳
更进一步,我发现连那个「壳」组件都不是必须的------真正可复用的是对话逻辑本身。把它抽成 composable,组件想要就用,不要也能裸用:
ini
// useAiChat.ts
export function useAiChat(opts: { endpoint: string }) {
const messages = ref<Message[]>([]);
const isStreaming = ref(false);
let ctrl: AbortController | null = null;
async function send(text: string) {
messages.value.push({ id: uid(), role: 'user', content: text });
const assistant = reactive<Message>({ id: uid(), role: 'assistant', content: '' });
messages.value.push(assistant);
isStreaming.value = true;
ctrl = new AbortController();
try {
for await (const delta of streamChat(opts.endpoint, text, ctrl.signal)) {
assistant.content += delta; // reactive 对象,直接改属性触发更新
}
} finally {
isStreaming.value = false;
}
}
function stop() { ctrl?.abort(); }
return { messages, isStreaming, send, stop };
}
这里有个 Vue3 的小细节坑:往 messages.value 里 push 普通对象,然后流式改它的 content,更新有时不触发。得保证那个 assistant 消息是 reactive 的(或整个数组的元素都经过响应式包装),直接改属性才会被追踪。我在这上面卡了快一个小时,盯着「数据变了视图不动」一脸问号。
别把 fetch 逻辑也焊进 composable
第二个取舍:要不要把请求实现写死在 useAiChat 里?我最后没有,把 streamChat 作为可替换的传输层注入。因为有的页面走 SSE、有的走 WebSocket,还有的本地 mock 联调。写死传输层,等于又造了个换皮就废的组件。
php
useAiChat({
endpoint: '/api/chat',
transport: customWsTransport, // 可选,默认 SSE
});
一个没做到的理想态
我本想把「消息持久化」也一并封进去,结果发现三个页面的存储诉求差太多------客服要存后端、文档问答只要 session 内存、后台要本地草稿。硬塞进组件只会让它变臃肿。最后我把持久化彻底踢出去,组件只 emit 消息变更事件,存哪由调用方接。组件因此「不够开箱即用」,每个页面还得自己写几行存储------这是我为了保住它的纯粹故意留的口子,算个清醒的妥协。
模型和工具编排那层我没自己搭,对话能力直接挂讯飞这种 MaaS 的 API 上,省掉自建算力的负担,我专心把组件的边界划清楚。
你们的 AI 对话组件,slot 和 composable 是怎么切的?评论区交流下抽象的尺度。