156 lines
5.4 KiB
QML
156 lines
5.4 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."
|
|
|
|
// The rig is not in the repo and never will be — it is licensed assets on
|
|
// Casey's own device (TASK-59). So its absence is the ordinary state of any
|
|
// machine that has not staged it, and it must read as "not set up here"
|
|
// rather than as a broken face.
|
|
property bool rigPresent: rigProbe.exists
|
|
FileView {
|
|
id: rigProbe
|
|
path: root.rigDir + "/index.html"
|
|
preload: true
|
|
}
|
|
|
|
function join() {
|
|
if (root.joined)
|
|
return;
|
|
if (!root.rigPresent) {
|
|
console.log("[face] no rig at " + root.rigDir
|
|
+ " — stage it with tools/stage-face.sh; not joining");
|
|
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)
|
|
|
|
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 = "";
|
|
}
|
|
}
|
|
}
|