cortico 0.1.0 → 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 (112) hide show
  1. package/README.md +7 -14
  2. package/package.json +1 -3
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +41 -6
  5. package/src/core/README.md +4 -6
  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 +19 -3
  14. package/src/core/util.ts +17 -1
  15. package/src/deploy.ts +4 -1
  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 -8
  23. package/src/providers/base.ts +3 -1
  24. package/src/providers/configuration.ts +2 -1
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +8 -18
  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 +8 -6
  30. package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
  31. package/src/providers/llamacpp/console/server.ts +3 -1
  32. package/src/providers/llamacpp/index.ts +3 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/strings.ts +6 -6
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +13 -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 +38 -13
  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 +11 -6
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
  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 +6 -8
  50. package/src/web/client/console-pages/host.ts +24 -7
  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/core/strings.ts +2 -2
  54. package/src/web/client/features/extensions/index.ts +276 -41
  55. package/src/web/client/features/extensions/strings.ts +84 -10
  56. package/src/web/client/features/feature.ts +2 -2
  57. package/src/web/client/features/live/diagnostics.ts +32 -0
  58. package/src/web/client/features/live/index.ts +34 -11
  59. package/src/web/client/features/live/onboarding.ts +4 -1
  60. package/src/web/client/features/live/protocol.ts +1 -0
  61. package/src/web/client/features/live/strings.ts +20 -14
  62. package/src/web/client/features/live/timeline.ts +2 -1
  63. package/src/web/client/features/providers/detail.ts +262 -0
  64. package/src/web/client/features/providers/drafts.ts +23 -0
  65. package/src/web/client/features/providers/index.ts +173 -87
  66. package/src/web/client/features/providers/strings.ts +44 -17
  67. package/src/web/client/features/providers/types.ts +15 -0
  68. package/src/web/client/features/settings/general.ts +13 -0
  69. package/src/web/client/features/settings/index.ts +1 -0
  70. package/src/web/client/features/settings/strings.ts +6 -0
  71. package/src/web/client/features/worlds/index.ts +1 -0
  72. package/src/web/client/main.ts +4 -0
  73. package/src/web/client/shell/index.ts +28 -48
  74. package/src/web/client/shell/strings.ts +0 -22
  75. package/src/web/client/ui/icons.ts +14 -1
  76. package/src/web/client/ui/prompt-input.tsx +17 -5
  77. package/src/web/client/ui/strings.ts +0 -2
  78. package/src/web/console-pages.ts +1 -1
  79. package/src/web/diagnostics.ts +133 -0
  80. package/src/web/public/login.html +67 -0
  81. package/src/web/public/styles.css +143 -26
  82. package/src/web/server.ts +233 -19
  83. package/src/web/shared/client-panel.ts +4 -1
  84. package/src/web/shared/console-protocol.ts +4 -1
  85. package/src/worlds/bilibili/README.md +1 -1
  86. package/src/worlds/bilibili/overlay/server.ts +4 -2
  87. package/src/worlds/minecraft/ADAPT.md +66 -0
  88. package/src/worlds/minecraft/README.md +69 -11
  89. package/src/worlds/minecraft/cell-facts.ts +226 -0
  90. package/src/worlds/minecraft/chests.ts +5 -0
  91. package/src/worlds/minecraft/containers.ts +325 -0
  92. package/src/worlds/minecraft/entity-facts.ts +31 -5
  93. package/src/worlds/minecraft/executor.ts +283 -10348
  94. package/src/worlds/minecraft/inventory.ts +268 -0
  95. package/src/worlds/minecraft/melee.ts +419 -0
  96. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  97. package/src/worlds/minecraft/placed-ledger.ts +152 -0
  98. package/src/worlds/minecraft/placement.ts +1038 -0
  99. package/src/worlds/minecraft/receipt.ts +340 -0
  100. package/src/worlds/minecraft/skill-context.ts +358 -0
  101. package/src/worlds/minecraft/skills-build.ts +1169 -0
  102. package/src/worlds/minecraft/skills-container.ts +1343 -0
  103. package/src/worlds/minecraft/skills-craft.ts +333 -0
  104. package/src/worlds/minecraft/skills-dig.ts +624 -0
  105. package/src/worlds/minecraft/skills-gather.ts +1230 -0
  106. package/src/worlds/minecraft/skills-interact.ts +1559 -0
  107. package/src/worlds/minecraft/tools.ts +331 -0
  108. package/src/worlds/minecraft/travel.ts +763 -0
  109. package/src/worlds/minecraft/until.ts +75 -0
  110. package/src/worlds/qq/normalize.ts +14 -0
  111. package/src/worlds/qq/world.ts +53 -7
  112. package/src/worlds/terminal/world.ts +5 -3
