176 lines
6.1 KiB
QML
176 lines
6.1 KiB
QML
// Her face on the glass — TASK-59.
|
|
//
|
|
// The face is a *limb*, not a second client. `Souveraine.qml` is the one
|
|
// connection to the server and stays that way: this owns a `souveraine-web`
|
|
// process, feeds it what she is already saying on the sidebar's stream, and
|
|
// sends what the user says to it back through the same `Souveraine.send()`.
|
|
// Casey, 2026-08-05: *"I will want it to be in sync with the sidebar — meaning
|
|
// if we 'resume' it's resumed."* Two transports could not promise that; one
|
|
// does by construction.
|
|
//
|
|
// ## Turning her on is joining
|
|
//
|
|
// `joined` is the whole state. While it is true she is on the glass **and** the
|
|
// expression vocabulary rides in the per-send ambient block, so she has a
|
|
// syntax for shifting expression. While it is false neither happens — and the
|
|
// second half is the point: that prompt is context nobody asked for when the
|
|
// face is closed. Casey, 2026-08-05: *"it'll be like a loadable/unloadable
|
|
// skill… we might have times where we just don't want that extra prompt added
|
|
// to context."*
|
|
//
|
|
// The sidebar ignores the tags it sees, which is why they are safe to leave in
|
|
// the stream rather than stripped on the way to one surface.
|
|
pragma Singleton
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.modules.common
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
// On the glass, and in the prompt. One flag, both consequences.
|
|
property bool joined: false
|
|
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/face.sock"
|
|
property string rigDir: Quickshell.env("HOME") + "/.souveraine/face"
|
|
|
|
// Accumulated text of the turn in flight, so the bubble shows the whole
|
|
// line rather than the last delta.
|
|
property string _line: ""
|
|
|
|
// The fragment that rides in `ambient` while she is joined. Kept here
|
|
// rather than in the server so that leaving costs exactly nothing — there
|
|
// is no flag to unset and no prompt to remember to remove.
|
|
readonly property string skill: "You have a face on this device right now. "
|
|
+ "You may shift expression by emitting a tag on its own line: "
|
|
+ "[[face:idle]], [[face:alert]], [[face:thinking]], [[face:processing]], "
|
|
+ "[[face:affectionate]], [[face:straining]], [[face:yawning]], "
|
|
+ "[[face:listening]], [[face:speaking]]. "
|
|
+ "These are the postures the presence system already uses. "
|
|
+ "Use them sparingly, where the shift is real."
|
|
|
|
// No pre-flight check on the rig.
|
|
//
|
|
// There was one, reading `FileView.exists`, and it reported false for a
|
|
// directory that was plainly there — so the guard meant to explain a
|
|
// missing rig became the thing preventing a present one from loading. The
|
|
// host already fails loudly and specifically when the directory is wrong,
|
|
// and `onExited` puts `joined` back, so the honest answer is to let it try
|
|
// and report what actually happened. A guard that can be wrong about the
|
|
// world is worse than no guard.
|
|
function join() {
|
|
if (root.joined)
|
|
return;
|
|
host.running = true;
|
|
root.joined = true;
|
|
}
|
|
|
|
function leave() {
|
|
root.joined = false;
|
|
root._send({ op: "quit" });
|
|
host.running = false;
|
|
}
|
|
|
|
function toggle() {
|
|
if (root.joined)
|
|
root.leave();
|
|
else
|
|
root.join();
|
|
}
|
|
|
|
Process {
|
|
id: host
|
|
command: ["souveraine-web",
|
|
"--rig", root.rigDir,
|
|
"--ipc", root.socketPath,
|
|
"--transparent",
|
|
"--app-id", "org.souveraine.face"]
|
|
stdout: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: line => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(line);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
// Anything the page wants to say goes through the one transport.
|
|
if (msg.event === "said" && msg.text)
|
|
Souveraine.send(msg.text);
|
|
else if (msg.event === "tapped")
|
|
root.tapped(msg.area ?? "body");
|
|
}
|
|
}
|
|
onExited: root.joined = false
|
|
}
|
|
|
|
signal tapped(string area)
|
|
|
|
// Reachable by name, so the dial, a launcher and the agent all summon her
|
|
// the same way rather than each growing a copy (TASK-30/31). `status`
|
|
// answers rather than assumes — an agent that cannot ask whether she is up
|
|
// has to guess, and guessing is what a verb table exists to stop.
|
|
IpcHandler {
|
|
target: "face"
|
|
|
|
function toggle(): void {
|
|
root.toggle();
|
|
}
|
|
|
|
function join(): void {
|
|
root.join();
|
|
}
|
|
|
|
function leave(): void {
|
|
root.leave();
|
|
}
|
|
|
|
function status(): string {
|
|
return JSON.stringify({
|
|
joined: root.joined,
|
|
rig: root.rigDir
|
|
});
|
|
}
|
|
}
|
|
|
|
Socket {
|
|
id: sock
|
|
path: root.socketPath
|
|
connected: root.joined
|
|
}
|
|
|
|
function _send(msg) {
|
|
if (sock.connected)
|
|
sock.write(JSON.stringify(msg) + "\n");
|
|
}
|
|
|
|
function _eval(script) {
|
|
root._send({ op: "eval", script: script });
|
|
}
|
|
|
|
// Everything she says on the sidebar's stream reaches the bubble. The face
|
|
// is a second *view* of one turn, never a second turn.
|
|
Connections {
|
|
target: Souveraine
|
|
enabled: root.joined
|
|
|
|
function onStreamEvent(event) {
|
|
if (event.message_type === "assistant_message" && event.content) {
|
|
root._line += event.content;
|
|
// Posture tags are hers to emit and the bubble's to not show.
|
|
const tag = /\[\[face:([a-z]+)\]\]/g;
|
|
let m;
|
|
while ((m = tag.exec(root._line)) !== null)
|
|
root._eval(`window.face.posture(${JSON.stringify(m[1])})`);
|
|
const shown = root._line.replace(tag, "").trim();
|
|
root._eval(`window.face.say(${JSON.stringify(shown)})`);
|
|
}
|
|
}
|
|
|
|
function onTurnActiveChanged() {
|
|
if (Souveraine.turnActive)
|
|
root._line = "";
|
|
}
|
|
}
|
|
}
|