Replace D2 rendering with DiagramAgent (Mermaid + Kroki)

- Delete d2-tool.ts and D2_RENDER_URL references
- Add diagram-agent.ts: OpenAI generates Mermaid → Kroki renders PNG → Telegram sendPhoto + R2 cache
- Update onboarding-agent to use generate_diagram tool with DiagramAgent
- Switch wrangler.toml from D2_RENDER_URL to KROKI_URL
- Remove D2 from env-validation, api-urls, tools/index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-12 09:32:46 +09:00
parent 25e56d3f58
commit 7dda198f47
7 changed files with 258 additions and 168 deletions

206
src/agents/diagram-agent.ts Normal file
View File

@@ -0,0 +1,206 @@
/**
* Diagram Agent - Mermaid 다이어그램 생성 서브 에이전트
*
* 세션/멀티라운드 불필요. 호출 시:
* 1. OpenAI로 Mermaid 코드 생성
* 2. Kroki로 PNG 렌더링
* 3. Telegram으로 직접 전송 (+ R2 캐시)
*/
import type { Env, OpenAIAPIResponse } from '../types';
import { sendPhoto, sendMessage, sendChatAction } from '../telegram';
import { createLogger } from '../utils/logger';
import { getOpenAIUrl } from '../utils/api-urls';
const logger = createLogger('diagram-agent');
const MERMAID_SYSTEM_PROMPT = `You are a technical diagram generator for customer support at a cloud hosting service.
The customer describes a problem they are experiencing. Analyze the problem and create a Mermaid diagram that visualizes:
- The system architecture involved
- Where the problem might be occurring
- The flow of data/requests that is failing
IMPORTANT RULES:
1. Output ONLY the Mermaid diagram code, nothing else
2. Do NOT wrap in \`\`\`mermaid\`\`\` code blocks
3. Use 'graph TD' for flow diagrams or 'sequenceDiagram' for request flows
4. Use Korean labels since the customer speaks Korean
5. Mark the problem area with style: style NodeName fill:#f66,stroke:#333
6. Keep the diagram clear and focused (max 15 nodes)
7. CRITICAL: Always wrap node labels in double quotes to escape special characters.
CORRECT: A["웹서버 web-prod-01"] --> B["DB서버 db-prod-01"]
WRONG: A[웹서버(web-prod-01)] --> B[DB서버(db-prod-01)]
Never use parentheses (), curly braces {}, or other special chars inside labels without double quotes.`;
export interface DiagramContext {
env: Env;
chatId: number;
telegramUserId: string;
messageId?: number;
}
export class DiagramAgent {
/**
* Mermaid 코드 생성 → Kroki 렌더 → Telegram 전송
*/
async generateAndSend(
context: DiagramContext,
description: string,
diagramType?: string
): Promise<{ success: boolean; message: string }> {
const { env, chatId, messageId } = context;
// 1. Generate Mermaid code
const mermaidCode = await this.generateMermaidCode(env, description, diagramType);
if (!mermaidCode) {
return { success: false, message: 'Mermaid 다이어그램 코드 생성에 실패했습니다.' };
}
// 2. Send typing indicator
await sendChatAction(env.BOT_TOKEN, chatId, 'upload_photo').catch(() => {});
// 3. Render via Kroki
const imageData = await this.renderWithKroki(env, mermaidCode);
if (!imageData) {
// Fallback: send mermaid code as text
const escaped = mermaidCode.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
await sendMessage(env.BOT_TOKEN, chatId,
`다이어그램 렌더링 서비스에 연결할 수 없습니다.\n\n<b>Mermaid 코드:</b>\n<pre>${escaped}</pre>`,
{ parse_mode: 'HTML' });
return { success: true, message: '렌더링 실패로 Mermaid 코드를 텍스트로 전송했습니다.' };
}
// 4. Cache in R2
await this.cacheInR2(env, mermaidCode, imageData);
// 5. Send photo to Telegram (retry without reply_to_message_id on failure)
try {
await sendPhoto(env.BOT_TOKEN, chatId, imageData, {
caption: '다이어그램입니다. 추가 설명이 필요하시면 말씀해주세요.',
reply_to_message_id: messageId,
});
} catch (firstError) {
logger.warn('sendPhoto failed, retrying without reply_to_message_id', {
error: (firstError as Error).message,
});
try {
await sendPhoto(env.BOT_TOKEN, chatId, imageData, {
caption: '다이어그램입니다. 추가 설명이 필요하시면 말씀해주세요.',
});
} catch (retryError) {
// Final fallback: send mermaid code as text
logger.error('sendPhoto retry failed', retryError as Error);
const escaped = mermaidCode.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
await sendMessage(env.BOT_TOKEN, chatId,
`다이어그램 이미지 전송에 실패했습니다.\n\n<b>Mermaid 코드:</b>\n<pre>${escaped}</pre>`,
{ parse_mode: 'HTML' });
return { success: true, message: '이미지 전송 실패로 Mermaid 코드를 텍스트로 전송했습니다.' };
}
}
logger.info('Diagram sent', { chatId });
return { success: true, message: '다이어그램이 고객에게 전송되었습니다.' };
}
private async generateMermaidCode(
env: Env,
description: string,
diagramType?: string
): Promise<string | null> {
if (!env.OPENAI_API_KEY) {
logger.error('OPENAI_API_KEY not set', new Error('Missing key'));
return null;
}
const typeHint = diagramType
? `\n\nPreferred diagram type: ${diagramType}. Use 'graph TD' for flow/architecture, 'sequenceDiagram' for sequence.`
: '';
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(getOpenAIUrl(env), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: MERMAID_SYSTEM_PROMPT + typeHint },
{ role: 'user', content: description },
],
max_tokens: 1000,
temperature: 0.3,
}),
});
if (!response.ok) {
logger.error('OpenAI API error', new Error(`HTTP ${response.status}`));
return null;
}
const data = (await response.json()) as OpenAIAPIResponse;
let code = data.choices[0]?.message?.content?.trim();
if (!code) return null;
code = code.replace(/```mermaid\n?/g, '').replace(/```\n?/g, '').trim();
return code;
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logger.error('Mermaid generation failed', error as Error);
return null;
}
}
private async renderWithKroki(env: Env, mermaidCode: string): Promise<ArrayBuffer | null> {
const krokiUrl = env.KROKI_URL;
if (!krokiUrl) {
logger.error('KROKI_URL not set', new Error('Missing KROKI_URL'));
return null;
}
try {
const response = await fetch(`${krokiUrl}/mermaid/png`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: mermaidCode,
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Kroki render failed', new Error(errorText), { status: response.status });
return null;
}
return await response.arrayBuffer();
} catch (error) {
logger.error('Kroki connection error', error as Error);
return null;
}
}
private async cacheInR2(env: Env, mermaidCode: string, imageData: ArrayBuffer): Promise<void> {
if (!env.R2_BUCKET) return;
try {
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(mermaidCode));
const hash = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
await env.R2_BUCKET.put(`diagrams/${hash}.png`, imageData, {
httpMetadata: { contentType: 'image/png' },
});
} catch (error) {
logger.error('R2 cache failed', error as Error);
}
}
}

