228 lines
9.5 KiB
TypeScript
228 lines
9.5 KiB
TypeScript
import * as http from 'http';
|
|
import * as fs from 'fs';
|
|
import {
|
|
_getBrainDir,
|
|
findBrainFiles,
|
|
logError,
|
|
logInfo,
|
|
summarizeText
|
|
} from './utils';
|
|
import { getConfig } from './config';
|
|
import { IAIService, IBrainService, AIService, BrainService } from './core/services';
|
|
import {
|
|
ISkillInjectionService,
|
|
FileSystemSkillInjectionService,
|
|
SkillInjectionError,
|
|
} from './skills/skillInjectionService';
|
|
import { resolveAgentSkillsDir } from './lib/paths';
|
|
|
|
export interface BridgeInterface {
|
|
injectSystemMessage(msg: string): void;
|
|
getHistoryText(): string;
|
|
sendPromptFromExtension(prompt: string): void;
|
|
brainEnabled: boolean;
|
|
findBrainFiles(dir: string): string[];
|
|
}
|
|
|
|
/**
|
|
* BridgeServer:
|
|
* 외부 툴(EZER, A.U 등)과 Astra 확장을 연결하는 통신 브릿지.
|
|
* 서비스 레이어(AIService, BrainService)를 통해 비즈니스 로직을 분리하여 유지보수성을 극대화했습니다.
|
|
*/
|
|
export class BridgeServer {
|
|
private server: http.Server | null = null;
|
|
private aiService: IAIService;
|
|
private brainService: IBrainService;
|
|
private skillService: ISkillInjectionService;
|
|
|
|
constructor(
|
|
private provider: BridgeInterface,
|
|
aiService?: IAIService,
|
|
brainService?: IBrainService,
|
|
skillService?: ISkillInjectionService
|
|
) {
|
|
// 의존성 주입 (DIP): 기본값 제공 및 외부 주입 허용
|
|
this.aiService = aiService || new AIService();
|
|
this.brainService = brainService || new BrainService();
|
|
this.skillService = skillService || new FileSystemSkillInjectionService({
|
|
resolveSkillsDir: resolveAgentSkillsDir,
|
|
onInjected: (result, req) => {
|
|
this.provider.injectSystemMessage(
|
|
`**[Skill]** Injected agent skill: ${req.displayName || result.safeName}`
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
public start(port: number = 4825) {
|
|
const server = http.createServer((req, res) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const url = req.url || '';
|
|
const method = req.method;
|
|
|
|
// 라우팅 로직 (SRP에 따라 비즈니스 로직은 서비스로 위임)
|
|
if (method === 'GET' && url === '/ping') {
|
|
this.handlePing(res);
|
|
} else if (method === 'POST' && url === '/api/exam') {
|
|
this.handlePost(req, res, this.processExam.bind(this));
|
|
} else if (method === 'POST' && url === '/api/evaluate') {
|
|
this.handlePost(req, res, this.processEvaluate.bind(this));
|
|
} else if (method === 'GET' && url === '/api/evaluate-history') {
|
|
this.processEvaluateHistory(res);
|
|
} else if (method === 'POST' && url === '/api/brain-inject') {
|
|
this.handlePost(req, res, this.processBrainInject.bind(this));
|
|
} else if (method === 'POST' && url === '/api/skill-inject') {
|
|
this.handlePost(req, res, this.processSkillInject.bind(this));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
// once() 사용: 중복 에러 이벤트 방지
|
|
server.once('error', (err: any) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
// INFO 레벨: ERR 콘솔 오염 방지 (Extension Host가 console.error를 ERR로 표시)
|
|
logInfo(`Bridge Port ${port} already in use. Trying port ${port + 1}... (Current PID: ${process.pid})`);
|
|
server.close();
|
|
if (this.server === server) {
|
|
this.server = null;
|
|
}
|
|
this.start(port + 1);
|
|
} else {
|
|
// EADDRINUSE 외 진짜 에러만 logError
|
|
logInfo(`Bridge server non-fatal error on port ${port}: ${err.code || err.message} (PID: ${process.pid})`);
|
|
}
|
|
});
|
|
|
|
// 성공 시 서버 참조 저장
|
|
server.listen(port, '127.0.0.1', () => {
|
|
this.server = server;
|
|
logInfo(`Bridge server active on 127.0.0.1:${port} (PID: ${process.pid}).`);
|
|
});
|
|
}
|
|
|
|
private handlePing(res: http.ServerResponse) {
|
|
const brainDir = _getBrainDir();
|
|
const brainCount = fs.existsSync(brainDir) ? findBrainFiles(brainDir).length : 0;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
status: 'ok',
|
|
config: getConfig(),
|
|
brain: { fileCount: brainCount, enabled: this.provider.brainEnabled }
|
|
}));
|
|
}
|
|
|
|
private handlePost(req: http.IncomingMessage, res: http.ServerResponse, processor: (data: any, res: http.ServerResponse) => Promise<void>) {
|
|
let body = '';
|
|
req.on('data', chunk => body += chunk.toString());
|
|
req.on('end', async () => {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
await processor(parsed, res);
|
|
} catch (e: any) {
|
|
logError('Bridge request processor failed.', { url: req.url, error: e.message });
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
}
|
|
|
|
private async processExam(data: any, res: http.ServerResponse) {
|
|
const prompt = data.prompt || 'Automatic Prompt';
|
|
this.provider.sendPromptFromExtension(`[Bridge] ${prompt}`);
|
|
const result = await this.aiService.call(prompt);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, rawOutput: result }));
|
|
}
|
|
|
|
private async processEvaluate(data: any, res: http.ServerResponse) {
|
|
const prompt = data.prompt || '';
|
|
this.provider.injectSystemMessage(`**[A.U Evaluation]** Analyzing input...`);
|
|
const result = await this.aiService.call(`[EVALUATE] ${prompt}`);
|
|
this.provider.injectSystemMessage(`**[Result]** ${summarizeText(result, 200)}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ rawOutput: result }));
|
|
}
|
|
|
|
private async processEvaluateHistory(res: http.ServerResponse) {
|
|
const historyText = this.provider.getHistoryText();
|
|
if (!historyText || historyText.length < 50) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: "Insufficient history" }));
|
|
return;
|
|
}
|
|
|
|
const result = await this.aiService.call(`Analyze chat history for metrics (JSON):\n${historyText.slice(-6000)}`);
|
|
|
|
// Try to extract valid JSON from the AI response
|
|
let parsed: any = null;
|
|
try {
|
|
parsed = JSON.parse(result);
|
|
} catch {
|
|
// AI may wrap JSON in markdown code fences — try to extract
|
|
const fenceMatch = result.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
if (fenceMatch) {
|
|
try { parsed = JSON.parse(fenceMatch[1].trim()); } catch { /* fall through */ }
|
|
}
|
|
}
|
|
|
|
if (parsed) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(parsed));
|
|
} else {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: "Failed to parse AI response as JSON", raw: summarizeText(result, 500) }));
|
|
}
|
|
}
|
|
|
|
private async processBrainInject(data: any, res: http.ServerResponse) {
|
|
const { title, markdown, prompt } = data;
|
|
await this.brainService.inject(title, markdown);
|
|
this.provider.injectSystemMessage(`**[Brain]** Knowledge captured: ${title}`);
|
|
const result = await this.aiService.call(prompt || `Analyze: ${title}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, rawOutput: result }));
|
|
}
|
|
|
|
/**
|
|
* Inject an agent skill (markdown-only — see SkillInjectionService doc) from
|
|
* an external tool. Routing-only: validation, file IO, and telemetry live in
|
|
* the service.
|
|
*/
|
|
private async processSkillInject(data: any, res: http.ServerResponse) {
|
|
try {
|
|
const result = await this.skillService.inject({
|
|
name: data?.name,
|
|
content: data?.content ?? data?.markdown,
|
|
displayName: data?.displayName,
|
|
description: data?.description,
|
|
source: data?.source,
|
|
});
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
safeName: result.safeName,
|
|
filePath: result.filePath,
|
|
}));
|
|
} catch (e: any) {
|
|
const status = e instanceof SkillInjectionError && (e.code === 'INVALID_NAME' || e.code === 'EMPTY_CONTENT')
|
|
? 400
|
|
: 500;
|
|
const code = e instanceof SkillInjectionError ? e.code : 'UNKNOWN';
|
|
logError('Skill inject endpoint failed.', { code, error: e?.message ?? String(e) });
|
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: e?.message ?? String(e), code }));
|
|
}
|
|
}
|
|
}
|