@@ -18,14 +18,23 @@ const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎
|
||||
|
||||
class JarvisProxy {
|
||||
async connect() {
|
||||
const proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
|
||||
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
|
||||
if (this._closed) return this;
|
||||
if (this.proxy) return this;
|
||||
if (this._connecting) {
|
||||
await this._connecting.catch(() => {});
|
||||
return this;
|
||||
}
|
||||
this._connecting = new Promise((resolve, reject) => Gio.DBusProxy.new(
|
||||
Gio.DBus.session, Gio.DBusProxyFlags.NONE, null, BUS, PATH, IFACE, null,
|
||||
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
|
||||
));
|
||||
if (this._closed) { proxy.run_dispose(); return this; }
|
||||
this.proxy = proxy;
|
||||
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
|
||||
return this;
|
||||
try {
|
||||
const proxy = await this._connecting;
|
||||
if (this._closed) { proxy.run_dispose(); return this; }
|
||||
this.proxy = proxy;
|
||||
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
|
||||
return this;
|
||||
} finally { this._connecting = null; }
|
||||
}
|
||||
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
|
||||
call(name, signature = null, value = null) {
|
||||
@@ -47,7 +56,7 @@ export default class JarvisExtension extends Extension {
|
||||
this.settings = this.getSettings();
|
||||
this.proxy = new JarvisProxy();
|
||||
this.popup = new ConversationView({ compact: true });
|
||||
this.osd = new JarvisOsd(); this.osd.attach();
|
||||
this.osd = new JarvisOsd();
|
||||
this.cu = new ComputerUseChrome(); this.cu.attach();
|
||||
this.session = new SessionPanel(); this.session.attach();
|
||||
this._bindSurface(this.popup);
|
||||
@@ -55,7 +64,11 @@ export default class JarvisExtension extends Extension {
|
||||
this._applyAccessibility();
|
||||
this._removeShellService = installShellService();
|
||||
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
||||
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
|
||||
this._panelBox = new St.BoxLayout({ style_class: 'jarvis-panel-box', y_align: Clutter.ActorAlign.CENTER });
|
||||
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle';
|
||||
this._panelBox.add_child(this._glyph);
|
||||
this.osd.attach(this._panelBox);
|
||||
this._indicator.add_child(this._panelBox);
|
||||
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
||||
this._mountPopup();
|
||||
this._indicator.connect('button-press-event', (_actor, event) => {
|
||||
@@ -173,28 +186,41 @@ export default class JarvisExtension extends Extension {
|
||||
const proxy = this.proxy;
|
||||
try {
|
||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||
this._signals = [
|
||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
||||
['WakeHeard', () => this.osd.showWake()],
|
||||
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
||||
['Token', (text) => this._eachView((view) => view.token(text))],
|
||||
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
||||
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
||||
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
||||
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
||||
['SpeakingLevel', () => {}],
|
||||
['ListeningLevel', () => {}],
|
||||
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
||||
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
||||
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
||||
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
||||
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
||||
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
|
||||
].map(([name, handler]) => proxy.on(name, handler));
|
||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||
if (!this._signals) {
|
||||
this._signals = [
|
||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
||||
['WakeHeard', () => this.osd.showWake()],
|
||||
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
||||
['Token', (text) => this._eachView((view) => view.token(text))],
|
||||
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
||||
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
||||
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
||||
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
||||
['SpeakingLevel', () => {}],
|
||||
['ListeningLevel', () => {}],
|
||||
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
||||
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
||||
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
||||
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
||||
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
||||
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
|
||||
].map(([name, handler]) => proxy.on(name, handler));
|
||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||
}
|
||||
await this._syncDaemon();
|
||||
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
if (this.proxy !== proxy) return;
|
||||
this._keepSyncing();
|
||||
}
|
||||
_keepSyncing() {
|
||||
if (this._syncTimer) return;
|
||||
this._syncTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 2000, () => {
|
||||
if (!this.proxy || this.proxy._closed) { this._syncTimer = 0; return GLib.SOURCE_REMOVE; }
|
||||
if (!this.proxy.proxy) this._connectDaemon();
|
||||
else this._syncDaemon();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
}
|
||||
async _syncDaemon() {
|
||||
if (!this.proxy) return;
|
||||
@@ -203,6 +229,9 @@ export default class JarvisExtension extends Extension {
|
||||
const result = await this.proxy.call('GetState'); if (!this.popup) return;
|
||||
this._setState(result.deep_unpack()[0]);
|
||||
await this._refreshVoiceStatus();
|
||||
this._eachView((view) => {
|
||||
if (/daemon is unavailable|Jarvis is starting/i.test(view.notice?.text || '')) view.setNotice('');
|
||||
});
|
||||
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||
}
|
||||
async _refreshVoiceStatus() {
|
||||
@@ -220,8 +249,14 @@ export default class JarvisExtension extends Extension {
|
||||
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
||||
}
|
||||
_call(name, signature, value) {
|
||||
if (!this.proxy?.owned?.()) {
|
||||
if (name !== 'PushToTalk') this._eachView((view) => view.setNotice('Jarvis is starting…'));
|
||||
this._connectDaemon();
|
||||
return;
|
||||
}
|
||||
this.proxy.call(name, signature, value).catch((error) => {
|
||||
log(`Jarvis ${name}: ${error.message}`);
|
||||
if (name === 'PushToTalk') return;
|
||||
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
||||
this._eachView((view) => view.setNotice(fallback));
|
||||
});
|
||||
@@ -258,6 +293,7 @@ export default class JarvisExtension extends Extension {
|
||||
if (this._settingsChanged) this.settings.disconnect(this._settingsChanged);
|
||||
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
|
||||
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
|
||||
if (this._syncTimer) { try { GLib.Source.remove(this._syncTimer); } catch {} this._syncTimer = 0; }
|
||||
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
|
||||
this.proxy?.close();
|
||||
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
|
||||
|
||||
@@ -1,181 +1,6 @@
|
||||
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 BUS = 'io.qvac.Jarvis';
|
||||
const PATH = '/io/qvac/Jarvis';
|
||||
const IFACE = 'io.qvac.Jarvis.Session';
|
||||
const PRIVACY_MODES = ['full-listen-after-wake', 'wake-only', 'off'];
|
||||
const OVERLAY_STYLES = ['tray', 'expanded'];
|
||||
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;
|
||||
}
|
||||
|
||||
function callDaemon(name, signature, values, onDone) {
|
||||
Gio.DBus.session.call(
|
||||
BUS,
|
||||
PATH,
|
||||
IFACE,
|
||||
name,
|
||||
signature ? GLib.Variant.new(signature, values) : null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
4000,
|
||||
null,
|
||||
(_source, result) => {
|
||||
try {
|
||||
const reply = Gio.DBus.session.call_finish(result);
|
||||
onDone?.(null, reply);
|
||||
} catch (error) {
|
||||
onDone?.(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function grantRow() {
|
||||
const row = new Adw.ActionRow({
|
||||
title: 'Desktop grant',
|
||||
subtitle: 'Off. Jarvis cannot click or type until you allow it here.',
|
||||
});
|
||||
const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER });
|
||||
allow.add_css_class('suggested-action');
|
||||
const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER });
|
||||
revoke.add_css_class('destructive-action');
|
||||
const refresh = () => {
|
||||
callDaemon('ComputerStatus', null, null, (error, reply) => {
|
||||
if (error) {
|
||||
row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.';
|
||||
return;
|
||||
}
|
||||
let status = {};
|
||||
try {
|
||||
const unpacked = reply.deep_unpack?.() ?? reply.unpack?.();
|
||||
const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked;
|
||||
status = JSON.parse(String(raw || '{}'));
|
||||
} catch {}
|
||||
if (status.active) {
|
||||
row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`;
|
||||
} else {
|
||||
row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for three minutes.';
|
||||
}
|
||||
});
|
||||
};
|
||||
allow.connect('clicked', () => {
|
||||
callDaemon('ComputerGrant', '(b)', [true], (error) => {
|
||||
row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.';
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
revoke.connect('clicked', () => {
|
||||
callDaemon('ComputerRevoke', null, null, (error) => {
|
||||
row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.';
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
row.add_suffix(allow);
|
||||
row.add_suffix(revoke);
|
||||
row.activatable_widget = allow;
|
||||
refresh();
|
||||
return row;
|
||||
}
|
||||
import { fillSettingsWindow } from './settings-window.js';
|
||||
|
||||
export default class JarvisPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
window.set_title('Jarvis QVAC');
|
||||
const settings = this.getSettings();
|
||||
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, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', '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 only after you press Allow now.', 'computer-use-mode', COMPUTER_MODES));
|
||||
computerGroup.add(grantRow());
|
||||
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));
|
||||
}
|
||||
}
|
||||
fillPreferencesWindow(window) { fillSettingsWindow(window, this.getSettings(), this.path); }
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,745 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fields": [
|
||||
{
|
||||
"key": "ttsEnabled",
|
||||
"title": "Spoken replies",
|
||||
"group": "Speech",
|
||||
"default": true,
|
||||
"type": "boolean",
|
||||
"description": "Read replies aloud using local speech synthesis.",
|
||||
"aliases": [
|
||||
"tts_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ttsPreset",
|
||||
"title": "Speech model",
|
||||
"group": "Speech",
|
||||
"default": "supertonic-en",
|
||||
"type": "choice",
|
||||
"description": "Models download on first use. Larger models need more memory.",
|
||||
"options": [
|
||||
{
|
||||
"value": "supertonic-en",
|
||||
"label": "Supertonic \u00b7 English \u00b7 lightweight"
|
||||
},
|
||||
{
|
||||
"value": "supertonic3",
|
||||
"label": "Supertonic 3 \u00b7 multilingual"
|
||||
},
|
||||
{
|
||||
"value": "chatterbox",
|
||||
"label": "Chatterbox Turbo \u00b7 English \u00b7 reference voice"
|
||||
},
|
||||
{
|
||||
"value": "parler",
|
||||
"label": "Parler Mini \u00b7 English \u00b7 describe a voice"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "voiceId",
|
||||
"title": "Voice",
|
||||
"group": "Speech",
|
||||
"default": "F1",
|
||||
"type": "choice",
|
||||
"description": "Try the same preview with different voices. Available voices depend on the model bundle.",
|
||||
"options": [
|
||||
{
|
||||
"value": "F1",
|
||||
"label": "F1"
|
||||
},
|
||||
{
|
||||
"value": "F2",
|
||||
"label": "F2"
|
||||
},
|
||||
{
|
||||
"value": "F3",
|
||||
"label": "F3"
|
||||
},
|
||||
{
|
||||
"value": "F4",
|
||||
"label": "F4"
|
||||
},
|
||||
{
|
||||
"value": "F5",
|
||||
"label": "F5"
|
||||
},
|
||||
{
|
||||
"value": "M1",
|
||||
"label": "M1"
|
||||
},
|
||||
{
|
||||
"value": "M2",
|
||||
"label": "M2"
|
||||
},
|
||||
{
|
||||
"value": "M3",
|
||||
"label": "M3"
|
||||
},
|
||||
{
|
||||
"value": "M4",
|
||||
"label": "M4"
|
||||
},
|
||||
{
|
||||
"value": "M5",
|
||||
"label": "M5"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"voice_id",
|
||||
"voice-id"
|
||||
],
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"supertonic-en",
|
||||
"supertonic3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsLanguage",
|
||||
"title": "Speech language",
|
||||
"group": "Speech",
|
||||
"default": "en",
|
||||
"type": "choice",
|
||||
"description": "The English models always use English. Choose Supertonic 3 for other languages.",
|
||||
"options": [
|
||||
{
|
||||
"value": "en",
|
||||
"label": "English"
|
||||
},
|
||||
{
|
||||
"value": "ko",
|
||||
"label": "Korean"
|
||||
},
|
||||
{
|
||||
"value": "ja",
|
||||
"label": "Japanese"
|
||||
},
|
||||
{
|
||||
"value": "ar",
|
||||
"label": "Arabic"
|
||||
},
|
||||
{
|
||||
"value": "bg",
|
||||
"label": "Bulgarian"
|
||||
},
|
||||
{
|
||||
"value": "cs",
|
||||
"label": "Czech"
|
||||
},
|
||||
{
|
||||
"value": "da",
|
||||
"label": "Danish"
|
||||
},
|
||||
{
|
||||
"value": "de",
|
||||
"label": "German"
|
||||
},
|
||||
{
|
||||
"value": "el",
|
||||
"label": "Greek"
|
||||
},
|
||||
{
|
||||
"value": "es",
|
||||
"label": "Spanish"
|
||||
},
|
||||
{
|
||||
"value": "et",
|
||||
"label": "Estonian"
|
||||
},
|
||||
{
|
||||
"value": "fi",
|
||||
"label": "Finnish"
|
||||
},
|
||||
{
|
||||
"value": "fr",
|
||||
"label": "French"
|
||||
},
|
||||
{
|
||||
"value": "hi",
|
||||
"label": "Hindi"
|
||||
},
|
||||
{
|
||||
"value": "hr",
|
||||
"label": "Croatian"
|
||||
},
|
||||
{
|
||||
"value": "hu",
|
||||
"label": "Hungarian"
|
||||
},
|
||||
{
|
||||
"value": "id",
|
||||
"label": "Indonesian"
|
||||
},
|
||||
{
|
||||
"value": "it",
|
||||
"label": "Italian"
|
||||
},
|
||||
{
|
||||
"value": "lt",
|
||||
"label": "Lithuanian"
|
||||
},
|
||||
{
|
||||
"value": "lv",
|
||||
"label": "Latvian"
|
||||
},
|
||||
{
|
||||
"value": "nl",
|
||||
"label": "Dutch"
|
||||
},
|
||||
{
|
||||
"value": "pl",
|
||||
"label": "Polish"
|
||||
},
|
||||
{
|
||||
"value": "pt",
|
||||
"label": "Portuguese"
|
||||
},
|
||||
{
|
||||
"value": "ro",
|
||||
"label": "Romanian"
|
||||
},
|
||||
{
|
||||
"value": "ru",
|
||||
"label": "Russian"
|
||||
},
|
||||
{
|
||||
"value": "sk",
|
||||
"label": "Slovak"
|
||||
},
|
||||
{
|
||||
"value": "sl",
|
||||
"label": "Slovenian"
|
||||
},
|
||||
{
|
||||
"value": "sv",
|
||||
"label": "Swedish"
|
||||
},
|
||||
{
|
||||
"value": "tr",
|
||||
"label": "Turkish"
|
||||
},
|
||||
{
|
||||
"value": "uk",
|
||||
"label": "Ukrainian"
|
||||
},
|
||||
{
|
||||
"value": "vi",
|
||||
"label": "Vietnamese"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"tts_language"
|
||||
],
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"supertonic3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsSpeed",
|
||||
"title": "Speaking speed",
|
||||
"group": "Speech",
|
||||
"default": 1.05,
|
||||
"type": "number",
|
||||
"description": "1 is normal speed; lower is slower.",
|
||||
"min": 0.25,
|
||||
"max": 4,
|
||||
"step": 0.05,
|
||||
"aliases": [
|
||||
"tts_speed"
|
||||
],
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"supertonic-en",
|
||||
"supertonic3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsSteps",
|
||||
"title": "Voice quality steps",
|
||||
"group": "Speech",
|
||||
"default": 5,
|
||||
"type": "number",
|
||||
"description": "More steps can improve quality but take longer.",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"supertonic-en",
|
||||
"supertonic3"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsVolume",
|
||||
"title": "Reply volume (%)",
|
||||
"group": "Speech",
|
||||
"default": 100,
|
||||
"type": "number",
|
||||
"description": "Relative to the system speaker volume. Does not change other apps.",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 5
|
||||
},
|
||||
{
|
||||
"key": "ttsReferenceAudio",
|
||||
"title": "Reference voice recording",
|
||||
"group": "Voice design",
|
||||
"default": "",
|
||||
"type": "file",
|
||||
"description": "Choose a clear mono WAV recording of at least 5 seconds. Leave empty for the model\u2019s default voice.",
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"chatterbox"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsCfmSteps",
|
||||
"title": "Synthesis quality steps",
|
||||
"group": "Voice design",
|
||||
"default": 2,
|
||||
"type": "number",
|
||||
"description": "Chatterbox: fewer steps respond faster.",
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"step": 1,
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"chatterbox"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsDescription",
|
||||
"title": "Describe the voice",
|
||||
"group": "Voice design",
|
||||
"default": "A clear, warm voice speaks at a natural pace in a quiet room.",
|
||||
"type": "string",
|
||||
"description": "Parler: describe tone, pace, pitch, and recording style in English.",
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"parler"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsTemperature",
|
||||
"title": "Voice variation",
|
||||
"group": "Voice design",
|
||||
"default": 1,
|
||||
"type": "number",
|
||||
"description": "Parler: higher values produce more variation.",
|
||||
"min": 0,
|
||||
"max": 2,
|
||||
"step": 0.05,
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"parler"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsSeed",
|
||||
"title": "Voice seed",
|
||||
"group": "Voice design",
|
||||
"default": 42,
|
||||
"type": "number",
|
||||
"description": "Use a fixed seed for repeatable speech generation.",
|
||||
"min": 0,
|
||||
"max": 2147483647,
|
||||
"step": 1,
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"chatterbox",
|
||||
"parler"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsThreads",
|
||||
"title": "Speech CPU threads",
|
||||
"group": "Voice design",
|
||||
"default": 4,
|
||||
"type": "number",
|
||||
"description": "Limit CPU work for Chatterbox and Parler.",
|
||||
"min": 1,
|
||||
"max": 32,
|
||||
"step": 1,
|
||||
"when": {
|
||||
"ttsPreset": [
|
||||
"chatterbox",
|
||||
"parler"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ttsUseGpu",
|
||||
"title": "Accelerate speech with GPU",
|
||||
"group": "Voice design",
|
||||
"default": false,
|
||||
"type": "boolean",
|
||||
"description": "Use a supported GPU backend for speech synthesis. May increase GPU memory use."
|
||||
},
|
||||
{
|
||||
"key": "ttsModel",
|
||||
"title": "Custom speech model",
|
||||
"group": "Voice design",
|
||||
"default": "",
|
||||
"type": "string",
|
||||
"description": "Advanced: registry name or local GGUF matching the selected speech model. Empty uses the bundled preset.",
|
||||
"aliases": [
|
||||
"tts_model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "previewText",
|
||||
"title": "Preview text",
|
||||
"group": "Speech",
|
||||
"default": "Hello. I am Jarvis. This is how I will sound with your settings.",
|
||||
"type": "string",
|
||||
"description": "Use the same sentence to compare voices."
|
||||
},
|
||||
{
|
||||
"key": "microphoneEnabled",
|
||||
"title": "Microphone input",
|
||||
"group": "Listening",
|
||||
"default": true,
|
||||
"type": "boolean",
|
||||
"description": "Turn off for typed chat and speech output only.",
|
||||
"aliases": [
|
||||
"microphone_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "asrModel",
|
||||
"title": "Recognition model",
|
||||
"group": "Listening",
|
||||
"default": "WHISPER_TINY",
|
||||
"type": "choice",
|
||||
"description": "Larger models need more memory and download on first use.",
|
||||
"options": [
|
||||
{
|
||||
"value": "WHISPER_TINY",
|
||||
"label": "Whisper Tiny \u00b7 fastest"
|
||||
},
|
||||
{
|
||||
"value": "WHISPER_BASE_Q8_0",
|
||||
"label": "Whisper Base \u00b7 balanced"
|
||||
},
|
||||
{
|
||||
"value": "WHISPER_SMALL_Q8_0",
|
||||
"label": "Whisper Small \u00b7 more accurate"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"asr_model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "asrLanguage",
|
||||
"title": "Recognition language",
|
||||
"group": "Listening",
|
||||
"default": "en",
|
||||
"type": "string",
|
||||
"description": "Whisper language code such as en, es, fr, or auto.",
|
||||
"aliases": [
|
||||
"asr_language",
|
||||
"language"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "wakePhrase",
|
||||
"title": "Wake phrase",
|
||||
"group": "Wake and privacy",
|
||||
"default": "hey jarvis",
|
||||
"type": "string",
|
||||
"description": "Must match a phrase supported by your local wake detector.",
|
||||
"aliases": [
|
||||
"wake_phrase"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "aliases",
|
||||
"title": "Wake aliases",
|
||||
"group": "Wake and privacy",
|
||||
"default": [
|
||||
"jarvis",
|
||||
"okay jarvis"
|
||||
],
|
||||
"type": "list",
|
||||
"description": "Additional detector phrases, separated by commas."
|
||||
},
|
||||
{
|
||||
"key": "wakeCommand",
|
||||
"title": "Wake detector command",
|
||||
"group": "Wake and privacy",
|
||||
"default": "",
|
||||
"type": "string",
|
||||
"description": "Advanced: local program that receives microphone audio. Leave empty to use Hold Talk.",
|
||||
"aliases": [
|
||||
"wake_command"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "listeningMode",
|
||||
"title": "Listening behavior",
|
||||
"group": "Wake and privacy",
|
||||
"default": "conversation",
|
||||
"type": "choice",
|
||||
"description": "Hold Talk only disables the wake detector and automatic follow-up listening.",
|
||||
"options": [
|
||||
{
|
||||
"value": "conversation",
|
||||
"label": "Continue listening after replies"
|
||||
},
|
||||
{
|
||||
"value": "single",
|
||||
"label": "One request per wake"
|
||||
},
|
||||
{
|
||||
"value": "ptt",
|
||||
"label": "Hold Talk only"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"listening_mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "idleMinutes",
|
||||
"title": "Idle sleep delay (minutes)",
|
||||
"group": "Wake and privacy",
|
||||
"default": 30,
|
||||
"type": "number",
|
||||
"description": "Sleep after this much listening inactivity.",
|
||||
"min": 1,
|
||||
"max": 240,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "vadThreshold",
|
||||
"title": "Speech detection threshold",
|
||||
"group": "Detection tuning",
|
||||
"default": 0.6,
|
||||
"type": "number",
|
||||
"description": "Lower picks up quieter speech; higher rejects more background noise.",
|
||||
"min": 0.05,
|
||||
"max": 1,
|
||||
"step": 0.05
|
||||
},
|
||||
{
|
||||
"key": "vadMinSpeechMs",
|
||||
"title": "Minimum speech (ms)",
|
||||
"group": "Detection tuning",
|
||||
"default": 300,
|
||||
"type": "number",
|
||||
"description": "Ignore very short sounds during automatic listening.",
|
||||
"min": 100,
|
||||
"max": 2000,
|
||||
"step": 50
|
||||
},
|
||||
{
|
||||
"key": "vadSilenceMs",
|
||||
"title": "Pause before sending (ms)",
|
||||
"group": "Detection tuning",
|
||||
"default": 700,
|
||||
"type": "number",
|
||||
"description": "Wait this long after speech before sending your request.",
|
||||
"min": 200,
|
||||
"max": 3000,
|
||||
"step": 50
|
||||
},
|
||||
{
|
||||
"key": "vadMaxSpeechSeconds",
|
||||
"title": "Maximum recording (seconds)",
|
||||
"group": "Detection tuning",
|
||||
"default": 15,
|
||||
"type": "number",
|
||||
"description": "Limit each recorded utterance, including pauses.",
|
||||
"min": 3,
|
||||
"max": 120,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "playbackCooldownMs",
|
||||
"title": "Echo protection after replies (ms)",
|
||||
"group": "Detection tuning",
|
||||
"default": 400,
|
||||
"type": "number",
|
||||
"description": "Delay microphone processing after speech output ends.",
|
||||
"min": 0,
|
||||
"max": 2000,
|
||||
"step": 50
|
||||
},
|
||||
{
|
||||
"key": "inputTarget",
|
||||
"title": "Microphone device",
|
||||
"group": "Audio routing",
|
||||
"default": "",
|
||||
"type": "device",
|
||||
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
|
||||
},
|
||||
{
|
||||
"key": "outputTarget",
|
||||
"title": "Speaker device",
|
||||
"group": "Audio routing",
|
||||
"default": "",
|
||||
"type": "device",
|
||||
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
|
||||
},
|
||||
{
|
||||
"key": "computerMode",
|
||||
"title": "Desktop access mode",
|
||||
"group": "Desktop access",
|
||||
"default": "act",
|
||||
"type": "choice",
|
||||
"description": "A temporary Allow now grant is always required. Changing this revokes existing access.",
|
||||
"options": [
|
||||
{
|
||||
"value": "off",
|
||||
"label": "Disabled"
|
||||
},
|
||||
{
|
||||
"value": "observe",
|
||||
"label": "Observe only"
|
||||
},
|
||||
{
|
||||
"value": "act",
|
||||
"label": "Observe and control"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"computer_mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "computerSteps",
|
||||
"title": "Actions per grant",
|
||||
"group": "Desktop access",
|
||||
"default": 20,
|
||||
"type": "number",
|
||||
"description": "Maximum input actions before another grant is needed.",
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"aliases": [
|
||||
"computer_step_budget"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "computerGrantMinutes",
|
||||
"title": "Grant duration (minutes)",
|
||||
"group": "Desktop access",
|
||||
"default": 3,
|
||||
"type": "number",
|
||||
"description": "Desktop access expires automatically.",
|
||||
"min": 1,
|
||||
"max": 15,
|
||||
"step": 1
|
||||
},
|
||||
{
|
||||
"key": "screenshotMaxEdge",
|
||||
"title": "Screenshot maximum edge (pixels)",
|
||||
"group": "Desktop images",
|
||||
"default": 1280,
|
||||
"type": "number",
|
||||
"description": "Larger screenshots preserve detail but take more memory.",
|
||||
"min": 640,
|
||||
"max": 2560,
|
||||
"step": 160
|
||||
},
|
||||
{
|
||||
"key": "screenshotQuality",
|
||||
"title": "Screenshot quality (%)",
|
||||
"group": "Desktop images",
|
||||
"default": 70,
|
||||
"type": "number",
|
||||
"description": "Higher WebP quality preserves more text detail.",
|
||||
"min": 30,
|
||||
"max": 95,
|
||||
"step": 5
|
||||
},
|
||||
{
|
||||
"key": "modelProfile",
|
||||
"title": "Chat model profile",
|
||||
"group": "Chat model",
|
||||
"default": "laptop-16gb",
|
||||
"type": "choice",
|
||||
"description": "Requires a daemon restart. GPU inference remains required.",
|
||||
"options": [
|
||||
{
|
||||
"value": "laptop-8gb",
|
||||
"label": "Small \u00b7 Qwen3 1.7B"
|
||||
},
|
||||
{
|
||||
"value": "laptop-16gb",
|
||||
"label": "Balanced \u00b7 Qwen3.5 4B"
|
||||
},
|
||||
{
|
||||
"value": "desktop-gpu",
|
||||
"label": "Large \u00b7 Qwen3.5 9B"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"model_profile"
|
||||
],
|
||||
"restart": true
|
||||
},
|
||||
{
|
||||
"key": "chatModel",
|
||||
"title": "Custom chat model",
|
||||
"group": "Chat model",
|
||||
"default": "",
|
||||
"type": "string",
|
||||
"description": "Advanced: overrides the profile. Empty uses the profile model. Requires restart.",
|
||||
"aliases": [
|
||||
"model"
|
||||
],
|
||||
"restart": true
|
||||
},
|
||||
{
|
||||
"key": "maxTurns",
|
||||
"title": "Maximum reasoning turns",
|
||||
"group": "Agent limits",
|
||||
"default": 6,
|
||||
"type": "number",
|
||||
"description": "Requires restart. Limits how long the assistant works on one request.",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
"step": 1,
|
||||
"restart": true
|
||||
},
|
||||
{
|
||||
"key": "maxShellCalls",
|
||||
"title": "Shell commands per request",
|
||||
"group": "Agent limits",
|
||||
"default": 1,
|
||||
"type": "number",
|
||||
"description": "Requires restart. Commands still require normal permissions.",
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"step": 1,
|
||||
"restart": true
|
||||
},
|
||||
{
|
||||
"key": "maxToolRounds",
|
||||
"title": "Tool rounds per request",
|
||||
"group": "Agent limits",
|
||||
"default": 4,
|
||||
"type": "number",
|
||||
"description": "Requires restart. Limits repeated tool use.",
|
||||
"min": 1,
|
||||
"max": 20,
|
||||
"step": 1,
|
||||
"restart": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Adw from 'gi://Adw';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import { normalizeSettings, mergeSettings, settingVisible } from './settings-values.js';
|
||||
|
||||
function readJson(file, optional = false) {
|
||||
try {
|
||||
const [, bytes] = file.load_contents(null);
|
||||
const value = JSON.parse(new TextDecoder().decode(bytes));
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a JSON object');
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (optional && error.matches?.(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND)) return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function daemonCall(method, signature = null, values = null) {
|
||||
return new Promise((resolve, reject) => Gio.DBus.session.call(
|
||||
'io.qvac.Jarvis', '/io/qvac/Jarvis', 'io.qvac.Jarvis.Session', method,
|
||||
signature ? new GLib.Variant(signature, values) : null, null,
|
||||
Gio.DBusCallFlags.NONE, 180000, null,
|
||||
(_source, result) => { try { resolve(Gio.DBus.session.call_finish(result).deep_unpack()); } catch (error) { reject(error); } },
|
||||
));
|
||||
}
|
||||
|
||||
async function audioDevices(mediaClass) {
|
||||
const process = Gio.Subprocess.new(['pw-dump'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
let timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 5000, () => { timeout = 0; process.force_exit(); return GLib.SOURCE_REMOVE; });
|
||||
try {
|
||||
const output = await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => {
|
||||
try { const [, stdout, stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'PipeWire unavailable'); resolve(stdout); } catch (error) { reject(error); }
|
||||
}));
|
||||
return JSON.parse(output).filter(item => item.info?.props?.['media.class'] === mediaClass).map(item => ({ value: item.info.props['node.name'], label: item.info.props['node.description'] || item.info.props['node.nick'] || item.info.props['node.name'] })).filter(item => item.value);
|
||||
} finally { if (timeout) GLib.Source.remove(timeout); }
|
||||
}
|
||||
|
||||
export class SettingsEditor {
|
||||
constructor(directory, legacySettings = null) {
|
||||
this.fields = readJson(Gio.File.new_for_path(`${directory}/settings-catalog.json`)).fields;
|
||||
this.file = Gio.File.new_for_path(GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis', 'config.json']));
|
||||
this.changes = {}; this.rows = new Map(); this.statusRows = []; this.applyButtons = []; this.previewButtons = []; this.busy = false;
|
||||
try { this.source = readJson(this.file, true); } catch (error) { this.source = {}; this.loadError = error.message; }
|
||||
// Existing JSON is authoritative. Migrate only explicitly changed GSettings,
|
||||
// never its defaults, and never write simply because preferences opened.
|
||||
const legacy = { 'wake-phrase': 'wakePhrase', aliases: 'aliases', 'voice-id': 'voiceId', language: 'asrLanguage', 'tts-enabled': 'ttsEnabled', 'model-profile': 'modelProfile', 'computer-use-mode': 'computerMode' };
|
||||
if (!this.loadError && legacySettings) for (const [key, canonical] of Object.entries(legacy)) {
|
||||
const field = this.fields.find(item => item.key === canonical);
|
||||
if ([canonical, ...(field.aliases || [])].some(name => this.source[name] != null)) continue;
|
||||
const value = legacySettings.get_user_value(key);
|
||||
if (value) this.changes[canonical] = value.deep_unpack();
|
||||
}
|
||||
this.values = normalizeSettings({ ...this.source, ...this.changes }, this.fields);
|
||||
}
|
||||
status(message) { this.message = message; for (const row of this.statusRows) row.subtitle = message; }
|
||||
setValue(field, value) {
|
||||
if (this._syncing) return;
|
||||
this.values[field.key] = value; this.changes[field.key] = value;
|
||||
this.status('Unsaved changes. Apply to use these settings.');
|
||||
this.refreshVisibility();
|
||||
}
|
||||
refreshVisibility() {
|
||||
for (const field of this.fields) {
|
||||
const row = this.rows.get(field.key);
|
||||
if (row) row.visible = settingVisible(field, this.values);
|
||||
}
|
||||
for (const button of this.previewButtons) button.sensitive = !this.busy && this.values.ttsEnabled;
|
||||
}
|
||||
row(field, parent) {
|
||||
let row;
|
||||
const changed = value => this.setValue(field, value);
|
||||
if (field.type === 'boolean') {
|
||||
row = new Adw.SwitchRow({ title: field.title, subtitle: field.description, active: this.values[field.key] });
|
||||
row.connect('notify::active', () => changed(row.active));
|
||||
row._setValue = value => { row.active = value; };
|
||||
} else if (field.type === 'choice') {
|
||||
row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(field.options.map(option => option.label)) });
|
||||
row.selected = Math.max(0, field.options.findIndex(option => option.value === this.values[field.key]));
|
||||
row.connect('notify::selected', () => { if (field.options[row.selected]) changed(field.options[row.selected].value); });
|
||||
row._setValue = value => { row.selected = field.options.findIndex(option => option.value === value); };
|
||||
} else if (field.type === 'device') {
|
||||
let options = [{ value: '', label: 'System default' }];
|
||||
if (this.values[field.key]) options.push({ value: this.values[field.key], label: this.values[field.key] });
|
||||
row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(options.map(o => o.label)), selected: options.length - 1 });
|
||||
let updating = false;
|
||||
row.connect('notify::selected', () => { if (!updating && options[row.selected]) changed(options[row.selected].value); });
|
||||
const refresh = new Gtk.Button({ label: 'Refresh', valign: Gtk.Align.CENTER });
|
||||
const scan = async () => {
|
||||
refresh.sensitive = false;
|
||||
try {
|
||||
const devices = await audioDevices(field.key === 'inputTarget' ? 'Audio/Source' : 'Audio/Sink');
|
||||
options = [{ value: '', label: 'System default' }, ...devices];
|
||||
if (this.values[field.key] && !options.some(o => o.value === this.values[field.key])) options.push({ value: this.values[field.key], label: `${this.values[field.key]} (not connected)` });
|
||||
updating = true;
|
||||
row.model = Gtk.StringList.new(options.map(o => o.label));
|
||||
row.selected = Math.max(0, options.findIndex(o => o.value === this.values[field.key]));
|
||||
row.subtitle = field.description;
|
||||
} catch (error) { row.subtitle = `Could not list devices: ${error.message}`; }
|
||||
finally { updating = false; refresh.sensitive = true; }
|
||||
};
|
||||
refresh.connect('clicked', scan); row.add_suffix(refresh);
|
||||
row._setValue = value => { row.selected = Math.max(0, options.findIndex(o => o.value === value)); };
|
||||
scan();
|
||||
} else if (field.type === 'number') {
|
||||
row = new Adw.SpinRow({ title: field.title, subtitle: field.description, digits: field.step < 1 ? 2 : 0,
|
||||
adjustment: new Gtk.Adjustment({ lower: field.min, upper: field.max, step_increment: field.step, page_increment: field.step * 5, value: this.values[field.key] }) });
|
||||
row.connect('notify::value', () => changed(row.value));
|
||||
row._setValue = value => { row.value = value; };
|
||||
} else if (field.type === 'file') {
|
||||
row = new Adw.ActionRow({ title: field.title, subtitle: this.values[field.key] || field.description });
|
||||
const choose = new Gtk.Button({ label: 'Choose WAV…', valign: Gtk.Align.CENTER });
|
||||
const clear = new Gtk.Button({ label: 'Clear', valign: Gtk.Align.CENTER });
|
||||
choose.connect('clicked', () => {
|
||||
const dialog = new Gtk.FileChooserNative({ title: 'Choose a reference voice recording', transient_for: parent, action: Gtk.FileChooserAction.OPEN, accept_label: 'Choose' });
|
||||
const filter = new Gtk.FileFilter(); filter.set_name('WAV audio'); filter.add_pattern('*.wav'); filter.add_pattern('*.WAV'); dialog.add_filter(filter);
|
||||
dialog.connect('response', (_dialog, response) => {
|
||||
if (response === Gtk.ResponseType.ACCEPT) { const file = dialog.get_file()?.get_path(); if (file) { changed(file); row.subtitle = file; } }
|
||||
dialog.destroy();
|
||||
});
|
||||
dialog.show();
|
||||
});
|
||||
clear.connect('clicked', () => { changed(''); row.subtitle = field.description; });
|
||||
row.add_suffix(choose); row.add_suffix(clear);
|
||||
row._setValue = value => { row.subtitle = value || field.description; };
|
||||
} else {
|
||||
row = new Adw.EntryRow({ title: field.title, tooltip_text: field.description, text: field.type === 'list' ? this.values[field.key].join(', ') : this.values[field.key] });
|
||||
row.connect('changed', () => changed(field.type === 'list' ? row.text.split(',').map(v => v.trim()).filter(Boolean) : row.text));
|
||||
row._setValue = value => { row.text = field.type === 'list' ? value.join(', ') : value; };
|
||||
}
|
||||
this.rows.set(field.key, row);
|
||||
return row;
|
||||
}
|
||||
controls(page, fields, { preview = false } = {}) {
|
||||
const group = new Adw.PreferencesGroup({ title: preview ? 'Make it sound like you want' : 'Apply your changes', description: 'Voice, listening, and desktop controls apply here. Chat model and agent limits require restarting Jarvis.' });
|
||||
const row = new Adw.ActionRow({ title: preview ? 'Listen before you settle on a voice' : 'Settings', subtitle: this.loadError ? `Could not read config.json: ${this.loadError}` : 'Saved locally. Nothing changes until you press Apply.' });
|
||||
this.statusRows.push(row);
|
||||
const apply = new Gtk.Button({ label: 'Apply', valign: Gtk.Align.CENTER }); apply.add_css_class('suggested-action');
|
||||
apply.connect('clicked', () => this.apply()); this.applyButtons.push(apply); row.add_suffix(apply);
|
||||
if (preview) {
|
||||
const listen = new Gtk.Button({ label: 'Apply & Preview', valign: Gtk.Align.CENTER });
|
||||
listen.connect('clicked', () => this.apply(true)); this.previewButtons.push(listen); row.add_suffix(listen);
|
||||
const stop = new Gtk.Button({ label: 'Stop', valign: Gtk.Align.CENTER });
|
||||
stop.connect('clicked', () => daemonCall('StopSpeech').catch(error => this.status(error.message))); row.add_suffix(stop);
|
||||
}
|
||||
group.add(row);
|
||||
const reset = new Adw.ActionRow({ title: 'Reset this page', subtitle: 'Restore defaults in the form, then Apply to save them.' });
|
||||
const button = new Gtk.Button({ label: 'Reset', valign: Gtk.Align.CENTER });
|
||||
button.connect('clicked', () => { for (const field of fields) { this.setValue(field, field.default); this.rows.get(field.key)?._setValue(field.default); } });
|
||||
reset.add_suffix(button); group.add(reset); page.add(group);
|
||||
}
|
||||
page(parent, title, groups, iconName, preview = false) {
|
||||
const page = new Adw.PreferencesPage({ title, icon_name: iconName });
|
||||
const fields = this.fields.filter(field => groups.includes(field.group));
|
||||
this.controls(page, fields, { preview });
|
||||
for (const name of groups) {
|
||||
const group = new Adw.PreferencesGroup({ title: name });
|
||||
for (const field of fields.filter(item => item.group === name)) {
|
||||
group.add(this.row(field, parent));
|
||||
// EntryRow has no subtitle. Show advanced guidance underneath it.
|
||||
if (['string', 'list'].includes(field.type)) {
|
||||
const help = new Gtk.Label({ label: field.description, wrap: true, xalign: 0, margin_start: 12, margin_end: 12, margin_bottom: 8 });
|
||||
help.add_css_class('dim-label');
|
||||
this.rows.get(field.key).bind_property('visible', help, 'visible', 2);
|
||||
group.add(help);
|
||||
}
|
||||
}
|
||||
page.add(group);
|
||||
}
|
||||
this.refreshVisibility();
|
||||
return page;
|
||||
}
|
||||
save() {
|
||||
if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`);
|
||||
// Re-read on save so another window's unrelated changes survive.
|
||||
const current = readJson(this.file, true);
|
||||
const merged = mergeSettings(current, this.changes, this.fields);
|
||||
const validated = normalizeSettings(merged, this.fields, { strict: true });
|
||||
for (const key of Object.keys(this.changes)) merged[key] = validated[key];
|
||||
GLib.mkdir_with_parents(this.file.get_parent().get_path(), 0o700);
|
||||
this.file.replace_contents(`${JSON.stringify(merged, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
|
||||
this.source = merged; this.changes = {}; this.values = validated;
|
||||
this._syncing = true;
|
||||
try { for (const field of this.fields) this.rows.get(field.key)?._setValue(validated[field.key]); }
|
||||
finally { this._syncing = false; this.refreshVisibility(); }
|
||||
return validated;
|
||||
}
|
||||
async restart() {
|
||||
if (this.busy) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
this.save(); this.status('Saved. Restarting Jarvis…');
|
||||
const process = Gio.Subprocess.new(['systemctl', '--user', 'restart', 'jarvisd.service'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
|
||||
await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => {
|
||||
try { const [, , stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'Restart failed'); resolve(); } catch (error) { reject(error); }
|
||||
}));
|
||||
this.status('Jarvis restarted. Voice models may take a moment to become ready.');
|
||||
} catch (error) { this.status(`Saved settings may still need applying: ${error.message}`); }
|
||||
finally { this.busy = false; this.refreshVisibility(); }
|
||||
}
|
||||
async apply(preview = false) {
|
||||
if (this.busy) return;
|
||||
this.busy = true; for (const button of this.applyButtons) button.sensitive = false; this.refreshVisibility();
|
||||
try {
|
||||
if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`);
|
||||
const validated = this.save();
|
||||
this.status('Saved. Applying settings and preparing voice models…');
|
||||
let reply;
|
||||
try { [reply] = await daemonCall('ReloadSettings'); }
|
||||
catch (error) { throw new Error(`Saved, but not applied: ${error.message}`); }
|
||||
const status = JSON.parse(reply);
|
||||
const errors = Object.entries(status.voice?.errors || {}).filter(([key]) => key !== 'tts' || validated.ttsEnabled).map(([key, message]) => `${key}: ${message}`);
|
||||
const restart = status.restartRequired?.length ? ' Restart Jarvis for chat model / agent changes.' : '';
|
||||
const overrides = status.environmentOverrides?.length ? ` Environment overrides: ${status.environmentOverrides.join(', ')}.` : '';
|
||||
if (errors.length) { this.status(`Applied.${restart}${overrides} ${errors.join(' · ')}`); return; }
|
||||
if (preview) { this.status('Playing your voice preview…'); await daemonCall('PreviewVoice', '(s)', [validated.previewText]); }
|
||||
this.status(`Applied.${restart}${overrides}${preview ? ' Preview finished.' : ''}`);
|
||||
} catch (error) { this.status(error.message); }
|
||||
finally { this.busy = false; for (const button of this.applyButtons) button.sensitive = true; this.refreshVisibility(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Shared by GNOME preferences and the daemon; no platform-specific imports.
|
||||
export function normalizeSettings(source, fields, { strict = false } = {}) {
|
||||
const config = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
|
||||
const result = {};
|
||||
for (const field of fields) {
|
||||
let value = config[field.key];
|
||||
if (value == null) {
|
||||
for (const alias of field.aliases || []) if (config[alias] != null) { value = config[alias]; break; }
|
||||
}
|
||||
if (value == null) value = field.default;
|
||||
if (field.type === 'boolean' && typeof value === 'string') {
|
||||
const flag = value.trim().toLowerCase();
|
||||
if (['false', '0', 'off', 'no'].includes(flag)) value = false;
|
||||
else if (['true', '1', 'on', 'yes'].includes(flag)) value = true;
|
||||
}
|
||||
if (field.type === 'number' && typeof value === 'string' && value.trim()) value = Number(value);
|
||||
if (field.type === 'list' && typeof value === 'string') value = value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
if (field.key === 'asrLanguage' && typeof value === 'string') value = value.toLowerCase().split(/[-_]/)[0];
|
||||
if (['string', 'file'].includes(field.type) && typeof value === 'string') value = value.trim();
|
||||
const valid = field.type === 'boolean' ? typeof value === 'boolean'
|
||||
: field.type === 'number' ? Number.isFinite(value) && value >= field.min && value <= field.max && (field.step < 1 || Number.isInteger(value))
|
||||
: field.type === 'choice' ? field.options.some(option => option.value === value)
|
||||
: field.type === 'list' ? Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
: typeof value === 'string' && value.length <= 4096;
|
||||
if (!valid && strict) throw new Error(`Invalid value for ${field.title}`);
|
||||
result[field.key] = valid ? value : field.default;
|
||||
}
|
||||
if (result.vadMinSpeechMs > result.vadMaxSpeechSeconds * 1000) throw new Error('Minimum speech must be shorter than the maximum recording');
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeSettings(source, changes, fields) {
|
||||
const merged = { ...source, ...changes };
|
||||
// Remove obsolete aliases only for settings actually changed by this window.
|
||||
for (const field of fields) if (Object.hasOwn(changes, field.key)) {
|
||||
for (const alias of field.aliases || []) delete merged[alias];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function settingVisible(field, values) {
|
||||
return !field.when || Object.entries(field.when).every(([key, choices]) => choices.includes(values[key]));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Adw from 'gi://Adw';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import { SettingsEditor } from './settings-editor.js';
|
||||
|
||||
const BUS = 'io.qvac.Jarvis';
|
||||
const PATH = '/io/qvac/Jarvis';
|
||||
const IFACE = 'io.qvac.Jarvis.Session';
|
||||
const OVERLAY_STYLES = ['tray', 'expanded'];
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
function callDaemon(name, signature, values, onDone) {
|
||||
Gio.DBus.session.call(
|
||||
BUS,
|
||||
PATH,
|
||||
IFACE,
|
||||
name,
|
||||
signature ? GLib.Variant.new(signature, values) : null,
|
||||
null,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
4000,
|
||||
null,
|
||||
(_source, result) => {
|
||||
try {
|
||||
const reply = Gio.DBus.session.call_finish(result);
|
||||
onDone?.(null, reply);
|
||||
} catch (error) {
|
||||
onDone?.(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function grantRow() {
|
||||
const row = new Adw.ActionRow({
|
||||
title: 'Desktop grant',
|
||||
subtitle: 'Jarvis needs a temporary grant before observing or controlling the desktop.',
|
||||
});
|
||||
const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER });
|
||||
allow.add_css_class('suggested-action');
|
||||
const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER });
|
||||
revoke.add_css_class('destructive-action');
|
||||
const refresh = () => {
|
||||
callDaemon('ComputerStatus', null, null, (error, reply) => {
|
||||
if (error) {
|
||||
row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.';
|
||||
return;
|
||||
}
|
||||
let status = {};
|
||||
try {
|
||||
const unpacked = reply.deep_unpack?.() ?? reply.unpack?.();
|
||||
const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked;
|
||||
status = JSON.parse(String(raw || '{}'));
|
||||
} catch {}
|
||||
if (status.active) {
|
||||
row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`;
|
||||
} else {
|
||||
row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for the configured grant duration.';
|
||||
}
|
||||
});
|
||||
};
|
||||
allow.connect('clicked', () => {
|
||||
callDaemon('ComputerGrant', '(b)', [true], (error) => {
|
||||
row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.';
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
revoke.connect('clicked', () => {
|
||||
callDaemon('ComputerRevoke', null, null, (error) => {
|
||||
row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.';
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
row.add_suffix(allow);
|
||||
row.add_suffix(revoke);
|
||||
row.activatable_widget = allow;
|
||||
refresh();
|
||||
return row;
|
||||
}
|
||||
|
||||
export function fillSettingsWindow(window, settings, directory) {
|
||||
window.set_title('Jarvis QVAC');
|
||||
window.set_default_size(840, 780);
|
||||
window.search_enabled = true;
|
||||
const editor = new SettingsEditor(directory, settings);
|
||||
const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true);
|
||||
window.add(voice);
|
||||
const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic');
|
||||
window.add(listening);
|
||||
|
||||
const desktop = editor.page(window, 'Desktop', ['Desktop access', 'Desktop images'], 'preferences-desktop-display-symbolic');
|
||||
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts', description: 'These appearance settings take effect immediately.' });
|
||||
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
|
||||
desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color'));
|
||||
desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES));
|
||||
desktop.add(desktopGroup);
|
||||
const grantGroup = new Adw.PreferencesGroup({ title: 'Temporary desktop access' });
|
||||
grantGroup.add(grantRow()); desktop.add(grantGroup);
|
||||
window.add(desktop);
|
||||
const models = editor.page(window, 'Models', ['Chat model', 'Agent limits'], 'system-run-symbolic');
|
||||
const service = new Adw.PreferencesGroup({ title: 'Apply chat model changes' });
|
||||
const restart = new Adw.ActionRow({ title: 'Restart Jarvis', subtitle: 'Saves your changes and restarts the user service. Ends the current request and desktop grant.' });
|
||||
const restartButton = new Gtk.Button({ label: 'Save & Restart', valign: Gtk.Align.CENTER });
|
||||
restartButton.connect('clicked', async () => { restartButton.sensitive = false; try { await editor.restart(); } finally { restartButton.sensitive = true; } });
|
||||
restart.add_suffix(restartButton); service.add(restart); models.add(service);
|
||||
window.add(models);
|
||||
return { editor, pages: [voice, listening, desktop, models] };
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 16px; }
|
||||
.jarvis-panel-box { spacing: 6px; }
|
||||
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 14px; }
|
||||
.jarvis-panel-state { padding: 1px 8px; border-radius: 999px; background-color: rgba(244, 185, 66, .22); }
|
||||
.jarvis-panel-state-label { color: var(--jarvis-accent, #F4B942); font-size: 12px; font-weight: bold; }
|
||||
.jarvis-menu { max-width: 360px; }
|
||||
.jarvis-menu-item { padding: 0; }
|
||||
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
|
||||
@@ -33,8 +36,6 @@
|
||||
.jarvis-settings { font-size: 12px; padding: 4px 8px; }
|
||||
.jarvis-popup StEntry, .jarvis-session StEntry { border-radius: 10px; padding: 8px 10px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
|
||||
.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: #F4B942; }
|
||||
.jarvis-osd { padding: 8px 16px; border-radius: 999px; background-color: rgba(11, 14, 20, .9); border: 1px solid rgba(244, 185, 66, .45); }
|
||||
.jarvis-osd-label { color: #f6f7fb; font-size: 13px; font-weight: bold; }
|
||||
.jarvis-cu { padding: 8px 12px; spacing: 4px; border-radius: 12px; background-color: rgba(11, 14, 20, .82); border: 1px solid rgba(79, 210, 255, .4); }
|
||||
.jarvis-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; }
|
||||
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
|
||||
|
||||
@@ -250,41 +250,50 @@ export class ConversationView {
|
||||
destroy() { this.root.destroy(); }
|
||||
}
|
||||
|
||||
const PANEL_STATES = {
|
||||
LISTENING: 'Listening',
|
||||
SPEAKING: 'Speaking',
|
||||
THINKING: 'Thinking',
|
||||
SLEEPING: 'Privacy',
|
||||
};
|
||||
|
||||
export class JarvisOsd {
|
||||
constructor() {
|
||||
this.root = new St.BoxLayout({ style_class: 'jarvis-osd', visible: false });
|
||||
this.label = new St.Label({ text: '', style_class: 'jarvis-osd-label' });
|
||||
this.root = new St.BoxLayout({ style_class: 'jarvis-panel-state', visible: false, y_align: Clutter.ActorAlign.CENTER });
|
||||
this.label = new St.Label({ text: '', style_class: 'jarvis-panel-state-label', y_align: Clutter.ActorAlign.CENTER });
|
||||
this.root.add_child(this.label);
|
||||
this.root.accessible_name = 'Jarvis status overlay';
|
||||
this._sticky = false;
|
||||
this.root.accessible_name = 'Jarvis status';
|
||||
this._state = 'ARMED';
|
||||
this._wake = false;
|
||||
this._timeout = 0;
|
||||
}
|
||||
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
|
||||
_place() {
|
||||
const monitor = Main.layoutManager.primaryMonitor;
|
||||
if (!monitor) return;
|
||||
const width = 220;
|
||||
this.root.set_width(width);
|
||||
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 36);
|
||||
}
|
||||
show(text, { sticky = false, ms = 2000 } = {}) {
|
||||
this.label.text = safeText(text);
|
||||
this._sticky = sticky;
|
||||
this._place();
|
||||
this.root.visible = true;
|
||||
if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; }
|
||||
const limit = sticky ? Math.min(ms || 8000, 8000) : ms;
|
||||
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit, () => { this._timeout = 0; this.hide(); return GLib.SOURCE_REMOVE; });
|
||||
}
|
||||
attach(parent) { parent?.add_child?.(this.root); this.hide(); }
|
||||
setState(state) {
|
||||
if (state === 'LISTENING') this.show('Listening', { sticky: true, ms: 8000 });
|
||||
else if (state === 'SPEAKING') this.show('Speaking', { sticky: true, ms: 8000 });
|
||||
else if (state === 'SLEEPING') this.show('Privacy mode', { sticky: false, ms: 1600 });
|
||||
else this.hide();
|
||||
this._state = STATES.has(state) ? state : 'ARMED';
|
||||
this._wake = false;
|
||||
this._clearTimer();
|
||||
this._render();
|
||||
}
|
||||
showWake() { this.show('Wake', { sticky: false, ms: 1200 }); }
|
||||
hide() { this.root.visible = false; this._sticky = false; if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
|
||||
destroy() { this.hide(); this.root.destroy(); }
|
||||
showWake() {
|
||||
this._wake = true;
|
||||
this._clearTimer();
|
||||
this._render();
|
||||
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1200, () => {
|
||||
this._timeout = 0;
|
||||
this._wake = false;
|
||||
this._render();
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
_render() {
|
||||
const text = this._wake ? 'Wake' : (PANEL_STATES[this._state] || '');
|
||||
this.label.text = text;
|
||||
this.root.visible = Boolean(text);
|
||||
this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status';
|
||||
}
|
||||
_clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
|
||||
hide() { this._wake = false; this._clearTimer(); this.root.visible = false; }
|
||||
destroy() { this._clearTimer(); this.root.destroy(); }
|
||||
}
|
||||
|
||||
export class ComputerUseChrome {
|
||||
|
||||
Reference in New Issue
Block a user