Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/Ai.qml
Fimeg 0ef239515f 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.
2026-07-30 09:18:56 -04:00

735 lines
31 KiB
QML

pragma Singleton
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
import qs.services
import qs.services.ai
/**
* ii-compat adapter over the Souveraine singleton.
*
* Keeps the public API the illogical-impulse sidebar UI expects (models,
* messages, sendUserMessage, /key advice, ...) but owns no transport —
* Souveraine.qml is the substrate connection. This file's job is shaping
* wire events into AiMessageData objects the existing chat UI renders.
*
* Lives in souveraine/surfaces/quickshell/, deployed over
* ~/.config/quickshell/ii/services/Ai.qml (see deploy.sh).
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
readonly property string interfaceRole: "interface"
signal responseFinished()
property var messageIDs: []
property var messageByID: ({})
// Which message is currently being spoken by TTS, as an index into
// messageIDs (-1 = none). Set by a message's Speak/re-synth button;
// auto-cleared when Speech stops. Keyed on the stable messageIndex, so
// scrolling back and tapping an older reply still resolves correctly.
property int speakingMessageIndex: -1
// Keys are server-side; the UI's key gate must always pass.
readonly property bool currentModelHasApiKey: true
readonly property var apiKeysLoaded: true
property var postResponseHook
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
property QtObject tokenCount: QtObject {
property int input: -1
property int output: -1
property int total: -1
}
function idForMessage(message) {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
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"]
property var toolDescriptions: {
"souveraine": Translation.tr("Sensors are configured per-agent in Souveraine")
}
// ── Agents as models (projected from Souveraine.agents) ─────────────
property var models: ({})
property var modelList: Object.keys(root.models)
property var currentModelId: Souveraine.currentAgentId
property var currentModel: models[currentModelId] || models[modelList[0]]
// Clear the speaking marker whenever TTS stops — covers natural end of
// playback, manual stop, and a new speak() replacing in-flight audio.
Connections {
target: Speech
function onSpeakingChanged() {
if (!Speech.speaking) root.speakingMessageIndex = -1
}
}
Connections {
target: Souveraine
function onAgentsRefreshed() {
const map = {};
Souveraine.agentList.forEach(id => {
const agent = Souveraine.agents[id];
map[id] = root.aiModelComponent.createObject(root, {
"name": agent.name,
"icon": "spark-symbolic",
"description": agent.description.length > 0 ? agent.description : Translation.tr("Souveraine agent"),
"endpoint": Souveraine.serverBase,
"model": id,
"requires_key": false,
});
});
root.models = map;
root.modelList = Object.keys(map);
// Restore only the selected-agent preference. Conversation resume
// is intentional: /resume derives it from the server on demand.
const persisted = Persistent.states?.ai?.model ?? "";
if (persisted.length > 0 && Souveraine.agents[persisted]) {
Souveraine.selectAgent(persisted);
}
}
function onConversationResumed(agentId, conversationId, messages) {
if (agentId !== Souveraine.currentAgentId) return;
root.restoreServerConversation(messages);
}
function onServerUnreachable() {
root.addMessage(
Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase),
root.interfaceRole
);
}
function onStreamEvent(event) {
root.handleStreamEvent(event);
}
function onStreamClosed(exitCode) {
// If a subconscious pass was still active when the stream closed,
// retire the Tier-1 ticker (promote to the log, clear the flag).
if (root.subconsciousActive) {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
if (root.streamingMessage && !root.streamingMessage.done) {
if (exitCode !== 0 && root.streamingMessage.content.length === 0) {
root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase));
}
root.finishStreaming();
}
}
}
// ── Lock-time response redaction ─────────────────────────────────────
// When the session locks during a personal-tier streaming response,
// the in-flight content must be withheld immediately. Only ambient
// output remains visible on the lock surface. This is the enforcement
// side of SESSION-TRUST-ARCHITECTURE.md's "A lock during personal
// agent output hides it" requirement.
//
// The streaming message is replaced with a redaction placeholder. The
// raw content is preserved in the message's rawContent so it can be
// shown again after unlock (the message stays in history), but the
// displayed content is cleared.
//
// The redaction is reversible: every message whose content we replaced
// with the placeholder is recorded in `redactedOnLock`, and on unlock
// its content is restored from `rawContent` (the streaming message too,
// if it is still in flight). A finished message redacted at lock then
// surfaced on the lock screen counts as "delivered" and is restored
// normally on unlock; one that never surfaced stays for the chat to
// replay once unlocked.
property var redactedOnLock: []
function _redactMessage(msg) {
if (!msg || msg.content.length === 0) return;
if (msg.content === Translation.tr("[content hidden until unlock]")) return;
msg.rawContent = msg.content;
msg.content = Translation.tr("[content hidden until unlock]");
if (!root.redactedOnLock.includes(msg)) {
root.redactedOnLock = [...root.redactedOnLock, msg];
}
root.redactedMessagesChanged();
}
function _restoreRedacted() {
if (root.redactedOnLock.length === 0) return;
for (const msg of root.redactedOnLock) {
if (msg && msg.rawContent && msg.rawContent.length > 0) {
msg.content = msg.rawContent;
}
}
root.redactedOnLock = [];
root.redactedMessagesChanged();
}
// Messages redacted while locked, newest first, for the lock surface to
// surface as ambient notifications. Strips to a one-line preview — the
// lock screen shows "the agent replied", not the personal-tier body.
function redactedPreviews() {
return root.redactedOnLock
.filter(m => m && m.rawContent && m.rawContent.length > 0 && m.role === "assistant")
.map(m => {
const firstLine = m.rawContent.split("\n").find(l => l.trim().length > 0) || "";
return firstLine.slice(0, 80);
})
.reverse();
}
signal redactedMessagesChanged()
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
// Lock fired mid-stream — redact the in-flight content.
if (root.streamingMessage) {
root._redactMessage(root.streamingMessage);
console.log("[ai] lock fired during stream — redacted personal output");
}
} else {
// Unlock: restore every message we hid, so the chat shows
// what actually came back rather than lingering placeholders.
root._restoreRedacted();
}
}
}
// ── Streaming message shaping ────────────────────────────────────────
property AiMessageData streamingMessage
property bool inThinkBlock: false
// ── Subconscious three-tier visibility ───────────────────────────────
// See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's
// N+1 pass is shown three ways: a transient live ticker while the pass
// runs (Tier 1), a persistent event log after (Tier 2), and rare agency
// surfacings in the chat field (Tier 3).
//
// Tier 1 — live stream fed by subconscious_token/tool_call/tool_result
// events. Rendered by SubconsciousTicker; cleared on pass end.
property bool subconsciousActive: false
property var subconsciousStream: []
// Tier 2 — persistent log. The Tier-1 stream is snapshotted here on pass
// end, and surfaced events (reflection/archivist/surfacing) are appended.
// Rendered by the SubconsciousEventPanel overlay widget.
property var subconsciousEvents: []
function appendToStreaming(text) {
if (!root.streamingMessage) return;
root.streamingMessage.rawContent += text;
root.streamingMessage.content += text;
}
// 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;
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
// the live stream. Called when a subconscious pass ends.
function snapshotSubconsciousStream() {
if (root.subconsciousStream.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind: "pass",
source: Translation.tr("Subconscious pass"),
priority: "",
content: root.subconsciousStream.map(e => e.text).join("\n"),
timestamp: Date.now(),
}];
root.subconsciousStream = [];
}
// Append a surfaced event to the Tier-2 log. Used by snapshotSubconsciousStream
// (pass end) and by the surfacing/reflection/archivist event handlers.
function appendSubconsciousEvent(kind, source, content, priority = "") {
const c = String(content ?? "");
if (c.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind, source, priority, content: c, timestamp: Date.now(),
}];
}
function finishStreaming() {
if (!root.streamingMessage) return;
if (root.inThinkBlock) {
root.appendToStreaming("\n</think>\n");
root.inThinkBlock = false;
}
root.streamingMessage.thinking = false;
root.streamingMessage.done = true;
// If the turn finished while the session is locked, the finished
// assistant message is personal-tier output the user hasn't seen —
// redact it for the chat view and let the lock surface announce it.
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;
}
root.saveChat("lastSession");
root.responseFinished();
}
function handleStreamEvent(event) {
if (root.streamingMessage?.thinking && event.message_type !== "ping")
root.streamingMessage.thinking = false;
switch (event.message_type) {
case "assistant_message":
if (root.inThinkBlock) {
root.appendToStreaming("\n</think>\n");
root.inThinkBlock = false;
}
root.appendToStreaming(event.content);
break;
case "reasoning_message":
if (!root.inThinkBlock) {
root.appendToStreaming("\n<think>\n");
root.inThinkBlock = true;
}
root.appendToStreaming(event.content);
break;
case "tool_call_message": {
const call = event.tool_call;
root.appendToStreaming(`\n\n<think>\nsensor: ${call.function.name}(${call.function.arguments})\n</think>\n`);
break;
}
case "tool_return_message": {
const ret = event.tool_return;
root.appendToStreaming(`\n<think>\n[${ret.status}] ${ret.output}\n</think>\n`);
break;
}
case "interstitial":
// Her narration between gestures. Register decides how loud:
// cenno is a quiet aside, her_voice is a passage.
root.appendToStreaming(event.register === "her_voice"
? `\n\n> ${event.text}\n\n`
: `\n\n*${event.text}*\n\n`);
break;
case "souveraine_surfacing":
// Tier 3: routine surfacings no longer pollute the chat field.
// Routed to the Tier-2 event panel only. (Aster is Annie's name
// for her subconscious; this surface may be Souveraine, Vanguard,
// or another agent — one canonical surfacing is shown.)
root.appendSubconsciousEvent(
"surfacing",
Translation.tr("Surfacing (%1)").arg(event.source ?? "?"),
event.content,
String(event.priority ?? "")
);
break;
case "souveraine_reflection":
root.appendSubconsciousEvent("reflection", Translation.tr("Reflection"), event.content);
break;
case "souveraine_archivist":
root.appendSubconsciousEvent(
"archivist",
Translation.tr("Archivist (pressure %1%)").arg(Math.round(event.pressure * 100)),
event.synthesis
);
break;
case "compaction_warning":
root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole);
break;
case "context_pressure":
root.tokenCount.total = event.tokens;
break;
case "subconscious_token":
// Tier 1: live reasoning tokens feed the ticker.
root.pushSubconsciousStream("token", event.content);
break;
case "subconscious_tool_call":
root.pushSubconsciousStream("tool_call", `${event.name ?? "tool"}(${event.arguments ?? ""})`);
break;
case "subconscious_tool_result":
root.pushSubconsciousStream("tool_result", `[${event.is_error ? "error" : "ok"}] ${event.name ?? "tool"}: ${event.output ?? ""}`);
break;
case "subconscious_pass":
// Pass start: light the Tier-1 ticker. Pass end: promote the
// stream into the Tier-2 log, then clear it.
if (event.active) {
root.subconsciousActive = true;
} else {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
break;
case "subconscious_halt":
root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole);
break;
case "inference_strain":
console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`);
break;
case "atmosphere":
case "outfit":
case "itinerary":
// Shell chrome hooks — their modules subscribe to
// Souveraine.streamEvent directly; nothing to do here.
break;
case "primary_complete":
// Primary yields; subconscious presses on behind this.
root.finishStreaming();
break;
case "done":
if (root.streamingMessage && !root.streamingMessage.done) root.finishStreaming();
break;
case "ping":
// Liveness only — not end-of-turn.
break;
}
}
// ── Message store ────────────────────────────────────────────────────
function addMessage(message, role) {
if (message.length === 0) return;
const aiMessage = aiMessageComponent.createObject(root, {
"role": role,
"model": Souveraine.currentAgentId,
"content": message,
"rawContent": message,
"thinking": false,
"done": true,
});
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function removeMessage(index) {
if (index < 0 || index >= messageIDs.length) return;
const id = root.messageIDs[index];
root.messageIDs.splice(index, 1);
root.messageIDs = [...root.messageIDs];
delete root.messageByID[id];
}
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
Souveraine.newConversation();
}
// Render the server's canonical transcript after /agent or /resume.
// This replaces ii's old local snapshot behaviour: the next send remains
// on the same live Souveraine conversation rather than silently forking.
function restoreServerConversation(messages) {
root.messageIDs = [];
root.messageByID = ({});
root.streamingMessage = null;
root.inThinkBlock = false;
root.subconsciousActive = false;
root.subconsciousStream = [];
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
messages.forEach(message => {
const content = (message.blocks ?? []).map(block => {
switch (block.type) {
case "text": return block.text ?? "";
case "reasoning": return `<think>\n${block.reasoning ?? ""}\n</think>`;
case "tool_use": return `sensor: ${block.name ?? "?"}(${block.input ?? ""})`;
case "tool_result": return `[${block.tool_name ?? "tool"}] ${block.output ?? ""}`;
case "image": return "[image]";
default: return "";
}
}).filter(Boolean).join("\n");
if (content.length === 0) return;
const role = message.role === "user"
? "user"
: message.role === "assistant" ? "assistant" : root.interfaceRole;
root.addMessage(content, role);
});
}
function sendUserMessage(message) {
if (message.length === 0) return;
root.addMessage(message, "user");
const result = Souveraine.send(message);
if (result === "step-up") {
// Step-up auth required. Trigger the PAM flow; on success,
// retry the send. The user message is already in the chat
// history, so we don't add it again.
if (typeof StepUpAuth !== "undefined") {
StepUpAuth.requestAuth("send", function(granted) {
if (granted) {
// Remove the "auth required" indicator if one was
// added, and retry. The queued text was not consumed
// by Souveraine, so we can send it again.
Souveraine.send(message);
// Re-create the streaming message for the response.
root._startStreaming();
} else {
root.addMessage(Translation.tr("Authentication required to send."), root.interfaceRole);
}
});
}
return;
}
if (!result) {
root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole);
return;
}
root._startStreaming();
}
// Set up the streaming assistant message container. Called after a
// successful send (or after a step-up auth retry succeeds).
function _startStreaming() {
root.inThinkBlock = false;
root.streamingMessage = root.aiMessageComponent.createObject(root, {
"role": "assistant",
"model": Souveraine.currentAgentId,
"content": "",
"rawContent": "",
"thinking": true,
"done": false,
});
const id = idForMessage(root.streamingMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = root.streamingMessage;
}
// ── Model (agent) selection ──────────────────────────────────────────
function getModel() {
return models[currentModelId];
}
function setModel(modelId, feedback = true, setPersistentState = true) {
if (!modelId) modelId = ""
if (modelList.indexOf(modelId) === -1) {
const match = modelList.find(id =>
id.toLowerCase() === modelId.toLowerCase() ||
(models[id]?.name ?? "").toLowerCase() === modelId.toLowerCase());
if (!match) {
if (feedback) root.addMessage(Translation.tr("Unknown agent. Available:\n- %1").arg(modelList.map(id => `${models[id].name} (\`${id}\`)`).join("\n- ")), root.interfaceRole);
return;
}
modelId = match;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
if (!Souveraine.selectAgent(modelId)) {
if (feedback) root.addMessage(Translation.tr("Finish or cancel the current turn before switching agents."), root.interfaceRole);
return;
}
root.currentModel = models[modelId];
if (feedback) root.addMessage(Translation.tr("Agent set to %1.").arg(models[modelId].name), root.interfaceRole);
}
function resumeConversation() {
if (!Souveraine.resumeLatestConversation()) {
root.addMessage(Translation.tr("Finish or cancel the current turn before resuming."), root.interfaceRole);
}
}
// ── Souveraine-owned settings: advice instead of local state ────────
function setTool(tool) {
root.addMessage(Translation.tr("Tools are Souveraine sensors, configured per-agent — not switchable from the sidebar."), root.interfaceRole);
return false;
}
function getTemperature() { return root.temperature; }
function setTemperature(value) {
root.addMessage(Translation.tr("Temperature is set in the agent's llm_config in Souveraine."), root.interfaceRole);
}
function printTemperature() {
root.addMessage(Translation.tr("Temperature is owned by the agent's llm_config in Souveraine."), root.interfaceRole);
}
function setApiKey(key) {
root.addMessage(Translation.tr("Keys live in souveraine.toml — set them with:\n```bash\nsouveraine auth set\n```"), root.interfaceRole);
}
function printApiKey() {
root.addMessage(Translation.tr("Keys are owned by Souveraine (souveraine.toml / `souveraine auth set`), never exposed here."), root.interfaceRole);
}
function printPrompt() {
root.addMessage(Translation.tr("The system prompt is composed by Souveraine (constitution + memory blocks + sensorium). Inspect it with `souveraine agent show`."), root.interfaceRole);
}
function loadPrompt(filePath) {
root.addMessage(Translation.tr("Prompts are owned by the agent's memory in Souveraine — edit memfs instead of loading prompt files."), root.interfaceRole);
}
function attachFile(filePath) {
// 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);
}
function regenerate(messageIndex) {
root.addMessage(Translation.tr("Regenerate isn't supported — Souveraine conversations are forward-only."), root.interfaceRole);
}
// Souveraine executes its own sensors server-side; nothing to approve.
function rejectCommand(message) {}
function approveCommand(message) {}
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
});
}
// ── Local chat snapshots (ii plumbing, unchanged) ────────────────────
Process {
id: getSavedChats
running: true
command: ["ls", "-1", Directories.aiChats]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.savedChats = text.split("\n")
.filter(fileName => fileName.endsWith(".json"))
.map(fileName => `${Directories.aiChats}/${fileName}`)
}
}
}
function chatToJson() {
return root.messageIDs.map(id => {
const message = root.messageByID[id]
return ({
"role": message.role,
"rawContent": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
})
})
}
FileView {
id: chatSaveFile
property string chatName: ""
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
blockLoading: true
}
FileView {
id: chatWriter
}
function saveChat(chatName) {
const filePath = `${Directories.aiChats}/${chatName.trim()}.json`
chatWriter.path = filePath
chatWriter.setText(JSON.stringify(root.chatToJson()))
getSavedChats.running = true;
}
function loadChat(chatName) {
try {
chatSaveFile.chatName = chatName.trim()
chatSaveFile.reload()
const saveData = JSON.parse(chatSaveFile.text())
root.clearMessages()
root.messageIDs = saveData.map((_, i) => i)
for (let i = 0; i < saveData.length; i++) {
const message = saveData[i];
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
"role": message.role,
"rawContent": message.rawContent,
"content": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
});
}
root.addMessage(Translation.tr("Loaded a local snapshot. Note: this restores the transcript view only — the live Souveraine conversation starts fresh on the next message."), root.interfaceRole);
} catch (e) {
console.log("[Souveraine] Could not load chat: ", e);
} finally {
getSavedChats.running = true;
}
}
}