cortico 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +40 -5
  5. package/src/core/README.md +4 -2
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +18 -2
  14. package/src/core/util.ts +15 -0
  15. package/src/deploy.ts +5 -4
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -7
  23. package/src/providers/base.ts +2 -0
  24. package/src/providers/console/config.ts +21 -0
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +11 -19
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +38 -0
  30. package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
  31. package/src/providers/llamacpp/console/server.ts +23 -2
  32. package/src/providers/llamacpp/index.ts +5 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/options.ts +2 -0
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +33 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +40 -12
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +12 -3
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
  50. package/src/web/client/console-pages/host.ts +22 -5
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/extensions/index.ts +276 -41
  54. package/src/web/client/features/extensions/strings.ts +84 -10
  55. package/src/web/client/features/feature.ts +1 -1
  56. package/src/web/client/features/live/diagnostics.ts +32 -0
  57. package/src/web/client/features/live/index.ts +27 -11
  58. package/src/web/client/features/live/protocol.ts +1 -0
  59. package/src/web/client/features/live/strings.ts +9 -3
  60. package/src/web/client/features/providers/detail.ts +262 -0
  61. package/src/web/client/features/providers/drafts.ts +23 -0
  62. package/src/web/client/features/providers/index.ts +173 -87
  63. package/src/web/client/features/providers/strings.ts +44 -17
  64. package/src/web/client/features/providers/types.ts +15 -0
  65. package/src/web/client/features/settings/general.ts +12 -0
  66. package/src/web/client/features/settings/strings.ts +6 -0
  67. package/src/web/client/main.ts +4 -0
  68. package/src/web/client/shell/index.ts +1 -1
  69. package/src/web/client/ui/icons.ts +9 -1
  70. package/src/web/client/ui/prompt-input.tsx +17 -5
  71. package/src/web/client/ui/strings.ts +0 -2
  72. package/src/web/diagnostics.ts +133 -0
  73. package/src/web/public/login.html +67 -0
  74. package/src/web/public/styles.css +122 -18
  75. package/src/web/server.ts +227 -21
  76. package/src/web/shared/client-panel.ts +3 -0
  77. package/src/web/shared/console-protocol.ts +3 -1
  78. package/src/worlds/bilibili/README.md +1 -1
  79. package/src/worlds/bilibili/overlay/server.ts +4 -2
  80. package/src/worlds/minecraft/ADAPT.md +66 -0
  81. package/src/worlds/minecraft/README.md +8 -0
  82. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  83. package/src/worlds/qq/normalize.ts +14 -0
  84. package/src/worlds/qq/world.ts +53 -7
  85. package/src/worlds/terminal/world.ts +3 -4
@@ -1,22 +1,45 @@
1
1
  /** Stateless native Responses input: the whole context is replayed on every request. */
2
2
  import type { Request } from '../../protocol/open-responses/index.ts';
3
- import { inputItem } from '../../protocol/open-responses/context.ts';
3
+ import { inputItem, itemText, type ContextRecord } from '../../protocol/open-responses/context.ts';
4
4
  import type { GenerateOptions } from '../../core/generation.ts';
5
5
  import { requestContext } from './native-input.ts';
6
6
  import type { CompatMediaOptions } from './history.ts';
7
7
 
8
8
  type Item = Record<string, unknown>;
9
9
 
10
+ /**
11
+ * How past reasoning re-enters the context. `encrypted`: the signed `encrypted_content` block, only
12
+ * when the recorded origin matches this request. `plaintext`: the reasoning text itself, for
13
+ * endpoints whose thinking mode requires the text of every tool-call turn back.
14
+ */
15
+ export const REASONING_REPLAYS = ['encrypted', 'plaintext'] as const;
16
+ export type ReasoningReplay = (typeof REASONING_REPLAYS)[number];
17
+
18
+ /**
19
+ * Default `reasoning_text` sent before a function call without a recorded origin in plaintext
20
+ * replay. A missing field, an empty `content`, an empty string and a summary without
21
+ * `reasoning_text` are each rejected, so the padding is one character; it is non-whitespace in case
22
+ * an endpoint trims. It carries no prose by default: what these calls are is the Persona's to say.
23
+ * The operator replaces it per endpoint.
24
+ */
25
+ export const SYNTHETIC_REASONING_TEXT = '-';
26
+
10
27
  export interface ResponsesInputOptions {
11
28
  media?: CompatMediaOptions;
12
29
  /** Whether past reasoning re-enters the context; the first synthetic turn is exempt. */
13
30
  keepThinking?: () => boolean;
31
+ /** Default `encrypted`. */
32
+ reasoningReplay?: ReasoningReplay;
33
+ /** Plaintext replay only; defaults to `SYNTHETIC_REASONING_TEXT`. Endpoints reject an empty one. */
34
+ syntheticReasoningText?: string;
14
35
  }
