pragma Singleton pragma ComponentBehavior: Bound import qs.modules.common 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: ({}) // 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 defaultPrompts: [] property list userPrompts: [] property list promptFiles: [...defaultPrompts, ...userPrompts] property list savedChats: [] property list pendingFiles: [] // Tool selection is owned by the agent's sensorium; keep the UI happy. property string currentTool: "souveraine" property list 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]] 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 persisted agent choice if it exists server-side const persisted = Persistent.states?.ai?.model ?? ""; if (persisted.length > 0 && Souveraine.agents[persisted]) { Souveraine.selectAgent(persisted); } } 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) { root.flushSubconscious(); 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(); } } } // ── Streaming message shaping ──────────────────────────────────────── property AiMessageData streamingMessage property bool inThinkBlock: false property string subconsciousBuffer: "" function appendToStreaming(text) { if (!root.streamingMessage) return; root.streamingMessage.rawContent += text; root.streamingMessage.content += text; } function flushSubconscious() { if (root.subconsciousBuffer.length > 0) { root.addMessage(Translation.tr("**Subconscious**\n\n%1").arg(root.subconsciousBuffer), root.interfaceRole); root.subconsciousBuffer = ""; } } function finishStreaming() { if (!root.streamingMessage) return; if (root.inThinkBlock) { root.appendToStreaming("\n\n"); root.inThinkBlock = false; } root.streamingMessage.thinking = false; root.streamingMessage.done = true; 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\n"); root.inThinkBlock = false; } root.appendToStreaming(event.content); break; case "reasoning_message": if (!root.inThinkBlock) { root.appendToStreaming("\n\n"); root.inThinkBlock = true; } root.appendToStreaming(event.content); break; case "tool_call_message": { const call = event.tool_call; root.appendToStreaming(`\n\n\nsensor: ${call.function.name}(${call.function.arguments})\n\n`); break; } case "tool_return_message": { const ret = event.tool_return; root.appendToStreaming(`\n\n[${ret.status}] ${ret.output}\n\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": root.addMessage(Translation.tr("**Aster surfaces** (%1, %2)\n\n%3").arg(event.source).arg(event.priority).arg(event.content), root.interfaceRole); break; case "souveraine_reflection": root.addMessage(Translation.tr("**Reflection**\n\n%1").arg(event.content), root.interfaceRole); break; case "souveraine_archivist": root.addMessage(Translation.tr("**Archivist** (pressure %1%)\n\n%2").arg(Math.round(event.pressure * 100)).arg(event.synthesis), root.interfaceRole); 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": root.subconsciousBuffer += event.content; break; case "subconscious_pass": if (!event.active) root.flushSubconscious(); 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, "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(); } function sendUserMessage(message) { if (message.length === 0) return; root.addMessage(message, "user"); if (!Souveraine.send(message)) { root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole); return; } /* Streaming assistant message; filled by handleStreamEvent */ 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; Souveraine.selectAgent(modelId); root.currentModel = models[modelId]; if (feedback) root.addMessage(Translation.tr("Agent set to %1").arg(models[modelId].name), 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) { root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), 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\n" + output + "\n") : ""}`, "rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n\n" + output + "\n") : ""}`, "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; } } }