fix: speech prefetch flag, agent identity on restore, workspace selector
Speech: prefetch() now sets synthProc._prefetch=true before voiceLookup so the background synth doesn't auto-play. speak() skips voiceLookup when the endpoint+voice are already resolved (faster repeat taps). Ai: addMessage defaults model to Souveraine.currentAgentId so restored messages (/resume, new conversations) show the agent icon/name instead of a generic placeholder. Session: create_with_messages_and_timestamps preserves persisted created_at/updated_at through load_persisted so /resume sorts by actual activity time, not server restart time. TTS: ttsClean strips unclosed think blocks, fixes regex dotAll flag for older Qt/V8 (phone), adds pronunciation map. SubconsciousTicker: tap psychology icon to expand inline mini-log, long-press to open the event panel overlay. New: workspace-selector.sh + .desktop — wofi dmenu grid for quick workspace 1-10 jump from the app drawer. Installed to phone via deploy.sh symlink.
This commit is contained in:
parent
19fe6b1d0e
commit
0b13e2d347
9 changed files with 442 additions and 72 deletions
|
|
@ -238,12 +238,14 @@ impl Backend for LocalBackend {
|
|||
let agents = self.server.agents.list(None).await?;
|
||||
for agent in agents {
|
||||
if let Some(store) = self.server.sessions.conversation_store_for(&agent.id) {
|
||||
if let Ok(Some(_record)) = store.load_metadata(conversation_id).await {
|
||||
if let Ok(Some(record)) = store.load_metadata(conversation_id).await {
|
||||
let messages = store.load_messages(conversation_id).await?;
|
||||
self.server.sessions.create_with_messages(
|
||||
self.server.sessions.create_with_messages_and_timestamps(
|
||||
&agent.id,
|
||||
conversation_id.to_string(),
|
||||
messages.clone(),
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
);
|
||||
return Ok(messages);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,20 @@ impl SessionManager {
|
|||
agent_id: &str,
|
||||
conversation_id: String,
|
||||
messages: Vec<ConversationMessage>,
|
||||
) -> String {
|
||||
self.create_with_messages_and_timestamps(agent_id, conversation_id, messages, Utc::now(), Utc::now())
|
||||
}
|
||||
|
||||
/// Like `create_with_messages` but preserves the persisted timestamps so
|
||||
/// the sort order in `list_conversations` reflects actual last activity,
|
||||
/// not the load time.
|
||||
pub fn create_with_messages_and_timestamps(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
conversation_id: String,
|
||||
messages: Vec<ConversationMessage>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
) -> String {
|
||||
let (sender, _receiver) = broadcast::channel(100);
|
||||
let turn_count = messages
|
||||
|
|
@ -112,8 +126,8 @@ impl SessionManager {
|
|||
conversation_id: conversation_id.clone(),
|
||||
agent_id: agent_id.to_string(),
|
||||
messages,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
created_at,
|
||||
updated_at,
|
||||
turn_count,
|
||||
last_n25: Utc::now(),
|
||||
context_pressure: 0.0,
|
||||
|
|
@ -217,7 +231,13 @@ impl SessionManager {
|
|||
continue;
|
||||
}
|
||||
let messages = store.load_messages(&record.id).await.unwrap_or_default();
|
||||
self.create_with_messages(agent_id, record.id.clone(), messages);
|
||||
self.create_with_messages_and_timestamps(
|
||||
agent_id,
|
||||
record.id.clone(),
|
||||
messages,
|
||||
record.created_at,
|
||||
record.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
|
|
|
|||
|
|
@ -231,8 +231,8 @@ if [[ "${1:-}" == "--phone" ]]; then
|
|||
printf '%s\n' "$deployed_revision" \
|
||||
| "${ssh_i[@]}" "$PHONE_USB" "cat > ~/$PHONE_DEST/DEPLOYED-REVISION"
|
||||
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
|
||||
# Phone-only: install the mobile settings launcher into the app grid.
|
||||
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop"
|
||||
# Phone-only: install mobile launchers into the app grid.
|
||||
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/.local/share/applications && ln -sf ~/$PHONE_DEST/souveraine-settings.desktop ~/.local/share/applications/souveraine-settings.desktop && ln -sf ~/$PHONE_DEST/ii-base/modules/common/workspace-selector.desktop ~/.local/share/applications/workspace-selector.desktop"
|
||||
finish_phone_maintenance
|
||||
trap - EXIT
|
||||
echo "Deployed to phone. Hyprland starts qs -c souveraine; no secondary shell is required."
|
||||
|
|
|
|||
|
|
@ -157,31 +157,81 @@ Singleton {
|
|||
return str.replace(/\\/g, '\\\\');
|
||||
}
|
||||
|
||||
// Pronunciation fixes for VibeVoice — word-boundary replacements so the
|
||||
// model says names and acronyms correctly. Ported from lettabot's Matrix
|
||||
// TTS (src/channels/matrix/tts.ts), tuned for this voice over time.
|
||||
readonly property var _ttsPronunciation: ({
|
||||
"Xzaviar": "X-zay-V-ar", "xzaviar": "X-zay-V-ar",
|
||||
"Jean Luc": "Zhan-Look", "jean luc": "Zhan-Look",
|
||||
"Sebastian": "Se-BASS-chen", "sebastian": "Se-BASS-chen",
|
||||
"API": "A P I", "SDK": "S D K",
|
||||
"E2EE": "end-to-end encrypted",
|
||||
"TTS": "text to speech", "STT": "speech to text",
|
||||
})
|
||||
|
||||
function _applyTtsPronunciation(text) {
|
||||
let result = text;
|
||||
for (const wrong in root._ttsPronunciation) {
|
||||
const right = root._ttsPronunciation[wrong];
|
||||
result = result.replace(new RegExp(`\\b${wrong.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "gi"), right);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans assistant reply text for text-to-speech: strips <think> blocks,
|
||||
* code fences, and markdown formatting so only the spoken voice remains.
|
||||
* agent control tags, HTML, code fences, markdown, and most emoji, and
|
||||
* applies pronunciation fixes — so only the spoken voice remains.
|
||||
* Returns the cleaned text (empty string if nothing speakable survives).
|
||||
* Order (ported from lettabot's cleanTextForTTS, plus Souveraine think
|
||||
* blocks): control tags → color/spoiler → HTML → think → code/markdown →
|
||||
* emoji → pronunciation → whitespace.
|
||||
* @param { string } text
|
||||
* @returns { string }
|
||||
*/
|
||||
function ttsClean(text) {
|
||||
if (!text)
|
||||
return "";
|
||||
return text
|
||||
// Remove <think>...</think> blocks entirely
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
// Remove markdown headers, bold, italic, code fences
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/#{1,6}\s/g, " ")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Remove markdown links and images
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
// Collapse whitespace
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
let cleaned = text;
|
||||
// Strip agent control tags
|
||||
cleaned = cleaned.replace(/\[silent\]/gi, "");
|
||||
cleaned = cleaned.replace(/\[chromatophore\]/gi, "");
|
||||
cleaned = cleaned.replace(/\[!c\]/gi, "");
|
||||
cleaned = cleaned.replace(/\[!s\]/gi, "");
|
||||
cleaned = cleaned.replace(/\[react:[^\]]*\]/gi, "");
|
||||
// Strip color syntax {color|text} → keep text; spoilers ||text|| → keep text
|
||||
cleaned = cleaned.replace(/\{[^}|]+\|([^}]+)\}/g, "$1");
|
||||
// dotAll via [\s\S] (not the 's' flag — older Qt/V8 rejects it)
|
||||
cleaned = cleaned.replace(/\|\|([\s\S]+?)\|\|/g, "$1");
|
||||
// Remove <think>...</think> blocks entirely (Souveraine reasoning).
|
||||
// MUST run before the HTML-tag strip, or the tags get eaten first and
|
||||
// the reasoning survives as plain text. Also drop an unclosed <think>
|
||||
// (stream cut mid-reasoning) so we never speak a half-thought — by
|
||||
// default we want only the actual message, not the thinking.
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/g, " ");
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*$/g, " ");
|
||||
// Strip HTML tags (keep content)
|
||||
cleaned = cleaned.replace(/<[^>]+>/g, "");
|
||||
// Remove markdown headers, bold, italic, code fences
|
||||
cleaned = cleaned.replace(/```[\s\S]*?```/g, " ");
|
||||
cleaned = cleaned.replace(/#{1,6}\s/g, " ");
|
||||
cleaned = cleaned.replace(/\*\*([^*]+)\*\*/g, "$1");
|
||||
cleaned = cleaned.replace(/\*([^*]+)\*/g, "$1");
|
||||
cleaned = cleaned.replace(/`([^`]+)`/g, "$1");
|
||||
// Remove markdown links and images
|
||||
cleaned = cleaned.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1");
|
||||
cleaned = cleaned.replace(/\[([^\]]*)\]\([^)]+\)/g, "$1");
|
||||
// Emoji: preserve ✨ and 🎤 (the model says those fine), strip the rest.
|
||||
// Marker-swap so the preserved ones survive the broad Unicode strip.
|
||||
const sparkle = cleaned.includes("✨"), mic = cleaned.includes("🎤");
|
||||
cleaned = cleaned.replace(/✨/g, "SPK").replace(/🎤/g, "MIC");
|
||||
cleaned = cleaned.replace(/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2702}-\u{27B0}\u{24C2}-\u{1F251}\u{1F900}-\u{1FAFF}]/gu, "");
|
||||
cleaned = cleaned.replace(/SPK/g, sparkle ? "✨" : "").replace(/MIC/g, mic ? "🎤" : "");
|
||||
// Pronunciation fixes
|
||||
cleaned = root._applyTtsPronunciation(cleaned);
|
||||
// Collapse whitespace
|
||||
cleaned = cleaned.replace(/\s+/g, " ").trim();
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Workspace
|
||||
Comment=Jump to a numbered workspace
|
||||
Exec=/home/ani/.config/quickshell/souveraine/modules/common/workspace-selector.sh
|
||||
Icon=view-grid-symbolic
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
20
surfaces/quickshell/ii-base/modules/common/workspace-selector.sh
Executable file
20
surfaces/quickshell/ii-base/modules/common/workspace-selector.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# Workspace Selector — quick jump to a numbered workspace
|
||||
# Uses wofi for touch-friendly selection
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Generate list of workspaces 1-10 (pipe to wofi dmenu mode)
|
||||
CHOICE=$(printf '%s\n' {1..10} | wofi --dmenu --prompt="Go to workspace" --width=200 --height=400 --columns=2)
|
||||
|
||||
# If user cancelled, exit
|
||||
[[ -z "$CHOICE" ]] && exit 0
|
||||
|
||||
# Strip any non-numeric prefix wofi might add (e.g. "1 - ")
|
||||
CHOICE="${CHOICE##* }"
|
||||
CHOICE="${CHOICE//[^0-9]/}"
|
||||
|
||||
# Validate and dispatch
|
||||
if [[ "$CHOICE" =~ ^[0-9]+$ ]] && (( CHOICE >= 1 && CHOICE <= 10 )); then
|
||||
hyprctl dispatch "workspace $CHOICE"
|
||||
fi
|
||||
|
|
@ -5,22 +5,30 @@ import qs.modules.common.widgets
|
|||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
|
||||
// Tier 1 of the subconscious three-tier visibility design (see
|
||||
// docs/tasks/subconscious-surfacing-threshold.md): a transient, fading line
|
||||
// that shows the subconscious's live reasoning/tools while its N+1 pass runs.
|
||||
// Visible only while Ai.subconsciousActive; fed by Ai.subconsciousStream;
|
||||
// cleared (and snapshotted to the Tier-2 log) when the pass ends.
|
||||
//
|
||||
// Interaction: the psychology icon is a real affordance, not decoration.
|
||||
// • tap → expand/collapse an inline mini-log (last few entries) in place
|
||||
// • long-press → open the Tier-2 Subconscious event panel as a draggable
|
||||
// overlay pane (and pin it), so the live stream and the persistent log
|
||||
// are one gesture apart.
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: row.implicitHeight + 2 * root.padding
|
||||
implicitHeight: col.implicitHeight + 2 * root.padding
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer2
|
||||
visible: Ai.subconsciousActive && Ai.subconsciousStream.length > 0
|
||||
opacity: visible ? 1 : 0
|
||||
|
||||
property real padding: 6
|
||||
property bool expanded: false
|
||||
|
||||
Behavior on opacity {
|
||||
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
|
||||
|
|
@ -35,48 +43,136 @@ Rectangle {
|
|||
Timer {
|
||||
interval: 4000
|
||||
repeat: true
|
||||
running: root.visible
|
||||
running: root.visible && !root.expanded
|
||||
onTriggered: {
|
||||
if (Ai.subconsciousStream.length > 1)
|
||||
Ai.subconsciousStream = Ai.subconsciousStream.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
function _openPanel() {
|
||||
// Open the Tier-2 Subconscious event panel as a draggable overlay
|
||||
// pane and pin it, so it stays after the overlay closes. Mirrors what
|
||||
// OverlayTaskbar's WidgetButton does on click.
|
||||
const id = "subconsciousEventPanel";
|
||||
const open = Persistent.states.overlay.open;
|
||||
if (!open.includes(id)) {
|
||||
Persistent.states.overlay.open.push(id);
|
||||
}
|
||||
OverlayContext.pin(id, true);
|
||||
GlobalStates.overlayOpen = true;
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
anchors.fill: parent
|
||||
anchors.margins: root.padding
|
||||
spacing: root.padding
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "psychology"
|
||||
color: Appearance.colors.colSubtext
|
||||
NumberAnimation on opacity {
|
||||
// gentle pulse while the pass runs
|
||||
from: 0.5; to: 1; duration: 1200
|
||||
running: root.visible; loops: Animation.Infinite
|
||||
easing.type: Easing.InOutSine
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: root.padding
|
||||
|
||||
// The icon is the affordance: tap = expand inline, long-press =
|
||||
// open the overlay panel. Press-and-hold beats a separate button
|
||||
// — keeps the ticker visually quiet.
|
||||
Item {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: icon.implicitWidth
|
||||
implicitHeight: icon.implicitHeight
|
||||
|
||||
MaterialSymbol {
|
||||
id: icon
|
||||
anchors.centerIn: parent
|
||||
iconSize: Appearance.font.pixelSize.larger
|
||||
text: "psychology"
|
||||
color: Appearance.colors.colSubtext
|
||||
NumberAnimation on opacity {
|
||||
// gentle pulse while the pass runs
|
||||
from: 0.5; to: 1; duration: 1200
|
||||
running: root.visible; loops: Animation.Infinite
|
||||
easing.type: Easing.InOutSine
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.expanded = !root.expanded
|
||||
onPressAndHold: root._openPanel()
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: {
|
||||
const s = Ai.subconsciousStream;
|
||||
if (s.length === 0) return "";
|
||||
const last = s[s.length - 1];
|
||||
const isTool = last.kind === "tool_call" || last.kind === "tool_result";
|
||||
const tag = isTool ? Translation.tr("tool") : Translation.tr("thinking");
|
||||
// For a forming thought (tokens), show the tail of the sentence
|
||||
// so the newest words are always visible as it grows, rather
|
||||
// than the head eliding away. Tools are short — show in full.
|
||||
const body = (!isTool && last.text.length > 120)
|
||||
? "…" + last.text.slice(-120) : last.text;
|
||||
return `${tag} · ${body}`;
|
||||
}
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
|
||||
// Hint that there's more — expand/collapse chevron, and a long-
|
||||
// press affordance tooltip.
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
iconSize: Appearance.font.pixelSize.small
|
||||
text: root.expanded ? "expand_less" : "expand_more"
|
||||
color: Appearance.colors.colSubtext
|
||||
visible: Ai.subconsciousStream.length > 0
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.expanded = !root.expanded
|
||||
onPressAndHold: root._openPanel()
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
StyledToolTip {
|
||||
text: Translation.tr("Tap to expand · Hold to open the event panel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
// Inline mini-log: the last several entries (newest at the bottom),
|
||||
// shown when expanded. Read-only — the full searchable log lives in
|
||||
// the Tier-2 overlay panel.
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: {
|
||||
const s = Ai.subconsciousStream;
|
||||
if (s.length === 0) return "";
|
||||
const last = s[s.length - 1];
|
||||
const tag = last.kind === "tool_call" || last.kind === "tool_result"
|
||||
? Translation.tr("tool") : Translation.tr("thinking");
|
||||
return `${tag} · ${last.text}`;
|
||||
visible: root.expanded && Ai.subconsciousStream.length > 0
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
// Show up to the last 6 entries, oldest first so the forming
|
||||
// thought reads top-to-bottom.
|
||||
model: ScriptModel {
|
||||
values: Ai.subconsciousStream.slice(-6)
|
||||
}
|
||||
delegate: StyledText {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
color: Appearance.colors.colSubtext
|
||||
text: {
|
||||
const isTool = modelData.kind === "tool_call" || modelData.kind === "tool_result";
|
||||
const tag = isTool ? Translation.tr("tool") : Translation.tr("thinking");
|
||||
return `${tag} · ${modelData.text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,10 +242,32 @@ Singleton {
|
|||
}
|
||||
|
||||
// Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result.
|
||||
// Consecutive tokens are coalesced onto the current line so the ticker reads
|
||||
// as a thought forming (a sentence), not a single flickering word replaced
|
||||
// on every token. A tool_call/tool_result starts a fresh line — those are
|
||||
// natural thought boundaries.
|
||||
function pushSubconsciousStream(kind, text) {
|
||||
const t = String(text ?? "").trim();
|
||||
if (t.length === 0) return;
|
||||
root.subconsciousStream = [...root.subconsciousStream, { kind, text: t }];
|
||||
const stream = root.subconsciousStream.slice();
|
||||
const last = stream.length > 0 ? stream[stream.length - 1] : null;
|
||||
if (kind === "token" && last && last.kind === "token") {
|
||||
// Same thought — append to the forming sentence, capped so a very
|
||||
// long monologue doesn't grow unbounded in the live view (the full
|
||||
// text is still snapshotted to the Tier-2 log on pass end).
|
||||
const merged = (last.text + " " + t);
|
||||
stream[stream.length - 1] = {
|
||||
kind: "token",
|
||||
text: merged.length > 280 ? merged.slice(-280) : merged,
|
||||
};
|
||||
} else {
|
||||
stream.push({ kind, text: t });
|
||||
}
|
||||
// Keep the live buffer shallow: the ticker only shows the tail, and a
|
||||
// long pass shouldn't accumulate hundreds of entries in memory. The
|
||||
// Tier-2 snapshot joins all entries anyway.
|
||||
if (stream.length > 12) stream.shift();
|
||||
root.subconsciousStream = stream;
|
||||
}
|
||||
|
||||
// Promote the Tier-1 stream into the Tier-2 log as one event, then clear
|
||||
|
|
@ -286,6 +308,14 @@ Singleton {
|
|||
if (GlobalStates.screenLocked && root.streamingMessage.content.length > 0) {
|
||||
root._redactMessage(root.streamingMessage);
|
||||
}
|
||||
// Prefetch the audio for this reply so Speak is near-instant if the
|
||||
// user taps it. Background fill only — never auto-plays. Only the
|
||||
// latest reply is cached; a subsequent reply overwrites it. If the
|
||||
// synth fails it is not retried (manual Speak handles that).
|
||||
if (Speech.enabled) {
|
||||
const spoken = StringUtils.ttsClean(root.streamingMessage.content ?? "");
|
||||
if (spoken.length > 0) Speech.prefetch(spoken);
|
||||
}
|
||||
if (root.postResponseHook) {
|
||||
root.postResponseHook();
|
||||
root.postResponseHook = null;
|
||||
|
|
@ -408,6 +438,7 @@ Singleton {
|
|||
if (message.length === 0) return;
|
||||
const aiMessage = aiMessageComponent.createObject(root, {
|
||||
"role": role,
|
||||
"model": Souveraine.currentAgentId,
|
||||
"content": message,
|
||||
"rawContent": message,
|
||||
"thinking": false,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ import QtQuick
|
|||
* Config.options.speech.tts.enable is the user's kill switch (settings →
|
||||
* Speech); Config.options.speech.tts.endpoint, when set, overrides the
|
||||
* server-mapped tts_url.
|
||||
*
|
||||
* Prefetch: the latest assistant reply is synthesized into the single tmpfs
|
||||
* file in the background the moment it finishes streaming, so tapping Speak
|
||||
* is near-instant when the audio is already ready. Prefetch NEVER auto-plays
|
||||
* — the user always initiates playback. A prefetch that fails is not retried
|
||||
* (the manual Speak path retries on its own when tapped). Only the latest
|
||||
* reply is kept; a new/edited reply invalidates the cached file because
|
||||
* readiness is keyed on the exact text.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
|
@ -28,23 +36,119 @@ Singleton {
|
|||
property string lastError: ""
|
||||
|
||||
property string _pendingText: ""
|
||||
// Resolved endpoint + voice from the last /v1/config lookup, kept so a
|
||||
// transient synth failure can be retried without re-resolving.
|
||||
property string _synthUrl: ""
|
||||
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
|
||||
// failed request) or time out on a slow synth. One retry recovers the common
|
||||
// case; a second failure surfaces a human message.
|
||||
property int _synthAttempts: 0
|
||||
readonly property int _synthMaxAttempts: 2
|
||||
readonly property string _outFile: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/souveraine-speech.mp3"
|
||||
|
||||
// What the file on disk currently holds (the exact text it was synthesized
|
||||
// from), or "" if nothing valid is cached. Keyed on text so a new or edited
|
||||
// reply naturally misses → Speak synthesizes fresh. Set only on a successful
|
||||
// synth; cleared by stop()/speak()/prefetch() of different text.
|
||||
property string _readyText: ""
|
||||
|
||||
// speak — synthesize and play `text` in the agent's voice. A new call
|
||||
// replaces any in-flight synthesis or playback.
|
||||
// replaces any in-flight synthesis or playback. If `text` is already
|
||||
// cached on disk (from a prefetch), skip synthesis and play instantly.
|
||||
// Check the cache BEFORE stopping — a prefetch that just completed should
|
||||
// be honored, not killed and re-requested.
|
||||
function speak(text) {
|
||||
const t = String(text ?? "").trim();
|
||||
if (t.length === 0 || !root.enabled) return;
|
||||
stop();
|
||||
root.lastError = "";
|
||||
// Prefetch hit: audio for exactly this text is already on disk — play
|
||||
// it straight away, no synth round-trip. Don't call stop() here;
|
||||
// if a prefetch is still running for a *different* message, stop()
|
||||
// would kill it — but our text matches, so we know the file is ours.
|
||||
if (root._readyText === t) {
|
||||
stop();
|
||||
root._pendingText = t;
|
||||
root._play();
|
||||
return;
|
||||
}
|
||||
// No cache hit — stop anything in-flight and synthesize fresh.
|
||||
stop();
|
||||
root._pendingText = t;
|
||||
voiceLookup.running = true;
|
||||
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) {
|
||||
root._fireSynth(false /*interactive*/);
|
||||
} else {
|
||||
voiceLookup.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// prefetch — synthesize `text` into the cache file in the background so a
|
||||
// later speak() of the same text plays instantly. Does NOT play. A prefetch
|
||||
// failure is silent and not retried; the eventual manual speak() will
|
||||
// synthesize (with its own retry) if the text still isn't ready.
|
||||
function prefetch(text) {
|
||||
const t = String(text ?? "").trim();
|
||||
if (t.length === 0 || !root.enabled) return;
|
||||
// Already have exactly this text ready — don't re-request it.
|
||||
if (root._readyText === t) return;
|
||||
// Don't let a prefetch clobber a speak() the user just initiated.
|
||||
if (root.speaking && root._pendingText !== t) return;
|
||||
root._pendingText = t;
|
||||
root._synthAttempts = 0;
|
||||
// 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) {
|
||||
root._fireSynth(true /*prefetch*/);
|
||||
} else {
|
||||
voiceLookup.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
voiceLookup.running = false;
|
||||
synthProc.running = false;
|
||||
playProc.running = false;
|
||||
retryTimer.running = false;
|
||||
}
|
||||
|
||||
// Play whatever is in the cache file.
|
||||
function _play() {
|
||||
playProc.running = true;
|
||||
}
|
||||
|
||||
// Fire (or re-fire) the synth request with the already-resolved url+voice.
|
||||
// `prefetch` marks the result as a background fill (no playback, no retry,
|
||||
// populate _readyText on success) vs. an interactive speak().
|
||||
function _fireSynth(prefetch) {
|
||||
synthProc._prefetch = prefetch;
|
||||
synthProc.command = [
|
||||
"curl", "-sf", "--max-time", "180",
|
||||
"-X", "POST", "-H", "Content-Type: application/json",
|
||||
"-d", JSON.stringify({
|
||||
input: root._pendingText,
|
||||
voice: root._synthVoice,
|
||||
model: "vibevoice-v1"
|
||||
}),
|
||||
"-o", root._outFile,
|
||||
`${root._synthUrl.replace(/\/$/, "")}/audio/speech`
|
||||
];
|
||||
synthProc.running = true;
|
||||
}
|
||||
|
||||
// Delay before a retry — short, just long enough for the server to clear.
|
||||
Timer {
|
||||
id: retryTimer
|
||||
interval: 750
|
||||
repeat: false
|
||||
onTriggered: root._fireSynth(false /*interactive speak retries only*/)
|
||||
}
|
||||
|
||||
// ── who → voice, from souveraine ─────────────────────────────────────
|
||||
|
|
@ -68,22 +172,17 @@ Singleton {
|
|||
console.log("[Speech]", root.lastError);
|
||||
return;
|
||||
}
|
||||
synthProc.command = [
|
||||
"curl", "-sf", "--max-time", "60",
|
||||
"-X", "POST", "-H", "Content-Type: application/json",
|
||||
"-d", JSON.stringify({
|
||||
input: root._pendingText,
|
||||
voice: voice,
|
||||
model: "vibevoice-v1"
|
||||
}),
|
||||
"-o", root._outFile,
|
||||
`${ttsUrl.replace(/\/$/, "")}/audio/speech`
|
||||
];
|
||||
synthProc.running = true;
|
||||
root._synthUrl = ttsUrl;
|
||||
root._synthVoice = voice;
|
||||
// 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.
|
||||
root._fireSynth(synthProc._prefetch ?? false);
|
||||
}
|
||||
}
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
|
||||
if (exitCode !== 0 && exitCode !== 15) {
|
||||
root.lastError = "souveraine server unreachable for voice mapping";
|
||||
console.log("[Speech]", root.lastError);
|
||||
}
|
||||
|
|
@ -93,13 +192,56 @@ Singleton {
|
|||
// ── synthesis ────────────────────────────────────────────────────────
|
||||
Process {
|
||||
id: synthProc
|
||||
// True when this synth is a background prefetch (no playback, no retry).
|
||||
property bool _prefetch: false
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
playProc.running = true;
|
||||
} else {
|
||||
root.lastError = `TTS synthesis failed (curl exit ${exitCode})`;
|
||||
console.log("[Speech]", root.lastError);
|
||||
// Success: the file now holds _pendingText.
|
||||
root._readyText = root._pendingText;
|
||||
if (synthProc._prefetch) {
|
||||
// Prefetch only fills the cache; never auto-plays.
|
||||
synthProc._prefetch = false;
|
||||
return;
|
||||
}
|
||||
root._play();
|
||||
return;
|
||||
}
|
||||
// Prefetch failures are silent and not retried — per design, we
|
||||
// don't burn a second request on background fill. The manual
|
||||
// speak() path will synthesize (and retry) when actually tapped.
|
||||
if (synthProc._prefetch) {
|
||||
synthProc._prefetch = false;
|
||||
console.log("[Speech] prefetch failed (curl exit " + exitCode + ") — will synth on demand when spoken");
|
||||
return;
|
||||
}
|
||||
// Exit 15 = SIGTERM — stop() killed this synth (user tapped stop,
|
||||
// or a new speak() replaced it). This is always intentional, never
|
||||
// an error. Suppress silently.
|
||||
if (exitCode === 15) {
|
||||
return;
|
||||
}
|
||||
// Transient failures: exit 22 = HTTP ≥400 (the server's intermittent
|
||||
// 500 under model concurrency), exit 28 = timeout (slow synth).
|
||||
// Retry once; a collision or a slow first attempt usually clears.
|
||||
const transient = (exitCode === 22 || exitCode === 28);
|
||||
root._synthAttempts += 1;
|
||||
if (transient && root._synthAttempts < root._synthMaxAttempts) {
|
||||
console.log(`[Speech] synth exit ${exitCode}, retry ${root._synthAttempts}/${root._synthMaxAttempts - 1}`);
|
||||
retryTimer.running = true;
|
||||
return;
|
||||
}
|
||||
if (exitCode === 28) {
|
||||
root.lastError = root._synthAttempts > 1
|
||||
? "voice synthesis timed out twice — the server may be busy or the reply too long"
|
||||
: "voice synthesis timed out";
|
||||
} else if (exitCode === 22) {
|
||||
root.lastError = root._synthAttempts > 1
|
||||
? "voice synthesis failed twice — the TTS server returned an error (may be busy)"
|
||||
: "voice synthesis failed (server error)";
|
||||
} else {
|
||||
root.lastError = `voice synthesis failed (curl exit ${exitCode})`;
|
||||
}
|
||||
console.log("[Speech]", root.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +252,8 @@ Singleton {
|
|||
`mpv --no-video --really-quiet '${root._outFile}' 2>/dev/null ` +
|
||||
`|| ffplay -nodisp -autoexit -loglevel quiet '${root._outFile}'`]
|
||||
onExited: (exitCode) => {
|
||||
if (exitCode !== 0) {
|
||||
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
|
||||
if (exitCode !== 0 && exitCode !== 15) {
|
||||
root.lastError = `audio playback failed (exit ${exitCode}) — mpv/ffplay present?`;
|
||||
console.log("[Speech]", root.lastError);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue