Skip to content

Commit a385447

Browse files
author
linyuan.yang
committed
webservice
1 parent d70f551 commit a385447

7 files changed

Lines changed: 169 additions & 121 deletions

File tree

packages/sbot/src/Channel/ChannelManager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ export class ChannelManager {
211211
getChannel(channelId: string) { return config.getChannel(channelId); }
212212
getService(channelId: string) { return this.services.get(channelId); }
213213

214+
/** 由内置 channel(WEB_CHANNEL_ID)等不走 plugin 路径的 service 主动注册。dispose 时统一回收。 */
215+
registerService(channelId: string, service: IChannelService): void {
216+
this.services.set(channelId, service);
217+
}
218+
214219
async sendText(channelId: string, sessionId: string, text: string): Promise<boolean> {
215220
const service = this.services.get(channelId);
216221
if (!service) return false;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import http from 'http';
4+
import { randomUUID } from 'crypto';
5+
import { WebSocket, WebSocketServer } from 'ws';
6+
import { isEmptyContent, resizeImageIfNeeded, type MessageContent } from "scorpio.ai";
7+
import { IChannelService, ChannelSessionHandler, SessionService } from "channel.base";
8+
import { WsCommandType, WEB_CHANNEL_ID, WEB_CHANNEL_TYPE } from 'sbot.commons';
9+
import { ensureChannelSession } from "../../Core/Database";
10+
import { sessionManager } from "../../Session/SessionManager";
11+
import { LoggerService } from "../../Core/LoggerService";
12+
import { WebSocketSessionHandler } from "./WebSocketSessionHandler";
13+
14+
const logger = LoggerService.getLogger("WebService.ts");
15+
16+
type AttachmentInput = { name: string; dataUrl?: string; content?: string };
17+
type ContentPartInput = { type: 'text'; text: string } | { type: 'image'; dataUrl: string };
18+
19+
function isImageDataUrl(dataUrl: string): boolean {
20+
return /^data:image\//.test(dataUrl);
21+
}
22+
23+
/**
24+
* 将编辑器交错的 text/image parts + 附件文件构造为 MessageContent。
25+
* - parts 保持编辑器顺序
26+
* - 非内联的文件附件追加在末尾,落盘到 uploadDir 后以 markdown 文件链接的形式插入文本
27+
*/
28+
async function processMessage(parts: ContentPartInput[], attachments: AttachmentInput[] | undefined, uploadDir: string): Promise<MessageContent> {
29+
const msgParts: Array<{ type: string; text?: string;[key: string]: any }> = [];
30+
let hasImage = false;
31+
32+
for (const p of parts) {
33+
if (p.type === 'text') {
34+
msgParts.push({ type: 'text', text: p.text });
35+
} else if (p.type === 'image' && p.dataUrl) {
36+
const url = await resizeImageIfNeeded(p.dataUrl);
37+
msgParts.push({ type: 'image_url', image_url: { url } });
38+
hasImage = true;
39+
}
40+
}
41+
42+
if (attachments?.length) {
43+
for (const att of attachments) {
44+
if (att.dataUrl && isImageDataUrl(att.dataUrl)) {
45+
const url = await resizeImageIfNeeded(att.dataUrl);
46+
msgParts.push({ type: 'image_url', image_url: { url } });
47+
hasImage = true;
48+
} else if (att.dataUrl) {
49+
const filePath = path.join(uploadDir, `${randomUUID()}-${att.name}`);
50+
fs.writeFileSync(filePath, Buffer.from(att.dataUrl.replace(/^data:[^;]+;base64,/, ''), 'base64'));
51+
msgParts.push({ type: 'text', text: `[file: ${att.name}](${filePath})` });
52+
} else if (att.content != null) {
53+
const filePath = path.join(uploadDir, `${randomUUID()}-${att.name}`);
54+
fs.writeFileSync(filePath, att.content);
55+
msgParts.push({ type: 'text', text: `[file: ${att.name}](${filePath})` });
56+
}
57+
}
58+
}
59+
60+
if (msgParts.length === 0) return '';
61+
if (!hasImage) return msgParts.map(p => p.text!).join('\n');
62+
return msgParts;
63+
}
64+
65+
/**
66+
* Web channel 的 IChannelService 实现:管理 ws 客户端连接、广播事件、为每个 SbotSession 创建 WebSocketSessionHandler。
67+
* 由 HttpServer 在创建 http.Server 后调用 attach() 完成 WS 升级路径绑定,并通过 channelManager.registerService 注册。
68+
*/
69+
export class WebService implements IChannelService {
70+
private readonly wsClients = new Set<WebSocket>();
71+
private wss?: WebSocketServer;
72+
73+
attach(server: http.Server, uploadDir: string): void {
74+
const wss = this.wss = new WebSocketServer({ server, path: '/ws/chat' });
75+
wss.on('connection', (ws) => {
76+
this.wsClients.add(ws);
77+
ws.on('close', () => { this.wsClients.delete(ws); });
78+
ws.on('message', async (data) => {
79+
try {
80+
const msg = JSON.parse(data.toString()) as { type?: string;[key: string]: any };
81+
const sid = msg.sessionId as string | undefined;
82+
if (!sid) throw new Error('sessionId is required');
83+
const { session, profile } = await ensureChannelSession(WEB_CHANNEL_ID, sid);
84+
const threadId = String(profile.id);
85+
switch (msg.type) {
86+
case WsCommandType.Query: {
87+
const enriched = await processMessage(msg.parts ?? [], msg.attachments, uploadDir);
88+
if (isEmptyContent(enriched)) break;
89+
sessionManager.onReceiveChannelMessage(threadId, enriched, {
90+
channelType: WEB_CHANNEL_TYPE,
91+
channelId: WEB_CHANNEL_ID,
92+
dbSessionId: session.id,
93+
sessionId: sid,
94+
});
95+
break;
96+
}
97+
case WsCommandType.Approval:
98+
case WsCommandType.Ask:
99+
case WsCommandType.Abort: {
100+
sessionManager.onTriggerChannelAction(threadId, msg.type!, msg).catch(e => logger.error(`ws trigger error: ${e?.message ?? e}`));
101+
break;
102+
}
103+
}
104+
} catch (e: any) {
105+
logger.error(`ws message error: ${e?.message ?? e}`);
106+
}
107+
});
108+
});
109+
}
110+
111+
broadcast(data: string): void {
112+
for (const ws of this.wsClients) {
113+
if (ws.readyState === WebSocket.OPEN) ws.send(data);
114+
}
115+
}
116+
117+
// ── IChannelService ──
118+
119+
createSessionHandler(session: SessionService): ChannelSessionHandler {
120+
return new WebSocketSessionHandler(session);
121+
}
122+
123+
async sendText(_sessionId: string, _text: string): Promise<void> {
124+
// Web channel 没有"原文直发"路径,输出统一通过 WebSocketSessionHandler 经 ws 广播给前端。
125+
}
126+
127+
async sendFile(_sessionId: string, _file: string | Buffer, _fileName?: string): Promise<void> {
128+
// 同上:web 没有独立的文件直发通道。
129+
}
130+
131+
async sendNative(_sessionId: string, _payload: any): Promise<void> {
132+
// 同上。
133+
}
134+
135+
dispose(): void {
136+
for (const ws of this.wsClients) {
137+
try { ws.close(); } catch (_) { /* ignore */ }
138+
}
139+
this.wsClients.clear();
140+
try { this.wss?.close(); } catch (_) { /* ignore */ }
141+
this.wss = undefined;
142+
}
143+
}
144+
145+
export const webService = new WebService();

packages/sbot/src/Channel/web/WebSocketSessionHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type ChannelMessageArgs, type ChatToolCall,
88
} from "channel.base";
99
import { WebChatEventType, WsCommandType, ApprovalTimeoutValue } from 'sbot.commons';
10-
import { httpServer } from "../../Server/HttpServer";
10+
import { webService } from "./WebService";
1111

1212
const WEB_ASK_PROMPT = `Ask the user one or more structured questions and wait for their response. Use this tool whenever you need clarification, a decision, or input before proceeding.
1313
@@ -122,6 +122,6 @@ export class WebSocketSessionHandler extends ChannelSessionHandler {
122122
// ── Emit helpers ──
123123

124124
private emit(type: WebChatEventType, data: Record<string, any>): void {
125-
httpServer.broadcastToWs(JSON.stringify({ sessionId: this.sessionId, type, data }));
125+
webService.broadcast(JSON.stringify({ sessionId: this.sessionId, type, data }));
126126
}
127127
}

packages/sbot/src/Processing/createProcessAIHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { config, AgentMode } from "../Core/Config";
55
import { buildExecuteTool } from "./buildExecuteTool";
66
import { updateUsageStats, type UsageContext } from "./updateUsageStats";
77
import { WebChatEventType, WEB_CHANNEL_ID, ApprovalTimeoutValue } from "sbot.commons";
8-
import { httpServer } from "../Server/HttpServer";
8+
import { webService } from "../Channel/web/WebService";
99
import { AgentRunner } from "../Agent/AgentRunner";
1010

1111
export function createProcessAIHandler(): ProcessAIHandler {
@@ -22,7 +22,7 @@ export function createProcessAIHandler(): ProcessAIHandler {
2222
if (!channel) throw new Error(`Channel config not found: ${channelId}`);
2323

2424
if (channelId === WEB_CHANNEL_ID) {
25-
httpServer.broadcastToWs(JSON.stringify({ sessionId, type: WebChatEventType.Human, data: { content: query } }));
25+
webService.broadcast(JSON.stringify({ sessionId, type: WebChatEventType.Human, data: { content: query } }));
2626
}
2727

2828
const agentId = resolved.agentId;

packages/sbot/src/Processing/updateUsageStats.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type TokenUsage } from "scorpio.ai";
22
import { WebChatEventType, WEB_CHANNEL_ID } from "sbot.commons";
33
import { database, getChannelSession } from "../Core/Database";
4-
import { httpServer } from "../Server/HttpServer";
4+
import { webService } from "../Channel/web/WebService";
55

66
export interface UsageContext {
77
agentId: string;
@@ -55,7 +55,7 @@ export async function updateUsageStats(
5555

5656
const row = await getChannelSession(dbSessionId);
5757
if (row && row.channelId === WEB_CHANNEL_ID) {
58-
httpServer.broadcastToWs(JSON.stringify({
58+
webService.broadcast(JSON.stringify({
5959
sessionId: row.sessionId,
6060
type: WebChatEventType.Usage,
6161
data: { inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, totalTokens: usage.total_tokens, cacheCreationTokens: cacheCreation, cacheReadTokens: cacheRead },

packages/sbot/src/Server/HttpServer.ts

Lines changed: 7 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { randomUUID } from 'crypto';
77
import { execFile } from 'child_process';
88
import { promisify } from 'util';
99
import { z } from 'zod';
10-
import { WebSocket, WebSocketServer } from 'ws';
11-
import { AgentToolService, SkillService, ModelProvider, listThreadIds, isEmptyContent, resizeImageIfNeeded, setMaxImageSize, type StoredMessage, type MessageContent } from "scorpio.ai";
10+
import { AgentToolService, SkillService, ModelProvider, listThreadIds, setMaxImageSize, type StoredMessage } from "scorpio.ai";
1211
import { config, isDev, isValidAgentId } from '../Core/Config';
1312
import { AgentRunner } from '../Agent/AgentRunner';
1413
import { ACPAgentPool } from '../Agent/ACPAgentPool';
@@ -25,9 +24,10 @@ import { sessionManager } from '../Session/SessionManager';
2524
import { schedulerService } from '../Scheduler/SchedulerService';
2625
import { heartbeatService } from '../Heartbeat/HeartbeatService';
2726
import { channelManager } from '../Channel/ChannelManager';
28-
import { WsCommandType, WEB_CHANNEL_ID, WEB_CHANNEL_TYPE } from 'sbot.commons';
27+
import { WEB_CHANNEL_ID } from 'sbot.commons';
2928
import { getModelMeta, getKnownModels } from './modelCatalog';
3029
import { FsApi } from './FsApi';
30+
import { webService } from '../Channel/web/WebService';
3131

3232
const logger = LoggerService.getLogger('HttpServer.ts');
3333
const execFileAsync = promisify(execFile);
@@ -317,57 +317,6 @@ function buildPromptTree(dir: string, basePath = '', userBaseDir = ''): PromptNo
317317
return result;
318318
}
319319

320-
// ===== 附件处理 =====
321-
type AttachmentInput = { name: string; dataUrl?: string; content?: string };
322-
type ContentPartInput = { type: 'text'; text: string } | { type: 'image'; dataUrl: string };
323-
324-
function isImageDataUrl(dataUrl: string): boolean {
325-
return /^data:image\//.test(dataUrl);
326-
}
327-
328-
/**
329-
* Build MessageContent from ordered parts (interleaved text/image) + file attachments.
330-
* Parts preserve the interleaved order from the editor.
331-
* File attachments (non-inline) are appended at the end.
332-
*/
333-
async function processMessage(parts: ContentPartInput[], attachments: AttachmentInput[] | undefined, uploadDir: string): Promise<MessageContent> {
334-
const msgParts: Array<{ type: string; text?: string; [key: string]: any }> = [];
335-
let hasImage = false;
336-
337-
for (const p of parts) {
338-
if (p.type === 'text') {
339-
msgParts.push({ type: 'text', text: p.text });
340-
} else if (p.type === 'image' && p.dataUrl) {
341-
const url = await resizeImageIfNeeded(p.dataUrl);
342-
msgParts.push({ type: 'image_url', image_url: { url } });
343-
hasImage = true;
344-
}
345-
}
346-
347-
// Append file attachments (non-inline files from the attachment picker)
348-
if (attachments?.length) {
349-
for (const att of attachments) {
350-
if (att.dataUrl && isImageDataUrl(att.dataUrl)) {
351-
const url = await resizeImageIfNeeded(att.dataUrl);
352-
msgParts.push({ type: 'image_url', image_url: { url } });
353-
hasImage = true;
354-
} else if (att.dataUrl) {
355-
const filePath = path.join(uploadDir, `${randomUUID()}-${att.name}`);
356-
fs.writeFileSync(filePath, Buffer.from(att.dataUrl.replace(/^data:[^;]+;base64,/, ''), 'base64'));
357-
msgParts.push({ type: 'text', text: `[file: ${att.name}](${filePath})` });
358-
} else if (att.content != null) {
359-
const filePath = path.join(uploadDir, `${randomUUID()}-${att.name}`);
360-
fs.writeFileSync(filePath, att.content);
361-
msgParts.push({ type: 'text', text: `[file: ${att.name}](${filePath})` });
362-
}
363-
}
364-
}
365-
366-
if (msgParts.length === 0) return '';
367-
if (!hasImage) return msgParts.map(p => p.text!).join('\n');
368-
return msgParts;
369-
}
370-
371320
// ===== Skills 辅助函数 =====
372321
function listSkills(skillsDir: string) {
373322
if (!fs.existsSync(skillsDir)) return [];
@@ -422,23 +371,14 @@ function api(fn: (req: Request, res: Response) => any) {
422371
class HttpServer {
423372
private readonly skillHubService = new SkillHubService();
424373
private readonly agentStoreService = new AgentStoreService();
425-
private readonly wsClients = new Set<WebSocket>();
426374
private server?: http.Server;
427375

428-
broadcastToWs(data: string): void {
429-
for (const ws of this.wsClients) {
430-
if (ws.readyState === WebSocket.OPEN) ws.send(data);
431-
}
432-
}
433-
434376
async shutdown(): Promise<void> {
435377
logger.info('Shutting down services...');
436378
try {
437379
schedulerService.stopAll();
438380
await ACPAgentPool.getInstance().disposeAll();
439381
await channelManager.dispose();
440-
for (const ws of this.wsClients) ws.close();
441-
this.wsClients.clear();
442382
if (this.server) {
443383
await new Promise<void>((resolve, reject) =>
444384
this.server!.close(err => err ? reject(err) : resolve()),
@@ -499,45 +439,11 @@ class HttpServer {
499439
this.registerUserRoutes(app);
500440
this.registerChatRoutes(app);
501441

502-
// HTTP + WebSocket 服务
442+
// HTTP + WebSocket 服务:把 ws 升级路径与 web channel 运行时交给 WebService,
443+
// 然后注册到 channelManager,让消息出路与 dispose 生命周期与其他 channel 对齐
503444
const server = this.server = http.createServer(app);
504-
505-
const wss = new WebSocketServer({ server, path: '/ws/chat' });
506-
wss.on('connection', (ws) => {
507-
this.wsClients.add(ws);
508-
ws.on('close', () => { this.wsClients.delete(ws); });
509-
ws.on('message', async (data) => {
510-
try {
511-
const msg = JSON.parse(data.toString()) as { type?: string; [key: string]: any };
512-
const sid = msg.sessionId as string | undefined;
513-
if (!sid) throw new Error('sessionId is required');
514-
// 与 channel plugin 路径对齐:先 ensure session+profile,再交给 sessionManager
515-
const { session, profile } = await ensureChannelSession(WEB_CHANNEL_ID, sid);
516-
const threadId = String(profile.id);
517-
switch (msg.type) {
518-
case WsCommandType.Query: {
519-
const enriched = await processMessage(msg.parts ?? [], msg.attachments, uploadDir);
520-
if (isEmptyContent(enriched)) break;
521-
sessionManager.onReceiveChannelMessage(threadId, enriched, {
522-
channelType: WEB_CHANNEL_TYPE,
523-
channelId: WEB_CHANNEL_ID,
524-
dbSessionId: session.id,
525-
sessionId: sid,
526-
});
527-
break;
528-
}
529-
case WsCommandType.Approval:
530-
case WsCommandType.Ask:
531-
case WsCommandType.Abort: {
532-
sessionManager.onTriggerChannelAction(threadId, msg.type!, msg).catch(e => logger.error(`ws trigger error: ${e?.message ?? e}`));
533-
break;
534-
}
535-
}
536-
} catch (e: any) {
537-
logger.error(`ws message error: ${e?.message ?? e}`);
538-
}
539-
});
540-
});
445+
webService.attach(server, uploadDir);
446+
channelManager.registerService(WEB_CHANNEL_ID, webService);
541447

542448
server.listen(port, () => {
543449
logger.info(`HTTP server started, admin UI available at: http://127.0.0.1:${port}`);

0 commit comments

Comments
 (0)