@@ -60,7 +60,7 @@ export interface ProviderInstance {
60
60
  contextWindow?(model: string): number | undefined;
61
61
  }
62
62
 
63
- /** 端点是否满足本地配置条件;不探测远端可达性或生成结果。 */
63
+ /** 一个端点此刻能不能发起生成。判断只看本地状态,不连上游。 */
64
64
  export interface ProviderAvailability {
65
65
  ready: boolean;
66
66
  /** 不可用的原因,控制台语言;可用时不带。 */
@@ -70,6 +70,7 @@ export interface ProviderAvailability {
70
70
  export interface ProviderModule {
71
71
  id: string;
72
72
  title: string;
73
+ description?: string;
73
74
  defaultBaseUrl?: string;
74
75
  /** Candidate endpoint URLs offered on the URL field. Candidates only: any URL is accepted. */
75
76
  baseUrlSuggestions?: readonly string[];
@@ -83,6 +84,7 @@ export interface ProviderModule {
83
84
  * Absent (or a field left out) = the tables above are shown as written.
84
85
  */
85
86
  localize?(language: Language): {
87
+ description?: string;
86
88
  reasoningTiers?: readonly ReasoningTier[];
87
89
  serviceTiers?: readonly ServiceTier[];
88
90
  temperatureNote?: string;
@@ -47,7 +47,8 @@ export function validateSpec(
47
47
  }
48
48
 
49
49
  /**
50
- * 返回第一项不满足的本地条件;通用条件满足后才询问模块。
50
+ * 端点能不能用:选了模型、声明的密钥读得到,模块自己的条件也满足。
51
+ * 通用条件不满足就不问模块,第一条不满足的就是回给操作员的那句话。
51
52
  */
52
53
  export function endpointAvailability(
53
54
  module: ProviderModule,
@@ -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,15 +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
30
  import { connectionGroup } from './config.ts';
31
31
 
32
32
  export type SecretStatus = 'env' | 'file' | 'none';
33
33
 
34
- /** Output token limit used by the connectivity probe. */
35
- const PROBE_MAX_OUTPUT_TOKENS = 256;
36
-
37
- /** 从磁盘加载的密钥变量名按字面匹配。 */
34
+ /** 密钥变量名由操作员自由填写,也可能是直接写在磁盘上的任意名字,拼进正则前按字面转义。 */
38
35
  function escapeRegExp(s: string): string {
39
36
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
37
  }
@@ -250,6 +247,7 @@ export class ProviderSettings {
250
247
  })) satisfies ConsolePageSource[];
251
248
  }
252
249
 
250
+ /** 端点能不能用:通用条件由框架查,模块自己的条件由模块答。 */
253
251
  availability(name: string, language: Language = 'zh'): ProviderAvailability {
254
252
  const entry = this.config.providers[name];
255
253
  if (!entry) return { ready: false, reason: text(language).unknownInstance };
@@ -263,7 +261,8 @@ export class ProviderSettings {
263
261
  }
264
262
 
265
263
  /**
266
- * 汇总端点的本地可用性,附第一个可用端点或最后一个不可用原因。
264
+ * 这批端点里有没有一个能用。灯亮=有;悬停说明给出第一个可用的端点名,
265
+ * 或者最后一个端点的不可用原因。
267
266
  */
268
267
  private availableLamp(
269
268
  entries: ReadonlyArray<{ name: string }>,
@@ -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
  {
@@ -385,7 +375,7 @@ export class ProviderSettings {
385
375
  const name = body.name;
386
376
  if (method === 'create') {
387
377
  this.assertNewName(name, language);
388
- // 未设置端点价目覆盖,报价仍可来自模块。
378
+ // 报价留空:不覆盖模块价目;模块也没给价目时,这条端点的调用只记 token,不记金额。
389
379
  this.save(name, {
390
380
  kind: module.id,
391
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'>;
@@ -7,26 +7,28 @@ import { panel } from './strings.ts';
7
7
  export function runtimeConfig(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup[] {
8
8
  if (!llamacppOptions(entry).runtime) return [];
9
9
  const S = panel[language];
10
- const group = (id: string, title: string, properties: Record<string, ConfigProperty>): ConfigGroup => ({
10
+ const group = (
11
+ id: string, title: string, description: string, properties: Record<string, ConfigProperty>,
12
+ ): ConfigGroup => ({
11
13
  id: `llm.llamacpp.${name}.${id}`,
12
14
  owner: 'provider:llamacpp',
13
15
  schema: {
14
- type: 'object', title,
16
+ type: 'object', title, description,
15
17
  properties: Object.fromEntries(Object.entries(properties).map(([path, property]) => [
16
18
  `providers.${name}.options.${path}`, { ...property, 'x-hot': true },
17
19
  ])),
18
20
  },
19
21
  });
20
22
  return [
21
- group('runtime', S.runtimeSection, {
23
+ group('runtime', S.runtimeSection, S.runtimeSectionDesc, {
22
24
  'runtime.release': { type: 'string', title: S.release, description: S.releaseHint },
23
- 'runtime.backend': { type: 'string', title: S.backend, enum: backendChoices() },
25
+ 'runtime.backend': { type: 'string', title: S.backend, enum: backendChoices(), description: S.backendHint },
24
26
  'runtime.runtimeDir': {
25
27
  type: 'string', title: S.runtimeDir, description: S.runtimeDirHint, 'x-path': { kind: 'directory' },
26
28
  },
27
29
  }),
28
- group('launch', S.launchSection, {
29
- 'launch.contextSize': { type: 'integer', title: S.contextSize, minimum: 1, description: S.launchSectionDesc },
30
+ group('launch', S.launchSection, S.launchSectionDesc, {
31
+ 'launch.contextSize': { type: 'integer', title: S.contextSize, minimum: 1 },
30
32
  'launch.nGpuLayers': { type: 'integer', title: S.nGpuLayers, minimum: 0 },
31
33
  'launch.parallel': { type: 'integer', title: S.parallel, minimum: 1 },
32
34
  'launch.extraArgs': { type: 'string', title: S.extraArgs, description: S.extraArgsHint },
@@ -59,12 +59,16 @@ export const runtimePanel: ConsolePanel = {
59
59
 
60
60
  function renderConfig(row: Row, index: number, body: HTMLElement): void {
61
61
  const { group, values } = row.config[index];
62
- body.append(ui.section(group.schema.title));
62
+ body.append(ui.section(group.schema.title, group.schema.description));
63
63
  for (const [path, property] of Object.entries(group.schema.properties)) {
64
64
  const field = configField(ui, property, values[path], () => {
65
65
  if (field.read) void saveConfig(group.id, { [path]: field.read() });
66
66
  }, ctx.signal);
67
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
+ }
68
72
  body.append(ui.field(property.title, field.node));
69
73
  if (property.description) body.append(ui.msgline(property.description));
70
74
  }
@@ -85,7 +85,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
85
85
  return { ok: true };
86
86
  }
87
87
  if (method === 'disable') {
88
- await control(name).runtime.stop(host.language);
88
+ if (!host.editing) await control(name).runtime.stop(host.language);
89
89
  const entry = entryOf(name);
90
90
  const { runtime: _runtime, launch: _launch, ...rest } = entry.options ?? {};
91
91
  host.save(name, { ...entry, options: rest });
@@ -115,6 +115,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
115
115
  });
116
116
  return { ok: true };
117
117
  }
118
+ if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再执行运行时操作。' : 'Save configuration before runtime operations.');
118
119
  if (method === 'install') {
119
120
  await control(name).runtime.install(host.language);
120
121
  return { ok: true };
@@ -139,6 +140,7 @@ export function llamacppConsole(host: ProviderConsoleHost): Partial<ConsolePageC
139
140
  await catalog.list(true);
140
141
  return { ok: true };
141
142
  }
143
+ if (host.editing) throw new Error(host.language === 'zh' ? '请先保存配置,再操作模型。' : 'Save configuration before model operations.');
142
144
  if (typeof value.model !== 'string' || !value.model.trim()) throw new Error(S.modelIdRequired);
143
145
  const model = value.model.trim();
144
146
  if (method === 'pull') await catalog.download(model);
@@ -24,13 +24,14 @@ const reasoningTiers = (language: Language): ReasoningTier[] => {
24
24
  export default {
25
25
  id: 'llamacpp',
26
26
  title: 'llama.cpp',
27
+ description: 'Connect to or manage local llama.cpp models.',
27
28
  defaultBaseUrl: 'http://127.0.0.1:8090/v1',
28
29
  baseUrlSuggestions: ['http://127.0.0.1:8090/v1', 'http://127.0.0.1:8080/v1'],
29
30
  normalize: normalizeLlamaCpp,
30
31
  console: llamacppConsole,
31
32
  config: runtimeConfig,
32
33
  reasoningTiers: reasoningTiers('zh'),
33
- localize: (language) => ({ reasoningTiers: reasoningTiers(language) }),
34
+ localize: (language) => ({ description: language === 'zh' ? '连接或托管本地 llama.cpp 模型。' : 'Connect to or manage local llama.cpp models.', reasoningTiers: reasoningTiers(language) }),
34
35
  serviceTiers: [],
35
36
  validateEntry: (entry, language) => {
36
37
  const S = text(language);
@@ -89,6 +90,7 @@ export default {
89
90
  apiKey,
90
91
  log: host.log,
91
92
  media: { enabled: () => entry.multimodal === true, read: host.readBlob },
93
+ keepThinking: host.keepThinking,
92
94
  }),
93
95
  };
94
96
  },
@@ -2,23 +2,26 @@
2
2
  * Chat Completions against llama-server. Thinking is the template's `enable_thinking` switch
3
3
  * sent per request through `chat_template_kwargs`; templates without that variable ignore it.
4
4
  * The server returns the chain of thought as `reasoning_content` (`--reasoning-format deepseek`),
5
- * which the shared Chat assembly already maps; past thinking is not replayed.
5
+ * which the shared Chat assembly already maps; history sends it back on every request.
6
+ * `keepPastThinking` off blanks every turn but the synthetic opening; thinking off sends none.
6
7
  */
7
8
  import type { NativeChatMessage } from '../transport/native-types.ts';
8
9
  import type { ModelSpec, ToolSchema, Logger } from '../../core/types.ts';
9
10
  import { nullLogger } from '../../core/util.ts';
10
11
  import { OpenAIHttpClient } from '../transport/chat.ts';
11
- import { mapTools, renderMessagesWithMedia, type CompatMediaOptions } from '../transport/history.ts';
12
+ import { dropPastThinking, mapTools, renderMessagesWithMedia, type CompatMediaOptions } from '../transport/history.ts';
12
13
 
13
14
  export function buildLlamaCppRequestBody(
14
15
  spec: ModelSpec,
15
16
  messages: NativeChatMessage[],
16
17
  tools?: ToolSchema[],
17
18
  media?: CompatMediaOptions,
19
+ opts?: { keepThinking?: boolean },
18
20
  ): Record<string, unknown> {
21
+ const history = opts?.keepThinking === false ? dropPastThinking(messages) : messages;
19
22
  const body: Record<string, unknown> = {
20
23
  model: spec.model,
21
- messages: renderMessagesWithMedia(messages, media),
24
+ messages: renderMessagesWithMedia(history, media, { keepReasoning: spec.thinking }),
22
25
  chat_template_kwargs: { enable_thinking: spec.thinking },
23
26
  };
24
27
  if (spec.thinking && spec.reasoningEffort) body.reasoning_effort = spec.reasoningEffort;
@@ -32,15 +35,17 @@ export function buildLlamaCppRequestBody(
32
35
  export class LlamaCppProvider extends OpenAIHttpClient {
33
36
  private readonly apiKey?: string;
34
37
  private readonly media?: CompatMediaOptions;
38
+ private readonly keepThinking?: () => boolean;
35
39
 
36
- constructor(opts: { baseUrl: string; apiKey?: string; log?: Logger; media?: CompatMediaOptions }) {
40
+ constructor(opts: { baseUrl: string; apiKey?: string; log?: Logger; media?: CompatMediaOptions; keepThinking?: () => boolean }) {
37
41
  super(opts.baseUrl, opts.log ?? nullLogger());
38
42
  this.apiKey = opts.apiKey;
39
43
  this.media = opts.media;
44
+ this.keepThinking = opts.keepThinking;
40
45
  }
41
46
 
42
47
  protected buildBody(spec: ModelSpec, messages: NativeChatMessage[], tools?: ToolSchema[]): Record<string, unknown> {
43
- return buildLlamaCppRequestBody(spec, messages, tools, this.media);
48
+ return buildLlamaCppRequestBody(spec, messages, tools, this.media, { keepThinking: this.keepThinking?.() });
44
49
  }
45
50
 
46
51
  protected headers(): Record<string, string> {
@@ -101,9 +101,9 @@ const panelZh = {
101
101
  '启动失败时可在 Windows 安全中心核对拦截记录;若确认被拦截,请使用符合信任要求的运行时。',
102
102
  server: '服务进程',
103
103
  serverSection: '服务进程与端点',
104
- serverSectionDesc: '',
104
+ serverSectionDesc: 'llama-server 本地服务管理与监听状态。',
105
105
  runtimeSection: '二进制运行环境',
106
- runtimeSectionDesc: '',
106
+ runtimeSectionDesc: '官方 llama.cpp 二进制包与运行依赖管理。',
107
107
  releaseHint: '上游 release tag,如 b10930;换了要重新下载。',
108
108
  backendHint: '按本机平台给出的官方构建;CUDA 版自动搭配上游的 cudart 包。',
109
109
  runtimeDirHint: '填了就不下载:目录里要有 llama-server 及其动态库(自编译、Linux CUDA、签过名的构建)。',
@@ -141,7 +141,7 @@ const panelZh = {
141
141
  pullPlaceholder: 'HuggingFace 仓库,如 ggml-org/Qwen2.5-Coder-7B-Instruct-GGUF',
142
142
  localDir: '本机 GGUF 目录',
143
143
  cacheDir: '缓存目录',
144
- noModels: '暂无 GGUF 模型。可下载模型,或将 .gguf 文件放入本机目录后重新扫描。',
144
+ noModels: '暂无可用的 GGUF 模型:可通过上方输入拉取,或将 .gguf 文件放入本机目录后点击重扫。',
145
145
  modelStatusLabel: {
146
146
  loaded: '已加载',
147
147
  loading: '加载中',
@@ -175,9 +175,9 @@ const panelEn: typeof panelZh = {
175
175
  'If startup fails, check Windows Security for a block record. If a block is confirmed, use a runtime that meets the trust requirements.',
176
176
  server: 'Server',
177
177
  serverSection: 'Server Process & Endpoint',
178
- serverSectionDesc: '',
178
+ serverSectionDesc: 'llama-server local process and listening status.',
179
179
  runtimeSection: 'Binary Runtime Environment',
180
- runtimeSectionDesc: '',
180
+ runtimeSectionDesc: 'Official llama.cpp binary and dependencies.',
181
181
  releaseHint: 'Upstream release tag, such as b10930; changing it downloads another build.',
182
182
  backendHint: 'Official builds for this platform; CUDA builds are paired with the upstream cudart package.',
183
183
  runtimeDirHint: 'When set nothing is downloaded: the directory holds llama-server and its shared libraries (self-built, Linux CUDA, signed builds).',
@@ -215,7 +215,7 @@ const panelEn: typeof panelZh = {
215
215
  pullPlaceholder: 'HuggingFace repo, e.g. ggml-org/Qwen2.5-Coder-7B-Instruct-GGUF',
216
216
  localDir: 'Local GGUF directory',
217
217
  cacheDir: 'Cache directory',
218
- noModels: 'No GGUF models. Download a model, or place GGUF files in the local directory and rescan.',
218
+ noModels: 'No models yet: pull one above, or drop GGUF files into the local directory and rescan.',
219
219
  modelStatusLabel: {
220
220
  loaded: 'loaded',
221
221
  loading: 'loading',
@@ -0,0 +1,8 @@
1
+ /** New connection names are portable directory names; unchanged historical keys are exempt. */
2
+ export function validateProviderName(name: string): string | null {
3
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name))
4
+ return 'Use English letters, digits, - or _; start with a letter or digit. Spaces are not allowed.';
5
+ if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(name))
6
+ return 'This name is reserved by Windows.';
7
+ return null;
8
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ConfigGroup } from '../../core/config-schema.ts';
2
2
  import type { LLMProviderEntry } from '../../core/types.ts';
3
3
  import type { Language } from '../../core/language.ts';
4
+ import { REASONING_REPLAYS, SYNTHETIC_REASONING_TEXT } from '../transport/responses-input.ts';
4
5
  import { text } from './strings.ts';
5
6
 
6
7
  export function protocolConfig(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup[] {
@@ -11,9 +12,21 @@ export function protocolConfig(name: string, entry: LLMProviderEntry, language:
11
12
  schema: {
12
13
  type: 'object', title: name,
13
14
  properties: {
15
+ [`providers.${name}.options.extraHeaders`]: {
16
+ type: 'object', title: language === 'zh' ? '附加请求头(JSON object)' : 'Extra headers (JSON object)',
17
+ },
18
+ [`providers.${name}.options.extraBody`]: {
19
+ type: 'object', title: language === 'zh' ? '附加请求体(JSON object)' : 'Extra request body (JSON object)',
20
+ },
14
21
  [`providers.${name}.options.endpointPath`]: {
15
22
  type: 'string', title: S.endpointPath, description: S.endpointPathDescription, 'x-hot': true,
16
23
  },
24
+ [`providers.${name}.options.reasoningReplay`]: {
25
+ type: 'string', enum: [...REASONING_REPLAYS], title: S.reasoningReplay, description: S.reasoningReplayDescription, 'x-hot': true,
26
+ },
27
+ [`providers.${name}.options.syntheticReasoningText`]: {
28
+ type: 'string', title: S.syntheticReasoningText, description: S.syntheticReasoningTextDescription(SYNTHETIC_REASONING_TEXT), 'x-hot': true,
29
+ },
17
30
  },
18
31
  },
19
32
  }];
@@ -0,0 +1,5 @@
1
+ import type { ConsoleClientBundle } from '../../../web/shared/client-panel.ts';
2
+ import { reasoningPanel } from './reasoning-panel.ts';
3
+
4
+ // The endpoint table (`builtin: 'llm-settings'`) is the console's own; only the reasoning section ships here.
5
+ export default { panels: { reasoning: reasoningPanel } } satisfies ConsoleClientBundle;