cortico 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +29 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +12 -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 +18 -6
  10. package/src/core/generation.ts +66 -9
  11. package/src/core/instance-lock.ts +6 -0
  12. package/src/core/loop.ts +62 -32
  13. package/src/core/secrets.ts +2 -4
  14. package/src/core/session.ts +3 -2
  15. package/src/core/timers.ts +8 -6
  16. package/src/core/types.ts +23 -4
  17. package/src/core/util.ts +15 -0
  18. package/src/deploy.ts +5 -4
  19. package/src/extensions/README.md +6 -4
  20. package/src/extensions/dry-mount.ts +34 -6
  21. package/src/extensions/manifest.ts +21 -11
  22. package/src/extensions.ts +147 -23
  23. package/src/launcher.ts +30 -22
  24. package/src/protocol/open-responses/context-helpers.ts +3 -1
  25. package/src/protocol/open-responses/context-log.ts +37 -9
  26. package/src/providers/README.md +44 -12
  27. package/src/providers/base.ts +2 -0
  28. package/src/providers/console/config.ts +36 -0
  29. package/src/providers/console/hub.ts +307 -0
  30. package/src/providers/console/settings.ts +30 -26
  31. package/src/providers/console/strings.ts +14 -0
  32. package/src/providers/console/types.ts +5 -0
  33. package/src/providers/hub-api.ts +3 -0
  34. package/src/providers/llamacpp/config.ts +38 -0
  35. package/src/providers/llamacpp/console/models-panel.ts +87 -4
  36. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  37. package/src/providers/llamacpp/console/server.ts +47 -4
  38. package/src/providers/llamacpp/huggingface.ts +93 -0
  39. package/src/providers/llamacpp/index.ts +5 -1
  40. package/src/providers/llamacpp/native.ts +10 -5
  41. package/src/providers/llamacpp/options.ts +2 -0
  42. package/src/providers/llamacpp/strings.ts +42 -0
  43. package/src/providers/name.ts +13 -0
  44. package/src/providers/openai-responses-compat/config.ts +33 -0
  45. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  46. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  47. package/src/providers/openai-responses-compat/console/server.ts +135 -0
  48. package/src/providers/openai-responses-compat/index.ts +40 -12
  49. package/src/providers/openai-responses-compat/native.ts +15 -5
  50. package/src/providers/openai-responses-compat/strings.ts +62 -1
  51. package/src/providers/pricebook.ts +5 -5
  52. package/src/providers/registry.ts +24 -10
  53. package/src/providers/strings.ts +2 -0
  54. package/src/providers/transport/errors.ts +9 -0
  55. package/src/providers/transport/response-http.ts +7 -3
  56. package/src/providers/transport/responses-input.ts +59 -10
  57. package/src/web/README.md +15 -3
  58. package/src/web/auth.ts +80 -0
  59. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  60. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  61. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
  62. package/src/web/client/console-pages/host.ts +22 -5
  63. package/src/web/client/core/api.ts +5 -1
  64. package/src/web/client/core/router.ts +15 -0
  65. package/src/web/client/features/extensions/index.ts +276 -41
  66. package/src/web/client/features/extensions/strings.ts +84 -10
  67. package/src/web/client/features/feature.ts +1 -1
  68. package/src/web/client/features/live/diagnostics.ts +32 -0
  69. package/src/web/client/features/live/index.ts +27 -11
  70. package/src/web/client/features/live/protocol.ts +1 -0
  71. package/src/web/client/features/live/strings.ts +9 -3
  72. package/src/web/client/features/providers/detail.ts +309 -0
  73. package/src/web/client/features/providers/drafts.ts +23 -0
  74. package/src/web/client/features/providers/index.ts +179 -87
  75. package/src/web/client/features/providers/strings.ts +48 -17
  76. package/src/web/client/features/providers/types.ts +15 -0
  77. package/src/web/client/features/settings/general.ts +12 -0
  78. package/src/web/client/features/settings/strings.ts +6 -0
  79. package/src/web/client/main.ts +4 -0
  80. package/src/web/client/shell/index.ts +1 -1
  81. package/src/web/client/ui/fields.ts +3 -1
  82. package/src/web/client/ui/icons.ts +9 -1
  83. package/src/web/client/ui/prompt-input.tsx +17 -5
  84. package/src/web/client/ui/strings.ts +0 -2
  85. package/src/web/diagnostics.ts +133 -0
  86. package/src/web/public/login.html +67 -0
  87. package/src/web/public/styles.css +130 -18
  88. package/src/web/server.ts +229 -21
  89. package/src/web/shared/client-panel.ts +3 -0
  90. package/src/web/shared/console-protocol.ts +16 -1
  91. package/src/worlds/bilibili/README.md +1 -1
  92. package/src/worlds/bilibili/overlay/server.ts +4 -2
  93. package/src/worlds/minecraft/ADAPT.md +66 -0
  94. package/src/worlds/minecraft/README.md +8 -0
  95. package/src/worlds/minecraft/client-launch.ts +11 -2
  96. package/src/worlds/minecraft/client.ts +4 -0
  97. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  98. package/src/worlds/qq/normalize.ts +14 -0
  99. package/src/worlds/qq/world.ts +56 -10
  100. package/src/worlds/terminal/world.ts +3 -4
