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.
- package/.eslintrc.json +3 -1
- package/CHANGELOG.md +12 -0
- package/README.md +54 -10
- package/assets/ext-apps-compare.png +0 -0
- package/assets/ext-apps-debate.png +0 -0
- package/assets/ext-apps-usage-stats.png +0 -0
- package/assets/ext-apps-vote.png +0 -0
- package/audit-ci.json +2 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +62 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/compare-ducks.d.ts.map +1 -1
- package/dist/tools/compare-ducks.js +19 -0
- package/dist/tools/compare-ducks.js.map +1 -1
- package/dist/tools/duck-debate.d.ts.map +1 -1
- package/dist/tools/duck-debate.js +24 -0
- package/dist/tools/duck-debate.js.map +1 -1
- package/dist/tools/duck-vote.d.ts.map +1 -1
- package/dist/tools/duck-vote.js +23 -0
- package/dist/tools/duck-vote.js.map +1 -1
- package/dist/tools/get-usage-stats.d.ts.map +1 -1
- package/dist/tools/get-usage-stats.js +13 -0
- package/dist/tools/get-usage-stats.js.map +1 -1
- package/dist/ui/compare-ducks/mcp-app.html +187 -0
- package/dist/ui/duck-debate/mcp-app.html +182 -0
- package/dist/ui/duck-vote/mcp-app.html +168 -0
- package/dist/ui/usage-stats/mcp-app.html +192 -0
- package/jest.config.js +1 -0
- package/package.json +7 -3
- package/src/server.ts +79 -4
- package/src/tools/compare-ducks.ts +20 -0
- package/src/tools/duck-debate.ts +27 -0
- package/src/tools/duck-vote.ts +24 -0
- package/src/tools/get-usage-stats.ts +14 -0
- package/src/ui/compare-ducks/app.ts +88 -0
- package/src/ui/compare-ducks/mcp-app.html +102 -0
- package/src/ui/duck-debate/app.ts +111 -0
- package/src/ui/duck-debate/mcp-app.html +97 -0
- package/src/ui/duck-vote/app.ts +128 -0
- package/src/ui/duck-vote/mcp-app.html +83 -0
- package/src/ui/usage-stats/app.ts +156 -0
- package/src/ui/usage-stats/mcp-app.html +107 -0
- package/tests/duck-debate.test.ts +3 -1
- package/tests/duck-vote.test.ts +3 -1
- package/tests/tools/compare-ducks-ui.test.ts +135 -0
- package/tests/tools/compare-ducks.test.ts +3 -1
- package/tests/tools/duck-debate-ui.test.ts +234 -0
- package/tests/tools/duck-vote-ui.test.ts +172 -0
- package/tests/tools/get-usage-stats.test.ts +3 -1
- package/tests/tools/usage-stats-ui.test.ts +130 -0
- package/tests/ui-build.test.ts +53 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +19 -0
package/jest.config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-rubber-duck",
|
|
3
|
-
"version": "1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/duck-debate.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/duck-vote.ts
CHANGED
|
@@ -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 · ${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);
|