acdev 1.0.5 → 1.0.7
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 +20 -10
- package/package.json +1 -1
- package/public/app.js +1166 -124
- package/public/index.html +154 -17
- package/public/styles.css +590 -33
- package/src/agent.js +127 -64
- package/src/config.js +181 -11
- package/src/models.js +296 -39
- package/src/openrouter-auth.js +37 -0
- package/src/server.js +77 -17
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,833 @@ 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: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)' },
|
|
3643
|
+
];
|
|
3644
|
+
}
|
|
3645
|
+
return [
|
|
3646
|
+
{ id: 'claude-sonnet-5', label: 'Sonnet 5' },
|
|
3647
|
+
{ id: 'claude-opus-5', label: 'Opus 5' },
|
|
3648
|
+
{ id: 'claude-fable-5', label: 'Fable 5' },
|
|
3649
|
+
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5' },
|
|
3650
|
+
{ id: 'sonnet', label: 'sonnet (latest)' },
|
|
3651
|
+
{ id: 'opus', label: 'opus (latest)' },
|
|
3652
|
+
];
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
/**
|
|
3656
|
+
* @param {'anthropic' | 'openrouter' | 'fallback' | string | undefined} source
|
|
3657
|
+
* @param {'claude' | 'openrouter'} [provider]
|
|
3658
|
+
*/
|
|
3659
|
+
function updateModelSourceHint(source, provider = 'claude') {
|
|
2942
3660
|
if (!els.settingsModelHint) return;
|
|
3661
|
+
const name = provider === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
2943
3662
|
if (source === 'anthropic') {
|
|
2944
3663
|
els.settingsModelHint.textContent =
|
|
2945
|
-
|
|
2946
|
-
} else if (source === '
|
|
3664
|
+
`${name} model for agent runs. List loaded from Anthropic Models API.`;
|
|
3665
|
+
} else if (source === 'openrouter') {
|
|
2947
3666
|
els.settingsModelHint.textContent =
|
|
2948
|
-
'
|
|
3667
|
+
'OpenRouter model for agent runs (anthropic/* only — Claude Agent SDK uses Anthropic-compatible API). List loaded from OpenRouter Models API.';
|
|
3668
|
+
} else if (source === 'fallback') {
|
|
3669
|
+
if (provider === 'openrouter') {
|
|
3670
|
+
els.settingsModelHint.textContent =
|
|
3671
|
+
'OpenRouter model for agent runs (anthropic/* only). Showing curated Anthropic models (live list unavailable — set OPENROUTER_API_KEY in Authentication).';
|
|
3672
|
+
} else {
|
|
3673
|
+
els.settingsModelHint.textContent =
|
|
3674
|
+
'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
|
|
3675
|
+
}
|
|
2949
3676
|
} else {
|
|
2950
|
-
els.settingsModelHint.textContent =
|
|
3677
|
+
els.settingsModelHint.textContent = `${name} model for agent runs.`;
|
|
2951
3678
|
}
|
|
2952
3679
|
}
|
|
2953
3680
|
|
|
2954
3681
|
/**
|
|
2955
|
-
* @param {{
|
|
3682
|
+
* @param {{ id: string, label?: string, name?: string }} model
|
|
3683
|
+
* @returns {string}
|
|
3684
|
+
*/
|
|
3685
|
+
function modelPickerLabel(model) {
|
|
3686
|
+
return model.label || model.name || model.id;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
/**
|
|
3690
|
+
* @param {{ id: string, label?: string, name?: string }} model
|
|
3691
|
+
* @param {string} query
|
|
3692
|
+
* @returns {boolean}
|
|
3693
|
+
*/
|
|
3694
|
+
function modelMatchesSearchQuery(model, query) {
|
|
3695
|
+
if (!query) return true;
|
|
3696
|
+
const q = query.toLowerCase();
|
|
3697
|
+
return [model.id, model.label, model.name]
|
|
3698
|
+
.filter(Boolean)
|
|
3699
|
+
.some((part) => String(part).toLowerCase().includes(q));
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
/**
|
|
3703
|
+
* @returns {'claude' | 'openrouter'}
|
|
3704
|
+
*/
|
|
3705
|
+
function currentLlmProviderSelection() {
|
|
3706
|
+
const fromOverview = els.overviewLlmProvider?.value;
|
|
3707
|
+
if (fromOverview === 'openrouter' || fromOverview === 'claude') return fromOverview;
|
|
3708
|
+
const fromForm = els.settingsLlmProvider?.value;
|
|
3709
|
+
if (fromForm === 'openrouter' || fromForm === 'claude') return fromForm;
|
|
3710
|
+
return appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/**
|
|
3714
|
+
* @param {'claude' | 'openrouter'} provider
|
|
3715
|
+
*/
|
|
3716
|
+
function syncLlmProviderSelects(provider) {
|
|
3717
|
+
const value = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
3718
|
+
if (els.settingsLlmProvider) els.settingsLlmProvider.value = value;
|
|
3719
|
+
if (els.overviewLlmProvider) els.overviewLlmProvider.value = value;
|
|
3720
|
+
syncAgentPickerTabs(value);
|
|
3721
|
+
if (els.settingsLlmProviderHint) {
|
|
3722
|
+
els.settingsLlmProviderHint.textContent =
|
|
3723
|
+
value === 'openrouter'
|
|
3724
|
+
? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY). Use anthropic/* models only.'
|
|
3725
|
+
: 'Agent runs use Claude via Anthropic API, OAuth token, or claude auth login.';
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
/**
|
|
3730
|
+
* @param {{ models?: Array<{id:string,label?:string,name?:string}>, selected?: string, source?: string, provider?: string }} data
|
|
2956
3731
|
*/
|
|
2957
3732
|
function populateSettingsModels(data) {
|
|
2958
|
-
const
|
|
3733
|
+
const provider = data?.provider || currentLlmProviderSelection();
|
|
3734
|
+
const cached = getAvailableModels(provider);
|
|
3735
|
+
const models = Array.isArray(data?.models)
|
|
2959
3736
|
? 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
|
-
}
|
|
3737
|
+
: data?.reconcileProvider
|
|
3738
|
+
? []
|
|
3739
|
+
: cached.length
|
|
3740
|
+
? cached
|
|
3741
|
+
: defaultModelsForProvider(provider);
|
|
3742
|
+
setAvailableModels(provider, models);
|
|
3743
|
+
|
|
3744
|
+
if (data?.reconcileProvider) {
|
|
3745
|
+
currentModel = reconcileModelForProvider(
|
|
3746
|
+
models,
|
|
3747
|
+
data.reconcileCandidate,
|
|
3748
|
+
lastModelForProvider(provider)
|
|
3749
|
+
);
|
|
3750
|
+
} else if (data?.selected != null) {
|
|
3751
|
+
const next = isNoModelSelection(data.selected) ? NO_MODEL : data.selected;
|
|
3752
|
+
currentModel = reconcileModelForProvider(models, next, lastModelForProvider(provider));
|
|
2988
3753
|
}
|
|
3754
|
+
if (data?.source) updateModelSourceHint(data.source, provider);
|
|
3755
|
+
|
|
3756
|
+
syncAllModelComboboxValues(currentModel);
|
|
3757
|
+
renderAllModelComboboxLists(currentModel);
|
|
2989
3758
|
updateModelLabel();
|
|
2990
3759
|
}
|
|
2991
3760
|
|
|
3761
|
+
function modelsForFetchFallback(provider, reconcileProvider) {
|
|
3762
|
+
if (reconcileProvider) return [];
|
|
3763
|
+
const cached = getAvailableModels(provider);
|
|
3764
|
+
if (cached.length) return cached;
|
|
3765
|
+
return defaultModelsForProvider(provider);
|
|
3766
|
+
}
|
|
3767
|
+
|
|
2992
3768
|
async function fetchModels(opts = {}) {
|
|
2993
3769
|
const refresh = opts.refresh === true;
|
|
3770
|
+
const provider = opts.provider || currentLlmProviderSelection();
|
|
3771
|
+
const reconcileProvider = opts.reconcileProvider === true;
|
|
3772
|
+
const reconcileCandidate = opts.reconcileCandidate;
|
|
3773
|
+
if (reconcileProvider) {
|
|
3774
|
+
clearAvailableModels();
|
|
3775
|
+
}
|
|
3776
|
+
const query = new URLSearchParams({ provider });
|
|
3777
|
+
if (refresh) query.set('refresh', '1');
|
|
2994
3778
|
try {
|
|
2995
|
-
const res = await fetch(
|
|
3779
|
+
const res = await fetch(`/api/models?${query.toString()}`);
|
|
2996
3780
|
if (!res.ok) {
|
|
2997
3781
|
populateSettingsModels({
|
|
2998
|
-
models:
|
|
2999
|
-
? availableModels
|
|
3000
|
-
: [
|
|
3001
|
-
{ id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
|
|
3002
|
-
],
|
|
3782
|
+
models: modelsForFetchFallback(provider, reconcileProvider),
|
|
3003
3783
|
selected: currentModel,
|
|
3784
|
+
provider,
|
|
3785
|
+
reconcileProvider,
|
|
3786
|
+
reconcileCandidate,
|
|
3004
3787
|
});
|
|
3005
3788
|
return;
|
|
3006
3789
|
}
|
|
3007
3790
|
const data = await readJson(res);
|
|
3008
|
-
populateSettingsModels(
|
|
3791
|
+
populateSettingsModels({
|
|
3792
|
+
...data,
|
|
3793
|
+
provider: data.provider || provider,
|
|
3794
|
+
reconcileProvider,
|
|
3795
|
+
reconcileCandidate,
|
|
3796
|
+
});
|
|
3009
3797
|
} catch (err) {
|
|
3010
3798
|
console.error('Failed to fetch models:', err);
|
|
3011
3799
|
populateSettingsModels({
|
|
3012
|
-
models:
|
|
3013
|
-
? availableModels
|
|
3014
|
-
: [
|
|
3015
|
-
{ id: currentModel || 'claude-sonnet-5', label: currentModel || 'claude-sonnet-5' },
|
|
3016
|
-
],
|
|
3800
|
+
models: modelsForFetchFallback(provider, reconcileProvider),
|
|
3017
3801
|
selected: currentModel,
|
|
3802
|
+
provider,
|
|
3803
|
+
reconcileProvider,
|
|
3804
|
+
reconcileCandidate,
|
|
3018
3805
|
});
|
|
3019
3806
|
}
|
|
3020
3807
|
}
|
|
@@ -3133,6 +3920,13 @@ function claudeStatusText(cfg) {
|
|
|
3133
3920
|
}
|
|
3134
3921
|
}
|
|
3135
3922
|
|
|
3923
|
+
function openrouterStatusText(cfg) {
|
|
3924
|
+
if (cfg.openrouterAuthOk) {
|
|
3925
|
+
return 'Authenticated via OPENROUTER_API_KEY';
|
|
3926
|
+
}
|
|
3927
|
+
return 'Not authenticated — add an OpenRouter API key below';
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3136
3930
|
function fillAuthSettings(cfg) {
|
|
3137
3931
|
if (els.settingsGhStatus) {
|
|
3138
3932
|
els.settingsGhStatus.textContent = ghStatusText(cfg);
|
|
@@ -3163,8 +3957,9 @@ function fillAuthSettings(cfg) {
|
|
|
3163
3957
|
|
|
3164
3958
|
if (els.settingsClaudeStatus) {
|
|
3165
3959
|
els.settingsClaudeStatus.textContent = claudeStatusText(cfg);
|
|
3166
|
-
els.settingsClaudeStatus.className =
|
|
3167
|
-
|
|
3960
|
+
els.settingsClaudeStatus.className = cfg.claudeAuthOk
|
|
3961
|
+
? 'auth-status ok'
|
|
3962
|
+
: 'auth-status err';
|
|
3168
3963
|
}
|
|
3169
3964
|
fillSecretInput(
|
|
3170
3965
|
els.settingsAnthropicKey,
|
|
@@ -3190,6 +3985,25 @@ function fillAuthSettings(cfg) {
|
|
|
3190
3985
|
? '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
3986
|
: '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
3987
|
}
|
|
3988
|
+
|
|
3989
|
+
if (els.settingsOpenrouterStatus) {
|
|
3990
|
+
els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
|
|
3991
|
+
els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
|
|
3992
|
+
? 'auth-status ok'
|
|
3993
|
+
: 'auth-status err';
|
|
3994
|
+
}
|
|
3995
|
+
fillSecretInput(
|
|
3996
|
+
els.settingsOpenrouterKey,
|
|
3997
|
+
els.settingsOpenrouterKeyClear,
|
|
3998
|
+
Boolean(cfg.openrouterApiKeySet),
|
|
3999
|
+
cfg.openrouterApiKeyMasked,
|
|
4000
|
+
'Paste an OpenRouter API key'
|
|
4001
|
+
);
|
|
4002
|
+
if (els.settingsOpenrouterKeyHint) {
|
|
4003
|
+
els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
|
|
4004
|
+
? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed).'
|
|
4005
|
+
: '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>.';
|
|
4006
|
+
}
|
|
3193
4007
|
}
|
|
3194
4008
|
|
|
3195
4009
|
function fillSettingsForm(cfg) {
|
|
@@ -3269,16 +4083,23 @@ function fillSettingsForm(cfg) {
|
|
|
3269
4083
|
els.settingsBaseBranch.value = cfg.baseBranch || '';
|
|
3270
4084
|
}
|
|
3271
4085
|
|
|
3272
|
-
if (els.
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
4086
|
+
if (els.settingsLlmProvider) {
|
|
4087
|
+
syncLlmProviderSelects(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
if (els.settingsModelPicker) {
|
|
4091
|
+
const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4092
|
+
if (getAvailableModels(provider).length) {
|
|
3276
4093
|
populateSettingsModels({
|
|
3277
|
-
models:
|
|
3278
|
-
selected: cfg.model,
|
|
4094
|
+
models: getAvailableModels(provider),
|
|
4095
|
+
selected: cfg.model ?? currentModel,
|
|
4096
|
+
provider,
|
|
3279
4097
|
});
|
|
4098
|
+
} else if (cfg.model != null) {
|
|
4099
|
+
currentModel = isNoModelSelection(cfg.model) ? NO_MODEL : cfg.model;
|
|
4100
|
+
syncModelPickerValue(currentModel);
|
|
3280
4101
|
}
|
|
3281
|
-
void fetchModels();
|
|
4102
|
+
void fetchModels({ provider });
|
|
3282
4103
|
}
|
|
3283
4104
|
|
|
3284
4105
|
if (els.settingsMaxTurns) {
|
|
@@ -3361,10 +4182,12 @@ function updateGithubRuleFieldsVisibility() {
|
|
|
3361
4182
|
function updateTicketSourceUI(source) {
|
|
3362
4183
|
ticketSource = source === 'jira' ? 'jira' : 'github';
|
|
3363
4184
|
|
|
3364
|
-
|
|
3365
|
-
|
|
4185
|
+
for (const toggle of [els.settingsTicketSource, els.overviewTicketSource]) {
|
|
4186
|
+
if (!toggle) continue;
|
|
4187
|
+
toggle.querySelectorAll('.source-btn').forEach((btn) => {
|
|
3366
4188
|
const active = btn.dataset.source === ticketSource;
|
|
3367
4189
|
btn.classList.toggle('active', active);
|
|
4190
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
3368
4191
|
});
|
|
3369
4192
|
}
|
|
3370
4193
|
|
|
@@ -3382,11 +4205,9 @@ function updateTicketSourceUI(source) {
|
|
|
3382
4205
|
|
|
3383
4206
|
if (els.issueUrls) {
|
|
3384
4207
|
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';
|
|
4208
|
+
els.issueUrls.placeholder = 'Paste Jira keys or browse URLs…';
|
|
3387
4209
|
} 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';
|
|
4210
|
+
els.issueUrls.placeholder = 'Paste GitHub issue links or URLs…';
|
|
3390
4211
|
}
|
|
3391
4212
|
}
|
|
3392
4213
|
|
|
@@ -3395,11 +4216,24 @@ function updateTicketSourceUI(source) {
|
|
|
3395
4216
|
ticketSource === 'jira' ? 'Jira issue keys or URLs' : 'GitHub issue URLs';
|
|
3396
4217
|
}
|
|
3397
4218
|
|
|
3398
|
-
if (els.
|
|
3399
|
-
els.
|
|
3400
|
-
ticketSource === 'jira'
|
|
3401
|
-
|
|
3402
|
-
|
|
4219
|
+
if (els.enqueueHintExample) {
|
|
4220
|
+
els.enqueueHintExample.textContent =
|
|
4221
|
+
ticketSource === 'jira' ? 'PROJ-123' : 'owner/repo#42';
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
if (els.enqueueInfoText) {
|
|
4225
|
+
if (ticketSource === 'jira') {
|
|
4226
|
+
els.enqueueInfoText.innerHTML =
|
|
4227
|
+
'<strong>Jira issues run one at a time.</strong> Open GitHub PRs are picked up automatically via gh.';
|
|
4228
|
+
} else {
|
|
4229
|
+
els.enqueueInfoText.innerHTML =
|
|
4230
|
+
'<strong>GitHub issues run one at a time.</strong> Open PRs are picked up automatically via gh.';
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
if (els.enqueueJiraHint) {
|
|
4235
|
+
const showJiraHint = ticketSource === 'jira' && !appConfig.jiraConfigured;
|
|
4236
|
+
els.enqueueJiraHint.classList.toggle('hidden', !showJiraHint);
|
|
3403
4237
|
}
|
|
3404
4238
|
|
|
3405
4239
|
if (els.overviewQueueEmpty) {
|
|
@@ -3410,6 +4244,27 @@ function updateTicketSourceUI(source) {
|
|
|
3410
4244
|
}
|
|
3411
4245
|
}
|
|
3412
4246
|
|
|
4247
|
+
/**
|
|
4248
|
+
* @param {'github' | 'jira'} next
|
|
4249
|
+
*/
|
|
4250
|
+
async function saveTicketSource(next) {
|
|
4251
|
+
const source = next === 'jira' ? 'jira' : 'github';
|
|
4252
|
+
updateTicketSourceUI(source);
|
|
4253
|
+
try {
|
|
4254
|
+
const res = await fetch('/api/config', {
|
|
4255
|
+
method: 'PATCH',
|
|
4256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4257
|
+
body: JSON.stringify({ ticketSource: source }),
|
|
4258
|
+
});
|
|
4259
|
+
const data = await readJson(res);
|
|
4260
|
+
if (res.ok) {
|
|
4261
|
+
applyConfigSnapshot({ ...appConfig, ...data });
|
|
4262
|
+
}
|
|
4263
|
+
} catch {
|
|
4264
|
+
// local UI already updated; save via Settings if needed
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
|
|
3413
4268
|
function updateTimeoutMsHint(minutes) {
|
|
3414
4269
|
if (!els.settingsTimeoutMs) return;
|
|
3415
4270
|
const m = Number(minutes);
|
|
@@ -3420,10 +4275,94 @@ function updateTimeoutMsHint(minutes) {
|
|
|
3420
4275
|
els.settingsTimeoutMs.textContent = String(Math.round(m * 60_000));
|
|
3421
4276
|
}
|
|
3422
4277
|
|
|
4278
|
+
function setOverviewLlmFeedback(message, kind = 'ok') {
|
|
4279
|
+
if (!els.overviewLlmFeedback) return;
|
|
4280
|
+
if (!message) {
|
|
4281
|
+
els.overviewLlmFeedback.textContent = '';
|
|
4282
|
+
els.overviewLlmFeedback.className = 'feedback hidden';
|
|
4283
|
+
return;
|
|
4284
|
+
}
|
|
4285
|
+
els.overviewLlmFeedback.textContent = message;
|
|
4286
|
+
els.overviewLlmFeedback.className = `feedback ${kind}`;
|
|
4287
|
+
els.overviewLlmFeedback.classList.remove('hidden');
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4290
|
+
/**
|
|
4291
|
+
* @param {typeof appConfig} cfg
|
|
4292
|
+
*/
|
|
4293
|
+
function fillOverviewLlmControls(cfg) {
|
|
4294
|
+
if (!cfg) return;
|
|
4295
|
+
const provider = cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4296
|
+
syncLlmProviderSelects(provider);
|
|
4297
|
+
syncAllModelComboboxValues(currentModel);
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
let llmConfigSaving = false;
|
|
4301
|
+
|
|
4302
|
+
/**
|
|
4303
|
+
* PATCH llmProvider and/or model immediately (Overview switcher).
|
|
4304
|
+
* @param {{ llmProvider?: 'claude' | 'openrouter', model?: string }} patch
|
|
4305
|
+
*/
|
|
4306
|
+
async function patchLlmConfig(patch) {
|
|
4307
|
+
if (llmConfigSaving) return;
|
|
4308
|
+
llmConfigSaving = true;
|
|
4309
|
+
setOverviewLlmFeedback(null);
|
|
4310
|
+
try {
|
|
4311
|
+
const res = await fetch('/api/config', {
|
|
4312
|
+
method: 'PATCH',
|
|
4313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4314
|
+
body: JSON.stringify(patch),
|
|
4315
|
+
});
|
|
4316
|
+
const data = await readJson(res);
|
|
4317
|
+
if (!res.ok) {
|
|
4318
|
+
setOverviewLlmFeedback(data.error || `Update failed (HTTP ${res.status})`, 'error');
|
|
4319
|
+
return;
|
|
4320
|
+
}
|
|
4321
|
+
applyConfigSnapshot(data);
|
|
4322
|
+
fillOverviewLlmControls(data);
|
|
4323
|
+
if (currentView === 'settings') {
|
|
4324
|
+
fillSettingsForm(data);
|
|
4325
|
+
}
|
|
4326
|
+
setOverviewLlmFeedback('LLM updated. Next jobs will use these values.', 'ok');
|
|
4327
|
+
window.setTimeout(() => setOverviewLlmFeedback(null), 2500);
|
|
4328
|
+
} catch (err) {
|
|
4329
|
+
setOverviewLlmFeedback(err.message || 'Update failed', 'error');
|
|
4330
|
+
} finally {
|
|
4331
|
+
llmConfigSaving = false;
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
/**
|
|
4336
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4337
|
+
*/
|
|
4338
|
+
async function onLlmProviderChange(provider) {
|
|
4339
|
+
const previousModel = currentModel;
|
|
4340
|
+
syncLlmProviderSelects(provider);
|
|
4341
|
+
clearAvailableModels();
|
|
4342
|
+
currentModel = NO_MODEL;
|
|
4343
|
+
syncAllModelComboboxValues(currentModel);
|
|
4344
|
+
renderAllModelComboboxLists(currentModel);
|
|
4345
|
+
updateModelLabel();
|
|
4346
|
+
await fetchModels({
|
|
4347
|
+
refresh: true,
|
|
4348
|
+
provider,
|
|
4349
|
+
reconcileProvider: true,
|
|
4350
|
+
reconcileCandidate: previousModel,
|
|
4351
|
+
});
|
|
4352
|
+
await patchLlmConfig({
|
|
4353
|
+
llmProvider: provider,
|
|
4354
|
+
model: currentModel,
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
|
|
3423
4358
|
function applyConfigSnapshot(data) {
|
|
3424
4359
|
appConfig = data || {};
|
|
3425
|
-
if (data?.model
|
|
4360
|
+
if (data?.model != null) {
|
|
4361
|
+
currentModel = isNoModelSelection(data.model) ? NO_MODEL : data.model;
|
|
4362
|
+
}
|
|
4363
|
+
syncAllModelComboboxValues(currentModel);
|
|
3426
4364
|
updateModelLabel();
|
|
4365
|
+
fillOverviewLlmControls(data);
|
|
3427
4366
|
updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
|
|
3428
4367
|
if (els.repoName) {
|
|
3429
4368
|
els.repoName.textContent = data?.repoName || 'local repo';
|
|
@@ -3431,33 +4370,12 @@ function applyConfigSnapshot(data) {
|
|
|
3431
4370
|
if (els.repoBranch) {
|
|
3432
4371
|
els.repoBranch.textContent = data?.baseBranch || '—';
|
|
3433
4372
|
}
|
|
3434
|
-
|
|
4373
|
+
syncLlmProviderAvailability(appConfig);
|
|
3435
4374
|
if (currentView === 'settings') {
|
|
3436
4375
|
fillSettingsForm(appConfig);
|
|
3437
4376
|
}
|
|
3438
4377
|
}
|
|
3439
4378
|
|
|
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
4379
|
async function fetchConfig() {
|
|
3462
4380
|
try {
|
|
3463
4381
|
const res = await fetch('/api/config');
|
|
@@ -3471,7 +4389,8 @@ async function fetchConfig() {
|
|
|
3471
4389
|
|
|
3472
4390
|
function readSettingsForm() {
|
|
3473
4391
|
const baseBranch = els.settingsBaseBranch?.value?.trim() || '';
|
|
3474
|
-
const
|
|
4392
|
+
const rawModel = els.settingsModel?.value ?? currentModel;
|
|
4393
|
+
const model = isNoModelSelection(rawModel) ? NO_MODEL : rawModel;
|
|
3475
4394
|
const maxAgentTurns = Number(els.settingsMaxTurns?.value);
|
|
3476
4395
|
const timeoutMinutes = Number(els.settingsTimeout?.value);
|
|
3477
4396
|
const testRaw = els.settingsTestCommand?.value ?? '';
|
|
@@ -3485,6 +4404,7 @@ function readSettingsForm() {
|
|
|
3485
4404
|
const patch = {
|
|
3486
4405
|
baseBranch,
|
|
3487
4406
|
model,
|
|
4407
|
+
llmProvider: els.settingsLlmProvider?.value === 'openrouter' ? 'openrouter' : 'claude',
|
|
3488
4408
|
maxAgentTurns,
|
|
3489
4409
|
agentTimeoutMs: Math.round(timeoutMinutes * 60_000),
|
|
3490
4410
|
testCommand: testTrimmed === '' ? null : testTrimmed,
|
|
@@ -3498,6 +4418,8 @@ function readSettingsForm() {
|
|
|
3498
4418
|
if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
|
|
3499
4419
|
const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
|
|
3500
4420
|
if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
|
|
4421
|
+
const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
|
|
4422
|
+
if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
|
|
3501
4423
|
|
|
3502
4424
|
if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
|
|
3503
4425
|
patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
|
|
@@ -3587,11 +4509,6 @@ document.querySelectorAll('.nav-item').forEach((btn) => {
|
|
|
3587
4509
|
btn.addEventListener('click', () => setView(/** @type {ViewId} */ (btn.dataset.view)));
|
|
3588
4510
|
});
|
|
3589
4511
|
|
|
3590
|
-
els.authBannerBtn?.addEventListener('click', () => {
|
|
3591
|
-
setView('settings');
|
|
3592
|
-
setSettingsTab('auth');
|
|
3593
|
-
});
|
|
3594
|
-
|
|
3595
4512
|
els.themeToggle.addEventListener('click', () => {
|
|
3596
4513
|
const next = els.app.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
3597
4514
|
applyTheme(next);
|
|
@@ -3670,10 +4587,141 @@ bindSecretClear(
|
|
|
3670
4587
|
els.settingsClaudeOauthHint,
|
|
3671
4588
|
'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
|
|
3672
4589
|
);
|
|
4590
|
+
bindSecretClear(
|
|
4591
|
+
els.settingsOpenrouterKeyClear,
|
|
4592
|
+
els.settingsOpenrouterKey,
|
|
4593
|
+
els.settingsOpenrouterKeyHint,
|
|
4594
|
+
'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
|
|
4595
|
+
);
|
|
4596
|
+
|
|
4597
|
+
|
|
4598
|
+
document.addEventListener('click', (ev) => {
|
|
4599
|
+
const target = /** @type {Node | null} */ (ev.target);
|
|
4600
|
+
for (const cb of modelComboboxes) {
|
|
4601
|
+
if (!cb.open) continue;
|
|
4602
|
+
if (cb.root.contains(target)) continue;
|
|
4603
|
+
setModelComboboxOpen(cb, false);
|
|
4604
|
+
}
|
|
4605
|
+
for (const ap of agentPickers) {
|
|
4606
|
+
if (!ap.open) continue;
|
|
4607
|
+
if (ap.root.contains(target)) continue;
|
|
4608
|
+
setAgentPickerOpen(ap, false);
|
|
4609
|
+
}
|
|
4610
|
+
});
|
|
4611
|
+
|
|
4612
|
+
document.addEventListener('keydown', (ev) => {
|
|
4613
|
+
if (ev.key !== 'Escape') return;
|
|
4614
|
+
for (const ap of agentPickers) {
|
|
4615
|
+
if (!ap.open) continue;
|
|
4616
|
+
setAgentPickerOpen(ap, false);
|
|
4617
|
+
ap.trigger.focus();
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
for (const cb of modelComboboxes) {
|
|
4621
|
+
if (!cb.open) continue;
|
|
4622
|
+
setModelComboboxOpen(cb, false);
|
|
4623
|
+
cb.trigger.focus();
|
|
4624
|
+
break;
|
|
4625
|
+
}
|
|
4626
|
+
});
|
|
4627
|
+
|
|
4628
|
+
els.settingsLlmProvider?.addEventListener('change', () => {
|
|
4629
|
+
const provider = currentLlmProviderSelection();
|
|
4630
|
+
if (!isLlmProviderAvailable(provider)) {
|
|
4631
|
+
const fallback =
|
|
4632
|
+
appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4633
|
+
syncLlmProviderSelects(fallback);
|
|
4634
|
+
return;
|
|
4635
|
+
}
|
|
4636
|
+
for (const cb of modelComboboxes) {
|
|
4637
|
+
setModelComboboxOpen(cb, false);
|
|
4638
|
+
}
|
|
4639
|
+
void onLlmProviderChange(provider);
|
|
4640
|
+
});
|
|
4641
|
+
|
|
4642
|
+
els.overviewLlmProvider?.addEventListener('change', () => {
|
|
4643
|
+
const provider =
|
|
4644
|
+
els.overviewLlmProvider.value === 'openrouter' ? 'openrouter' : 'claude';
|
|
4645
|
+
if (!isLlmProviderAvailable(provider)) {
|
|
4646
|
+
const fallback =
|
|
4647
|
+
appConfig.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4648
|
+
syncLlmProviderSelects(fallback);
|
|
4649
|
+
return;
|
|
4650
|
+
}
|
|
4651
|
+
for (const cb of modelComboboxes) {
|
|
4652
|
+
setModelComboboxOpen(cb, false);
|
|
4653
|
+
}
|
|
4654
|
+
for (const ap of agentPickers) {
|
|
4655
|
+
setAgentPickerOpen(ap, false);
|
|
4656
|
+
}
|
|
4657
|
+
void onLlmProviderChange(provider);
|
|
4658
|
+
});
|
|
4659
|
+
|
|
4660
|
+
if (
|
|
4661
|
+
els.settingsModelPicker &&
|
|
4662
|
+
els.settingsModelTrigger &&
|
|
4663
|
+
els.settingsModelPanel &&
|
|
4664
|
+
els.settingsModelSearch &&
|
|
4665
|
+
els.settingsModelList &&
|
|
4666
|
+
els.settingsModelValue
|
|
4667
|
+
) {
|
|
4668
|
+
initModelCombobox({
|
|
4669
|
+
root: els.settingsModelPicker,
|
|
4670
|
+
trigger: els.settingsModelTrigger,
|
|
4671
|
+
panel: els.settingsModelPanel,
|
|
4672
|
+
search: els.settingsModelSearch,
|
|
4673
|
+
list: els.settingsModelList,
|
|
4674
|
+
hiddenInput: els.settingsModel,
|
|
4675
|
+
valueEl: els.settingsModelValue,
|
|
4676
|
+
optionIdPrefix: 'settings-model-opt',
|
|
4677
|
+
onSelect: (modelId) => {
|
|
4678
|
+
currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
|
|
4679
|
+
syncAllModelComboboxValues(currentModel);
|
|
4680
|
+
renderAllModelComboboxLists(currentModel);
|
|
4681
|
+
updateModelLabel();
|
|
4682
|
+
},
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
if (
|
|
4687
|
+
els.overviewAgentPicker &&
|
|
4688
|
+
els.overviewAgentTrigger &&
|
|
4689
|
+
els.overviewAgentPanel &&
|
|
4690
|
+
els.overviewAgentSearch &&
|
|
4691
|
+
els.overviewAgentModelList &&
|
|
4692
|
+
els.overviewAgentValue
|
|
4693
|
+
) {
|
|
4694
|
+
initAgentPicker({
|
|
4695
|
+
root: els.overviewAgentPicker,
|
|
4696
|
+
trigger: els.overviewAgentTrigger,
|
|
4697
|
+
panel: els.overviewAgentPanel,
|
|
4698
|
+
search: els.overviewAgentSearch,
|
|
4699
|
+
list: els.overviewAgentModelList,
|
|
4700
|
+
valueEl: els.overviewAgentValue,
|
|
4701
|
+
iconEl: els.overviewAgentIcon,
|
|
4702
|
+
tabs: [
|
|
4703
|
+
...(els.overviewAgentPanel.querySelectorAll('.agent-picker-tab') || []),
|
|
4704
|
+
],
|
|
4705
|
+
hiddenInput: els.overviewModel,
|
|
4706
|
+
optionIdPrefix: 'overview-agent-model-opt',
|
|
4707
|
+
onSelect: (modelId) => {
|
|
4708
|
+
currentModel = isNoModelSelection(modelId) ? NO_MODEL : modelId;
|
|
4709
|
+
syncAllModelComboboxValues(currentModel);
|
|
4710
|
+
renderAllModelComboboxLists(currentModel);
|
|
4711
|
+
updateModelLabel();
|
|
4712
|
+
void patchLlmConfig({ model: currentModel });
|
|
4713
|
+
},
|
|
4714
|
+
});
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
els.issueUrls?.addEventListener('keydown', (ev) => {
|
|
4718
|
+
if (ev.key !== 'Enter' || ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) return;
|
|
4719
|
+
ev.preventDefault();
|
|
4720
|
+
els.addBtn?.click();
|
|
4721
|
+
});
|
|
3673
4722
|
|
|
3674
4723
|
els.addBtn.addEventListener('click', async () => {
|
|
3675
4724
|
els.enqueueFeedback.classList.add('hidden');
|
|
3676
|
-
if (els.enqueueHint) els.enqueueHint.classList.remove('hidden');
|
|
3677
4725
|
const text = els.issueUrls.value.trim();
|
|
3678
4726
|
if (!text) return;
|
|
3679
4727
|
|
|
@@ -3687,7 +4735,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3687
4735
|
});
|
|
3688
4736
|
const data = await res.json();
|
|
3689
4737
|
if (!res.ok) {
|
|
3690
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3691
4738
|
els.enqueueFeedback.textContent =
|
|
3692
4739
|
data.error + (data.invalidUrls ? `: ${data.invalidUrls.join(', ')}` : '');
|
|
3693
4740
|
els.enqueueFeedback.className = 'feedback error';
|
|
@@ -3701,7 +4748,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3701
4748
|
parts.push(`Skipped duplicates: ${data.skipped.join(', ')}`);
|
|
3702
4749
|
}
|
|
3703
4750
|
if (parts.length) {
|
|
3704
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3705
4751
|
els.enqueueFeedback.textContent = parts.join(' · ');
|
|
3706
4752
|
els.enqueueFeedback.className =
|
|
3707
4753
|
'feedback ' + (data.skipped?.length ? 'warn' : 'ok');
|
|
@@ -3713,31 +4759,27 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
3713
4759
|
selectRun(data.jobs[0].id);
|
|
3714
4760
|
}
|
|
3715
4761
|
} catch (err) {
|
|
3716
|
-
if (els.enqueueHint) els.enqueueHint.classList.add('hidden');
|
|
3717
4762
|
els.enqueueFeedback.textContent = err.message;
|
|
3718
4763
|
els.enqueueFeedback.className = 'feedback error';
|
|
3719
4764
|
els.enqueueFeedback.classList.remove('hidden');
|
|
3720
4765
|
}
|
|
3721
4766
|
});
|
|
3722
4767
|
|
|
3723
|
-
|
|
4768
|
+
function handleTicketSourceToggleClick(e) {
|
|
3724
4769
|
const btn = e.target.closest('.source-btn');
|
|
3725
4770
|
if (!btn?.dataset.source) return;
|
|
3726
4771
|
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
|
-
}
|
|
4772
|
+
if (next === ticketSource) return;
|
|
4773
|
+
void saveTicketSource(next);
|
|
4774
|
+
}
|
|
4775
|
+
|
|
4776
|
+
els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4777
|
+
els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4778
|
+
|
|
4779
|
+
els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
|
|
4780
|
+
e.preventDefault();
|
|
4781
|
+
setView('settings');
|
|
4782
|
+
setSettingsTab('ticket');
|
|
3741
4783
|
});
|
|
3742
4784
|
|
|
3743
4785
|
els.jiraTestBtn?.addEventListener('click', async () => {
|