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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/boot.ts +18 -0
- package/src/bot.ts +40 -5
- package/src/core/README.md +4 -2
- package/src/core/blobs.ts +23 -2
- package/src/core/config-schema.ts +1 -1
- package/src/core/config.ts +2 -16
- package/src/core/core.ts +15 -5
- package/src/core/loop.ts +55 -27
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +18 -2
- package/src/core/util.ts +15 -0
- package/src/deploy.ts +5 -4
- package/src/extensions/README.md +6 -4
- package/src/extensions/dry-mount.ts +23 -2
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +23 -9
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +32 -7
- package/src/providers/base.ts +2 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/hub.ts +270 -0
- package/src/providers/console/settings.ts +11 -19
- package/src/providers/console/types.ts +5 -0
- package/src/providers/hub-api.ts +3 -0
- package/src/providers/llamacpp/config.ts +38 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
- package/src/providers/llamacpp/console/server.ts +23 -2
- package/src/providers/llamacpp/index.ts +5 -1
- package/src/providers/llamacpp/native.ts +10 -5
- package/src/providers/llamacpp/options.ts +2 -0
- package/src/providers/name.ts +8 -0
- package/src/providers/openai-responses-compat/config.ts +33 -0
- package/src/providers/openai-responses-compat/console/client.ts +5 -0
- package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
- package/src/providers/openai-responses-compat/console/server.ts +127 -0
- package/src/providers/openai-responses-compat/index.ts +40 -12
- package/src/providers/openai-responses-compat/native.ts +9 -4
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/registry.ts +5 -0
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +12 -3
- package/src/web/auth.ts +80 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +42 -35
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +2 -4
- package/src/web/client/console-pages/host.ts +22 -5
- package/src/web/client/core/api.ts +5 -1
- package/src/web/client/core/router.ts +15 -0
- package/src/web/client/features/extensions/index.ts +276 -41
- package/src/web/client/features/extensions/strings.ts +84 -10
- package/src/web/client/features/feature.ts +1 -1
- package/src/web/client/features/live/diagnostics.ts +32 -0
- package/src/web/client/features/live/index.ts +27 -11
- package/src/web/client/features/live/protocol.ts +1 -0
- package/src/web/client/features/live/strings.ts +9 -3
- package/src/web/client/features/providers/detail.ts +262 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +173 -87
- package/src/web/client/features/providers/strings.ts +44 -17
- package/src/web/client/features/providers/types.ts +15 -0
- package/src/web/client/features/settings/general.ts +12 -0
- package/src/web/client/features/settings/strings.ts +6 -0
- package/src/web/client/main.ts +4 -0
- package/src/web/client/shell/index.ts +1 -1
- package/src/web/client/ui/icons.ts +9 -1
- package/src/web/client/ui/prompt-input.tsx +17 -5
- package/src/web/client/ui/strings.ts +0 -2
- package/src/web/diagnostics.ts +133 -0
- package/src/web/public/login.html +67 -0
- package/src/web/public/styles.css +122 -18
- package/src/web/server.ts +227 -21
- package/src/web/shared/client-panel.ts +3 -0
- package/src/web/shared/console-protocol.ts +3 -1
- package/src/worlds/bilibili/README.md +1 -1
- package/src/worlds/bilibili/overlay/server.ts +4 -2
- package/src/worlds/minecraft/ADAPT.md +66 -0
- package/src/worlds/minecraft/README.md +8 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +53 -7
- 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
|
-
*
|
|
18
|
-
* compatibility domain and model match this request.
|
|
19
|
-
*
|
|
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
|
-
|
|
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 (
|
|
32
|
-
|
|
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)
|
|
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
|
-
|
|
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` 是配置组与存储清单的通用视图,运行诊断页与
|
package/src/web/auth.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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(
|
|
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
|
-
|
|
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():
|
|
147
|
+
function commitDraft(): boolean {
|
|
143
148
|
try {
|
|
144
|
-
const pricing = JSON.parse(raw.value)
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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.
|
|
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
|
-
|
|
551
|
-
if (page.reason)
|
|
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
|
-
|
|
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;
|