surface: per-agent TTS voice + turn clock + reply-handler fix
Speech resolves voice per active agent (agents[].voice_id from the list), with the system voice as fallback — two agents on a host need not match. Souveraine.qml carries voice_id into the agents map and adds a turn clock (turnStartedAt/turnElapsedMs) + a 60s agent-inventory refresh. Ai.qml declares pendingFilePath and imports qs.modules.common.functions so StringUtils.ttsClean no longer aborts the stream-finished handler.
This commit is contained in:
parent
e9bf33e64a
commit
0ef239515f
4 changed files with 137 additions and 16 deletions
|
|
@ -20,6 +20,25 @@ Item {
|
|||
property var suggestionQuery: ""
|
||||
property var suggestionList: []
|
||||
|
||||
// Turn clock formatting. A local 9B can take minutes on a cold prompt, so
|
||||
// the readout has to stay legible from milliseconds to tens of minutes.
|
||||
function formatTurnDuration(ms) {
|
||||
if (ms < 1000)
|
||||
return `${ms}ms`;
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60)
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = Math.floor(seconds % 60);
|
||||
return `${minutes}m ${rest < 10 ? "0" : ""}${rest}s`;
|
||||
}
|
||||
|
||||
function formatClockTime(msEpoch) {
|
||||
if (!msEpoch)
|
||||
return "—";
|
||||
return new Date(msEpoch).toLocaleTimeString(Qt.locale(), "HH:mm:ss");
|
||||
}
|
||||
|
||||
onFocusChanged: focus => {
|
||||
if (focus) {
|
||||
root.inputField.forceActiveFocus();
|
||||
|
|
@ -369,7 +388,18 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
visible: Ai.tokenCount.total > 0
|
||||
icon: "token"
|
||||
statusText: Ai.tokenCount.total
|
||||
description: Translation.tr("Total token count\nInput: %1\nOutput: %2").arg(Ai.tokenCount.input).arg(Ai.tokenCount.output)
|
||||
description: Translation.tr("Context estimate: %1 tokens\n(Local count of the conversation — not the provider's usage numbers)").arg(Ai.tokenCount.total)
|
||||
}
|
||||
StatusSeparator {
|
||||
visible: Souveraine.turnStartedAt > 0
|
||||
}
|
||||
StatusItem {
|
||||
visible: Souveraine.turnStartedAt > 0
|
||||
icon: Souveraine.turnActive ? "hourglass_top" : "timer"
|
||||
statusText: root.formatTurnDuration(Souveraine.turnElapsedMs)
|
||||
description: Souveraine.turnActive
|
||||
? Translation.tr("Waiting on the agent\nSent at %1").arg(root.formatClockTime(Souveraine.turnStartedAt))
|
||||
: Translation.tr("Last turn took %1\nSent at %2").arg(root.formatTurnDuration(Souveraine.turnElapsedMs)).arg(root.formatClockTime(Souveraine.turnStartedAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ pragma ComponentBehavior: Bound
|
|||
|
||||
import qs
|
||||
import qs.modules.common
|
||||
// StringUtils.ttsClean() is called in the stream-finished handler. Without
|
||||
// this import it is a ReferenceError that aborts the rest of that handler —
|
||||
// postResponseHook and saveChat never run, so a turn that actually succeeded
|
||||
// looks stuck. Every other service imports it unnamespaced; match that.
|
||||
import qs.modules.common.functions
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
|
@ -60,6 +65,13 @@ Singleton {
|
|||
property list<var> savedChats: []
|
||||
property list<var> pendingFiles: []
|
||||
|
||||
// AttachedFileIndicator binds `filePath` straight to this. ii-base
|
||||
// declares it; this override did not, so every chat panel logged
|
||||
// "Unable to assign [undefined] to QString" and the chip could never
|
||||
// render. Declared here even though the send path is still text-only —
|
||||
// a missing property is a broken binding, not a disabled feature.
|
||||
property string pendingFilePath: ""
|
||||
|
||||
// Tool selection is owned by the agent's sensorium; keep the UI happy.
|
||||
property string currentTool: "souveraine"
|
||||
property list<var> availableTools: ["souveraine"]
|
||||
|
|
@ -612,7 +624,18 @@ Singleton {
|
|||
}
|
||||
|
||||
function attachFile(filePath) {
|
||||
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), root.interfaceRole);
|
||||
// Clearing is always honoured — AttachedFileIndicator's remove button
|
||||
// calls attachFile(""), and that must not read as a failed attach.
|
||||
root.pendingFilePath = "";
|
||||
if (!filePath || filePath.length === 0)
|
||||
return;
|
||||
// The refusal is accurate, not lazy: the HTTP message API carries
|
||||
// `content: String` (src/api/models.rs), so an image cannot cross the
|
||||
// surface boundary even though the provider client already speaks
|
||||
// OpenAI image_url parts and Kitty's model has vision. Fixing that is
|
||||
// a server change, so don't show a chip for a file that will never be
|
||||
// sent.
|
||||
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet — the message API carries text only."), root.interfaceRole);
|
||||
}
|
||||
function removePendingFile(file) {
|
||||
root.pendingFiles = root.pendingFiles.filter(f => f !== file);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,21 @@ Singleton {
|
|||
property string conversationId: ""
|
||||
property bool turnActive: false
|
||||
|
||||
// ── Turn clock ───────────────────────────────────────────────────────
|
||||
// Wall-clock for the request in flight. Lives here because turnActive
|
||||
// does; the chat surface and the pill both read it. `turnStartedAt` is
|
||||
// when we handed the request to curl (ms epoch, 0 = nothing sent yet),
|
||||
// `turnElapsedMs` ticks while the turn runs and freezes at the total.
|
||||
property double turnStartedAt: 0
|
||||
property int turnElapsedMs: 0
|
||||
|
||||
Timer {
|
||||
running: root.turnActive
|
||||
interval: 100
|
||||
repeat: true
|
||||
onTriggered: root.turnElapsedMs = Date.now() - root.turnStartedAt
|
||||
}
|
||||
|
||||
/* Raw wire events (message_type-tagged objects from the SSE stream). */
|
||||
signal streamEvent(var event)
|
||||
/* Stream closed (process exit). exitCode 0 = clean. */
|
||||
|
|
@ -71,7 +86,10 @@ Singleton {
|
|||
try {
|
||||
const list = JSON.parse(text);
|
||||
const map = {};
|
||||
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; });
|
||||
// voice_id comes from the agent's `_souveraine` block via
|
||||
// the public list endpoint. None/absent = use the system
|
||||
// voice. Speech reads this per active agent.
|
||||
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "", "voice_id": a.voice_id ?? "" }; });
|
||||
root.agents = map;
|
||||
root.agentList = Object.keys(map);
|
||||
root.serverUp = true;
|
||||
|
|
@ -100,6 +118,18 @@ Singleton {
|
|||
getAgents.running = true;
|
||||
}
|
||||
|
||||
// The inventory used to be fetched exactly once, at shell start, so an
|
||||
// agent created afterwards stayed invisible until the whole shell was
|
||||
// reloaded. One curl a minute is cheaper than that surprise. Skipped
|
||||
// while a turn is in flight so a slow local model isn't competing with
|
||||
// polling for the server's attention.
|
||||
Timer {
|
||||
interval: 60000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: if (!root.turnActive) root.refreshAgents()
|
||||
}
|
||||
|
||||
// ── Server autostart ─────────────────────────────────────────────────
|
||||
// systemd user unit first (survives shell restarts, journald logging);
|
||||
// bare nohup fallback for systems without it. One attempt per shell
|
||||
|
|
@ -365,6 +395,8 @@ Singleton {
|
|||
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
|
||||
requesterScriptFile.setText(scriptContent);
|
||||
requester.command = ["bash", shellScriptPath];
|
||||
root.turnStartedAt = Date.now();
|
||||
root.turnElapsedMs = 0;
|
||||
root.turnActive = true;
|
||||
requester.running = true;
|
||||
}
|
||||
|
|
@ -385,6 +417,10 @@ Singleton {
|
|||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// Freeze the clock on the real total — the 100ms tick can be up to
|
||||
// one interval behind when the process exits.
|
||||
if (root.turnStartedAt > 0)
|
||||
root.turnElapsedMs = Date.now() - root.turnStartedAt;
|
||||
root.turnActive = false;
|
||||
root.streamClosed(exitCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import QtQuick
|
|||
/**
|
||||
* Speech — TTS egress for the shell (the Read Aloud seam, TASK-18).
|
||||
*
|
||||
* Souveraine owns the who→voice mapping: VoiceConfig in the consciousness
|
||||
* config carries tts_url and voice_id, served live at /v1/config. This
|
||||
* service reads that mapping on every speak() and posts the text to
|
||||
* VibeVoice's /audio/speech (JSON {input, voice, model}, mp3 back — the
|
||||
* same contract core/voice/client.rs speaks), then plays the result. The
|
||||
* shell picks no voice of its own; souveraine decides how she sounds.
|
||||
* Souveraine owns the who→voice mapping. The TTS endpoint (tts_url) is a
|
||||
* substrate concern, read from VoiceConfig at /v1/config. The VOICE is a
|
||||
* per-agent concern: each agent's [_souveraine].voice_id rides on the public
|
||||
* agent list, and on every speak()/prefetch() this service resolves it — the
|
||||
* active agent's own voice wins, the system voice_id (/v1/config) is the
|
||||
* fallback for an agent who hasn't set one. The shell picks no voice of its
|
||||
* own; souveraine decides how each agent sounds, and they need not match.
|
||||
*
|
||||
* Config.options.speech.tts.enable is the user's kill switch (settings →
|
||||
* Speech); Config.options.speech.tts.endpoint, when set, overrides the
|
||||
|
|
@ -39,6 +40,13 @@ Singleton {
|
|||
// Resolved endpoint + voice from the last /v1/config lookup, kept so a
|
||||
// transient synth failure can be retried without re-resolving.
|
||||
property string _synthUrl: ""
|
||||
// The substrate's system voice, from /v1/config. This is the fallback —
|
||||
// the voice of "no agent picked" or an agent who hasn't set her own.
|
||||
property string _systemVoice: ""
|
||||
// The voice actually used this turn: the active agent's own voice if she
|
||||
// has one ([_souveraine].voice_id via the agent list), else the system
|
||||
// voice. Recomputed at every speak()/prefetch() so switching agents
|
||||
// switches voice without a re-fetch — the agent list is already local.
|
||||
property string _synthVoice: ""
|
||||
// Retry cap: the VibeVoice server can intermittently return 500 (concurrency
|
||||
// on its shared model — now server-locked, but a collision still costs one
|
||||
|
|
@ -78,9 +86,11 @@ Singleton {
|
|||
root._pendingText = t;
|
||||
root._synthAttempts = 0;
|
||||
root._readyText = "";
|
||||
// If the endpoint+voice are already resolved, skip the /v1/config
|
||||
// round-trip and go straight to synthesis.
|
||||
if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
|
||||
// If the endpoint is already resolved, skip the /v1/config round-trip
|
||||
// and go straight to synthesis. Voice is recomputed locally — the
|
||||
// agent may have switched since the last turn — so it's never the gate.
|
||||
if (root._synthUrl.length > 0) {
|
||||
root._applyVoice();
|
||||
root._fireSynth(false /*interactive*/);
|
||||
} else {
|
||||
voiceLookup.running = true;
|
||||
|
|
@ -103,9 +113,11 @@ Singleton {
|
|||
// Mark this as a background prefetch so voiceLookup chains into
|
||||
// _fireSynth(prefetch=true) instead of treating it as interactive.
|
||||
synthProc._prefetch = true;
|
||||
// If the endpoint+voice are already resolved, go straight to synth;
|
||||
// otherwise resolve via /v1/config first.
|
||||
if (root._synthUrl.length > 0 && root._synthVoice.length > 0) {
|
||||
// If the endpoint is already resolved, go straight to synth; voice is
|
||||
// recomputed locally for the current agent. Otherwise resolve via
|
||||
// /v1/config first.
|
||||
if (root._synthUrl.length > 0) {
|
||||
root._applyVoice();
|
||||
root._fireSynth(true /*prefetch*/);
|
||||
} else {
|
||||
voiceLookup.running = true;
|
||||
|
|
@ -119,6 +131,22 @@ Singleton {
|
|||
retryTimer.running = false;
|
||||
}
|
||||
|
||||
// The active agent's own voice, if she has one. The shell picks no voice
|
||||
// of its own: souveraine owns the who→voice mapping, and that mapping is
|
||||
// per-agent now — [_souveraine].voice_id rides on the public agent list.
|
||||
// "" means "this agent sounds like the substrate" → system-voice fallback.
|
||||
function _agentVoice() {
|
||||
const a = Souveraine.agents[Souveraine.currentAgentId];
|
||||
return (a && a.voice_id && a.voice_id.length > 0) ? a.voice_id : "";
|
||||
}
|
||||
|
||||
// Recompute the turn's voice from the active agent + cached system voice.
|
||||
// Called on every speak()/prefetch() (agent may have switched since last
|
||||
// turn) and after a /v1/config fetch. No fetch here — both inputs are local.
|
||||
function _applyVoice() {
|
||||
root._synthVoice = root._agentVoice() || root._systemVoice;
|
||||
}
|
||||
|
||||
// Play whatever is in the cache file.
|
||||
function _play() {
|
||||
playProc.running = true;
|
||||
|
|
@ -173,7 +201,11 @@ Singleton {
|
|||
return;
|
||||
}
|
||||
root._synthUrl = ttsUrl;
|
||||
root._synthVoice = voice;
|
||||
// `voice` here is the SYSTEM voice — the substrate speaking as
|
||||
// itself. Stash it as the fallback, then let the active agent
|
||||
// override it: she sounds like herself when she has a voice.
|
||||
root._systemVoice = voice;
|
||||
root._applyVoice();
|
||||
// If speak() was the caller, synth+play; if prefetch() was, the
|
||||
// flag is already on synthProc from _fireSynth — but voiceLookup
|
||||
// can be the first hop of a prefetch, so default to interactive.
|
||||
|
|
|
|||
Loading…
Reference in a new issue