package/src/extensions.ts CHANGED
@@ -14,8 +14,14 @@ import type { CoreConfig, Logger } from './core/types.ts';
14
14
  import type { BotDefinition } from './bot.ts';
15
15
  import type { WorldDefinition, WorldSection } from './world.ts';
16
16
  import type { ProviderModule } from './providers/base.ts';
17
- import type { ExtensionInfo, ExtensionInstallTarget, ExtensionSearchHit } from './web/server.ts';
17
+ import type {
18
+ ExtensionInfo,
19
+ ExtensionInstallTarget,
20
+ ExtensionPackageDetail,
21
+ ExtensionSearchHit,
22
+ } from './web/server.ts';
18
23
  import {
24
+ EXTENSION_API_VERSIONS,
19
25
  EXTENSION_KEYWORDS,
20
26
  parseExtensionManifest,
21
27
  type ExtensionConsoleAsset,
@@ -368,11 +374,59 @@ interface RegistrySearchResponse {
368
374
  description?: string;
369
375
  keywords?: string[];
370
376
  date?: string;
377
+ license?: string;
371
378
  links?: { npm?: string; homepage?: string; repository?: string };
372
379
  publisher?: { username?: string };
373
380
  };
381
+ /** 被依赖的包数。热门包回字符串,新包回数字。 */
382
+ dependents?: number | string;
374
383
  downloads?: { monthly?: number };
375
384
  }>;
