cortico 0.1.2 → 0.1.3
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/package.json +1 -1
- package/src/boot.ts +11 -0
- package/src/core/README.md +8 -0
- package/src/core/core.ts +3 -1
- package/src/core/generation.ts +66 -9
- package/src/core/instance-lock.ts +6 -0
- package/src/core/loop.ts +7 -5
- package/src/core/secrets.ts +2 -4
- package/src/core/types.ts +5 -2
- package/src/extensions/dry-mount.ts +16 -9
- package/src/extensions/manifest.ts +1 -1
- package/src/launcher.ts +8 -14
- package/src/protocol/open-responses/context-helpers.ts +3 -1
- package/src/providers/README.md +16 -9
- package/src/providers/console/config.ts +15 -0
- package/src/providers/console/hub.ts +49 -12
- package/src/providers/console/settings.ts +20 -8
- package/src/providers/console/strings.ts +14 -0
- package/src/providers/llamacpp/console/models-panel.ts +87 -4
- package/src/providers/llamacpp/console/server.ts +24 -2
- package/src/providers/llamacpp/huggingface.ts +93 -0
- package/src/providers/llamacpp/strings.ts +42 -0
- package/src/providers/name.ts +5 -0
- package/src/providers/openai-responses-compat/console/server.ts +9 -1
- package/src/providers/openai-responses-compat/native.ts +8 -3
- package/src/providers/pricebook.ts +5 -5
- package/src/providers/registry.ts +21 -12
- package/src/providers/strings.ts +2 -0
- package/src/providers/transport/errors.ts +9 -0
- package/src/providers/transport/response-http.ts +7 -3
- package/src/web/README.md +3 -0
- package/src/web/client/console-pages/host.ts +10 -10
- package/src/web/client/features/providers/detail.ts +184 -137
- package/src/web/client/features/providers/index.ts +48 -42
- package/src/web/client/features/providers/strings.ts +22 -18
- package/src/web/client/ui/fields.ts +3 -1
- package/src/web/public/styles.css +28 -20
- package/src/web/server.ts +4 -2
- package/src/web/shared/console-protocol.ts +13 -0
- package/src/worlds/minecraft/client-launch.ts +11 -2
- package/src/worlds/minecraft/client.ts +4 -0
- package/src/worlds/qq/world.ts +3 -3
|
@@ -46,3 +46,12 @@ export function retryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
46
46
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** `Retry-After` 头换算成毫秒:数字按秒,HTTP 日期按距今;缺失或不成形返回 null,过期为 0。 */
|
|
51
|
+
export function parseRetryAfter(header: string | null): number | null {
|
|
52
|
+
if (!header) return null;
|
|
53
|
+
const value = header.trim();
|
|
54
|
+
if (/^\d+$/.test(value)) return Number(value) * 1000;
|
|
55
|
+
const at = Date.parse(value);
|
|
56
|
+
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
|
|
57
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { Request, Response, StreamEvent } from '../../protocol/open-respons
|
|
|
3
3
|
import type { ItemOrigin } from '../../protocol/open-responses/context.ts';
|
|
4
4
|
import { GenerationError, priceUsage, unknownMeters, type GenerateOptions, type Generation, type ProviderAttempt, type PriceSnapshot, type TokenMeters } from '../../core/generation.ts';
|
|
5
5
|
import { ResponseProtocolError } from '../../protocol/open-responses/stream.ts';
|
|
6
|
-
import { LLMError, abortError, retryDelay, reqIdSuffix } from './errors.ts';
|
|
6
|
+
import { LLMError, abortError, parseRetryAfter, retryDelay, reqIdSuffix } from './errors.ts';
|
|
7
7
|
import type { ResponseAssembly } from './response-assembly.ts';
|
|
8
8
|
|
|
9
9
|
export interface ResponseTransport {
|
|
@@ -67,14 +67,17 @@ export async function generate(request: Request, options: GenerateOptions, origi
|
|
|
67
67
|
let lastError: unknown;
|
|
68
68
|
let partial: Response | null = null;
|
|
69
69
|
let authRetried = false;
|
|
70
|
+
// 上一次尝试的 `Retry-After`;没有头或解析不成形时按固定间隔退避。
|
|
71
|
+
let retryAfterMs: number | null = null;
|
|
70
72
|
const delays = options.diagnostic ? [] : [1000, 4000, 10000];
|
|
71
73
|
const fail = (error: unknown): GenerationError => new GenerationError((error instanceof Error ? error.message : String(error)) + reqIdSuffix(attempts.filter(attempt => attempt.purpose !== 'diagnostic').at(-1)?.requestId), attempts,
|
|
72
74
|
partial, origin, error instanceof LLMError ? error.status : 0, error instanceof LLMError ? error.body : '', { cause: error });
|
|
73
75
|
for (let ordinal = 0; ordinal <= delays.length; ordinal++) {
|
|
74
76
|
try {
|
|
75
77
|
if (options.signal?.aborted) throw abortError(options.signal);
|
|
76
|
-
if (ordinal) await retryDelay(delays[ordinal - 1], options.signal);
|
|
78
|
+
if (ordinal) await retryDelay(retryAfterMs ?? delays[ordinal - 1], options.signal);
|
|
77
79
|
} catch (error) { throw fail(error); }
|
|
80
|
+
retryAfterMs = null;
|
|
78
81
|
const attempt: ProviderAttempt = {
|
|
79
82
|
id: crypto.randomUUID(), generationId, ordinal, origin: structuredClone(origin), startedAt: new Date().toISOString(), elapsedMs: 0,
|
|
80
83
|
requestId: null, responseId: null, outcome: 'failed', status: null, serviceTier: null, requestedServiceTier: typeof transport.body.service_tier === 'string' ? transport.body.service_tier : request.service_tier ?? null, purpose: options.diagnostic ? 'diagnostic' : 'generation', meters: unknownMeters(), charges: [],
|
|
@@ -123,6 +126,7 @@ export async function generate(request: Request, options: GenerateOptions, origi
|
|
|
123
126
|
attempt.requestId = response.headers.get('x-request-id');
|
|
124
127
|
if (!response.ok) {
|
|
125
128
|
const error = new LLMError(`LLM API ${response.status}`, response.status, await response.text());
|
|
129
|
+
retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
|
|
126
130
|
if (!options.diagnostic && (response.status === 401 || response.status === 403) && !authRetried && await transport.refresh()) {
|
|
127
131
|
authRetried = true;
|
|
128
132
|
lastError = error;
|
|
@@ -176,7 +180,7 @@ export async function generate(request: Request, options: GenerateOptions, origi
|
|
|
176
180
|
lastError = error;
|
|
177
181
|
if (!sent || committed || runaway || (!streaming && error instanceof ResponseProtocolError)) break;
|
|
178
182
|
const status = error instanceof LLMError ? error.status : 0;
|
|
179
|
-
if (status !== 0 && status !== 429 && status < 500) break;
|
|
183
|
+
if (status !== 0 && status !== 408 && status !== 429 && status < 500) break;
|
|
180
184
|
if (!options.diagnostic && observed && Date.now() - started >= 20000 && !await transport.failure({
|
|
181
185
|
model: request.model ?? '', role: options.role, elapsedMs: Date.now() - started, status,
|
|
182
186
|
requestId: attempt.requestId, body: error instanceof LLMError ? error.body : '', message: String(error),
|
package/src/web/README.md
CHANGED
|
@@ -86,4 +86,7 @@ upgrade 断开;登录态是 HttpOnly Cookie 里的无状态签名令牌(见 [con
|
|
|
86
86
|
| `bots/*/console/client.ts` | `persona:<name>` |
|
|
87
87
|
|
|
88
88
|
esbuild 输出分包 ESM 和带 hash 的文件名,写入 `asset-manifest.json`;Tailwind 输出 `styles.css`。
|
|
89
|
+
清单的 `sources` 记下 esbuild 读到的每个仓库内源文件的 sha256,`bin/web-assets.mjs` 据此判断产物是否落后于源码;依赖包与 Tailwind 的输入不在其中。
|
|
89
90
|
浏览器代码使用 `tsconfig.web.json` 检查:`pnpm typecheck:web`。
|
|
91
|
+
|
|
92
|
+
供应商卡片区分当前连接、可连接、配置问题与草稿。其他部署的运行实例选用同一连接时显示实例名并隐藏连接操作;未运行部署的选用记录单独标注。页面每五秒刷新列表,保留详情草稿。
|
|
@@ -121,20 +121,20 @@ export class ConsolePageHost {
|
|
|
121
121
|
this.emitNav();
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** Mount
|
|
125
|
-
async mountConnection(pageId: string, root: HTMLElement, scope: Readonly<Record<string, string>>,
|
|
124
|
+
/** Mount one of a page's panels inside the connection editor; `adapt` stages the panel's configuration edits. */
|
|
125
|
+
async mountConnection(pageId: string, panelId: string, root: HTMLElement, scope: Readonly<Record<string, string>>,
|
|
126
126
|
adapt: (context: ConsolePanelContext) => ConsolePanelContext): Promise<Disposable> {
|
|
127
127
|
const lifecycle = new Lifecycle(this.deps.onError);
|
|
128
128
|
const page = this.find(pageId);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const result = await impl.mount(adapt(this.panelContext(pageId, panel.id,
|
|
129
|
+
const panel = asArray(page?.panels).find(candidate => candidate.id === panelId);
|
|
130
|
+
try {
|
|
131
|
+
if (!panel) throw new Error(S.noSuchPanel(page?.label ?? pageId, panelId));
|
|
132
|
+
const impl = panel.builtin ? this.builtinPanel(panel.builtin) : await this.deps.loader.resolvePanel(pageId, panel.id, page?.client);
|
|
133
|
+
if (!lifecycle.disposed) {
|
|
134
|
+
const result = await impl.mount(adapt(this.panelContext(pageId, panel.id, root, lifecycle, this.generation, scope)));
|
|
135
135
|
if (result) lifecycle.own(result);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) { root.textContent = String(error); }
|
|
138
138
|
return lifecycle;
|
|
139
139
|
}
|
|
140
140
|
|
|
@@ -6,33 +6,60 @@ import { validateProviderName } from '../../../../providers/name.ts';
|
|
|
6
6
|
import { connectionPath, type Detail, type Editing, type Module } from './types.ts';
|
|
7
7
|
import { LANGUAGE } from '../../core/language.ts';
|
|
8
8
|
import { pricingEditor } from '../../console-pages/builtins/llm-settings/pricing-panel.ts';
|
|
9
|
+
import type { Disposable } from '../../../shared/client-panel.ts';
|
|
9
10
|
import { S } from './strings.ts';
|
|
10
11
|
|
|
11
|
-
export interface DetailController { dispose(): void;
|
|
12
|
+
export interface DetailController { dispose(): void; }
|
|
12
13
|
interface Options {
|
|
13
14
|
ctx: FeatureContext; root: HTMLElement; modules: Module[]; saved: Detail | null; draft: Editing | null;
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
/** Every edit; `dirty` is false when the form again equals the saved connection. */
|
|
16
|
+
changed(editing: Editing, invalid: boolean, dirty: boolean): void;
|
|
17
|
+
onSaved(name: string, select?: boolean): Promise<void>; cancelled(): Promise<void>;
|
|
18
|
+
deleted(): Promise<void>; duplicate(editing: Editing): Promise<void>;
|
|
16
19
|
}
|
|
20
|
+
type Section = Module['sections'][number];
|
|
21
|
+
type Spec = NonNullable<Detail['entry']['spec']>;
|
|
22
|
+
/** What a save would write; `raw` only carries field text and never reaches the server. */
|
|
23
|
+
const snapshot = (editing: Editing) => JSON.stringify({ name: editing.name, entry: editing.entry, secretValue: editing.secretValue });
|
|
24
|
+
/** Protocol knobs the editor keeps under the protocol block; the rest of a module's scalars belong to its own sections. */
|
|
25
|
+
const PROTOCOL_FIELDS = ['endpointPath', 'extraHeaders', 'extraBody'];
|
|
26
|
+
/**
|
|
27
|
+
* One connection's editor. Sections come from `module.sections` in declared order: a `builtin`
|
|
28
|
+
* names a block drawn here, the rest are the module's own panels, mounted with their edits staged
|
|
29
|
+
* into `editing`.
|
|
30
|
+
*/
|
|
17
31
|
export async function mountDetail(options: Options): Promise<DetailController> {
|
|
18
32
|
const { ctx, root, saved, modules } = options;
|
|
19
33
|
const { ui } = ctx;
|
|
20
34
|
const lifecycle = new Lifecycle(ctx.onError);
|
|
21
35
|
const opts = { signal: lifecycle.signal };
|
|
22
|
-
const
|
|
36
|
+
const persisted: Editing | null = saved ? { original: saved.name, name: saved.name, entry: saved.entry, revision: saved.revision, secretValue: '', raw: {} } : null;
|
|
37
|
+
const editing: Editing = structuredClone(options.draft ?? persisted!);
|
|
23
38
|
editing.entry.spec ??= { model: '', thinking: false };
|
|
24
|
-
|
|
25
|
-
const dirty = () =>
|
|
39
|
+
if (persisted) (persisted.entry.spec ??= { model: '', thinking: false });
|
|
40
|
+
const dirty = () => !persisted || snapshot(editing) !== snapshot(persisted);
|
|
26
41
|
const report = ui.msgline();
|
|
27
42
|
const form = ui.h('div');
|
|
28
43
|
root.append(form, report);
|
|
29
44
|
const errors = new Map<string, () => boolean>();
|
|
30
45
|
let rendering = 0;
|
|
31
46
|
let saving = false;
|
|
32
|
-
|
|
47
|
+
const panelHandles: Disposable[] = [];
|
|
33
48
|
let panelHost: ReturnType<NonNullable<FeatureContext['consolePageHost']>> | null = null;
|
|
34
|
-
|
|
49
|
+
/** The model block's way of showing a spec a module panel changed. */
|
|
50
|
+
let syncSpec: (() => void) | null = null;
|
|
51
|
+
/** A saved connection shows the discard button only while the form differs from what is saved. */
|
|
52
|
+
let cancel: HTMLButtonElement | null = null;
|
|
53
|
+
const change = () => {
|
|
54
|
+
if (cancel) cancel.hidden = !!saved && !dirty();
|
|
55
|
+
options.changed(editing, !!form.querySelector('[aria-invalid="true"]'), dirty());
|
|
56
|
+
};
|
|
35
57
|
const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!lifecycle.disposed) report.textContent = String(error); }); };
|
|
58
|
+
/** Preview name for an unsaved connection; the server resolves secrets by it. */
|
|
59
|
+
const identity = saved?.name ?? 'draft';
|
|
60
|
+
/** The probe and the model list run on what the form holds, key included, before anything is saved. */
|
|
61
|
+
const draftBody = () => ({ entry: editing.entry, ...(editing.secretValue ? { secretValue: editing.secretValue } : {}) });
|
|
62
|
+
const disposePanels = () => { for (const handle of panelHandles.splice(0)) handle.dispose(); };
|
|
36
63
|
function field(body: HTMLElement, key: string, label: string, value: string, set: (value: string) => void, validate?: (value: string) => string | null, type = 'text') {
|
|
37
64
|
const input = ui.input({ value, type: type as 'text' }); input.setAttribute('aria-label', label);
|
|
38
65
|
const note = ui.h('div', 'field-error');
|
|
@@ -57,206 +84,226 @@ export async function mountDetail(options: Options): Promise<DetailController> {
|
|
|
57
84
|
if (editing.raw[key] !== undefined) check();
|
|
58
85
|
input.addEventListener('input', () => { editing.raw[key] = input.value; check(); change(); }, opts);
|
|
59
86
|
}
|
|
60
|
-
function
|
|
61
|
-
const card =
|
|
62
|
-
|
|
87
|
+
function block(box: HTMLElement, section: Section, fold = false) {
|
|
88
|
+
const card = fold ? ui.foldSheet('connection-' + section.id, { title: section.title, desc: section.description }) : ui.sheet({ title: section.title, desc: section.description });
|
|
89
|
+
box.append(card.el); return card.body;
|
|
63
90
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
onChange: kind => {
|
|
80
|
-
const module = modules.find(module => module.id === kind);
|
|
81
|
-
editing.entry = { kind, baseUrl: module?.defaultBaseUrl ?? '', spec: { model: '', thinking: module?.reasoningTiers[0]?.thinking ?? true, ...(module?.reasoningTiers[0]?.effort ? { reasoningEffort: module.reasoningTiers[0].effort } : {}) } };
|
|
82
|
-
editing.raw = {}; change(); run(render);
|
|
83
|
-
} });
|
|
84
|
-
select.setAttribute('aria-label', S.module); basic.append(ui.field(S.module + ' *', select));
|
|
85
|
-
const required = ui.h('div', 'field-error'); basic.append(required);
|
|
86
|
-
errors.set('module', () => { required.textContent = editing.entry.kind ? '' : S.required; select.setAttribute('aria-invalid', String(!editing.entry.kind)); return !!editing.entry.kind; });
|
|
87
|
-
}
|
|
88
|
-
if (selectedModule) basic.append(ui.h('p', 'field-note', S.moduleNote(selectedModule.description, selectedModule.id)));
|
|
89
|
-
const connection = section(S.connection);
|
|
90
|
-
field(connection, 'baseUrl', S.url, editing.entry.baseUrl, value => { editing.entry.baseUrl = value; }, value => {
|
|
91
|
+
/** Reads a dotted path under the entry; writes create the objects on the way. */
|
|
92
|
+
const entryPath = (suffix: string) => {
|
|
93
|
+
const parts = suffix.split('.');
|
|
94
|
+
return {
|
|
95
|
+
get: () => parts.reduce<unknown>((value, part) => (value as Record<string, unknown> | undefined)?.[part], editing.entry),
|
|
96
|
+
set: (value: unknown) => {
|
|
97
|
+
let target = editing.entry as unknown as Record<string, unknown>;
|
|
98
|
+
for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
99
|
+
if (value === undefined) delete target[parts.at(-1)!]; else target[parts.at(-1)!] = value;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
function endpointBlock(box: HTMLElement, section: Section) {
|
|
104
|
+
const body = block(box, section);
|
|
105
|
+
field(body, 'baseUrl', S.url, editing.entry.baseUrl, value => { editing.entry.baseUrl = value; }, value => {
|
|
91
106
|
try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? null : S.required; } catch { return S.required; }
|
|
92
107
|
});
|
|
93
|
-
const key = field(
|
|
94
|
-
key.placeholder = saved?.secretConfigured !== 'none' && saved ?
|
|
108
|
+
const key = field(body, 'key', S.key, editing.secretValue, value => { editing.secretValue = value; }, undefined, 'password');
|
|
109
|
+
key.placeholder = saved?.secretConfigured !== 'none' && saved ? '••••••••' : S.keyEmpty;
|
|
95
110
|
const test = ui.button(S.test, { onClick: () => run(async () => {
|
|
96
|
-
if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
|
|
97
111
|
test.disabled = true;
|
|
98
112
|
try {
|
|
99
|
-
const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(
|
|
113
|
+
const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(identity) + '/test', draftBody(), opts);
|
|
100
114
|
report.textContent = result.ok ? `${S.testOk} · HTTP ${result.status ?? '—'} · ${(result.elapsedMs / 1000).toFixed(1)}s · ${result.model ?? ''}` : `${S.testFailed}: ${result.hint ?? result.error ?? ''}`;
|
|
101
115
|
} finally { test.disabled = false; }
|
|
102
|
-
}) });
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
}) });
|
|
117
|
+
const testRow = ui.rowbar(); testRow.classList.add('connection-test'); testRow.append(test);
|
|
118
|
+
if (saved?.readiness.reason) testRow.append(ui.msgline(saved.readiness.reason, true));
|
|
119
|
+
body.append(testRow);
|
|
120
|
+
}
|
|
121
|
+
function modelBlock(box: HTMLElement, section: Section, module: Module, spec: Spec) {
|
|
122
|
+
const body = block(box, section);
|
|
123
|
+
const modelInput = field(body, 'model', S.model, spec.model, value => { spec.model = value; }, value => value.trim() ? null : S.required);
|
|
124
|
+
const catalog = ui.h('datalist'); catalog.id = 'connection-models-' + Math.random().toString(36).slice(2); modelInput.setAttribute('list', catalog.id); body.append(catalog);
|
|
108
125
|
let listedModels: Array<{ id: string; contextWindow?: number }> = [];
|
|
109
126
|
let contextInput: HTMLInputElement | null = null;
|
|
127
|
+
const windowNote = ui.msgline();
|
|
128
|
+
const noteCatalogWindow = () => {
|
|
129
|
+
if (!listedModels.length) { windowNote.textContent = ''; return; }
|
|
130
|
+
windowNote.textContent = listedModels.find(item => item.id === spec.model)?.contextWindow ? '' : S.catalogNoWindow;
|
|
131
|
+
};
|
|
110
132
|
modelInput.addEventListener('change', () => {
|
|
111
133
|
const known = listedModels.find(item => item.id === modelInput.value)?.contextWindow;
|
|
112
134
|
if (known && contextInput) { spec.contextWindow = known; contextInput.value = String(known); delete editing.raw.contextWindow; change(); }
|
|
135
|
+
noteCatalogWindow();
|
|
113
136
|
}, opts);
|
|
114
137
|
const fetch = ui.button(S.fetchModels, { onClick: () => run(async () => {
|
|
115
|
-
if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
|
|
116
138
|
fetch.disabled = true;
|
|
117
|
-
try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(
|
|
139
|
+
try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(identity) + '/models', draftBody(), opts);
|
|
118
140
|
catalog.replaceChildren(...result.models.map(item => { const option = ui.h('option'); option.value = item.id; return option; }));
|
|
119
141
|
listedModels = result.models;
|
|
142
|
+
noteCatalogWindow();
|
|
120
143
|
} finally { fetch.disabled = false; }
|
|
121
|
-
}) });
|
|
122
|
-
const tiers =
|
|
144
|
+
}) }); body.append(fetch);
|
|
145
|
+
const tiers = module.reasoningTiers;
|
|
123
146
|
if (tiers.length) {
|
|
124
147
|
const select = ui.select({ value: tiers.find(tier => tier.thinking === spec.thinking && tier.effort === spec.reasoningEffort)?.id ?? '', options: tiers.map(tier => ({ value: tier.id, label: tier.label })), onChange: value => {
|
|
125
148
|
const tier = tiers.find(tier => tier.id === value)!; spec.thinking = tier.thinking;
|
|
126
149
|
if (tier.effort) spec.reasoningEffort = tier.effort; else delete spec.reasoningEffort; change();
|
|
127
|
-
} }); select.setAttribute('aria-label', S.reasoning);
|
|
128
|
-
} else field(
|
|
150
|
+
} }); select.setAttribute('aria-label', S.reasoning); body.append(ui.field(S.reasoning, select));
|
|
151
|
+
} else field(body, 'reasoning', S.reasoning, !spec.thinking ? 'none' : spec.reasoningEffort ?? '', value => {
|
|
129
152
|
spec.thinking = value !== 'none'; if (value && value !== 'none') spec.reasoningEffort = value; else delete spec.reasoningEffort;
|
|
130
153
|
});
|
|
131
154
|
for (const [name, label] of [['temperature', S.temperature], ['maxTokens', S.maxTokens], ['contextWindow', S.context]] as const) {
|
|
132
|
-
const input = field(
|
|
155
|
+
const input = field(body, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
|
|
133
156
|
editing.raw[name] = value; if (!value) delete spec[name]; else spec[name] = Number(value);
|
|
134
157
|
}, value => !value || Number.isFinite(Number(value)) && (name === 'temperature' ? Number(value) >= 0 && Number(value) <= 2 : Number.isInteger(Number(value)) && Number(value) > 0) ? null : S.invalidNumber, 'number');
|
|
135
158
|
if (name === 'contextWindow') contextInput = input;
|
|
136
159
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
select.
|
|
160
|
+
body.append(windowNote);
|
|
161
|
+
if (module.serviceTiers.length) {
|
|
162
|
+
const select = ui.select({ value: editing.entry.serviceTier ?? '', options: [{ value: '', label: '—' }, ...module.serviceTiers.map(tier => ({ value: tier.id, label: tier.label }))], onChange: value => { editing.entry.serviceTier = value; change(); } });
|
|
163
|
+
select.setAttribute('aria-label', S.tier); body.append(ui.field(S.tier, select));
|
|
140
164
|
}
|
|
141
165
|
const images = ui.h('input'); images.type = 'checkbox'; images.checked = editing.entry.multimodal === true; images.setAttribute('aria-label', S.images);
|
|
142
|
-
images.addEventListener('change', () => { editing.entry.multimodal = images.checked; change(); }, opts);
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
images.addEventListener('change', () => { editing.entry.multimodal = images.checked; change(); }, opts); body.append(ui.field(S.images, images));
|
|
167
|
+
syncSpec = () => { modelInput.value = spec.model; errors.get('model')?.(); if (contextInput && spec.contextWindow !== undefined) contextInput.value = String(spec.contextWindow); };
|
|
168
|
+
}
|
|
169
|
+
function pricingBlock(box: HTMLElement, section: Section) {
|
|
170
|
+
const body = block(box, section, true);
|
|
145
171
|
const prices = pricingEditor(ui, editing.entry.pricing ?? [], saved?.quotes ?? [], value => {
|
|
146
172
|
editing.entry.pricing = value as Detail['entry']['pricing']; change();
|
|
147
173
|
}, LANGUAGE, { raw: editing.raw.pricing, onRaw: value => { editing.raw.pricing = value; change(); } });
|
|
148
|
-
|
|
174
|
+
body.append(prices.body);
|
|
149
175
|
errors.set('pricing', prices.validate);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
176
|
+
}
|
|
177
|
+
/** Module scalars the editor renders itself: declared fields of a module that ships no sections of its own. */
|
|
178
|
+
function groupBlock(box: HTMLElement, group: ConfigGroup) {
|
|
179
|
+
const scalars = Object.entries(group.schema.properties ?? {}).filter(([path, property]) => property.type !== 'object' && !PROTOCOL_FIELDS.some(name => path.endsWith('.' + name)));
|
|
180
|
+
if (!scalars.length) return;
|
|
181
|
+
const body = block(box, { id: group.id, title: group.schema.title ?? group.id, description: group.schema.description });
|
|
182
|
+
for (const [path, property] of scalars) {
|
|
183
|
+
const at = entryPath(path.slice(`providers.${identity}.`.length));
|
|
184
|
+
const control = configField(ui, property, at.get(), () => { if (control.read) { at.set(control.read()); change(); } }, lifecycle.signal);
|
|
185
|
+
control.node.setAttribute('aria-label', property.title ?? path);
|
|
186
|
+
body.append(ui.field(property.title ?? path, control.node));
|
|
187
|
+
if (property.description) body.append(ui.h('p', 'tdesc', property.description));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function protocolBlock(box: HTMLElement, section: Section, groups: ConfigGroup[]) {
|
|
191
|
+
const body = block(box, section, true);
|
|
192
|
+
field(body, 'secret', S.secret, editing.entry.secret ?? '', value => { if (value) editing.entry.secret = value; else delete editing.entry.secret; });
|
|
193
|
+
for (const group of groups) for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
|
|
194
|
+
const suffix = path.slice(`providers.${identity}.`.length);
|
|
195
|
+
const at = entryPath(suffix);
|
|
196
|
+
if (property.type === 'object') { jsonField(body, path, property.title ?? suffix.split('.').at(-1)!, at.get(), false, at.set); continue; }
|
|
197
|
+
if (!PROTOCOL_FIELDS.some(name => suffix.endsWith(name))) continue;
|
|
198
|
+
const control = configField(ui, property, at.get(), () => { if (control.read) { at.set(control.read()); change(); } }, lifecycle.signal);
|
|
199
|
+
control.node.setAttribute('aria-label', property.title ?? suffix);
|
|
200
|
+
body.append(ui.field(property.title ?? suffix, control.node));
|
|
201
|
+
if (property.description) body.append(ui.h('p', 'tdesc', property.description));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function render() {
|
|
205
|
+
const gen = ++rendering;
|
|
206
|
+
disposePanels(); syncSpec = null;
|
|
207
|
+
errors.clear(); form.replaceChildren();
|
|
208
|
+
const identityCard = ui.sheet({ title: S.basic }); identityCard.el.classList.add('connection-identity'); form.append(identityCard.el);
|
|
209
|
+
const basic = identityCard.body;
|
|
210
|
+
field(basic, 'name', S.name, editing.name, value => { editing.name = value; }, value => value === saved?.name ? null : validateProviderName(value) ? S.nameHint : null)
|
|
211
|
+
.placeholder = S.newName;
|
|
212
|
+
basic.append(ui.msgline(S.nameHint));
|
|
213
|
+
const selectedModule = modules.find(module => module.id === editing.entry.kind);
|
|
214
|
+
if (saved) {
|
|
215
|
+
const module = ui.select({ value: editing.entry.kind, options: [{ value: editing.entry.kind, label: selectedModule?.title ?? editing.entry.kind }], disabled: true });
|
|
216
|
+
module.setAttribute('aria-label', S.module); module.classList.add('connection-module-readonly');
|
|
217
|
+
basic.append(ui.field(S.module, module), ui.msgline(S.fixedModule));
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const select = ui.select({ value: editing.entry.kind, options: [{ value: '', label: '—' }, ...modules.map(module => ({ value: module.id, label: module.title }))],
|
|
221
|
+
onChange: kind => {
|
|
222
|
+
const module = modules.find(module => module.id === kind);
|
|
223
|
+
editing.entry = { kind, baseUrl: module?.defaultBaseUrl ?? '', spec: { model: '', thinking: module?.reasoningTiers[0]?.thinking ?? true, ...(module?.reasoningTiers[0]?.effort ? { reasoningEffort: module.reasoningTiers[0].effort } : {}) } };
|
|
224
|
+
editing.raw = {}; change(); run(render);
|
|
225
|
+
} });
|
|
226
|
+
select.setAttribute('aria-label', S.module); basic.append(ui.field(S.module + ' *', select));
|
|
227
|
+
const required = ui.h('div', 'field-error'); basic.append(required);
|
|
228
|
+
errors.set('module', () => { required.textContent = editing.entry.kind ? '' : S.required; select.setAttribute('aria-invalid', String(!editing.entry.kind)); return !!editing.entry.kind; });
|
|
229
|
+
}
|
|
230
|
+
const flow = ui.h('div', 'connection-flow'); form.append(flow);
|
|
231
|
+
const spec = editing.entry.spec ??= { model: '', thinking: false };
|
|
153
232
|
const actions = ui.h('div', 'connection-actions');
|
|
154
233
|
if (saved) {
|
|
155
234
|
actions.append(ui.button(S.remove, { variant: 'danger', onClick: () => run(async () => {
|
|
156
235
|
const current = await get<Detail>(connectionPath(saved.name), opts);
|
|
157
236
|
if (current.references.length) { await ui.confirm({ title: S.remove, body: S.referenced + current.references.join(', ') }); return; }
|
|
158
237
|
if (!(await ui.confirm({ title: S.remove, body: S.deleteConfirm, danger: true }))) return;
|
|
159
|
-
await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts);
|
|
160
|
-
}) }), ui.button(S.duplicate, { onClick: () => run(
|
|
161
|
-
if (!(await leave())) return;
|
|
162
|
-
await options.duplicate({ original: null, copyFrom: { name: saved.name, revision: saved.revision }, name: (validateProviderName(saved.name) ? 'Connection' : saved.name) + '-Copy', entry: structuredClone(saved.entry), secretValue: '', raw: {} });
|
|
163
|
-
}) }));
|
|
238
|
+
await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts); await options.deleted();
|
|
239
|
+
}) }), ui.button(S.duplicate, { onClick: () => run(() => options.duplicate({ original: null, copyFrom: { name: saved.name, revision: saved.revision }, name: (validateProviderName(saved.name) ? 'Connection' : saved.name) + '-Copy', entry: structuredClone(saved.entry), secretValue: '', raw: {} })) }));
|
|
164
240
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
241
|
+
cancel = ui.button(S.cancel, { onClick: () => run(() => options.cancelled()) });
|
|
242
|
+
cancel.hidden = !!saved && !dirty();
|
|
243
|
+
actions.append(ui.h('span', 'grow'), cancel, ui.button(S.save, { variant: 'primary', onClick: () => run(() => save()) }));
|
|
244
|
+
form.append(actions, ui.h('div', 'connection-shared', S.shared), ui.h('div', 'connection-shared', S.draftNote));
|
|
245
|
+
if (!selectedModule) { flow.append(ui.msgline(saved ? S.readiness['module-missing'] : S.chooseModule)); return; }
|
|
246
|
+
const groups = (await post<ConfigGroup[]>('/api/provider-modules/config', { name: identity, entry: editing.entry }, opts)).filter(group => !group.id.endsWith('.connection'));
|
|
170
247
|
if (gen !== rendering || lifecycle.disposed) return;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
185
|
-
target[parts.at(-1)!] = value;
|
|
186
|
-
};
|
|
187
|
-
const body = suffix.includes('endpointPath') || suffix.includes('extraHeaders') || suffix.includes('extraBody') ? advanced : moduleBody;
|
|
188
|
-
if (body === moduleBody && hasPanels) continue;
|
|
189
|
-
const control = configField(ui, property, getValue(), () => { if (control.read) { setValue(control.read()); change(); } }, lifecycle.signal);
|
|
190
|
-
control.node.setAttribute('aria-label', property.title ?? suffix);
|
|
191
|
-
body.append(ui.field(property.title ?? suffix, control.node));
|
|
192
|
-
if (property.description) body.append(ui.h('p', 'tdesc', property.description));
|
|
248
|
+
const ownSections = selectedModule.sections.some(section => !section.builtin);
|
|
249
|
+
const pending: Array<{ section: Section; box: HTMLElement }> = [];
|
|
250
|
+
for (const section of selectedModule.sections) {
|
|
251
|
+
const box = ui.h('div', 'connection-step'); flow.append(box);
|
|
252
|
+
switch (section.builtin) {
|
|
253
|
+
case 'connection-endpoint': endpointBlock(box, section); break;
|
|
254
|
+
case 'connection-model':
|
|
255
|
+
modelBlock(box, section, selectedModule, spec);
|
|
256
|
+
if (!ownSections) for (const group of groups) groupBlock(box, group);
|
|
257
|
+
break;
|
|
258
|
+
case 'connection-pricing': pricingBlock(box, section); break;
|
|
259
|
+
case 'connection-protocol': protocolBlock(box, section, groups); break;
|
|
260
|
+
default: pending.push({ section, box });
|
|
193
261
|
}
|
|
194
262
|
}
|
|
195
|
-
if (ctx.consolePageHost)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
263
|
+
if (!pending.length || !ctx.consolePageHost) return;
|
|
264
|
+
panelHost ??= ctx.consolePageHost({ root: ui.h('div'), route: () => ['providers', saved?.name ?? ''] });
|
|
265
|
+
await panelHost.load();
|
|
266
|
+
if (gen !== rendering || lifecycle.disposed) return;
|
|
267
|
+
for (const { section, box } of pending) {
|
|
268
|
+
const handle = await panelHost.mountConnection(`llm:${editing.entry.kind}`, section.id, box, { instance: identity }, context => ({
|
|
201
269
|
...context,
|
|
202
270
|
setConfig: async (_id, values) => {
|
|
203
271
|
for (const [path, value] of Object.entries(values)) {
|
|
204
272
|
const prefix = `providers.${identity}.`;
|
|
205
273
|
if (!path.startsWith(prefix)) throw new Error('Foreign connection field');
|
|
206
|
-
|
|
207
|
-
let target = editing.entry as unknown as Record<string, unknown>;
|
|
208
|
-
for (const part of parts.slice(0, -1)) target = (target[part] ??= {}) as Record<string, unknown>;
|
|
209
|
-
target[parts.at(-1)!] = value;
|
|
274
|
+
entryPath(path.slice(prefix.length)).set(value);
|
|
210
275
|
}
|
|
211
276
|
change(); return S.unsaved;
|
|
212
277
|
},
|
|
213
278
|
invoke: async <T>(method: string, args?: unknown[]): Promise<T> => {
|
|
214
279
|
const before = JSON.stringify(editing.entry);
|
|
215
280
|
const reply = await post<{ result: T; entry: Detail['entry'] }>('/api/provider-modules/preview', { name: identity, entry: editing.entry, panel: context.panelId, method, args }, opts);
|
|
216
|
-
if (before === JSON.stringify(editing.entry) && JSON.stringify(reply.entry) !== before) {
|
|
281
|
+
if (before === JSON.stringify(editing.entry) && JSON.stringify(reply.entry) !== before) {
|
|
282
|
+
if (reply.entry.spec) Object.assign(spec, reply.entry.spec);
|
|
283
|
+
editing.entry = { ...reply.entry, spec }; syncSpec?.(); change();
|
|
284
|
+
}
|
|
217
285
|
return reply.result;
|
|
218
286
|
},
|
|
219
287
|
refresh: async () => {},
|
|
220
288
|
}));
|
|
221
|
-
if (gen !== rendering || lifecycle.disposed)
|
|
222
|
-
|
|
223
|
-
// Object-valued protocol fields remain JSON editors; their presence is declared by the module schema.
|
|
224
|
-
for (const group of groups) for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
|
|
225
|
-
if (property.type !== 'object') continue;
|
|
226
|
-
const parts = path.slice(`providers.${identity}.`.length).split('.');
|
|
227
|
-
const value = parts.reduce<unknown>((object, key) => (object as Record<string, unknown> | undefined)?.[key], editing.entry);
|
|
228
|
-
jsonField(advanced, path, property.title ?? parts.at(-1)!, value, false, value => {
|
|
229
|
-
let object = editing.entry as unknown as Record<string, unknown>;
|
|
230
|
-
for (const key of parts.slice(0, -1)) object = (object[key] ??= {}) as Record<string, unknown>;
|
|
231
|
-
if (value === undefined) delete object[parts.at(-1)!]; else object[parts.at(-1)!] = value;
|
|
232
|
-
});
|
|
289
|
+
if (gen !== rendering || lifecycle.disposed) { handle.dispose(); return; }
|
|
290
|
+
panelHandles.push(handle);
|
|
233
291
|
}
|
|
234
292
|
}
|
|
235
293
|
async function save(select = true): Promise<boolean> {
|
|
236
294
|
if (saving) return false;
|
|
237
295
|
const valid = [...errors.values()].map(check => check()).every(Boolean); change();
|
|
238
|
-
if (!valid) {
|
|
296
|
+
if (!valid) {
|
|
297
|
+
for (const input of form.querySelectorAll('[aria-invalid="true"]')) { const fold = input.closest('details'); if (fold) fold.open = true; }
|
|
298
|
+
report.textContent = S.readiness.invalid; return false;
|
|
299
|
+
}
|
|
239
300
|
saving = true;
|
|
240
301
|
try {
|
|
241
302
|
const result = await post<Detail>(saved ? connectionPath(saved.name) + '/save' : '/api/providers', { name: editing.name, entry: editing.entry, expectedRevision: editing.revision, copyFrom: editing.copyFrom, ...(editing.secretValue ? { secretValue: editing.secretValue } : {}) }, opts);
|
|
242
|
-
|
|
303
|
+
await options.onSaved(result.name, select); return true;
|
|
243
304
|
} catch (error) { report.textContent = String(error); return false; }
|
|
244
305
|
finally { saving = false; }
|
|
245
306
|
}
|
|
246
|
-
async function leave(): Promise<boolean> {
|
|
247
|
-
if (!dirty()) return true;
|
|
248
|
-
return new Promise(resolve => {
|
|
249
|
-
const dialog = ui.h('dialog', 'connection-guard');
|
|
250
|
-
const buttons = ui.rowbar();
|
|
251
|
-
const finish = (answer: boolean) => { dialog.remove(); resolve(answer); };
|
|
252
|
-
dialog.append(ui.h('h3', '', S.unsaved), buttons);
|
|
253
|
-
buttons.append(ui.button(S.stay, { onClick: () => finish(false) }), ui.button(S.discard, { onClick: () => { baseline = JSON.stringify(editing); options.discarded(); finish(true); } }), ui.button(S.save, { variant: 'primary', onClick: () => { void save(false).then(ok => finish(ok)); } }));
|
|
254
|
-
dialog.addEventListener('cancel', event => { event.preventDefault(); finish(false); }, opts);
|
|
255
|
-
lifecycle.signal.addEventListener('abort', () => finish(false), { once: true });
|
|
256
|
-
root.ownerDocument.body.append(dialog);
|
|
257
|
-
if (dialog.showModal) dialog.showModal(); else dialog.setAttribute('open', '');
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
307
|
await render(); change();
|
|
261
|
-
return { dispose: () => { lifecycle.dispose();
|
|
308
|
+
return { dispose: () => { lifecycle.dispose(); disposePanels(); panelHost?.unmount(); } };
|
|
262
309
|
}
|