@@ -97,7 +97,7 @@ class ArcOverlay {
|
|||||||
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
|
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
|
||||||
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
|
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
|
||||||
this.controls.add_child(this.talk);
|
this.controls.add_child(this.talk);
|
||||||
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Minimize', () => this.minimize()], ['Close', () => this.hide()]]) {
|
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Reset context', () => this.onReset?.()], ['Minimize', () => this.minimize()], ['Close', () => this.hide()]]) {
|
||||||
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
|
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
|
||||||
button.connect('clicked', action); this.controls.add_child(button);
|
button.connect('clicked', action); this.controls.add_child(button);
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ export default class JarvisExtension extends Extension {
|
|||||||
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
||||||
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
|
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
|
||||||
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
||||||
this.overlay.onStop = () => this._call('Cancel'); this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
|
this.overlay.onStop = () => this._call('Cancel'); this.overlay.onReset = () => this._call('ResetContext'); this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
|
||||||
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]); this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]); this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
||||||
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.toggle()); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
|
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.toggle()); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
|
||||||
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show();
|
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show();
|
||||||
@@ -194,6 +194,7 @@ export default class JarvisExtension extends Extension {
|
|||||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||||
this._signals = [
|
this._signals = [
|
||||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||||
|
['ContextReset', () => this.overlay.clear()],
|
||||||
['WakeHeard', () => this.overlay.show()],
|
['WakeHeard', () => this.overlay.show()],
|
||||||
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
||||||
['Token', (text) => this.overlay.token(text)],
|
['Token', (text) => this.overlay.token(text)],
|
||||||
@@ -243,6 +244,6 @@ export default class JarvisExtension extends Extension {
|
|||||||
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
|
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
|
||||||
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
||||||
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
|
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
|
||||||
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show()], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
|
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show()], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Reset context', () => this._call('ResetContext')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
|
||||||
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
|
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
PushToTalk(pressed) { daemon.setPushToTalk?.(Boolean(pressed)); }
|
PushToTalk(pressed) { daemon.setPushToTalk?.(Boolean(pressed)); }
|
||||||
async Say(text) { await daemon.say?.(text); }
|
async Say(text) { await daemon.say?.(text); }
|
||||||
async Ask(text) { await daemon.ask(text); }
|
async Ask(text) { await daemon.ask(text); }
|
||||||
|
async ResetContext() { await daemon.resetContext(); }
|
||||||
Cancel() { daemon.cancel(); }
|
Cancel() { daemon.cancel(); }
|
||||||
SetMode(mode) { daemon.mode = mode; daemon.emit('ModeChanged', mode); }
|
SetMode(mode) { daemon.mode = mode; daemon.emit('ModeChanged', mode); }
|
||||||
GetState() { return daemon.state; }
|
GetState() { return daemon.state; }
|
||||||
@@ -66,6 +67,7 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
PushToTalk: { inSignature: 'b', outSignature: '', method: 'PushToTalk' },
|
PushToTalk: { inSignature: 'b', outSignature: '', method: 'PushToTalk' },
|
||||||
Say: { inSignature: 's', outSignature: '', method: 'Say' },
|
Say: { inSignature: 's', outSignature: '', method: 'Say' },
|
||||||
Ask: { inSignature: 's', outSignature: '', method: 'Ask' },
|
Ask: { inSignature: 's', outSignature: '', method: 'Ask' },
|
||||||
|
ResetContext: { inSignature: '', outSignature: '', method: 'ResetContext' },
|
||||||
Cancel: { inSignature: '', outSignature: '', method: 'Cancel' },
|
Cancel: { inSignature: '', outSignature: '', method: 'Cancel' },
|
||||||
SetMode: { inSignature: 's', outSignature: '', method: 'SetMode' },
|
SetMode: { inSignature: 's', outSignature: '', method: 'SetMode' },
|
||||||
GetState: { inSignature: '', outSignature: 's', method: 'GetState' },
|
GetState: { inSignature: '', outSignature: 's', method: 'GetState' },
|
||||||
@@ -82,6 +84,7 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
},
|
},
|
||||||
signals: {
|
signals: {
|
||||||
StateChanged: { signature: 's' },
|
StateChanged: { signature: 's' },
|
||||||
|
ContextReset: { signature: '' },
|
||||||
Reply: { signature: 's' },
|
Reply: { signature: 's' },
|
||||||
Token: { signature: 's' },
|
Token: { signature: 's' },
|
||||||
Error: { signature: 'ss' },
|
Error: { signature: 'ss' },
|
||||||
@@ -139,7 +142,7 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
daemon.on('Token', (token) => iface.Token(token));
|
daemon.on('Token', (token) => iface.Token(token));
|
||||||
daemon.on('Error', (code, message) => iface.Error(code, message));
|
daemon.on('Error', (code, message) => iface.Error(code, message));
|
||||||
daemon.on('ConfirmationRequired', (event) => iface.ConfirmationRequired(event?.tool || 'action', JSON.stringify(event?.args || {}), event?.pattern || 'explicit confirmation required'));
|
daemon.on('ConfirmationRequired', (event) => iface.ConfirmationRequired(event?.tool || 'action', JSON.stringify(event?.args || {}), event?.pattern || 'explicit confirmation required'));
|
||||||
for (const signal of ['WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight', 'Thinking', 'ToolCall', 'ToolResult']) {
|
for (const signal of ['ContextReset', 'WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight', 'Thinking', 'ToolCall', 'ToolResult']) {
|
||||||
daemon.on(signal, (...args) => iface[signal](...args));
|
daemon.on(signal, (...args) => iface[signal](...args));
|
||||||
}
|
}
|
||||||
return bus;
|
return bus;
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export class HarnessBridge extends EventEmitter {
|
|||||||
|
|
||||||
cancel() { this.session?.cancel(); }
|
cancel() { this.session?.cancel(); }
|
||||||
|
|
||||||
|
async resetContext() {
|
||||||
|
this.session?.cancel?.();
|
||||||
|
await this.session?.dispose?.();
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
await this.session?.dispose();
|
await this.session?.dispose();
|
||||||
releaseQvac();
|
releaseQvac();
|
||||||
|
|||||||
+19
-1
@@ -35,6 +35,7 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
this.locked = false;
|
this.locked = false;
|
||||||
this.lastReply = '';
|
this.lastReply = '';
|
||||||
this.voiceLoop = null;
|
this.voiceLoop = null;
|
||||||
|
this._activeAsk = null;
|
||||||
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
|
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
|
||||||
this._idleTimer.unref?.();
|
this._idleTimer.unref?.();
|
||||||
this.telemetry = new RuntimeTelemetry();
|
this.telemetry = new RuntimeTelemetry();
|
||||||
@@ -61,13 +62,30 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
this.voiceLoop?.interrupt?.();
|
this.voiceLoop?.interrupt?.();
|
||||||
try {
|
try {
|
||||||
this.voice.typedUtterance(); this.setState('THINKING');
|
this.voice.typedUtterance(); this.setState('THINKING');
|
||||||
const startedAt = Date.now(); const reply = await this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' }); this.telemetry.record('llm', startedAt, { success: true });
|
const startedAt = Date.now();
|
||||||
|
const job = this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' });
|
||||||
|
this._activeAsk = job;
|
||||||
|
const reply = await job;
|
||||||
|
this.telemetry.record('llm', startedAt, { success: true });
|
||||||
const spoken = spokenReply(reply);
|
const spoken = spokenReply(reply);
|
||||||
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
|
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
||||||
|
} finally {
|
||||||
|
this._activeAsk = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async resetContext() {
|
||||||
|
this.harness.cancel();
|
||||||
|
this.scheduler.cancelQueued((job) => job.lane === 'voice');
|
||||||
|
await this._activeAsk?.catch?.(() => {});
|
||||||
|
await this.harness.resetContext();
|
||||||
|
this.lastReply = '';
|
||||||
|
this.voiceLoop?.interrupt?.();
|
||||||
|
this.voice.cancel();
|
||||||
|
this.setState('ARMED');
|
||||||
|
this.emit('ContextReset');
|
||||||
|
}
|
||||||
_beginSpeech() {
|
_beginSpeech() {
|
||||||
try {
|
try {
|
||||||
if (this.voice.state === 'ARMED' || this.voice.state === 'SLEEPING') this.voice.wake();
|
if (this.voice.state === 'ARMED' || this.voice.state === 'SLEEPING') this.voice.wake();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<method name="PushToTalk"><arg name="pressed" type="b" direction="in"/></method>
|
<method name="PushToTalk"><arg name="pressed" type="b" direction="in"/></method>
|
||||||
<method name="Say"><arg name="text" type="s" direction="in"/></method>
|
<method name="Say"><arg name="text" type="s" direction="in"/></method>
|
||||||
<method name="Ask"><arg name="text" type="s" direction="in"/></method>
|
<method name="Ask"><arg name="text" type="s" direction="in"/></method>
|
||||||
|
<method name="ResetContext"/>
|
||||||
<method name="Cancel"/><method name="SetMode"><arg name="mode" type="s" direction="in"/></method>
|
<method name="Cancel"/><method name="SetMode"><arg name="mode" type="s" direction="in"/></method>
|
||||||
<method name="GetState"><arg type="s" direction="out"/></method>
|
<method name="GetState"><arg type="s" direction="out"/></method>
|
||||||
<method name="EnrollWake"><arg name="phrase" type="s" direction="in"/></method>
|
<method name="EnrollWake"><arg name="phrase" type="s" direction="in"/></method>
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
<method name="DownloadModel"><arg name="model" type="s" direction="in"/><arg type="s" direction="out"/></method>
|
<method name="DownloadModel"><arg name="model" type="s" direction="in"/><arg type="s" direction="out"/></method>
|
||||||
<method name="CancelModel"><arg name="model" type="s" direction="in"/><arg type="s" direction="out"/></method>
|
<method name="CancelModel"><arg name="model" type="s" direction="in"/><arg type="s" direction="out"/></method>
|
||||||
<method name="WipeComputerTraces"><arg type="b" direction="out"/></method>
|
<method name="WipeComputerTraces"><arg type="b" direction="out"/></method>
|
||||||
<signal name="StateChanged"><arg type="s"/></signal><signal name="WakeHeard"><arg type="s"/></signal>
|
<signal name="StateChanged"><arg type="s"/></signal><signal name="ContextReset"/><signal name="WakeHeard"><arg type="s"/></signal>
|
||||||
<signal name="PartialTranscript"><arg type="s"/></signal><signal name="FinalTranscript"><arg type="s"/></signal>
|
<signal name="PartialTranscript"><arg type="s"/></signal><signal name="FinalTranscript"><arg type="s"/></signal>
|
||||||
<signal name="Token"><arg type="s"/></signal><signal name="Reply"><arg type="s"/></signal>
|
<signal name="Token"><arg type="s"/></signal><signal name="Reply"><arg type="s"/></signal>
|
||||||
<signal name="SpeakingLevel"><arg type="d"/></signal><signal name="ListeningLevel"><arg type="d"/></signal>
|
<signal name="SpeakingLevel"><arg type="d"/></signal><signal name="ListeningLevel"><arg type="d"/></signal>
|
||||||
|
|||||||
@@ -68,6 +68,19 @@ test('harness bridge recovers streamed text when final envelope is empty after a
|
|||||||
assert.equal(reply.text, 'Your computer is ready.');
|
assert.equal(reply.text, 'Your computer is ready.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('harness bridge resetContext disposes the current conversation', async () => {
|
||||||
|
let cancelled = false; let disposed = false;
|
||||||
|
const bridge = Object.create(HarnessBridge.prototype);
|
||||||
|
bridge.session = {
|
||||||
|
cancel() { cancelled = true; },
|
||||||
|
async dispose() { disposed = true; },
|
||||||
|
};
|
||||||
|
await bridge.resetContext();
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
assert.equal(disposed, true);
|
||||||
|
assert.equal(bridge.session, null);
|
||||||
|
});
|
||||||
|
|
||||||
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
|
||||||
const scheduler = new QvacScheduler();
|
const scheduler = new QvacScheduler();
|
||||||
const order = [];
|
const order = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user