cortico 0.1.1 → 0.1.2

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.
Files changed (85) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +4 -2
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +18 -2
  14. package/src/core/util.ts +15 -0
  15. package/src/deploy.ts +5 -4
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -7
  23. package/src/providers/base.ts +2 -0
  24. package/src/providers/console/config.ts +21 -0
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +11 -19
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +38 -0
  30. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  31. package/src/providers/llamacpp/console/server.ts +23 -2
  32. package/src/providers/llamacpp/index.ts +5 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/options.ts +2 -0
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +33 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +40 -12
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +12 -3
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
  50. package/src/web/client/console-pages/host.ts +22 -5
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/extensions/index.ts +276 -41
  54. package/src/web/client/features/extensions/strings.ts +84 -10
  55. package/src/web/client/features/feature.ts +1 -1
  56. package/src/web/client/features/live/diagnostics.ts +32 -0
  57. package/src/web/client/features/live/index.ts +27 -11
  58. package/src/web/client/features/live/protocol.ts +1 -0
  59. package/src/web/client/features/live/strings.ts +9 -3
  60. package/src/web/client/features/providers/detail.ts +262 -0
  61. package/src/web/client/features/providers/drafts.ts +23 -0
  62. package/src/web/client/features/providers/index.ts +173 -87
  63. package/src/web/client/features/providers/strings.ts +44 -17
  64. package/src/web/client/features/providers/types.ts +15 -0
  65. package/src/web/client/features/settings/general.ts +12 -0
  66. package/src/web/client/features/settings/strings.ts +6 -0
  67. package/src/web/client/main.ts +4 -0
  68. package/src/web/client/shell/index.ts +1 -1
  69. package/src/web/client/ui/icons.ts +9 -1
  70. package/src/web/client/ui/prompt-input.tsx +17 -5
  71. package/src/web/client/ui/strings.ts +0 -2
  72. package/src/web/diagnostics.ts +133 -0
  73. package/src/web/public/login.html +67 -0
  74. package/src/web/public/styles.css +122 -18
  75. package/src/web/server.ts +227 -21
  76. package/src/web/shared/client-panel.ts +3 -0
  77. package/src/web/shared/console-protocol.ts +3 -1
  78. package/src/worlds/bilibili/README.md +1 -1
  79. package/src/worlds/bilibili/overlay/server.ts +4 -2
  80. package/src/worlds/minecraft/ADAPT.md +66 -0
  81. package/src/worlds/minecraft/README.md +8 -0
  82. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  83. package/src/worlds/qq/normalize.ts +14 -0
  84. package/src/worlds/qq/world.ts +53 -7
  85. 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
