acdev 1.0.4 → 1.0.6
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/.acdev/.env.example +3 -0
- package/README.md +25 -13
- package/bin/acdev.js +23 -18
- package/package.json +1 -1
- package/public/app.js +1167 -124
- package/public/index.html +154 -17
- package/public/styles.css +590 -33
- package/src/agent.js +122 -64
- package/src/claude-auth.js +7 -13
- package/src/config.js +181 -11
- package/src/gh-auth.js +11 -10
- package/src/models.js +230 -39
- package/src/openrouter-auth.js +37 -0
- package/src/server.js +59 -15
package/public/app.js
CHANGED
|
@@ -127,10 +127,14 @@ let reviewExcludedByJob = {};
|
|
|
127
127
|
let settingsSaving = false;
|
|
128
128
|
/** @type {'ticket' | 'auth' | 'rules' | 'config'} */
|
|
129
129
|
let settingsTab = 'ticket';
|
|
130
|
+
/** Sentinel: no model selected (jobs blocked until user picks one). */
|
|
131
|
+
const NO_MODEL = '-';
|
|
130
132
|
/** @type {string} */
|
|
131
133
|
let currentModel = 'claude-sonnet-5';
|
|
132
134
|
/** @type {Array<{id:string,label?:string,name?:string}>} */
|
|
133
135
|
let availableModels = [];
|
|
136
|
+
/** @type {'claude' | 'openrouter' | null} */
|
|
137
|
+
let availableModelsProvider = null;
|
|
134
138
|
/** @type {'github' | 'jira'} */
|
|
135
139
|
let ticketSource = 'github';
|
|
136
140
|
/** @type {{
|
|
@@ -163,6 +167,12 @@ let ticketSource = 'github';
|
|
|
163
167
|
* anthropicApiKeyMasked?: string | null,
|
|
164
168
|
* claudeOauthTokenSet?: boolean,
|
|
165
169
|
* claudeOauthTokenMasked?: string | null,
|
|
170
|
+
* llmProvider?: 'claude' | 'openrouter',
|
|
171
|
+
* lastModelsByProvider?: Record<string, string>,
|
|
172
|
+
* openrouterAuthOk?: boolean,
|
|
173
|
+
* openrouterApiKeySet?: boolean,
|
|
174
|
+
* openrouterApiKeyMasked?: string | null,
|
|
175
|
+
* llmAuthOk?: boolean,
|
|
166
176
|
* stubAgent?: boolean,
|
|
167
177
|
* }} */
|
|
168
178
|
let appConfig = {};
|
|
@@ -174,18 +184,33 @@ const els = {
|
|
|
174
184
|
repoName: document.getElementById('repo-name'),
|
|
175
185
|
repoBranch: document.getElementById('repo-branch'),
|
|
176
186
|
ticketSourceMeta: document.getElementById('ticket-source-meta'),
|
|
187
|
+
modelProvider: document.getElementById('model-provider'),
|
|
177
188
|
modelCurrent: document.getElementById('model-current'),
|
|
189
|
+
modelCombined: document.getElementById('model-combined'),
|
|
190
|
+
overviewLlmProvider: document.getElementById('overview-llm-provider'),
|
|
191
|
+
overviewModel: document.getElementById('overview-model'),
|
|
192
|
+
overviewAgentPicker: document.getElementById('overview-agent-picker'),
|
|
193
|
+
overviewAgentTrigger: document.getElementById('overview-agent-trigger'),
|
|
194
|
+
overviewAgentValue: document.getElementById('overview-agent-value'),
|
|
195
|
+
overviewAgentIcon: document.getElementById('overview-agent-icon'),
|
|
196
|
+
overviewAgentPanel: document.getElementById('overview-agent-panel'),
|
|
197
|
+
overviewAgentSearch: document.getElementById('overview-agent-search'),
|
|
198
|
+
overviewAgentModelList: document.getElementById('overview-agent-model-list'),
|
|
199
|
+
overviewLlmFeedback: document.getElementById('overview-llm-feedback'),
|
|
178
200
|
navBadgeOverview: document.getElementById('nav-badge-overview'),
|
|
179
201
|
navBadgeRuns: document.getElementById('nav-badge-runs'),
|
|
180
202
|
navBadgeReview: document.getElementById('nav-badge-review'),
|
|
181
203
|
navBadgeAlerts: document.getElementById('nav-badge-alerts'),
|
|
182
|
-
authBanner: document.getElementById('auth-banner'),
|
|
183
|
-
authBannerText: document.getElementById('auth-banner-text'),
|
|
184
|
-
authBannerBtn: document.getElementById('auth-banner-btn'),
|
|
185
204
|
issueUrls: document.getElementById('issue-urls'),
|
|
186
205
|
issueUrlsLabel: document.getElementById('issue-urls-label'),
|
|
206
|
+
enqueueQueuedCount: document.getElementById('enqueue-queued-count'),
|
|
207
|
+
enqueueInputHint: document.getElementById('enqueue-input-hint'),
|
|
208
|
+
enqueueHintExample: document.getElementById('enqueue-hint-example'),
|
|
209
|
+
enqueueInfoText: document.getElementById('enqueue-info-text'),
|
|
210
|
+
enqueueJiraHint: document.getElementById('enqueue-jira-hint'),
|
|
211
|
+
enqueueJiraSettingsLink: document.getElementById('enqueue-jira-settings-link'),
|
|
212
|
+
overviewTicketSource: document.getElementById('overview-ticket-source'),
|
|
187
213
|
enqueueFeedback: document.getElementById('enqueue-feedback'),
|
|
188
|
-
enqueueHint: document.getElementById('enqueue-hint'),
|
|
189
214
|
addBtn: document.getElementById('add-btn'),
|
|
190
215
|
statsGrid: document.getElementById('stats-grid'),
|
|
191
216
|
overviewRunning: document.getElementById('overview-running'),
|
|
@@ -241,7 +266,15 @@ const els = {
|
|
|
241
266
|
settingsForm: document.getElementById('settings-form'),
|
|
242
267
|
settingsBaseBranch: document.getElementById('settings-base-branch'),
|
|
243
268
|
settingsModel: document.getElementById('settings-model'),
|
|
269
|
+
settingsModelPicker: document.getElementById('settings-model-picker'),
|
|
270
|
+
settingsModelTrigger: document.getElementById('settings-model-trigger'),
|
|
271
|
+
settingsModelValue: document.getElementById('settings-model-value'),
|
|
272
|
+
settingsModelPanel: document.getElementById('settings-model-panel'),
|
|
273
|
+
settingsModelList: document.getElementById('settings-model-list'),
|
|
274
|
+
settingsModelSearch: document.getElementById('settings-model-search'),
|
|
244
275
|
settingsModelHint: document.getElementById('settings-model-hint'),
|
|
276
|
+
settingsLlmProvider: document.getElementById('settings-llm-provider'),
|
|
277
|
+
settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
|
|
245
278
|
settingsMaxTurns: document.getElementById('settings-max-turns'),
|
|
246
279
|
settingsTimeout: document.getElementById('settings-timeout'),
|
|
247
280
|
settingsTimeoutMs: document.getElementById('settings-timeout-ms'),
|
|
@@ -282,6 +315,10 @@ const els = {
|
|
|
282
315
|
settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
|
|
283
316
|
settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
|
|
284
317
|
settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
|
|
318
|
+
settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
|
|
319
|
+
settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
|
|
320
|
+
settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
|
|
321
|
+
settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
|
|
285
322
|
settingsTabs: document.getElementById('settings-tabs'),
|
|
286
323
|
};
|
|
287
324
|
|
|
@@ -1911,6 +1948,16 @@ function updateNavBadges(jobs) {
|
|
|
1911
1948
|
setNavBadge(els.navBadgeRuns, running);
|
|
1912
1949
|
setNavBadge(els.navBadgeReview, review);
|
|
1913
1950
|
setNavBadge(els.navBadgeAlerts, alerts);
|
|
1951
|
+
updateEnqueueQueuedCount(queued);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* @param {number} count
|
|
1956
|
+
*/
|
|
1957
|
+
function updateEnqueueQueuedCount(count) {
|
|
1958
|
+
if (!els.enqueueQueuedCount) return;
|
|
1959
|
+
const n = Number.isFinite(count) && count >= 0 ? count : 0;
|
|
1960
|
+
els.enqueueQueuedCount.textContent = `${n} queued`;
|
|
1914
1961
|
}
|
|
1915
1962
|
|
|
1916
1963
|
function collectAlerts(jobs) {
|
|
@@ -2928,93 +2975,834 @@ async function readJson(res) {
|
|
|
2928
2975
|
}
|
|
2929
2976
|
}
|
|
2930
2977
|
|
|
2978
|
+
function llmProviderLabel(provider) {
|
|
2979
|
+
return provider === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
/**
|
|
2983
|
+
* @param {'claude' | 'openrouter'} [provider]
|
|
2984
|
+
* @param {string} [modelId]
|
|
2985
|
+
* @returns {string}
|
|
2986
|
+
*/
|
|
2987
|
+
function agentDisplayText(provider = currentLlmProviderSelection(), modelId = currentModel) {
|
|
2988
|
+
const providerName = llmProviderLabel(provider);
|
|
2989
|
+
const modelText = isNoModelSelection(modelId) ? '—' : modelPickerDisplayText(modelId);
|
|
2990
|
+
return `${providerName} • ${modelText}`;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2931
2993
|
function updateModelLabel() {
|
|
2932
|
-
|
|
2933
|
-
const
|
|
2934
|
-
|
|
2935
|
-
|
|
2994
|
+
const provider = appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
2995
|
+
const providerName = llmProviderLabel(provider);
|
|
2996
|
+
const modelText = isNoModelSelection(currentModel) ? '—' : currentModel;
|
|
2997
|
+
const modelTitle = isNoModelSelection(currentModel)
|
|
2998
|
+
? 'none selected'
|
|
2999
|
+
: currentModel;
|
|
3000
|
+
const combined = agentDisplayText(provider, currentModel);
|
|
3001
|
+
|
|
3002
|
+
if (els.modelProvider) {
|
|
3003
|
+
els.modelProvider.textContent = providerName;
|
|
3004
|
+
els.modelProvider.title = `${providerName} — switch in Overview or Settings`;
|
|
3005
|
+
}
|
|
3006
|
+
if (els.modelCurrent) {
|
|
3007
|
+
els.modelCurrent.textContent = modelText;
|
|
3008
|
+
els.modelCurrent.title = `Model: ${modelTitle} — switch in Overview or Settings`;
|
|
3009
|
+
}
|
|
3010
|
+
if (els.modelCombined) {
|
|
3011
|
+
els.modelCombined.textContent = combined;
|
|
3012
|
+
els.modelCombined.title = combined;
|
|
3013
|
+
}
|
|
3014
|
+
syncAgentPickerDisplay();
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* @param {'claude' | 'openrouter'} [provider]
|
|
3019
|
+
* @returns {Array<{id:string,label?:string,name?:string}>}
|
|
3020
|
+
*/
|
|
3021
|
+
function getAvailableModels(provider = currentLlmProviderSelection()) {
|
|
3022
|
+
if (availableModelsProvider === provider) return availableModels;
|
|
3023
|
+
return [];
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
/**
|
|
3027
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3028
|
+
* @param {Array<{id:string,label?:string,name?:string}>} models
|
|
3029
|
+
*/
|
|
3030
|
+
function setAvailableModels(provider, models) {
|
|
3031
|
+
availableModelsProvider = provider;
|
|
3032
|
+
availableModels = models;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function clearAvailableModels() {
|
|
3036
|
+
availableModels = [];
|
|
3037
|
+
availableModelsProvider = null;
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/**
|
|
3041
|
+
* @param {string | undefined | null} value
|
|
3042
|
+
* @returns {boolean}
|
|
3043
|
+
*/
|
|
3044
|
+
function isNoModelSelection(value) {
|
|
3045
|
+
const s = String(value ?? '').trim();
|
|
3046
|
+
return s === '' || s === NO_MODEL;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* @param {string} [selectedId]
|
|
3051
|
+
* @returns {string}
|
|
3052
|
+
*/
|
|
3053
|
+
function modelPickerDisplayText(selectedId = currentModel) {
|
|
3054
|
+
if (isNoModelSelection(selectedId)) return '—';
|
|
3055
|
+
const models = getAvailableModels();
|
|
3056
|
+
const hit = models.find((m) => m.id === selectedId);
|
|
3057
|
+
return hit ? modelPickerLabel(hit) : selectedId;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
/**
|
|
3061
|
+
* @typedef {Object} ModelComboboxInstance
|
|
3062
|
+
* @property {HTMLElement} root
|
|
3063
|
+
* @property {HTMLButtonElement} trigger
|
|
3064
|
+
* @property {HTMLElement} panel
|
|
3065
|
+
* @property {HTMLInputElement} search
|
|
3066
|
+
* @property {HTMLElement} list
|
|
3067
|
+
* @property {HTMLInputElement | null} [hiddenInput]
|
|
3068
|
+
* @property {HTMLElement} valueEl
|
|
3069
|
+
* @property {string} optionIdPrefix
|
|
3070
|
+
* @property {boolean} open
|
|
3071
|
+
* @property {number} activeIndex
|
|
3072
|
+
* @property {(modelId: string) => void} onSelect
|
|
3073
|
+
*/
|
|
3074
|
+
|
|
3075
|
+
/** @type {ModelComboboxInstance[]} */
|
|
3076
|
+
const modelComboboxes = [];
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* @param {HTMLElement} panel
|
|
3080
|
+
* @param {boolean} open
|
|
3081
|
+
*/
|
|
3082
|
+
function setOverlayPanelOpen(panel, open) {
|
|
3083
|
+
panel.classList.toggle('is-open', open);
|
|
3084
|
+
panel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
/**
|
|
3088
|
+
* @param {HTMLInputElement} input
|
|
3089
|
+
*/
|
|
3090
|
+
function focusWithoutScroll(input) {
|
|
3091
|
+
try {
|
|
3092
|
+
input.focus({ preventScroll: true });
|
|
3093
|
+
} catch {
|
|
3094
|
+
input.focus();
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/**
|
|
3099
|
+
* @param {{
|
|
3100
|
+
* root: HTMLElement,
|
|
3101
|
+
* trigger: HTMLButtonElement,
|
|
3102
|
+
* panel: HTMLElement,
|
|
3103
|
+
* search: HTMLInputElement,
|
|
3104
|
+
* list: HTMLElement,
|
|
3105
|
+
* hiddenInput?: HTMLInputElement | null,
|
|
3106
|
+
* valueEl: HTMLElement,
|
|
3107
|
+
* optionIdPrefix?: string,
|
|
3108
|
+
* onSelect: (modelId: string) => void,
|
|
3109
|
+
* }} opts
|
|
3110
|
+
* @returns {ModelComboboxInstance}
|
|
3111
|
+
*/
|
|
3112
|
+
function initModelCombobox(opts) {
|
|
3113
|
+
/** @type {ModelComboboxInstance} */
|
|
3114
|
+
const instance = {
|
|
3115
|
+
root: opts.root,
|
|
3116
|
+
trigger: opts.trigger,
|
|
3117
|
+
panel: opts.panel,
|
|
3118
|
+
search: opts.search,
|
|
3119
|
+
list: opts.list,
|
|
3120
|
+
hiddenInput: opts.hiddenInput ?? null,
|
|
3121
|
+
valueEl: opts.valueEl,
|
|
3122
|
+
optionIdPrefix: opts.optionIdPrefix || 'model-opt',
|
|
3123
|
+
open: false,
|
|
3124
|
+
activeIndex: -1,
|
|
3125
|
+
onSelect: opts.onSelect,
|
|
3126
|
+
};
|
|
3127
|
+
modelComboboxes.push(instance);
|
|
3128
|
+
|
|
3129
|
+
instance.trigger.addEventListener('click', () => {
|
|
3130
|
+
setModelComboboxOpen(instance, !instance.open);
|
|
3131
|
+
});
|
|
3132
|
+
|
|
3133
|
+
instance.search.addEventListener('input', () => {
|
|
3134
|
+
instance.activeIndex = -1;
|
|
3135
|
+
renderModelComboboxList(instance, currentModel);
|
|
3136
|
+
});
|
|
3137
|
+
|
|
3138
|
+
instance.search.addEventListener('keydown', (ev) => {
|
|
3139
|
+
if (ev.key === 'Escape') {
|
|
3140
|
+
ev.preventDefault();
|
|
3141
|
+
setModelComboboxOpen(instance, false);
|
|
3142
|
+
instance.trigger.focus();
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
3146
|
+
ev.preventDefault();
|
|
3147
|
+
const options = [
|
|
3148
|
+
...(instance.list.querySelectorAll('.model-picker-option') || []),
|
|
3149
|
+
];
|
|
3150
|
+
if (!options.length) return;
|
|
3151
|
+
if (ev.key === 'ArrowDown') {
|
|
3152
|
+
instance.activeIndex =
|
|
3153
|
+
instance.activeIndex < options.length - 1 ? instance.activeIndex + 1 : 0;
|
|
3154
|
+
} else {
|
|
3155
|
+
instance.activeIndex =
|
|
3156
|
+
instance.activeIndex > 0 ? instance.activeIndex - 1 : options.length - 1;
|
|
3157
|
+
}
|
|
3158
|
+
renderModelComboboxList(instance, currentModel);
|
|
3159
|
+
options[instance.activeIndex]?.scrollIntoView({ block: 'nearest' });
|
|
3160
|
+
}
|
|
3161
|
+
if (ev.key === 'Enter' && instance.activeIndex >= 0) {
|
|
3162
|
+
ev.preventDefault();
|
|
3163
|
+
const options = [
|
|
3164
|
+
...(instance.list.querySelectorAll('.model-picker-option') || []),
|
|
3165
|
+
];
|
|
3166
|
+
const active = options[instance.activeIndex];
|
|
3167
|
+
if (active?.dataset.modelId) {
|
|
3168
|
+
instance.onSelect(active.dataset.modelId);
|
|
3169
|
+
setModelComboboxOpen(instance, false);
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
return instance;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
/**
|
|
3178
|
+
* @param {ModelComboboxInstance} instance
|
|
3179
|
+
* @param {string} [selectedId]
|
|
3180
|
+
*/
|
|
3181
|
+
function syncModelComboboxValue(instance, selectedId = currentModel) {
|
|
3182
|
+
if (instance.hiddenInput) {
|
|
3183
|
+
instance.hiddenInput.value = isNoModelSelection(selectedId) ? NO_MODEL : selectedId;
|
|
3184
|
+
}
|
|
3185
|
+
const text = modelPickerDisplayText(selectedId);
|
|
3186
|
+
instance.valueEl.textContent = text;
|
|
3187
|
+
instance.valueEl.classList.toggle('is-empty', isNoModelSelection(selectedId));
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
function syncAllModelComboboxValues(selectedId = currentModel) {
|
|
3191
|
+
for (const cb of modelComboboxes) {
|
|
3192
|
+
syncModelComboboxValue(cb, selectedId);
|
|
3193
|
+
}
|
|
3194
|
+
syncAgentPickerDisplay(selectedId);
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* @param {ModelComboboxInstance} instance
|
|
3199
|
+
* @param {boolean} open
|
|
3200
|
+
*/
|
|
3201
|
+
function setModelComboboxOpen(instance, open) {
|
|
3202
|
+
if (open) {
|
|
3203
|
+
for (const cb of modelComboboxes) {
|
|
3204
|
+
if (cb !== instance) setModelComboboxOpen(cb, false);
|
|
3205
|
+
}
|
|
3206
|
+
for (const ap of agentPickers) {
|
|
3207
|
+
if (ap.open) setAgentPickerOpen(ap, false);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
instance.open = open;
|
|
3212
|
+
instance.trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3213
|
+
setOverlayPanelOpen(instance.panel, open);
|
|
3214
|
+
|
|
3215
|
+
if (open) {
|
|
3216
|
+
instance.activeIndex = -1;
|
|
3217
|
+
instance.search.value = '';
|
|
3218
|
+
renderModelComboboxList(instance, currentModel);
|
|
3219
|
+
focusWithoutScroll(instance.search);
|
|
3220
|
+
} else {
|
|
3221
|
+
instance.search.value = '';
|
|
3222
|
+
renderModelComboboxList(instance, currentModel);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
/**
|
|
3227
|
+
* @param {ModelComboboxInstance} instance
|
|
3228
|
+
* @returns {string}
|
|
3229
|
+
*/
|
|
3230
|
+
function getModelComboboxSearchQuery(instance) {
|
|
3231
|
+
return instance.search?.value?.trim() || '';
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
/**
|
|
3235
|
+
* @param {ModelComboboxInstance} instance
|
|
3236
|
+
* @param {Array<{id:string,label?:string,name?:string}>} allModels
|
|
3237
|
+
* @param {string} [selectedId]
|
|
3238
|
+
* @returns {Array<{id:string,label?:string,name?:string}>}
|
|
3239
|
+
*/
|
|
3240
|
+
function modelsForCombobox(instance, allModels, selectedId) {
|
|
3241
|
+
const query = getModelComboboxSearchQuery(instance);
|
|
3242
|
+
return query
|
|
3243
|
+
? allModels.filter((m) => modelMatchesSearchQuery(m, query))
|
|
3244
|
+
: [...allModels];
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
/**
|
|
3248
|
+
* @param {ModelComboboxInstance} instance
|
|
3249
|
+
* @param {string} [selectedId]
|
|
3250
|
+
*/
|
|
3251
|
+
function renderModelComboboxList(instance, selectedId = currentModel) {
|
|
3252
|
+
const models = modelsForCombobox(instance, getAvailableModels(), selectedId);
|
|
3253
|
+
const query = getModelComboboxSearchQuery(instance);
|
|
3254
|
+
|
|
3255
|
+
instance.list.innerHTML = '';
|
|
3256
|
+
if (!models.length) {
|
|
3257
|
+
const empty = document.createElement('li');
|
|
3258
|
+
empty.className = 'model-picker-empty';
|
|
3259
|
+
empty.textContent = query ? 'No models match your search' : 'No models available';
|
|
3260
|
+
empty.setAttribute('role', 'presentation');
|
|
3261
|
+
instance.list.appendChild(empty);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
models.forEach((m, index) => {
|
|
3266
|
+
const li = document.createElement('li');
|
|
3267
|
+
li.setAttribute('role', 'presentation');
|
|
3268
|
+
const btn = document.createElement('button');
|
|
3269
|
+
btn.type = 'button';
|
|
3270
|
+
btn.className = 'model-picker-option';
|
|
3271
|
+
btn.setAttribute('role', 'option');
|
|
3272
|
+
btn.dataset.modelId = m.id;
|
|
3273
|
+
btn.id = `${instance.optionIdPrefix}-${index}`;
|
|
3274
|
+
if (m.id === selectedId) {
|
|
3275
|
+
btn.classList.add('is-selected');
|
|
3276
|
+
btn.setAttribute('aria-selected', 'true');
|
|
3277
|
+
} else {
|
|
3278
|
+
btn.setAttribute('aria-selected', 'false');
|
|
3279
|
+
}
|
|
3280
|
+
if (index === instance.activeIndex) {
|
|
3281
|
+
btn.classList.add('is-active');
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
const title = document.createElement('span');
|
|
3285
|
+
title.textContent = modelPickerLabel(m);
|
|
3286
|
+
btn.appendChild(title);
|
|
3287
|
+
|
|
3288
|
+
if (m.id !== modelPickerLabel(m)) {
|
|
3289
|
+
const sub = document.createElement('span');
|
|
3290
|
+
sub.className = 'model-picker-option-id';
|
|
3291
|
+
sub.textContent = m.id;
|
|
3292
|
+
btn.appendChild(sub);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
btn.addEventListener('click', () => {
|
|
3296
|
+
instance.onSelect(m.id);
|
|
3297
|
+
setModelComboboxOpen(instance, false);
|
|
3298
|
+
});
|
|
3299
|
+
li.appendChild(btn);
|
|
3300
|
+
instance.list.appendChild(li);
|
|
3301
|
+
});
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function renderAllModelComboboxLists(selectedId = currentModel) {
|
|
3305
|
+
for (const cb of modelComboboxes) {
|
|
3306
|
+
renderModelComboboxList(cb, selectedId);
|
|
3307
|
+
}
|
|
3308
|
+
for (const ap of agentPickers) {
|
|
3309
|
+
renderAgentPickerModelList(ap, selectedId);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
/**
|
|
3314
|
+
* @typedef {Object} AgentPickerInstance
|
|
3315
|
+
* @property {HTMLElement} root
|
|
3316
|
+
* @property {HTMLButtonElement} trigger
|
|
3317
|
+
* @property {HTMLElement} panel
|
|
3318
|
+
* @property {HTMLInputElement} search
|
|
3319
|
+
* @property {HTMLElement} list
|
|
3320
|
+
* @property {HTMLElement} valueEl
|
|
3321
|
+
* @property {HTMLElement | null} iconEl
|
|
3322
|
+
* @property {HTMLElement[]} tabs
|
|
3323
|
+
* @property {HTMLInputElement | null} hiddenInput
|
|
3324
|
+
* @property {string} optionIdPrefix
|
|
3325
|
+
* @property {boolean} open
|
|
3326
|
+
* @property {number} activeIndex
|
|
3327
|
+
* @property {(modelId: string) => void} onSelect
|
|
3328
|
+
*/
|
|
3329
|
+
|
|
3330
|
+
/** @type {AgentPickerInstance[]} */
|
|
3331
|
+
const agentPickers = [];
|
|
3332
|
+
|
|
3333
|
+
const PROVIDER_UNAVAILABLE_TOOLTIP =
|
|
3334
|
+
'Not configured — add credentials in Settings → Authentication';
|
|
3335
|
+
|
|
3336
|
+
/**
|
|
3337
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3338
|
+
* @param {typeof appConfig} [cfg]
|
|
3339
|
+
*/
|
|
3340
|
+
function isLlmProviderAvailable(provider, cfg = appConfig) {
|
|
3341
|
+
if (cfg.stubAgent) return true;
|
|
3342
|
+
if (provider === 'openrouter') return cfg.openrouterAuthOk === true;
|
|
3343
|
+
return cfg.claudeAuthOk === true;
|
|
2936
3344
|
}
|
|
2937
3345
|
|
|
2938
3346
|
/**
|
|
2939
|
-
*
|
|
3347
|
+
* Disable provider tabs and select options when auth is missing.
|
|
3348
|
+
* @param {typeof appConfig} [cfg]
|
|
2940
3349
|
*/
|
|
2941
|
-
function
|
|
3350
|
+
function syncLlmProviderAvailability(cfg = appConfig) {
|
|
3351
|
+
for (const ap of agentPickers) {
|
|
3352
|
+
for (const tab of ap.tabs) {
|
|
3353
|
+
const provider =
|
|
3354
|
+
tab.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3355
|
+
const available = isLlmProviderAvailable(provider, cfg);
|
|
3356
|
+
tab.disabled = !available;
|
|
3357
|
+
tab.classList.toggle('is-disabled', !available);
|
|
3358
|
+
tab.setAttribute('aria-disabled', available ? 'false' : 'true');
|
|
3359
|
+
if (available) {
|
|
3360
|
+
tab.removeAttribute('title');
|
|
3361
|
+
} else {
|
|
3362
|
+
tab.title = PROVIDER_UNAVAILABLE_TOOLTIP;
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
for (const select of [els.settingsLlmProvider, els.overviewLlmProvider]) {
|
|
3368
|
+
if (!select) continue;
|
|
3369
|
+
for (const opt of select.options) {
|
|
3370
|
+
const provider = opt.value === 'openrouter' ? 'openrouter' : 'claude';
|
|
3371
|
+
const available = isLlmProviderAvailable(provider, cfg);
|
|
3372
|
+
opt.disabled = !available;
|
|
3373
|
+
if (available) {
|
|
3374
|
+
opt.removeAttribute('title');
|
|
3375
|
+
} else {
|
|
3376
|
+
opt.title = PROVIDER_UNAVAILABLE_TOOLTIP;
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
/**
|
|
3383
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3384
|
+
*/
|
|
3385
|
+
function syncAgentPickerTabs(provider) {
|
|
3386
|
+
const value = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3387
|
+
for (const ap of agentPickers) {
|
|
3388
|
+
for (const tab of ap.tabs) {
|
|
3389
|
+
const active = tab.dataset.provider === value;
|
|
3390
|
+
tab.classList.toggle('is-active', active);
|
|
3391
|
+
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
3392
|
+
}
|
|
3393
|
+
if (ap.iconEl) {
|
|
3394
|
+
ap.iconEl.classList.toggle('agent-pill-icon--claude', value === 'claude');
|
|
3395
|
+
ap.iconEl.classList.toggle('agent-pill-icon--openrouter', value === 'openrouter');
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
function syncAgentPickerDisplay(selectedId = currentModel) {
|
|
3401
|
+
const provider = currentLlmProviderSelection();
|
|
3402
|
+
const text = agentDisplayText(provider, selectedId);
|
|
3403
|
+
for (const ap of agentPickers) {
|
|
3404
|
+
ap.valueEl.textContent = text;
|
|
3405
|
+
ap.valueEl.classList.toggle('is-empty', isNoModelSelection(selectedId));
|
|
3406
|
+
if (ap.hiddenInput) {
|
|
3407
|
+
ap.hiddenInput.value = isNoModelSelection(selectedId) ? NO_MODEL : selectedId;
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
syncAgentPickerTabs(provider);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
/**
|
|
3414
|
+
* @param {AgentPickerInstance} instance
|
|
3415
|
+
* @param {boolean} open
|
|
3416
|
+
*/
|
|
3417
|
+
function setAgentPickerOpen(instance, open) {
|
|
3418
|
+
if (open) {
|
|
3419
|
+
for (const ap of agentPickers) {
|
|
3420
|
+
if (ap !== instance) setAgentPickerOpen(ap, false);
|
|
3421
|
+
}
|
|
3422
|
+
for (const cb of modelComboboxes) {
|
|
3423
|
+
if (cb.open) setModelComboboxOpen(cb, false);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
instance.open = open;
|
|
3428
|
+
instance.trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3429
|
+
setOverlayPanelOpen(instance.panel, open);
|
|
3430
|
+
|
|
3431
|
+
if (open) {
|
|
3432
|
+
instance.activeIndex = -1;
|
|
3433
|
+
instance.search.value = '';
|
|
3434
|
+
syncAgentPickerTabs(currentLlmProviderSelection());
|
|
3435
|
+
renderAgentPickerModelList(instance, currentModel);
|
|
3436
|
+
focusWithoutScroll(instance.search);
|
|
3437
|
+
} else {
|
|
3438
|
+
instance.search.value = '';
|
|
3439
|
+
renderAgentPickerModelList(instance, currentModel);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
/**
|
|
3444
|
+
* @param {AgentPickerInstance} instance
|
|
3445
|
+
* @param {string} [selectedId]
|
|
3446
|
+
*/
|
|
3447
|
+
function renderAgentPickerModelList(instance, selectedId = currentModel) {
|
|
3448
|
+
const models = modelsForCombobox(instance, getAvailableModels(), selectedId);
|
|
3449
|
+
const query = getModelComboboxSearchQuery(instance);
|
|
3450
|
+
|
|
3451
|
+
instance.list.innerHTML = '';
|
|
3452
|
+
if (!models.length) {
|
|
3453
|
+
const empty = document.createElement('li');
|
|
3454
|
+
empty.className = 'model-picker-empty';
|
|
3455
|
+
empty.textContent = query ? 'No models match your search' : 'No models available';
|
|
3456
|
+
empty.setAttribute('role', 'presentation');
|
|
3457
|
+
instance.list.appendChild(empty);
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
models.forEach((m, index) => {
|
|
3462
|
+
const li = document.createElement('li');
|
|
3463
|
+
li.setAttribute('role', 'presentation');
|
|
3464
|
+
const btn = document.createElement('button');
|
|
3465
|
+
btn.type = 'button';
|
|
3466
|
+
btn.className = 'model-picker-option';
|
|
3467
|
+
btn.setAttribute('role', 'option');
|
|
3468
|
+
btn.dataset.modelId = m.id;
|
|
3469
|
+
btn.id = `${instance.optionIdPrefix}-${index}`;
|
|
3470
|
+
if (m.id === selectedId) {
|
|
3471
|
+
btn.classList.add('is-selected');
|
|
3472
|
+
btn.setAttribute('aria-selected', 'true');
|
|
3473
|
+
} else {
|
|
3474
|
+
btn.setAttribute('aria-selected', 'false');
|
|
3475
|
+
}
|
|
3476
|
+
if (index === instance.activeIndex) {
|
|
3477
|
+
btn.classList.add('is-active');
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
const title = document.createElement('span');
|
|
3481
|
+
title.textContent = modelPickerLabel(m);
|
|
3482
|
+
btn.appendChild(title);
|
|
3483
|
+
|
|
3484
|
+
if (m.id !== modelPickerLabel(m)) {
|
|
3485
|
+
const sub = document.createElement('span');
|
|
3486
|
+
sub.className = 'model-picker-option-id';
|
|
3487
|
+
sub.textContent = m.id;
|
|
3488
|
+
btn.appendChild(sub);
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
btn.addEventListener('click', () => {
|
|
3492
|
+
instance.onSelect(m.id);
|
|
3493
|
+
setAgentPickerOpen(instance, false);
|
|
3494
|
+
});
|
|
3495
|
+
li.appendChild(btn);
|
|
3496
|
+
instance.list.appendChild(li);
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
/**
|
|
3501
|
+
* @param {{
|
|
3502
|
+
* root: HTMLElement,
|
|
3503
|
+
* trigger: HTMLButtonElement,
|
|
3504
|
+
* panel: HTMLElement,
|
|
3505
|
+
* search: HTMLInputElement,
|
|
3506
|
+
* list: HTMLElement,
|
|
3507
|
+
* valueEl: HTMLElement,
|
|
3508
|
+
* iconEl?: HTMLElement | null,
|
|
3509
|
+
* tabs?: HTMLElement[],
|
|
3510
|
+
* hiddenInput?: HTMLInputElement | null,
|
|
3511
|
+
* optionIdPrefix?: string,
|
|
3512
|
+
* onSelect: (modelId: string) => void,
|
|
3513
|
+
* }} opts
|
|
3514
|
+
* @returns {AgentPickerInstance}
|
|
3515
|
+
*/
|
|
3516
|
+
function initAgentPicker(opts) {
|
|
3517
|
+
/** @type {AgentPickerInstance} */
|
|
3518
|
+
const instance = {
|
|
3519
|
+
root: opts.root,
|
|
3520
|
+
trigger: opts.trigger,
|
|
3521
|
+
panel: opts.panel,
|
|
3522
|
+
search: opts.search,
|
|
3523
|
+
list: opts.list,
|
|
3524
|
+
valueEl: opts.valueEl,
|
|
3525
|
+
iconEl: opts.iconEl ?? null,
|
|
3526
|
+
tabs: opts.tabs || [],
|
|
3527
|
+
hiddenInput: opts.hiddenInput ?? null,
|
|
3528
|
+
optionIdPrefix: opts.optionIdPrefix || 'agent-model-opt',
|
|
3529
|
+
open: false,
|
|
3530
|
+
activeIndex: -1,
|
|
3531
|
+
onSelect: opts.onSelect,
|
|
3532
|
+
};
|
|
3533
|
+
agentPickers.push(instance);
|
|
3534
|
+
|
|
3535
|
+
instance.trigger.addEventListener('click', () => {
|
|
3536
|
+
setAgentPickerOpen(instance, !instance.open);
|
|
3537
|
+
});
|
|
3538
|
+
|
|
3539
|
+
instance.search.addEventListener('input', () => {
|
|
3540
|
+
instance.activeIndex = -1;
|
|
3541
|
+
renderAgentPickerModelList(instance, currentModel);
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
instance.search.addEventListener('keydown', (ev) => {
|
|
3545
|
+
if (ev.key === 'Escape') {
|
|
3546
|
+
ev.preventDefault();
|
|
3547
|
+
setAgentPickerOpen(instance, false);
|
|
3548
|
+
instance.trigger.focus();
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
3552
|
+
ev.preventDefault();
|
|
3553
|
+
const options = [
|
|
3554
|
+
...(instance.list.querySelectorAll('.model-picker-option') || []),
|
|
3555
|
+
];
|
|
3556
|
+
if (!options.length) return;
|
|
3557
|
+
if (ev.key === 'ArrowDown') {
|
|
3558
|
+
instance.activeIndex =
|
|
3559
|
+
instance.activeIndex < options.length - 1 ? instance.activeIndex + 1 : 0;
|
|
3560
|
+
} else {
|
|
3561
|
+
instance.activeIndex =
|
|
3562
|
+
instance.activeIndex > 0 ? instance.activeIndex - 1 : options.length - 1;
|
|
3563
|
+
}
|
|
3564
|
+
renderAgentPickerModelList(instance, currentModel);
|
|
3565
|
+
options[instance.activeIndex]?.scrollIntoView({ block: 'nearest' });
|
|
3566
|
+
}
|
|
3567
|
+
if (ev.key === 'Enter' && instance.activeIndex >= 0) {
|
|
3568
|
+
ev.preventDefault();
|
|
3569
|
+
const options = [
|
|
3570
|
+
...(instance.list.querySelectorAll('.model-picker-option') || []),
|
|
3571
|
+
];
|
|
3572
|
+
const active = options[instance.activeIndex];
|
|
3573
|
+
if (active?.dataset.modelId) {
|
|
3574
|
+
instance.onSelect(active.dataset.modelId);
|
|
3575
|
+
setAgentPickerOpen(instance, false);
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
});
|
|
3579
|
+
|
|
3580
|
+
for (const tab of instance.tabs) {
|
|
3581
|
+
tab.addEventListener('click', () => {
|
|
3582
|
+
if (tab.disabled) return;
|
|
3583
|
+
const provider =
|
|
3584
|
+
tab.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3585
|
+
if (!isLlmProviderAvailable(provider)) return;
|
|
3586
|
+
if (provider === currentLlmProviderSelection()) return;
|
|
3587
|
+
syncLlmProviderSelects(provider);
|
|
3588
|
+
syncAgentPickerTabs(provider);
|
|
3589
|
+
void onLlmProviderChange(provider);
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
return instance;
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
/** @deprecated use syncModelComboboxValue */
|
|
3597
|
+
function syncModelPickerValue(selectedId = currentModel) {
|
|
3598
|
+
syncAllModelComboboxValues(selectedId);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* Reconcile current model against a provider's model list.
|
|
3603
|
+
* @param {Array<{id:string}>} models
|
|
3604
|
+
* @param {string} [candidate]
|
|
3605
|
+
* @param {string} [storedCandidate] last valid model for this provider
|
|
3606
|
+
* @returns {string}
|
|
3607
|
+
*/
|
|
3608
|
+
function reconcileModelForProvider(models, candidate = currentModel, storedCandidate) {
|
|
3609
|
+
if (
|
|
3610
|
+
!isNoModelSelection(candidate) &&
|
|
3611
|
+
models.some((m) => m.id === candidate)
|
|
3612
|
+
) {
|
|
3613
|
+
return candidate;
|
|
3614
|
+
}
|
|
3615
|
+
if (
|
|
3616
|
+
storedCandidate &&
|
|
3617
|
+
!isNoModelSelection(storedCandidate) &&
|
|
3618
|
+
models.some((m) => m.id === storedCandidate)
|
|
3619
|
+
) {
|
|
3620
|
+
return storedCandidate;
|
|
3621
|
+
}
|
|
3622
|
+
return NO_MODEL;
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
/**
|
|
3626
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3627
|
+
* @returns {string | undefined}
|
|
3628
|
+
*/
|
|
3629
|
+
function lastModelForProvider(provider) {
|
|
3630
|
+
const stored = appConfig.lastModelsByProvider?.[provider];
|
|
3631
|
+
return typeof stored === 'string' && !isNoModelSelection(stored) ? stored : undefined;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
/**
|
|
3635
|
+
* @returns {Array<{id:string,label?:string,name?:string}>}
|
|
3636
|
+
*/
|
|
3637
|
+
function defaultModelsForProvider(provider = 'claude') {
|
|
3638
|
+
if (provider === 'openrouter') {
|
|
3639
|
+
return [
|
|
3640
|
+
{ id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
|
|
3641
|
+
{ id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
|
|
3642
|
+
{ id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)' },
|
|
3643
|
+
{ id: 'x-ai/grok-2', label: 'Grok 2 (xAI)' },
|
|
3644
|
+
];
|
|
3645
|
+
}
|
|
3646
|
+
return [
|
|
3647
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5' },
|
|
3648
|
+
{ id: 'claude-opus-5', label: 'Opus 5' },
|
|
3649
|
+
{ id: 'claude-fable-5', label: 'Fable 5' },
|
|
3650
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
|
|
3651
|
+
{ id: 'sonnet', label: 'sonnet (latest)' },
|
|
3652
|
+
{ id: 'opus', label: 'opus (latest)' },
|
|
3653
|
+
];
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
/**
|
|
3657
|
+
* @param {'anthropic' | 'openrouter' | 'fallback' | string | undefined} source
|
|
3658
|
+
* @param {'claude' | 'openrouter'} [provider]
|
|
3659
|
+
*/
|
|
3660
|
+
function updateModelSourceHint(source, provider = 'claude') {
|
|
2942
3661
|
if (!els.settingsModelHint) return;
|
|
3662
|
+
const name = provider === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
2943
3663
|
if (source === 'anthropic') {
|
|
2944
3664
|
els.settingsModelHint.textContent =
|
|
2945
|
-
|
|
2946
|
-
} else if (source === '
|
|
3665
|
+
`${name} model for agent runs. List loaded from Anthropic Models API.`;
|
|
3666
|
+
} else if (source === 'openrouter') {
|
|
2947
3667
|
els.settingsModelHint.textContent =
|
|
2948
|
-
'
|
|
3668
|
+
'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
|
|
3669
|
+
} else if (source === 'fallback') {
|
|
3670
|
+
if (provider === 'openrouter') {
|
|
3671
|
+
els.settingsModelHint.textContent =
|
|
3672
|
+
'OpenRouter model for agent runs. Showing curated models (live list unavailable — set OPENROUTER_API_KEY in Authentication).';
|
|
3673
|
+
} else {
|
|
3674
|
+
els.settingsModelHint.textContent =
|
|
3675
|
+
'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
|
|
3676
|
+
}
|
|
2949
3677
|
} else {
|
|
2950
|
-
els.settingsModelHint.textContent =
|
|
3678
|
+
els.settingsModelHint.textContent = `${name} model for agent runs.`;
|
|
2951
3679
|
}
|
|
2952
3680
|
}
|
|
2953
3681
|
|
|
2954
3682
|
/**
|
|
2955
|
-
* @param {{
|
|
3683
|
+
* @param {{ id: string, label?: string, name?: string }} model
|
|
3684
|
+
* @returns {string}
|
|
3685
|
+
*/
|
|
3686
|
+
function modelPickerLabel(model) {
|
|
3687
|
+
return model.label || model.name || model.id;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
/**
|
|
3691
|
+
* @param {{ id: string, label?: string, name?: string }} model
|
|
3692
|
+
* @param {string} query
|
|
3693
|
+
* @returns {boolean}
|
|
3694
|
+
*/
|
|
3695
|
+
function modelMatchesSearchQuery(model, query) {
|
|
3696
|
+
if (!query) return true;
|
|
3697
|
+
const q = query.toLowerCase();
|
|
3698
|
+
return [model.id, model.label, model.name]
|
|
3699
|
+
.filter(Boolean)
|
|
3700
|
+
.some((part) => String(part).toLowerCase().includes(q));
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/**
|
|
3704
|
+
* @returns {'claude' | 'openrouter'}
|
|
3705
|
+
*/
|
|
3706
|
+
function currentLlmProviderSelection() {
|
|
3707
|
+
const fromOverview = els.overviewLlmProvider?.value;
|
|
3708
|
+
if (fromOverview === 'openrouter' || fromOverview === 'claude') return fromOverview;
|
|
3709
|
+
const fromForm = els.settingsLlmProvider?.value;
|
|
3710
|
+
if (fromForm === 'openrouter' || fromForm === 'claude') return fromForm;
|
|
3711
|
+
return appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/**
|
|
3715
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3716
|
+
*/
|
|
3717
|
+
function syncLlmProviderSelects(provider) {
|
|
3718
|
+
const value = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3719
|
+
if (els.settingsLlmProvider) els.settingsLlmProvider.value = value;
|
|
3720
|
+
if (els.overviewLlmProvider) els.overviewLlmProvider.value = value;
|
|
3721
|
+
syncAgentPickerTabs(value);
|
|
3722
|
+
if (els.settingsLlmProviderHint) {
|
|
3723
|
+
els.settingsLlmProviderHint.textContent =
|
|
3724
|
+
value === 'openrouter'
|
|
3725
|
+
? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY in Authentication).'
|
|
3726
|
+
: 'Agent runs use Claude via Anthropic API, OAuth token, or claude auth login.';
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
/**
|
|
3731
|
+
* @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string, provider?: string }} data
|
|
2956
3732
|
*/
|
|
2957
3733
|
function populateSettingsModels(data) {
|
|
2958
|
-
const
|
|
3734
|
+
const provider = data?.provider || currentLlmProviderSelection();
|
|
3735
|
+
const cached = getAvailableModels(provider);
|
|
3736
|
+
const models = Array.isArray(data?.models)
|
|
2959
3737
|
? data.models
|
|
2960
|
-
:
|
|
2961
|
-
?
|
|
2962
|
-
:
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
const
|
|
2975
|
-
|
|
2976
|
-
els.settingsModel.innerHTML = '';
|
|
2977
|
-
for (const m of models) {
|
|
2978
|
-
const opt = document.createElement('option');
|
|
2979
|
-
opt.value = m.id;
|
|
2980
|
-
opt.textContent = m.label || m.name || m.id;
|
|
2981
|
-
els.settingsModel.appendChild(opt);
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
const selected = currentModel;
|
|
2985
|
-
if (selected && [...els.settingsModel.options].some((o) => o.value === selected)) {
|
|
2986
|
-
els.settingsModel.value = selected;
|
|
2987
|
-
}
|
|
3738
|
+
: data?.reconcileProvider
|
|
3739
|
+
? []
|
|
3740
|
+
: cached.length
|
|
3741
|
+
? cached
|
|
3742
|
+
: defaultModelsForProvider(provider);
|
|
3743
|
+
setAvailableModels(provider, models);
|
|
3744
|
+
|
|
3745
|
+
if (data?.reconcileProvider) {
|
|
3746
|
+
currentModel = reconcileModelForProvider(
|
|
3747
|
+
models,
|
|
3748
|
+
data.reconcileCandidate,
|
|
3749
|
+
lastModelForProvider(provider)
|
|
3750
|
+
);
|
|
3751
|
+
} else if (data?.selected != null) {
|
|
3752
|
+
const next = isNoModelSelection(data.selected) ? NO_MODEL : data.selected;
|
|
3753
|
+
currentModel = reconcileModelForProvider(models, next, lastModelForProvider(provider));
|
|
2988
3754
|
}
|
|
3755
|
+
if (data?.source) updateModelSourceHint(data.source, provider);
|
|
3756
|
+
|
|
3757
|
+
syncAllModelComboboxValues(currentModel);
|
|
3758
|
+
renderAllModelComboboxLists(currentModel);
|
|
2989
3759
|
updateModelLabel();
|
|
2990
3760
|
}
|
|
2991
3761
|
|
|
3762
|
+
function modelsForFetchFallback(provider, reconcileProvider) {
|
|
3763
|
+
if (reconcileProvider) return [];
|
|
3764
|
+
const cached = getAvailableModels(provider);
|
|
3765
|
+
if (cached.length) return cached;
|
|
3766
|
+
return defaultModelsForProvider(provider);
|
|
3767
|
+
}
|
|
3768
|
+
|
|
2992
3769
|
async function fetchModels(opts = {}) {
|
|
2993
3770
|
const refresh = opts.refresh === true;
|
|
3771
|
+
const provider = opts.provider || currentLlmProviderSelection();
|
|
3772
|
+
const reconcileProvider = opts.reconcileProvider === true;
|
|
3773
|
+
const reconcileCandidate = opts.reconcileCandidate;
|
|
3774
|
+
if (reconcileProvider) {
|
|
3775
|
+
clearAvailableModels();
|
|
3776
|
+
}
|
|
3777
|
+
const query = new URLSearchParams({ provider });
|
|
3778
|
+
if (refresh) query.set('refresh', '1');
|
|
2994
3779
|
try {
|
|
2995
|
-
const res = await fetch(
|
|
3780
|
+
const res = await fetch(`/api/models?${query.toString()}`);
|
|
2996
3781
|
if (!res.ok) {
|
|
2997
3782
|
populateSettingsModels({
|
|
2998
|
-
models:
|
|
2999
|
-
? availableModels
|
|
3000
|
-
: [
|
|
3001
|
-
{ id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
|
|
3002
|
-
],
|
|
3783
|
+
models: modelsForFetchFallback(provider, reconcileProvider),
|
|
3003
3784
|
selected: currentModel,
|
|
3785
|
+
provider,
|
|
3786
|
+
reconcileProvider,
|
|
3787
|
+
reconcileCandidate,
|
|
3004
3788
|
});
|
|
3005
3789
|
return;
|
|
3006
3790
|
}
|
|
3007
3791
|
const data = await readJson(res);
|
|
3008
|
-
populateSettingsModels(
|
|
3792
|
+
populateSettingsModels({
|
|
3793
|
+
...data,
|
|
3794
|
+
provider: data.provider || provider,
|
|
3795
|
+
reconcileProvider,
|
|
3796
|
+
reconcileCandidate,
|
|
3797
|
+
});
|
|
3009
3798
|
} catch (err) {
|
|
3010
3799
|
console.error('Failed to fetch models:', err);
|
|
3011
3800
|
populateSettingsModels({
|
|
3012
|
-
models:
|
|
3013
|
-
? availableModels
|
|
3014
|
-
: [
|
|
3015
|
-
{ id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
|
|
3016
|
-
],
|
|
3801
|
+
models: modelsForFetchFallback(provider, reconcileProvider),
|
|
3017
3802
|
selected: currentModel,
|
|
3803
|
+
provider,
|
|
3804
|
+
reconcileProvider,
|
|
3805
|
+
reconcileCandidate,
|
|
3018
3806
|
});
|
|
3019
3807
|
}
|
|
3020
3808
|
}
|
|
@@ -3133,6 +3921,13 @@ function claudeStatusText(cfg) {
|
|
|
3133
3921
|
}
|
|
3134
3922
|
}
|
|
3135
3923
|
|
|
3924
|
+
function openrouterStatusText(cfg) {
|
|
3925
|
+
if (cfg.openrouterAuthOk) {
|
|
3926
|
+
return 'Authenticated via OPENROUTER_API_KEY';
|
|
3927
|
+
}
|
|
3928
|
+
return 'Not authenticated — add an OpenRouter API key below';
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3136
3931
|
function fillAuthSettings(cfg) {
|
|
3137
3932
|
if (els.settingsGhStatus) {
|
|
3138
3933
|
els.settingsGhStatus.textContent = ghStatusText(cfg);
|
|
@@ -3163,8 +3958,9 @@ function fillAuthSettings(cfg) {
|
|
|
3163
3958
|
|
|
3164
3959
|
if (els.settingsClaudeStatus) {
|
|
3165
3960
|
els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
|
|
3166
|
-
els.settingsClaudeStatus.className =
|
|
3167
|
-
|
|
3961
|
+
els.settingsClaudeStatus.className = cfg.claudeAuthOk
|
|
3962
|
+
? 'auth-status ok'
|
|
3963
|
+
: 'auth-status err';
|
|
3168
3964
|
}
|
|
3169
3965
|
fillSecretInput(
|
|
3170
3966
|
els.settingsAnthropicKey,
|
|
@@ -3190,6 +3986,25 @@ function fillAuthSettings(cfg) {
|
|
|
3190
3986
|
? 'OAuth token stored in <code>.acdev/.env</code> as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.'
|
|
3191
3987
|
: 'From <code>claude setup-token</code> for non-interactive subscription auth. Stored as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.';
|
|
3192
3988
|
}
|
|
3989
|
+
|
|
3990
|
+
if (els.settingsOpenrouterStatus) {
|
|
3991
|
+
els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
|
|
3992
|
+
els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
|
|
3993
|
+
? 'auth-status ok'
|
|
3994
|
+
: 'auth-status err';
|
|
3995
|
+
}
|
|
3996
|
+
fillSecretInput(
|
|
3997
|
+
els.settingsOpenrouterKey,
|
|
3998
|
+
els.settingsOpenrouterKeyClear,
|
|
3999
|
+
Boolean(cfg.openrouterApiKeySet),
|
|
4000
|
+
cfg.openrouterApiKeyMasked,
|
|
4001
|
+
'Paste an OpenRouter API key'
|
|
4002
|
+
);
|
|
4003
|
+
if (els.settingsOpenrouterKeyHint) {
|
|
4004
|
+
els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
|
|
4005
|
+
? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed).'
|
|
4006
|
+
: 'Required when LLM provider is OpenRouter. Get a key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">openrouter.ai/keys</a>. Stored as <code>OPENROUTER_API_KEY</code> in <code>.acdev/.env</code>.';
|
|
4007
|
+
}
|
|
3193
4008
|
}
|
|
3194
4009
|
|
|
3195
4010
|
function fillSettingsForm(cfg) {
|
|
@@ -3269,16 +4084,23 @@ function fillSettingsForm(cfg) {
|
|
|
3269
4084
|
els.settingsBaseBranch.value = cfg.baseBranch || '';
|
|
3270
4085
|
}
|
|
3271
4086
|
|
|
3272
|
-
if (els.
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
4087
|
+
if (els.settingsLlmProvider) {
|
|
4088
|
+
syncLlmProviderSelects(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
if (els.settingsModelPicker) {
|
|
4092
|
+
const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4093
|
+
if (getAvailableModels(provider).length) {
|
|
3276
4094
|
populateSettingsModels({
|
|
3277
|
-
models:
|
|
3278
|
-
selected: cfg.model,
|
|
4095
|
+
models: getAvailableModels(provider),
|
|
4096
|
+
selected: cfg.model ?? currentModel,
|
|
4097
|
+
provider,
|
|
3279
4098
|
});
|
|
4099
|
+
} else if (cfg.model != null) {
|
|
4100
|
+
currentModel = isNoModelSelection(cfg.model) ? NO_MODEL : cfg.model;
|
|
4101
|
+
syncModelPickerValue(currentModel);
|
|
3280
4102
|
}
|
|
3281
|
-
void fetchModels();
|
|
4103
|
+
void fetchModels({ provider });
|
|
3282
4104
|
}
|
|
3283
4105
|
|
|
3284
4106
|
if (els.settingsMaxTurns) {
|
|
@@ -3361,10 +4183,12 @@ function updateGithubRuleFieldsVisibility() {
|
|
|
3361
4183
|
function updateTicketSourceUI(source) {
|
|
3362
4184
|
ticketSource = source === 'jira' ? 'jira' : 'github';
|
|
3363
4185
|
|
|
3364
|
-
|
|
3365
|
-
|
|
4186
|
+
for (const toggle of [els.settingsTicketSource, els.overviewTicketSource]) {
|
|
4187
|
+
if (!toggle) continue;
|
|
4188
|
+
toggle.querySelectorAll('.source-btn').forEach((btn) => {
|
|
3366
4189
|
const active = btn.dataset.source === ticketSource;
|
|
3367
4190
|
btn.classList.toggle('active', active);
|
|
4191
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
3368
4192
|
});
|
|
3369
4193
|
}
|
|
3370
4194
|
|
|
@@ -3382,11 +4206,9 @@ function updateTicketSourceUI(source) {
|
|
|
3382
4206
|
|
|
3383
4207
|
if (els.issueUrls) {
|
|
3384
4208
|
if (ticketSource === 'jira') {
|
|
3385
|
-
els.issueUrls.placeholder =
|
|
3386
|
-
'Paste Jira keys or browse URLs — one per line, or comma-separated\ne.g. PROJ-123 or https://your-domain.atlassian.net/browse/PROJ-123';
|
|
4209
|
+
els.issueUrls.placeholder = 'Paste Jira keys or browse URLs…';
|
|
3387
4210
|
} else {
|
|
3388
|
-
els.issueUrls.placeholder =
|
|
3389
|
-
'Paste GitHub issue links — one per line, or comma-separated\ne.g. https://github.com/acme/storefront/issues/482';
|
|
4211
|
+
els.issueUrls.placeholder = 'Paste GitHub issue links or URLs…';
|
|
3390
4212
|
}
|
|
3391
4213
|
}
|
|
3392
4214
|
|
|
@@ -3395,11 +4217,24 @@ function updateTicketSourceUI(source) {
|
|
|
3395
4217
|
ticketSource === 'jira' ? 'Jira issue keys or URLs' : 'GitHub issue URLs';
|
|
3396
4218
|
}
|
|
3397
4219
|
|
|
3398
|
-
if (els.
|
|
3399
|
-
els.
|
|
3400
|
-
ticketSource === 'jira'
|
|
3401
|
-
|
|
3402
|
-
|
|
4220
|
+
if (els.enqueueHintExample) {
|
|
4221
|
+
els.enqueueHintExample.textContent =
|
|
4222
|
+
ticketSource === 'jira' ? 'PROJ-123' : 'owner/repo#42';
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
if (els.enqueueInfoText) {
|
|
4226
|
+
if (ticketSource === 'jira') {
|
|
4227
|
+
els.enqueueInfoText.innerHTML =
|
|
4228
|
+
'<strong>Jira issues run one at a time.</strong> Open GitHub PRs are picked up automatically via gh.';
|
|
4229
|
+
} else {
|
|
4230
|
+
els.enqueueInfoText.innerHTML =
|
|
4231
|
+
'<strong>GitHub issues run one at a time.</strong> Open PRs are picked up automatically via gh.';
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
if (els.enqueueJiraHint) {
|
|
4236
|
+
const showJiraHint = ticketSource === 'jira' && !appConfig.jiraConfigured;
|
|
4237
|
+
els.enqueueJiraHint.classList.toggle('hidden', !showJiraHint);
|
|
3403
4238
|
}
|
|
3404
4239
|
|
|
3405
4240
|
if (els.overviewQueueEmpty) {
|
|
@@ -3410,6 +4245,27 @@ function updateTicketSourceUI(source) {
|
|
|
3410
4245
|
}
|
|
3411
4246
|
}
|
|
3412
4247
|
|
|
4248
|
+
/**
|
|
4249
|
+
* @param {'github' | 'jira'} next
|
|
4250
|
+
*/
|
|
4251
|
+
async function saveTicketSource(next) {
|
|
4252
|
+
const source = next === 'jira' ? 'jira' : 'github';
|
|
4253
|
+
updateTicketSourceUI(source);
|
|
4254
|
+
try {
|
|
4255
|
+
const res = await fetch('/api/config', {
|
|
4256
|
+
method: 'PATCH',
|
|
4257
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4258
|
+
body: JSON.stringify({ ticketSource: source }),
|
|
4259
|
+
});
|
|
4260
|
+
const data = await readJson(res);
|
|
4261
|
+
if (res.ok) {
|
|
4262
|
+
applyConfigSnapshot({ ...appConfig, ...data });
|
|
4263
|
+
}
|
|
4264
|
+
} catch {
|
|
4265
|
+
// local UI already updated; save via Settings if needed
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
|
|
3413
4269
|
function updateTimeoutMsHint(minutes) {
|
|
3414
4270
|
if (!els.settingsTimeoutMs) return;
|
|
3415
4271
|
const m = Number(minutes);
|
|
@@ -3420,10 +4276,94 @@ function updateTimeoutMsHint(minutes) {
|
|
|
3420
4276
|
els.settingsTimeoutMs.textContent = String(Math.round(m * 60_000));
|
|
3421
4277
|
}
|
|
3422
4278
|
|
|
4279
|
+
function setOverviewLlmFeedback(message, kind = 'ok') {
|
|
4280
|
+
if (!els.overviewLlmFeedback) return;
|
|
4281
|
+
if (!message) {
|
|
4282
|
+
els.overviewLlmFeedback.textContent = '';
|
|
4283
|
+
els.overviewLlmFeedback.className = 'feedback hidden';
|
|
4284
|
+
return;
|
|
4285
|
+
}
|
|
4286
|
+
els.overviewLlmFeedback.textContent = message;
|
|
4287
|
+
els.overviewLlmFeedback.className = `feedback ${kind}`;
|
|
4288
|
+
els.overviewLlmFeedback.classList.remove('hidden');
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
/**
|
|
4292
|
+
* @param {typeof appConfig} cfg
|
|
4293
|
+
*/
|
|
4294
|
+
function fillOverviewLlmControls(cfg) {
|
|
4295
|
+
if (!cfg) return;
|
|
4296
|
+
const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4297
|
+
syncLlmProviderSelects(provider);
|
|
4298
|
+
syncAllModelComboboxValues(currentModel);
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
let llmConfigSaving = false;
|
|
4302
|
+
|
|
4303
|
+
/**
|
|
4304
|
+
* PATCH llmProvider and/or model immediately (Overview switcher).
|
|
4305
|
+
* @param {{ llmProvider?: 'claude' | 'openrouter', model?: string }} patch
|
|
4306
|
+
*/
|
|
4307
|
+
async function patchLlmConfig(patch) {
|
|
4308
|
+
if (llmConfigSaving) return;
|
|
4309
|
+
llmConfigSaving = true;
|
|
4310
|
+
setOverviewLlmFeedback(null);
|
|
4311
|
+
try {
|
|
4312
|
+
const res = await fetch('/api/config', {
|
|
4313
|
+
method: 'PATCH',
|
|
4314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4315
|
+
body: JSON.stringify(patch),
|
|
4316
|
+
});
|
|
4317
|
+
const data = await readJson(res);
|
|
4318
|
+
if (!res.ok) {
|
|
4319
|
+
setOverviewLlmFeedback(data.error || `Update failed (HTTP ${res.status})`, 'error');
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
4322
|
+
applyConfigSnapshot(data);
|
|
4323
|
+
fillOverviewLlmControls(data);
|
|
4324
|
+
if (currentView === 'settings') {
|
|
4325
|
+
fillSettingsForm(data);
|
|
4326
|
+
}
|
|
4327
|
+
setOverviewLlmFeedback('LLM updated. Next jobs will use these values.', 'ok');
|
|
4328
|
+
window.setTimeout(() => setOverviewLlmFeedback(null), 2500);
|
|
4329
|
+
} catch (err) {
|
|
4330
|
+
setOverviewLlmFeedback(err.message || 'Update failed', 'error');
|
|
4331
|
+
} finally {
|
|
4332
|
+
llmConfigSaving = false;
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
/**
|
|
4337
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4338
|
+
*/
|
|
4339
|
+
async function onLlmProviderChange(provider) {
|
|
4340
|
+
const previousModel = currentModel;
|
|
4341
|
+
syncLlmProviderSelects(provider);
|
|
4342
|
+
clearAvailableModels();
|
|
4343
|
+
currentModel = NO_MODEL;
|
|
4344
|
+
syncAllModelComboboxValues(currentModel);
|
|
4345
|
+
renderAllModelComboboxLists(currentModel);
|
|
4346
|
+
updateModelLabel();
|
|
4347
|
+
await fetchModels({
|
|
4348
|
+
refresh: true,
|
|
4349
|
+
provider,
|
|
4350
|
+
reconcileProvider: true,
|
|
4351
|
+
reconcileCandidate: previousModel,
|
|
4352
|
+
});
|
|
4353
|
+
await patchLlmConfig({
|
|
4354
|
+
llmProvider: provider,
|
|
4355
|
+
model: currentModel,
|
|
4356
|
+
});
|
|
4357
|
+
}
|
|
4358
|
+
|
|
3423
4359
|
function applyConfigSnapshot(data) {
|
|
3424
4360
|
appConfig = data || {};
|
|
3425
|
-
if (data?.model
|
|
4361
|
+
if (data?.model != null) {
|
|
4362
|
+
currentModel = isNoModelSelection(data.model) ? NO_MODEL : data.model;
|
|
4363
|
+
}
|
|
4364
|
+
syncAllModelComboboxValues(currentModel);
|
|
3426
4365
|
updateModelLabel();
|
|
4366
|
+
fillOverviewLlmControls(data);
|
|
3427
4367
|
updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
|
|
3428
4368
|
if (els.repoName) {
|
|
3429
4369
|
els.repoName.textContent = data?.repoName || 'local repo';
|
|
@@ -3431,33 +4371,12 @@ function applyConfigSnapshot(data) {
|
|
|
3431
4371
|
if (els.repoBranch) {
|
|
3432
4372
|
els.repoBranch.textContent = data?.baseBranch || '—';
|
|
3433
4373
|
}
|
|
3434
|
-
|
|
4374
|
+
syncLlmProviderAvailability(appConfig);
|
|
3435
4375
|
if (currentView === 'settings') {
|
|
3436
4376
|
fillSettingsForm(appConfig);
|
|
3437
4377
|
}
|
|
3438
4378
|
}
|
|
3439
4379
|
|
|
3440
|
-
/**
|
|
3441
|
-
* Surface missing GitHub / Claude auth on Overview so users can open Settings.
|
|
3442
|
-
* @param {typeof appConfig} cfg
|
|
3443
|
-
*/
|
|
3444
|
-
function updateAuthBanner(cfg) {
|
|
3445
|
-
if (!els.authBanner || !els.authBannerText) return;
|
|
3446
|
-
const missing = [];
|
|
3447
|
-
if (!cfg.ghAuthOk) missing.push('GitHub');
|
|
3448
|
-
if (!cfg.claudeAuthOk && !cfg.stubAgent) missing.push('Claude');
|
|
3449
|
-
if (missing.length === 0) {
|
|
3450
|
-
els.authBanner.classList.add('hidden');
|
|
3451
|
-
els.authBanner.hidden = true;
|
|
3452
|
-
els.authBannerText.textContent = '';
|
|
3453
|
-
return;
|
|
3454
|
-
}
|
|
3455
|
-
const label = missing.join(' and ');
|
|
3456
|
-
els.authBannerText.textContent = `Configure ${label} in Settings → Authentication before starting jobs.`;
|
|
3457
|
-
els.authBanner.classList.remove('hidden');
|
|
3458
|
-
els.authBanner.hidden = false;
|
|
3459
|
-
}
|
|
3460
|
-
|
|
3461
4380
|
async function fetchConfig() {
|
|
3462
4381
|
try {
|
|
3463
4382
|
const res = await fetch('/api/config');
|
|
@@ -3471,7 +4390,8 @@ async function fetchConfig() {
|
|
|
3471
4390
|
|
|
3472
4391
|
function readSettingsForm() {
|
|
3473
4392
|
const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
|
|
3474
|
-
const
|
|
4393
|
+
const rawModel = els.settingsModel?.value ?? currentModel;
|
|
4394
|
+
const model = isNoModelSelection(rawModel) ? NO_MODEL : rawModel;
|
|
3475
4395
|
const maxAgentTurns = Number(els.settingsMaxTurns?.value);
|
|
3476
4396
|
const timeoutMinutes = Number(els.settingsTimeout?.value);
|
|
3477
4397
|
const testRaw = els.settingsTestCommand?.value ?? '';
|
|
@@ -3485,6 +4405,7 @@ function readSettingsForm() {
|
|
|
3485
4405
|
const patch = {
|
|
3486
4406
|
baseBranch,
|
|
3487
4407
|
model,
|
|
4408
|
+
llmProvider: els.settingsLlmProvider?.value === 'openrouter' ? 'openrouter' : 'claude',
|
|
3488
4409
|
maxAgentTurns,
|
|
3489
4410
|
agentTimeoutMs: Math.round(timeoutMinutes * 60_000),
|
|
3490
4411
|
testCommand: testTrimmed === '' ? null : testTrimmed,
|
|
@@ -3498,6 +4419,8 @@ function readSettingsForm() {
|
|
|
3498
4419
|
if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
|
|
3499
4420
|
const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
|
|
3500
4421
|
if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
|
|
4422
|
+
const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
|
|
4423
|
+
if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
|
|
3501
4424
|
|
|
3502
4425
|
if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
|
|
3503
4426
|
patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
|
|
@@ -3587,11 +4510,6 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
|
|
|
3587
4510
|
btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
|
|
3588
4511
|
});
|
|
3589
4512
|
|
|
3590
|
-
els.authBannerBtn?.addEventListener('click', () => {
|
|
3591
|
-
setView('settings');
|
|
3592
|
-
setSettingsTab('auth');
|
|
3593
|
-
});
|
|
3594
|
-
|
|
3595
4513
|
els.themeToggle.addEventListener('click', () => {
|
|
3596
4514
|
const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
3597
4515
|
applyTheme(next);
|
|
@@ -3670,10 +4588,141 @@ bindSecretClear(
|
|
|
3670
4588
|
els.settingsClaudeOauthHint,
|
|
3671
4589
|
'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
|
|
3672
4590
|
);
|
|
4591
|
+
bindSecretClear(
|
|
4592
|
+
els.settingsOpenrouterKeyClear,
|
|
4593
|
+
els.settingsOpenrouterKey,
|
|
4594
|
+
els.settingsOpenrouterKeyHint,
|
|
4595
|
+
'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
|
|
4596
|
+
);
|
|
4597
|
+
|
|
4598
|
+
|
|
4599
|
+
document.addEventListener('click', (ev) => {
|
|
4600
|
+
const target = /** @type {Node | null} */ (ev.target);
|
|
4601
|
+
for (const cb of modelComboboxes) {
|
|
4602
|
+
if (!cb.open) continue;
|
|
4603
|
+
if (cb.root.contains(target)) continue;
|
|
4604
|
+
setModelComboboxOpen(cb, false);
|
|
4605
|
+
}
|
|
4606
|
+
for (const ap of agentPickers) {
|
|
4607
|
+
if (!ap.open) continue;
|
|
4608
|
+
if (ap.root.contains(target)) continue;
|
|
4609
|
+
setAgentPickerOpen(ap, false);
|
|
4610
|
+
}
|
|
4611
|
+
});
|
|
4612
|
+
|
|
4613
|
+
document.addEventListener('keydown', (ev) => {
|
|
4614
|
+
if (ev.key !== 'Escape') return;
|
|
4615
|
+
for (const ap of agentPickers) {
|
|
4616
|
+
if (!ap.open) continue;
|
|
4617
|
+
setAgentPickerOpen(ap, false);
|
|
4618
|
+
ap.trigger.focus();
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
for (const cb of modelComboboxes) {
|
|
4622
|
+
if (!cb.open) continue;
|
|
4623
|
+
setModelComboboxOpen(cb, false);
|
|
4624
|
+
cb.trigger.focus();
|
|
4625
|
+
break;
|
|
4626
|
+
}
|
|
4627
|
+
});
|
|
4628
|
+
|
|
4629
|
+
els.settingsLlmProvider?.addEventListener('change', () => {
|
|
4630
|
+
const provider = currentLlmProviderSelection();
|
|
4631
|
+
if (!isLlmProviderAvailable(provider)) {
|
|
4632
|
+
const fallback =
|
|
4633
|
+
appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4634
|
+
syncLlmProviderSelects(fallback);
|
|
4635
|
+
return;
|
|
4636
|
+
}
|
|
4637
|
+
for (const cb of modelComboboxes) {
|
|
4638
|
+
setModelComboboxOpen(cb, false);
|
|
4639
|
+
}
|
|
4640
|
+
void onLlmProviderChange(provider);
|
|
4641
|
+
});
|
|
4642
|
+
|
|
4643
|
+
els.overviewLlmProvider?.addEventListener('change', () => {
|
|
4644
|
+
const provider =
|
|
4645
|
+
els.overviewLlmProvider.value === 'openrouter' ? 'openrouter' : 'claude';
|
|
4646
|
+
if (!isLlmProviderAvailable(provider)) {
|
|
4647
|
+
const fallback =
|
|
4648
|
+
appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4649
|
+
syncLlmProviderSelects(fallback);
|
|
4650
|
+
return;
|
|
4651
|
+
}
|
|
4652
|
+
for (const cb of modelComboboxes) {
|
|
4653
|
+
setModelComboboxOpen(cb, false);
|
|
4654
|
+
}
|
|
4655
|
+
for (const ap of agentPickers) {
|
|
4656
|
+
setAgentPickerOpen(ap, false);
|
|
4657
|
+
}
|
|
4658
|
+
void onLlmProviderChange(provider);
|
|
4659
|
+
});
|
|
4660
|
+
|
|
4661
|
+
if (
|
|
4662
|
+
els.settingsModelPicker &&
|
|
4663
|
+
els.settingsModelTrigger &&
|
|
4664
|
+
els.settingsModelPanel &&
|
|
4665
|
+
els.settingsModelSearch &&
|
|
4666
|
+
els.settingsModelList &&
|
|
4667
|
+
els.settingsModelValue
|
|
4668
|
+
) {
|
|
4669
|
+
initModelCombobox({
|
|
4670
|
+
root: els.settingsModelPicker,
|
|
4671
|
+
trigger: els.settingsModelTrigger,
|
|
4672
|
+
panel: els.settingsModelPanel,
|
|
4673
|
+
search: els.settingsModelSearch,
|
|
4674
|
+
list: els.settingsModelList,
|
|
4675
|
+
hiddenInput: els.settingsModel,
|
|
4676
|
+
valueEl: els.settingsModelValue,
|
|
4677
|
+
optionIdPrefix: 'settings-model-opt',
|
|
4678
|
+
onSelect: (modelId) => {
|
|
4679
|
+
currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
|
|
4680
|
+
syncAllModelComboboxValues(currentModel);
|
|
4681
|
+
renderAllModelComboboxLists(currentModel);
|
|
4682
|
+
updateModelLabel();
|
|
4683
|
+
},
|
|
4684
|
+
});
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
if (
|
|
4688
|
+
els.overviewAgentPicker &&
|
|
4689
|
+
els.overviewAgentTrigger &&
|
|
4690
|
+
els.overviewAgentPanel &&
|
|
4691
|
+
els.overviewAgentSearch &&
|
|
4692
|
+
els.overviewAgentModelList &&
|
|
4693
|
+
els.overviewAgentValue
|
|
4694
|
+
) {
|
|
4695
|
+
initAgentPicker({
|
|
4696
|
+
root: els.overviewAgentPicker,
|
|
4697
|
+
trigger: els.overviewAgentTrigger,
|
|
4698
|
+
panel: els.overviewAgentPanel,
|
|
4699
|
+
search: els.overviewAgentSearch,
|
|
4700
|
+
list: els.overviewAgentModelList,
|
|
4701
|
+
valueEl: els.overviewAgentValue,
|
|
4702
|
+
iconEl: els.overviewAgentIcon,
|
|
4703
|
+
tabs: [
|
|
4704
|
+
...(els.overviewAgentPanel.querySelectorAll('.agent-picker-tab') || []),
|
|
4705
|
+
],
|
|
4706
|
+
hiddenInput: els.overviewModel,
|
|
4707
|
+
optionIdPrefix: 'overview-agent-model-opt',
|
|
4708
|
+
onSelect: (modelId) => {
|
|
4709
|
+
currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
|
|
4710
|
+
syncAllModelComboboxValues(currentModel);
|
|
4711
|
+
renderAllModelComboboxLists(currentModel);
|
|
4712
|
+
updateModelLabel();
|
|
4713
|
+
void patchLlmConfig({ model: currentModel });
|
|
4714
|
+
},
|
|
4715
|
+
});
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
els.issueUrls?.addEventListener('keydown', (ev) => {
|
|
4719
|
+
if (ev.key !== 'Enter' || ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
4720
|
+
ev.preventDefault();
|
|
4721
|
+
els.addBtn?.click();
|
|
4722
|
+
});
|
|
3673
4723
|
|
|
3674
4724
|
els.addBtn.addEventListener('click', async () => {
|
|
3675
4725
|
els.enqueueFeedback.classList.add('hidden');
|
|
3676
|
-
if (els.enqueueHint) els.enqueueHint.classList.remove('hidden');
|
|
3677
4726
|
const text = els.issueUrls.value.trim();
|
|
3678
4727
|
if (!text) return;
|
|
3679
4728
|
|
|
@@ -3687,7 +4736,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3687
4736
|
});
|
|
3688
4737
|
const data = await res.json();
|
|
3689
4738
|
if (!res.ok) {
|
|
3690
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3691
4739
|
els.enqueueFeedback.textContent =
|
|
3692
4740
|
data.error + (data.invalidUrls ? `: ${data.invalidUrls.join(', ')}` : '');
|
|
3693
4741
|
els.enqueueFeedback.className = 'feedback error';
|
|
@@ -3701,7 +4749,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3701
4749
|
parts.push(`Skipped duplicates: ${data.skipped.join(', ')}`);
|
|
3702
4750
|
}
|
|
3703
4751
|
if (parts.length) {
|
|
3704
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3705
4752
|
els.enqueueFeedback.textContent = parts.join(' · ');
|
|
3706
4753
|
els.enqueueFeedback.className =
|
|
3707
4754
|
'feedback ' + (data.skipped?.length ? 'warn' : 'ok');
|
|
@@ -3713,31 +4760,27 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3713
4760
|
selectRun(data.jobs[0].id);
|
|
3714
4761
|
}
|
|
3715
4762
|
} catch (err) {
|
|
3716
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3717
4763
|
els.enqueueFeedback.textContent = err.message;
|
|
3718
4764
|
els.enqueueFeedback.className = 'feedback error';
|
|
3719
4765
|
els.enqueueFeedback.classList.remove('hidden');
|
|
3720
4766
|
}
|
|
3721
4767
|
});
|
|
3722
4768
|
|
|
3723
|
-
|
|
4769
|
+
function handleTicketSourceToggleClick(e) {
|
|
3724
4770
|
const btn = e.target.closest('.source-btn');
|
|
3725
4771
|
if (!btn?.dataset.source) return;
|
|
3726
4772
|
const next = btn.dataset.source === 'jira' ? 'jira' : 'github';
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
} catch {
|
|
3739
|
-
// local UI already updated; save via Settings if needed
|
|
3740
|
-
}
|
|
4773
|
+
if (next === ticketSource) return;
|
|
4774
|
+
void saveTicketSource(next);
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4777
|
+
els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4778
|
+
els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4779
|
+
|
|
4780
|
+
els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
|
|
4781
|
+
e.preventDefault();
|
|
4782
|
+
setView('settings');
|
|
4783
|
+
setSettingsTab('ticket');
|
|
3741
4784
|
});
|
|
3742
4785
|
|
|
3743
4786
|
els.jiraTestBtn?.addEventListener('click', async () => {
|