mcp-rubber-duck 1.9.5 → 1.10.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.
Files changed (54) hide show
  1. package/.eslintrc.json +3 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +54 -10
  4. package/assets/ext-apps-compare.png +0 -0
  5. package/assets/ext-apps-debate.png +0 -0
  6. package/assets/ext-apps-usage-stats.png +0 -0
  7. package/assets/ext-apps-vote.png +0 -0
  8. package/audit-ci.json +2 -1
  9. package/dist/server.d.ts +1 -0
  10. package/dist/server.d.ts.map +1 -1
  11. package/dist/server.js +62 -4
  12. package/dist/server.js.map +1 -1
  13. package/dist/tools/compare-ducks.d.ts.map +1 -1
  14. package/dist/tools/compare-ducks.js +19 -0
  15. package/dist/tools/compare-ducks.js.map +1 -1
  16. package/dist/tools/duck-debate.d.ts.map +1 -1
  17. package/dist/tools/duck-debate.js +24 -0
  18. package/dist/tools/duck-debate.js.map +1 -1
  19. package/dist/tools/duck-vote.d.ts.map +1 -1
  20. package/dist/tools/duck-vote.js +23 -0
  21. package/dist/tools/duck-vote.js.map +1 -1
  22. package/dist/tools/get-usage-stats.d.ts.map +1 -1
  23. package/dist/tools/get-usage-stats.js +13 -0
  24. package/dist/tools/get-usage-stats.js.map +1 -1
  25. package/dist/ui/compare-ducks/mcp-app.html +187 -0
  26. package/dist/ui/duck-debate/mcp-app.html +182 -0
  27. package/dist/ui/duck-vote/mcp-app.html +168 -0
  28. package/dist/ui/usage-stats/mcp-app.html +192 -0
  29. package/jest.config.js +1 -0
  30. package/package.json +7 -3
  31. package/src/server.ts +79 -4
  32. package/src/tools/compare-ducks.ts +20 -0
  33. package/src/tools/duck-debate.ts +27 -0
  34. package/src/tools/duck-vote.ts +24 -0
  35. package/src/tools/get-usage-stats.ts +14 -0
  36. package/src/ui/compare-ducks/app.ts +88 -0
  37. package/src/ui/compare-ducks/mcp-app.html +102 -0
  38. package/src/ui/duck-debate/app.ts +111 -0
  39. package/src/ui/duck-debate/mcp-app.html +97 -0
  40. package/src/ui/duck-vote/app.ts +128 -0
  41. package/src/ui/duck-vote/mcp-app.html +83 -0
  42. package/src/ui/usage-stats/app.ts +156 -0
  43. package/src/ui/usage-stats/mcp-app.html +107 -0
  44. package/tests/duck-debate.test.ts +3 -1
  45. package/tests/duck-vote.test.ts +3 -1
  46. package/tests/tools/compare-ducks-ui.test.ts +135 -0
  47. package/tests/tools/compare-ducks.test.ts +3 -1
  48. package/tests/tools/duck-debate-ui.test.ts +234 -0
  49. package/tests/tools/duck-vote-ui.test.ts +172 -0
  50. package/tests/tools/get-usage-stats.test.ts +3 -1
  51. package/tests/tools/usage-stats-ui.test.ts +130 -0
  52. package/tests/ui-build.test.ts +53 -0
  53. package/tsconfig.json +1 -1
  54. package/vite.config.ts +19 -0
package/jest.config.js CHANGED
@@ -22,5 +22,6 @@ export default {
22
22
  'src/**/*.ts',
23
23
  '!src/**/*.d.ts',
24
24
  '!src/index.ts',
25
+ '!src/ui/**',
25
26
  ],
26
27
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-rubber-duck",
3
- "version": "1.9.5",
3
+ "version": "1.10.0",
4
4
  "description": "An MCP server that bridges to multiple OpenAI-compatible LLMs - your AI rubber duck debugging panel",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "mcp-rubber-duck": "./dist/index.js"