- composerHint: 'Terminal · Enter 发送 · 可粘贴或拖入图片',
14
- composerNoProvider: '当前无可用 Provider,请前往「模型提供商」页设置',
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
- composerHint: 'Terminal · Enter to send · paste or drop images',
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,262 @@
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 { S } from './strings.ts';
10
+
11
+ export interface DetailController { dispose(): void; dirty(): boolean; leave(): Promise<boolean>; }
12
+ interface Options {
13
+ ctx: FeatureContext; root: HTMLElement; modules: Module[]; saved: Detail | null; draft: Editing | null;
14
+ changed(editing: Editing, invalid: boolean): void; saveDraft(editing: Editing): void; onSaved(name: string, select?: boolean): Promise<void>; cancelled(): Promise<void>;
15
+ discarded(): void; deleted(): Promise<void>; duplicate(editing: Editing): Promise<void>;
16
+ }
17
+ export async function mountDetail(options: Options): Promise<DetailController> {
18
+ const { ctx, root, saved, modules } = options;
19
+ const { ui } = ctx;
20
+ const lifecycle = new Lifecycle(ctx.onError);
21
+ const opts = { signal: lifecycle.signal };
22
+ const editing: Editing = structuredClone(options.draft ?? { original: saved!.name, name: saved!.name, entry: saved!.entry, revision: saved!.revision, secretValue: '', raw: {} });
23
+ editing.entry.spec ??= { model: '', thinking: false };
24
+ let baseline = JSON.stringify(editing);
25
+ const dirty = () => JSON.stringify(editing) !== baseline;
26
+ const report = ui.msgline();
27
+ const form = ui.h('div');
28
+ root.append(form, report);
29
+ const errors = new Map<string, () => boolean>();
30
+ let rendering = 0;
31
+ let saving = false;
32
+ let panelHandle: { dispose(): void } | null = null;
33
+ let panelHost: ReturnType<NonNullable<FeatureContext['consolePageHost']>> | null = null;
34
+ const change = () => options.changed(editing, !!form.querySelector('[aria-invalid="true"]'));
35
+ const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!lifecycle.disposed) report.textContent = String(error); }); };
36
+ function field(body: HTMLElement, key: string, label: string, value: string, set: (value: string) => void, validate?: (value: string) => string | null, type = 'text') {
37
+ const input = ui.input({ value, type: type as 'text' }); input.setAttribute('aria-label', label);
38
+ const note = ui.h('div', 'field-error');
39
+ const row = ui.h('div', 'connection-field'); row.append(ui.field(label + (['name', 'baseUrl', 'model'].includes(key) ? ' *' : ''), input), note); body.append(row);
40
+ const check = () => { const error = validate?.(input.value); note.textContent = error ?? ''; input.setAttribute('aria-invalid', String(!!error)); return !error; };
41
+ errors.set(key, check);
42
+ input.addEventListener('input', () => { set(input.value); check(); change(); }, opts);
43
+ return input;
44
+ }
45
+ function jsonField(body: HTMLElement, key: string, label: string, value: unknown, array: boolean, set: (value: unknown) => void) {
46
+ const input = ui.textarea({ rows: 4, value: editing.raw[key] ?? (value === undefined ? '' : JSON.stringify(value, null, 2)), cls: 'mono' });
47
+ input.setAttribute('aria-label', label);
48
+ const error = ui.h('div', 'field-error'); body.append(ui.field(label, input), error);
49
+ const check = () => {
50
+ try {
51
+ const parsed: unknown = input.value.trim() ? JSON.parse(input.value) : undefined;
52
+ if (parsed !== undefined && (array ? !Array.isArray(parsed) : !parsed || typeof parsed !== 'object' || Array.isArray(parsed))) throw new Error();
53
+ set(parsed); error.textContent = ''; input.setAttribute('aria-invalid', 'false'); return true;
54
+ } catch { error.textContent = array ? S.jsonArray : S.jsonObject; input.setAttribute('aria-invalid', 'true'); return false; }
55
+ };
56
+ errors.set(key, check);
57
+ if (editing.raw[key] !== undefined) check();
58
+ input.addEventListener('input', () => { editing.raw[key] = input.value; check(); change(); }, opts);
59
+ }
60
+ function section(title: string, id?: string, open = false) {
61
+ const card = id ? ui.foldSheet('connection-' + id, { title, defaultOpen: open }) : ui.sheet({ title });
62
+ form.append(card.el); return card.body;
63
+ }
64
+ async function render() {
65
+ const gen = ++rendering;
66
+ panelHandle?.dispose(); panelHandle = null;
67
+ errors.clear(); form.replaceChildren();
68
+ const basic = section(S.basic);
69
+ field(basic, 'name', S.name, editing.name, value => { editing.name = value; }, value => value === saved?.name ? null : validateProviderName(value) ? S.nameHint : null);
70
+ basic.append(ui.msgline(S.nameHint));
71
+ const selectedModule = modules.find(module => module.id === editing.entry.kind);
72
+ if (saved) {
73
+ const module = ui.select({ value: editing.entry.kind, options: [{ value: editing.entry.kind, label: selectedModule?.title ?? editing.entry.kind }], disabled: true });
74
+ module.setAttribute('aria-label', S.module); module.classList.add('connection-module-readonly');
75
+ basic.append(ui.field(S.module, module), ui.msgline(S.fixedModule));
76
+ }
77
+ else {
78
+ const select = ui.select({ value: editing.entry.kind, options: [{ value: '', label: '—' }, ...modules.map(module => ({ value: module.id, label: module.title }))],
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
+ try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? null : S.required; } catch { return S.required; }
92
+ });
93
+ const key = field(connection, 'key', S.key, editing.secretValue, value => { editing.secretValue = value; }, undefined, 'password');
94
+ key.placeholder = saved?.secretConfigured !== 'none' && saved ? S.keySet : S.keyEmpty;
95
+ const test = ui.button(S.test, { onClick: () => run(async () => {
96
+ if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
97
+ test.disabled = true;
98
+ try {
99
+ const result = await post<{ ok: boolean; status: number | null; elapsedMs: number; model?: string; error?: string; hint?: string }>(connectionPath(saved.name) + '/test', {}, opts);
100
+ report.textContent = result.ok ? `${S.testOk} · HTTP ${result.status ?? '—'} · ${(result.elapsedMs / 1000).toFixed(1)}s · ${result.model ?? ''}` : `${S.testFailed}: ${result.hint ?? result.error ?? ''}`;
101
+ } finally { test.disabled = false; }
102
+ }) }); connection.append(test);
103
+ if (saved?.readiness.reason) connection.append(ui.msgline(saved.readiness.reason, true));
104
+ const model = section(S.modelSection, 'model', true);
105
+ const spec = editing.entry.spec ??= { model: '', thinking: false };
106
+ const modelInput = field(model, 'model', S.model, spec.model, value => { spec.model = value; }, value => value.trim() ? null : S.required);
107
+ const catalog = ui.h('datalist'); catalog.id = 'connection-models-' + Math.random().toString(36).slice(2); modelInput.setAttribute('list', catalog.id); model.append(catalog);
108
+ let listedModels: Array<{ id: string; contextWindow?: number }> = [];
109
+ let contextInput: HTMLInputElement | null = null;
110
+ modelInput.addEventListener('change', () => {
111
+ const known = listedModels.find(item => item.id === modelInput.value)?.contextWindow;
112
+ if (known && contextInput) { spec.contextWindow = known; contextInput.value = String(known); delete editing.raw.contextWindow; change(); }
113
+ }, opts);
114
+ const fetch = ui.button(S.fetchModels, { onClick: () => run(async () => {
115
+ if (!saved || dirty()) { report.textContent = S.savedFirst; return; }
116
+ fetch.disabled = true;
117
+ try { const result = await post<{ models: Array<{ id: string; contextWindow?: number }> }>(connectionPath(saved.name) + '/models', {}, opts);
118
+ catalog.replaceChildren(...result.models.map(item => { const option = ui.h('option'); option.value = item.id; return option; }));
119
+ listedModels = result.models;
120
+ } finally { fetch.disabled = false; }
121
+ }) }); model.append(fetch);
122
+ const tiers = selectedModule?.reasoningTiers ?? [];
123
+ if (tiers.length) {
124
+ 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
+ const tier = tiers.find(tier => tier.id === value)!; spec.thinking = tier.thinking;
126
+ if (tier.effort) spec.reasoningEffort = tier.effort; else delete spec.reasoningEffort; change();
127
+ } }); select.setAttribute('aria-label', S.reasoning); model.append(ui.field(S.reasoning, select));
128
+ } else field(model, 'reasoning', S.reasoning, !spec.thinking ? 'none' : spec.reasoningEffort ?? '', value => {
129
+ spec.thinking = value !== 'none'; if (value && value !== 'none') spec.reasoningEffort = value; else delete spec.reasoningEffort;
130
+ });
131
+ for (const [name, label] of [['temperature', S.temperature], ['maxTokens', S.maxTokens], ['contextWindow', S.context]] as const) {
132
+ const input = field(model, name, label, editing.raw[name] ?? String(spec[name] ?? ''), value => {
133
+ editing.raw[name] = value; if (!value) delete spec[name]; else spec[name] = Number(value);
134
+ }, 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
+ if (name === 'contextWindow') contextInput = input;
136
+ }
137
+ if (selectedModule?.serviceTiers.length) {
138
+ const select = ui.select({ value: editing.entry.serviceTier ?? '', options: [{ value: '', label: '—' }, ...selectedModule.serviceTiers.map(tier => ({ value: tier.id, label: tier.label }))], onChange: value => { editing.entry.serviceTier = value; change(); } });
139
+ select.setAttribute('aria-label', S.tier); model.append(ui.field(S.tier, select));
140
+ }
141
+ 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); model.append(ui.field(S.images, images));
143
+ const moduleBody = section(S.moduleSection, 'module', true);
144
+ const pricing = section(S.pricing, 'pricing');
145
+ const prices = pricingEditor(ui, editing.entry.pricing ?? [], saved?.quotes ?? [], value => {
146
+ editing.entry.pricing = value as Detail['entry']['pricing']; change();
147
+ }, LANGUAGE, { raw: editing.raw.pricing, onRaw: value => { editing.raw.pricing = value; change(); } });
148
+ pricing.append(prices.body);
149
+ errors.set('pricing', prices.validate);
150
+ const advanced = section(S.advanced, 'advanced'); advanced.append(ui.msgline(S.advancedHint));
151
+ field(advanced, 'secret', S.secret, editing.entry.secret ?? '', value => { if (value) editing.entry.secret = value; else delete editing.entry.secret; });
152
+ form.append(ui.h('div', 'connection-shared', S.shared));
153
+ const actions = ui.h('div', 'connection-actions');
154
+ if (saved) {
155
+ actions.append(ui.button(S.remove, { variant: 'danger', onClick: () => run(async () => {
156
+ const current = await get<Detail>(connectionPath(saved.name), opts);
157
+ if (current.references.length) { await ui.confirm({ title: S.remove, body: S.referenced + current.references.join(', ') }); return; }
158
+ if (!(await ui.confirm({ title: S.remove, body: S.deleteConfirm, danger: true }))) return;
159
+ await post(connectionPath(saved.name) + '/delete', { expectedRevision: saved.revision }, opts); baseline = JSON.stringify(editing); await options.deleted();
160
+ }) }), ui.button(S.duplicate, { onClick: () => run(async () => {
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
+ }) }));
164
+ }
165
+ actions.append(ui.h('span', 'grow'), ui.button(S.cancel, { onClick: () => run(async () => { baseline = JSON.stringify(editing); await options.cancelled(); }) }), ui.button(S.draft, { onClick: () => { try { options.saveDraft(editing); baseline = JSON.stringify(editing); report.textContent = S.drafted; } catch (error) { report.textContent = String(error); } } }), ui.button(S.save, { variant: 'primary', onClick: () => run(() => save()) }));
166
+ form.append(actions);
167
+ if (!selectedModule) { moduleBody.append(ui.msgline(saved ? S.readiness['module-missing'] : S.chooseModule)); return; }
168
+ const identity = saved?.name ?? 'draft';
169
+ const groups = await post<ConfigGroup[]>('/api/provider-modules/config', { name: identity, entry: editing.entry }, opts);
170
+ if (gen !== rendering || lifecycle.disposed) return;
171
+ if (ctx.consolePageHost) {
172
+ panelHost ??= ctx.consolePageHost({ root: moduleBody, route: () => ['providers', saved?.name ?? ''] });
173
+ await panelHost.load();
174
+ }
175
+ const hasPanels = panelHost?.find(`llm:${editing.entry.kind}`)?.panels?.some(panel => panel.id !== 'settings');
176
+ for (const group of groups.filter(group => !group.id.endsWith('.connection'))) {
177
+ for (const [path, property] of Object.entries(group.schema.properties ?? {})) {
178
+ const suffix = path.slice(`providers.${identity}.`.length);
179
+ if (['baseUrl', 'secret', 'multimodal'].includes(suffix) || property.type === 'object') continue;
180
+ const parts = suffix.split('.');
181
+ const getValue = () => parts.reduce<unknown>((value, part) => (value as Record<string, unknown> | undefined)?.[part], editing.entry);
182
+ const setValue = (value: unknown) => {
183
+ let target = editing.entry as unknown as Record<string, unknown>;
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));
193
+ }
194
+ }
195
+ if (ctx.consolePageHost) {
196
+ const slot = ui.h('div'); moduleBody.append(slot);
197
+ panelHost ??= ctx.consolePageHost({ root: slot, route: () => ['providers', saved?.name ?? ''] });
198
+ await panelHost.load();
199
+ if (gen !== rendering || lifecycle.disposed) return;
200
+ panelHandle = await panelHost.mountConnection(`llm:${editing.entry.kind}`, slot, { instance: identity }, context => ({
201
+ ...context,
202
+ setConfig: async (_id, values) => {
203
+ for (const [path, value] of Object.entries(values)) {
204
+ const prefix = `providers.${identity}.`;
205
+ if (!path.startsWith(prefix)) throw new Error('Foreign connection field');
206
+ const parts = path.slice(prefix.length).split('.');
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;
210
+ }
211
+ change(); return S.unsaved;
212
+ },
213
+ invoke: async <T>(method: string, args?: unknown[]): Promise<T> => {
214
+ const before = JSON.stringify(editing.entry);
215
+ 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) { if (reply.entry.spec) Object.assign(spec, reply.entry.spec); editing.entry = { ...reply.entry, spec }; change(); }
217
+ return reply.result;
218
+ },
219
+ refresh: async () => {},
220
+ }));
221
+ if (gen !== rendering || lifecycle.disposed) panelHandle.dispose();
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
+ });
233
+ }
234
+ }
235
+ async function save(select = true): Promise<boolean> {
236
+ if (saving) return false;
237
+ const valid = [...errors.values()].map(check => check()).every(Boolean); change();
238
+ if (!valid) { report.textContent = S.readiness.invalid; return false; }
239
+ saving = true;
240
+ try {
241
+ 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
+ baseline = JSON.stringify(editing); await options.onSaved(result.name, select); return true;
243
+ } catch (error) { report.textContent = String(error); return false; }
244
+ finally { saving = false; }
245
+ }
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
+ await render(); change();
261
+ return { dispose: () => { lifecycle.dispose(); panelHandle?.dispose(); panelHost?.unmount(); }, dirty, leave };
262
+ }
@@ -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
+ }
@@ -1,100 +1,186 @@
1
- /**
2
- * 「语言模型」页 —— 模型供应模块的入口。
3
- *
4
- * 左侧次级菜单列出 manifest 里 kind 为 `llm` 的页(每个供应模块一条),右侧由
5
- * 嵌入的控制台页宿主渲染选中模块的面板:实例与模型、授权、托管、配置。这一页不认识
6
- * 任何具体模块——名字、灯、徽标与面板全部来自 manifest。
7
- *
8
- * 路由 `#/providers/<pageId>/<panelId>`:第二段选模块,第三段选面板;缺省取第一个
9
- * 模块及其第一个面板。面板页签由宿主渲染并指向同一前缀,所以切换留在本页内。
10
- */
11
-
12
- import { PROVIDERS_LAMP_ID } from '../../../shared/console-protocol.ts';
13
- import { lampRow, paintLamps, subscribeLamps } from '../../ui/lamp.ts';
14
1
  import { pageIntro } from '../../ui/page.ts';
