makecc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,732 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { GoogleGenerativeAI } from '@google/generative-ai';
3
+ import ExcelJS from 'exceljs';
4
+ import PptxGenJS from 'pptxgenjs';
5
+ import { writeFile, mkdir } from 'fs/promises';
6
+ import { join } from 'path';
7
+ import { existsSync } from 'fs';
8
+
9
+ export interface SkillExecutionResult {
10
+ success: boolean;
11
+ result?: string;
12
+ files?: Array<{ path: string; type: string; name: string }>;
13
+ error?: string;
14
+ }
15
+
16
+ type LogCallback = (type: 'info' | 'warn' | 'error' | 'debug', message: string) => void;
17
+
18
+ /**
19
+ * 스킬 실행 서비스 - 실제 파일 생성
20
+ */
21
+ export class SkillExecutionService {
22
+ private client: Anthropic;
23
+ private gemini: GoogleGenerativeAI | null = null;
24
+
25
+ constructor() {
26
+ this.client = new Anthropic({
27
+ apiKey: process.env.ANTHROPIC_API_KEY,
28
+ });
29
+
30
+ if (process.env.GEMINI_API_KEY) {
31
+ this.gemini = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 스킬 실행 메인 라우터
37
+ */
38
+ async execute(
39
+ skillId: string,
40
+ input: string,
41
+ outputDir: string,
42
+ onLog?: LogCallback
43
+ ): Promise<SkillExecutionResult> {
44
+ // 출력 디렉토리 생성
45
+ if (!existsSync(outputDir)) {
46
+ await mkdir(outputDir, { recursive: true });
47
+ }
48
+
49
+ onLog?.('info', `스킬 "${skillId}" 실행 중...`);
50
+
51
+ switch (skillId) {
52
+ case 'xlsx':
53
+ case 'excel':
54
+ return this.executeExcelSkill(input, outputDir, onLog);
55
+
56
+ case 'pptx':
57
+ case 'ppt-generator':
58
+ return this.executePptSkill(input, outputDir, onLog);
59
+
60
+ case 'image-gen-nanobanana':
61
+ case 'image-gen':
62
+ return this.executeImageGenSkill(input, outputDir, onLog);
63
+
64
+ case 'docx':
65
+ case 'word':
66
+ return this.executeWordSkill(input, outputDir, onLog);
67
+
68
+ case 'pdf':
69
+ return this.executePdfSkill(input, outputDir, onLog);
70
+
71
+ default:
72
+ onLog?.('warn', `알 수 없는 스킬: ${skillId}, 일반 텍스트 처리로 대체`);
73
+ return this.executeGenericSkill(skillId, input, outputDir, onLog);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Excel 스킬 - 실제 xlsx 파일 생성
79
+ */
80
+ private async executeExcelSkill(
81
+ input: string,
82
+ outputDir: string,
83
+ onLog?: LogCallback
84
+ ): Promise<SkillExecutionResult> {
85
+ onLog?.('info', 'Excel 파일 생성 중...');
86
+
87
+ try {
88
+ // 1단계: Claude로 엑셀 데이터 구조 생성
89
+ const dataPrompt = `당신은 Excel 데이터 구조화 전문가입니다.
90
+
91
+ ## 요청 내용
92
+ ${input}
93
+
94
+ ## 작업
95
+ 위 요청을 바탕으로 Excel 스프레드시트에 들어갈 데이터를 JSON 형식으로 생성해주세요.
96
+
97
+ 다음 형식으로 반환하세요:
98
+ {
99
+ "title": "문서 제목",
100
+ "sheets": [
101
+ {
102
+ "name": "시트 이름",
103
+ "headers": ["컬럼1", "컬럼2", "컬럼3"],
104
+ "data": [
105
+ ["값1", "값2", "값3"],
106
+ ["값4", "값5", "값6"]
107
+ ],
108
+ "columnWidths": [20, 30, 15]
109
+ }
110
+ ]
111
+ }
112
+
113
+ 실제 유용한 데이터를 생성해주세요. 반드시 유효한 JSON만 반환하세요.`;
114
+
115
+ const response = await this.client.messages.create({
116
+ model: 'claude-sonnet-4-20250514',
117
+ max_tokens: 4096,
118
+ messages: [{ role: 'user', content: dataPrompt }],
119
+ });
120
+
121
+ const responseText = response.content
122
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
123
+ .map((block) => block.text)
124
+ .join('\n');
125
+
126
+ // JSON 추출
127
+ const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/) ||
128
+ responseText.match(/\{[\s\S]*\}/);
129
+ const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : responseText;
130
+
131
+ let excelData;
132
+ try {
133
+ excelData = JSON.parse(jsonStr);
134
+ } catch {
135
+ // JSON 파싱 실패 시 기본 구조 생성
136
+ excelData = {
137
+ title: '생성된 문서',
138
+ sheets: [{
139
+ name: 'Sheet1',
140
+ headers: ['항목', '내용'],
141
+ data: [['데이터', responseText.substring(0, 100)]],
142
+ columnWidths: [20, 50],
143
+ }],
144
+ };
145
+ }
146
+
147
+ // 2단계: ExcelJS로 실제 파일 생성
148
+ const workbook = new ExcelJS.Workbook();
149
+ workbook.creator = 'Million Agent';
150
+ workbook.created = new Date();
151
+
152
+ for (const sheet of excelData.sheets) {
153
+ const worksheet = workbook.addWorksheet(sheet.name);
154
+
155
+ // 헤더 추가
156
+ if (sheet.headers) {
157
+ const headerRow = worksheet.addRow(sheet.headers);
158
+ headerRow.font = { bold: true };
159
+ headerRow.fill = {
160
+ type: 'pattern',
161
+ pattern: 'solid',
162
+ fgColor: { argb: 'FF4472C4' },
163
+ };
164
+ headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
165
+ }
166
+
167
+ // 데이터 추가
168
+ if (sheet.data) {
169
+ for (const row of sheet.data) {
170
+ worksheet.addRow(row);
171
+ }
172
+ }
173
+
174
+ // 컬럼 너비 설정
175
+ if (sheet.columnWidths) {
176
+ sheet.columnWidths.forEach((width: number, index: number) => {
177
+ worksheet.getColumn(index + 1).width = width;
178
+ });
179
+ }
180
+
181
+ // 테두리 추가
182
+ worksheet.eachRow((row) => {
183
+ row.eachCell((cell) => {
184
+ cell.border = {
185
+ top: { style: 'thin' },
186
+ left: { style: 'thin' },
187
+ bottom: { style: 'thin' },
188
+ right: { style: 'thin' },
189
+ };
190
+ });
191
+ });
192
+ }
193
+
194
+ // 파일 저장
195
+ const fileName = `${excelData.title || 'document'}.xlsx`.replace(/[/\\?%*:|"<>]/g, '_');
196
+ const filePath = join(outputDir, fileName);
197
+ await workbook.xlsx.writeFile(filePath);
198
+
199
+ onLog?.('info', `Excel 파일 생성 완료: ${fileName}`);
200
+
201
+ return {
202
+ success: true,
203
+ result: `Excel 파일이 생성되었습니다: ${fileName}\n\n시트 수: ${excelData.sheets.length}`,
204
+ files: [{ path: filePath, type: 'xlsx', name: fileName }],
205
+ };
206
+ } catch (error) {
207
+ const errorMsg = error instanceof Error ? error.message : 'Excel 생성 실패';
208
+ onLog?.('error', errorMsg);
209
+ return { success: false, error: errorMsg };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * PPT 스킬 - 실제 pptx 파일 생성
215
+ */
216
+ private async executePptSkill(
217
+ input: string,
218
+ outputDir: string,
219
+ onLog?: LogCallback
220
+ ): Promise<SkillExecutionResult> {
221
+ onLog?.('info', 'PowerPoint 파일 생성 중...');
222
+
223
+ try {
224
+ // 1단계: Claude로 PPT 구조 생성
225
+ const slidePrompt = `당신은 프레젠테이션 전문가입니다.
226
+
227
+ ## 요청 내용
228
+ ${input}
229
+
230
+ ## 작업
231
+ 위 요청을 바탕으로 프레젠테이션 슬라이드 구조를 JSON 형식으로 생성해주세요.
232
+
233
+ 다음 형식으로 반환하세요:
234
+ {
235
+ "title": "프레젠테이션 제목",
236
+ "author": "작성자",
237
+ "slides": [
238
+ {
239
+ "type": "title",
240
+ "title": "메인 제목",
241
+ "subtitle": "부제목"
242
+ },
243
+ {
244
+ "type": "content",
245
+ "title": "슬라이드 제목",
246
+ "bullets": ["항목 1", "항목 2", "항목 3"]
247
+ },
248
+ {
249
+ "type": "two-column",
250
+ "title": "비교 슬라이드",
251
+ "left": { "title": "왼쪽 제목", "bullets": ["항목1", "항목2"] },
252
+ "right": { "title": "오른쪽 제목", "bullets": ["항목1", "항목2"] }
253
+ }
254
+ ]
255
+ }
256
+
257
+ 10-15개 슬라이드로 구성해주세요. 반드시 유효한 JSON만 반환하세요.`;
258
+
259
+ const response = await this.client.messages.create({
260
+ model: 'claude-sonnet-4-20250514',
261
+ max_tokens: 4096,
262
+ messages: [{ role: 'user', content: slidePrompt }],
263
+ });
264
+
265
+ const responseText = response.content
266
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
267
+ .map((block) => block.text)
268
+ .join('\n');
269
+
270
+ // JSON 추출
271
+ const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/) ||
272
+ responseText.match(/\{[\s\S]*\}/);
273
+ const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : responseText;
274
+
275
+ let pptData;
276
+ try {
277
+ pptData = JSON.parse(jsonStr);
278
+ } catch {
279
+ pptData = {
280
+ title: '프레젠테이션',
281
+ slides: [
282
+ { type: 'title', title: '프레젠테이션', subtitle: input.substring(0, 50) },
283
+ { type: 'content', title: '내용', bullets: [responseText.substring(0, 200)] },
284
+ ],
285
+ };
286
+ }
287
+
288
+ // 2단계: PptxGenJS로 실제 파일 생성
289
+ const pptx = new PptxGenJS();
290
+ pptx.author = pptData.author || 'Million Agent';
291
+ pptx.title = pptData.title;
292
+ pptx.subject = pptData.title;
293
+
294
+ // 슬라이드 생성
295
+ for (const slideData of pptData.slides) {
296
+ const slide = pptx.addSlide();
297
+
298
+ switch (slideData.type) {
299
+ case 'title':
300
+ // 타이틀 슬라이드
301
+ slide.addText(slideData.title, {
302
+ x: 0.5,
303
+ y: 2.5,
304
+ w: '90%',
305
+ h: 1.5,
306
+ fontSize: 44,
307
+ bold: true,
308
+ align: 'center',
309
+ color: '363636',
310
+ });
311
+ if (slideData.subtitle) {
312
+ slide.addText(slideData.subtitle, {
313
+ x: 0.5,
314
+ y: 4,
315
+ w: '90%',
316
+ h: 0.75,
317
+ fontSize: 24,
318
+ align: 'center',
319
+ color: '666666',
320
+ });
321
+ }
322
+ break;
323
+
324
+ case 'content':
325
+ // 컨텐츠 슬라이드
326
+ slide.addText(slideData.title, {
327
+ x: 0.5,
328
+ y: 0.5,
329
+ w: '90%',
330
+ h: 0.75,
331
+ fontSize: 32,
332
+ bold: true,
333
+ color: '363636',
334
+ });
335
+ if (slideData.bullets) {
336
+ const bulletText = slideData.bullets.map((b: string) => ({
337
+ text: b,
338
+ options: { bullet: true, fontSize: 18, color: '444444' },
339
+ }));
340
+ slide.addText(bulletText, {
341
+ x: 0.5,
342
+ y: 1.5,
343
+ w: '90%',
344
+ h: 4,
345
+ valign: 'top',
346
+ });
347
+ }
348
+ break;
349
+
350
+ case 'two-column':
351
+ // 2컬럼 슬라이드
352
+ slide.addText(slideData.title, {
353
+ x: 0.5,
354
+ y: 0.5,
355
+ w: '90%',
356
+ h: 0.75,
357
+ fontSize: 32,
358
+ bold: true,
359
+ color: '363636',
360
+ });
361
+ // 왼쪽 컬럼
362
+ if (slideData.left) {
363
+ slide.addText(slideData.left.title, {
364
+ x: 0.5,
365
+ y: 1.5,
366
+ w: 4.5,
367
+ h: 0.5,
368
+ fontSize: 20,
369
+ bold: true,
370
+ color: '4472C4',
371
+ });
372
+ const leftBullets = (slideData.left.bullets || []).map((b: string) => ({
373
+ text: b,
374
+ options: { bullet: true, fontSize: 16, color: '444444' },
375
+ }));
376
+ slide.addText(leftBullets, {
377
+ x: 0.5,
378
+ y: 2.1,
379
+ w: 4.5,
380
+ h: 3,
381
+ valign: 'top',
382
+ });
383
+ }
384
+ // 오른쪽 컬럼
385
+ if (slideData.right) {
386
+ slide.addText(slideData.right.title, {
387
+ x: 5.2,
388
+ y: 1.5,
389
+ w: 4.5,
390
+ h: 0.5,
391
+ fontSize: 20,
392
+ bold: true,
393
+ color: '4472C4',
394
+ });
395
+ const rightBullets = (slideData.right.bullets || []).map((b: string) => ({
396
+ text: b,
397
+ options: { bullet: true, fontSize: 16, color: '444444' },
398
+ }));
399
+ slide.addText(rightBullets, {
400
+ x: 5.2,
401
+ y: 2.1,
402
+ w: 4.5,
403
+ h: 3,
404
+ valign: 'top',
405
+ });
406
+ }
407
+ break;
408
+
409
+ default:
410
+ // 기본 슬라이드
411
+ slide.addText(slideData.title || '슬라이드', {
412
+ x: 0.5,
413
+ y: 0.5,
414
+ w: '90%',
415
+ h: 0.75,
416
+ fontSize: 32,
417
+ bold: true,
418
+ });
419
+ }
420
+ }
421
+
422
+ // 파일 저장
423
+ const fileName = `${pptData.title || 'presentation'}.pptx`.replace(/[/\\?%*:|"<>]/g, '_');
424
+ const filePath = join(outputDir, fileName);
425
+ await pptx.writeFile({ fileName: filePath });
426
+
427
+ onLog?.('info', `PowerPoint 파일 생성 완료: ${fileName}`);
428
+
429
+ return {
430
+ success: true,
431
+ result: `PowerPoint 파일이 생성되었습니다: ${fileName}\n\n슬라이드 수: ${pptData.slides.length}`,
432
+ files: [{ path: filePath, type: 'pptx', name: fileName }],
433
+ };
434
+ } catch (error) {
435
+ const errorMsg = error instanceof Error ? error.message : 'PPT 생성 실패';
436
+ onLog?.('error', errorMsg);
437
+ return { success: false, error: errorMsg };
438
+ }
439
+ }
440
+
441
+ /**
442
+ * 이미지 생성 스킬 - Gemini API 사용
443
+ */
444
+ private async executeImageGenSkill(
445
+ input: string,
446
+ outputDir: string,
447
+ onLog?: LogCallback
448
+ ): Promise<SkillExecutionResult> {
449
+ onLog?.('info', '이미지 생성 중...');
450
+
451
+ try {
452
+ // 1단계: Claude로 이미지 프롬프트 최적화
453
+ const promptOptimization = `당신은 AI 이미지 생성 프롬프트 전문가입니다.
454
+
455
+ ## 요청
456
+ ${input}
457
+
458
+ ## 작업
459
+ 위 요청을 바탕으로 고품질 이미지 생성을 위한 영문 프롬프트를 작성하세요.
460
+ 프롬프트는 구체적이고 시각적으로 묘사해야 합니다.
461
+
462
+ 다음 JSON 형식으로 반환하세요:
463
+ {
464
+ "prompts": [
465
+ {
466
+ "name": "이미지 이름 (한글)",
467
+ "prompt": "상세한 영문 이미지 프롬프트",
468
+ "style": "스타일 (realistic, illustration, cartoon 등)"
469
+ }
470
+ ]
471
+ }
472
+
473
+ 3-5개 이미지 프롬프트를 생성하세요. 반드시 유효한 JSON만 반환하세요.`;
474
+
475
+ const response = await this.client.messages.create({
476
+ model: 'claude-sonnet-4-20250514',
477
+ max_tokens: 2048,
478
+ messages: [{ role: 'user', content: promptOptimization }],
479
+ });
480
+
481
+ const responseText = response.content
482
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
483
+ .map((block) => block.text)
484
+ .join('\n');
485
+
486
+ // JSON 추출
487
+ const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/) ||
488
+ responseText.match(/\{[\s\S]*\}/);
489
+ const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : responseText;
490
+
491
+ let imageData;
492
+ try {
493
+ imageData = JSON.parse(jsonStr);
494
+ } catch {
495
+ imageData = {
496
+ prompts: [{ name: '이미지', prompt: input, style: 'realistic' }],
497
+ };
498
+ }
499
+
500
+ const generatedFiles: Array<{ path: string; type: string; name: string }> = [];
501
+ const results: string[] = [];
502
+
503
+ // 2단계: Gemini API로 이미지 생성
504
+ if (this.gemini) {
505
+ onLog?.('info', 'Gemini API로 이미지 생성 중...');
506
+
507
+ for (let i = 0; i < imageData.prompts.length; i++) {
508
+ const { name, prompt, style } = imageData.prompts[i];
509
+ onLog?.('debug', `이미지 ${i + 1}/${imageData.prompts.length}: ${name}`);
510
+
511
+ try {
512
+ const model = this.gemini.getGenerativeModel({ model: 'gemini-3-pro-image-preview' });
513
+
514
+ const result = await model.generateContent({
515
+ contents: [{
516
+ role: 'user',
517
+ parts: [{ text: `Generate an image: ${prompt}. Style: ${style}` }]
518
+ }],
519
+ generationConfig: {
520
+ responseModalities: ['TEXT', 'IMAGE'],
521
+ } as any,
522
+ });
523
+
524
+ const response = result.response;
525
+
526
+ // 이미지 데이터 추출
527
+ for (const part of response.candidates?.[0]?.content?.parts || []) {
528
+ if ((part as any).inlineData) {
529
+ const imageBuffer = Buffer.from((part as any).inlineData.data, 'base64');
530
+ const fileName = `${name.replace(/[/\\?%*:|"<>]/g, '_')}_${i + 1}.png`;
531
+ const filePath = join(outputDir, fileName);
532
+
533
+ await writeFile(filePath, imageBuffer);
534
+
535
+ generatedFiles.push({ path: filePath, type: 'image', name: fileName });
536
+ results.push(`✅ ${name}: ${fileName}`);
537
+ onLog?.('info', `이미지 생성 완료: ${fileName}`);
538
+ }
539
+ }
540
+ } catch (imgError) {
541
+ const errMsg = imgError instanceof Error ? imgError.message : '이미지 생성 실패';
542
+ results.push(`❌ ${name}: ${errMsg}`);
543
+ onLog?.('warn', `이미지 생성 실패 (${name}): ${errMsg}`);
544
+ }
545
+ }
546
+ } else {
547
+ onLog?.('warn', 'GEMINI_API_KEY가 설정되지 않아 이미지 프롬프트만 생성합니다.');
548
+
549
+ // 프롬프트 파일로 저장
550
+ const promptContent = imageData.prompts.map((p: any, i: number) =>
551
+ `## 이미지 ${i + 1}: ${p.name}\n\n**프롬프트:** ${p.prompt}\n\n**스타일:** ${p.style}\n`
552
+ ).join('\n---\n\n');
553
+
554
+ const promptPath = join(outputDir, 'image-prompts.md');
555
+ await writeFile(promptPath, `# 이미지 생성 프롬프트\n\n${promptContent}`, 'utf-8');
556
+
557
+ generatedFiles.push({ path: promptPath, type: 'markdown', name: 'image-prompts.md' });
558
+ results.push('이미지 프롬프트가 생성되었습니다. (GEMINI_API_KEY 설정 시 실제 이미지 생성)');
559
+ }
560
+
561
+ return {
562
+ success: true,
563
+ result: `이미지 생성 결과:\n\n${results.join('\n')}`,
564
+ files: generatedFiles,
565
+ };
566
+ } catch (error) {
567
+ const errorMsg = error instanceof Error ? error.message : '이미지 생성 실패';
568
+ onLog?.('error', errorMsg);
569
+ return { success: false, error: errorMsg };
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Word 문서 스킬 - HTML 기반 문서 생성
575
+ */
576
+ private async executeWordSkill(
577
+ input: string,
578
+ outputDir: string,
579
+ onLog?: LogCallback
580
+ ): Promise<SkillExecutionResult> {
581
+ onLog?.('info', 'Word 문서 생성 중...');
582
+
583
+ try {
584
+ const docPrompt = `당신은 전문 문서 작성가입니다.
585
+
586
+ ## 요청
587
+ ${input}
588
+
589
+ ## 작업
590
+ 위 요청을 바탕으로 전문적인 문서를 작성하세요.
591
+ 마크다운 형식으로 작성하며, 제목, 부제, 목차, 본문을 포함해주세요.`;
592
+
593
+ const response = await this.client.messages.create({
594
+ model: 'claude-sonnet-4-20250514',
595
+ max_tokens: 4096,
596
+ messages: [{ role: 'user', content: docPrompt }],
597
+ });
598
+
599
+ const docContent = response.content
600
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
601
+ .map((block) => block.text)
602
+ .join('\n');
603
+
604
+ // HTML 문서 생성 (Word에서 열 수 있는 형식)
605
+ const htmlContent = `<!DOCTYPE html>
606
+ <html>
607
+ <head>
608
+ <meta charset="UTF-8">
609
+ <title>문서</title>
610
+ <style>
611
+ body { font-family: 'Malgun Gothic', sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; }
612
+ h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }
613
+ h2 { color: #34495e; margin-top: 30px; }
614
+ h3 { color: #7f8c8d; }
615
+ p { line-height: 1.8; color: #2c3e50; }
616
+ ul, ol { margin: 15px 0; }
617
+ li { margin: 8px 0; }
618
+ </style>
619
+ </head>
620
+ <body>
621
+ ${this.markdownToHtml(docContent)}
622
+ </body>
623
+ </html>`;
624
+
625
+ const fileName = 'document.html';
626
+ const filePath = join(outputDir, fileName);
627
+ await writeFile(filePath, htmlContent, 'utf-8');
628
+
629
+ // 마크다운 버전도 저장
630
+ const mdPath = join(outputDir, 'document.md');
631
+ await writeFile(mdPath, docContent, 'utf-8');
632
+
633
+ onLog?.('info', `문서 생성 완료: ${fileName}`);
634
+
635
+ return {
636
+ success: true,
637
+ result: docContent,
638
+ files: [
639
+ { path: filePath, type: 'html', name: fileName },
640
+ { path: mdPath, type: 'markdown', name: 'document.md' },
641
+ ],
642
+ };
643
+ } catch (error) {
644
+ const errorMsg = error instanceof Error ? error.message : '문서 생성 실패';
645
+ onLog?.('error', errorMsg);
646
+ return { success: false, error: errorMsg };
647
+ }
648
+ }
649
+
650
+ /**
651
+ * PDF 스킬 - HTML 기반 PDF 생성 (기본)
652
+ */
653
+ private async executePdfSkill(
654
+ input: string,
655
+ outputDir: string,
656
+ onLog?: LogCallback
657
+ ): Promise<SkillExecutionResult> {
658
+ // PDF 생성은 Word와 유사하게 HTML 생성 후 안내
659
+ onLog?.('info', 'PDF 문서 생성 중...');
660
+
661
+ const result = await this.executeWordSkill(input, outputDir, onLog);
662
+
663
+ if (result.success) {
664
+ result.result += '\n\n💡 HTML 파일을 브라우저에서 열고 PDF로 인쇄하여 PDF를 생성할 수 있습니다.';
665
+ }
666
+
667
+ return result;
668
+ }
669
+
670
+ /**
671
+ * 일반 스킬 실행 (Claude 기반)
672
+ */
673
+ private async executeGenericSkill(
674
+ skillId: string,
675
+ input: string,
676
+ outputDir: string,
677
+ onLog?: LogCallback
678
+ ): Promise<SkillExecutionResult> {
679
+ try {
680
+ const response = await this.client.messages.create({
681
+ model: 'claude-sonnet-4-20250514',
682
+ max_tokens: 4096,
683
+ messages: [{
684
+ role: 'user',
685
+ content: `스킬: ${skillId}\n\n입력:\n${input}\n\n위 스킬을 실행하고 결과를 제공해주세요.`,
686
+ }],
687
+ });
688
+
689
+ const result = response.content
690
+ .filter((block): block is Anthropic.TextBlock => block.type === 'text')
691
+ .map((block) => block.text)
692
+ .join('\n');
693
+
694
+ // 결과 파일로 저장
695
+ const filePath = join(outputDir, `${skillId}-result.md`);
696
+ await writeFile(filePath, result, 'utf-8');
697
+
698
+ return {
699
+ success: true,
700
+ result,
701
+ files: [{ path: filePath, type: 'markdown', name: `${skillId}-result.md` }],
702
+ };
703
+ } catch (error) {
704
+ const errorMsg = error instanceof Error ? error.message : '스킬 실행 실패';
705
+ onLog?.('error', errorMsg);
706
+ return { success: false, error: errorMsg };
707
+ }
708
+ }
709
+
710
+ /**
711
+ * 간단한 마크다운 → HTML 변환
712
+ */
713
+ private markdownToHtml(md: string): string {
714
+ return md
715
+ .replace(/^### (.*$)/gim, '<h3>$1</h3>')
716
+ .replace(/^## (.*$)/gim, '<h2>$1</h2>')
717
+ .replace(/^# (.*$)/gim, '<h1>$1</h1>')
718
+ .replace(/^\* (.*$)/gim, '<li>$1</li>')
719
+ .replace(/^- (.*$)/gim, '<li>$1</li>')
720
+ .replace(/^\d+\. (.*$)/gim, '<li>$1</li>')
721
+ .replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
722
+ .replace(/\*(.*)\*/gim, '<em>$1</em>')
723
+ .replace(/\n\n/gim, '</p><p>')
724
+ .replace(/^(.*)$/gim, '<p>$1</p>')
725
+ .replace(/<p><h/gim, '<h')
726
+ .replace(/<\/h(\d)><\/p>/gim, '</h$1>')
727
+ .replace(/<p><li>/gim, '<ul><li>')
728
+ .replace(/<\/li><\/p>/gim, '</li></ul>');
729
+ }
730
+ }
731
+
732
+ export const skillExecutionService = new SkillExecutionService();