@@ -29,6 +29,7 @@ const coerceText = (value) => {
|
||||
return fallback === '[object Object]' ? '' : fallback;
|
||||
};
|
||||
const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
|
||||
const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140);
|
||||
|
||||
class JarvisProxy {
|
||||
async connect() {
|
||||
@@ -66,29 +67,42 @@ class ArcOverlay {
|
||||
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
|
||||
this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status);
|
||||
this.statusLine = new St.Label({ text: 'Super+Shift+J · Hold Talk to speak', style_class: 'jarvis-status-line' });
|
||||
this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true });
|
||||
this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true, x_align: Clutter.ActorAlign.START });
|
||||
this.thinkingToggle.accessible_name = 'Show or hide Jarvis thinking';
|
||||
this.thinkingToggle.connect('clicked', () => this.toggleThinking());
|
||||
this.thinking = new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true });
|
||||
this.thinking.accessible_name = 'Jarvis thinking';
|
||||
if (this.thinking.clutter_text) { this.thinking.clutter_text.line_wrap = true; this.thinking.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; }
|
||||
this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true });
|
||||
this.notice.accessible_name = 'Jarvis notice';
|
||||
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true, visible: false }); this.job.accessible_name = 'Jarvis job progress';
|
||||
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true, visible: false }); this.target.accessible_name = 'Computer use target';
|
||||
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false, visible: false }); this.cursor.accessible_name = 'Visible computer use cursor';
|
||||
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true, y_expand: true });
|
||||
this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true });
|
||||
this.confirmLabel = new St.Label({ text: '', style_class: 'jarvis-confirm-label', x_expand: true });
|
||||
this.confirm.add_child(this.confirmLabel);
|
||||
for (const [label, decision] of [['Allow', 'allow'], ['Deny', 'deny']]) {
|
||||
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
|
||||
button.connect('clicked', () => this._answerConfirm(decision));
|
||||
this.confirm.add_child(button);
|
||||
}
|
||||
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true });
|
||||
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
|
||||
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript';
|
||||
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
|
||||
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave', visible: false }); this.bars = [];
|
||||
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
|
||||
this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true });
|
||||
try { this.chipScroll.vscrollbar_policy = St.PolicyType.NEVER; this.chipScroll.hscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
|
||||
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
|
||||
if (typeof this.chipScroll.set_child === 'function') this.chipScroll.set_child(this.chips); else this.chipScroll.add_child(this.chips);
|
||||
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
|
||||
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
|
||||
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
|
||||
}
|
||||
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.thinkingToggle); this.root.add_child(this.thinking); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chips);
|
||||
this.controls = new St.BoxLayout({ style_class: 'jarvis-chips' });
|
||||
this.talk = new St.Button({ label: 'Talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
|
||||
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.thinkingToggle); this.root.add_child(this.thinking); this.root.add_child(this.notice); this.root.add_child(this.confirm); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chipScroll);
|
||||
this.controls = new St.BoxLayout({ style_class: 'jarvis-controls' });
|
||||
this.talk = new St.Button({ label: 'Hold to talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
|
||||
this.talk.accessible_name = 'Hold Space or Enter to talk';
|
||||
this.talk.connect('key-press-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(true); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
|
||||
this.talk.connect('key-release-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(false); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
|
||||
@@ -98,7 +112,7 @@ class ArcOverlay {
|
||||
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
|
||||
this.controls.add_child(this.talk);
|
||||
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 jarvis-chip-quiet', reactive: true, can_focus: true });
|
||||
button.connect('clicked', action); this.controls.add_child(button);
|
||||
}
|
||||
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
|
||||
@@ -107,25 +121,31 @@ class ArcOverlay {
|
||||
this.reducedMotion = false;
|
||||
this.streamingReply = false;
|
||||
this.replyFinalized = false;
|
||||
this.minimized = false;
|
||||
this._state = 'ARMED';
|
||||
}
|
||||
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); }
|
||||
show(force = false) {
|
||||
if (this.minimized && !force) return;
|
||||
this.minimized = false;
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (monitor) {
|
||||
const width = Math.min(OVERLAY_WIDTH, monitor.width - 80);
|
||||
this.root.set_width(width);
|
||||
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 48);
|
||||
this.scroll.set_height(Math.max(100, Math.min(280, monitor.height - 360)));
|
||||
this.scroll.set_height(Math.max(80, Math.min(220, monitor.height - 420)));
|
||||
}
|
||||
const wasVisible = this.root.visible;
|
||||
this.root.visible = true;
|
||||
this.wave.visible = this._state === 'LISTENING' || this._state === 'SPEAKING';
|
||||
if (!wasVisible) this.entry.grab_key_focus();
|
||||
}
|
||||
hide() { this.onTalk?.(false); this.root.visible = false; this.minimized = false; }
|
||||
minimize() { this.onTalk?.(false); this.root.visible = false; this.minimized = true; }
|
||||
_dismiss() { this.onTalk?.(false); this.root.visible = false; this.minimized = true; this.halo?.hide(); }
|
||||
hide() { this._dismiss(); }
|
||||
minimize() { this._dismiss(); }
|
||||
toggle() { this.root.visible ? this.hide() : this.show(true); }
|
||||
clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; this.streamingReply = false; this.replyFinalized = false; }
|
||||
clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; this.notice.visible = false; this.confirm.visible = false; this.streamingReply = false; this.replyFinalized = false; }
|
||||
setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); }
|
||||
_followConversation() {
|
||||
const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment;
|
||||
if (!adjustment) return;
|
||||
@@ -134,22 +154,31 @@ class ArcOverlay {
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this.show(); this._followConversation(); return row; }
|
||||
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis')) { row = this.addRow('J', ''); this.streamingReply = true; this.replyFinalized = false; } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); this._followConversation(); }
|
||||
updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; this.thinkingToggle.visible = true; this.thinking.visible = true; this.thinkingToggle.label = 'Thinking ▾'; this.show(); }
|
||||
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this._followConversation(); return row; }
|
||||
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis') || String(row.style_class || '').includes('jarvis-row-tool')) { row = this.addRow('J', ''); this.streamingReply = true; this.replyFinalized = false; } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this._followConversation(); }
|
||||
updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▾'; }
|
||||
toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; }
|
||||
finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } }
|
||||
addToolCall(json) { this.streamingReply = false; try { const call = JSON.parse(json); this.addRow('J', `Using ${safeText(call.name || 'tool')}…`); } catch { this.addRow('J', `Using ${safeText(json)}…`); } this.finishThinking(); }
|
||||
addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); this.addRow('J', `${safeText(result.name || 'tool')} complete`); } catch { this.addRow('J', 'Tool complete'); } this.finishThinking(); }
|
||||
addToolCall(json) { this.streamingReply = false; this.replyFinalized = false; try { const call = JSON.parse(json); this._addToolRow(`Using ${safeText(call.name || 'tool')}…`); } catch { this._addToolRow(`Using ${safeText(json)}…`); } }
|
||||
addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); this._addToolRow(`${safeText(result.name || 'tool')} complete`); } catch { this._addToolRow('Tool complete'); } }
|
||||
_addToolRow(text) { const row = this.addRow('J', text); row.style_class = `${row.style_class} jarvis-row-tool`; }
|
||||
offerConfirm(tool, argsJson, pattern) {
|
||||
let jobId = ''; let toolCallId = '';
|
||||
try { const parsed = JSON.parse(argsJson); jobId = parsed.jobId || ''; toolCallId = parsed.toolCallId || ''; } catch {}
|
||||
this._confirmJob = jobId; this._confirmCall = toolCallId;
|
||||
this.confirmLabel.text = `Allow ${safeText(tool)}? ${safeText(pattern)}`.trim();
|
||||
this.confirm.visible = true;
|
||||
}
|
||||
_answerConfirm(decision) { this.confirm.visible = false; this.onConfirm?.(this._confirmJob || '', this._confirmCall || '', decision); }
|
||||
finalizeReply(text) {
|
||||
this.finishThinking();
|
||||
const spoken = safeText(text);
|
||||
if (this.replyFinalized) return;
|
||||
let row = this.transcript.get_last_child?.();
|
||||
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis')) {
|
||||
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) {
|
||||
const body = String(row.text || '').replace(/^J\s+/, '');
|
||||
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
|
||||
this.streamingReply = false; this.replyFinalized = true; this.show(); return;
|
||||
this.streamingReply = false; this.replyFinalized = true; return;
|
||||
}
|
||||
if (spoken) this.addRow('J', spoken);
|
||||
this.replyFinalized = true;
|
||||
@@ -161,18 +190,19 @@ class ArcOverlay {
|
||||
}
|
||||
setState(state) {
|
||||
const value = STATES.has(state) ? state : 'ARMED';
|
||||
this._state = value;
|
||||
this.statusLine.text = `${value.toLowerCase()} · Super+Shift+J · Hold Talk to speak`;
|
||||
this.title.text = `${value === 'SLEEPING' ? '⧸' : '◉'} JARVIS`;
|
||||
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
|
||||
this.wave.visible = value === 'LISTENING' || value === 'SPEAKING';
|
||||
if (value === 'LISTENING') this.showHalo(); if (value !== 'ARMED') this.show();
|
||||
this.wave.visible = !this.minimized && (value === 'LISTENING' || value === 'SPEAKING');
|
||||
if (value === 'LISTENING') this.showHalo();
|
||||
}
|
||||
level(rms) { this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
|
||||
level(rms) { if (this.minimized) return; this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
|
||||
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
|
||||
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
|
||||
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this.show(); }
|
||||
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this.show(); }
|
||||
showHalo() { if (!this.halo) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
|
||||
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; }
|
||||
setTarget(json) { this.target.text = `⌾ ${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; }
|
||||
showHalo() { if (!this.halo || this.minimized || !this.root.visible) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
|
||||
destroy() { if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = 0; this.halo?.destroy(); this.halo = null; this.root.destroy(); }
|
||||
}
|
||||
|
||||
@@ -186,10 +216,14 @@ export default class JarvisExtension extends Extension {
|
||||
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.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.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._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.overlay.onStop = () => this._call('Cancel');
|
||||
this.overlay.onReset = () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); 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.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
|
||||
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.show(true)); } 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(true);
|
||||
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
|
||||
}
|
||||
async _connectDaemon() {
|
||||
@@ -198,21 +232,22 @@ export default class JarvisExtension extends Extension {
|
||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||
this._signals = [
|
||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||
['ContextReset', () => this.overlay.clear()],
|
||||
['WakeHeard', () => this.overlay.show()],
|
||||
['ContextReset', () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); }],
|
||||
['WakeHeard', () => {}],
|
||||
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
||||
['Token', (text) => this.overlay.token(text)],
|
||||
['Thinking', (text) => this.overlay.updateThinking(text)],
|
||||
['ToolCall', (json) => this.overlay.addToolCall(json)],
|
||||
['ToolResult', (json) => this.overlay.addToolResult(json)],
|
||||
['Reply', (text) => { this.overlay.finalizeReply(text); this.overlay.show(); }],
|
||||
['Reply', (text) => this.overlay.finalizeReply(text)],
|
||||
['SpeakingLevel', (rms) => this.overlay.level(rms)],
|
||||
['ListeningLevel', (rms) => this.overlay.level(rms)],
|
||||
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
|
||||
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
|
||||
['ComputerStep', (json) => this.overlay.addStep(json)],
|
||||
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
|
||||
['Error', (code, message) => { this.overlay.addRow('J', message); this.overlay.finishThinking(); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
|
||||
['ConfirmationRequired', (tool, args, pattern) => this.overlay.offerConfirm(tool, args, pattern)],
|
||||
['Error', (code, message) => { this.overlay.setNotice(message); this.overlay.finishThinking(); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
|
||||
].map(([name, handler]) => proxy.on(name, handler));
|
||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||
await this._syncDaemon();
|
||||
@@ -244,10 +279,16 @@ export default class JarvisExtension extends Extension {
|
||||
}
|
||||
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
|
||||
}
|
||||
_call(name, signature, value) { this.proxy.call(name, signature, value).catch((error) => { log(`Jarvis ${name}: ${error.message}`); this.overlay?.addRow('J', `${name} failed: ${error.message}`); }); }
|
||||
_call(name, signature, value) {
|
||||
this.proxy.call(name, signature, value).catch((error) => {
|
||||
log(`Jarvis ${name}: ${error.message}`);
|
||||
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
||||
this.overlay?.setNotice(fallback);
|
||||
});
|
||||
}
|
||||
_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')};`); }
|
||||
_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')], ['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(); }
|
||||
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show(true)], ['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; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,108 @@
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
import Adw from 'gi://Adw';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gtk from 'gi://Gtk';
|
||||
|
||||
const BIND = Gio.SettingsBindFlags.DEFAULT;
|
||||
const PRIVACY_MODES = ['full-listen-after-wake', 'wake-only', 'off'];
|
||||
const OVERLAY_STYLES = ['arc', 'compact'];
|
||||
const COMPUTER_MODES = ['off', 'observe', 'act'];
|
||||
const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu'];
|
||||
|
||||
function persistDaemonConfig(settings) {
|
||||
const dir = GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis']);
|
||||
GLib.mkdir_with_parents(dir, 0o755);
|
||||
const file = Gio.File.new_for_path(GLib.build_filenamev([dir, 'config.json']));
|
||||
let config = {};
|
||||
try {
|
||||
const [, contents] = file.load_contents(null);
|
||||
const text = typeof contents === 'string' ? contents : new TextDecoder().decode(contents);
|
||||
config = JSON.parse(text);
|
||||
} catch {}
|
||||
config.wakePhrase = settings.get_string('wake-phrase');
|
||||
config.aliases = settings.get_strv('aliases');
|
||||
config.ttsEnabled = settings.get_boolean('tts-enabled');
|
||||
config.modelProfile = settings.get_string('model-profile');
|
||||
file.replace_contents(`${JSON.stringify(config, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
|
||||
}
|
||||
|
||||
function entryRow(settings, title, key) {
|
||||
const row = new Adw.EntryRow({ title });
|
||||
row.set_text(settings.get_string(key));
|
||||
row.connect('changed', () => settings.set_string(key, row.get_text()));
|
||||
return row;
|
||||
}
|
||||
|
||||
function strvRow(settings, title, key) {
|
||||
const row = new Adw.EntryRow({ title });
|
||||
row.set_text(settings.get_strv(key).join(', '));
|
||||
row.connect('changed', () => {
|
||||
const values = row.get_text().split(',').map((item) => item.trim()).filter(Boolean);
|
||||
settings.set_strv(key, values);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function switchRow(settings, title, subtitle, key) {
|
||||
const row = new Adw.SwitchRow({ title, subtitle });
|
||||
settings.bind(key, row, 'active', BIND);
|
||||
return row;
|
||||
}
|
||||
|
||||
function comboRow(settings, title, subtitle, key, values) {
|
||||
const row = new Adw.ComboRow({ title, subtitle, model: Gtk.StringList.new(values) });
|
||||
const current = settings.get_string(key);
|
||||
row.selected = Math.max(0, values.indexOf(current));
|
||||
row.connect('notify::selected', () => {
|
||||
const value = values[row.selected];
|
||||
if (value) settings.set_string(key, value);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
export default class JarvisPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
window.set_title('Jarvis QVAC');
|
||||
const page = new Adw.PreferencesPage();
|
||||
const group = new Adw.PreferencesGroup({ title: 'Using Jarvis' });
|
||||
const settings = this.getSettings();
|
||||
group.add(new Adw.ActionRow({ title: 'Open the assistant', subtitle: settings.get_strv('hotkey').join(', ') }));
|
||||
group.add(new Adw.ActionRow({ title: 'Voice and text', subtitle: 'Click Jarvis in the top panel, then hold Talk to speak, or type a question and press Enter.' }));
|
||||
group.add(new Adw.ActionRow({ title: 'Wake phrase setup', subtitle: 'Hey Jarvis requires a local wake detector configured with JARVIS_WAKE_COMMAND. Use Talk when no detector is installed.' }));
|
||||
page.add(group); window.add(page);
|
||||
const voice = new Adw.PreferencesPage({ title: 'Voice', name: 'voice' });
|
||||
const voiceGroup = new Adw.PreferencesGroup({ title: 'Speech and wake' });
|
||||
voiceGroup.add(switchRow(settings, 'Spoken replies', 'Play Jarvis replies through local TTS', 'tts-enabled'));
|
||||
voiceGroup.add(switchRow(settings, 'Wake chime', 'Play a short chime when Jarvis starts listening', 'chime-enabled'));
|
||||
voiceGroup.add(entryRow(settings, 'Wake phrase', 'wake-phrase'));
|
||||
voiceGroup.add(strvRow(settings, 'Wake aliases', 'aliases'));
|
||||
voiceGroup.add(entryRow(settings, 'Voice id', 'voice-id'));
|
||||
voiceGroup.add(entryRow(settings, 'Language', 'language'));
|
||||
voice.add(voiceGroup);
|
||||
|
||||
const desktop = new Adw.PreferencesPage({ title: 'Desktop', name: 'desktop' });
|
||||
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts' });
|
||||
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
|
||||
desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color'));
|
||||
desktopGroup.add(comboRow(settings, 'Overlay style', 'Layout of the on-screen HUD', 'overlay-style', OVERLAY_STYLES));
|
||||
desktopGroup.add(switchRow(settings, 'Confirm destructive actions', 'Ask before write, delete, or computer-use changes', 'confirm-destructive'));
|
||||
desktop.add(desktopGroup);
|
||||
|
||||
const computer = new Adw.PreferencesPage({ title: 'Computer use', name: 'computer' });
|
||||
const computerGroup = new Adw.PreferencesGroup({ title: 'Desktop control' });
|
||||
computerGroup.add(comboRow(settings, 'Computer use mode', 'Observe is read-only; act can click and type after a grant', 'computer-use-mode', COMPUTER_MODES));
|
||||
computerGroup.add(switchRow(settings, 'Legacy input', 'Use the older input backend when portals are unavailable', 'computer-use-legacy-input'));
|
||||
computerGroup.add(comboRow(settings, 'Privacy mode', 'How long Jarvis keeps the microphone open after wake', 'privacy-mode', PRIVACY_MODES));
|
||||
computer.add(computerGroup);
|
||||
|
||||
const models = new Adw.PreferencesPage({ title: 'Models', name: 'models' });
|
||||
const modelGroup = new Adw.PreferencesGroup({ title: 'Local profile' });
|
||||
modelGroup.add(comboRow(settings, 'Model profile', 'Restart jarvisd after changing the QVAC profile', 'model-profile', MODEL_PROFILES));
|
||||
models.add(modelGroup);
|
||||
|
||||
window.add(voice);
|
||||
window.add(desktop);
|
||||
window.add(computer);
|
||||
window.add(models);
|
||||
|
||||
persistDaemonConfig(settings);
|
||||
for (const key of ['wake-phrase', 'aliases', 'tts-enabled', 'model-profile']) {
|
||||
settings.connect(`changed::${key}`, () => persistDaemonConfig(settings));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
.jarvis-panel-glyph { color: #F4B942; font-size: 16px; }
|
||||
.jarvis-arc { width: 720px; padding: 24px; margin-top: 0; margin-left: 0; margin-right: 0; spacing: 14px; border-radius: 22px; background-color: rgba(11, 14, 20, .92); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
|
||||
.jarvis-arc { width: 720px; padding: 18px 20px; margin-top: 0; margin-left: 0; margin-right: 0; spacing: 8px; border-radius: 22px; background-color: rgba(11, 14, 20, .92); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
|
||||
.jarvis-arc-header { spacing: 8px; }
|
||||
.jarvis-title { font-weight: bold; letter-spacing: 1px; }
|
||||
.jarvis-title { font-weight: bold; letter-spacing: 1px; font-size: 18px; }
|
||||
.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
|
||||
.jarvis-status-line { color: #aeb6c8; font-size: 12px; }
|
||||
.jarvis-thinking-toggle { color: #9aa8c0; font-size: 12px; padding: 4px 8px; border-radius: 8px; background-color: rgba(255, 255, 255, .05); }
|
||||
.jarvis-thinking-toggle { color: #9aa8c0; font-size: 11px; padding: 2px 8px; border-radius: 8px; background-color: transparent; }
|
||||
.jarvis-thinking-toggle:hover, .jarvis-thinking-toggle:focus { color: #f6f7fb; background-color: rgba(79, 210, 255, .16); }
|
||||
.jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 8px 12px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; }
|
||||
.jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 6px 10px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; }
|
||||
.jarvis-notice { color: #f4b942; font-size: 12px; }
|
||||
.jarvis-confirm { spacing: 8px; padding: 6px 0; }
|
||||
.jarvis-confirm-label { color: #f6f7fb; font-size: 13px; }
|
||||
.jarvis-job, .jarvis-target { color: #4FD2FF; font-size: 12px; }
|
||||
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
|
||||
.jarvis-halo { border-top: 2px solid #F4B942; margin: 12px; }
|
||||
.jarvis-high-contrast { border-width: 2px; background-color: #000; }
|
||||
.jarvis-wave { height: 42px; spacing: 3px; }
|
||||
.jarvis-wave { height: 36px; spacing: 3px; }
|
||||
.jarvis-wave-bar { width: 7px; background-color: var(--jarvis-accent, #F4B942); border-radius: 4px; }
|
||||
.jarvis-transcript-scroll { height: 280px; }
|
||||
.jarvis-transcript { spacing: 7px; }
|
||||
.jarvis-row { padding: 12px 14px; border-radius: 8px; font-size: 14px; }
|
||||
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); }
|
||||
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; }
|
||||
.jarvis-transcript-scroll { height: 220px; }
|
||||
.jarvis-transcript { spacing: 6px; }
|
||||
.jarvis-row { padding: 8px 12px; border-radius: 8px; font-size: 14px; }
|
||||
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); background-color: rgba(244, 185, 66, .08); }
|
||||
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .05); }
|
||||
.jarvis-row-tool { background-color: transparent; color: #9aa8c0; font-size: 12px; padding: 4px 8px; }
|
||||
.jarvis-chip-scroll { height: 36px; }
|
||||
.jarvis-chips { spacing: 6px; }
|
||||
.jarvis-chip { padding: 5px 9px; border-radius: 999px; background-color: rgba(255, 255, 255, .08); }
|
||||
.jarvis-chip:hover, .jarvis-chip:focus { background-color: rgba(244, 185, 66, .25); }
|
||||
|
||||
.jarvis-title { font-size: 18px; }
|
||||
.jarvis-row-user { background-color: rgba(244, 185, 66, .08); }
|
||||
.jarvis-row-jarvis { background-color: rgba(79, 210, 255, .05); }
|
||||
.jarvis-chip-quiet { background-color: transparent; color: #aeb6c8; padding: 5px 8px; }
|
||||
.jarvis-chip-quiet:hover, .jarvis-chip-quiet:focus { color: #f6f7fb; background-color: rgba(255, 255, 255, .08); }
|
||||
.jarvis-talk { background-color: #F4B942; color: #16191f; font-weight: bold; padding: 9px 18px; }
|
||||
.jarvis-talk:active { background-color: #ffe09a; }
|
||||
.jarvis-arc StEntry { border-radius: 12px; padding: 12px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
|
||||
.jarvis-controls { spacing: 8px; }
|
||||
.jarvis-arc StEntry { border-radius: 12px; padding: 10px 12px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
|
||||
.jarvis-arc StEntry:focus { border-color: #F4B942; }
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function serveOnSessionBus(daemon) {
|
||||
async Say(text) { await daemon.say?.(text); }
|
||||
async Ask(text) { await daemon.ask(text); }
|
||||
async ResetContext() { await daemon.resetContext(); }
|
||||
Confirm(jobId, toolCallId, decision) { daemon.confirmPermission?.(jobId, toolCallId, decision); }
|
||||
Cancel() { daemon.cancel(); }
|
||||
SetMode(mode) { daemon.mode = mode; daemon.emit('ModeChanged', mode); }
|
||||
GetState() { return daemon.state; }
|
||||
@@ -42,6 +43,7 @@ export async function serveOnSessionBus(daemon) {
|
||||
// private $emitter. Returning the payload is sufficient; calling
|
||||
// `this.emit` here crashes because Interface does not expose that method.
|
||||
ConfirmationRequired(tool, args, pattern) { return [String(tool), String(args), String(pattern)]; }
|
||||
ContextReset() { return; }
|
||||
StateChanged(state) { return String(state); }
|
||||
Reply(text) { return String(text); }
|
||||
Token(text) { return String(text); }
|
||||
@@ -68,6 +70,7 @@ export async function serveOnSessionBus(daemon) {
|
||||
Say: { inSignature: 's', outSignature: '', method: 'Say' },
|
||||
Ask: { inSignature: 's', outSignature: '', method: 'Ask' },
|
||||
ResetContext: { inSignature: '', outSignature: '', method: 'ResetContext' },
|
||||
Confirm: { inSignature: 'sss', outSignature: '', method: 'Confirm' },
|
||||
Cancel: { inSignature: '', outSignature: '', method: 'Cancel' },
|
||||
SetMode: { inSignature: 's', outSignature: '', method: 'SetMode' },
|
||||
GetState: { inSignature: '', outSignature: 's', method: 'GetState' },
|
||||
@@ -141,7 +144,7 @@ export async function serveOnSessionBus(daemon) {
|
||||
daemon.on('Reply', (reply) => iface.Reply(reply));
|
||||
daemon.on('Token', (token) => iface.Token(token));
|
||||
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({ jobId: event?.jobId || '', toolCallId: event?.toolCallId || '', args: event?.args || {} }), event?.pattern || 'explicit confirmation required'));
|
||||
for (const signal of ['ContextReset', 'WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight', 'Thinking', 'ToolCall', 'ToolResult']) {
|
||||
daemon.on(signal, (...args) => iface[signal](...args));
|
||||
}
|
||||
|
||||
@@ -46,15 +46,22 @@ export class HarnessBridge extends EventEmitter {
|
||||
// omit it from the final envelope after a tool call. Keep the current
|
||||
// post-tool stream as a fallback so the daemon can still speak the reply.
|
||||
let streamed = '';
|
||||
const onChunk = (payload) => { streamed += String(payload?.text || payload?.delta || ''); };
|
||||
const onToolCall = () => { streamed = ''; };
|
||||
let postTool = '';
|
||||
let sawTool = false;
|
||||
const onChunk = (payload) => {
|
||||
const chunk = String(payload?.text || payload?.delta || '');
|
||||
streamed += chunk;
|
||||
if (sawTool) postTool += chunk;
|
||||
};
|
||||
const onToolCall = () => { sawTool = true; postTool = ''; streamed = ''; };
|
||||
const chunkSubscription = this.session.on('agent_message_chunk', onChunk);
|
||||
const toolSubscription = this.session.on('tool_call', onToolCall);
|
||||
const offChunk = typeof chunkSubscription === 'function' ? chunkSubscription : () => this.session.off?.('agent_message_chunk', onChunk);
|
||||
const offToolCall = typeof toolSubscription === 'function' ? toolSubscription : () => this.session.off?.('tool_call', onToolCall);
|
||||
try {
|
||||
const reply = await this.session.prompt(text);
|
||||
if (reply && !reply.text && streamed.trim()) return { ...reply, text: streamed };
|
||||
const spoken = String(reply?.text || '').trim() || postTool.trim() || streamed.trim();
|
||||
if (reply && spoken && spoken !== String(reply.text || '').trim()) return { ...reply, text: spoken };
|
||||
return reply;
|
||||
} finally {
|
||||
offChunk?.();
|
||||
|
||||
@@ -68,6 +68,7 @@ export class JarvisDaemon extends EventEmitter {
|
||||
const reply = await job;
|
||||
this.telemetry.record('llm', startedAt, { success: true });
|
||||
const spoken = spokenReply(reply);
|
||||
if (!spoken) { this.setState('LISTENING'); return ''; }
|
||||
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
|
||||
} catch (error) {
|
||||
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
||||
@@ -86,6 +87,9 @@ export class JarvisDaemon extends EventEmitter {
|
||||
this.setState('ARMED');
|
||||
this.emit('ContextReset');
|
||||
}
|
||||
confirmPermission(jobId, toolCallId, decision) {
|
||||
this.harness.session?.permit?.(String(jobId || ''), String(toolCallId || ''), String(decision || 'deny'));
|
||||
}
|
||||
_beginSpeech() {
|
||||
try {
|
||||
if (this.voice.state === 'ARMED' || this.voice.state === 'SLEEPING') this.voice.wake();
|
||||
|
||||
@@ -5,9 +5,12 @@ import os from 'node:os';
|
||||
export function voiceSettings() {
|
||||
let config = {};
|
||||
try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {}
|
||||
const phrase = config.wakePhrase || config.wake_phrase || 'hey jarvis';
|
||||
const aliases = Array.isArray(config.aliases) ? config.aliases : ['jarvis', 'okay jarvis'];
|
||||
return {
|
||||
command: process.env.JARVIS_WAKE_COMMAND || config.wake_command || '',
|
||||
phrases: [config.wake_phrase || 'hey jarvis', 'jarvis', 'okay jarvis'],
|
||||
ttsEnabled: config.tts_enabled !== false,
|
||||
command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '',
|
||||
phrases: [phrase, ...aliases].map((item) => String(item || '').trim()).filter(Boolean),
|
||||
ttsEnabled: config.ttsEnabled !== false && config.tts_enabled !== false,
|
||||
modelProfile: config.modelProfile || config.model_profile || 'laptop-16gb',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<method name="Say"><arg name="text" type="s" direction="in"/></method>
|
||||
<method name="Ask"><arg name="text" type="s" direction="in"/></method>
|
||||
<method name="ResetContext"/>
|
||||
<method name="Confirm"><arg name="jobId" type="s" direction="in"/><arg name="toolCallId" type="s" direction="in"/><arg name="decision" 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="EnrollWake"><arg name="phrase" type="s" direction="in"/></method>
|
||||
|
||||
@@ -68,6 +68,34 @@ test('harness bridge recovers streamed text when final envelope is empty after a
|
||||
assert.equal(reply.text, 'Your computer is ready.');
|
||||
});
|
||||
|
||||
test('ask with no spoken text after tools returns to listening without a Reply', async () => {
|
||||
const daemon = new JarvisDaemon();
|
||||
daemon.harness = { ask: async () => ({ ok: true, text: '', reason: 'stop' }), cancel() {}, close: async () => {} };
|
||||
daemon.voiceLoop = null;
|
||||
const replies = [];
|
||||
daemon.on('Reply', (text) => replies.push(text));
|
||||
try {
|
||||
const result = await daemon.ask('status');
|
||||
assert.equal(result, '');
|
||||
assert.deepEqual(replies, []);
|
||||
assert.equal(daemon.state, 'LISTENING');
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('confirmPermission forwards Allow/Deny to the harness session', async () => {
|
||||
const daemon = new JarvisDaemon();
|
||||
const calls = [];
|
||||
daemon.harness = { session: { permit(...args) { calls.push(args); } }, cancel() {}, close: async () => {} };
|
||||
try {
|
||||
daemon.confirmPermission('job-1', 'call-9', 'allow');
|
||||
assert.deepEqual(calls, [['job-1', 'call-9', 'allow']]);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('harness bridge resetContext disposes the current conversation', async () => {
|
||||
let cancelled = false; let disposed = false;
|
||||
const bridge = Object.create(HarnessBridge.prototype);
|
||||
|
||||
@@ -24,9 +24,12 @@ function harness() {
|
||||
show() { this.visible = true; }
|
||||
destroy() { this.destroyed = true; }
|
||||
set_position() {}
|
||||
set_width() {}
|
||||
set_height() {}
|
||||
grab_key_focus() {}
|
||||
set_style() {}
|
||||
add_style_class_name() {}
|
||||
get_first_child() { return this.children[0] || null; }
|
||||
}
|
||||
const context = vm.createContext({
|
||||
Extension: class {},
|
||||
@@ -34,9 +37,10 @@ function harness() {
|
||||
St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } },
|
||||
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0 },
|
||||
Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } },
|
||||
Main: { layoutManager: { addChrome() {} } },
|
||||
Main: { layoutManager: { addChrome() {}, primaryMonitor: { x: 0, y: 0, width: 1920, height: 1080 } } },
|
||||
GLib: {
|
||||
PRIORITY_DEFAULT: 0, SOURCE_REMOVE: false,
|
||||
PRIORITY_DEFAULT: 0, PRIORITY_DEFAULT_IDLE: 200, SOURCE_REMOVE: false,
|
||||
idle_add(_priority, callback) { callback(); return 1; },
|
||||
timeout_add(_priority, _delay, callback) { const id = timers.size + 1; timers.set(id, callback); return id; },
|
||||
Source: { remove(id) { timers.delete(id); } },
|
||||
},
|
||||
@@ -52,6 +56,7 @@ test('overlay constructs and destroys with pending halo animation', () => {
|
||||
const overlay = new ArcOverlay();
|
||||
assert.equal(overlay.header.children[1].x_expand, true);
|
||||
overlay.attach();
|
||||
overlay.show(true);
|
||||
overlay.showHalo();
|
||||
overlay.showHalo();
|
||||
assert.equal(timers.size, 1);
|
||||
@@ -89,6 +94,18 @@ test('final reply is shown after tool result rows', () => {
|
||||
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
||||
});
|
||||
|
||||
test('tokens after a tool result start a new spoken Jarvis row', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
overlay.addToolCall('{"name":"capability_status"}');
|
||||
overlay.addToolResult('{"name":"capability_status"}');
|
||||
overlay.token('Your computer is ready.');
|
||||
overlay.finalizeReply('Your computer is ready.');
|
||||
assert.equal(overlay.transcript.children.length, 3);
|
||||
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
||||
assert.doesNotMatch(overlay.transcript.children[2].style_class, /jarvis-row-tool/);
|
||||
});
|
||||
|
||||
test('addRow and token coerce objects to readable text', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
@@ -130,4 +147,55 @@ test('D-Bus confirmation signal does not call missing Interface.emit', () => {
|
||||
const dbusSource = readFileSync(new URL('../daemon/dbus-service.js', import.meta.url), 'utf8');
|
||||
assert.match(dbusSource, /ConfirmationRequired\(tool, args, pattern\) \{ return \[String\(tool\)/);
|
||||
assert.doesNotMatch(dbusSource, /ConfirmationRequired\(tool, args, pattern\) \{ this\.emit/);
|
||||
assert.match(dbusSource, /ContextReset\(\) \{ return; \}/);
|
||||
assert.match(dbusSource, /Confirm: \{ inSignature: 'sss'/);
|
||||
});
|
||||
|
||||
test('minimize stays closed while Jarvis speaks, then restore shows the transcript', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
overlay.attach();
|
||||
overlay.show(true);
|
||||
overlay.minimize();
|
||||
overlay.setState('SPEAKING');
|
||||
overlay.finalizeReply('I am still talking in the background.');
|
||||
assert.equal(overlay.root.visible, false);
|
||||
overlay.show(true);
|
||||
assert.equal(overlay.root.visible, true);
|
||||
assert.match(overlay.transcript.children[0].text, /still talking/);
|
||||
});
|
||||
|
||||
test('ConfirmationRequired chips answer Confirm with job and tool ids', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
const answers = [];
|
||||
overlay.onConfirm = (...args) => answers.push(args);
|
||||
overlay.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
|
||||
assert.equal(overlay.confirm.visible, true);
|
||||
overlay._answerConfirm('allow');
|
||||
assert.equal(overlay.confirm.visible, false);
|
||||
assert.deepEqual(answers, [['job-1', 'call-9', 'allow']]);
|
||||
});
|
||||
|
||||
test('errors and reset failures are one-line notices, not chat rows', () => {
|
||||
const { ArcOverlay } = harness();
|
||||
const overlay = new ArcOverlay();
|
||||
overlay.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
|
||||
assert.equal(overlay.transcript.children.length, 0);
|
||||
assert.equal(overlay.notice.visible, true);
|
||||
assert.doesNotMatch(overlay.notice.text, /\n/);
|
||||
overlay.clear();
|
||||
overlay.setNotice('New conversation');
|
||||
assert.equal(overlay.notice.text, 'New conversation');
|
||||
});
|
||||
|
||||
test('prefs bind every GSettings schema key', () => {
|
||||
const prefs = readFileSync(new URL('../apps/gnome-extension/[email protected]/prefs.js', import.meta.url), 'utf8');
|
||||
const schema = readFileSync(new URL('../apps/gnome-extension/[email protected]/schemas/org.gnome.shell.extensions.jarvis.gschema.xml', import.meta.url), 'utf8');
|
||||
const keys = [...schema.matchAll(/<key name="([^"]+)"/g)].map((match) => match[1]);
|
||||
assert.ok(keys.length >= 14);
|
||||
for (const key of keys) assert.match(prefs, new RegExp(`['"]${key}['"]`));
|
||||
assert.match(prefs, /wakePhrase/);
|
||||
assert.match(prefs, /ttsEnabled/);
|
||||
assert.match(prefs, /modelProfile/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user