15
- import type { Route } from '../../core/router.ts';
2
+ import { icon } from '../../ui/icons.ts';
3
+ import { NEW_DRAFT_ID, providerDrafts } from './drafts.ts';
16
4
  import type { FeatureContext, FrameworkFeature } from '../feature.ts';
5
+ import { get, post } from '../../core/api.ts';
6
+ import { LANGUAGE } from '../../core/language.ts';
7
+ import { panel } from '../../console-pages/builtins/llm-settings/strings.ts';
8
+ import { probeCard, type ProbeResult } from '../../console-pages/builtins/llm-settings/panel.ts';
17
9
  import { S } from './strings.ts';
18
-
19
- const PROVIDERS_ROUTE = 'providers';
20
- /** 这一页只列供应模块;人格与 IO 各有自己的入口。 */
21
- const PROVIDER_KIND = 'llm';
10
+ import { connectionPath, type HubState, type Connection, type Module, type Detail, type Editing } from './types.ts';
11
+ import { mountDetail, type DetailController } from './detail.ts';
22
12
 
23
13
  export async function mountProviders(ctx: FeatureContext): Promise<void> {
24
14
  const { ui, root } = ctx;
15
+ root.append(pageIntro(ui, S.pageTitle));
16
+ const report = ui.msgline();
17
+ const layout = ui.h('div', 'connection-hub');
18
+ const index = ui.h('div', 'connection-index');
19
+ const cards = ui.h('div', 'connection-cards');
20
+ const detailRoot = ui.h('div', 'connection-detail');
21
+ layout.append(index, detailRoot); root.append(report, layout);
22
+ const opts = { signal: ctx.signal };
23
+ let state = await get<HubState>('/api/providers', opts);
24
+ state.providers.sort((a, b) => Number(b.name === state.active) - Number(a.name === state.active));
25
+ const modules = await get<Module[]>('/api/provider-modules', opts);
26
+ if (ctx.signal.aborted) return;
27
+ const drafts = providerDrafts(root.ownerDocument.defaultView!.localStorage, state.scope);
28
+ let selected = '';
29
+ let newDraft: Editing | null = drafts.get(NEW_DRAFT_ID);
30
+ const invalid = new Set<string>();
31
+ let controller: DetailController | null = null;
32
+ let renderId = 0;
33
+ interface CardNode {
34
+ el: HTMLElement; title: HTMLElement; model: HTMLElement; url: HTMLElement; status: HTMLElement; secondary: HTMLElement;
35
+ kind: HTMLElement; activate: HTMLButtonElement; erase: HTMLButtonElement; probe: HTMLButtonElement; probePanel: HTMLElement; probeBody: HTMLElement;
36
+ }
37
+ const nodes = new Map<string, CardNode>();
38
+ const rows = new Map<string, Connection>();
25
39
  const doc = root.ownerDocument;
26
- const intro = pageIntro(ui, S.pageTitle);
27
- const layout = ui.h('div', 'settings-layout providerhub');
28
- const index = ui.h('nav', 'settings-index');
29
- index.setAttribute('aria-label', S.modulesAria);
30
- index.setAttribute('role', 'tablist');
31
- const content = ui.h('div', 'settings-content');
32
- layout.append(index, content);
33
- root.append(intro, layout);
34
-
35
- if (!ctx.consolePageHost) {
36
- content.appendChild(ui.placeholder(S.needHost));
37
- return;
40
+ const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!ctx.signal.aborted) report.textContent = String(error); }); };
41
+ // 同时只有一张卡处在「确认删除」态。
42
+ let armed: (() => void) | null = null;
43
+ const disarm = () => { armed?.(); armed = null; };
44
+ const fallback = () => state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '';
45
+ function paint() {
46
+ report.textContent = state.active && !state.providers.some(item => item.name === state.active) ? S.missing + state.active : '';
47
+ const all: Array<Connection | { name: string; moduleTitle: string; model: string | null; baseUrl: string; readiness: { state: string } }> = [...state.providers];
48
+ if (newDraft) all.unshift({ name: NEW_DRAFT_ID, moduleTitle: moduleName(newDraft.entry.kind), model: newDraft.entry.spec?.model ?? null, baseUrl: newDraft.entry.baseUrl, readiness: { state: 'draft' } });
49
+ for (const [name, node] of nodes) if (!all.some(item => item.name === name)) { node.el.remove(); nodes.delete(name); }
50
+ for (const item of all) {
51
+ const identity = item.name;
52
+ let node = nodes.get(identity);
53
+ if (!node) {
54
+ const el = ui.h('article', 'connection-card'); el.dataset.provider = identity;
55
+ const title = ui.h('div', 'connection-name'); const kind = ui.h('div', 'connection-kind');
56
+ const model = ui.h('div', 'connection-model'); const url = ui.h('div', 'connection-url');
57
+ const status = ui.h('div', 'connection-status'); const secondary = ui.h('div', 'connection-secondary');
58
+ const actions = ui.rowbar();
59
+ const configure = ui.button(S.configure, { size: 'sm', onClick: () => run(() => select(identity)) });
60
+ const activate = ui.button(S.activate, { size: 'sm', onClick: () => run(async () => {
61
+ activate.disabled = true;
62
+ try { await post(connectionPath(identity) + '/activate', {}, opts); state.active = identity; paint(); }
63
+ finally { activate.disabled = false; }
64
+ }) });
65
+ const probePanel = ui.h('div', 'connection-probe');
66
+ const probeBody = ui.h('div', 'connection-probe-body');
67
+ probePanel.append(probeBody);
68
+ const probe = ui.button(S.probe, { size: 'sm', onClick: () => run(() => runProbe(identity)) });
69
+ probe.classList.add('connection-probe-btn'); probe.append(ui.h('span', 'connection-spin'));
70
+ const erase = eraseButton(identity);
71
+ actions.append(configure, probe, activate); el.append(erase, title, kind, model, url, status, secondary, actions, probePanel);
72
+ el.addEventListener('click', event => { if (!(event.target as Element).closest('button')) run(() => select(identity)); }, opts);
73
+ if (identity === NEW_DRAFT_ID) cards.prepend(el); else cards.append(el);
74
+ node = { el, title, kind, model, url, status, secondary, activate, erase, probe, probePanel, probeBody }; nodes.set(identity, node);
75
+ }
76
+ const active = identity === state.active;
77
+ node.el.classList.toggle('is-active', active); node.el.classList.toggle('is-selected', identity === selected);
78
+ node.title.textContent = identity === NEW_DRAFT_ID ? newDraft?.name || S.newName : identity;
79
+ node.title.title = node.title.textContent;
80
+ node.kind.textContent = item.moduleTitle; node.kind.title = item.moduleTitle;
81
+ node.model.textContent = item.model || '—'; node.url.textContent = item.baseUrl || '—';
82
+ node.model.title = item.model ?? ''; node.url.title = item.baseUrl ?? '';
83
+ const readiness = invalid.has(identity) && identity !== NEW_DRAFT_ID ? 'invalid' : item.readiness.state;
84
+ node.status.textContent = active ? S.active : S.readiness[readiness];
85
+ node.secondary.textContent = active && readiness !== 'ready' ? S.readiness[readiness] : identity !== NEW_DRAFT_ID && drafts.has(identity) ? S.readiness.draft : '';
86
+ node.activate.hidden = active || identity === NEW_DRAFT_ID;
87
+ node.activate.disabled = item.readiness.state !== 'ready';
88
+ node.probe.hidden = identity === NEW_DRAFT_ID;
89
+ if (identity === NEW_DRAFT_ID) rows.delete(identity); else rows.set(identity, item as Connection);
90
+ }
38
91
  }
