amicus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI - Alias Editor Script
|
|
3
|
+
*
|
|
4
|
+
* Returns the inline JS for alias search, inline edit,
|
|
5
|
+
* delete, and add custom alias handlers.
|
|
6
|
+
* Extracted from setup-ui.js to keep file sizes under 300 lines.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the alias editor JS for inline inclusion in the wizard script
|
|
11
|
+
* @returns {string} JavaScript source (no <script> tags)
|
|
12
|
+
*/
|
|
13
|
+
function buildAliasScript() {
|
|
14
|
+
return `
|
|
15
|
+
// Alias editor: search
|
|
16
|
+
var aliasSearchInput = $('alias-search');
|
|
17
|
+
if (aliasSearchInput) {
|
|
18
|
+
aliasSearchInput.addEventListener('input', function() {
|
|
19
|
+
var q = this.value.toLowerCase();
|
|
20
|
+
document.querySelectorAll('.alias-group').forEach(function(group) {
|
|
21
|
+
var rows = group.querySelectorAll('.alias-row');
|
|
22
|
+
var anyVisible = false;
|
|
23
|
+
rows.forEach(function(row) {
|
|
24
|
+
var name = row.getAttribute('data-alias') || '';
|
|
25
|
+
var model = (row.querySelector('.alias-model') || {}).textContent || '';
|
|
26
|
+
var match = !q || name.includes(q) || model.toLowerCase().includes(q);
|
|
27
|
+
row.style.display = match ? '' : 'none';
|
|
28
|
+
if (match) { anyVisible = true; }
|
|
29
|
+
});
|
|
30
|
+
group.style.display = anyVisible ? '' : 'none';
|
|
31
|
+
if (q && anyVisible) { group.open = true; }
|
|
32
|
+
if (!q) { group.open = false; }
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper: filter model groups by a keyword (alias name)
|
|
38
|
+
// Splits keyword on hyphens so "gemini-pro" matches "gemini-3-pro-preview"
|
|
39
|
+
function filterModels(groups, keyword) {
|
|
40
|
+
if (!keyword || !groups || groups.length === 0) { return groups; }
|
|
41
|
+
var parts = keyword.toLowerCase().split('-').filter(function(p) { return p.length > 0; });
|
|
42
|
+
var filtered = [];
|
|
43
|
+
groups.forEach(function(g) {
|
|
44
|
+
var matching = g.models.filter(function(m) {
|
|
45
|
+
var id = m.id.toLowerCase();
|
|
46
|
+
var name = (m.name || '').toLowerCase();
|
|
47
|
+
return parts.every(function(p) { return id.includes(p) || name.includes(p); });
|
|
48
|
+
});
|
|
49
|
+
if (matching.length > 0) {
|
|
50
|
+
filtered.push({ family: g.family, models: matching });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// Fall back to all models when no matches found
|
|
54
|
+
if (filtered.length === 0) { return groups; }
|
|
55
|
+
return filtered;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Helper: build a model <select> dropdown from window.availableModels
|
|
59
|
+
function buildModelSelect(currentValue, cls, filterKeyword) {
|
|
60
|
+
var select = document.createElement('select');
|
|
61
|
+
select.className = cls || 'alias-model-select';
|
|
62
|
+
var groups = filterModels(window.availableModels, filterKeyword);
|
|
63
|
+
if (groups && groups.length > 0) {
|
|
64
|
+
groups.forEach(function(g) {
|
|
65
|
+
var optgroup = document.createElement('optgroup');
|
|
66
|
+
optgroup.label = g.family;
|
|
67
|
+
g.models.forEach(function(m) {
|
|
68
|
+
var opt = document.createElement('option');
|
|
69
|
+
opt.value = m.id;
|
|
70
|
+
opt.textContent = m.name || m.id;
|
|
71
|
+
if (m.id === currentValue) { opt.selected = true; }
|
|
72
|
+
optgroup.appendChild(opt);
|
|
73
|
+
});
|
|
74
|
+
select.appendChild(optgroup);
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
// Fallback: flat list from DEFAULT_ALIASES values
|
|
78
|
+
var seen = {};
|
|
79
|
+
Object.values(defaultAliases).forEach(function(v) {
|
|
80
|
+
if (!seen[v]) {
|
|
81
|
+
var opt = document.createElement('option');
|
|
82
|
+
opt.value = v; opt.textContent = v;
|
|
83
|
+
if (v === currentValue) { opt.selected = true; }
|
|
84
|
+
select.appendChild(opt);
|
|
85
|
+
seen[v] = true;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// If current value not in options, add it
|
|
90
|
+
if (currentValue && !select.querySelector('option[value="' + CSS.escape(currentValue) + '"]')) {
|
|
91
|
+
var custom = document.createElement('option');
|
|
92
|
+
custom.value = currentValue; custom.textContent = currentValue; custom.selected = true;
|
|
93
|
+
select.insertBefore(custom, select.firstChild);
|
|
94
|
+
}
|
|
95
|
+
return select;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Alias editor: inline edit
|
|
99
|
+
document.addEventListener('click', function(e) {
|
|
100
|
+
var nameSpan = e.target.closest('.alias-name');
|
|
101
|
+
var modelSpan = e.target.closest('.alias-model');
|
|
102
|
+
var span = nameSpan || modelSpan;
|
|
103
|
+
if (!span) { return; }
|
|
104
|
+
var row = span.closest('.alias-row');
|
|
105
|
+
if (!row || row.classList.contains('alias-deleted')) { return; }
|
|
106
|
+
var isName = !!nameSpan;
|
|
107
|
+
var origValue = span.textContent;
|
|
108
|
+
var origAlias = row.getAttribute('data-alias');
|
|
109
|
+
|
|
110
|
+
if (isName) {
|
|
111
|
+
var input = document.createElement('input');
|
|
112
|
+
input.className = 'alias-name-input';
|
|
113
|
+
input.value = origValue;
|
|
114
|
+
span.replaceWith(input);
|
|
115
|
+
input.focus();
|
|
116
|
+
function commitName() {
|
|
117
|
+
var newVal = input.value.trim();
|
|
118
|
+
var newSpan = document.createElement('span');
|
|
119
|
+
newSpan.className = 'alias-name';
|
|
120
|
+
newSpan.textContent = newVal || origValue;
|
|
121
|
+
input.replaceWith(newSpan);
|
|
122
|
+
if (newVal && newVal !== origAlias) {
|
|
123
|
+
aliasEdits[origAlias] = null;
|
|
124
|
+
var modelEl = row.querySelector('.alias-model') || row.querySelector('.alias-model-select');
|
|
125
|
+
var modelVal = modelEl ? (modelEl.value || modelEl.textContent) : '';
|
|
126
|
+
aliasEdits[newVal] = modelVal || defaultAliases[origAlias];
|
|
127
|
+
row.setAttribute('data-alias', newVal);
|
|
128
|
+
var delBtn = row.querySelector('.alias-delete');
|
|
129
|
+
if (delBtn) { delBtn.setAttribute('data-alias', newVal); }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
input.addEventListener('blur', commitName);
|
|
133
|
+
input.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { input.blur(); } });
|
|
134
|
+
} else {
|
|
135
|
+
var select = buildModelSelect(origValue, 'alias-model-select', origAlias);
|
|
136
|
+
span.replaceWith(select);
|
|
137
|
+
select.focus();
|
|
138
|
+
function commitModel() {
|
|
139
|
+
var newVal = select.value;
|
|
140
|
+
var newSpan = document.createElement('span');
|
|
141
|
+
newSpan.className = 'alias-model';
|
|
142
|
+
newSpan.textContent = newVal || origValue;
|
|
143
|
+
select.replaceWith(newSpan);
|
|
144
|
+
if (newVal && newVal !== origValue) {
|
|
145
|
+
aliasEdits[origAlias] = newVal;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
select.addEventListener('change', commitModel);
|
|
149
|
+
select.addEventListener('blur', commitModel);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Alias editor: delete
|
|
154
|
+
document.addEventListener('click', function(e) {
|
|
155
|
+
var btn = e.target.closest('.alias-delete');
|
|
156
|
+
if (!btn) { return; }
|
|
157
|
+
var alias = btn.getAttribute('data-alias');
|
|
158
|
+
if (!alias) { return; }
|
|
159
|
+
var row = btn.closest('.alias-row');
|
|
160
|
+
if (!row) { return; }
|
|
161
|
+
row.classList.add('alias-deleted');
|
|
162
|
+
if (defaultAliases[alias]) {
|
|
163
|
+
aliasEdits[alias] = null;
|
|
164
|
+
} else {
|
|
165
|
+
delete aliasEdits[alias];
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Alias editor: add custom shortcut
|
|
170
|
+
var addBtn = $('alias-add-btn');
|
|
171
|
+
if (addBtn) {
|
|
172
|
+
addBtn.addEventListener('click', function() {
|
|
173
|
+
var row = document.createElement('div');
|
|
174
|
+
row.className = 'alias-row';
|
|
175
|
+
var nameInput = document.createElement('input');
|
|
176
|
+
nameInput.className = 'alias-name-input';
|
|
177
|
+
nameInput.placeholder = 'shortcut';
|
|
178
|
+
var arrow = document.createElement('span');
|
|
179
|
+
arrow.className = 'alias-arrow';
|
|
180
|
+
arrow.textContent = '\\u2192';
|
|
181
|
+
var modelSelect = buildModelSelect('', 'alias-model-select');
|
|
182
|
+
var delBtn = document.createElement('button');
|
|
183
|
+
delBtn.className = 'alias-delete';
|
|
184
|
+
delBtn.textContent = '\\u00d7';
|
|
185
|
+
row.appendChild(nameInput);
|
|
186
|
+
row.appendChild(arrow);
|
|
187
|
+
row.appendChild(modelSelect);
|
|
188
|
+
row.appendChild(delBtn);
|
|
189
|
+
var editor = document.querySelector('.alias-editor');
|
|
190
|
+
if (editor) { editor.insertBefore(row, addBtn); }
|
|
191
|
+
nameInput.focus();
|
|
192
|
+
function commitNew() {
|
|
193
|
+
var n = nameInput.value.trim();
|
|
194
|
+
var m = modelSelect.value;
|
|
195
|
+
if (n && m) {
|
|
196
|
+
aliasEdits[n] = m;
|
|
197
|
+
row.setAttribute('data-alias', n);
|
|
198
|
+
var ns = document.createElement('span');
|
|
199
|
+
ns.className = 'alias-name'; ns.textContent = n;
|
|
200
|
+
nameInput.replaceWith(ns);
|
|
201
|
+
var ms = document.createElement('span');
|
|
202
|
+
ms.className = 'alias-model'; ms.textContent = m;
|
|
203
|
+
modelSelect.replaceWith(ms);
|
|
204
|
+
delBtn.setAttribute('data-alias', n);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
modelSelect.addEventListener('change', commitNew);
|
|
208
|
+
nameInput.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { nameInput.blur(); commitNew(); } });
|
|
209
|
+
delBtn.addEventListener('click', function() {
|
|
210
|
+
var a = row.getAttribute('data-alias');
|
|
211
|
+
if (a) { delete aliasEdits[a]; }
|
|
212
|
+
row.remove();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { buildAliasScript };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI - Alias Editor
|
|
3
|
+
*
|
|
4
|
+
* Builds collapsible alias groups with search, inline editing,
|
|
5
|
+
* delete, and add functionality for the setup wizard Step 3.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Grouping metadata for the 20 default aliases */
|
|
9
|
+
const ALIAS_GROUPS = [
|
|
10
|
+
{ name: 'Gemini', keys: ['gemini', 'gemini-pro'] },
|
|
11
|
+
{ name: 'GPT', keys: ['gpt', 'gpt-pro', 'codex'] },
|
|
12
|
+
{ name: 'Claude', keys: ['claude', 'sonnet', 'opus', 'haiku'] },
|
|
13
|
+
{ name: 'DeepSeek', keys: ['deepseek'] },
|
|
14
|
+
{ name: 'Qwen', keys: ['qwen', 'qwen-coder', 'qwen-flash'] },
|
|
15
|
+
{ name: 'Mistral', keys: ['mistral', 'devstral'] },
|
|
16
|
+
{ name: 'Other', keys: ['glm', 'minimax', 'grok', 'kimi', 'seed'] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the HTML fragment for the alias editor section
|
|
21
|
+
* @param {Object<string,string>} aliases - Map of alias name to model string
|
|
22
|
+
* @returns {string} HTML fragment with search, groups, rows, and add button
|
|
23
|
+
*/
|
|
24
|
+
function buildAliasEditorHTML(aliases) {
|
|
25
|
+
const searchInput = `<input type="text" id="alias-search" class="alias-search" placeholder="Search aliases..." autocomplete="off" spellcheck="false">`;
|
|
26
|
+
|
|
27
|
+
const groups = ALIAS_GROUPS.map(group => {
|
|
28
|
+
const rows = group.keys
|
|
29
|
+
.filter(key => aliases[key] !== undefined)
|
|
30
|
+
.map(key => {
|
|
31
|
+
const model = aliases[key];
|
|
32
|
+
return `<div class="alias-row" data-alias="${key}">` +
|
|
33
|
+
`<span class="alias-name">${key}</span>` +
|
|
34
|
+
`<span class="alias-arrow">\u2192</span>` +
|
|
35
|
+
`<span class="alias-model">${model}</span>` +
|
|
36
|
+
`<button class="alias-delete" data-alias="${key}">\u00d7</button>` +
|
|
37
|
+
`</div>`;
|
|
38
|
+
}).join('\n ');
|
|
39
|
+
|
|
40
|
+
const count = group.keys.filter(key => aliases[key] !== undefined).length;
|
|
41
|
+
|
|
42
|
+
return `<details class="alias-group">
|
|
43
|
+
<summary>${group.name} <span class="alias-count">(${count})</span></summary>
|
|
44
|
+
${rows}
|
|
45
|
+
</details>`;
|
|
46
|
+
}).join('\n ');
|
|
47
|
+
|
|
48
|
+
// Pick a representative example from actual aliases
|
|
49
|
+
const exampleAlias = 'gemini';
|
|
50
|
+
// Fallback literal kept in sync with curated-models (toDefaultAliases().gemini)
|
|
51
|
+
const exampleModel = aliases[exampleAlias] || 'openrouter/google/gemini-3.1-flash-lite-preview';
|
|
52
|
+
|
|
53
|
+
// SVG icons for the example box
|
|
54
|
+
const terminalIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="14" height="12" rx="2" stroke="#D97757" stroke-width="1.5"/><path d="M4 6l2.5 2L4 10" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 10H11" stroke="#5A5550" stroke-width="1.5" stroke-linecap="round"/></svg>`;
|
|
55
|
+
const arrowIcon = `<svg width="20" height="12" viewBox="0 0 20 12" fill="none"><path d="M2 6h14" stroke="#D97757" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2l4 4-4 4" stroke="#D97757" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
56
|
+
const modelIcon = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5" r="3" stroke="#6BBF6B" stroke-width="1.5"/><path d="M8 8v3" stroke="#6BBF6B" stroke-width="1.5" stroke-linecap="round"/><circle cx="4" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="8" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><circle cx="12" cy="13" r="1.5" stroke="#6BBF6B" stroke-width="1.2"/><path d="M4 11.5L8 11M8 11l4 .5" stroke="#6BBF6B" stroke-width="1" stroke-linecap="round"/></svg>`;
|
|
57
|
+
|
|
58
|
+
const exampleBox = `<div class="routing-example">
|
|
59
|
+
<div class="example-label">How it works</div>
|
|
60
|
+
<div class="example-flow">
|
|
61
|
+
<div class="example-step">
|
|
62
|
+
${terminalIcon}
|
|
63
|
+
<span class="example-cmd">amicus start --model <strong>${exampleAlias}</strong></span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="example-connector">${arrowIcon}</div>
|
|
66
|
+
<div class="example-step">
|
|
67
|
+
${modelIcon}
|
|
68
|
+
<span class="example-model">${exampleModel}</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>`;
|
|
72
|
+
|
|
73
|
+
return `<div class="step-content">
|
|
74
|
+
<h1>Model Routing</h1>
|
|
75
|
+
<p class="subtitle">When you ask Amicus for help, you can pick which LLM to collaborate with or offload tasks to. These names on the left route to the specific model on the right.</p>
|
|
76
|
+
${exampleBox}
|
|
77
|
+
<div class="alias-editor">
|
|
78
|
+
${searchInput}
|
|
79
|
+
${groups}
|
|
80
|
+
<button class="alias-add-btn" id="alias-add-btn">+ Add Custom Route</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { ALIAS_GROUPS, buildAliasEditorHTML };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI - Step 1 Key Management Script
|
|
3
|
+
*
|
|
4
|
+
* Returns the inline JS for provider selection, key input,
|
|
5
|
+
* test/save, eye toggle, and remove handlers.
|
|
6
|
+
* Extracted from setup-ui.js to keep file sizes under 300 lines.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the key management JS for inline inclusion in the wizard script
|
|
11
|
+
* @returns {string} JavaScript source (no <script> tags)
|
|
12
|
+
*/
|
|
13
|
+
function buildKeysScript() {
|
|
14
|
+
return `
|
|
15
|
+
// Step 1: Provider selection & key management
|
|
16
|
+
document.querySelectorAll('.provider-btn').forEach(function(btn) {
|
|
17
|
+
btn.addEventListener('click', function() {
|
|
18
|
+
var id = this.getAttribute('data-provider');
|
|
19
|
+
var prov = providers.find(function(p) { return p.id === id; });
|
|
20
|
+
if (!prov) { return; }
|
|
21
|
+
selectedProvider = prov;
|
|
22
|
+
document.querySelectorAll('.provider-btn').forEach(function(b) { b.classList.remove('selected'); });
|
|
23
|
+
this.classList.add('selected');
|
|
24
|
+
keySection.classList.add('visible');
|
|
25
|
+
keyLabel.textContent = prov.name + ' API Key';
|
|
26
|
+
keyInput.placeholder = prov.placeholder;
|
|
27
|
+
if (keyHints[prov.id]) {
|
|
28
|
+
keyInput.value = keyHints[prov.id]; keyInput.type = 'text'; keyValid = false;
|
|
29
|
+
setInputState('valid');
|
|
30
|
+
statusMsg.textContent = 'Key configured \\u2714'; statusMsg.className = 'status-valid';
|
|
31
|
+
removeBtn.style.display = '';
|
|
32
|
+
} else {
|
|
33
|
+
keyInput.value = ''; keyInput.type = 'password'; keyValid = false;
|
|
34
|
+
setInputState(null);
|
|
35
|
+
statusMsg.textContent = ''; statusMsg.className = '';
|
|
36
|
+
removeBtn.style.display = 'none';
|
|
37
|
+
}
|
|
38
|
+
eyeBtn.classList.remove('active'); keyInput.focus();
|
|
39
|
+
var a = document.createElement('a');
|
|
40
|
+
a.href = prov.helpUrl; a.textContent = prov.helpLabel;
|
|
41
|
+
helpLink.textContent = "Don't have a key? Get one at ";
|
|
42
|
+
helpLink.appendChild(a);
|
|
43
|
+
a.addEventListener('click', function(e) {
|
|
44
|
+
e.preventDefault();
|
|
45
|
+
if (window.sidecarSetup && window.sidecarSetup.openExternal) { window.sidecarSetup.openExternal(this.href); }
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function setInputState(state) {
|
|
51
|
+
keyInput.classList.remove('input-valid', 'input-invalid', 'input-testing');
|
|
52
|
+
if (state) { keyInput.classList.add('input-' + state); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
testBtn.addEventListener('click', async function() {
|
|
56
|
+
if (!selectedProvider) { return; }
|
|
57
|
+
var key = keyInput.value.trim();
|
|
58
|
+
if (!key) {
|
|
59
|
+
statusMsg.textContent = 'Please enter an API key'; statusMsg.className = 'status-invalid';
|
|
60
|
+
setInputState('invalid'); return;
|
|
61
|
+
}
|
|
62
|
+
testBtn.disabled = true; testBtn.textContent = 'Testing...';
|
|
63
|
+
statusMsg.textContent = ''; statusMsg.className = ''; setInputState('testing');
|
|
64
|
+
try {
|
|
65
|
+
var res = await window.sidecarSetup.invoke('sidecar:validate-key', selectedProvider.id, key);
|
|
66
|
+
if (res.valid) {
|
|
67
|
+
await window.sidecarSetup.invoke('sidecar:save-key', selectedProvider.id, key);
|
|
68
|
+
configuredKeys[selectedProvider.id] = true;
|
|
69
|
+
var c = document.getElementById('check-' + selectedProvider.id);
|
|
70
|
+
if (c) { c.textContent = '\\u2713'; }
|
|
71
|
+
statusMsg.textContent = 'Saved \\u2713'; statusMsg.className = 'status-valid';
|
|
72
|
+
setInputState('valid'); keyValid = true; validatedKey = key;
|
|
73
|
+
keyHints[selectedProvider.id] = key.slice(0, 8) + '\\u2022'.repeat(Math.min(key.length - 8, 12));
|
|
74
|
+
removeBtn.style.display = ''; updateNextState();
|
|
75
|
+
} else {
|
|
76
|
+
statusMsg.textContent = res.error || 'Invalid key'; statusMsg.className = 'status-invalid';
|
|
77
|
+
setInputState('invalid'); keyValid = false;
|
|
78
|
+
}
|
|
79
|
+
} catch (_e) {
|
|
80
|
+
statusMsg.textContent = 'Connection failed'; statusMsg.className = 'status-invalid';
|
|
81
|
+
setInputState('invalid'); keyValid = false;
|
|
82
|
+
}
|
|
83
|
+
testBtn.disabled = false; testBtn.textContent = 'Save \\u0026 Test';
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
keyInput.addEventListener('input', function() {
|
|
87
|
+
if (keyInput.type === 'text') { keyInput.type = 'password'; }
|
|
88
|
+
if (keyValid && keyInput.value.trim() !== validatedKey) { keyValid = false; statusMsg.textContent = ''; setInputState(null); }
|
|
89
|
+
if (!keyInput.value.trim()) { statusMsg.textContent = ''; statusMsg.className = ''; setInputState(null); }
|
|
90
|
+
});
|
|
91
|
+
keyInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') { testBtn.click(); } });
|
|
92
|
+
|
|
93
|
+
eyeBtn.addEventListener('click', function() {
|
|
94
|
+
if (keyInput.type === 'password') { keyInput.type = 'text'; eyeBtn.classList.add('active'); }
|
|
95
|
+
else { keyInput.type = 'password'; eyeBtn.classList.remove('active'); }
|
|
96
|
+
keyInput.focus();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
removeBtn.addEventListener('click', async function() {
|
|
100
|
+
if (!selectedProvider) { return; }
|
|
101
|
+
removeBtn.disabled = true;
|
|
102
|
+
try {
|
|
103
|
+
await window.sidecarSetup.invoke('sidecar:remove-key', selectedProvider.id);
|
|
104
|
+
delete configuredKeys[selectedProvider.id]; delete keyHints[selectedProvider.id];
|
|
105
|
+
var c = document.getElementById('check-' + selectedProvider.id);
|
|
106
|
+
if (c) { c.textContent = ''; }
|
|
107
|
+
keyInput.value = ''; keyInput.type = 'password'; keyValid = false; setInputState(null);
|
|
108
|
+
statusMsg.textContent = 'Key removed'; statusMsg.className = 'status-testing';
|
|
109
|
+
removeBtn.style.display = 'none'; updateNextState();
|
|
110
|
+
} catch (_e) { statusMsg.textContent = 'Failed to remove'; statusMsg.className = 'status-invalid'; }
|
|
111
|
+
removeBtn.disabled = false;
|
|
112
|
+
});`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { buildKeysScript };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI - Step 1: API Keys
|
|
3
|
+
*
|
|
4
|
+
* Builds the HTML for the API key configuration step of the wizard.
|
|
5
|
+
* Supports multiple providers with checkmarks for configured keys,
|
|
6
|
+
* test connection, and save per provider.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Provider metadata for the setup form */
|
|
10
|
+
const PROVIDERS = [
|
|
11
|
+
{
|
|
12
|
+
id: 'openrouter',
|
|
13
|
+
name: 'OpenRouter',
|
|
14
|
+
description: 'Access all models (Gemini, GPT, Claude, etc.) with one key',
|
|
15
|
+
placeholder: 'sk-or-v1-...',
|
|
16
|
+
helpUrl: 'https://openrouter.ai/keys',
|
|
17
|
+
helpLabel: 'openrouter.ai/keys',
|
|
18
|
+
recommended: true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'google',
|
|
22
|
+
name: 'Google AI (Gemini)',
|
|
23
|
+
description: 'Direct access to Gemini models',
|
|
24
|
+
placeholder: 'AIza...',
|
|
25
|
+
helpUrl: 'https://aistudio.google.com/apikey',
|
|
26
|
+
helpLabel: 'aistudio.google.com/apikey',
|
|
27
|
+
recommended: false
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'openai',
|
|
31
|
+
name: 'OpenAI',
|
|
32
|
+
description: 'Direct access to GPT models',
|
|
33
|
+
placeholder: 'sk-...',
|
|
34
|
+
helpUrl: 'https://platform.openai.com/api-keys',
|
|
35
|
+
helpLabel: 'platform.openai.com/api-keys',
|
|
36
|
+
recommended: false
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'anthropic',
|
|
40
|
+
name: 'Anthropic',
|
|
41
|
+
description: 'Direct access to Claude models',
|
|
42
|
+
placeholder: 'sk-ant-...',
|
|
43
|
+
helpUrl: 'https://console.anthropic.com/settings/keys',
|
|
44
|
+
helpLabel: 'console.anthropic.com/settings/keys',
|
|
45
|
+
recommended: false
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the HTML fragment for Step 1 (API Keys)
|
|
51
|
+
* @param {Array} providers - Provider metadata array
|
|
52
|
+
* @returns {string} HTML fragment (not a full document)
|
|
53
|
+
*/
|
|
54
|
+
function buildKeysStepHTML(providers) {
|
|
55
|
+
const providerCards = providers.map(p => {
|
|
56
|
+
const badge = p.recommended
|
|
57
|
+
? '<span class="badge">Recommended</span>'
|
|
58
|
+
: '';
|
|
59
|
+
return `<button class="provider-btn" data-provider="${p.id}">
|
|
60
|
+
<span class="provider-name">${p.name}${badge}</span>
|
|
61
|
+
<span class="provider-desc">${p.description}</span>
|
|
62
|
+
<span class="provider-check" id="check-${p.id}"></span>
|
|
63
|
+
</button>`;
|
|
64
|
+
}).join('\n ');
|
|
65
|
+
|
|
66
|
+
return `<div class="step-content">
|
|
67
|
+
<h1>Configure API Keys</h1>
|
|
68
|
+
<p class="subtitle">Add at least one API key to get started.</p>
|
|
69
|
+
|
|
70
|
+
<div class="provider-list" id="provider-list">
|
|
71
|
+
${providerCards}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="key-section" id="key-section">
|
|
75
|
+
<label class="field-label" id="key-label" for="api-key-input">API Key</label>
|
|
76
|
+
<div class="input-row">
|
|
77
|
+
<div class="input-wrap">
|
|
78
|
+
<input id="api-key-input" type="password" placeholder="" autocomplete="off" spellcheck="false">
|
|
79
|
+
<button class="eye-btn" id="eye-btn" type="button" title="Show/hide key">
|
|
80
|
+
<svg id="eye-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
81
|
+
<path d="M8 3C4.4 3 1.4 5.4.5 8c.9 2.6 3.9 5 7.5 5s6.6-2.4 7.5-5c-.9-2.6-3.9-5-7.5-5z" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
|
82
|
+
<circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.2" fill="none"/>
|
|
83
|
+
</svg>
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
<button class="test-btn" id="test-btn">Save & Test</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="key-actions">
|
|
89
|
+
<span id="status-msg"></span>
|
|
90
|
+
<button class="remove-btn" id="remove-btn" type="button" style="display:none">Remove key</button>
|
|
91
|
+
</div>
|
|
92
|
+
<p class="help-link" id="help-link"></p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { buildKeysStepHTML, PROVIDERS };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup UI - Step 2: Default Model Selection
|
|
3
|
+
*
|
|
4
|
+
* Builds the HTML for the model selection step of the wizard.
|
|
5
|
+
* Renders radio card choices with provider routing and pre-selection support.
|
|
6
|
+
* Models are disabled when no configured API key matches their routes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { getCuratedModels } = require('../src/utils/curated-models');
|
|
10
|
+
/**
|
|
11
|
+
* Wizard quick-pick cards \u2014 derived from curated-models (F5).
|
|
12
|
+
* @type {Array<{alias: string, label: string, routes: Object<string,string>}>}
|
|
13
|
+
*/
|
|
14
|
+
const MODEL_CHOICES = getCuratedModels().map(c => ({
|
|
15
|
+
alias: c.alias, label: `${c.label} \u2014 ${c.blurb}`, routes: c.routes
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const PROVIDER_NAMES = {
|
|
19
|
+
openrouter: 'OpenRouter',
|
|
20
|
+
google: 'Google AI',
|
|
21
|
+
openai: 'OpenAI',
|
|
22
|
+
anthropic: 'Anthropic'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a model has at least one route with a configured key.
|
|
27
|
+
* When no keys are configured at all (empty configuredKeys), all models are available
|
|
28
|
+
* to allow the initial render before Step 1 completes.
|
|
29
|
+
* @param {string[]} providers - Route provider IDs for this model
|
|
30
|
+
* @param {Object<string,boolean>} configuredKeys - Which providers have keys
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
function isModelAvailable(providers, configuredKeys) {
|
|
34
|
+
const hasAnyKey = Object.values(configuredKeys).some(v => v);
|
|
35
|
+
if (!hasAnyKey) { return true; }
|
|
36
|
+
return providers.some(p => configuredKeys[p]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find the best available provider for a model's static route text.
|
|
41
|
+
* Prefers the first provider with a configured key; falls back to first provider.
|
|
42
|
+
* @param {string[]} providers - Route provider IDs
|
|
43
|
+
* @param {Object<string,boolean>} configuredKeys - Which providers have keys
|
|
44
|
+
* @returns {string} Provider ID to display
|
|
45
|
+
*/
|
|
46
|
+
function bestAvailableProvider(providers, configuredKeys) {
|
|
47
|
+
const withKey = providers.find(p => configuredKeys[p]);
|
|
48
|
+
return withKey || providers[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Search-over-catalog section (F5). Hidden until the wizard script confirms
|
|
53
|
+
* a non-empty catalog; rows are rendered client-side from the get-catalog IPC.
|
|
54
|
+
* @returns {string} HTML fragment
|
|
55
|
+
*/
|
|
56
|
+
function buildModelSearchHTML() {
|
|
57
|
+
return `<div id="model-search-section" style="display:none">
|
|
58
|
+
<div class="search-head">
|
|
59
|
+
<input type="text" id="model-search-input" placeholder="Search all models (id or name)..." autocomplete="off">
|
|
60
|
+
<button class="icon-btn" id="model-search-refresh" title="Refresh catalog">↻</button>
|
|
61
|
+
</div>
|
|
62
|
+
<div id="model-search-meta" class="search-meta"></div>
|
|
63
|
+
<div id="model-search-results" class="search-results"></div>
|
|
64
|
+
</div>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the HTML fragment for Step 2 (Model Selection)
|
|
69
|
+
* @param {Array<{alias: string, label: string, routes: Object<string,string>}>} choices
|
|
70
|
+
* @param {string} [selectedAlias] - Pre-selected alias, defaults to first available choice
|
|
71
|
+
* @param {Object<string,boolean>} [configuredKeys] - Provider IDs the user has keys for
|
|
72
|
+
* @returns {string} HTML fragment
|
|
73
|
+
*/
|
|
74
|
+
function buildModelStepHTML(choices, selectedAlias, configuredKeys = {}) {
|
|
75
|
+
// Determine availability for each model
|
|
76
|
+
const availability = choices.map(c => {
|
|
77
|
+
const providers = Object.keys(c.routes);
|
|
78
|
+
return { alias: c.alias, available: isModelAvailable(providers, configuredKeys) };
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Select: prefer selectedAlias if available, else first available model
|
|
82
|
+
const availableAliases = availability.filter(a => a.available).map(a => a.alias);
|
|
83
|
+
let selected;
|
|
84
|
+
if (selectedAlias && availableAliases.includes(selectedAlias)) {
|
|
85
|
+
selected = selectedAlias;
|
|
86
|
+
} else if (availableAliases.length > 0) {
|
|
87
|
+
selected = availableAliases[0];
|
|
88
|
+
} else {
|
|
89
|
+
selected = choices[0].alias;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const cards = choices.map(c => {
|
|
93
|
+
const providers = Object.keys(c.routes);
|
|
94
|
+
const available = providers.filter(p => configuredKeys[p]);
|
|
95
|
+
const modelAvailable = availability.find(a => a.alias === c.alias).available;
|
|
96
|
+
const checked = (c.alias === selected && modelAvailable) ? 'checked' : '';
|
|
97
|
+
const disabled = modelAvailable ? '' : ' disabled';
|
|
98
|
+
const cardClass = modelAvailable ? 'model-card' : 'model-card model-unavailable';
|
|
99
|
+
const hasMultipleRoutes = providers.length >= 2;
|
|
100
|
+
const showToggle = available.length >= 2;
|
|
101
|
+
const bestProvider = bestAvailableProvider(providers, configuredKeys);
|
|
102
|
+
|
|
103
|
+
let routeHtml = '';
|
|
104
|
+
if (!modelAvailable) {
|
|
105
|
+
routeHtml = '<span class="no-key-hint">No API key configured</span>';
|
|
106
|
+
} else if (hasMultipleRoutes) {
|
|
107
|
+
const pills = providers.map(p => {
|
|
108
|
+
const isActive = (showToggle && p === bestProvider) || (!showToggle && p === bestProvider);
|
|
109
|
+
const cls = isActive ? 'route-pill active' : 'route-pill';
|
|
110
|
+
return `<button class="${cls}" data-alias="${c.alias}" data-provider="${p}">${PROVIDER_NAMES[p]}</button>`;
|
|
111
|
+
}).join('');
|
|
112
|
+
const toggleDisplay = showToggle ? '' : ' style="display:none"';
|
|
113
|
+
const staticDisplay = showToggle ? ' style="display:none"' : '';
|
|
114
|
+
routeHtml = `<span class="route-toggle" data-alias="${c.alias}"${toggleDisplay}>${pills}</span>`;
|
|
115
|
+
routeHtml += `<span class="route-static" data-alias="${c.alias}"${staticDisplay}>via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
116
|
+
} else {
|
|
117
|
+
routeHtml = `<span class="route-static">via ${PROVIDER_NAMES[bestProvider]}</span>`;
|
|
118
|
+
}
|
|
119
|
+
return `<label class="${cardClass}">
|
|
120
|
+
<input type="radio" name="default-model" value="${c.alias}" ${checked}${disabled}>
|
|
121
|
+
<span class="model-alias">${c.alias}</span>
|
|
122
|
+
<span class="model-label">${c.label}</span>
|
|
123
|
+
${routeHtml}
|
|
124
|
+
</label>`;
|
|
125
|
+
}).join('\n ');
|
|
126
|
+
|
|
127
|
+
return `<div class="step-content">
|
|
128
|
+
<h1>Choose Default Model</h1>
|
|
129
|
+
<p class="subtitle">Pick the model to use when no --model flag is given.</p>
|
|
130
|
+
|
|
131
|
+
<div class="model-list" id="model-list">
|
|
132
|
+
${cards}
|
|
133
|
+
</div>
|
|
134
|
+
${buildModelSearchHTML()}
|
|
135
|
+
</div>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { buildModelSearchHTML, buildModelStepHTML, MODEL_CHOICES, PROVIDER_NAMES };
|