View File

@@ -8,12 +8,12 @@
* - 지식 베이스 검색
*/
import type { Env, ToolDefinition, OnboardingSession, RenderD2Args } from '../types';
import type { ToolDefinition, OnboardingSession } from '../types';
import type { AgentToolContext } from './base-agent';
import { BaseAgent } from './base-agent';
import { DiagramAgent } from './diagram-agent';
import { SessionManager } from '../utils/session-manager';
import { getSessionConfig } from '../constants/agent-config';
import { getD2RenderUrl } from '../utils/api-urls';
import { createLogger } from '../utils/logger';
const config = getSessionConfig('onboarding');
@@ -54,7 +54,7 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항)
4. 예산 범위 확인
5. 적절한 서비스 조합 추천
6. 필요 시 D2 다이어그램으로 아키텍처 시각화
6. 필요 시 Mermaid 다이어그램으로 아키텍처 시각화
## 현재 세션 상태: ${session.status}
@@ -67,7 +67,7 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
- 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다.
- 아키텍처 설명 시 generate_diagram으로 Mermaid 다이어그램을 생성하여 고객에게 전송할 수 있습니다.
- 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`;
}
@@ -76,22 +76,22 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
{
type: 'function',
function: {
name: 'render_d2',
description: '아키텍처 다이어그램을 D2 언어로 렌더링합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.',
name: 'generate_diagram',
description: '아키텍처 다이어그램을 Mermaid로 생성하여 고객에게 전송합니다. 서버 구성, 네트워크 토폴로지, 서비스 아키텍처를 시각화할 때 사용합니다.',
parameters: {
type: 'object',
properties: {
source: {
description: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
description: '다이어그램에 표현할 아키텍처 또는 상황 설명',
},
format: {
diagram_type: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 형식 (기본값: svg)',
enum: ['flow', 'sequence', 'architecture'],
description: '다이어그램 유형 (선택, 기본값: architecture)',
},
},
required: ['source'],
required: ['description'],
},
},
},
@@ -127,8 +127,12 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
context: AgentToolContext
): Promise<string> {
switch (name) {
case 'render_d2':
return this.handleRenderD2(args as unknown as RenderD2Args, context.env);
case 'generate_diagram':
return this.handleGenerateDiagram(
args.description as string,
args.diagram_type as string | undefined,
context
);
case 'search_knowledge':
return this.handleSearchKnowledge(
args as { query: string; category?: string },
@@ -139,32 +143,28 @@ export class OnboardingAgent extends BaseAgent<OnboardingSession> {
}
}
private async handleRenderD2(args: RenderD2Args, env: Env): Promise<string> {
try {
const renderUrl = getD2RenderUrl(env);
const response = await fetch(`${renderUrl}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: args.source,
format: args.format || 'svg',
}),
});
if (!response.ok) {
logger.error('D2 렌더링 실패', new Error(`HTTP ${response.status}`));
return JSON.stringify({ error: 'D2 다이어그램 렌더링에 실패했습니다.' });
}
return JSON.stringify({
success: true,
message: '아키텍처 다이어그램이 생성되었습니다.',
format: args.format || 'svg',
});
} catch (error) {
logger.error('D2 렌더링 오류', error as Error);
return JSON.stringify({ error: 'D2 렌더링 서비스에 연결할 수 없습니다.' });
private async handleGenerateDiagram(
description: string,
diagramType: string | undefined,
context: AgentToolContext
): Promise<string> {
if (!context.chatId) {
return JSON.stringify({ error: '채팅 정보가 없어 다이어그램을 전송할 수 없습니다.' });
}
const diagramAgent = new DiagramAgent();
const result = await diagramAgent.generateAndSend(
{
env: context.env,
chatId: context.chatId,
telegramUserId: context.userId,
messageId: context.messageId,
},
description,
diagramType
);
return JSON.stringify(result);
}
private async handleSearchKnowledge(

View File

@@ -1,109 +0,0 @@
import { createLogger } from '../utils/logger';
import type { Env, ToolDefinition, RenderD2Args } from '../types';
const logger = createLogger('d2-tool');
export const renderD2Tool: ToolDefinition = {
type: 'function',
function: {
name: 'render_d2',
description:
'D2 다이어그램 렌더링: D2 소스 코드를 이미지(SVG/PNG)로 변환합니다.',
parameters: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'D2 다이어그램 소스 코드',
},
format: {
type: 'string',
enum: ['svg', 'png'],
description: '출력 포맷 (기본: svg)',
},
},
required: ['source'],
},
},
};
export async function executeRenderD2(
args: RenderD2Args,
env?: Env,
_userId?: string,
db?: D1Database
): Promise<string> {
try {
if (!env?.D2_RENDER_URL) {
return 'D2 렌더링 서비스가 설정되지 않았습니다.';
}
if (!env.R2_BUCKET) {
return 'R2 스토리지가 설정되지 않았습니다.';
}
const format = args.format ?? 'svg';
const sourceHash = await hashSource(args.source + format);
// Check cache
if (db) {
const cached = await db
.prepare('SELECT r2_key FROM d2_cache WHERE source_hash = ?')
.bind(sourceHash)
.first<{ r2_key: string }>();
if (cached) {
logger.info('D2 cache hit', { hash: sourceHash });
return `다이어그램이 준비되었습니다: /d2/${cached.r2_key}`;
}
}
// Render via external service
const response = await fetch(env.D2_RENDER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: args.source, format }),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('D2 render API failed', undefined, {
status: response.status,
error: errorText,
});
return `다이어그램 렌더링 실패: ${response.statusText}`;
}
const imageData = await response.arrayBuffer();
const contentType = format === 'svg' ? 'image/svg+xml' : 'image/png';
const r2Key = `d2/${sourceHash}.${format}`;
// Store in R2
await env.R2_BUCKET.put(r2Key, imageData, {
httpMetadata: { contentType },
});
// Cache metadata
if (db) {
await db
.prepare(
'INSERT INTO d2_cache (source_hash, r2_key, format) VALUES (?, ?, ?)'
)
.bind(sourceHash, r2Key, format)
.run();
}
logger.info('D2 diagram rendered and cached', { hash: sourceHash, format });
return `다이어그램이 준비되었습니다: /d2/${r2Key}`;
} catch (error) {
logger.error('D2 tool error', error as Error);
return '다이어그램 렌더링 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
}
async function hashSource(source: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(source);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

View File

@@ -7,7 +7,6 @@ import { manageDomainTool, executeManageDomain } from './domain-tool';
import { manageWalletTool, executeManageWallet } from './wallet-tool';
import { manageServerTool, executeManageServer } from './server-tool';
import { checkServiceTool, executeCheckService } from './service-tool';
import { renderD2Tool, executeRenderD2 } from './d2-tool';
import { adminTool, executeAdmin } from './admin-tool';
import { searchKnowledgeTool, executeSearchKnowledge } from './knowledge-tool';
@@ -45,11 +44,6 @@ const CheckServiceArgsSchema = z.object({
service_id: z.number().int().positive().optional(),
});
const RenderD2ArgsSchema = z.object({
source: z.string().min(1).max(10000),
format: z.enum(['svg', 'png']).optional(),
});
const AdminArgsSchema = z.object({
action: z.enum([
'block_user', 'unblock_user', 'set_role', 'broadcast',
@@ -109,7 +103,6 @@ const toolExecutors: Record<
manage_wallet: createValidatedExecutor(ManageWalletArgsSchema, executeManageWallet, 'wallet'),
manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'),
check_service: createValidatedExecutor(CheckServiceArgsSchema, executeCheckService, 'service'),
render_d2: createValidatedExecutor(RenderD2ArgsSchema, executeRenderD2, 'd2'),
admin: createValidatedExecutor(AdminArgsSchema, executeAdmin, 'admin'),
search_knowledge: createValidatedExecutor(SearchKnowledgeArgsSchema, executeSearchKnowledge, 'knowledge'),
};
@@ -123,7 +116,6 @@ export const tools: ToolDefinition[] = [
manageWalletTool,
manageServerTool,
checkServiceTool,
renderD2Tool,
adminTool,
searchKnowledgeTool,
];
@@ -205,6 +197,5 @@ export { manageDomainTool } from './domain-tool';
export { manageWalletTool } from './wallet-tool';
export { manageServerTool } from './server-tool';
export { checkServiceTool } from './service-tool';
export { renderD2Tool } from './d2-tool';
export { adminTool } from './admin-tool';
export { searchKnowledgeTool } from './knowledge-tool';

View File

@@ -1,8 +1,6 @@
import type { Env } from '../types';
const DEFAULT_OPENAI_GATEWAY = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai';
const DEFAULT_D2_RENDER_URL = 'http://10.253.100.107:8080';
/**
* OpenAI Chat Completions API URL (AI Gateway 경유)
*/
@@ -18,9 +16,3 @@ export function getOpenAIBaseUrl(env: Env): string {
return env.OPENAI_API_BASE || DEFAULT_OPENAI_GATEWAY;
}
/**
* D2 diagram rendering service URL
*/
export function getD2RenderUrl(env: Env): string {
return env.D2_RENDER_URL || DEFAULT_D2_RENDER_URL;
}

View File

@@ -25,7 +25,6 @@ export const EnvSchema = z.object({
// API URLs (optional, have defaults in wrangler.toml)
OPENAI_API_BASE: z.string().url().optional(),
D2_RENDER_URL: z.string().url().optional(),
NAMECHEAP_API_URL: z.string().url().optional(),
WHOIS_API_URL: z.string().url().optional(),
CLOUD_ORCHESTRATOR_URL: z.string().url().optional(),

View File

@@ -9,8 +9,8 @@ binding = "AI"
ENVIRONMENT = "production"
# AI Gateway 경유
OPENAI_API_BASE = "https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-ai-support/openai"
# D2 렌더링 서비스 (jp1 Incus)
D2_RENDER_URL = "http://10.253.100.107:8080"
# Kroki 다이어그램 서비스 (Mermaid/D2 등 → HAProxy)
KROKI_URL = "https://kroki.anvil.it.com"
# 외부 API
NAMECHEAP_API_URL = "https://namecheap-api.anvil.it.com"
WHOIS_API_URL = "https://whois-api-kappa-inoutercoms-projects.vercel.app"
@@ -58,6 +58,17 @@ index_name = "knowledge-embeddings"
[triggers]
crons = ["0 15 * * *", "*/5 * * * *", "0 * * * *"]
# Cloudflare Queues: 태그 기반 범용 비동기 작업 큐
[[queues.producers]]
queue = "work-queue"
binding = "WORK_QUEUE"
[[queues.consumers]]
queue = "work-queue"
max_batch_size = 1
max_retries = 2
dead_letter_queue = "work-queue-dlq"
# Secrets (wrangler secret put):
# - BOT_TOKEN: Telegram Bot Token
# - WEBHOOK_SECRET: Webhook 검증용 시크릿