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
@@ -0,0 +1,270 @@
1
+ import { coerceGroupValues, getByPath, setByPath } from '../../core/config-schema.ts';
2
+ /** Connection configuration transactions serialize writers across deployments and roll back all touched files on failure. */
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import type { CoreConfig, LLMProviderEntry } from '../../core/types.ts';
7
+ import type { Language } from '../../core/language.ts';
8
+ import { readJsonObject, updateJsonObject } from '../../config-file.ts';
9
+ import { readTextFile } from '../../core/util.ts';
10
+ import type { ProviderModule } from '../base.ts';
11
+ import { validateEntry } from '../configuration.ts';
12
+ import { validateProviderName } from '../name.ts';
13
+ import { providerModules, type ProviderRegistry } from '../registry.ts';
14
+ import type { ProviderSettings } from './settings.ts';
15
+ import { quotePrices } from '../pricebook.ts';
16
+ import { connectionGroup } from './config.ts';
17
+
18
+ export class ProviderHubError extends Error {
19
+ constructor(message: string, readonly status = 400) { super(message); }
20
+ }
21
+ export interface ConnectionSave {
22
+ name: string;
23
+ copyFrom?: { name: string; revision: string };
24
+ entry: LLMProviderEntry;
25
+ expectedRevision?: string;
26
+ secretValue?: string;
27
+ }
28
+ export class ProviderHub {
29
+ private readonly revisions = new Map<string, string>();
30
+ constructor(private readonly config: CoreConfig, private readonly registry: ProviderRegistry,
31
+ private readonly settings: ProviderSettings, private readonly file: string,
32
+ private readonly root: string, private readonly modules: readonly ProviderModule[] = providerModules) {}
33
+
34
+ private path(name: string): string {
35
+ const path = resolve(this.root, name);
36
+ if (!name || dirname(path) !== resolve(this.root)) throw new ProviderHubError('Invalid provider identifier.');
37
+ return path;
38
+ }
39
+ private revision(name: string): string {
40
+ const dir = this.path(name);
41
+ return createHash('sha256').update(['config.json', '.env'].map(file =>
42
+ existsSync(join(dir, file)) ? readFileSync(join(dir, file)).toString('base64') : '').join(':')).digest('hex');
43
+ }
44
+ private refresh(): void {
45
+ const entries: Record<string, LLMProviderEntry> = Object.create(null);
46
+ if (existsSync(this.root)) for (const dir of readdirSync(this.root, { withFileTypes: true })) {
47
+ if (!dir.isDirectory() || (dir.name === '.write-lock' || dir.name.startsWith('.transaction-') || dir.name.startsWith('.deleted-'))) continue;
48
+ const file = join(this.root, dir.name, 'config.json');
49
+ if (existsSync(file)) entries[dir.name] = JSON.parse(readTextFile(file));
50
+ }
51
+ for (const name of new Set([...Object.keys(entries), ...Object.keys(this.config.providers)])) {
52
+ const revision = entries[name] ? this.revision(name) : '';
53
+ if (JSON.stringify(entries[name]) !== JSON.stringify(this.config.providers[name]) || this.revisions.get(name) !== revision) this.registry.invalidate(name);
54
+ this.revisions.set(name, revision);
55
+ }
56
+ this.config.providers = entries;
57
+ const disk = readJsonObject(this.file);
58
+ if (typeof disk.activeProvider === 'string') this.config.activeProvider = disk.activeProvider;
59
+ }
60
+ moduleList(language: Language) {
61
+ return this.modules.map(module => ({ id: module.id, title: module.title,
62
+ description: module.localize?.(language)?.description ?? module.description ?? module.title, defaultBaseUrl: module.defaultBaseUrl ?? '',
63
+ reasoningTiers: module.localize?.(language)?.reasoningTiers ?? module.reasoningTiers,
64
+ effortSuggestions: module.effortSuggestions ?? [], serviceTiers: module.localize?.(language)?.serviceTiers ?? module.serviceTiers,
65
+ temperatureNote: module.localize?.(language)?.temperatureNote ?? module.temperatureNote,
66
+ }));
67
+ }
68
+ private readiness(name: string, entry: LLMProviderEntry, language: Language) {
69
+ const module = this.modules.find(m => m.id === entry.kind);
70
+ if (!module) return { state: 'module-missing', reason: language === 'zh' ? '供应商模块不可用。' : 'Provider module is unavailable.' };
71
+ try { validateEntry(module, entry, language); }
72
+ catch (error) { return { state: 'invalid', reason: String(error) }; }
73
+ if (!entry.spec?.model || (entry.secret && this.settings.secretStatus(name, entry) === 'none'))
74
+ return { state: 'needs-setup', reason: !entry.spec?.model ? (language === 'zh' ? '请选择模型。' : 'Model is required.') : (language === 'zh' ? '请配置 API Key。' : 'API Key is required.') };
75
+ const available = module.availability?.(name, entry, language);
76
+ if (available && !available.ready) return { state: 'runtime-unavailable', reason: available.reason };
77
+ return { state: 'ready' };
78
+ }
79
+ current(language: Language) {
80
+ const name = this.config.activeProvider;
81
+ const entry = this.config.providers[name];
82
+ return entry ? { name, model: entry.spec?.model ?? null, module: entry.kind,
83
+ moduleTitle: this.modules.find(module => module.id === entry.kind)?.title ?? entry.kind,
84
+ baseUrl: entry.baseUrl, ready: this.readiness(name, entry, language).state === 'ready' } : null;
85
+ }
86
+ list(language: Language) {
87
+ this.refresh();
88
+ return { scope: createHash('sha256').update(resolve(this.file)).digest('hex'), active: this.config.activeProvider, providers: Object.entries(this.config.providers).map(([name, entry]) => ({
89
+ id: name, name, module: entry.kind, moduleTitle: this.modules.find(m => m.id === entry.kind)?.title ?? entry.kind,
90
+ model: entry.spec?.model ?? null, baseUrl: entry.baseUrl, active: name === this.config.activeProvider,
91
+ readiness: this.readiness(name, entry, language), revision: this.revision(name),
92
+ })) };
93
+ }
94
+ detail(name: string, language: Language) {
95
+ this.refresh();
96
+ const entry = this.config.providers[name];
97
+ if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
98
+ return { name, entry: structuredClone(entry), revision: this.revision(name),
99
+ secretConfigured: this.settings.secretStatus(name, entry), readiness: this.readiness(name, entry, language),
100
+ references: this.references(name).map(file => dirname(file).split(/[\\/]/).at(-1)),
101
+ config: this.groups(name, entry, language),
102
+ quotes: entry.spec ? [{ model: entry.spec.model, quotes: this.quotes(entry) }] : [],
103
+ };
104
+ }
105
+ private quotes(entry: LLMProviderEntry) {
106
+ const module = this.modules.find(module => module.id === entry.kind);
107
+ const request = { model: entry.spec?.model };
108
+ const at = { startedAt: new Date().toISOString(), requestedServiceTier: entry.serviceTier ?? null };
109
+ return quotePrices(entry, request, at, module?.prices?.(entry, request, at) ?? []);
110
+ }
111
+ groups(name: string, entry: LLMProviderEntry, language: Language) {
112
+ const module = this.modules.find(m => m.id === entry.kind);
113
+ return [connectionGroup(name, entry, language), ...(module?.config?.(name, entry, language) ?? [])];
114
+ }
115
+ async preview(name: string, entry: LLMProviderEntry, panel: string, method: string, args: unknown[], language: Language) {
116
+ this.path(name);
117
+ const module = this.modules.find(module => module.id === entry.kind);
118
+ if (!module?.console) throw new ProviderHubError('Module panel unavailable.');
119
+ this.refresh();
120
+ const current = this.config.providers[name];
121
+ const persisted = !!current && JSON.stringify(current) === JSON.stringify(entry);
122
+ let draft = structuredClone(entry);
123
+ const contribution = module.console({ language, editing: !persisted,
124
+ entries: () => [{ name, entry: draft }],
125
+ instance: requested => {
126
+ if (requested !== name) throw new ProviderHubError('Foreign provider.');
127
+ return persisted ? this.registry.resolve(name) : this.registry.preview(name, draft);
128
+ },
129
+ save: (requested, next) => {
130
+ if (requested !== name || next.kind !== entry.kind) throw new ProviderHubError('Foreign provider.');
131
+ draft = structuredClone(next);
132
+ },
133
+ });
134
+ const result = await contribution.invoke?.(panel, method, args);
135
+ return { result, entry: draft };
136
+ }
137
+ private references(name: string): string[] {
138
+ const files = new Set([this.file]);
139
+ const parent = dirname(this.root);
140
+ for (const dir of readdirSync(parent, { withFileTypes: true })) {
141
+ if (!dir.isDirectory() || resolve(parent, dir.name) === resolve(this.root)) continue;
142
+ const file = join(parent, dir.name, 'config.json');
143
+ if (existsSync(file) && existsSync(join(parent, dir.name, 'deployment.json'))) files.add(file);
144
+ }
145
+ return [...files].filter(file => readJsonObject(file).activeProvider === name);
146
+ }
147
+ private locked<T>(work: () => T): T {
148
+ mkdirSync(this.root, { recursive: true });
149
+ const lock = join(this.root, '.write-lock');
150
+ try { mkdirSync(lock); } catch { throw new ProviderHubError('Another provider transaction is in progress. Retry after it completes.', 409); }
151
+ try { this.refresh(); return work(); } finally { rmSync(lock, { recursive: true, force: true }); }
152
+ }
153
+ private checkRevision(name: string, expected: unknown): void {
154
+ if (typeof expected !== 'string' || expected !== this.revision(name))
155
+ throw new ProviderHubError('Configuration changed in another page or bot. Reload before saving.', 409);
156
+ }
157
+ save(original: string | null, input: ConnectionSave, language: Language) {
158
+ return this.locked(() => {
159
+ const { name } = input;
160
+ if (typeof name !== 'string') throw new ProviderHubError('Provider name is required.');
161
+ const prior = original ? this.config.providers[original] : undefined;
162
+ if (original && !prior) throw new ProviderHubError('Provider does not exist.', 404);
163
+ if (original) this.checkRevision(original, input.expectedRevision);
164
+ if (name !== original) {
165
+ const problem = validateProviderName(name);
166
+ if (problem) throw new ProviderHubError(problem);
167
+ if (readdirSync(this.root).some(value => value.toLowerCase() === name.toLowerCase()))
168
+ throw new ProviderHubError('Provider name or directory already exists.', 409);
169
+ }
170
+ if (!input.entry || typeof input.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
171
+ if (prior && prior.kind !== input.entry.kind) throw new ProviderHubError('Provider module cannot be changed.');
172
+ const module = this.modules.find(m => m.id === input.entry.kind);
173
+ if (!module) throw new ProviderHubError('Provider module is unavailable.');
174
+ const requested = structuredClone(input.entry);
175
+ if (!requested.spec?.model) throw new ProviderHubError('Model is required.');
176
+ if (input.secretValue !== undefined && (typeof input.secretValue !== 'string' || !input.secretValue || /\s/.test(input.secretValue)))
177
+ throw new ProviderHubError('API Key must be nonempty and contain no whitespace.');
178
+ if (input.secretValue && !requested.secret) requested.secret = 'CORTICO_PROVIDER_API_KEY';
179
+ const entry = validateEntry(module, requested, language);
180
+ const prefix = `providers.${name}.`;
181
+ for (const group of this.groups(name, entry, language)) {
182
+ const values = Object.fromEntries(Object.keys(group.schema.properties).filter(path => path.startsWith(prefix))
183
+ .map(path => [path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length))])
184
+ .filter(([, value]) => value !== undefined));
185
+ const result = coerceGroupValues(group, values, language);
186
+ if ('error' in result) throw new ProviderHubError(result.error);
187
+ for (const [path, value] of Object.entries(result.values)) setByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length), value);
188
+ }
189
+ validateEntry(module, entry, language);
190
+ let copiedSecret: Buffer | null = null;
191
+ let credentialSource = original;
192
+ if (!original && input.copyFrom) {
193
+ const source = this.config.providers[input.copyFrom.name];
194
+ if (!source) throw new ProviderHubError('Copy source no longer exists.', 409);
195
+ this.checkRevision(input.copyFrom.name, input.copyFrom.revision);
196
+ if (source.secret === entry.secret) {
197
+ credentialSource = input.copyFrom.name;
198
+ const file = join(this.path(input.copyFrom.name), '.env');
199
+ if (existsSync(file)) copiedSecret = readFileSync(file);
200
+ }
201
+ }
202
+ if (entry.secret && !input.secretValue && (!credentialSource || this.settings.secretStatus(credentialSource, entry) === 'none'))
203
+ throw new ProviderHubError('API Key is required.');
204
+ const target = this.path(name);
205
+ const source = original ? this.path(original) : null;
206
+ const stage = join(this.root, `.transaction-${randomUUID()}`);
207
+ const refs = original && name !== original ? this.references(original) : [];
208
+ const backups = new Map<string, Buffer | null>();
209
+ for (const file of [...refs, ...(source ? [join(source, 'config.json'), join(source, '.env')] : [])])
210
+ backups.set(file, existsSync(file) ? readFileSync(file) : null);
211
+ let moved = false;
212
+ try {
213
+ if (!source) mkdirSync(stage);
214
+ const dir = source ?? stage;
215
+ updateJsonObject(join(dir, 'config.json'), raw => { for (const key of Object.keys(raw)) delete raw[key]; Object.assign(raw, entry); });
216
+ if (copiedSecret) writeFileSync(join(dir, '.env'), copiedSecret);
217
+ if (input.secretValue) {
218
+ const file = join(dir, '.env');
219
+ const lines = (existsSync(file) ? readTextFile(file) : '').split(/\r?\n/).filter(line => line.split('=')[0]?.trim() !== entry.secret);
220
+ lines.push(`${entry.secret}=${input.secretValue}`);
221
+ writeFileSync(file, lines.filter(Boolean).join('\n') + '\n');
222
+ }
223
+ if (source !== target) { renameSync(dir, target); moved = true; }
224
+ for (const file of refs) updateJsonObject(file, raw => { raw.activeProvider = name; });
225
+ } catch (error) {
226
+ if (moved) renameSync(target, source ?? stage);
227
+ for (const [file, contents] of backups) {
228
+ if (contents === null) rmSync(file, { force: true }); else writeFileSync(file, contents);
229
+ }
230
+ throw error;
231
+ } finally { if (existsSync(stage)) rmSync(stage, { recursive: true, force: true }); }
232
+ if (original) this.registry.invalidate(original);
233
+ this.registry.invalidate(name);
234
+ this.refresh();
235
+ return this.detail(name, language);
236
+ });
237
+ }
238
+ activate(name: string, language: Language): void {
239
+ this.locked(() => {
240
+ const entry = this.config.providers[name];
241
+ if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
242
+ const readiness = this.readiness(name, entry, language);
243
+ if (readiness.state !== 'ready') throw new ProviderHubError(readiness.reason ?? readiness.state);
244
+ updateJsonObject(this.file, raw => { raw.activeProvider = name; });
245
+ this.config.activeProvider = name;
246
+ });
247
+ }
248
+ delete(name: string, revision: string): void {
249
+ this.locked(() => {
250
+ if (!this.config.providers[name]) throw new ProviderHubError('Provider does not exist.', 404);
251
+ this.checkRevision(name, revision);
252
+ const refs = this.references(name);
253
+ if (this.config.activeProvider === name || refs.length)
254
+ throw new ProviderHubError(`Provider is used by: ${refs.map(file => dirname(file).split(/[\\/]/).at(-1)).join(', ') || 'current bot'}`, 409);
255
+ const tomb = join(this.root, `.deleted-${randomUUID()}`);
256
+ renameSync(this.path(name), tomb);
257
+ this.registry.invalidate(name);
258
+ delete this.config.providers[name];
259
+ rmSync(tomb, { recursive: true, force: true });
260
+ });
261
+ }
262
+ async action(name: string, action: string, language: Language) {
263
+ this.refresh();
264
+ const entry = this.config.providers[name];
265
+ if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
266
+ const source = this.settings.sources().find(source => source.id === `llm:${entry.kind}`);
267
+ if (!source) throw new ProviderHubError('Provider module is unavailable.');
268
+ return source.contribute(language).invoke!('settings', action === 'test' ? 'probe' : 'models', [{ name }]);
269
+ }
270
+ }
@@ -26,14 +26,12 @@ import { readTextFile } from '../../core/util.ts';
26
26
  import { responseRequest } from '../../protocol/open-responses/context-helpers.ts';