15
36
 
16
37
  /**
17
- * Replay reasoning only when encrypted_content exists and the recorded instance, module,
18
- * compatibility domain and model match this request. These are local eligibility checks.
19
- * Plaintext reasoning is omitted. System and developer text is joined into instructions.
38
+ * Encrypted replay: a reasoning item re-enters only with `encrypted_content` and when the recorded
39
+ * instance, module, compatibility domain and model match this request. Plaintext replay: a reasoning
40
+ * item re-enters as `reasoning_text`; the turn after the last user message is always replayed, earlier
41
+ * turns follow `keepThinking`; a function call without an origin gets a synthetic reasoning item
42
+ * unless one already precedes it. System and developer text is joined into instructions.
20
43
  */
21
44
  export function responsesInput(
22
45
  request: Request,
@@ -25,20 +48,37 @@ export function responsesInput(
25
48
  ): { input: Item[]; instructions: string | undefined } {
26
49
  const input: Item[] = [];
27
50
  const systems = request.instructions ? [request.instructions] : [];
28
- for (const entry of requestContext(request, options)) {
51
+ const plaintext = opts.reasoningReplay === 'plaintext';
52
+ const entries = requestContext(request, options);
53
+ const roundStart = plaintext ? lastUserMessage(entries) : -1;
54
+ entries.forEach((entry, index) => {
29
55
  const item = entry.item;
30
56
  if (item.type === 'reasoning') {
31
- if (!item.encrypted_content) continue;
32
- if (!entry.context.head && opts.keepThinking?.() === false) continue;
57
+ if (plaintext) {
58
+ const text = itemText(item);
59
+ if (!text && !item.encrypted_content) return;
60
+ if (!entry.context.head && index < roundStart && opts.keepThinking?.() === false) return;
61
+ const wire = inputItem(entry) as Item;
62
+ if (text) wire.content = [{ type: 'reasoning_text', text }];
63
+ input.push(wire);
64
+ return;
65
+ }
66
+ if (!item.encrypted_content) return;
67
+ if (!entry.context.head && opts.keepThinking?.() === false) return;
33
68
  const owner = entry.context.origin;
34
69
  const current = options.origin;
35
70
  if (!owner || !current || owner.instance !== current.instance || owner.module !== current.module
36
- || owner.compatibilityDomain !== current.compatibilityDomain || owner.model !== request.model) continue;
71
+ || owner.compatibilityDomain !== current.compatibilityDomain || owner.model !== request.model) return;
37
72
  }
38
73
  if (item.type === 'message' && (item.role === 'system' || item.role === 'developer')) {
39
74
  systems.push(typeof item.content === 'string' ? item.content : item.content.map(part => 'text' in part ? part.text : '').join(''));
40
- continue;
75
+ return;
41
76
  }
77
+ if (plaintext && item.type === 'function_call' && !entry.context.origin && input.at(-1)?.type !== 'reasoning')
78
+ input.push({
79
+ type: 'reasoning', id: `rs_${item.call_id}`, summary: [],
80
+ content: [{ type: 'reasoning_text', text: opts.syntheticReasoningText ?? SYNTHETIC_REASONING_TEXT }],
81
+ });
42
82
  const wire = inputItem(entry) as Item;
43
83
  if (opts.media?.enabled() && entry.context.blobs?.length && (item.type === 'message' || item.type === 'function_call_output')) {
44
84
  const field = item.type === 'message' ? 'content' : 'output';
@@ -51,6 +91,15 @@ export function responsesInput(
51
91
  wire[field] = parts;
52
92
  }
53
93
  input.push(wire);
54
- }
94
+ });
55
95
  return { input, instructions: systems.length ? systems.join('\n') : undefined };
56
96
  }
97
+
98
+ /** Index of the last user message, -1 when there is none. */
99
+ function lastUserMessage(entries: readonly ContextRecord[]): number {
100
+ for (let index = entries.length - 1; index >= 0; index--) {
101
+ const item = entries[index].item;
102
+ if (item.type === 'message' && item.role === 'user') return index;
103
+ }
104
+ return -1;
105
+ }
package/src/web/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Owner: src/web/server.ts, src/web/shared/console-protocol.ts, src/web/shared/client-panel.ts -->
1
+ <!-- Owner: src/web/server.ts, src/web/auth.ts, src/web/shared/console-protocol.ts, src/web/shared/client-panel.ts, src/web/client/console-pages/host.ts, src/web/client/console-pages/builtins/llm-settings/panel.ts, src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts, src/web/client/features/providers/index.ts, src/web/client/features/live/index.ts -->
2
2
 
3
3
  # src/web
4
4
 
@@ -43,8 +43,11 @@ manifest 的 `CONSOLE_PROTOCOL_VERSION` 不匹配时,浏览器拒绝加载。
43
43
 
44
44
  `WebApp` 默认监听 `127.0.0.1`,支持由依赖配置指定监听地址。从首选端口起最多尝试五个端口;
45
45
  端口为 0 时仅申请一次系统分配。WebSocket 使用 `noServer` 分派 `/ws/debug`、`/ws/sessions` 和面板流。
46
- 所有请求与 upgrade 先校验 Host 头:只接受回环名或显式绑定的地址,绑到通配地址时不校验。
47
- 写请求与 upgrade 再按 Host 校验 Origin,拒绝不匹配或无效的 Origin;缺少 Origin 时放行。
46
+ 所有请求与 upgrade 先校验 Host 头:只接受回环名、显式绑定的地址和 `allowedHosts` 里的名字,绑到通配地址时不校验。
47
+ 写请求与 upgrade 再按 Host 校验 Origin,拒绝不匹配或无效的 Origin;缺少 Origin 时放行,Origin 是
48
+ `allowedHosts` 里的名字时放行。监听非回环地址而没有设访问密码时启动记一条 warn。
49
+ `auth.ts` 的 `ConsoleAuth` 在设了密码时把未登录的请求挡在各路由与 body 解析之前:`/` 给登录页,其余 401,
50
+ upgrade 断开;登录态是 HttpOnly Cookie 里的无状态签名令牌(见 [console.md](../../docs/console.md))。
48
51
 
49
52
  `/assets` 提供 `dist/web` 资源。首页在最后一个 `</body>` 前注入带 hash 的入口。
50
53
  扩展面板通过 `/assets/extensions/<包>/<版本>/<文件>` 提供,仅允许 manifest 声明的脚本与样式文件。
@@ -53,6 +56,12 @@ manifest 的 `CONSOLE_PROTOCOL_VERSION` 不匹配时,浏览器拒绝加载。
53
56
 
54
57
  ## 浏览器
55
58
 
59
+ `features/providers` 一次编辑一条端点,改动只进浏览器暂存,保存时整条提交。模块面板挂在所选端点
60
+ 的作用域里,面板的 `setConfig` 同样进暂存;`llm-settings` 的服务接口保留,旧的 `llm:<kind>` 路由
61
+ 转到这一页。`features/providers/drafts.ts` 的暂存按部署分 scope 存进 localStorage,不含 API Key;
62
+ 这一页自己拦切换端点,并向路由登记离开保护。端点保存、删除或设为当前之后推送状态帧,终端页顶部
63
+ 的统计行显示当前端点的名称与模型,点开进该端点。
64
+
56
65
  `main.ts` 的 `FEATURES` 包含 `live`、`core`、`usage`、`provider`、`world`、`extensions`、`prompts`、
57
66
  `appearance`、`settings`。贡献页由 manifest 加载,保留路由段 `provider` 交由 `ConsolePageHost` 处理。
58
67
  `features/config/view.ts` 与 `features/storage/view.ts` 是配置组与存储清单的通用视图,运行诊断页与
@@ -0,0 +1,80 @@
1
+ /**
2
+ * 控制台访问密码。密码为空时不启用,所有请求照常放行。
3
+ * 登录令牌无状态:由密码与数据目录里的随机盐导出的 HMAC,进程重启后仍有效,改密码使它失效;
4
+ * 令牌自身不设到期,有效到改密码或退出登录为止。令牌放在 HttpOnly Cookie 里,
5
+ * WebSocket 升级与只能带 URL 的 GET 调用随之携带。
6
+ */
7
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+
11
+ export const SESSION_COOKIE = 'cortico_session';
12
+ export const AUTH_KEY_FILE = 'web-auth.key';
13
+ /** 盐的字节数;文件解码后不是这个长度即视同不存在,重新生成。 */
14
+ export const SALT_BYTES = 32;
15
+ /** Cookie 的 Max-Age:浏览器对 Cookie 寿命的上限是 400 天(RFC 6265bis),取它即一直保留。 */
16
+ export const SESSION_COOKIE_MAX_AGE_SEC = 400 * 24 * 60 * 60;
17
+ /**
18
+ * 每次失败的登录等这么久才回应,且失败串行处理:并发再多,猜测的总速率也不超过每秒一次。
19
+ * 八位随机字母数字的密码空间约 2.8e14,按此速率穷举以百万年计;不设延迟时局域网内每秒可试数千次。
20
+ * 正确的密码不进这条队列。
21
+ */
22
+ export const FAILED_LOGIN_DELAY_MS = 1000;
23
+
24
+ function sameText(a: string, b: string): boolean {
25
+ const digest = (text: string): Buffer => createHash('sha256').update(text, 'utf8').digest();
26
+ return timingSafeEqual(digest(a), digest(b));
27
+ }
28
+
29
+ /** 取 Cookie 头里的一个值;没有或头不是字符串时为 null。 */
30
+ export function cookieValue(header: unknown, name: string): string | null {
31
+ if (typeof header !== 'string') return null;
32
+ for (const part of header.split(';')) {
33
+ const at = part.indexOf('=');
34
+ if (at > 0 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
35
+ }
36
+ return null;
37
+ }
38
+
39
+ export class ConsoleAuth {
40
+ private token: string | null = null;
41
+ private failures: Promise<void> = Promise.resolve();
42
+
43
+ constructor(
44
+ private readonly password: string,
45
+ private readonly keyFile: string,
46
+ private readonly failedDelayMs: number = FAILED_LOGIN_DELAY_MS,
47
+ ) {}
48
+
49
+ get enabled(): boolean {
50
+ return this.password !== '';
51
+ }
52
+
53
+ /** 这个密码在这个数据目录下的登录令牌;盐文件在第一次用到时生成,解码后不是 SALT_BYTES 字节时重新生成。 */
54
+ issue(): string {
55
+ if (this.token === null) {
56
+ let salt = existsSync(this.keyFile) ? Buffer.from(readFileSync(this.keyFile, 'utf8').trim(), 'hex') : Buffer.alloc(0);
57
+ if (salt.length !== SALT_BYTES) {
58
+ salt = randomBytes(SALT_BYTES);
59
+ mkdirSync(dirname(this.keyFile), { recursive: true });
60
+ writeFileSync(this.keyFile, `${salt.toString('hex')}\n`, { encoding: 'utf8', mode: 0o600 });
61
+ }
62
+ const key = createHmac('sha256', salt).update(this.password, 'utf8').digest();
63
+ this.token = createHmac('sha256', key).update(SESSION_COOKIE, 'utf8').digest('hex');
64
+ }
65
+ return this.token;
66
+ }
67
+
68
+ verify(token: string | null): boolean {
69
+ return token !== null && token !== '' && sameText(token, this.issue());
70
+ }
71
+
72
+ /** 密码正确立即返回 true;错误的排队等 failedDelayMs 后返回 false。 */
73
+ async login(candidate: string): Promise<boolean> {
74
+ if (sameText(candidate, this.password)) return true;
75
+ const turn = this.failures.then(() => new Promise<void>((resolve) => setTimeout(resolve, this.failedDelayMs)));
76
+ this.failures = turn;
77
+ await turn;
78
+ return false;
79
+ }
80
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  } from '../../../../shared/client-panel.ts';
14
14
  import { pricingEditor, type ModelQuote } from './pricing-panel.ts';
15
15
  import { panel } from './strings.ts';
16
+ import { configField, type ConfigGroupEntry } from '../../../features/config/view.ts';
16
17
 
17
18
  /** 每个端点实例的模型与推理配置。 */
18
19
  interface Spec {
@@ -45,6 +46,7 @@ interface Instance {
45
46
  entry: Entry;
46
47
  quotes: ModelQuote[];
47
48
  secretConfigured: 'env' | 'file' | 'none';
49
+ config: ConfigGroupEntry[];
48
50
  }
49
51
  interface SettingsState {
50
52
  active: string;
@@ -59,7 +61,7 @@ interface ModelEntry {
59
61
  id: string;
60
62
  contextWindow?: number;
61
63
  }
62
- interface ProbeResult {
64
+ export interface ProbeResult {
63
65
  ok: boolean;
64
66
  status: number | null;
65
67
  elapsedMs: number;
@@ -104,7 +106,8 @@ function datalist(ui: ConsoleUi, id: string, values: readonly string[]): HTMLDat
104
106
  return list;
105
107
  }
106
108
 
107
- function probeCard(ui: ConsoleUi, S: typeof panel.zh, result: ProbeResult): HTMLElement {
109
+ /** 探活结果的呈现,模块页与连接卡片共用。 */
110
+ export function probeCard(ui: ConsoleUi, S: typeof panel.zh, result: ProbeResult): HTMLElement {
108
111
  const fmt: ConsoleFormat = ui.fmt;
109
112
  const usage = result.usage;
110
113
  const rows = [
@@ -255,26 +258,37 @@ export const llmSettingsPanel: ConsolePanel = {
255
258
  const spec: Spec = structuredClone(entry.spec) ?? { model: '', thinking: open };
256
259
  const saveSpec = () => void patch({ spec });
257
260
 
261
+ function configInput(suffix: string, label: string, changed?: (value: unknown) => void | Promise<void>): HTMLElement {
262
+ const path = `providers.${current!.name}.${suffix}`;
263
+ const { group, values } = current!.config.find(({ group }) => path in group.schema.properties!)!;
264
+ const field = configField(ui, group.schema.properties![path], values?.[path], () => {
265
+ void (async () => {
266
+ const raw = field.read!();
267
+ const value = typeof raw === 'string' ? raw.trim() : raw;
268
+ try {
269
+ await ctx.setConfig(group.id, { [path]: value });
270
+ report.textContent = S.saved;
271
+ await ctx.refresh();
272
+ await changed?.(value);
273
+ } catch (error) {
274
+ report.textContent = String(error);
275
+ }
276
+ })();
277
+ }, ctx.signal);
278
+ (field.node.querySelector('input, select, textarea') ?? field.node).setAttribute('aria-label', label);
279
+ return field.node;
280
+ }
281
+
258
282
  // ---- 连接 ----
259
283
  const connection = ui.sheet({ title: S.connectionTitle, desc: S.connectionDescription });
260
284
  const options = entry.options ?? {};
261
- let endpointPath =
285
+ const endpointPath =
262
286
  typeof options.endpointPath === 'string' ? options.endpointPath : DEFAULT_ENDPOINT_PATH;
263
287
  const jsonText = (value: unknown) =>
264
288
  value === undefined ? '' : JSON.stringify(value, null, 2);
265
- const baseUrlInput = ui.input({
266
- value: entry.baseUrl,
267
- cls: 'mono',
268
- onChange: (value) => void connectionChanged({ baseUrl: value.trim() }),
269
- });
270
- baseUrlInput.setAttribute('aria-label', S.baseUrl);
271
- const secretNameInput = ui.input({
272
- value: entry.secret ?? '',
273
- placeholder: S.secretNamePlaceholder,
274
- cls: 'mono',
275
- onChange: (value) => void connectionChanged({ secret: value.trim() }),
276
- });
277
- secretNameInput.setAttribute('aria-label', S.secretName);
289
+ const baseUrlInput = configInput('baseUrl', S.baseUrl, connectionChanged);
290
+ const secretNameInput = configInput('secret', S.secretName, connectionChanged);
291
+ (secretNameInput as HTMLInputElement).placeholder = S.secretNamePlaceholder;
278
292
  connection.body.append(
279
293
  ui.field(S.baseUrl, baseUrlInput),
280
294
  ui.field(S.secretName, secretNameInput),
@@ -294,24 +308,18 @@ export const llmSettingsPanel: ConsolePanel = {
294
308
  connection.body.append(ui.field(S.secretStatus, row));
295
309
  if (!entry.secret) connection.body.append(ui.msgline(S.secretNameFirst));
296
310
  }
297
- connection.body.append(
298
- ui.checkbox(S.multimodal, {
299
- checked: entry.multimodal === true,
300
- onChange: (checked) => void patch({ multimodal: checked }),
301
- }).el,
302
- );
311
+ connection.body.append(ui.field(S.multimodal, configInput('multimodal', S.multimodal)));
303
312
  let extraHeaders: HTMLTextAreaElement | null = null;
304
313
  let extraBody: HTMLTextAreaElement | null = null;
314
+ let path: HTMLInputElement | null = null;
305
315
  if (open) {
306
- const path = ui.input({
307
- value: endpointPath,
308
- cls: 'mono',
309
- onChange: (value) => {
310
- endpointPath = value.trim();
311
- saveOptions();
312
- },
313
- });
314
- path.setAttribute('aria-label', S.endpointPath);
316
+ const pathKey = `providers.${current.name}.options.endpointPath`;
317
+ const hasPath = current.config.some(({ group }) => pathKey in group.schema.properties!);
318
+ path = hasPath ? configInput('options.endpointPath', S.endpointPath) as HTMLInputElement : null;
319
+ if (path) {
320
+ path.value = endpointPath;
321
+ path.placeholder = DEFAULT_ENDPOINT_PATH;
322
+ }
315
323
  extraHeaders = ui.textarea({
316
324
  rows: 3,
317
325
  cls: 'mono',
@@ -328,7 +336,7 @@ export const llmSettingsPanel: ConsolePanel = {
328
336
  extraBody.setAttribute('aria-label', S.extraBody);
329
337
  connection.body.append(
330
338
  ui.section(S.advancedProtocolTitle, S.advancedProtocolDescription),
331
- ui.field(S.endpointPath, path),
339
+ ...(path ? [ui.field(S.endpointPath, path)] : []),
332
340
  ui.field(S.extraHeaders, extraHeaders),
333
341
  ui.field(S.extraBody, extraBody),
334
342
  );
@@ -346,7 +354,7 @@ export const llmSettingsPanel: ConsolePanel = {
346
354
  return parsed;
347
355
  };
348
356
  const edited: Record<string, unknown> = {
349
- endpointPath,
357
+ endpointPath: path?.value.trim() ?? options.endpointPath,
350
358
  extraHeaders: object(extraHeaders!, S.extraHeaders),
351
359
  extraBody: object(extraBody!, S.extraBody),
352
360
  };
@@ -431,8 +439,7 @@ export const llmSettingsPanel: ConsolePanel = {
431
439
  if (!ctx.signal.aborted) renderModelField();
432
440
  }
433
441
  /** 地址或密钥改过就重取目录:换了端点,上一份模型表不作数。 */
434
- async function connectionChanged(fields: Record<string, unknown>): Promise<void> {
435
- if (!(await patch(fields))) return;
442
+ async function connectionChanged(): Promise<void> {
436
443
  catalog = [];
437
444
  await fetchCatalog();
438
445
  }
@@ -70,6 +70,7 @@ export function pricingEditor(
70
70
  quotes: ModelQuote[],
71
71
  commit: (pricing: unknown[]) => void,
72
72
  language?: Language,
73
+ draft?: { raw?: string; onRaw(value: string): void },
73
74
  ) {
74
75
  const S = language === 'en' ? panel.en : panel.zh;
75
76
  const card = ui.sheet({
@@ -78,7 +79,6 @@ export function pricingEditor(
78
79
  });
79
80
  const labels: Record<string, string> = S.meters;
80
81
  for (const row of quotes) {
81
- if (!row.quotes.length) card.body.append(ui.msgline(S.quoteUnknown(row.model)));
82
82
  for (const quote of row.quotes) {
83
83
  card.body.append(
84
84
  ui.kv([
@@ -101,11 +101,16 @@ export function pricingEditor(
101
101
  }
102
102
  }
103
103
  const unset = saved.length === 0;
104
- if (unset) card.body.append(ui.msgline(S.pricingUnset));
104
+ const pricingNote = ui.h('p', 'field-note pricing-note');
105
+ const updateNote = (pricing: unknown[]) => {
106
+ pricingNote.textContent = pricing.length ? '' : S.pricingUnset;
107
+ pricingNote.hidden = pricing.length > 0;
108
+ };
109
+ updateNote(saved); card.body.append(pricingNote);
105
110
  const simple: SimpleCost | null = unset ? null : readSimple(saved);
106
111
  const raw = ui.textarea({
107
112
  rows: 10,
108
- value: JSON.stringify(simple ? writeSimple(simple) : saved, null, 2),
113
+ value: draft?.raw ?? JSON.stringify(simple ? writeSimple(simple) : saved, null, 2),
109
114
  onChange: commitDraft,
110
115
  });
111
116
  const currency = ui.input({ value: simple?.currency ?? 'USD', onChange: writeThrough });
@@ -139,15 +144,23 @@ export function pricingEditor(
139
144
  commitDraft();
140
145
  }
141
146
  /** 完整规则那格可以写坏;解析不过就停在这张卡上说清楚,不往端点写。 */
142
- function commitDraft(): void {
147
+ function commitDraft(): boolean {
143
148
  try {
144
- const pricing = JSON.parse(raw.value) as unknown[];
149
+ const pricing: unknown = JSON.parse(raw.value.trim() || '[]');
150
+ if (!Array.isArray(pricing)) throw new Error(S.pricingTitle + ': JSON array required');
145
151
  problem.textContent = '';
146
152
  problem.classList.remove('bad');
153
+ raw.setAttribute('aria-invalid', 'false');
154
+ updateNote(pricing);
147
155
  commit(pricing);
156
+ draft?.onRaw(raw.value);
157
+ return true;
148
158
  } catch (error) {
149
159
  problem.textContent = String(error);
150
160
  problem.classList.add('bad');
161
+ raw.setAttribute('aria-invalid', 'true');
162
+ draft?.onRaw(raw.value);
163
+ return false;
151
164
  }
152
165
  }
153
166
  card.body.append(
@@ -155,14 +168,15 @@ export function pricingEditor(
155
168
  ...rates.map((input, i) => ui.field(rateLabels[i], input)),
156
169
  ui.msgline(simple || unset ? S.costFormNote : S.costFormOverridden),
157
170
  );
158
- const advanced = ui.h('details');
171
+ const advanced = ui.h('details', 'pricing-rules');
159
172
  advanced.open = !simple && !unset;
160
173
  advanced.append(ui.h('summary', null, S.editFull), raw, ui.msgline(S.fullNote), problem);
161
- const preview = ui.h('details');
174
+ const preview = ui.h('details', 'pricing-snapshot');
162
175
  preview.append(
163
176
  ui.h('summary', null, S.viewSnapshot),
164
177
  ui.h('pre', 'mono', JSON.stringify(quotes, null, 2)),
165
178
  );
166
179
  card.body.append(advanced, preview);
167
- return { el: card.el };
180
+ if (draft?.raw !== undefined) commitDraft();
181
+ return { el: card.el, body: card.body, validate: commitDraft };
168
182
  }
@@ -76,7 +76,7 @@ const zh = {
76
76
  deleted: '已删除。',
77
77
  pricingTitle: '报价',
78
78
  pricingDescription: '历史流水固定使用请求时的报价。自定义报价按模型与费用口径覆盖模块默认值。',
79
- pricingUnset: '未设报价:这个端点的调用在用量与成本页只计 token,不计金额。',
79
+ pricingUnset: '未设自定义报价:优先使用模块价目;若无适用价目,仅记录 token,不计金额。',
80
80
  meters: {
81
81
  input: '输入',
82
82
  cachedInput: '缓存命中',
@@ -85,7 +85,6 @@ const zh = {
85
85
  reasoning: '推理',
86
86
  total: '总量',
87
87
  },
88
- quoteUnknown: (model: string) => `${model}:报价未知`,
89
88
  marginal: '边际费用',
90
89
  equivalent: 'API 等价费用',
91
90
  perMillion: (label: string, rate: number) => `${label} ${rate}/百万`,
@@ -182,7 +181,7 @@ const en: typeof zh = {
182
181
  pricingTitle: 'Pricing',
183
182
  pricingDescription:
184
183
  'Historical records keep the quote in effect at request time. Custom quotes override module defaults per model and cost basis.',
185
- pricingUnset: 'No pricing set: calls on this endpoint count tokens but no amount on the usage page.',
184
+ pricingUnset: 'No custom pricing: module prices apply when available; otherwise only tokens are recorded.',
186
185
  meters: {
187
186
  input: 'Input',
188
187
  cachedInput: 'Cache hit',
@@ -191,7 +190,6 @@ const en: typeof zh = {
191
190
  reasoning: 'Reasoning',
192
191
  total: 'Total',
193
192
  },
194
- quoteUnknown: (model: string) => `${model}: quote unknown`,
195
193
  marginal: 'Marginal cost',
196
194
  equivalent: 'API-equivalent cost',
197
195
  perMillion: (label: string, rate: number) => `${label} ${rate}/M`,
@@ -8,7 +8,7 @@ import {
8
8
  type ConsolePageManifest,
9
9
  type ConsolePanelManifest,
10
10
  } from '../../shared/console-protocol.ts';
11
- import type { ConsoleMemo, ConsolePanel, Disposable } from '../../shared/client-panel.ts';
11
+ import type { ConsoleMemo, ConsolePanel, ConsolePanelContext, Disposable } from '../../shared/client-panel.ts';
12
12
  import { get, post } from '../core/api.ts';
13
13
  import { Lifecycle } from '../core/lifecycle.ts';
14
14
  import type { Router } from '../core/router.ts';
@@ -121,6 +121,23 @@ export class ConsolePageHost {
121
121
  this.emitNav();
122
122
  }
123
123
 
124
+ /** Mount module panels inside a connection editor while its adapter stages configuration edits. */
125
+ async mountConnection(pageId: string, root: HTMLElement, scope: Readonly<Record<string, string>>,
126
+ adapt: (context: ConsolePanelContext) => ConsolePanelContext): Promise<Disposable> {
127
+ const lifecycle = new Lifecycle(this.deps.onError);
128
+ const page = this.find(pageId);
129
+ for (const panel of asArray(page?.panels).filter(panel => panel.id !== 'settings')) {
130
+ const box = this.deps.doc.createElement('div'); root.append(box);
131
+ try {
132
+ const impl = panel.builtin ? this.builtinPanel(panel.builtin) : await this.deps.loader.resolvePanel(pageId, panel.id, page?.client);
133
+ if (lifecycle.disposed) break;
134
+ const result = await impl.mount(adapt(this.panelContext(pageId, panel.id, box, lifecycle, this.generation, scope)));
135
+ if (result) lifecycle.own(result);
136
+ } catch (error) { box.textContent = String(error); }
137
+ }
138
+ return lifecycle;
139
+ }
140
+
124
141
  /** 重取 manifest 并刷新导航与当前页头,**不重挂面板**。 */
125
142
  async refresh(): Promise<void> {
126
143
  await this.load();
@@ -515,7 +532,8 @@ export class ConsolePageHost {
515
532
  ? page.lamps ?? []
516
533
  : [{ label: S.assembly, state: 'offline', hint: page.availability === 'missing' ? S.notInstalled : S.notActivated }],
517
534
  ));
518
- head.append(title, ui.h('p', 'pagedesc', page.id));
535
+ head.appendChild(title);
536
+ chrome.append(head, ui.h('p', 'pagedesc', page.id));
519
537
  const bar = ui.rowbar();
520
538
 
521
539
  for (const badge of asArray(page.badges)) {
@@ -547,9 +565,8 @@ export class ConsolePageHost {
547
565
  });
548
566
  bar.appendChild(open);
549
567
  }
550
- head.appendChild(bar);
551
- if (page.reason) head.appendChild(ui.msgline(page.reason, true));
552
- chrome.appendChild(head);
568
+ if (bar.children.length > 1) chrome.appendChild(bar);
569
+ if (page.reason) chrome.appendChild(ui.msgline(page.reason, true));
553
570
 
554
571
  const panels = tabbed(page);
555
572
  const prompts = asArray(page.prompts);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {
9
+ CONSOLE_AUTH_HEADER,
9
10
  CONSOLE_MANIFEST_ROUTE,
10
11
  panelRoute,
11
12
  type ConsoleManifest,
@@ -57,12 +58,15 @@ function normalizeError(err: unknown, status: number): never {
57
58
 
58
59
  async function send(path: string, init: RequestInit, opts?: RequestOptions): Promise<Response> {
59
60
  try {
60
- return await fetch(path, {
61
+ const res = await fetch(path, {
61
62
  ...init,
62
63
  headers: { ...languageHeaders(), ...(init.headers as Record<string, string> | undefined) },
63
64
  ...(opts?.signal ? { signal: opts.signal } : {}),
64
65
  ...(opts?.keepalive ? { keepalive: true } : {}),
65
66
  });
67
+ // 登录态失效:重新载入入口页,服务端在那里给出登录页。
68
+ if (res.status === 401 && res.headers.get(CONSOLE_AUTH_HEADER) !== null) location.reload();
69
+ return res;
66
70
  } catch (err) {
67
71
  // fetch reject = 网络层没走到 HTTP,没有状态码可言,记 0。
68
72
  normalizeError(err, 0);
@@ -70,6 +70,7 @@ export class Router {
70
70
  private readonly deps: RouterDeps;
71
71
  private readonly listeners = new Set<(route: Route) => void>();
72
72
  private readonly guards = new Set<LeaveGuard>();
73
+ private readonly decisions = new Set<() => Promise<boolean>>();
73
74
  private current: Route;
74
75
  /** 待处理的回拨 hash;写入相同 hash 不触发 hashchange,因此按目标值识别回拨。 */
75
76
  private pendingRevert: string | null = null;
@@ -108,6 +109,11 @@ export class Router {
108
109
  return toDisposable(() => this.guards.delete(guard));
109
110
  }
110
111
 
112
+ addLeaveDecision(decide: () => Promise<boolean>): Disposable {
113
+ this.decisions.add(decide);
114
+ return toDisposable(() => this.decisions.delete(decide));
115
+ }
116
+
111
117
  navigate(segments: readonly string[], query?: Record<string, string>): void {
112
118
  this.go(buildHash(segments, query), (hash) => { this.deps.win.location.hash = hash; });
113
119
  }
@@ -166,6 +172,15 @@ export class Router {
166
172
  return;
167
173
  }
168
174
 
175
+ if (this.decisions.size) {
176
+ this.confirming = true;
177
+ let allowed = true;
178
+ try {
179
+ for (const decide of this.decisions) if (!(await decide())) { allowed = false; break; }
180
+ } catch (error) { allowed = false; this.deps.onError?.(error); }
181
+ finally { this.confirming = false; }
182
+ if (!allowed) { this.revertTo(this.current.raw); return; }
183
+ }
169
184
  const block = this.firstBlock();
170
185
  if (block) {
171
186
  this.confirming = true;