![[项目篇25] 构建OpenCode机器人Web可视化交互界面:TaoToken统一Key接入Fastify+SSE热加载实战](http://pic.xiahunao.cn/yaotu/[项目篇25] 构建OpenCode机器人Web可视化交互界面:TaoToken统一Key接入Fastify+SSE热加载实战)
1. 为什么要把 OpenCode 机器人搬到浏览器里OpenCode 机器人跑在终端里其实已经够用了问答、检索、代码生成都能干活。但问题也很明显每次给团队演示都得对着黑底绿字的终端窗口同事想自己试一下得先学一堆命令行操作想展示一张流程图或者一段高亮代码终端里只能靠 ASCII 凑合。说白了能力有了但“脸”没有。这篇要做的就是给 OpenCode 机器人装上一张 Web 可视化交互界面。核心链路是浏览器页面通过 HTTP 请求打到 Fastify 服务Fastify 直接调用 QABot 核心方法QABot 再走 OpenCode 的机制干活。整条链路跑在同一个进程里没有额外的网络跳转也没有序列化开销单机部署非常省心。适合谁看如果你已经跟着前面的项目篇把 QABot 核心类和热加载能力跑通了现在想给它加一个“所有人打开浏览器就能用”的入口那这篇就是为你写的。我会把 Fastify 服务端、SSE 实时推送、热加载生效验证这三块拆开讲配置和代码都给到能直接复制的程度。另外AI 工具接入这块我会用 TaoToken 的统一 Key 通道来演示这样你不需要在多个模型供应商之间来回切换配置。先明确一下本文的产出物一个可复制的config.toml与settings.json配置骨架、一段能跑的 SSE 事件流代码、以及热加载生效后的验证动作。跟着做你能在本地跑通一个带会话管理、Markdown 渲染、代码高亮的 Web 聊天界面。2. TaoToken 前置统一 Key 与 API 通道准备在动手写 Web 服务之前先把 AI 工具的接入通道理顺。OpenCode 机器人要调用模型能力传统做法是每个供应商配一套 Key换模型就得改配置。TaoToken 的思路是提供一个统一的 API 通道你只需要维护一份 Key就能在模型对话、编码计划、控制台管理之间切换。我试过把模型调用统一走 TaoToken 的 API 地址配置项收敛得很干净。你需要准备的东西不多一个 TaoToken 账号然后在控制台生成 API Key。这个 Key 后面会写进 OpenCode 的配置文件里作为模型调用的凭证。具体操作路径是这样的先访问官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 了解统一 Key 的接入方式然后进控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite API Key 管理页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。生成之后先复制保存后面配置里要用。API 的基础地址是 https://taotoken.net/api 注意这个地址不带 UTM 参数直接作为 base_url 写进配置即可。如果你后面要验证模型是否通可以用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 先手动发一条消息确认 Key 有效。长期做编码和 Agent 任务的话Coding Plan 页面 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 有更细的额度说明接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。注意API Key 属于敏感凭证不要提交到 Git 仓库。建议放在环境变量或者本地配置文件里并在.gitignore中排除。3. 可复制配置config.toml 与 settings.json 骨架配置分两块一块是 OpenCode 机器人本身的config.toml负责模型通道和 Web 服务参数另一块是settings.json负责前端界面和 SSE 行为。两块都给出骨架你按自己的端口和路径改。先看config.toml。这里把 TaoToken 的 API 地址和 Key 写进模型通道同时开启 Web 服务# config.toml [model] # 统一走 TaoToken API 通道 provider taotoken base_url https://taotoken.net/api api_key ${TAOTOKEN_API_KEY} # 从环境变量读取避免硬编码 model claude-sonnet # 按需替换为可用模型标识 timeout 60 [web_server] enabled true port 3000 host 0.0.0.0 static_dir ./public enable_cors true [hot_reload] enabled true watch_dir ./src/plugins debounce_ms 300这里有几个点值得说明。api_key用${TAOTOKEN_API_KEY}占位实际运行时从环境变量注入这样配置文件可以安全地进版本库。base_url固定为 TaoToken 的 API 地址不带任何查询参数。web_server段控制 Fastify 的监听端口和静态目录static_dir指向你放index.html的目录。再看settings.json这块主要给前端和 SSE 用{ api: { base: /api, chatEndpoint: /api/chat, streamEndpoint: /api/chat/stream, sessionsEndpoint: /api/sessions, statusEndpoint: /api/status }, sse: { enabled: true, retryMs: 3000, heartbeatMs: 15000, eventTypes: [start, chunk, end, error, reload] }, ui: { markdown: true, codeHighlight: true, maxMessageWidth: 85%, theme: github-dark }, hotReload: { notifyWeb: true, broadcastEvent: reload } }settings.json里的sse.eventTypes定义了前端要监听的事件类型后面 SSE 代码会按这个约定发事件。hotReload.notifyWeb打开后插件热加载完成会通过 SSE 广播reload事件前端收到后刷新状态提示不用手动刷新页面。提示两个配置文件的路径建议放在项目根目录OpenCode 启动时会按约定读取。如果你的项目结构不同在初始化代码里显式指定路径即可。4. Fastify 服务端与 SSE 事件流代码配置就绪后开始写服务端。Fastify 的注册方式和 Express 略有不同但 API 很直观。先装依赖npm install fastify fastify/static fastify/cors然后创建src/services/web-server.ts核心是注册路由、绑定 QABot 实例、暴露 SSE 端点。下面这段是精简后的骨架保留了关键逻辑// src/services/web-server.ts import Fastify from fastify import fastifyStatic from fastify/static import fastifyCors from fastify/cors import * as path from path import * as fs from fs export interface WebServerConfig { port: number host: string staticDir?: string enableCors: boolean } export class WebServer { private fastify: any private config: WebServerConfig private bot: any private isRunning false constructor(config: PartialWebServerConfig {}) { this.config { port: 3000, host: 0.0.0.0, staticDir: path.join(process.cwd(), public), enableCors: true, ...config } this.fastify Fastify({ logger: { level: warn } }) } setBot(bot: any): void { this.bot bot } private registerRoutes(): void { // 健康检查 this.fastify.get(/api/health, async (_req: any, reply: any) { return reply.send({ success: true, data: { status: ok, botInitialized: this.bot?.isInitialized || false } }) }) // 普通问答 this.fastify.post(/api/chat, async (request: any, reply: any) { const { question, sessionId } request.body as any if (!question || !question.trim()) { return reply.status(400).send({ success: false, error: 问题不能为空 }) } const result await this.handleChat({ question, sessionId }) return reply.send({ success: true, data: result }) }) // SSE 流式问答 this.fastify.post(/api/chat/stream, async (request: any, reply: any) { const { question } request.body as any if (!question || !question.trim()) { return reply.status(400).send({ success: false, error: 问题不能为空 }) } reply.raw.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * }) const send (type: string, payload: any {}) { reply.raw.write(data: ${JSON.stringify({ type, ...payload })}\n\n) } try { send(start) // 这里接入 OpenCode 的流式能力逐块推送 const stream await this.bot.streamAnswer(question) for await (const chunk of stream) { send(chunk, { content: chunk }) } send(end) } catch (err) { send(error, { error: err instanceof Error ? err.message : 未知错误 }) } finally { reply.raw.end() } }) // 会话列表、创建、切换、删除 this.fastify.get(/api/sessions, async (_req: any, reply: any) { const sessions await this.bot.listSessions() return reply.send({ success: true, data: sessions }) }) this.fastify.post(/api/sessions, async (request: any, reply: any) { const { title } request.body as any const session await this.bot.createNewSession(title) return reply.send({ success: true, data: session }) }) // 静态文件与 CORS if (this.config.staticDir fs.existsSync(this.config.staticDir)) { this.fastify.register(fastifyStatic, { root: this.config.staticDir, prefix: / }) } if (this.config.enableCors) { this.fastify.register(fastifyCors, { origin: *, methods: [GET, POST, PUT, DELETE, OPTIONS] }) } } private async handleChat(req: { question: string; sessionId?: string }) { if (req.sessionId) { try { await this.bot.switchSession(req.sessionId) } catch { /* 忽略 */ } } await this.bot.saveUserMessage(req.question) const result await this.bot.processUserInput(req.question) await this.bot.saveAssistantMessage(result.response) return { answer: result.response, sessionId: await this.bot.getCurrentSessionId(), intent: result.intent?.type } } async start(): Promisevoid { if (this.isRunning) return this.registerRoutes() await this.fastify.listen({ port: this.config.port, host: this.config.host }) this.isRunning true } async stop(): Promisevoid { if (!this.isRunning) return await this.fastify.close() this.isRunning false } getUrl(): string { return http://${this.config.host}:${this.config.port} } }SSE 这段的关键在于reply.raw.writeHead设置text/event-stream然后每次write都按data: {...}\n\n的格式发。前端用EventSource或者fetch的流式读取都能接。注意send(start)和send(end)是成对的前端靠这两个事件判断流是否结束。把 WebServer 集成到 QABot 里在initialize方法中根据配置启动// src/core/bot.ts 片段 if (this.config?.webServer?.enabled) { this.webServer new WebServer({ port: this.config.webServer.port, host: this.config.webServer.host, staticDir: this.config.webServer.staticDir, enableCors: true }) this.webServer.setBot(this) await this.webServer.start() }5. 验证请求与成功结果服务端跑起来后先别急着开浏览器用命令行验证接口是否通。启动 OpenCode观察日志里是否出现 Web 服务监听提示。然后开一个终端发一条健康检查curl -s http://localhost:3000/api/health | jq预期返回{ success: true, data: { status: ok, botInitialized: true } }接着验证普通问答接口curl -s -X POST http://localhost:3000/api/chat \ -H Content-Type: application/json \ -d {question:Fastify 和 Express 有什么区别} | jq如果返回里success为truedata.answer有内容说明 QABot 核心链路通了。再验证 SSE 流式接口用curl的-N参数关闭缓冲curl -N -X POST http://localhost:3000/api/chat/stream \ -H Content-Type: application/json \ -d {question:用一句话解释 SSE}你应该能看到类似这样的逐行输出data: {type:start} data: {type:chunk,content:S} data: {type:chunk,content:S} data: {type:chunk,content:E} data: {type:end}最后打开浏览器访问http://localhost:3000应该能看到一个深色主题的聊天界面左侧是会话列表右侧是消息区底部是输入框。输入一个问题观察回复是否逐字出现代码块是否有高亮。如果这些都正常说明 Web 可视化交互界面已经跑通了。6. 本篇常见错排查报错一端口被占用Error: listen EADDRINUSE: address already in use :::3000原因是 3000 端口已经被其他进程占用。解决办法有两个改配置里的web_server.port为 3001 或其他空闲端口或者找到占用进程并结束lsof -i :3000 kill -9 PID报错二静态文件路径不存在Error: ENOENT: no such file or directory, stat /project/public/index.html说明static_dir指向的目录不存在或者index.html没放进去。检查config.toml里的static_dir路径确保public/目录存在且里面有入口文件。如果你不想用 Fastify 托管静态文件也可以单独部署前端通过 CORS 跨域调 API。报错三SSE 连接被缓冲消息不实时有些反向代理或者中间层会缓冲text/event-stream响应导致前端收不到逐块消息。排查时先确认响应头里有Cache-Control: no-cache和Connection: keep-alive。如果前面挂了 Nginx需要关掉对应 location 的proxy_buffering。本地直连 Fastify 一般不会有这个问题。报错四热加载后 Web 界面没反应插件热加载完成但前端状态提示没更新。检查settings.json里hotReload.notifyWeb是否为true以及 SSE 事件类型里有没有reload。另外确认热加载管理器在 reload 成功后确实调用了广播方法。如果广播走了但前端没收到打开浏览器开发者工具的 Network 面板看 SSE 连接是否还活着。报错五模型调用返回鉴权失败如果问答接口返回的错误里提到鉴权或 Key 无效先确认环境变量TAOTOKEN_API_KEY是否注入成功再确认base_url写的是https://taotoken.net/api而不是其他地址。可以到模型对话页面手动发一条消息确认 Key 本身有效。7. 接入文档与后续动作Web 界面跑通之后你手里就有了一个能演示、能协作的 OpenCode 机器人入口。接下来如果要把模型调用做得更规范建议把 API Key 的管理和接入文档过一遍。API Key 的创建和管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有不同语言和框架的调用示例。如果你后面要做长期编码任务或者 Agent 编排可以看看 Coding Plan 的额度说明https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。模型对话的验证入口在 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。最后留一个实操建议把 SSE 的heartbeatMs设成 15000 左右长时间没有消息时发一个心跳注释行防止中间层把空闲连接掐掉。这个细节在本地开发时不容易暴露但部署到有反向代理的环境后很关键。