385
+ total?: number;
386
+ }
387
+
388
+ /** registry 的包文档(`GET /<name>`)。只列我们要读的字段。 */
389
+ interface RegistryPackument {
390
+ 'dist-tags'?: Record<string, string>;
391
+ versions?: Record<string, ExtensionPackageJson & {
392
+ license?: string;
393
+ deprecated?: string;
394
+ engines?: Record<string, string>;
395
+ maintainers?: Array<{ username?: string; name?: string }>;
396
+ _npmUser?: { name?: string };
397
+ dist?: { unpackedSize?: number; fileCount?: number };
398
+ homepage?: string;
399
+ bugs?: { url?: string } | string;
400
+ repository?: { url?: string } | string;
401
+ }>;
402
+ /** `created`、`modified` 和每个版本号各一条发布时间 */
403
+ time?: Record<string, string>;
404
+ }
405
+
406
+ /** 一页取满 250:registry 的上限(传更大也只回 250)。 */
407
+ const SEARCH_PAGE_SIZE = 250;
408
+ /**
409
+ * 最多翻四页。`keywords:` 过滤后的全集现在是个位数;真涨到一千条,说明关键字被滥用,
410
+ * 再往后翻的也不是操作员要找的包。
411
+ */
412
+ const SEARCH_MAX_HITS = 1000;
413
+
414
+ /**
415
+ * registry 给的仓库地址是 npm 规范化过的 `git+https://….git`:浏览器不认这个 scheme。
416
+ * 收成可点的 https;认不出形状就原样返回,让操作员自己看。
417
+ */
418
+ export function repositoryWebUrl(raw: string): string {
419
+ let url = raw.trim().replace(/^git\+/, '').replace(/^git:\/\//, 'https://');
420
+ const ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/.exec(url);
421
+ if (ssh) url = `https://${ssh[1]}/${ssh[2]}`;
422
+ url = url.replace(/\.git$/, '');
423
+ return /^https?:\/\//.test(url) ? url : raw;
424
+ }
425
+
426
+ /** `{ url }` 或裸串两种写法都收。 */
427
+ function urlOf(v: { url?: string } | string | undefined): string | undefined {
428
+ const raw = typeof v === 'string' ? v : v?.url;
429
+ return raw && raw.trim() ? raw.trim() : undefined;
376
430
  }
377
431
 
378
432
  export interface ExtensionManagerOptions {
@@ -441,35 +495,105 @@ export class ExtensionManager {
441
495
  return { dir: this.dir, extensions: out };
442
496
  }
443
497
 
444
- async search(query: string, kind: ExtensionKind = 'world'): Promise<ExtensionSearchHit[]> {
498
+ /**
499
+ * 列出 npm 上带这一类关键字的全部包。**不接文本查询**:registry 的 `text=` 在
500
+ * `keywords:` 过滤之下只影响排序不缩小结果(实测 `keywords:cortico-world 任意词`
501
+ * 仍返回同样 8 条),筛选交给控制台在整份结果上做。
502
+ */
503
+ async search(kind: ExtensionKind = 'world'): Promise<ExtensionSearchHit[]> {
445
504
  const keyword = EXTENSION_KEYWORDS[kind];
446
- const text = `keywords:${keyword} ${query.trim()}`.trim();
447
- const url = `${this.registry}/-/v1/search?text=${encodeURIComponent(text)}&size=50`;
448
- const data = (await this.fetchJson(url)) as RegistrySearchResponse;
449
505
  const installed = new Set(readInstalled(this.dir).map((p) => p.name));
450
506
  const hits: ExtensionSearchHit[] = [];
451
- for (const obj of data.objects ?? []) {
452
- const p = obj.package;
453
- if (!p?.name || !p.version || !(p.keywords ?? []).includes(keyword)) continue;
454
- hits.push({
455
- name: p.name,
456
- version: p.version,
457
- description: p.description ?? '',
458
- kind,
459
- ...(p.date ? { date: p.date } : {}),
460
- ...(p.publisher?.username ? { publisher: p.publisher.username } : {}),
461
- downloads: obj.downloads?.monthly ?? 0,
462
- links: {
463
- ...(p.links?.npm ? { npm: p.links.npm } : {}),
464
- ...(p.links?.repository ? { repository: p.links.repository } : {}),
465
- ...(p.links?.homepage ? { homepage: p.links.homepage } : {}),
466
- },
467
- installed: installed.has(p.name),
468
- });
507
+ const seen = new Set<string>();
508
+ for (let from = 0; from < SEARCH_MAX_HITS; from += SEARCH_PAGE_SIZE) {
509
+ const text = encodeURIComponent(`keywords:${keyword}`);
510
+ const url = `${this.registry}/-/v1/search?text=${text}&size=${SEARCH_PAGE_SIZE}&from=${from}`;
511
+ const data = (await this.fetchJson(url)) as RegistrySearchResponse;
512
+ const objects = data.objects ?? [];
513
+ for (const obj of objects) {
514
+ const p = obj.package;
515
+ if (!p?.name || !p.version || !(p.keywords ?? []).includes(keyword)) continue;
516
+ if (seen.has(p.name)) continue;
517
+ seen.add(p.name);
518
+ const repository = p.links?.repository;
519
+ hits.push({
520
+ name: p.name,
521
+ version: p.version,
522
+ description: p.description ?? '',
523
+ kind,
524
+ ...(p.date ? { date: p.date } : {}),
525
+ ...(p.publisher?.username ? { publisher: p.publisher.username } : {}),
526
+ ...(p.license ? { license: p.license } : {}),
527
+ ...(p.keywords?.length ? { keywords: p.keywords } : {}),
528
+ downloads: obj.downloads?.monthly ?? 0,
529
+ dependents: Number(obj.dependents) || 0,
530
+ links: {
531
+ ...(p.links?.npm ? { npm: p.links.npm } : {}),
532
+ ...(repository ? { repository: repositoryWebUrl(repository) } : {}),
533
+ ...(p.links?.homepage ? { homepage: p.links.homepage } : {}),
534
+ },
535
+ installed: installed.has(p.name),
536
+ });
537
+ }
538
+ if (objects.length < SEARCH_PAGE_SIZE) break;
469
539
  }
470
540
  return hits;
471
541
  }
472
542
 
543
+ /**
544
+ * 一个包的详情。控制台在操作员点开某张卡片时才调,因此这里取整份包文档
545
+ * (`GET /<name>`,比搜索结果多出 cortico 声明、许可证、体积与版本史)。
546
+ * readme 不回传:一份 30KB 以上的 markdown,控制台也不渲染它。
547
+ */
548
+ async packageInfo(name: string): Promise<ExtensionPackageDetail> {
549
+ if (!PACKAGE_NAME.test(name)) throw new Error(`不是合法的 npm 包名: ${name}`);
550
+ const doc = (await this.fetchJson(`${this.registry}/${name.replace('/', '%2F')}`)) as RegistryPackument;
551
+ const latest = doc['dist-tags']?.latest;
552
+ const v = latest ? doc.versions?.[latest] : undefined;
553
+ if (!latest || !v) throw new Error(`registry 没有给出 ${name} 的 latest 版本`);
554
+
555
+ const parsed = parseExtensionManifest(v);
556
+ const times = Object.entries(doc.time ?? {}).filter(([k]) => k !== 'created' && k !== 'modified');
557
+ const history = times
558
+ .sort((a, b) => (a[1] < b[1] ? 1 : -1))
559
+ .slice(0, 6)
560
+ .map(([version, date]) => ({ version, date }));
561
+ const spec = readInstalled(this.dir).find((p) => p.name === name)?.spec;
562
+ const repository = urlOf(v.repository);
563
+ const bugs = urlOf(v.bugs);
564
+
565
+ return {
566
+ name,
567
+ version: latest,
568
+ ...(v.description ? { description: v.description } : {}),
569
+ ...(v.license ? { license: v.license } : {}),
570
+ ...(v.keywords?.length ? { keywords: v.keywords } : {}),
571
+ ...(doc.time?.[latest] ? { published: doc.time[latest] } : {}),
572
+ ...(doc.time?.created ? { created: doc.time.created } : {}),
573
+ versionCount: times.length,
574
+ history,
575
+ ...(v.deprecated ? { deprecated: v.deprecated } : {}),
576
+ ...(parsed.ok
577
+ ? { manifest: parsed.manifest, frameworkApi: EXTENSION_API_VERSIONS[parsed.manifest.kind] }
578
+ : { problems: parsed.reasons }),
579
+ warnings: parsed.warnings,
580
+ ...(v.engines?.node ? { engines: v.engines.node } : {}),
581
+ ...(v.dist?.unpackedSize ? { unpackedSize: v.dist.unpackedSize } : {}),
582
+ ...(v.dist?.fileCount ? { fileCount: v.dist.fileCount } : {}),
583
+ dependencies: Object.keys(v.dependencies ?? {}),
584
+ maintainers: (v.maintainers ?? []).map((m) => m.username ?? m.name ?? '').filter(Boolean),
585
+ ...(v._npmUser?.name ? { publisher: v._npmUser.name } : {}),
586
+ links: {
587
+ npm: `https://www.npmjs.com/package/${name}`,
588
+ ...(repository ? { repository: repositoryWebUrl(repository) } : {}),
589
+ ...(v.homepage ? { homepage: v.homepage } : {}),
590
+ ...(bugs ? { bugs } : {}),
591
+ },
592
+ installed: spec !== undefined,
593
+ ...(spec !== undefined ? { installedSpec: spec } : {}),
594
+ };
595
+ }
596
+
473
597
  async install(target: ExtensionInstallTarget): Promise<string> {
474
598
  const spec = this.installSpec(target);
475
599
  if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true });
package/src/launcher.ts CHANGED
@@ -13,7 +13,7 @@ import { createBot } from './bot.ts';
13
13
  import { createDeployment, ensureDeployment, listBots, loadDeployment } from './deploy.ts';
14
14
  import { buildListing, type BotDefaults } from './deploy-listing.ts';
15
15
  import { secretReader } from './core/secrets.ts';
16
- import { announceDataDir, consumeBootFlags } from './boot.ts';
16
+ import { announceDataDir, consoleUrlOf, consumeBootFlags, listensOnEveryInterface, startsPaused, providerAtBoot } from './boot.ts';
17
17
  import { extensionsDir, importBotDefinition, loadExtensions, locateBotPackage, readInstalled, type ActiveBotPackage } from './extensions.ts';
18
18
  import { withWorlds } from './world.ts';
19
19
  import { BUILTIN_WORLDS } from './worlds/index.ts';
@@ -160,39 +160,47 @@ async function main(): Promise<void> {
160
160
  cfg.logging.file = levelArg as LogLevel;
161
161
  }
162
162
 
163
+ // 监听地址与端口:--host= / --port= 只覆盖本次运行,不写回 config.json。
164
+ const hostArg = flagValue('host');
165
+ if (hostArg !== null) {
166
+ if (!hostArg.trim()) { console.error('--host 需要一个地址,如 0.0.0.0'); process.exit(1); }
167
+ cfg.web.host = hostArg.trim();
168
+ }
169
+ const portArg = flagValue('port');
170
+ if (portArg !== null) {
171
+ const port = Number(portArg);
172
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
173
+ console.error(`无效端口: ${portArg}(0–65535 的整数,0 由系统分配)`);
174
+ process.exit(1);
175
+ }
176
+ cfg.web.port = port;
177
+ }
178
+
163
179
  // 重启标志必须在装配与 session 加载之前处理
164
180
  consumeBootFlags(loaded.dataDir);
165
181
 
166
- const activeProvider = cfg.providers?.[cfg.activeProvider];
167
- if (!activeProvider) {
168
- console.error(
169
- `activeProvider="${cfg.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(cfg.providers ?? {}).join(' / ') || '无'})`,
170
- );
171
- process.exit(1);
172
- }
173
- const endpointDir = providerDir(cfg.activeProvider);
182
+ const activeProvider = providerAtBoot(cfg);
183
+ const endpointDir = activeProvider ? providerDir(cfg.activeProvider) : null;
174
184
  const missingSecret =
175
- activeProvider.secret && !secretReader(resolve(endpointDir, '.env'))(activeProvider.secret)
185
+ activeProvider?.secret && endpointDir && !secretReader(resolve(endpointDir, '.env'))(activeProvider.secret)
176
186
  ? activeProvider.secret
177
187
  : null;
178
188
 
179
189
  const bot = createBot(loaded, definition, { extensions });
180
190
 
181
191
  // 必须在启动主循环前暂停,避免首批事件提前投递。
182
- const startPaused =
183
- process.env.CORTICO_START_PAUSED === '1' ||
184
- process.env.CORTICO_START_PAUSED === 'true' ||
185
- process.argv.includes('--paused');
186
- if (startPaused) bot.core.bus.setPaused(true);
192
+ const paused = startsPaused();
193
+ if (paused) bot.core.bus.setPaused(true);
187
194
 
188
195
  const { port } = await bot.start();
189
196
 
190
197
  console.log(`\n Bot: ${botName}${cfg.displayName && cfg.displayName !== botName ? ` (${cfg.displayName})` : ''}`);
198
+ const consoleUrl = port === null ? null : consoleUrlOf(bot.webApp?.boundAddress ?? null, port);
191
199
  if (port !== null) {
192
- console.log(` 控制台: http://127.0.0.1:${port}/`);
193
- const assetsProblem = webAssetsProblem(fileURLToPath(new URL('../dist/web', import.meta.url)));
200
+ console.log(` 控制台: ${consoleUrl}${listensOnEveryInterface(bot.webApp?.boundAddress ?? null) ? `(监听 ${bot.webApp?.boundAddress},所有网卡)` : ''}`);
201
+ const assetsProblem = webAssetsProblem(fileURLToPath(new URL('..', import.meta.url)));
194
202
  if (assetsProblem) {
195
- console.log(` ⚠ 控制台产物不完整(${assetsProblem});停止 bot 后运行 pnpm build:web`);
203
+ console.log(` ⚠ 控制台产物需要重建(${assetsProblem});停止 bot 后运行 pnpm build:web`);
196
204
  }
197
205
  }
198
206
  for (const slot of bot.assembly.slots) {
@@ -208,13 +216,13 @@ async function main(): Promise<void> {
208
216
  ? ` 扩展: ${ext.name}@${ext.version} · bot 包,本部署未引用`
209
217
  : ` 扩展: ${ext.name} 未加载: ${ext.reason}`);
210
218
  }
211
- console.log(` 主模型: ${bot.core.mainSessionSpec().model}`);
212
- if (missingSecret) {
219
+ console.log(` 主模型: ${activeProvider?.spec?.model || '未配置;请在控制台「模型供应商」页选择连接与模型'}`);
220
+ if (missingSecret && endpointDir) {
213
221
  const envFile = resolve(endpointDir, '.env');
214
222
  const where = existsSync(envFile) ? `${envFile} 里也没有` : `${envFile} 不存在`;
215
223
  console.log(` ⚠ 缺少 ${missingSecret}:进程环境里没有,${where};可在控制台「模型提供商」页修改密钥变量名或补填密钥`);
216
224
  }
217
- if (startPaused) {
225
+ if (paused) {
218
226
  console.log(' ⏸ 已暂停');
219
227
  }
220
228
  console.log('');
@@ -223,7 +231,7 @@ async function main(): Promise<void> {
223
231
  process.env.CORTICO_OPEN_BROWSER === '1' ||
224
232
  process.env.CORTICO_OPEN_BROWSER === 'true' ||
225
233
  process.argv.includes('--open');
226
- if (openBrowserFlag && port !== null) openBrowser(`http://127.0.0.1:${port}/`);
234
+ if (openBrowserFlag && consoleUrl !== null) openBrowser(consoleUrl);
227
235
 
228
236
  // 此时限覆盖整个关机流程;各步骤的时限由 bot.shutdown() 管理。
229
237
  const SHUTDOWN_GRACE_MS = 35_000;
@@ -12,7 +12,9 @@ export function withoutPastReasoning(entries: readonly ContextRecord[]): Context
12
12
  }
13
13
  export function responseRequest(spec: ModelSpec, context: readonly ContextRecord[], tools: readonly ToolSchema[] = []): Request {
14
14
  return {
15
- model: spec.model, input: context.map(inputItem), tools: tools.map(tool => ({ ...tool, type: 'function' })),
15
+ model: spec.model, input: context.map(inputItem),
16
+ // 只有协议声明的成员进线;运行时传进来的对象还带着分类标签,它归控制台与工具装配。
17
+ tools: tools.map(({ name, description, parameters }) => ({ type: 'function' as const, name, description, parameters })),
16
18
  // effort 词表归端点(见 ModelSpec.reasoningEffort);协议枚举只覆盖 OpenAI 自己的取值。
17
19
  reasoning: spec.thinking ? { ...(spec.reasoningEffort ? { effort: spec.reasoningEffort as Schemas['ReasoningEffortEnum'] } : {}) } : { effort: 'none' },
18
20
  ...(spec.temperature !== undefined ? { temperature: spec.temperature } : {}),
@@ -1,4 +1,4 @@
1
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, truncateSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import type { ContextRecord } from './context.ts';
4
4
 
@@ -10,6 +10,20 @@ function freeze<T>(value: T): T {
10
10
  return value;
11
11
  }
12
12
 
13
+ function isRecord(row: unknown): row is ContextRecord {
14
+ const r = row as ContextRecord | null;
15
+ return !!r && r.version === 2 && !!r.item && typeof r.item === 'object' && !Array.isArray(r.item)
16
+ && !!r.context && typeof r.context === 'object' && !Array.isArray(r.context);
17
+ }
18
+
19
+ /**
20
+ * The repair `load()` applied to a final line that has no trailing newline. A line that does not
21
+ * parse is cut off; a complete record only gets its newline.
22
+ */
23
+ export type ContextLoadRepair =
24
+ | { kind: 'torn-tail'; bytes: number }
25
+ | { kind: 'unterminated-tail' };
26
+
13
27
  /** Immutable standard Items with runtime metadata stored in a separate field. */
14
28
  export class ContextLog {
15
29
  private entries: readonly ContextRecord[] = Object.freeze([]);
@@ -19,18 +33,32 @@ export class ContextLog {
19
33
 
20
34
  get records(): readonly ContextRecord[] { return this.entries; }
21
35
 
22
- load(): void {
23
- if (!existsSync(this.file)) { this.entries = Object.freeze([]); return; }
24
- const rows = readFileSync(this.file, 'utf8').split(/\r?\n/).filter(line => line.trim()).map((line, index) => {
25
- let row: ContextRecord;
26
- try { row = JSON.parse(line) as ContextRecord; }
36
+ /** Any other damaged line throws and leaves the file untouched. */
37
+ load(): ContextLoadRepair | null {
38
+ if (!existsSync(this.file)) { this.entries = Object.freeze([]); return null; }
39
+ const raw = readFileSync(this.file, 'utf8');
40
+ let body = raw;
41
+ let repair: ContextLoadRepair | null = null;
42
+ if (raw.length > 0 && !raw.endsWith('\n')) {
43
+ const cut = raw.lastIndexOf('\n') + 1;
44
+ const tail = raw.slice(cut);
45
+ let parsed: unknown = null;
46
+ try { parsed = JSON.parse(tail); } catch { /* torn */ }
47
+ if (isRecord(parsed)) repair = { kind: 'unterminated-tail' };
48
+ else { body = raw.slice(0, cut); repair = { kind: 'torn-tail', bytes: Buffer.byteLength(tail, 'utf8') }; }
49
+ }
50
+ const rows = body.split(/\r?\n/).filter(line => line.trim()).map((line, index) => {
51
+ let row: unknown;
52
+ try { row = JSON.parse(line); }
27
53
  catch { throw new Error(`Invalid session JSON at ${this.file}:${index + 1}`); }
28
- if (!row || row.version !== 2 || !row.item || typeof row.item !== 'object' || Array.isArray(row.item)
29
- || !row.context || typeof row.context !== 'object' || Array.isArray(row.context))
30
- throw new Error(`Invalid context record at ${this.file}:${index + 1}`);
54
+ if (!isRecord(row)) throw new Error(`Invalid context record at ${this.file}:${index + 1}`);
31
55
  return freeze(row);
32
56
  });
57
+ // Written only after every kept line validated.
58
+ if (repair?.kind === 'torn-tail') truncateSync(this.file, Buffer.byteLength(body, 'utf8'));
59
+ else if (repair) appendFileSync(this.file, '\n');
33
60
  this.entries = Object.freeze(rows);
61
+ return repair;
34
62
  }
35
63
 
36
64
  append(entry: ContextRecord): ContextRecord {
@@ -1,4 +1,4 @@
1
- <!-- Owner: src/providers/base.ts, src/providers/registry.ts -->
1
+ <!-- Owner: src/providers/base.ts, src/providers/console/hub.ts, src/providers/hub-api.ts, src/providers/name.ts, src/providers/registry.ts, src/providers/console/settings.ts, src/providers/console/config.ts, src/providers/openai-responses-compat/config.ts, src/providers/transport/responses-input.ts, src/providers/transport/history.ts, src/providers/llamacpp/native.ts -->
2
2
 
3
3
  # src/providers
4
4
 
@@ -22,20 +22,23 @@
22
22
  ## ProviderModule
23
23
 
24
24
  `id` 必须等于目录名(扩展包里则是包声明的 id)。必填:`title`、`reasoningTiers`(空表 = 开放,
25
- effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可选:`defaultBaseUrl` 与
25
+ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可选:`description`、`defaultBaseUrl` 与
26
26
  `baseUrlSuggestions`、`effortSuggestions`、`temperatureNote`、`localize()`、`normalize()`、
27
27
  `validateEntry()`、`validateModel()`、`accepts()`(多模态判定)、`config()` 与 `console()`
28
28
  (附加配置组与面板)、`prices()`、`estimateTokens()`、`contextOverflow()`。
29
29
 
30
- 地址、密钥变量名与图像开关由控制台的端点面板编辑,模块不为它们声明配置组。模块自己的
31
- `options.*` 要么走 `config()` 的配置组,要么由 `console()` 声明一块挂进 `instance` 插槽的面板——
32
- 内建 llamacpp 的运行时与模型两段走的是后者。
30
+ 地址、密钥变量名与图像开关不归模块:框架在 `console/config.ts` 里为每个端点声明这一组,
31
+ 扩展来的模块照样有。模块自己的 `options.*` `config()` 的配置组,模块的面板作为端点编辑页的段落
32
+ 用控制台的 schema 渲染器画同一份声明、经 `ctx.setConfig` 暂存到连接草稿——内建 llamacpp 的运行时与启动
33
+ 两段走的是这条。段落顺序由 `console()` 的 `panels` 定:编辑页自带的四块(`console/config.ts` 的
34
+ `connectionBlocks`)与模块自己的面板排成一列。`console()` 显式给空 `config` 表示这一页不另开配置页签,
35
+ 声明仍参与服务端校验。启停与模型操作这类动作走面板 invoke。
33
36
 
34
37
  `create()` 返回 `ProviderInstance`:`client`(实现 `respond`)、`listModels?`、`control?`、
35
38
  `compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`。
36
39
 
37
40
  `ProviderHost` 给实例:`stateDir`(`<部署根>/providers/<端点名>/`,归实例独占)、`repoRoot`、
38
- `resource()`、`currentEntry()`、`secret(name)`(进程环境优先,否则读 `stateDir/.env` 一次)、
41
+ `resource()`、`currentEntry()`、`secret(name)`(进程环境优先,否则现读 `stateDir/.env`)、
39
42
  `readBlob()`、`keepThinking()`、`log`。
40
43
 
41
44
  ## 注册与解析
@@ -44,10 +47,11 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可
44
47
  之前通过 `registerProviderModules()` 注册,id 重复时抛错。
45
48
 
46
49
  `ProviderRegistry.resolve(name)`:按 `entry.kind` 找模块,`normalize`,以去掉 `pricing` 与
47
- `spec` 的条目 JSON 为缓存键(改模型或价格不重建实例),填 `stateDir` 与 `secret`。
50
+ `spec` 的条目 JSON 加端点 `.env` 的内容指纹为缓存键(改模型或价格不重建实例,密钥文件变更后
51
+ 下一次解析重建),填 `stateDir` 与 `secret`。
48
52
  `bind(name)` 在实例外包一层:注入 `quote`(报价快照)与 `origin`(实例、模块、模型、
49
53
  `compatibilityDomain` = sha256(kind + baseUrl + `compatibilityKey()`))。Responses 历史推理仅在实例、
50
- 模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存;写入 `.env` 后必须调用,每个实例仅缓存一次文件内容。
54
+ 模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存,控制台保存端点后调用;`.env` 变更经缓存键在下一次解析生效。
51
55
 
52
56
  ## 配置形状
53
57
 
@@ -62,14 +66,18 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
62
66
  ## transport
63
67
 
64
68
  `response-http.ts` 处理 HTTP/SSE:一次生成可包含多次请求尝试,重试间隔为 `[1s, 4s, 10s]`;超时
65
- 四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 429 / 5xx 重试,
66
- 401 / 403 先 `transport.refresh()` 一次;已提交不可逆增量后不再重试;输出字符超过
69
+ 四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 408 / 429 / 5xx
70
+ 重试,响应带 `Retry-After` 时按它退避,401 / 403 先 `transport.refresh()`
71
+ 一次;已提交不可逆增量后不再重试;输出字符超过
67
72
  `max_output_tokens × 12` 时终止请求并报告超限;终态只接受 `completed` / `incomplete`,`failed` 抛
68
73
  `LLMError`;每次请求尝试记录 `meters` 与 `charges`。
69
74
 
70
75
  `responses-input.ts` 是原生 Responses 的无状态重放(system / developer 上提为
71
- `instructions`);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史
72
- 思维链不回传);`response-assembly.ts` 把两种流归一成 Open Responses Item 流,
76
+ `instructions`;思维链按 `reasoningReplay` 以签名块或 `reasoning_text` 回传,明文形态下没有记录来源
77
+ 的调用前补一项合成推理);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史
78
+ 思维链回不回传由模块定:`renderMessagesWithMedia` 的 `keepReasoning` 决定出不出线,`dropPastThinking`
79
+ 把合成开头之外的每条抹空);
80
+ `response-assembly.ts` 把两种流归一成 Open Responses 的 Item 流,
73
81
  `finish_reason` 的 `length` / `content_filter` 落成 `incomplete_details.reason`;
74
82
  `response-meters.ts` 把两种 usage 归一成 `TokenMeters`,缺项保持 null。
75
83
 
@@ -83,3 +91,27 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
83
91
  模块默认,快照带 sha256 id 与 `capturedAt`。计量键:`input` / `output` / `total` /
84
92
  `cachedInput` / `uncachedInput` / `reasoning` 与 `detail:*`,单价按每百万 token。实际扣费在
85
93
  `src/core/generation.ts` 的 `priceUsage()`,缺计量记 `amount: null`。
94
+
95
+ ## 端点配置接口
96
+
97
+ `console/hub.ts` 的 `ProviderHub` 是控制台改端点的唯一入口,`hub-api.ts` 把它接到
98
+ `/api/providers`(聚合、按名读取、保存、删除、设为当前、测试、模型列表)与
99
+ `/api/provider-modules`(已注册模块)。列表保留 `kind` 没有对应模块的端点,active 与可用性
100
+ 分别报告;状态帧的 `modelConnection` 带当前端点的名称、模型、模块与地址。
101
+
102
+ 新名称由 `name.ts` 校验:英文字母、数字、`-` 或 `_`,首字符是字母或数字,不收空格与 Windows
103
+ 设备名——名字就是 `<部署根>/providers/` 下的目录名。已在磁盘上的名字不改就继续有效。保存带
104
+ 读取时的 revision(端点 `config.json` 与 `.env` 的 sha256),对不上返回 409。共享目录上的写锁
105
+ `.write-lock` 串行化各进程的写入。改名同时改目录和部署根内各 `deployment.json` 的
106
+ `activeProvider`,中途失败回滚已动过的文件和目录;还被引用的端点不能删。
107
+
108
+ 保存一次校验模型、连接与模块配置,再写 `config.json` 和可选密钥;密钥进该端点的 `.env`,读取
109
+ 接口不返回密钥值。写完清掉本进程的实例缓存,已绑定的请求与 fork 保持原客户端;其他进程在下次
110
+ 重读共享配置时跟上。
111
+
112
+ `ProviderConsoleHost.editing` 表示这个实例来自还没保存的表单,模块据此拒绝运行时副作用,
113
+ `host.save` 把配置交回浏览器暂存。`ProviderRegistry.previewRegistry` 按一份条目造注册表,实例不进
114
+ 运行实例缓存,浏览器刚输入的密钥作为覆盖值排在进程环境与端点 `.env` 之前;测试与模型列表带
115
+ `{ entry, secretValue }` 时走它。
116
+
117
+ 连接列表的 usage 包含其他部署的选用记录及运行锁状态,排除当前部署。运行状态按部署配置的 paths.data(缺省使用 Core 默认值)检查实例锁,不代表供应商正在处理请求,也不建立供应商独占锁。
@@ -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;
@@ -0,0 +1,36 @@
1
+ import type { ConfigGroup } from '../../core/config-schema.ts';
2
+ import type { LLMProviderEntry } from '../../core/types.ts';
3
+ import type { Language } from '../../core/language.ts';
4
+ import type { ConsolePanelDecl } from '../../web/shared/console-protocol.ts';
5
+ import { text } from './strings.ts';
6
+
7
+ /**
8
+ * The editor's own sections with their default wording. A module lists them among its panels,
9
+ * in the order its workflow wants, and may override title and description.
10
+ */
11
+ export function connectionBlocks(language: Language): Record<'endpoint' | 'model' | 'pricing' | 'protocol', ConsolePanelDecl> {
12
+ const S = text(language);
13
+ return {
14
+ endpoint: { id: 'endpoint', title: S.endpointBlock, description: S.endpointBlockDescription, builtin: 'connection-endpoint' },
15
+ model: { id: 'model', title: S.modelBlock, description: S.modelBlockDescription, builtin: 'connection-model' },
16
+ pricing: { id: 'pricing', title: S.pricingBlock, builtin: 'connection-pricing' },
17
+ protocol: { id: 'protocol', title: S.protocolBlock, description: S.protocolBlockDescription, builtin: 'connection-protocol' },
18
+ };
19
+ }
20
+
21
+ export function connectionGroup(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup {
22
+ const S = text(language);
23
+ const prefix = `providers.${name}.`;
24
+ return {
25
+ id: `llm.${entry.kind}.${name}.connection`,
26
+ owner: `provider:${entry.kind}`,
27
+ schema: {
28
+ type: 'object', title: name, description: S.connectionDescription,
29
+ properties: {
30
+ [`${prefix}baseUrl`]: { type: 'string', title: S.baseUrl, 'x-hot': true },
31
+ [`${prefix}secret`]: { type: 'string', title: S.secret, description: S.secretDescription, 'x-hot': true },
32
+ [`${prefix}multimodal`]: { type: 'boolean', title: S.multimodal, 'x-hot': true },
33
+ },
34
+ },
35
+ };
36
+ }