Skip to content

Commit 88f76d0

Browse files
committed
feat(say-server): add info button, SVG icons, and UI fixes
- Replace emoji play/pause buttons with neutral white SVG icons - Add (i) info button in bottom-right with attribution popup - Use app.openLink() API for external links in popup - Add dynamic padding to fit popup when shown - Make create_tts_queue async to fix AnyIO event loop error - Add better error logging for TTS queue initialization - Update README with openLink feature documentation - Remove unnecessary HuggingFace login prerequisites
1 parent e06afdc commit 88f76d0

2 files changed

Lines changed: 71 additions & 7 deletions

File tree

examples/say-server/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ This example showcases several MCP App capabilities:
88

99
- **Single-file executable**: Python server with embedded React UI - no build step required
1010
- **Partial tool inputs** (`ontoolinputpartial`): Widget receives streaming text as it's being generated
11+
- **Queue-based streaming**: Demonstrates how to stream text out and audio in via a polling tool (adds text to an input queue, retrieves audio chunks from an output queue)
1112
- **Model context updates**: Widget updates the LLM with playback progress ("Playing: ...snippet...")
1213
- **Native theming**: Uses CSS variables for automatic dark/light mode adaptation
1314
- **Fullscreen mode**: Toggle fullscreen via `requestDisplayMode()` API, press Escape to exit
1415
- **Multi-widget speak lock**: Coordinates multiple TTS widgets via localStorage so only one plays at a time
1516
- **Hidden tools** (`visibility: ["app"]`): Private tools only accessible to the widget, not the model
17+
- **External links** (`openLink`): Attribution popup uses `app.openLink()` to open external URLs
1618
- **CSP metadata**: Resource declares required domains (`esm.sh`) for in-browser transpilation
1719

1820
## Features

