mcp-rubber-duck 1.9.4 → 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 +19 -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 +3 -1
- package/dist/server.d.ts +5 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +414 -498
- 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 +491 -523
- 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/tool-annotations.test.ts +208 -41
- 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
|
@@ -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);
|
|
@@ -0,0 +1,97 @@
|
|
|
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>Duck Debate</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
|
+
.header { text-align: center; margin-bottom: 20px; }
|
|
17
|
+
.format-badge {
|
|
18
|
+
display: inline-block;
|
|
19
|
+
padding: 4px 16px;
|
|
20
|
+
border-radius: 20px;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
font-size: 0.9em;
|
|
23
|
+
margin-bottom: 8px;
|
|
24
|
+
}
|
|
25
|
+
.oxford .format-badge { background: #e8eaf6; color: #283593; }
|
|
26
|
+
.socratic .format-badge { background: #f3e5f5; color: #6a1b9a; }
|
|
27
|
+
.adversarial .format-badge { background: #fbe9e7; color: #bf360c; }
|
|
28
|
+
.topic { font-size: 1.2em; margin-bottom: 4px; }
|
|
29
|
+
.meta { font-size: 0.85em; opacity: 0.6; }
|
|
30
|
+
.participants {
|
|
31
|
+
display: flex;
|
|
32
|
+
gap: 8px;
|
|
33
|
+
flex-wrap: wrap;
|
|
34
|
+
justify-content: center;
|
|
35
|
+
margin-bottom: 20px;
|
|
36
|
+
}
|
|
37
|
+
.participant {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
padding: 4px 12px;
|
|
42
|
+
border: 2px solid;
|
|
43
|
+
border-radius: 20px;
|
|
44
|
+
font-size: 0.85em;
|
|
45
|
+
background: #fff;
|
|
46
|
+
}
|
|
47
|
+
.pos-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
48
|
+
.rounds { margin-bottom: 20px; }
|
|
49
|
+
.round { margin-bottom: 8px; }
|
|
50
|
+
.round-header {
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
padding: 8px 16px;
|
|
53
|
+
background: #e8eaf6;
|
|
54
|
+
border-radius: 8px;
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
font-size: 0.95em;
|
|
57
|
+
}
|
|
58
|
+
.round-body { padding: 12px 0; }
|
|
59
|
+
.argument {
|
|
60
|
+
background: #fff;
|
|
61
|
+
border-left: 4px solid;
|
|
62
|
+
border-radius: 0 8px 8px 0;
|
|
63
|
+
padding: 12px 16px;
|
|
64
|
+
margin-bottom: 10px;
|
|
65
|
+
}
|
|
66
|
+
.arg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
67
|
+
.arg-name { font-weight: 700; }
|
|
68
|
+
.arg-pos { font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; }
|
|
69
|
+
.arg-content { white-space: pre-wrap; word-break: break-word; font-size: 0.9em; }
|
|
70
|
+
.synthesis {
|
|
71
|
+
background: #fffde7;
|
|
72
|
+
border: 2px solid #ffd54f;
|
|
73
|
+
border-radius: 12px;
|
|
74
|
+
padding: 16px;
|
|
75
|
+
}
|
|
76
|
+
.synthesis h3 { margin-bottom: 8px; }
|
|
77
|
+
.synthesis small { font-weight: 400; opacity: 0.6; }
|
|
78
|
+
.synthesis-content { white-space: pre-wrap; word-break: break-word; font-size: 0.9em; }
|
|
79
|
+
.error-banner { background: #ffebee; color: #c62828; padding: 12px; border-radius: 8px; text-align: center; font-weight: 600; }
|
|
80
|
+
@media (prefers-color-scheme: dark) {
|
|
81
|
+
body { background: #1a1a2e; color: #e0e0e0; }
|
|
82
|
+
.argument { background: #16213e; color: #e0e0e0; }
|
|
83
|
+
.synthesis { background: #1b3a4b; border-color: #0f3460; color: #e0e0e0; }
|
|
84
|
+
.round-header { background: #16213e; color: #e0e0e0; }
|
|
85
|
+
.participant { background: #16213e; color: #e0e0e0; }
|
|
86
|
+
.oxford .format-badge { background: #1a237e; color: #9fa8da; }
|
|
87
|
+
.socratic .format-badge { background: #4a148c; color: #ce93d8; }
|
|
88
|
+
.adversarial .format-badge { background: #4e1a00; color: #ffab91; }
|
|
89
|
+
.error-banner { background: #5c1a1a; color: #ff8a80; }
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
<div id="app"><p style="text-align:center;opacity:0.5">Waiting for results...</p></div>
|
|
95
|
+
<script type="module" src="./app.ts"></script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { App } from '@modelcontextprotocol/ext-apps';
|
|
2
|
+
|
|
3
|
+
interface VoteData {
|
|
4
|
+
question: string;
|
|
5
|
+
options: string[];
|
|
6
|
+
winner: string | null;
|
|
7
|
+
isTie: boolean;
|
|
8
|
+
tally: Record<string, number>;
|
|
9
|
+
confidenceByOption: Record<string, number>;
|
|
10
|
+
votes: {
|
|
11
|
+
voter: string;
|
|
12
|
+
nickname: string;
|
|
13
|
+
choice: string;
|
|
14
|
+
confidence: number;
|
|
15
|
+
reasoning: string;
|
|
16
|
+
}[];
|
|
17
|
+
totalVoters: number;
|
|
18
|
+
validVotes: number;
|
|
19
|
+
consensusLevel: 'unanimous' | 'majority' | 'plurality' | 'split' | 'none';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const consensusConfig: Record<string, { color: string; label: string }> = {
|
|
23
|
+
unanimous: { color: '#4caf50', label: 'Unanimous' },
|
|
24
|
+
majority: { color: '#2196f3', label: 'Majority' },
|
|
25
|
+
plurality: { color: '#ff9800', label: 'Plurality' },
|
|
26
|
+
split: { color: '#ff5722', label: 'Split' },
|
|
27
|
+
none: { color: '#9e9e9e', label: 'No Consensus' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const app = new App({ name: 'DuckVote', version: '1.0.0' }, {});
|
|
31
|
+
|
|
32
|
+
app.ontoolresult = (params) => {
|
|
33
|
+
const container = document.getElementById('app')!;
|
|
34
|
+
if (params.isError) {
|
|
35
|
+
container.innerHTML = `<div class="error-banner">Tool execution failed</div>`;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const content = params.content;
|
|
40
|
+
if (!content || !Array.isArray(content) || content.length < 2) {
|
|
41
|
+
container.innerHTML = `<div class="error-banner">No structured data received</div>`;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const data: VoteData = JSON.parse(
|
|
47
|
+
(content[1] as { type: string; text: string }).text
|
|
48
|
+
);
|
|
49
|
+
render(data);
|
|
50
|
+
} catch {
|
|
51
|
+
container.innerHTML = `<div class="error-banner">Failed to parse vote data</div>`;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function render(data: VoteData) {
|
|
56
|
+
const container = document.getElementById('app')!;
|
|
57
|
+
const cfg = consensusConfig[data.consensusLevel] || consensusConfig.none;
|
|
58
|
+
const maxVotes = Math.max(...Object.values(data.tally), 1);
|
|
59
|
+
|
|
60
|
+
let html = `<h2 class="question">${esc(data.question)}</h2>`;
|
|
61
|
+
|
|
62
|
+
// Winner card
|
|
63
|
+
if (data.winner) {
|
|
64
|
+
html += `<div class="winner-card">`;
|
|
65
|
+
html += `<span class="winner-label">Winner</span>`;
|
|
66
|
+
html += `<span class="winner-name">${esc(data.winner)}</span>`;
|
|
67
|
+
if (data.isTie) {
|
|
68
|
+
html += `<span class="tie-note">Tie-breaker by confidence</span>`;
|
|
69
|
+
}
|
|
70
|
+
html += `</div>`;
|
|
71
|
+
} else {
|
|
72
|
+
html += `<div class="winner-card no-winner"><span class="winner-label">No valid votes</span></div>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Consensus badge
|
|
76
|
+
html += `<div class="consensus-badge" style="background:${cfg.color}">${cfg.label}</div>`;
|
|
77
|
+
|
|
78
|
+
// Bar chart
|
|
79
|
+
html += `<div class="tally-section"><h3>Vote Tally</h3>`;
|
|
80
|
+
const sortedOptions = [...data.options].sort(
|
|
81
|
+
(a, b) => (data.tally[b] || 0) - (data.tally[a] || 0)
|
|
82
|
+
);
|
|
83
|
+
for (const opt of sortedOptions) {
|
|
84
|
+
const votes = data.tally[opt] || 0;
|
|
85
|
+
const conf = data.confidenceByOption[opt] || 0;
|
|
86
|
+
const pct = (votes / maxVotes) * 100;
|
|
87
|
+
const isWinner = opt === data.winner;
|
|
88
|
+
html += `<div class="bar-row${isWinner ? ' winner-row' : ''}">`;
|
|
89
|
+
html += `<div class="bar-label">${esc(opt)}</div>`;
|
|
90
|
+
html += `<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>`;
|
|
91
|
+
html += `<div class="bar-value">${votes} vote${votes !== 1 ? 's' : ''} (${conf}%)</div>`;
|
|
92
|
+
html += `</div>`;
|
|
93
|
+
}
|
|
94
|
+
html += `</div>`;
|
|
95
|
+
|
|
96
|
+
// Individual voters
|
|
97
|
+
html += `<div class="voters-section"><h3>Individual Votes</h3><div class="voters-grid">`;
|
|
98
|
+
for (const v of data.votes) {
|
|
99
|
+
const hasChoice = !!v.choice;
|
|
100
|
+
html += `<div class="voter-card${!hasChoice ? ' invalid-vote' : ''}">`;
|
|
101
|
+
html += `<div class="voter-name">${esc(v.nickname)}</div>`;
|
|
102
|
+
if (hasChoice) {
|
|
103
|
+
html += `<div class="voter-choice">${esc(v.choice)}</div>`;
|
|
104
|
+
html += `<div class="confidence-bar-wrap"><div class="confidence-bar" style="width:${v.confidence}%"></div></div>`;
|
|
105
|
+
html += `<div class="confidence-label">${v.confidence}% confidence</div>`;
|
|
106
|
+
if (v.reasoning) {
|
|
107
|
+
html += `<details class="reasoning"><summary>Reasoning</summary><p>${esc(v.reasoning)}</p></details>`;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
html += `<div class="voter-choice invalid">Invalid vote</div>`;
|
|
111
|
+
}
|
|
112
|
+
html += `</div>`;
|
|
113
|
+
}
|
|
114
|
+
html += `</div></div>`;
|
|
115
|
+
|
|
116
|
+
// Footer
|
|
117
|
+
html += `<div class="footer">${data.validVotes}/${data.totalVoters} valid votes</div>`;
|
|
118
|
+
|
|
119
|
+
container.innerHTML = html;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function esc(s: string): string {
|
|
123
|
+
const d = document.createElement('div');
|
|
124
|
+
d.textContent = s;
|
|
125
|
+
return d.innerHTML;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
app.connect().catch(console.error);
|