diff --git a/src/agents/diagram-agent.ts b/src/agents/diagram-agent.ts
new file mode 100644
index 0000000..4fa62e2
--- /dev/null
+++ b/src/agents/diagram-agent.ts
@@ -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, '&').replace(//g, '>');
+ await sendMessage(env.BOT_TOKEN, chatId,
+ `다이어그램 렌더링 서비스에 연결할 수 없습니다.\n\nMermaid 코드:\n
${escaped}`,
+ { 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, '&').replace(//g, '>');
+ await sendMessage(env.BOT_TOKEN, chatId,
+ `다이어그램 이미지 전송에 실패했습니다.\n\nMermaid 코드:\n${escaped}`,
+ { 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+ }
+}
diff --git a/src/agents/onboarding-agent.ts b/src/agents/onboarding-agent.ts
index 6c83952..4e9b039 100644
--- a/src/agents/onboarding-agent.ts
+++ b/src/agents/onboarding-agent.ts
@@ -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 {
3. 기술 요구사항 파악 (트래픽 규모, 필요 스펙, 보안 요구사항)
4. 예산 범위 확인
5. 적절한 서비스 조합 추천
-6. 필요 시 D2 다이어그램으로 아키텍처 시각화
+6. 필요 시 Mermaid 다이어그램으로 아키텍처 시각화
## 현재 세션 상태: ${session.status}
@@ -67,7 +67,7 @@ export class OnboardingAgent extends BaseAgent {
- 상담이 자연스럽게 종료되면 __SESSION_END__를 응답 끝에 추가하세요.
## 도구 사용
-- 아키텍처 설명 시 render_d2로 다이어그램을 생성할 수 있습니다.
+- 아키텍처 설명 시 generate_diagram으로 Mermaid 다이어그램을 생성하여 고객에게 전송할 수 있습니다.
- 서비스 관련 질문에 search_knowledge로 지식 베이스를 검색할 수 있습니다.`;
}
@@ -76,22 +76,22 @@ export class OnboardingAgent extends BaseAgent {
{
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 {
context: AgentToolContext
): Promise {
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 {
}
}
- private async handleRenderD2(args: RenderD2Args, env: Env): Promise {
- 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 {
+ 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(
diff --git a/src/tools/d2-tool.ts b/src/tools/d2-tool.ts
deleted file mode 100644
index 26a7ab4..0000000
--- a/src/tools/d2-tool.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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('');
-}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index d03d75b..b6944c5 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -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';
diff --git a/src/utils/api-urls.ts b/src/utils/api-urls.ts
index 139ee74..3b3e68b 100644
--- a/src/utils/api-urls.ts
+++ b/src/utils/api-urls.ts
@@ -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;
-}
diff --git a/src/utils/env-validation.ts b/src/utils/env-validation.ts
index 2fc233c..6cfc217 100644
--- a/src/utils/env-validation.ts
+++ b/src/utils/env-validation.ts
@@ -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(),
diff --git a/wrangler.toml b/wrangler.toml
index 2d46495..6800002 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -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 검증용 시크릿