examples/say-server/server.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def say(
213213
# ------------------------------------------------------
214214

215215
@mcp.tool(meta={"ui":{"visibility":["app"]}})
216-
def create_tts_queue(voice: str = "cosette") -> list[types.TextContent]:
216+
async def create_tts_queue(voice: str = "cosette") -> list[types.TextContent]:
217217
"""Create a TTS generation queue. Returns queue_id and sample_rate.
218218
219219
Args:
@@ -233,8 +233,7 @@ def create_tts_queue(voice: str = "cosette") -> list[types.TextContent]:
233233
tts_queues[queue_id] = state
234234

235235
# Start background TTS processing task
236-
loop = asyncio.get_event_loop()
237-
state.task = loop.create_task(_run_tts_queue(state))
236+
state.task = asyncio.create_task(_run_tts_queue(state))
238237

239238
logger.info(f"Created TTS queue {queue_id}")
240239

@@ -731,6 +730,45 @@ def generate_sync():
731730
.fullscreenBtn .collapseIcon { display: none; }
732731
.container.fullscreen .fullscreenBtn .expandIcon { display: none; }
733732
.container.fullscreen .fullscreenBtn .collapseIcon { display: block; }
733+
/* Info button - bottom right */
734+
.infoBtn {
735+
position: absolute;
736+
bottom: 8px; right: 8px;
737+
width: 24px; height: 24px;
738+
border: none; border-radius: 50%;
739+
background: rgba(128, 128, 128, 0.4);
740+
color: white; cursor: pointer;
741+
display: flex; align-items: center; justify-content: center;
742+
font-size: 12px; font-weight: bold; font-style: italic; font-family: serif;
743+
opacity: 0.5;
744+
transition: opacity 0.2s, background 0.2s;
745+
z-index: 10;
746+
}
747+
.container:hover .infoBtn { opacity: 0.8; }
748+
.infoBtn:hover { opacity: 1; background: rgba(128, 128, 128, 0.7); }
749+
/* Info popup */
750+
.infoPopup {
751+
position: absolute;
752+
bottom: 40px; right: 8px;
753+
background: rgba(0, 0, 0, 0.9);
754+
color: white;
755+
padding: 12px 16px;
756+
border-radius: 8px;
757+
font-size: 12px;
758+
line-height: 1.5;
759+
max-width: 280px;
760+
z-index: 20;
761+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
762+
}
763+
.infoPopup h4 { margin: 0 0 8px 0; font-size: 13px; }
764+
.infoPopup a {
765+
color: #6cb6ff;
766+
text-decoration: none;
767+
cursor: pointer;
768+
}
769+
.infoPopup a:hover { text-decoration: underline; }
770+
.infoPopup ul { margin: 0; padding-left: 16px; }
771+
.infoPopup li { margin: 4px 0; }
734772
@media (prefers-color-scheme: dark) {
735773
:root {
736774
--color-text-primary: #eee;
@@ -755,6 +793,7 @@ def generate_sync():
755793
const [displayMode, setDisplayMode] = useState("inline");
756794
const [fullscreenAvailable, setFullscreenAvailable] = useState(false);
757795
const [autoPlay, setAutoPlay] = useState(true); // Default to autoPlay, can be overridden by tool input
796+
const [showInfo, setShowInfo] = useState(false);
758797
759798
const voiceRef = useRef("cosette"); // Current voice, updated from tool input
760799
const widgetUuidRef = useRef(null); // Widget UUID for speak lock coordination
@@ -1004,7 +1043,12 @@ def generate_sync():
10041043
// Create new queue
10051044
console.log('[TTS] creating new queue');
10061045
const result = await app.callServerTool({ name: "create_tts_queue", arguments: { voice: voiceRef.current } });
1007-
const data = JSON.parse(result.content[0].text);
1046+
console.log('[TTS] create_tts_queue result:', result);
1047+
const text = result.content?.[0]?.text;
1048+
if (!text) { console.error('[TTS] No text in result'); return false; }
1049+
let data;
1050+
try { data = JSON.parse(text); }
1051+
catch (e) { console.error('[TTS] Failed to parse result:', text); return false; }
10081052
if (data.error) { console.log('[TTS] queue creation error:', data.error); return false; }
10091053
queueIdRef.current = data.queue_id;
10101054
sampleRateRef.current = data.sample_rate || 24000;
@@ -1013,7 +1057,7 @@ def generate_sync():
10131057
nextPlayTimeRef.current = 0;
10141058
startPolling();
10151059
return true;
1016-
} catch (err) { return false; }
1060+
} catch (err) { console.error('[TTS] initTTSQueue error:', err); return false; }
10171061
finally { initQueuePromiseRef.current = null; }
10181062
})();
10191063
return initQueuePromiseRef.current;
@@ -1284,7 +1328,7 @@ def generate_sync():
12841328
style={{
12851329
paddingTop: hostContext?.safeAreaInsets?.top,
12861330
paddingRight: hostContext?.safeAreaInsets?.right,
1287-
paddingBottom: hostContext?.safeAreaInsets?.bottom,
1331+
paddingBottom: showInfo ? 140 : hostContext?.safeAreaInsets?.bottom,
12881332
paddingLeft: hostContext?.safeAreaInsets?.left,
12891333
}}
12901334
tabIndex={0}
@@ -1303,7 +1347,13 @@ def generate_sync():
13031347
{/* Toolbar - top right */}
13041348
<div className="toolbar">
13051349
<button className="controlBtn" onClick={togglePlayPause} title="Play/Pause">
1306-
{status === "playing" ? "⏸️" : status === "finished" ? "🔄" : "▶️"}
1350+
{status === "playing" ? (
1351+
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
1352+
) : status === "finished" ? (
1353+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
1354+
) : (
1355+
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>
1356+
)}
13071357
</button>
13081358
<button className="controlBtn" onClick={restartPlayback} title="Restart">
13091359
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -1320,6 +1370,18 @@ def generate_sync():
13201370
</svg>
13211371
</button>
13221372
</div>
1373+
{/* Info button - bottom right */}
1374+
<button className="infoBtn" onClick={() => setShowInfo(!showInfo)} title="Attribution info">i</button>
1375+
{showInfo && (
1376+
<div className="infoPopup">
1377+
<h4>Powered by Pocket TTS</h4>
1378+
<ul>
1379+
<li><a onClick={() => { app.openLink({ url: "https://github.com/kyutai-labs/pocket-tts" }); }}>Pocket TTS Repository</a> (Apache 2.0)</li>
1380+
<li><a onClick={() => { app.openLink({ url: "https://huggingface.co/kyutai/pocket-tts" }); }}>Pocket TTS Model</a> (CC BY 4.0)</li>
1381+
<li><a onClick={() => { app.openLink({ url: "https://huggingface.co/kyutai/tts-voices" }); }}>Voice Samples</a> (CC BY 4.0)</li>
1382+
</ul>
1383+
</div>
1384+
)}
13231385
</main>
13241386
);
13251387
}

0 commit comments

Comments
 (0)