9
9
  },
10
10
  "scripts": {
11
- "build": "tsc",
11
+ "build": "tsc && npm run build:ui",
12
+ "build:ui": "VITE_UI_ENTRY=compare-ducks vite build && VITE_UI_ENTRY=duck-vote vite build && VITE_UI_ENTRY=duck-debate vite build && VITE_UI_ENTRY=usage-stats vite build",
12
13
  "dev": "tsx watch src/index.ts",
13
14
  "start": "node dist/index.js",
14
15
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
@@ -42,6 +43,7 @@
42
43
  "access": "public"
43
44
  },
44
45
  "dependencies": {
46
+ "@modelcontextprotocol/ext-apps": "^1.0.1",
45
47
  "@modelcontextprotocol/sdk": "^1.24.0",
46
48
  "@semantic-release/npm": "^13.1.3",
47
49
  "ajv": "^8.17.1",
@@ -65,7 +67,9 @@
65
67
  "semantic-release": "^25.0.2",
66
68
  "ts-jest": "^29.0.0",
67
69
  "tsx": "^4.0.0",
68
- "typescript": "^5.0.0"
70
+ "typescript": "^5.0.0",
71
+ "vite": "^7.3.1",
72
+ "vite-plugin-singlefile": "^2.3.0"
69
73
  },
