cortico 0.1.2 → 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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/boot.ts +11 -0
  3. package/src/core/README.md +8 -0
  4. package/src/core/core.ts +3 -1
  5. package/src/core/generation.ts +66 -9
  6. package/src/core/instance-lock.ts +6 -0
  7. package/src/core/loop.ts +7 -5
  8. package/src/core/secrets.ts +2 -4
  9. package/src/core/types.ts +5 -2
  10. package/src/extensions/dry-mount.ts +16 -9
  11. package/src/extensions/manifest.ts +1 -1
  12. package/src/launcher.ts +8 -14
  13. package/src/protocol/open-responses/context-helpers.ts +3 -1
  14. package/src/providers/README.md +16 -9
  15. package/src/providers/console/config.ts +15 -0
  16. package/src/providers/console/hub.ts +49 -12
  17. package/src/providers/console/settings.ts +20 -8
  18. package/src/providers/console/strings.ts +14 -0
  19. package/src/providers/llamacpp/console/models-panel.ts +87 -4
  20. package/src/providers/llamacpp/console/server.ts +24 -2
  21. package/src/providers/llamacpp/huggingface.ts +93 -0
  22. package/src/providers/llamacpp/strings.ts +42 -0
  23. package/src/providers/name.ts +5 -0
  24. package/src/providers/openai-responses-compat/console/server.ts +9 -1
  25. package/src/providers/openai-responses-compat/native.ts +8 -3
  26. package/src/providers/pricebook.ts +5 -5
  27. package/src/providers/registry.ts +21 -12
  28. package/src/providers/strings.ts +2 -0
  29. package/src/providers/transport/errors.ts +9 -0
  30. package/src/providers/transport/response-http.ts +7 -3
  31. package/src/web/README.md +3 -0
  32. package/src/web/client/console-pages/host.ts +10 -10
  33. package/src/web/client/features/providers/detail.ts +184 -137
  34. package/src/web/client/features/providers/index.ts +48 -42
  35. package/src/web/client/features/providers/strings.ts +22 -18
  36. package/src/web/client/ui/fields.ts +3 -1
  37. package/src/web/public/styles.css +28 -20
  38. package/src/web/server.ts +4 -2
  39. package/src/web/shared/console-protocol.ts +13 -0
  40. package/src/worlds/minecraft/client-launch.ts +11 -2
  41. package/src/worlds/minecraft/client.ts +4 -0
  42. package/src/worlds/qq/world.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "A TypeScript framework for event-driven, long-running persona bots",
6
6
  "license": "MIT",
