cortico 0.1.1 → 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/README.md +3 -3
- package/package.json +1 -1
- package/src/boot.ts +29 -0
- package/src/bot.ts +40 -5
- package/src/core/README.md +12 -2
- package/src/core/blobs.ts +23 -2
- package/src/core/config-schema.ts +1 -1
- package/src/core/config.ts +2 -16
- package/src/core/core.ts +18 -6
- package/src/core/generation.ts +66 -9
- package/src/core/instance-lock.ts +6 -0
- package/src/core/loop.ts +62 -32
- package/src/core/secrets.ts +2 -4
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +23 -4
- package/src/core/util.ts +15 -0
- package/src/deploy.ts +5 -4
- package/src/extensions/README.md +6 -4
- package/src/extensions/dry-mount.ts +34 -6
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +30 -22
- package/src/protocol/open-responses/context-helpers.ts +3 -1
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +44 -12
- package/src/providers/base.ts +2 -0
- package/src/providers/console/config.ts +36 -0
- package/src/providers/console/hub.ts +307 -0
- package/src/providers/console/settings.ts +30 -26
- package/src/providers/console/strings.ts +14 -0
- package/src/providers/console/types.ts +5 -0
- package/src/providers/hub-api.ts +3 -0
- package/src/providers/llamacpp/config.ts +38 -0
- package/src/providers/llamacpp/console/models-panel.ts +87 -4
- package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
- package/src/providers/llamacpp/console/server.ts +47 -4
- package/src/providers/llamacpp/huggingface.ts +93 -0
- package/src/providers/llamacpp/index.ts +5 -1
- package/src/providers/llamacpp/native.ts +10 -5
- package/src/providers/llamacpp/options.ts +2 -0
- package/src/providers/llamacpp/strings.ts +42 -0
- package/src/providers/name.ts +13 -0
- package/src/providers/openai-responses-compat/config.ts +33 -0
- package/src/providers/openai-responses-compat/console/client.ts +5 -0
- package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
- package/src/providers/openai-responses-compat/console/server.ts +135 -0
- package/src/providers/openai-responses-compat/index.ts +40 -12
- package/src/providers/openai-responses-compat/native.ts +15 -5
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/pricebook.ts +5 -5
- package/src/providers/registry.ts +24 -10
- 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/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +15 -3
- package/src/web/auth.ts +80 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
- package/src/web/client/console-pages/host.ts +22 -5
- package/src/web/client/core/api.ts +5 -1
- package/src/web/client/core/router.ts +15 -0
- package/src/web/client/features/extensions/index.ts +276 -41
- package/src/web/client/features/extensions/strings.ts +84 -10
- package/src/web/client/features/feature.ts +1 -1
- package/src/web/client/features/live/diagnostics.ts +32 -0
- package/src/web/client/features/live/index.ts +27 -11
- package/src/web/client/features/live/protocol.ts +1 -0
- package/src/web/client/features/live/strings.ts +9 -3
- package/src/web/client/features/providers/detail.ts +309 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +179 -87
- package/src/web/client/features/providers/strings.ts +48 -17
- package/src/web/client/features/providers/types.ts +15 -0
- package/src/web/client/features/settings/general.ts +12 -0
- package/src/web/client/features/settings/strings.ts +6 -0
- package/src/web/client/main.ts +4 -0
- package/src/web/client/shell/index.ts +1 -1
- package/src/web/client/ui/fields.ts +3 -1
- package/src/web/client/ui/icons.ts +9 -1
- package/src/web/client/ui/prompt-input.tsx +17 -5
- package/src/web/client/ui/strings.ts +0 -2
- package/src/web/diagnostics.ts +133 -0
- package/src/web/public/login.html +67 -0
- package/src/web/public/styles.css +130 -18
- package/src/web/server.ts +229 -21
- package/src/web/shared/client-panel.ts +3 -0
- package/src/web/shared/console-protocol.ts +16 -1
- package/src/worlds/bilibili/README.md +1 -1
- package/src/worlds/bilibili/overlay/server.ts +4 -2
- package/src/worlds/minecraft/ADAPT.md +66 -0
- package/src/worlds/minecraft/README.md +8 -0
- package/src/worlds/minecraft/client-launch.ts +11 -2
- package/src/worlds/minecraft/client.ts +4 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +56 -10
- package/src/worlds/terminal/world.ts +3 -4
|
@@ -71,6 +71,7 @@ export interface StatusChip {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export interface StatusSnapshot {
|
|
74
|
+
modelConnection?: { name: string; model: string | null; module: string; moduleTitle: string; baseUrl: string; ready: boolean } | null;
|
|
74
75
|
displayName?: string;
|
|
75
76
|
loop?: LoopStatus | null;
|
|
76
77
|
/**
|
|
@@ -3,6 +3,8 @@ import { pick } from '../../core/language.ts';
|
|
|
3
3
|
const zh = {
|
|
4
4
|
// index.ts
|
|
5
5
|
navLabel: '终端',
|
|
6
|
+
currentProvider: '当前模型供应商',
|
|
7
|
+
noProvider: '未选择模型供应商',
|
|
6
8
|
ctxOpenAria: '查看 token 分类',
|
|
7
9
|
ctxPanelAria: 'Token 分类',
|
|
8
10
|
netConnecting: '连接中…',
|
|
@@ -10,8 +12,9 @@ const zh = {
|
|
|
10
12
|
netOffline: '接口不可达',
|
|
11
13
|
composerLabel: '终端消息输入',
|
|
12
14
|
composerPlaceholder: "输入消息…",
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
exportDiagnostics: '导出诊断',
|
|
16
|
+
exportFailed: '诊断包取不回来',
|
|
17
|
+
composerNoProvider: '当前模型供应商不可用,请前往「模型供应商」页设置',
|
|
15
18
|
composerQueued: '终端通道正在重连,消息已排队',
|
|
16
19
|
emptyConnecting: '连接调试通道中…',
|
|
17
20
|
ctxTitle: (total: string, max: string | null) =>
|
|
@@ -127,6 +130,8 @@ const zh = {
|
|
|
127
130
|
const en: typeof zh = {
|
|
128
131
|
// index.ts
|
|
129
132
|
navLabel: 'Terminal',
|
|
133
|
+
currentProvider: 'Current model provider',
|
|
134
|
+
noProvider: 'No provider selected',
|
|
130
135
|
ctxOpenAria: 'View token breakdown',
|
|
131
136
|
ctxPanelAria: 'Token breakdown',
|
|
132
137
|
netConnecting: 'Connecting…',
|
|
@@ -134,7 +139,8 @@ const en: typeof zh = {
|
|
|
134
139
|
netOffline: 'API unreachable',
|
|
135
140
|
composerLabel: 'Terminal message input',
|
|
136
141
|
composerPlaceholder: "Enter a message…",
|
|
137
|
-
|
|
142
|
+
exportDiagnostics: 'Export diagnostics',
|
|
143
|
+
exportFailed: 'The diagnostics bundle could not be fetched',
|
|
138
144
|
composerNoProvider: 'No usable provider. Set one up on the LLM Provider page.',
|
|
139
145
|
composerQueued: 'Terminal channel is reconnecting; message queued',
|
|
140
146
|
emptyConnecting: 'Connecting to the debug channel…',
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { FeatureContext } from '../feature.ts';
|
|
2
|
+
import { get, post } from '../../core/api.ts';
|
|
3
|
+
import { Lifecycle } from '../../core/lifecycle.ts';
|
|
4
|
+
import { configField, type ConfigGroup } from '../config/view.ts';
|
|
5
|
+
import { validateProviderName } from '../../../../providers/name.ts';
|
|
6
|
+
import { connectionPath, type Detail, type Editing, type Module } from './types.ts';
|
|
7
|
+
import { LANGUAGE } from '../../core/language.ts';
|
|
8
|
+
import { pricingEditor } from '../../console-pages/builtins/llm-settings/pricing-panel.ts';
|
|
9
|
+
import type { Disposable } from '../../../shared/client-panel.ts';
|
|
10
|
+
import { S } from './strings.ts';
|
|
11
|
+
|
|
12
|
+
export interface DetailController { dispose(): void; }
|
|
13
|
+
interface Options {
|
|
14
|
+
ctx: FeatureContext; root: HTMLElement; modules: Module[]; saved: Detail | null; draft: Editing | null;
|
|
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>;
|
|
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
|
+
*/
|
|
31
|
+
export async function mountDetail(options: Options): Promise<DetailController> {
|
|
32
|
+
const { ctx, root, saved, modules } = options;
|
|
33
|
+
const { ui } = ctx;
|
|
34
|
+
const lifecycle = new Lifecycle(ctx.onError);
|
|
35
|
+
const opts = { signal: lifecycle.signal };
|
|
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!);
|
|
38
|
+
editing.entry.spec ??= { model: '', thinking: false };
|
|
39
|
+
if (persisted) (persisted.entry.spec ??= { model: '', thinking: false });
|
|
40
|
+
const dirty = () => !persisted || snapshot(editing) !== snapshot(persisted);
|
|
41
|
+
const report = ui.msgline();
|
|
42
|
+
const form = ui.h('div');
|
|
43
|
+
root.append(form, report);
|
|
44
|
+
const errors = new Map<string, () => boolean>();
|
|
45
|
+
let rendering = 0;
|
|
46
|
+
let saving = false;
|
|
47
|
+
const panelHandles: Disposable[] = [];
|
|
48
|
+
let panelHost: ReturnType<NonNullable<FeatureContext['consolePageHost']>> | null = null;
|
|
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
|
+
};
|
|
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(); };
|
|
63
|
+
function field(body: HTMLElement, key: string, label: string, value: string, set: (value: string) => void, validate?: (value: string) => string | null, type = 'text') {
|
|
64
|
+
const input = ui.input({ value, type: type as 'text' }); input.setAttribute('aria-label', label);
|
|
65
|
+
const note = ui.h('div', 'field-error');
|
|
66
|
+
const row = ui.h('div', 'connection-field'); row.append(ui.field(label + (['name', 'baseUrl', 'model'].includes(key) ? ' *' : ''), input), note); body.append(row);
|
|
67
|
+
const check = () => { const error = validate?.(input.value); note.textContent = error ?? ''; input.setAttribute('aria-invalid', String(!!error)); return !error; };
|
|
68
|
+
errors.set(key, check);
|
|
69
|
+
input.addEventListener('input', () => { set(input.value); check(); change(); }, opts);
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
function jsonField(body: HTMLElement, key: string, label: string, value: unknown, array: boolean, set: (value: unknown) => void) {
|
|
73
|
+
const input = ui.textarea({ rows: 4, value: editing.raw[key] ?? (value === undefined ? '' : JSON.stringify(value, null, 2)), cls: 'mono' });
|
|
74
|
+
input.setAttribute('aria-label', label);
|
|
75
|
+
const error = ui.h('div', 'field-error'); body.append(ui.field(label, input), error);
|
|
76
|
+
const check = () => {
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = input.value.trim() ? JSON.parse(input.value) : undefined;
|
|
79
|
+
if (parsed !== undefined && (array ? !Array.isArray(parsed) : !parsed || typeof parsed !== 'object' || Array.isArray(parsed))) throw new Error();
|
|
80
|
+
set(parsed); error.textContent = ''; input.setAttribute('aria-invalid', 'false'); return true;
|
|
81
|
+
} catch { error.textContent = array ? S.jsonArray : S.jsonObject; input.setAttribute('aria-invalid', 'true'); return false; }
|
|
82
|
+
};
|
|
83
|
+
errors.set(key, check);
|
|
84
|
+
if (editing.raw[key] !== undefined) check();
|
|
85
|
+
input.addEventListener('input', () => { editing.raw[key] = input.value; check(); change(); }, opts);
|
|
86
|
+
}
|
|
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;
|
|
90
|
+
}
|
|
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 => {
|
|
106
|
+
try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? null : S.required; } catch { return S.required; }
|
|
107
|
+
});
|
|
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;
|
|
110
|
+
const test = ui.button(S.test, { onClick: () => run(async () => {
|
|
111
|
+
test.disabled = true;
|
|
112
|
+
try {
|
|
113
|
+
const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(identity) + '/test', draftBody(), opts);
|
|
114
|
+
report.textContent = result.ok ? `${S.testOk} · HTTP ${result.status ?? '—'} · ${(result.elapsedMs / 1000).toFixed(1)}s · ${result.model ?? ''}` : `${S.testFailed}: ${result.hint ?? result.error ?? ''}`;
|
|
115
|
+
} finally { test.disabled = false; }
|
|
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);
|
|
125
|
+
let listedModels: Array<{ id: string; contextWindow?: number }> = [];
|
|
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
|
+
};
|
|
132
|
+
modelInput.addEventListener('change', () => {
|
|
133
|
+
const known = listedModels.find(item => item.id === modelInput.value)?.contextWindow;
|
|
134
|
+
if (known && contextInput) { spec.contextWindow = known; contextInput.value = String(known); delete editing.raw.contextWindow; change(); }
|
|
135
|
+
noteCatalogWindow();
|
|
136
|
+
}, opts);
|
|
137
|
+
const fetch = ui.button(S.fetchModels, { onClick: () => run(async () => {
|
|
138
|
+
fetch.disabled = true;
|
|
139
|
+
try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(identity) + '/models', draftBody(), opts);
|
|
140
|
+
catalog.replaceChildren(...result.models.map(item => { const option = ui.h('option'); option.value = item.id; return option; }));
|
|
141
|
+
listedModels = result.models;
|
|
142
|
+
noteCatalogWindow();
|
|
143
|
+
} finally { fetch.disabled = false; }
|
|
144
|
+
}) }); body.append(fetch);
|
|
145
|
+
const tiers = module.reasoningTiers;
|
|
146
|
+
if (tiers.length) {
|
|
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 => {
|
|
148
|
+
const tier = tiers.find(tier => tier.id === value)!; spec.thinking = tier.thinking;
|
|
149
|
+
if (tier.effort) spec.reasoningEffort = tier.effort; else delete spec.reasoningEffort; change();
|
|
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 => {
|
|
152
|
+
spec.thinking = value !== 'none'; if (value && value !== 'none') spec.reasoningEffort = value; else delete spec.reasoningEffort;
|
|
153
|
+
});
|
|
154
|
+
for (const [name, label] of [['temperature', S.temperature], ['maxTokens', S.maxTokens], ['contextWindow', S.context]] as const) {
|
|
155
|
+
const input = field(body, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
|
|
156
|
+
editing.raw[name] = value; if (!value) delete spec[name]; else spec[name] = Number(value);
|
|
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');
|
|
158
|
+
if (name === 'contextWindow') contextInput = input;
|
|
159
|
+
}
|
|
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));
|
|
164
|
+
}
|
|
165
|
+
const images = ui.h('input'); images.type = 'checkbox'; images.checked = editing.entry.multimodal === true; images.setAttribute('aria-label', S.images);
|
|
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);
|
|
171
|
+
const prices = pricingEditor(ui, editing.entry.pricing ?? [], saved?.quotes ?? [], value => {
|
|
172
|
+
editing.entry.pricing = value as Detail['entry']['pricing']; change();
|
|
173
|
+
}, LANGUAGE, { raw: editing.raw.pricing, onRaw: value => { editing.raw.pricing = value; change(); } });
|
|
174
|
+
body.append(prices.body);
|
|
175
|
+
errors.set('pricing', prices.validate);
|
|
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 };
|
|
232
|
+
const actions = ui.h('div', 'connection-actions');
|
|
233
|
+
if (saved) {
|
|
234
|
+
actions.append(ui.button(S.remove, { variant: 'danger', onClick: () => run(async () => {
|
|
235
|
+
const current = await get<Detail>(connectionPath(saved.name), opts);
|
|
236
|
+
if (current.references.length) { await ui.confirm({ title: S.remove, body: S.referenced + current.references.join(', ') }); return; }
|
|
237
|
+
if (!(await ui.confirm({ title: S.remove, body: S.deleteConfirm, danger: true }))) return;
|
|
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: {} })) }));
|
|
240
|
+
}
|
|
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'));
|
|
247
|
+
if (gen !== rendering || lifecycle.disposed) return;
|
|
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 });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
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 => ({
|
|
269
|
+
...context,
|
|
270
|
+
setConfig: async (_id, values) => {
|
|
271
|
+
for (const [path, value] of Object.entries(values)) {
|
|
272
|
+
const prefix = `providers.${identity}.`;
|
|
273
|
+
if (!path.startsWith(prefix)) throw new Error('Foreign connection field');
|
|
274
|
+
entryPath(path.slice(prefix.length)).set(value);
|
|
275
|
+
}
|
|
276
|
+
change(); return S.unsaved;
|
|
277
|
+
},
|
|
278
|
+
invoke: async <T>(method: string, args?: unknown[]): Promise<T> => {
|
|
279
|
+
const before = JSON.stringify(editing.entry);
|
|
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);
|
|
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
|
+
}
|
|
285
|
+
return reply.result;
|
|
286
|
+
},
|
|
287
|
+
refresh: async () => {},
|
|
288
|
+
}));
|
|
289
|
+
if (gen !== rendering || lifecycle.disposed) { handle.dispose(); return; }
|
|
290
|
+
panelHandles.push(handle);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function save(select = true): Promise<boolean> {
|
|
294
|
+
if (saving) return false;
|
|
295
|
+
const valid = [...errors.values()].map(check => check()).every(Boolean); change();
|
|
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
|
+
}
|
|
300
|
+
saving = true;
|
|
301
|
+
try {
|
|
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);
|
|
303
|
+
await options.onSaved(result.name, select); return true;
|
|
304
|
+
} catch (error) { report.textContent = String(error); return false; }
|
|
305
|
+
finally { saving = false; }
|
|
306
|
+
}
|
|
307
|
+
await render(); change();
|
|
308
|
+
return { dispose: () => { lifecycle.dispose(); disposePanels(); panelHost?.unmount(); } };
|
|
309
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Editing } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export const NEW_DRAFT_ID = '/new';
|
|
4
|
+
|
|
5
|
+
/** Drafts are scoped to the deployment and never retain API Key input. */
|
|
6
|
+
export function providerDrafts(storage: Storage, scope: string) {
|
|
7
|
+
const key = `cortico:provider-drafts:v2:${scope}`;
|
|
8
|
+
let values: Record<string, Editing> = Object.create(null);
|
|
9
|
+
try { values = JSON.parse(storage.getItem(key) ?? '{}'); } catch { /* A corrupt draft does not block saved connections. */ }
|
|
10
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) values = Object.create(null);
|
|
11
|
+
else values = Object.assign(Object.create(null), values);
|
|
12
|
+
const persist = () => storage.setItem(key, JSON.stringify(values));
|
|
13
|
+
return {
|
|
14
|
+
get(name: string): Editing | null {
|
|
15
|
+
const value = values[name];
|
|
16
|
+
return value && typeof value.name === 'string' && value.entry && typeof value.entry.kind === 'string' && value.raw
|
|
17
|
+
? structuredClone({ ...value, secretValue: '' }) : null;
|
|
18
|
+
},
|
|
19
|
+
set(value: Editing): void { values[value.original ?? NEW_DRAFT_ID] = structuredClone({ ...value, secretValue: '' }); persist(); },
|
|
20
|
+
remove(name: string): void { delete values[name]; persist(); },
|
|
21
|
+
has(name: string): boolean { return !!values[name]; },
|
|
22
|
+
};
|
|
23
|
+
}
|