39
- const host = ctx.consolePageHost({
40
- root: content,
41
- route: (pageId, panelId) => [PROVIDERS_ROUTE, pageId, panelId],
42
- });
43
- ctx.lifecycle.own({ dispose: () => host.unmount() });
44
- content.appendChild(ui.placeholder(S.loading));
45
- await host.load();
46
- if (ctx.signal.aborted) return;
47
-
48
- const providers = host.pages.filter((p) => p.kind === PROVIDER_KIND);
49
- if (!providers.length) {
50
- content.replaceChildren(ui.placeholder(S.none));
51
- return;
92
+ /** 删除按钮先伸成「确认删除」,第二次点击才动手;点别处或 Esc 收回。 */
93
+ function eraseButton(identity: string): HTMLButtonElement {
94
+ const el = ui.h('button', 'connection-erase') as HTMLButtonElement;
95
+ el.type = 'button';
96
+ const text = ui.h('span', 'connection-erase-text'); text.append(ui.h('span', '', S.eraseConfirm));
97
+ el.append(icon(doc, 'trash'), text);
98
+ const label = (armedNow: boolean) => { const name = armedNow ? S.eraseConfirm : S.remove; el.title = name; el.setAttribute('aria-label', name); };
99
+ label(false);
100
+ el.addEventListener('click', event => {
101
+ event.stopPropagation();
102
+ const wasArmed = el.classList.contains('is-armed');
103
+ disarm();
104
+ if (wasArmed) { run(() => remove(identity)); return; }
105
+ el.classList.add('is-armed'); label(true);
106
+ armed = () => { el.classList.remove('is-armed'); label(false); };
107
+ }, opts);
108
+ return el;
52
109
  }
53
-
54
- const jumps = new Map<string, HTMLButtonElement>();
55
- const lampNodes = new Map<string, HTMLSpanElement>();
56
- for (const p of providers) {
57
- const jump = ui.h('button', 'settings-jump providerhub-jump');
58
- jump.type = 'button';
59
- jump.setAttribute('role', 'tab');
60
- jump.append(ui.h('span', 'lbl', p.label || p.id));
61
- const lamps = lampRow(doc, p.lamps ?? []);
62
- jump.appendChild(lamps);
63
- lampNodes.set(p.id, lamps);
64
- jump.addEventListener('click', () => {
65
- try { ctx.router.navigate([PROVIDERS_ROUTE, p.id]); } catch (err) { ctx.onError(err); }
66
- }, { signal: ctx.signal });
67
- index.appendChild(jump);
68
- jumps.set(p.id, jump);
110
+ async function remove(identity: string): Promise<void> {
111
+ if (identity === NEW_DRAFT_ID) {
112
+ drafts.remove(NEW_DRAFT_ID); newDraft = null; invalid.delete(identity);
113
+ if (selected === NEW_DRAFT_ID) await select(fallback(), true); else paint();
114
+ return;
115
+ }
116
+ await post(connectionPath(identity) + '/delete', { expectedRevision: rows.get(identity)?.revision }, opts);
117
+ drafts.remove(identity); invalid.delete(identity);
118
+ await refresh();
119
+ if (selected === identity) await select(fallback(), true);
69
120
  }
70
- // 灯的活数据只改那几个点,不重排菜单(与左栏同一节拍)。
71
- ctx.lifecycle.own(subscribeLamps(doc, (lamps) => {
72
- for (const [id, el] of lampNodes) paintLamps(el, lamps[id] ?? []);
73
- }));
74
-
75
- const show = (route: Route): void => {
76
- const wanted = route.segments[1];
77
- const provider = providers.find((p) => p.id === wanted) ?? providers[0]!;
78
- for (const [id, jump] of jumps) {
79
- const on = id === provider.id;
80
- jump.classList.toggle('active', on);
81
- jump.setAttribute('aria-selected', String(on));
121
+ /** 探活结果挂在卡片下面,与模块页上的那份同一种呈现。 */
122
+ async function runProbe(identity: string): Promise<void> {
123
+ const node = nodes.get(identity);
124
+ if (!node || node.probe.disabled) return;
125
+ node.probe.disabled = true; node.probe.classList.add('is-busy');
126
+ try {
127
+ const result = await post<ProbeResult>(connectionPath(identity) + '/test', {}, opts);
128
+ if (ctx.signal.aborted) return;
129
+ const close = ui.button(S.probeClose, { size: 'sm', onClick: () => node.probePanel.classList.remove('is-open') });
130
+ node.probeBody.replaceChildren(probeCard(ui, LANGUAGE === 'en' ? panel.en : panel.zh, result), close);
131
+ node.probePanel.classList.add('is-open');
132
+ } finally { node.probe.disabled = false; node.probe.classList.remove('is-busy'); }
133
+ }
134
+ async function refresh() { state = await get<HubState>('/api/providers', opts); paint(); }
135
+ async function select(identity: string, force = false): Promise<void> {
136
+ if (!force && identity === selected) return;
137
+ if (!force && controller && !(await controller.leave())) return;
138
+ const gen = ++renderId;
139
+ controller?.dispose(); controller = null;
140
+ selected = identity; paint(); detailRoot.replaceChildren();
141
+ if (!identity) {
142
+ detailRoot.append(ui.h('h3', '', S.empty), ui.msgline(S.emptyHint), ui.button(S.create, { onClick: () => run(create) }));
143
+ return;
82
144
  }
83
- const panel = route.segments[2];
84
- void host.show(provider.id, typeof panel === 'string' && panel !== '' ? panel : undefined);
85
- };
86
- show(ctx.route);
87
- ctx.lifecycle.own(ctx.router.onChange((route) => {
88
- if (route.segments[0] !== PROVIDERS_ROUTE) return;
89
- show(route);
145
+ const saved = identity === NEW_DRAFT_ID ? null : await get<Detail>(connectionPath(identity), opts);
146
+ if (gen !== renderId || ctx.signal.aborted) return;
147
+ const detailView = ui.h('div'); detailRoot.replaceChildren(detailView);
148
+ const mounted = await mountDetail({ ctx, root: detailView, modules, saved, draft: identity === NEW_DRAFT_ID ? newDraft : drafts.get(identity),
149
+ discarded: () => { invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = drafts.get(NEW_DRAFT_ID); paint(); },
150
+ saveDraft: editing => { drafts.set(editing); paint(); },
151
+ changed: (editing, hasErrors) => { if (hasErrors) invalid.add(identity); else invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = editing; paint(); },
152
+ onSaved: async (name, show = true) => { drafts.remove(identity); invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = null; await refresh(); if (show) await select(name, true); },
153
+ cancelled: async () => { drafts.remove(identity); invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = null; await select(identity === NEW_DRAFT_ID ? state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '' : identity, true); },
154
+ deleted: async () => { drafts.remove(identity); invalid.delete(identity); await refresh(); await select(state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '', true); },
155
+ duplicate: async editing => {
156
+ if (newDraft) { await select(NEW_DRAFT_ID); return; }
157
+ const used = new Set(state.providers.map(item => item.name.toLowerCase()));
158
+ const base = editing.name.slice(0, 56); let candidate = base; let number = 2;
159
+ while (used.has(candidate.toLowerCase())) candidate = `${base}-${number++}`;
160
+ editing.name = candidate; newDraft = editing; await select(NEW_DRAFT_ID, true);
161
+ },
162
+ });
163
+ if (gen !== renderId || ctx.signal.aborted) mounted.dispose(); else controller = mounted;
164
+ }
165
+ async function create() {
166
+ if (newDraft) return select(NEW_DRAFT_ID);
167
+ if (controller && !(await controller.leave())) return;
168
+ newDraft = { original: null, name: '', entry: { kind: '', baseUrl: '' }, secretValue: '', raw: {} };
169
+ await select(NEW_DRAFT_ID, true);
170
+ }
171
+ const moduleName = (kind: string) => modules.find(module => module.id === kind)?.title ?? kind;
172
+ doc.addEventListener('click', event => { if (!(event.target as Element).closest('.connection-erase')) disarm(); }, opts);
173
+ doc.addEventListener('keydown', event => { if (event.key === 'Escape') disarm(); }, opts);
174
+ const creator = ui.h('div', 'connection-create');
175
+ creator.append(ui.button(S.create, { variant: 'primary', onClick: () => run(create) }), ui.h('p', 'connection-create-hint', S.createHint));
176
+ index.append(creator, cards);
177
+ ctx.lifecycle.own({ dispose: () => { renderId++; controller?.dispose(); } });
178
+ ctx.lifecycle.own(ctx.router.addLeaveDecision(async () => controller ? controller.leave() : true));
179
+ ctx.lifecycle.own(ctx.router.onChange(route => {
180
+ if (route.segments[0] === 'providers' && route.segments[1]) run(() => select(route.segments[1]));
90
181
  }));
182
+ paint();
183
+ const wanted = ctx.route.segments[1] ?? (newDraft ? NEW_DRAFT_ID : undefined);
184
+ await select(wanted && (wanted === NEW_DRAFT_ID || state.providers.some(item => item.name === wanted)) ? wanted : state.providers.find(item => item.name === state.active)?.name ?? state.providers[0]?.name ?? '', true);
91
185
  }
92
-
93
- export const providersFeature: FrameworkFeature = {
94
- route: PROVIDERS_ROUTE,
95
- label: S.navLabel,
96
- icon: 'cpu',
97
- lampId: PROVIDERS_LAMP_ID,
98
- navGroup: S.navGroup,
99
- mount: mountProviders,
100
- };
186
+ export const providersFeature: FrameworkFeature = { route: 'providers', label: S.navLabel, icon: 'cpu', navGroup: S.navGroup, mount: mountProviders };