27
27
  import { record } from '../../protocol/open-responses/context.ts';
28
28
  import { text } from './strings.ts';
29
- import type { ProviderConsoleHost } from './types.ts';
29
+ import { PROBE_MAX_OUTPUT_TOKENS, type ProviderConsoleHost } from './types.ts';
30
+ import { connectionGroup } from './config.ts';
30
31
 
31
32
  export type SecretStatus = 'env' | 'file' | 'none';
32
33
 
33
- /** Output token limit used by the connectivity probe. */
34
- const PROBE_MAX_OUTPUT_TOKENS = 256;
35
-
36
- /** 密钥变量名由操作员自由填写,拼进正则前按字面转义。 */
34
+ /** 密钥变量名由操作员自由填写,也可能是直接写在磁盘上的任意名字,拼进正则前按字面转义。 */
37
35
  function escapeRegExp(s: string): string {
38
36
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
39
37
  }
@@ -70,7 +68,8 @@ export class ProviderSettings {
70
68
  private declaredGroups(language: Language) {
71
69
  return this.modules.flatMap((module) =>
72
70
  this.entries(module).flatMap(({ name, entry }) =>
73
- (module.config?.(name, entry, language) ?? []).map((group) => ({ name, group })),
71
+ [connectionGroup(name, entry, language), ...(module.config?.(name, entry, language) ?? [])]
72
+ .map((group) => ({ name, group })),
74
73
  ),
75
74
  );
76
75
  }
@@ -281,7 +280,7 @@ export class ProviderSettings {
281
280
  return { label: S.availableLamp, state: 'offline', hint: reason };
282
281
  }
283
282
 
284
- /** 所有模块合起来有没有一个可用端点。控制台左栏「语言模型」那一行点的就是它。 */
283
+ /** 所有模块合起来有没有一个可用端点。控制台左栏「模型提供商」那一行点的就是它。 */
285
284
  providersLamp(language: Language = 'zh'): ConsoleLamp {
286
285
  return this.availableLamp(
287
286
  this.modules.flatMap((module) => this.entries(module)),
@@ -312,16 +311,7 @@ export class ProviderSettings {
312
311
  kind: 'llm',
313
312
  label: module.title,
314
313
  availability: 'active',
315
- lamps: [
316
- {
317
- label: S.activeInstanceLamp,
318
- state: entries.some((value) => value.name === this.config.activeProvider)
319
- ? 'online'
320
- : 'offline',
321
- },
322
- this.availableLamp(this.entries(module), language),
323
- ...(extra.lamps ?? []),
324
- ],
314
+ lamps: [],
325
315
  badges: [{ label: S.instancesBadge, value: String(entries.length) }, ...(extra.badges ?? [])],
326
316
  panels: [
327
317
  {
@@ -334,7 +324,7 @@ export class ProviderSettings {
334
324
  },
335
325
  ...(extra.panels ?? []),
336
326
  ],
337
- config: entries.flatMap(
327
+ config: extra.config ?? entries.flatMap(
338
328
  ({ name, entry }) => module.config?.(name, entry, language) ?? [],
339
329
  ),
340
330
  invoke: async (panel, method, args) => {
@@ -358,6 +348,8 @@ export class ProviderSettings {
358
348
  instances: this.entries(module).map(({ name, entry }) => ({
359
349
  name,
360
350
  entry,
351
+ config: this.declaredGroups(language).filter((item) => item.name === name)
352
+ .map(({ group }) => ({ group, values: this.values(group.id, language) })),
361
353
  secretConfigured: this.secretStatus(name, entry),
362
354
  quotes: (entry.spec ? [entry.spec] : []).map((spec) => ({
363
355
  model: spec.model,
@@ -383,7 +375,7 @@ export class ProviderSettings {
383
375
  const name = body.name;
384
376
  if (method === 'create') {
385
377
  this.assertNewName(name, language);
386
- // 报价留空:用量页把这条端点的调用记成未计价,而不是零元。
378
+ // 报价留空:不覆盖模块价目;模块也没给价目时,这条端点的调用只记 token,不记金额。
387
379
  this.save(name, {
388
380
  kind: module.id,
389
381
  baseUrl: String(body.baseUrl || module.defaultBaseUrl || ''),
@@ -2,9 +2,14 @@ import type { LLMProviderEntry } from '../../core/types.ts';
2
2
  import type { Language } from '../../core/language.ts';
3
3
  import type { ProviderInstance } from '../base.ts';
4
4
 
5
+ /** Output token limit for the connectivity probe and module probes. */
6
+ export const PROBE_MAX_OUTPUT_TOKENS = 256;
7
+
5
8
  export interface ProviderConsoleHost {
6
9
  /** Console language for panel titles, receipts and error texts. */
7
10
  readonly language: Language;
11
+ /** Configuration edits return to the browser draft; runtime mutations require a saved connection. */
12
+ readonly editing?: boolean;
8
13
  entries(): Array<{ name: string; entry: LLMProviderEntry }>;
9
14
  instance(name: string): ProviderInstance;
10
15
  save(name: string, entry: LLMProviderEntry): void;
@@ -0,0 +1,3 @@
1
+ import type { ProviderHub } from './console/hub.ts';
2
+
3
+ export type ProviderHubApi = Pick<ProviderHub, 'list' | 'moduleList' | 'groups' | 'preview' | 'detail' | 'save' | 'delete' | 'activate' | 'action'>;
@@ -0,0 +1,38 @@
1
+ import type { ConfigGroup, ConfigProperty } from '../../core/config-schema.ts';
2
+ import type { Language } from '../../core/language.ts';
3
+ import type { LLMProviderEntry } from '../../core/types.ts';
4
+ import { backendChoices, llamacppOptions } from './options.ts';
5
+ import { panel } from './strings.ts';
6
+
7
+ export function runtimeConfig(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup[] {
8
+ if (!llamacppOptions(entry).runtime) return [];
9
+ const S = panel[language];
10
+ const group = (
11
+ id: string, title: string, description: string, properties: Record<string, ConfigProperty>,
12
+ ): ConfigGroup => ({
13
+ id: `llm.llamacpp.${name}.${id}`,
14
+ owner: 'provider:llamacpp',
15
+ schema: {
16
+ type: 'object', title, description,
17
+ properties: Object.fromEntries(Object.entries(properties).map(([path, property]) => [
18
+ `providers.${name}.options.${path}`, { ...property, 'x-hot': true },
19
+ ])),
20
+ },
21
+ });
22
+ return [
23
+ group('runtime', S.runtimeSection, S.runtimeSectionDesc, {
24
+ 'runtime.release': { type: 'string', title: S.release, description: S.releaseHint },
25
+ 'runtime.backend': { type: 'string', title: S.backend, enum: backendChoices(), description: S.backendHint },
26
+ 'runtime.runtimeDir': {
27
+ type: 'string', title: S.runtimeDir, description: S.runtimeDirHint, 'x-path': { kind: 'directory' },
28
+ },
29
+ }),
30
+ group('launch', S.launchSection, S.launchSectionDesc, {
31
+ 'launch.contextSize': { type: 'integer', title: S.contextSize, minimum: 1 },
32
+ 'launch.nGpuLayers': { type: 'integer', title: S.nGpuLayers, minimum: 0 },
33
+ 'launch.parallel': { type: 'integer', title: S.parallel, minimum: 1 },
34
+ 'launch.extraArgs': { type: 'string', title: S.extraArgs, description: S.extraArgsHint },
35
+ autoStart: { type: 'boolean', title: S.autoStart },
36
+ }),
37
+ ];
38
+ }
@@ -3,11 +3,12 @@
3
3
  * The endpoint comes from `ctx.scope.instance`; edits are written on change.
4
4
  */
5
5
  import type { ConsolePanel, ConsolePanelContext } from '../../../web/shared/client-panel.ts';
6
- import type { LaunchOptions } from '../options.ts';
7
- import type { RuntimeState } from '../runtime.ts';
6
+ import type { ConfigValues } from '../../../core/config-schema.ts';
7
+ import { configField } from '../../../web/client/features/config/view.ts';
8
+ import type { RuntimePanelState } from './server.ts';
8
9
  import { panel } from '../strings.ts';
9
10
 
10
- type Row = RuntimeState & { name: string };
11
+ type Row = RuntimePanelState;
11
12
 
12
13
  const POLL_MS = 2_000;
13
14
 
@@ -42,18 +43,35 @@ export const runtimePanel: ConsolePanel = {
42
43
  await load(true);
43
44
  }
44
45
 
45
- /** One launch field; empty input sends NaN so the endpoint reports the same error as a bad value. */
46
- function launchNumber(row: Row, key: keyof LaunchOptions, label: string, min: number): HTMLElement {
47
- const current = row.launch?.[key];
48
- const input = ui.input({
49
- type: 'number',
50
- value: current === undefined ? '' : String(current),
51
- onChange: (value) => void act('configure', { launch: { [key]: Number(value) } }),
52
- });
53
- input.min = String(min);
54
- input.step = '1';
55
- input.setAttribute('aria-label', label);
56
- return ui.field(label, input);
46
+ async function saveConfig(groupId: string, values: ConfigValues): Promise<void> {
47
+ if (busy) return;
48
+ busy = true;
49
+ message.textContent = '';
50
+ try {
51
+ await ctx.setConfig(groupId, values);
52
+ } catch (error) {
53
+ message.textContent = String(error);
54
+ } finally {
55
+ busy = false;
56
+ }
57
+ await load(true);
58
+ }
59
+
60
+ function renderConfig(row: Row, index: number, body: HTMLElement): void {
61
+ const { group, values } = row.config[index];
62
+ body.append(ui.section(group.schema.title, group.schema.description));
63
+ for (const [path, property] of Object.entries(group.schema.properties)) {
64
+ const field = configField(ui, property, values[path], () => {
65
+ if (field.read) void saveConfig(group.id, { [path]: field.read() });
66
+ }, ctx.signal);
67
+ field.node.setAttribute('aria-label', property.title);
68
+ // 自备运行时目录时后端由目录里的二进制决定,后端那一格禁用。
69
+ if (path.endsWith('.runtime.backend') && row.own) {
70
+ (field.node as HTMLSelectElement).disabled = true;
71
+ }
72
+ body.append(ui.field(property.title, field.node));
73
+ if (property.description) body.append(ui.msgline(property.description));
74
+ }
57
75
  }
58
76
 
59
77
  function renderUnmanaged(row: Row, body: HTMLElement): void {
@@ -74,42 +92,8 @@ export const runtimePanel: ConsolePanel = {
74
92
  : install.phase === 'extracting' ? ui.pill(S.extracting, 'plain')
75
93
  : ui.pill(S.absent, 'off');
76
94
 
77
- body.append(ui.section(S.runtimeSection, S.runtimeSectionDesc));
78
- const release = ui.input({
79
- value: row.release ?? '',
80
- cls: 'mono',
81
- onChange: (value) => void act('configure', { release: value }),
82
- });
83
- release.setAttribute('aria-label', S.release);
84
- const backend = ui.select({
85
- value: row.backend ?? '',
86
- options: row.backendChoices,
87
- onChange: (value) => void act('configure', { backend: value }),
88
- });
89
- backend.setAttribute('aria-label', S.backend);
90
- backend.disabled = row.own;
91
- const dir = ui.input({
92
- value: row.own ? row.runtimeDir ?? '' : '',
93
- cls: 'mono',
94
- onChange: (value) => void act('configure', { runtimeDir: value }),
95
- });
96
- dir.setAttribute('aria-label', S.runtimeDir);
97
- const dirRow = ui.rowbar();
98
- dirRow.append(dir, ui.button(S.browse, {
99
- size: 'sm',
100
- onClick: async () => {
101
- const picked = await ctx.pickPath({ kind: 'directory', title: S.runtimeDir, currentPath: dir.value });
102
- if (picked) await act('configure', { runtimeDir: picked });
103
- },
104
- }));
105
- body.append(
106
- ui.field(S.release, release),
107
- ui.msgline(S.releaseHint),
108
- ui.field(S.backend, backend),
109
- ui.field(S.runtimeDir, dirRow),
110
- ui.msgline(S.runtimeDirHint),
111
- ui.kv([{ k: S.installStatus, v: installPill }]),
112
- );
95
+ renderConfig(row, 0, body);
96
+ body.append(ui.kv([{ k: S.installStatus, v: installPill }]));
113
97
  if (row.smartAppControl === 1) body.append(ui.msgline(S.sacWarning, true));
114
98
  if (install.phase === 'downloading' || install.phase === 'extracting') {
115
99
  const progress = ui.progress({
@@ -133,28 +117,7 @@ export const runtimePanel: ConsolePanel = {
133
117
  installBar.append(ui.h('span', 'grow'), ui.button(S.disable, { onClick: () => void act('disable') }));
134
118
  body.append(installBar);
135
119
 
136
- body.append(ui.section(S.launchSection, S.launchSectionDesc));
137
- const launchRow = ui.rowbar();
138
- launchRow.append(
139
- launchNumber(row, 'contextSize', S.contextSize, 1),
140
- launchNumber(row, 'nGpuLayers', S.nGpuLayers, 0),
141
- launchNumber(row, 'parallel', S.parallel, 1),
142
- );
143
- const extraArgs = ui.input({
144
- value: row.launch?.extraArgs ?? '',
145
- cls: 'mono',
146
- onChange: (value) => void act('configure', { launch: { extraArgs: value } }),
147
- });
148
- extraArgs.setAttribute('aria-label', S.extraArgs);
149
- body.append(
150
- launchRow,
151
- ui.field(S.extraArgs, extraArgs),
152
- ui.msgline(S.extraArgsHint),
153
- ui.checkbox(S.autoStart, {
154
- checked: row.autoStart,
155
- onChange: (checked) => void act('configure', { autoStart: checked }),
156
- }).el,
157
- );
120
+ renderConfig(row, 1, body);
158
121
 
159
122
  body.append(ui.section(S.serverSection, S.serverSectionDesc));
160
123
  const server = row.server;
@@ -8,6 +8,9 @@ import type { ProviderConsoleHost } from '../../console/types.ts';
8
8
  import type { RouterCatalog, RouterModel } from '../catalog.ts';
9
9
  import { LAUNCH_DEFAULTS, PINNED_RELEASE, backendChoices, defaultBackend, llamacppOptions, type LaunchOptions } from '../options.ts';
10
10
  import type { LlamaRuntime } from '../runtime.ts';
11
+ import type { RuntimeState } from '../runtime.ts';
12
+ import { runtimeConfig } from '../config.ts';
13
+ import { getByPath, type ConfigGroup, type ConfigValues } from '../../../core/config-schema.ts';
11
14
  import { text } from '../strings.ts';
12
15
 
13
16
  interface Control {
@@ -23,6 +26,11 @@ export interface ModelsState {
23
26
  models: RouterModel[];
24
27
  }
25
28
 
29
+ export type RuntimePanelState = RuntimeState & {
30
+ name: string;
31
+ config: Array<{ group: ConfigGroup; values: ConfigValues }>;
32
+ };
33
+
26
34
  /** The launch fields the panel edits, each optional and applied over the stored values. */
27
35
  type LaunchPatch = Partial<Record<keyof LaunchOptions, unknown>>;
28
36
 
@@ -42,6 +50,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
42
50
  return found.entry;
43
51
  };
44
52
  return {
53
+ config: [],
45
54
  panels: [
46
55
  { id: 'runtime', title: S.runtimePanel, description: S.runtimePanelDescription, slot: 'instance' },
47
56
  { id: 'models', title: S.modelsPanel, description: S.modelsPanelDescription, slot: 'instance' },
@@ -50,7 +59,17 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
50
59
  if (panel === 'runtime') {
51
60
  const value = body(args);
52
61
  const name = value.name as string;
53
- if (method === 'state') return { name, ...(await control(name).runtime.state(host.language)) };
62
+ if (method === 'state') {
63
+ const entry = entryOf(name);
64
+ const prefix = `providers.${name}.`;
65
+ const config = runtimeConfig(name, entry, host.language).map((group) => ({
66
+ group,
67
+ values: Object.fromEntries(Object.keys(group.schema.properties).map((path) => [
68
+ path, getByPath(entry as unknown as Record<string, unknown>, path.slice(prefix.length)) ?? '',
69
+ ])) as ConfigValues,
70
+ }));
71
+ return { name, ...(await control(name).runtime.state(host.language)), config } satisfies RuntimePanelState;
72
+ }
54
73
  if (method === 'enable') {
55
74
  const entry = entryOf(name);
56
75
  const options = llamacppOptions(entry);
@@ -66,7 +85,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
66
85
  return { ok: true };
67
86
  }
68
87
  if (method === 'disable') {
69
- await control(name).runtime.stop(host.language);
88
+ if (!host.editing) await control(name).runtime.stop(host.language);
70
89
  const entry = entryOf(name);
71
90
  const { runtime: _runtime, launch: _launch, ...rest } = entry.options ?? {};
72
91
  host.save(name, { ...entry, options: rest });
@@ -96,6 +115,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
96
115
  });
97
116
  return { ok: true };
98
117
  }
118
+ if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再执行运行时操作。' : 'Save configuration before runtime operations.');
99
119
  if (method === 'install') {
100
120
  await control(name).runtime.install(host.language);
101
121
  return { ok: true };
@@ -120,6 +140,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
120
140
  await catalog.list(true);
121
141
  return { ok: true };
122
142
  }
143
+ if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再操作模型。' : 'Save configuration before model operations.');
123
144
  if (typeof value.model !== 'string' || !value.model.trim()) throw new Error(S.modelIdRequired);
124
145
  const model = value.model.trim();
125
146
  if (method === 'pull') await catalog.download(model);
@@ -4,6 +4,7 @@ import type { Language } from '../../core/language.ts';
4
4
  import { isContextOverflow } from '../transport/errors.ts';
5
5
  import { modelsRoot, runtimesRoot } from '../../paths.ts';
6
6
  import { RouterCatalog } from './catalog.ts';
7
+ import { runtimeConfig } from './config.ts';
7
8
  import { llamacppConsole } from './console/server.ts';
8
9
  import { LlamaCppProvider } from './native.ts';
9
10
  import { backendChoices, llamacppOptions, normalizeLlamaCpp } from './options.ts';
@@ -23,12 +24,14 @@ const reasoningTiers = (language: Language): ReasoningTier[] => {
23
24
  export default {
24
25
  id: 'llamacpp',
25
26
  title: 'llama.cpp',
27
+ description: 'Connect to or manage local llama.cpp models.',
26
28
  defaultBaseUrl: 'http://127.0.0.1:8090/v1',
27
29
  baseUrlSuggestions: ['http://127.0.0.1:8090/v1', 'http://127.0.0.1:8080/v1'],
28
30
  normalize: normalizeLlamaCpp,
29
31
  console: llamacppConsole,
32
+ config: runtimeConfig,
30
33
  reasoningTiers: reasoningTiers('zh'),
31
- localize: (language) => ({ reasoningTiers: reasoningTiers(language) }),
34
+ localize: (language) => ({ description: language === 'zh' ? '连接或托管本地 llama.cpp 模型。' : 'Connect to or manage local llama.cpp models.', reasoningTiers: reasoningTiers(language) }),
32
35
  serviceTiers: [],
33
36
  validateEntry: (entry, language) => {
34
37
  const S = text(language);
@@ -87,6 +90,7 @@ export default {
87
90
  apiKey,
88
91
  log: host.log,
89
92
  media: { enabled: () => entry.multimodal === true, read: host.readBlob },
93
+ keepThinking: host.keepThinking,
90
94
  }),
91
95
  };
92
96
  },