package/src/boot.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { existsSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
+ import type { CoreConfig, LLMProviderEntry } from './core/types.ts';
7
8
 
8
9
  export const RESTART_FLAG_FILE = '.restart-request';
9
10
 
@@ -62,3 +63,13 @@ export function consoleUrlOf(bound: string | null, port: number): string {
62
63
  const host = bound === null || listensOnEveryInterface(bound) ? '127.0.0.1' : bound.includes(':') ? `[${bound}]` : bound;
63
64
  return `http://${host}:${port}/`;
64
65
  }
66
+
67
+ /** 空选择允许进入控制台完成配置;非空名称必须引用已有连接。 */
68
+ export function providerAtBoot(config: Pick<CoreConfig, 'activeProvider' | 'providers'>): LLMProviderEntry | undefined {
69
+ if (!config.activeProvider) return undefined;
70
+ const entry = config.providers[config.activeProvider];
71
+ if (!entry) {
72
+ throw new Error(`activeProvider="${config.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(config.providers).join(' / ') || '无'})`);
73
+ }
74
+ return entry;
75
+ }
@@ -34,6 +34,9 @@ Core 管理 session、事件流与模型调用的生命周期,包括事件投
34
34
  `sessionDecls`;`spawnFork`、`resolveBlob` / `internBlobs`、`setWorldVisible`、`activeSpec` /
35
35
  `activeProviderEntry`、`mountWorld` / `unmountWorld`、`start` / `stop`。
36
36
 
37
+ `WorldHost.modelFacts` 每次调用按当前端点读取。`activeProvider` 为空或端点没有模型时 `accepts`
38
+ 返回 false,`activeProvider` 指向不存在的端点时抛错。
39
+
37
40
  Persona 通过 `CoreApi` 访问:`injectInternal` / `injectDeferred` / `injectExternal`、
38
41
  `requestContextHandoff`、`spawnFork`、`sessionInfo`、`llm`、`timers`、`deliveryGate`、
39
42
  `personaState` / `savePersonaState`、`toolsTagged`、`blob`、`log`。
@@ -62,6 +65,9 @@ piggyback 只入队,随后续唤醒一起投递。
62
65
  退回总线,进入下一批。控制台的前缀重载与清空 session 在批次边界执行:正在处理批次时等该批
63
66
  结束,空闲时立即;并发请求复用同一事务。
64
67
 
68
+ 一批正文归档后,Core 调用 `Persona.onDelivery` 并等待它返回的 Promise,不设期限。完成前
69
+ `injectInternal` 的内容排在这批的内部行末尾、外部正文之前;完成后的注入进入总线。
70
+
65
71
  状态 0、429 或 5xx 的模型调用失败,可保留已记录的输出和工具回执,按 `ResubmitPolicy` 重试。
66
72
  默认允许连续重试 2 次、每批最多 4 次,退避为 2 秒、10 秒;上下文超限、抢占、关机或轮数
67
73
  达到硬上限时不重试。
@@ -88,3 +94,5 @@ handler 异常转为失败回执。流式生成时 `EagerDispatch` 可提前执
88
94
  warn;`onHandoff` 异常记录 error 并使用默认交接策略。其他钩子的异常由调用方处理。
89
95
  读取 `console()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 error
90
96
  并停止定时器。启动器处理未捕获的进程异常并执行关机流程。
97
+
98
+ `instanceIsRunning(dataDir)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定,不获取或更改锁。
package/src/core/core.ts CHANGED
@@ -468,8 +468,10 @@ export class Core<C extends CoreConfig = CoreConfig> {
468
468
  return {
469
469
  model: () => spec().model,
470
470
  accepts: (mime) => {
471
+ if (!this.loaded.config.activeProvider) return false;
471
472
  const {entry} = this.activeProviderEntry();
472
- return providerModule(entry.kind).accepts?.(entry,spec(),mime) ?? (entry.multimodal === true && mime.startsWith('image/'));
473
+ if (!entry.spec) return false;
474
+ return providerModule(entry.kind).accepts?.(entry,entry.spec,mime) ?? (entry.multimodal === true && mime.startsWith('image/'));
473
475
  },
474
476
  contextWindow: () => this.contextWindowOf(spec()),
475
477
  };
@@ -16,13 +16,26 @@ export interface TokenMeters {
16
16
  }
17
17
  export type Meter = Exclude<keyof TokenMeters, 'native' | 'details'> | `detail:${string}`;
18
18
  export interface PriceRule { meter: Meter; perMillion: number; unit?: string; }
19
- export interface PriceSnapshot {
19
+ export interface PriceSchedule { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }>; }
20
+ export interface PriceTable extends PriceSchedule { serviceTiers?: Record<string, PriceSchedule>; }
21
+ /**
22
+ * `from` and `to` are `HH:MM` on the clock of `timezone`, half-open; `to` at or before `from` spans midnight.
23
+ * A span belongs to the local date it starts on. `weekdays` (ISO, 1 = Monday to 7 = Sunday) limits the dates
24
+ * that open it; `exceptDates` (`YYYY-MM-DD`) removes single dates.
25
+ */
26
+ export interface PriceWindow extends PriceTable {
27
+ from: string;
28
+ to: string;
29
+ timezone: string;
30
+ weekdays?: number[];
31
+ exceptDates?: string[];
32
+ }
33
+ export interface PriceSnapshot extends PriceTable {
20
34
  id: string;
21
35
  currency: string;
22
36
  basis: 'marginal' | 'equivalent';
23
- rules: PriceRule[];
24
- inputBands?: Array<{ from: number; rules: PriceRule[] }>;
25
- serviceTiers?: Record<string, { rules: PriceRule[]; inputBands?: Array<{ from: number; rules: PriceRule[] }> }>;
37
+ /** The first window holding `capturedAt` replaces the table above it; outside every window that table applies. */
38
+ timeWindows?: PriceWindow[];
26
39
  source: string;
27
40
  capturedAt: string;
28
41
  }
@@ -30,7 +43,7 @@ export interface Charge {
30
43
  quote: PriceSnapshot;
31
44
  amount: number | null;
32
45
  knownAmount: number;
33
- missing: Array<Meter | 'serviceTier'>;
46
+ missing: Array<Meter | 'serviceTier' | 'timeWindow'>;
34
47
  lines: Array<{ meter: Meter; unit: string; quantity: number | null; perMillion: number; amount: number | null }>;
35
48
  }
36
49
  export interface ProviderAttempt {
@@ -89,13 +102,57 @@ export function standardUsage(meters: TokenMeters): Usage | null {
89
102
  return { input_tokens: input, output_tokens: output, total_tokens: total,
90
103
  input_tokens_details: { cached_tokens: cachedInput }, output_tokens_details: { reasoning_tokens: reasoning } };
91
104
  }
105
+ /** Minutes since midnight for an `HH:MM` bound, 24:00 included; null when the text is not one. */
106
+ function clockMinutes(value: string): number | null {
107
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value);
108
+ if (!match) return null;
109
+ const minutes = Number(match[1]) * 60 + Number(match[2]);
110
+ return Number(match[2]) > 59 || minutes > 24 * 60 ? null : minutes;
111
+ }
112
+ /** Whether the text is a real calendar date written `YYYY-MM-DD`. */
113
+ function isCalendarDate(value: string): boolean {
114
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
115
+ if (!match) return false;
116
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
117
+ const date = new Date(Date.UTC(year, month - 1, day));
118
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
119
+ }
120
+ /** Whether the instant falls in the window on the window's own clock and date; null when a field is unreadable. */
121
+ function holdsTime(window: PriceWindow, at: string): boolean | null {
122
+ const instant = new Date(at);
123
+ const [from, to] = [clockMinutes(window.from), clockMinutes(window.to)];
124
+ if (from === null || to === null || Number.isNaN(instant.getTime())) return null;
125
+ if (window.weekdays?.some(day => !Number.isInteger(day) || day < 1 || day > 7)) return null;
126
+ if (window.exceptDates?.some(date => !isCalendarDate(date))) return null;
127
+ let year: number, month: number, day: number, local: number;
128
+ try {
129
+ // Intl throws on a zone name it does not know.
130
+ const parts = new Intl.DateTimeFormat('en-GB', { timeZone: window.timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(instant);
131
+ const read = (type: string): number => Number(parts.find(part => part.type === type)!.value);
132
+ [year, month, day] = [read('year'), read('month'), read('day')];
133
+ local = read('hour') * 60 + read('minute');
134
+ } catch { return null; }
135
+ const spansMidnight = to <= from;
136
+ if (spansMidnight ? local < from && local >= to : local < from || local >= to) return false;
137
+ // After midnight the span still belongs to the previous date, the one it started on.
138
+ const start = new Date(Date.UTC(year, month - 1, spansMidnight && local < to ? day - 1 : day));
139
+ if (window.weekdays && !window.weekdays.includes(start.getUTCDay() || 7)) return false;
140
+ return !window.exceptDates?.includes(start.toISOString().slice(0, 10));
141
+ }
92
142
  export function priceUsage(meters: TokenMeters, quotes: readonly PriceSnapshot[], serviceTier: string | null = null): Charge[] {
93
143
  return quotes.map(quote => {
94
144
  const missing: Charge['missing'] = [];
95
- let schedule: Pick<PriceSnapshot, 'rules' | 'inputBands'> = quote;
96
- if (quote.serviceTiers) {
97
- if (serviceTier === null || !quote.serviceTiers[serviceTier]) missing.push('serviceTier');
98
- else schedule = quote.serviceTiers[serviceTier];
145
+ let table: PriceTable = quote;
146
+ for (const window of quote.timeWindows ?? []) {
147
+ const holds = holdsTime(window, quote.capturedAt);
148
+ // An unreadable window may be the one holding the request; a later table cannot stand in for it.
149
+ if (holds === null) { missing.push('timeWindow'); break; }
150
+ if (holds) { table = window; break; }
151
+ }
152
+ let schedule: PriceSchedule = table;
153
+ if (table.serviceTiers) {
154
+ if (serviceTier === null || !table.serviceTiers[serviceTier]) missing.push('serviceTier');
155
+ else schedule = table.serviceTiers[serviceTier];
99
156
  }
100
157
  let rules = schedule.rules;
101
158
  if (schedule.inputBands?.length) {
@@ -72,6 +72,12 @@ function readPayload(file: string): LockPayload | null {
72
72
  }
73
73
  }
74
74
 
75
+ /** 只读检查数据目录的锁是否仍对应存活进程。 */
76
+ export function instanceIsRunning(dataDir: string): boolean {
77
+ const payload = readPayload(join(dataDir, INSTANCE_LOCK_FILE));
78
+ return payload !== null && ownerStillRunning(payload);
79
+ }
80
+
75
81
  /** 排他创建;仅 EEXIST 返回 false,其他错误抛出。 */
76
82
  function createExclusive(file: string, payload: LockPayload): boolean {
77
83
  let fd: number;
package/src/core/loop.ts CHANGED
@@ -251,7 +251,7 @@ export class MainLoop {
251
251
  /** 当前正在处理一个事件批;手动交接必须等到该批自然结束,不能重置半轮session。 */
252
252
  private processingBatch = false;
253
253
  private handoffRequested = false;
254
- /** onDelivery 同步执行期间,injectInternal 的即时项加入当前批,不经过总线。 */
254
+ /** onDelivery 执行期间(含其 Promise 完成前),injectInternal 的即时项加入当前批,不经过总线。 */
255
255
  private deliveryCollector: EventEnvelope[] | null = null;
256
256
  /** 已投递但前面仍有外部缺口的游标;水位只越过连续前缀。 */
257
257
  private readonly deliveredCursors = new Set<number>();
@@ -484,7 +484,7 @@ export class MainLoop {
484
484
  /**
485
485
  * 将一批事件写入主 session。即时事件在前,候选生成内容与延迟渲染内容在后。
486
486
  * 候选按 source、origin 和处理函数分组,再按来源项在批次中的顺序生成正文。
487
- * 正文归档后调用 onDelivery;同步注入的内部项追加到内部行末尾、外部正文之前。
487
+ * 正文归档后调用并等待 onDelivery;钩子完成前注入的内部项追加到内部行末尾、外部正文之前。
488
488
  * 内部行合成一条 user 消息;外部正文按 eventDelivery 进入合成工具回执或同一条 user 消息。
489
489
  */
490
490
  private async deliverBatch(batch: WakeItem[], generation: number): Promise<boolean> {
@@ -537,11 +537,13 @@ export class MainLoop {
537
537
  }
538
538
  if (!this.active(generation)) return false;
539
539
  if (delivered.length > 0) {
540
- // 仅捕获同步钩子内的即时注入;退出钩子后恢复总线投递。
540
+ // 捕获钩子完成前的即时注入;钩子完成后恢复总线投递。
541
541
  const injected: EventEnvelope[] = [];
542
542
  this.deliveryCollector = injected;
543
543
  try {
544
- persona.onDelivery?.({ events: [...delivered] });
544
+ // 同步钩子不经过 await,收集范围仍只是钩子本身的执行期。
545
+ const pending = persona.onDelivery?.({ events: [...delivered] });
546
+ if (pending) await pending;
545
547
  } catch (e) {
546
548
  log.warn('onDelivery钩子异常', { err: e });
547
549
  } finally {
@@ -1663,7 +1665,7 @@ export class MainLoop {
1663
1665
  return this.stallAt.filter((t) => t >= from).length;
1664
1666
  }
1665
1667
 
1666
- /** 注入 Persona 提供的内部文本。onDelivery 同步执行期间加入当前批,其余时刻进入总线。 */
1668
+ /** 注入 Persona 提供的内部文本。onDelivery 完成前加入当前批,其余时刻进入总线。 */
1667
1669
  injectInternal(text: string, kind = 'notice'): void {
1668
1670
  if (!this.activeNow()) return;
1669
1671
  const item = this.internalItem('persona', kind, text);
@@ -3,16 +3,14 @@ import { existsSync } from 'node:fs';
3
3
  import { readTextFile } from './util.ts';
4
4
 
5
5
  /**
6
- * 每次优先读取非空进程环境变量;否则使用首次读取后缓存的文件内容,缺失返回空串。
6
+ * 每次优先读取非空进程环境变量;否则现读文件,缺失返回空串。
7
7
  * 文件值读取到首个空白字符,不解析引号。
8
8
  */
9
9
  export function secretReader(file: string): (name: string) => string {
10
- let text: string | null = null;
11
10
  return (name: string): string => {
12
11
  const fromEnv = process.env[name];
13
12
  if (fromEnv) return fromEnv;
14
- if (text === null) text = existsSync(file) ? readTextFile(file) : '';
15
- const m = new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm').exec(text);
13
+ const m = new RegExp(`^\\s*${name}\\s*=\\s*(\\S+)`, 'm').exec(existsSync(file) ? readTextFile(file) : '');
16
14
  return m ? m[1] : '';
17
15
  };
18
16
  }
package/src/core/types.ts CHANGED
@@ -551,6 +551,7 @@ export interface DeliveryGateApi {
551
551
  /** 运行时查询当前模型的名称、MIME 支持和上下文窗口;配置热改后读取新值。 */
552
552
  export interface ModelFacts {
553
553
  model(): string;
554
+ /** 未选端点或端点未选模型时返回 false;activeProvider 指向不存在的端点时抛错。 */
554
555
  accepts(mime: string): boolean;
555
556
  /** provider 探测值与手动配置取较小者,单位 token;两者均未知时返回 undefined。 */
556
557
  contextWindow(): number | undefined;
@@ -910,9 +911,11 @@ export interface Persona {
910
911
  onOpening?(ctx: { reason: SessionOpeningReason }): void;
911
912
  /**
912
913
  * 一批事件已渲染并分配游标、尚未进入上下文时调用;events 按投递序包含内部和外部事件。
913
- * injectInternal 在此钩子内注入的内容加入当前批,排在已有内部行之后、外部正文之前;钩子阻塞投递。
914
+ * 返回 Promise 时 Core 等它完成再投递,不设期限:钩子不完成,主循环不前进,钩子自己发起的
915
+ * 外部调用由 Persona 负责超时。完成前 Persona 的所有 injectInternal 都加入当前批,排在已有
916
+ * 内部行之后、外部正文之前。钩子抛错或拒绝时 Core 记录 warn,带着已注入的内容照常投递。
914
917
  */
915
- onDelivery?(ctx: { events: EventEnvelope[] }): void;
918
+ onDelivery?(ctx: { events: EventEnvelope[] }): void | Promise<void>;
916
919
  /**
917
920
  * 一批事件处理结束时调用;Persona 可查询 sessionInfo 并决定是否请求交接。
918
921
  * Core 在超过 hardTokens 时强制交接。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 在临时部署中检查扩展构造与声明接口,不调用 start()。
3
3
  * World 使用默认配置、无密钥,检查 create、tools、envPromptVars、console、工具名冲突与配置路径;
4
- * provider 使用测试端点 createbot 使用测试部署 build。
4
+ * provider 使用测试端点 create 并检查配置路径,bot 使用测试部署 build。
5
5
  * World 构造失败为错误;provider 仅在绑定端点时构造,测试条目不完整导致的失败记为警告。
6
6
  * 临时文件位于 scratchDir,调用方负责创建与清理。
7
7
  */
@@ -40,17 +40,18 @@ const isPlainObject = (v: unknown): v is Record<string, unknown> =>
40
40
  const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
41
41
 
42
42
  /**
43
- * 声明过的每个配置路径都要在 `defaults()` 里有对应项,且落在 prefix 指的段内。
43
+ * 声明过的每个配置路径都要落在 prefix 指的段内;给了 `defaults` 时还要在里面有对应项。
44
44
  * 缺默认值时控制台照样渲染旋钮、也照样写回 config.json,而读到的是代码里另一处的兜底值。
45
+ * provider 的旋钮写进端点条目而不是 `defaults()`,所以只核对段。
45
46
  */
46
- function configPathProblems(group: ConfigGroup, defaults: Record<string, unknown>, prefix: string): string[] {
47
+ function configPathProblems(group: ConfigGroup, prefix: string, defaults?: Record<string, unknown>): string[] {
47
48
  const problems: string[] = [];
48
49
  for (const path of Object.keys(group.schema.properties ?? {})) {
49
50
  if (!path.startsWith(prefix)) {
50
51
  problems.push(`配置组「${group.id}」声明的「${path}」不在 ${prefix} 段里:写回按路径走,值会落到别人的段上。`);
51
52
  continue;
52
53
  }
53
- if (getByPath(defaults, path.slice(prefix.length)) === undefined) {
54
+ if (defaults && getByPath(defaults, path.slice(prefix.length)) === undefined) {
54
55
  problems.push(`配置组「${group.id}」声明的「${path}」在 defaults() 里没有对应项:旋钮能改、能写回 config.json,读到的仍是代码里的兜底值。`);
55
56
  }
56
57
  }
@@ -209,7 +210,7 @@ export async function dryMountWorld(def: WorldDefinition<WorldSection>, opts: Wo
209
210
  }
210
211
  for (const group of decl.config ?? []) {
211
212
  if (group.owner !== `world:${def.id}`) warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,World 的配置组 owner 应为 world:${def.id}。`);
212
- failures.push(...configPathProblems(group, defaults, `worlds.${def.id}.`));
213
+ failures.push(...configPathProblems(group, `worlds.${def.id}.`, defaults));
213
214
  }
214
215
  const keys = new Set<string>();
215
216
  for (const doc of decl.promptDocs ?? []) {
@@ -233,6 +234,9 @@ export interface ProviderDryMountOptions extends DryMountOptions {
233
234
  hasConsoleClient?: boolean;
234
235
  }
235
236
 
237
+ /** 假端点的名字;模块声明的配置路径按它组成 `providers.<端点名>.options.`。 */
238
+ const PROBE_ENDPOINT = 'check';
239
+
236
240
  export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOptions): DryMountReport {
237
241
  const report: DryMountReport = { ok: [], warnings: [], failures: [] };
238
242
  const { ok, warnings, failures } = report;
@@ -257,7 +261,7 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
257
261
  return report;
258
262
  }
259
263
 
260
- const stateDir = join(opts.scratchDir, 'providers', 'check');
264
+ const stateDir = join(opts.scratchDir, 'providers', PROBE_ENDPOINT);
261
265
  mkdirSync(stateDir, { recursive: true });
262
266
  const resources = new Map<string, unknown>();
263
267
  const host = {
@@ -274,7 +278,7 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
274
278
  log: nullLogger(),
275
279
  };
276
280
  try {
277
- const instance = mod.create('check', entry, host);
281
+ const instance = mod.create(PROBE_ENDPOINT, entry, host);
278
282
  if (typeof instance?.client?.respond !== 'function') {
279
283
  failures.push('create() 返回的实例没有 client.respond():Core 只经它调模型。');
280
284
  } else {
@@ -286,11 +290,14 @@ export function dryMountProvider(mod: ProviderModule, opts: ProviderDryMountOpti
286
290
 
287
291
  if (mod.config) {
288
292
  try {
289
- const groups = mod.config('check', entry, language);
293
+ const groups = mod.config(PROBE_ENDPOINT, entry, language);
290
294
  if (!Array.isArray(groups)) failures.push('config() 没有返回数组。');
291
295
  else {
292
296
  for (const group of groups) {
293
297
  if (group.owner !== `provider:${mod.id}`) warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,provider 的配置组 owner 应为 provider:${mod.id}。`);
298
+ // 写回的三道闸都只按 `providers.<端点名>.` 过滤:段外的路径改不动,段内 options 之外的
299
+ // 路径覆盖框架自己编辑的字段(baseUrl、secret、multimodal、spec)。两类都拦在这里。
300
+ failures.push(...configPathProblems(group, `providers.${PROBE_ENDPOINT}.options.`));
294
301
  }
295
302
  ok.push(`config(): ${groups.length} 个配置组。`);
296
303
  }
@@ -405,7 +412,7 @@ export function dryMountBot(def: BotDefinition<CoreConfig>, opts: BotDryMountOpt
405
412
 
406
413
  for (const group of parts.console?.configGroups ?? []) {
407
414
  if (group.owner !== 'persona') warnings.push(`配置组「${group.id}」的 owner 是「${group.owner}」,Persona 的配置组 owner 应为 persona。`);
408
- failures.push(...configPathProblems(group, config, ''));
415
+ failures.push(...configPathProblems(group, '', config));
409
416
  }
410
417
  return report;
411
418
  }
@@ -17,7 +17,7 @@ export const EXTENSION_KINDS: readonly ExtensionKind[] = ['world', 'provider', '
17
17
  export const EXTENSION_API_VERSIONS: Readonly<Record<ExtensionKind, number>> = {
18
18
  world: 5,
19
19
  provider: 5,
20
- bot: 5,
20
+ bot: 6,
21
21
  };
22
22
 
23
23
  /** npm 上按类发现用的关键字。 */
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, consoleUrlOf, consumeBootFlags, listensOnEveryInterface, startsPaused } 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';
@@ -179,16 +179,10 @@ async function main(): Promise<void> {
179
179
  // 重启标志必须在装配与 session 加载之前处理
180
180
  consumeBootFlags(loaded.dataDir);
181
181
 
182
- const activeProvider = cfg.providers?.[cfg.activeProvider];
183
- if (!activeProvider) {
184
- console.error(
185
- `activeProvider="${cfg.activeProvider}" 在 providers 段里不存在(现有: ${Object.keys(cfg.providers ?? {}).join(' / ') || '无'})`,
186
- );
187
- process.exit(1);
188
- }
189
- const endpointDir = providerDir(cfg.activeProvider);
182
+ const activeProvider = providerAtBoot(cfg);
183
+ const endpointDir = activeProvider ? providerDir(cfg.activeProvider) : null;
190
184
  const missingSecret =
191
- activeProvider.secret && !secretReader(resolve(endpointDir, '.env'))(activeProvider.secret)
185
+ activeProvider?.secret && endpointDir && !secretReader(resolve(endpointDir, '.env'))(activeProvider.secret)
192
186
  ? activeProvider.secret
193
187
  : null;
194
188
 
@@ -204,9 +198,9 @@ async function main(): Promise<void> {
204
198
  const consoleUrl = port === null ? null : consoleUrlOf(bot.webApp?.boundAddress ?? null, port);
205
199
  if (port !== null) {
206
200
  console.log(` 控制台: ${consoleUrl}${listensOnEveryInterface(bot.webApp?.boundAddress ?? null) ? `(监听 ${bot.webApp?.boundAddress},所有网卡)` : ''}`);
207
- const assetsProblem = webAssetsProblem(fileURLToPath(new URL('../dist/web', import.meta.url)));
201
+ const assetsProblem = webAssetsProblem(fileURLToPath(new URL('..', import.meta.url)));
208
202
  if (assetsProblem) {
209
- console.log(` ⚠ 控制台产物不完整(${assetsProblem});停止 bot 后运行 pnpm build:web`);
203
+ console.log(` ⚠ 控制台产物需要重建(${assetsProblem});停止 bot 后运行 pnpm build:web`);
210
204
  }
211
205
  }
212
206
  for (const slot of bot.assembly.slots) {
@@ -222,8 +216,8 @@ async function main(): Promise<void> {
222
216
  ? ` 扩展: ${ext.name}@${ext.version} · bot 包,本部署未引用`
223
217
  : ` 扩展: ${ext.name} 未加载: ${ext.reason}`);
224
218
  }
225
- console.log(` 主模型: ${bot.core.mainSessionSpec().model}`);
226
- if (missingSecret) {
219
+ console.log(` 主模型: ${activeProvider?.spec?.model || '未配置;请在控制台「模型供应商」页选择连接与模型'}`);
220
+ if (missingSecret && endpointDir) {
227
221
  const envFile = resolve(endpointDir, '.env');
228
222
  const where = existsSync(envFile) ? `${envFile} 里也没有` : `${envFile} 不存在`;
229
223
  console.log(` ⚠ 缺少 ${missingSecret}:进程环境里没有,${where};可在控制台「模型提供商」页修改密钥变量名或补填密钥`);
@@ -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 } : {}),
@@ -28,16 +28,17 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可
28
28
  (附加配置组与面板)、`prices()`、`estimateTokens()`、`contextOverflow()`。
29
29
 
30
30
  地址、密钥变量名与图像开关不归模块:框架在 `console/config.ts` 里为每个端点声明这一组,
31
- 扩展来的模块照样有。模块自己的 `options.*` 走 `config()` 的配置组,面板在 `instance` 插槽里
31
+ 扩展来的模块照样有。模块自己的 `options.*` 走 `config()` 的配置组,模块的面板作为端点编辑页的段落
32
32
  用控制台的 schema 渲染器画同一份声明、经 `ctx.setConfig` 暂存到连接草稿——内建 llamacpp 的运行时与启动
33
- 两段走的是这条。`console()` 显式给空 `config` 表示这一页不另开配置页签,声明仍参与服务端
34
- 校验。启停与模型操作这类动作走面板 invoke。
33
+ 两段走的是这条。段落顺序由 `console()` `panels` 定:编辑页自带的四块(`console/config.ts`
34
+ `connectionBlocks`)与模块自己的面板排成一列。`console()` 显式给空 `config` 表示这一页不另开配置页签,
35
+ 声明仍参与服务端校验。启停与模型操作这类动作走面板 invoke。
35
36
 
36
37
  `create()` 返回 `ProviderInstance`:`client`(实现 `respond`)、`listModels?`、`control?`、
37
38
  `compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`。
38
39
 
39
40
  `ProviderHost` 给实例:`stateDir`(`<部署根>/providers/<端点名>/`,归实例独占)、`repoRoot`、
40
- `resource()`、`currentEntry()`、`secret(name)`(进程环境优先,否则读 `stateDir/.env` 一次)、
41
+ `resource()`、`currentEntry()`、`secret(name)`(进程环境优先,否则现读 `stateDir/.env`)、
41
42
  `readBlob()`、`keepThinking()`、`log`。
42
43
 
43
44
  ## 注册与解析
@@ -46,10 +47,11 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可
46
47
  之前通过 `registerProviderModules()` 注册,id 重复时抛错。
47
48
 
48
49
  `ProviderRegistry.resolve(name)`:按 `entry.kind` 找模块,`normalize`,以去掉 `pricing` 与
49
- `spec` 的条目 JSON 为缓存键(改模型或价格不重建实例),填 `stateDir` 与 `secret`。
50
+ `spec` 的条目 JSON 加端点 `.env` 的内容指纹为缓存键(改模型或价格不重建实例,密钥文件变更后
51
+ 下一次解析重建),填 `stateDir` 与 `secret`。
50
52
  `bind(name)` 在实例外包一层:注入 `quote`(报价快照)与 `origin`(实例、模块、模型、
51
53
  `compatibilityDomain` = sha256(kind + baseUrl + `compatibilityKey()`))。Responses 历史推理仅在实例、
52
- 模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存;写入 `.env` 后必须调用,每个实例仅缓存一次文件内容。
54
+ 模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存,控制台保存端点后调用;`.env` 变更经缓存键在下一次解析生效。
53
55
 
54
56
  ## 配置形状
55
57
 
@@ -64,8 +66,9 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
64
66
  ## transport
65
67
 
66
68
  `response-http.ts` 处理 HTTP/SSE:一次生成可包含多次请求尝试,重试间隔为 `[1s, 4s, 10s]`;超时
67
- 四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 429 / 5xx 重试,
68
- 401 / 403 先 `transport.refresh()` 一次;已提交不可逆增量后不再重试;输出字符超过
69
+ 四档(流式首包 300s、非流式 120s、帧空闲 120s、内容空闲 300s);只对状态 0 / 408 / 429 / 5xx
70
+ 重试,响应带 `Retry-After` 时按它退避,401 / 403 先 `transport.refresh()`
71
+ 一次;已提交不可逆增量后不再重试;输出字符超过
69
72
  `max_output_tokens × 12` 时终止请求并报告超限;终态只接受 `completed` / `incomplete`,`failed` 抛
70
73
  `LLMError`;每次请求尝试记录 `meters` 与 `charges`。
71
74
 
@@ -107,4 +110,8 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf(
107
110
  重读共享配置时跟上。
108
111
 
109
112
  `ProviderConsoleHost.editing` 表示这个实例来自还没保存的表单,模块据此拒绝运行时副作用,
110
- `host.save` 把配置交回浏览器暂存。`ProviderRegistry.preview` 拿到的实例不进运行实例缓存。
113
+ `host.save` 把配置交回浏览器暂存。`ProviderRegistry.previewRegistry` 按一份条目造注册表,实例不进
114
+ 运行实例缓存,浏览器刚输入的密钥作为覆盖值排在进程环境与端点 `.env` 之前;测试与模型列表带
115
+ `{ entry, secretValue }` 时走它。
116
+
117
+ 连接列表的 usage 包含其他部署的选用记录及运行锁状态,排除当前部署。运行状态按部署配置的 paths.data(缺省使用 Core 默认值)检查实例锁,不代表供应商正在处理请求,也不建立供应商独占锁。
@@ -1,8 +1,23 @@
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 type { ConsolePanelDecl } from '../../web/shared/console-protocol.ts';
4
5
  import { text } from './strings.ts';
5
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
+
6
21
  export function connectionGroup(name: string, entry: LLMProviderEntry, language: Language): ConfigGroup {
7
22
  const S = text(language);
8
23
  const prefix = `providers.${name}.`;
@@ -2,18 +2,20 @@ import { coerceGroupValues, getByPath, setByPath } from '../../core/config-schem
2
2
  /** Connection configuration transactions serialize writers across deployments and roll back all touched files on failure. */
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
- import { dirname, join, resolve } from 'node:path';
5
+ import { basename, dirname, join, resolve } from 'node:path';
6
6
  import type { CoreConfig, LLMProviderEntry } from '../../core/types.ts';
7
7
  import type { Language } from '../../core/language.ts';
8
8
  import { readJsonObject, updateJsonObject } from '../../config-file.ts';
9
9
  import { readTextFile } from '../../core/util.ts';
10
10
  import type { ProviderModule } from '../base.ts';
11
11
  import { validateEntry } from '../configuration.ts';
12
- import { validateProviderName } from '../name.ts';
12
+ import { validateProviderName, defaultSecretName } from '../name.ts';
13
13
  import { providerModules, type ProviderRegistry } from '../registry.ts';
14
14
  import type { ProviderSettings } from './settings.ts';
15
15
  import { quotePrices } from '../pricebook.ts';
16
16
  import { connectionGroup } from './config.ts';
17
+ import { instanceIsRunning } from '../../core/instance-lock.ts';
18
+ import { CORE_DEFAULTS } from '../../core/config.ts';
17
19
 
18
20
  export class ProviderHubError extends Error {
19
21
  constructor(message: string, readonly status = 400) { super(message); }
@@ -63,8 +65,15 @@ export class ProviderHub {
63
65
  reasoningTiers: module.localize?.(language)?.reasoningTiers ?? module.reasoningTiers,
64
66
  effortSuggestions: module.effortSuggestions ?? [], serviceTiers: module.localize?.(language)?.serviceTiers ?? module.serviceTiers,
65
67
  temperatureNote: module.localize?.(language)?.temperatureNote ?? module.temperatureNote,
68
+ sections: this.sections(module.id, language),
66
69
  }));
67
70
  }
71
+ /** The editor's sections for one module, in order: the page's panels minus the endpoint table. */
72
+ private sections(kind: string, language: Language) {
73
+ const source = this.settings.sources().find(source => source.id === `llm:${kind}`);
74
+ return (source?.contribute(language).panels ?? []).filter(panel => panel.id !== 'settings')
75
+ .map(({ id, title, description, builtin }) => ({ id, title, ...(description ? { description } : {}), ...(builtin ? { builtin } : {}) }));
76
+ }
68
77
  private readiness(name: string, entry: LLMProviderEntry, language: Language) {
69
78
  const module = this.modules.find(m => m.id === entry.kind);
70
79
  if (!module) return { state: 'module-missing', reason: language === 'zh' ? '供应商模块不可用。' : 'Provider module is unavailable.' };
@@ -89,6 +98,11 @@ export class ProviderHub {
89
98
  id: name, name, module: entry.kind, moduleTitle: this.modules.find(m => m.id === entry.kind)?.title ?? entry.kind,
90
99
  model: entry.spec?.model ?? null, baseUrl: entry.baseUrl, active: name === this.config.activeProvider,
91
100
  readiness: this.readiness(name, entry, language), revision: this.revision(name),
101
+ usage: this.references(name).filter(file => resolve(file) !== resolve(this.file)).map(file => {
102
+ const config = readJsonObject(file);
103
+ const paths = config.paths as { data?: string } | undefined;
104
+ return { name: basename(dirname(file)), running: instanceIsRunning(resolve(dirname(file), paths?.data ?? CORE_DEFAULTS.paths.data)) };
105
+ }),
92
106
  })) };
93
107
  }
94
108
  detail(name: string, language: Language) {
@@ -171,11 +185,8 @@ export class ProviderHub {
171
185
  if (prior && prior.kind !== input.entry.kind) throw new ProviderHubError('Provider module cannot be changed.');
172
186
  const module = this.modules.find(m => m.id === input.entry.kind);
173
187
  if (!module) throw new ProviderHubError('Provider module is unavailable.');
174
- const requested = structuredClone(input.entry);
188
+ const requested = withTypedSecret(name, structuredClone(input.entry), input.secretValue);
175
189
  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
190
  const entry = validateEntry(module, requested, language);
180
191
  const prefix = `providers.${name}.`;
181
192
  for (const group of this.groups(name, entry, language)) {
@@ -259,12 +270,38 @@ export class ProviderHub {
259
270
  rmSync(tomb, { recursive: true, force: true });
260
271
  });
261
272
  }
262
- async action(name: string, action: string, language: Language) {
273
+ /**
274
+ * Probe (`test`) or list models (`models`). With `draft`, the request goes through an instance
275
+ * built from the browser's entry and typed key; nothing is written, and `name` may not exist yet.
276
+ */
277
+ async action(name: string, action: 'test' | 'models', language: Language, draft?: { entry: LLMProviderEntry; secretValue?: string }) {
263
278
  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 }]);
279
+ if (!draft) {
280
+ const entry = this.config.providers[name];
281
+ if (!entry) throw new ProviderHubError('Provider does not exist.', 404);
282
+ const source = this.settings.sources().find(source => source.id === `llm:${entry.kind}`);
283
+ if (!source) throw new ProviderHubError('Provider module is unavailable.');
284
+ return source.contribute(language).invoke!('settings', action === 'test' ? 'probe' : 'models', [{ name }]);
285
+ }
286
+ this.path(name);
287
+ if (!draft.entry || typeof draft.entry !== 'object') throw new ProviderHubError('Provider configuration is required.');
288
+ const module = this.modules.find(m => m.id === draft.entry.kind);
289
+ if (!module) throw new ProviderHubError('Provider module is unavailable.');
290
+ const entry = validateEntry(module, withTypedSecret(name, draft.entry, draft.secretValue), language);
291
+ const registry = this.registry.previewRegistry(name, entry, entry.secret && draft.secretValue ? { [entry.secret]: draft.secretValue } : {});
292
+ if (action === 'models') {
293
+ const instance = registry.resolve(name);
294
+ if (!instance.listModels) throw new ProviderHubError('This module does not list models.');
295
+ return { models: await instance.listModels() };
296
+ }
297
+ if (!entry.spec) throw new ProviderHubError('Model is required.');
298
+ return this.settings.probeClient(registry.bind(name), entry.spec, language);
269
299
  }
270
300
  }
301
+
302
+ /** A typed key with no variable name declared is stored under the name derived from the endpoint name. */
303
+ function withTypedSecret(name: string, entry: LLMProviderEntry, secretValue: string | undefined): LLMProviderEntry {
304
+ if (secretValue !== undefined && (typeof secretValue !== 'string' || !secretValue || /\s/.test(secretValue)))
305
+ throw new ProviderHubError('API Key must be nonempty and contain no whitespace.');
306
+ return secretValue && !entry.secret ? { ...entry, secret: defaultSecretName(name) } : entry;
307
+ }