70
74
  "overrides": {
71
75
  "js-yaml": "^4.1.1",
package/src/server.ts CHANGED
@@ -2,6 +2,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { z } from 'zod';
5
+ import { readFileSync } from 'fs';
6
+ import { join, dirname } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import {
9
+ registerAppTool,
10
+ registerAppResource,
11
+ RESOURCE_MIME_TYPE,
12
+ } from '@modelcontextprotocol/ext-apps/server';
5
13
 
6
14
  import { ConfigManager } from './config/config.js';
7
15
  import { ProviderManager } from './providers/manager.js';
@@ -94,6 +102,7 @@ export class RubberDuckServer {
94
102
 
95
103
  this.registerTools();
96
104
  this.registerPrompts();
105
+ this.registerUIResources();
97
106
 
98
107
  // Handle errors
99
108
  this.server.server.onerror = (error) => {
@@ -288,7 +297,8 @@ export class RubberDuckServer {
288
297
  );
289
298
 
290
299
  // compare_ducks
291
- this.server.registerTool(
300
+ registerAppTool(
301
+ this.server,
292
302
  'compare_ducks',
293
303
  {
294
304
  description: 'Ask the same question to multiple ducks simultaneously',
@@ -301,6 +311,7 @@ export class RubberDuckServer {
301
311
  readOnlyHint: true,
302
312
  openWorldHint: true,
303
313
  },
314
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/compare-ducks' } },
304
315
  },
305
316
  async (args) => {
306
317
  try {
@@ -341,7 +352,8 @@ export class RubberDuckServer {
341
352
  );
342
353
 
343
354
  // duck_vote
344
- this.server.registerTool(
355
+ registerAppTool(
356
+ this.server,
345
357
  'duck_vote',
346
358
  {
347
359
  description: 'Have multiple ducks vote on options with reasoning. Returns vote tally, confidence scores, and consensus level.',
@@ -355,6 +367,7 @@ export class RubberDuckServer {
355
367
  readOnlyHint: true,
356
368
  openWorldHint: true,
357
369
  },
370
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/duck-vote' } },
358
371
  },
359
372
  async (args) => {
360
373
  try {
@@ -421,7 +434,8 @@ export class RubberDuckServer {
421
434
  );
422
435
 
423
436
  // duck_debate
424
- this.server.registerTool(
437
+ registerAppTool(
438
+ this.server,
425
439
  'duck_debate',
426
440
  {
427
441
  description: 'Structured multi-round debate between ducks. Supports oxford (pro/con), socratic (questioning), and adversarial (attack/defend) formats.',
@@ -436,6 +450,7 @@ export class RubberDuckServer {
436
450
  readOnlyHint: true,
437
451
  openWorldHint: true,
438
452
  },
453
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/duck-debate' } },
439
454
  },
440
455
  async (args) => {
441
456
  try {
@@ -447,7 +462,8 @@ export class RubberDuckServer {
447
462
  );
448
463
 
449
464
  // get_usage_stats
450
- this.server.registerTool(
465
+ registerAppTool(
466
+ this.server,
451
467
  'get_usage_stats',
452
468
  {
453
469
  description: 'Get usage statistics for a time period. Shows token counts and costs (when pricing configured).',
@@ -458,6 +474,7 @@ export class RubberDuckServer {
458
474
  readOnlyHint: true,
459
475
  openWorldHint: false,
460
476
  },
477
+ _meta: { ui: { resourceUri: 'ui://rubber-duck/usage-stats' } },
461
478
  },
462
479
  (args) => {
463
480
  try {
@@ -581,6 +598,44 @@ export class RubberDuckServer {
581
598
  }
582
599
  }
583
600
 
601
+ private registerUIResources() {
602
+ const currentDir = dirname(fileURLToPath(import.meta.url));
603
+ const uiDir = join(currentDir, '..', 'dist', 'ui');
604
+
605
+ const uiApps = [
606
+ { name: 'Compare Ducks', uri: 'ui://rubber-duck/compare-ducks', file: 'compare-ducks/mcp-app.html' },
607
+ { name: 'Duck Vote', uri: 'ui://rubber-duck/duck-vote', file: 'duck-vote/mcp-app.html' },
608
+ { name: 'Duck Debate', uri: 'ui://rubber-duck/duck-debate', file: 'duck-debate/mcp-app.html' },
609
+ { name: 'Usage Stats', uri: 'ui://rubber-duck/usage-stats', file: 'usage-stats/mcp-app.html' },
610
+ ];
611
+
612
+ for (const app of uiApps) {
613
+ registerAppResource(
614
+ this.server,
615
+ app.name,
616
+ app.uri,
617
+ { description: `Interactive UI for ${app.name}` },
618
+ () => {
619
+ let html: string;
620
+ try {
621
+ html = readFileSync(join(uiDir, app.file), 'utf-8');
622
+ } catch {
623
+ html = `<html><body><p>UI not built. Run npm run build:ui</p></body></html>`;
624
+ }
625
+ return {
626
+ contents: [
627
+ {
628
+ uri: app.uri,
629
+ mimeType: RESOURCE_MIME_TYPE,
630
+ text: html,
631
+ },
632
+ ],
633
+ };
634
+ }
635
+ );
636
+ }
637
+ }
638
+
584
639
  // MCP-enhanced tool handlers
585
640
  private async handleAskDuckWithMCP(args: Record<string, unknown>) {
586
641
  if (!this.enhancedProviderManager || !this.cache) {
@@ -641,12 +696,32 @@ export class RubberDuckServer {
641
696
  .map((response) => this.formatEnhancedDuckResponse(response))
642
697
  .join('\n\n═══════════════════════════════════════\n\n');
643
698
 
699
+ // Build structured data for UI consumption (same shape as compareDucksTool)
700
+ const structuredData = responses.map(r => ({
701
+ provider: r.provider,
702
+ nickname: r.nickname,
703
+ model: r.model,
704
+ content: r.content,
705
+ latency: r.latency,
706
+ tokens: r.usage ? {
707
+ prompt: r.usage.prompt_tokens,
708
+ completion: r.usage.completion_tokens,
709
+ total: r.usage.total_tokens,
710
+ } : null,
711
+ cached: r.cached,
712
+ error: r.content.startsWith('Error:') ? r.content : undefined,
713
+ }));
714
+
644
715
  return {
645
716
  content: [
646
717
  {
647
718
  type: 'text' as const,
648
719
  text: formattedResponse,
649
720
  },
721
+ {
722
+ type: 'text' as const,
723
+ text: JSON.stringify(structuredData),
724
+ },
650
725
  ],
651
726
  };
652
727
  }
@@ -55,12 +55,32 @@ export async function compareDucksTool(
55
55
 
56
56
  logger.info(`Compared ${responses.length} ducks, ${successCount} successful`);
57
57
 
58
+ // Build structured data for UI consumption
59
+ const structuredData = responses.map(r => ({
60
+ provider: r.provider,
61
+ nickname: r.nickname,
62
+ model: r.model,
63
+ content: r.content,
64
+ latency: r.latency,
65
+ tokens: r.usage ? {
66
+ prompt: r.usage.prompt_tokens,
67
+ completion: r.usage.completion_tokens,
68
+ total: r.usage.total_tokens,
69
+ } : null,
70
+ cached: r.cached,
71
+ error: r.content.startsWith('Error:') ? r.content : undefined,
72
+ }));
73
+
58
74
  return {
59
75
  content: [
60
76
  {
61
77
  type: 'text',
62
78
  text: response,
63
79
  },
80
+ {
81
+ type: 'text',
82
+ text: JSON.stringify(structuredData),
83
+ },
64
84
  ],
65
85
  };
66
86
  }
@@ -124,12 +124,39 @@ export async function duckDebateTool(
124
124
 
125
125
  logger.info(`Debate completed: ${rounds} rounds, synthesized by ${synthesizerProvider}`);
126
126
 
127
+ // Build structured data for UI consumption
128
+ const structuredData = {
129
+ topic: result.topic,
130
+ format: result.format,
131
+ totalRounds: result.totalRounds,
132
+ participants: result.participants.map(p => ({
133
+ provider: p.provider,
134
+ nickname: p.nickname,
135
+ position: p.position,
136
+ })),
137
+ rounds: result.rounds.map(roundArgs =>
138
+ roundArgs.map(arg => ({
139
+ round: arg.round,
140
+ provider: arg.provider,
141
+ nickname: arg.nickname,
142
+ position: arg.position,
143
+ content: arg.content,
144
+ }))
145
+ ),
146
+ synthesis: result.synthesis,
147
+ synthesizer: result.synthesizer,
148
+ };
149
+
127
150
  return {
128
151
  content: [
129
152
  {
130
153
  type: 'text',
131
154
  text: formattedOutput,
132
155
  },
156
+ {
157
+ type: 'text',
158
+ text: JSON.stringify(structuredData),
159
+ },
133
160
  ],
134
161
  };
135
162
  }
@@ -76,12 +76,36 @@ export async function duckVoteTool(
76
76
  `winner: ${aggregatedResult.winner || 'none'}`
77
77
  );
78
78
 
79
+ // Build structured data for UI consumption
80
+ const structuredData = {
81
+ question: aggregatedResult.question,
82
+ options: aggregatedResult.options,
83
+ winner: aggregatedResult.winner,
84
+ isTie: aggregatedResult.isTie,
85
+ tally: aggregatedResult.tally,
86
+ confidenceByOption: aggregatedResult.confidenceByOption,
87
+ votes: aggregatedResult.votes.map(v => ({
88
+ voter: v.voter,
89
+ nickname: v.nickname,
90
+ choice: v.choice,
91
+ confidence: v.confidence,
92
+ reasoning: v.reasoning,
93
+ })),
94
+ totalVoters: aggregatedResult.totalVoters,
95
+ validVotes: aggregatedResult.validVotes,
96
+ consensusLevel: aggregatedResult.consensusLevel,
97
+ };
98
+
79
99
  return {
80
100
  content: [
81
101
  {
82
102
  type: 'text',
83
103
  text: formattedOutput,
84
104
  },
105
+ {
106
+ type: 'text',
107
+ text: JSON.stringify(structuredData),
108
+ },
85
109
  ],
86
110
  };
87
111
  }
@@ -75,12 +75,26 @@ export function getUsageStatsTool(
75
75
 
76
76
  logger.info(`Retrieved usage stats for period: ${period}`);
77
77
 
78
+ // Build structured data for UI consumption
79
+ const structuredData = {
80
+ period: stats.period,
81
+ startDate: stats.startDate,
82
+ endDate: stats.endDate,
83
+ totals: stats.totals,
84
+ usage: stats.usage,
85
+ costByProvider: stats.costByProvider,
86
+ };
87
+
78
88
  return {
79
89
  content: [
80
90
  {
81
91
  type: 'text',
82
92
  text: output,
83
93
  },
94
+ {
95
+ type: 'text',
96
+ text: JSON.stringify(structuredData),
97
+ },
84
98
  ],
85
99
  };
86
100
  }
@@ -0,0 +1,88 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface CompareResponse {
4
+ provider: string;
5
+ nickname: string;
6
+ model: string;
7
+ content: string;
8
+ latency: number;
9
+ tokens: { prompt: number; completion: number; total: number } | null;
10
+ cached: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ const app = new App({ name: 'CompareDucks', version: '1.0.0' }, {});
15
+
16
+ app.ontoolresult = (params) => {
17
+ const container = document.getElementById('app')!;
18
+ if (params.isError) {
19
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
20
+ return;
21
+ }
22
+
23
+ // Parse JSON from second content item
24
+ const content = params.content;
25
+ if (!content || !Array.isArray(content) || content.length < 2) {
26
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
27
+ return;
28
+ }
29
+
30
+ try {
31
+ const data: CompareResponse[] = JSON.parse(
32
+ (content[1] as { type: string; text: string }).text
33
+ );
34
+ render(data);
35
+ } catch {
36
+ container.innerHTML = `<div class="error-banner">Failed to parse response data</div>`;
37
+ }
38
+ };
39
+
40
+ function render(responses: CompareResponse[]) {
41
+ const container = document.getElementById('app')!;
42
+ const successCount = responses.filter((r) => !r.error).length;
43
+
44
+ let html = `<div class="summary-bar">${successCount}/${responses.length} ducks responded successfully</div>`;
45
+ html += `<div class="grid">`;
46
+
47
+ for (const r of responses) {
48
+ const isError = !!r.error;
49
+ const latencyClass =
50
+ r.latency < 2000 ? 'fast' : r.latency < 5000 ? 'medium' : 'slow';
51
+
52
+ html += `<div class="card${isError ? ' card-error' : ''}">`;
53
+ html += `<div class="card-header">`;
54
+ html += `<span class="nickname">${esc(r.nickname)}</span>`;
55
+ html += `<span class="provider">${esc(r.provider)}</span>`;
56
+ html += `</div>`;
57
+
58
+ if (!isError) {
59
+ html += `<div class="badges">`;
60
+ html += `<span class="badge model">${esc(r.model)}</span>`;
61
+ if (r.tokens) {
62
+ html += `<span class="badge tokens">${r.tokens.total} tokens</span>`;
63
+ }
64
+ if (r.cached) {
65
+ html += `<span class="badge cached">Cached</span>`;
66
+ }
67
+ html += `</div>`;
68
+ html += `<div class="latency-bar ${latencyClass}" style="width:${Math.min(100, (r.latency / 10000) * 100)}%"></div>`;
69
+ html += `<div class="latency-label">${r.latency}ms</div>`;
70
+ html += `<div class="content"><pre>${esc(r.content)}</pre></div>`;
71
+ } else {
72
+ html += `<div class="content error-text"><pre>${esc(r.content)}</pre></div>`;
73
+ }
74
+
75
+ html += `</div>`;
76
+ }
77
+
78
+ html += `</div>`;
79
+ container.innerHTML = html;
80
+ }
81
+
82
+ function esc(s: string): string {
83
+ const d = document.createElement('div');
84
+ d.textContent = s;
85
+ return d.innerHTML;
86
+ }
87
+
88
+ app.connect().catch(console.error);
@@ -0,0 +1,102 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Compare Ducks</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ line-height: 1.5;
12
+ color: #1a1a2e;
13
+ background: #f8f9fa;
14
+ padding: 16px;
15
+ }
16
+ .summary-bar {
17
+ background: #e3f2fd;
18
+ color: #1565c0;
19
+ padding: 8px 16px;
20
+ border-radius: 8px;
21
+ font-weight: 600;
22
+ margin-bottom: 16px;
23
+ text-align: center;
24
+ }
25
+ .grid {
26
+ display: grid;
27
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
28
+ gap: 16px;
29
+ }
30
+ .card {
31
+ background: #fff;
32
+ border: 1px solid #e0e0e0;
33
+ border-radius: 12px;
34
+ padding: 16px;
35
+ overflow: hidden;
36
+ }
37
+ .card-error { border-color: #ef5350; }
38
+ .card-header {
39
+ display: flex;
40
+ justify-content: space-between;
41
+ align-items: center;
42
+ margin-bottom: 8px;
43
+ }
44
+ .nickname { font-weight: 700; font-size: 1.1em; }
45
+ .provider { font-size: 0.85em; opacity: 0.7; }
46
+ .badges { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
47
+ .badge {
48
+ font-size: 0.75em;
49
+ padding: 2px 8px;
50
+ border-radius: 12px;
51
+ background: #e8eaf6;
52
+ color: #3949ab;
53
+ font-weight: 500;
54
+ }
55
+ .badge.cached { background: #e8f5e9; color: #2e7d32; }
56
+ .latency-bar {
57
+ height: 4px;
58
+ border-radius: 2px;
59
+ margin-bottom: 4px;
60
+ min-width: 4px;
61
+ }
62
+ .latency-bar.fast { background: #4caf50; }
63
+ .latency-bar.medium { background: #ff9800; }
64
+ .latency-bar.slow { background: #f44336; }
65
+ .latency-label { font-size: 0.75em; opacity: 0.6; margin-bottom: 8px; }
66
+ .content pre {
67
+ white-space: pre-wrap;
68
+ word-break: break-word;
69
+ font-size: 0.9em;
70
+ background: #f5f5f5;
71
+ padding: 12px;
72
+ border-radius: 8px;
73
+ max-height: 300px;
74
+ overflow-y: auto;
75
+ }
76
+ .error-text pre { color: #ef5350; }
77
+ .error-banner {
78
+ background: #ffebee;
79
+ color: #c62828;
80
+ padding: 12px;
81
+ border-radius: 8px;
82
+ text-align: center;
83
+ font-weight: 600;
84
+ }
85
+ @media (prefers-color-scheme: dark) {
86
+ body { background: #1a1a2e; color: #e0e0e0; }
87
+ .card { background: #16213e; border-color: #0f3460; color: #e0e0e0; }
88
+ .badge { background: #0f3460; color: #a0c4ff; }
89
+ .summary-bar { background: #0f3460; color: #a0c4ff; }
90
+ .content pre { background: #0d1b2a; color: #d0d0d0; }
91
+ .error-banner { background: #5c1a1a; color: #ff8a80; }
92
+ .card-error { border-color: #8b3a3a; }
93
+ .latency-label { color: #a0a0a0; }
94
+ .provider { color: #9e9e9e; }
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ <div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
100
+ <script type="module" src="./app.ts"></script>
101
+ </body>
102
+ </html>
@@ -0,0 +1,111 @@
1
+ import { App } from '@modelcontextprotocol/ext-apps';
2
+
3
+ interface DebateData {
4
+ topic: string;
5
+ format: 'oxford' | 'socratic' | 'adversarial';
6
+ totalRounds: number;
7
+ participants: { provider: string; nickname: string; position: string }[];
8
+ rounds: { round: number; provider: string; nickname: string; position: string; content: string }[][];
9
+ synthesis: string;
10
+ synthesizer: string;
11
+ }
12
+
13
+ const formatMeta: Record<string, { emoji: string; label: string; style: string }> = {
14
+ oxford: { emoji: '\uD83C\uDF93', label: 'Oxford', style: 'oxford' },
15
+ socratic: { emoji: '\uD83C\uDFDB\uFE0F', label: 'Socratic', style: 'socratic' },
16
+ adversarial: { emoji: '\u2694\uFE0F', label: 'Adversarial', style: 'adversarial' },
17
+ };
18
+
19
+ const positionColors: Record<string, string> = {
20
+ pro: '#4caf50',
21
+ con: '#f44336',
22
+ neutral: '#9e9e9e',
23
+ };
24
+
25
+ const app = new App({ name: 'DuckDebate', version: '1.0.0' }, {});
26
+
27
+ app.ontoolresult = (params) => {
28
+ const container = document.getElementById('app')!;
29
+ if (params.isError) {
30
+ container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
31
+ return;
32
+ }
33
+
34
+ const content = params.content;
35
+ if (!content || !Array.isArray(content) || content.length < 2) {
36
+ container.innerHTML = `<div class="error-banner">No structured data received</div>`;
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const data: DebateData = JSON.parse(
42
+ (content[1] as { type: string; text: string }).text
43
+ );
44
+ render(data);
45
+ } catch {
46
+ container.innerHTML = `<div class="error-banner">Failed to parse debate data</div>`;
47
+ }
48
+ };
49
+
50
+ function render(data: DebateData) {
51
+ const container = document.getElementById('app')!;
52
+ const fmt = formatMeta[data.format] || formatMeta.oxford;
53
+
54
+ let html = `<div class="debate ${fmt.style}">`;
55
+
56
+ // Header
57
+ html += `<div class="header">`;
58
+ html += `<div class="format-badge">${fmt.emoji} ${fmt.label} Debate</div>`;
59
+ html += `<h2 class="topic">${esc(data.topic)}</h2>`;
60
+ html += `<div class="meta">${data.totalRounds} rounds &middot; ${data.participants.length} participants</div>`;
61
+ html += `</div>`;
62
+
63
+ // Participants
64
+ html += `<div class="participants">`;
65
+ for (const p of data.participants) {
66
+ const color = positionColors[p.position] || '#9e9e9e';
67
+ html += `<span class="participant" style="border-color:${color}">`;
68
+ html += `<span class="pos-dot" style="background:${color}"></span>`;
69
+ html += `${esc(p.nickname)} <small>(${p.position})</small>`;
70
+ html += `</span>`;
71
+ }
72
+ html += `</div>`;
73
+
74
+ // Rounds
75
+ html += `<div class="rounds">`;
76
+ for (let i = 0; i < data.rounds.length; i++) {
77
+ const round = data.rounds[i];
78
+ html += `<details class="round">`;
79
+ html += `<summary class="round-header">Round ${i + 1}</summary>`;
80
+ html += `<div class="round-body">`;
81
+ for (const arg of round) {
82
+ const color = positionColors[arg.position] || '#9e9e9e';
83
+ html += `<div class="argument" style="border-left-color:${color}">`;
84
+ html += `<div class="arg-header">`;
85
+ html += `<span class="arg-name">${esc(arg.nickname)}</span>`;
86
+ html += `<span class="arg-pos" style="color:${color}">${arg.position.toUpperCase()}</span>`;
87
+ html += `</div>`;
88
+ html += `<div class="arg-content">${esc(arg.content)}</div>`;
89
+ html += `</div>`;
90
+ }
91
+ html += `</div></details>`;
92
+ }
93
+ html += `</div>`;
94
+
95
+ // Synthesis
96
+ html += `<div class="synthesis">`;
97
+ html += `<h3>Synthesis <small>by ${esc(data.synthesizer)}</small></h3>`;
98
+ html += `<div class="synthesis-content">${esc(data.synthesis)}</div>`;
99
+ html += `</div>`;
100
+
101
+ html += `</div>`;
102
+ container.innerHTML = html;
103
+ }
104
+
105
+ function esc(s: string): string {
106
+ const d = document.createElement('div');
107
+ d.textContent = s;
108
+ return d.innerHTML;
109
+ }
110
+
111
+ app.connect().catch(console.error);