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
@@ -3,13 +3,12 @@ import { icon } from '../../ui/icons.ts';
3
3
  import { NEW_DRAFT_ID, providerDrafts } from './drafts.ts';
4
4
  import type { FeatureContext, FrameworkFeature } from '../feature.ts';
5
5
  import { get, post } from '../../core/api.ts';
6
- import { LANGUAGE } from '../../core/language.ts';
7
- import { panel } from '../../console-pages/builtins/llm-settings/strings.ts';
8
- import { probeCard, type ProbeResult } from '../../console-pages/builtins/llm-settings/panel.ts';
9
6
  import { S } from './strings.ts';
10
7
  import { connectionPath, type HubState, type Connection, type Module, type Detail, type Editing } from './types.ts';
11
8
  import { mountDetail, type DetailController } from './detail.ts';
12
9
 
10
+ /** How often the card list re-reads other deployments' usage. */
11
+ const USAGE_REFRESH_MS = 5000;
13
12
  export async function mountProviders(ctx: FeatureContext): Promise<void> {
14
13
  const { ui, root } = ctx;
15
14
  root.append(pageIntro(ui, S.pageTitle));
@@ -32,7 +31,7 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
32
31
  let renderId = 0;
33
32
  interface CardNode {
34
33
  el: HTMLElement; title: HTMLElement; model: HTMLElement; url: HTMLElement; status: HTMLElement; secondary: HTMLElement;
35
- kind: HTMLElement; activate: HTMLButtonElement; erase: HTMLButtonElement; probe: HTMLButtonElement; probePanel: HTMLElement; probeBody: HTMLElement;
34
+ kind: HTMLElement; activate: HTMLButtonElement; erase: HTMLButtonElement;
36
35
  }
37
36
  const nodes = new Map<string, CardNode>();
38
37
  const rows = new Map<string, Connection>();
@@ -52,40 +51,51 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
52
51
  let node = nodes.get(identity);
53
52
  if (!node) {
54
53
  const el = ui.h('article', 'connection-card'); el.dataset.provider = identity;
55
- const title = ui.h('div', 'connection-name'); const kind = ui.h('div', 'connection-kind');
56
- const model = ui.h('div', 'connection-model'); const url = ui.h('div', 'connection-url');
54
+ const title = ui.h('div', 'connection-name');
55
+ const facts = ui.h('div', 'connection-facts');
56
+ const fact = (key: string) => { const box = ui.h('span', `kv kv-${key}`); const value = ui.h('span', 'kv-v'); box.append(ui.h('span', 'kv-k', key), value); facts.append(box); return value; };
57
+ const kind = fact('kind'); const model = fact('model'); const url = fact('baseUrl');
57
58
  const status = ui.h('div', 'connection-status'); const secondary = ui.h('div', 'connection-secondary');
58
- const actions = ui.rowbar();
59
- const configure = ui.button(S.configure, { size: 'sm', onClick: () => run(() => select(identity)) });
60
- const activate = ui.button(S.activate, { size: 'sm', onClick: () => run(async () => {
59
+ const activate = ui.button('⇄', { size: 'sm', onClick: () => run(async () => {
61
60
  activate.disabled = true;
62
61
  try { await post(connectionPath(identity) + '/activate', {}, opts); state.active = identity; paint(); }
63
62
  finally { activate.disabled = false; }
64
63
  }) });
65
- const probePanel = ui.h('div', 'connection-probe');
66
- const probeBody = ui.h('div', 'connection-probe-body');
67
- probePanel.append(probeBody);
68
- const probe = ui.button(S.probe, { size: 'sm', onClick: () => run(() => runProbe(identity)) });
69
- probe.classList.add('connection-probe-btn'); probe.append(ui.h('span', 'connection-spin'));
64
+ activate.classList.add('connection-activate');
65
+ activate.setAttribute('aria-label', S.activate); activate.title = S.activate;
66
+ const connect = ui.h('div', 'connection-connect'); connect.append(activate, ui.h('span', 'connection-connect-label', S.connect));
70
67
  const erase = eraseButton(identity);
71
- actions.append(configure, probe, activate); el.append(erase, title, kind, model, url, status, secondary, actions, probePanel);
68
+ el.append(erase, title, facts, status, secondary, connect);
69
+ el.tabIndex = 0;
70
+ el.addEventListener('keydown', event => { if (event.target === el && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); run(() => select(identity)); } }, opts);
72
71
  el.addEventListener('click', event => { if (!(event.target as Element).closest('button')) run(() => select(identity)); }, opts);
73
72
  if (identity === NEW_DRAFT_ID) cards.prepend(el); else cards.append(el);
74
- node = { el, title, kind, model, url, status, secondary, activate, erase, probe, probePanel, probeBody }; nodes.set(identity, node);
73
+ node = { el, title, kind, model, url, status, secondary, activate, erase }; nodes.set(identity, node);
75
74
  }
76
75
  const active = identity === state.active;
77
76
  node.el.classList.toggle('is-active', active); node.el.classList.toggle('is-selected', identity === selected);
78
77
  node.title.textContent = identity === NEW_DRAFT_ID ? newDraft?.name || S.newName : identity;
79
78
  node.title.title = node.title.textContent;
79
+ const draft = identity === NEW_DRAFT_ID ? null : drafts.get(identity);
80
+ const model = draft?.entry.spec?.model || item.model || '—'; const url = draft?.entry.baseUrl || item.baseUrl || '—';
80
81
  node.kind.textContent = item.moduleTitle; node.kind.title = item.moduleTitle;
81
- node.model.textContent = item.model || '—'; node.url.textContent = item.baseUrl || '—';
82
- node.model.title = item.model ?? ''; node.url.title = item.baseUrl ?? '';
82
+ node.model.textContent = model; node.url.textContent = url;
83
+ node.model.title = model; node.url.title = url;
83
84
  const readiness = invalid.has(identity) && identity !== NEW_DRAFT_ID ? 'invalid' : item.readiness.state;
84
- node.status.textContent = active ? S.active : S.readiness[readiness];
85
- node.secondary.textContent = active && readiness !== 'ready' ? S.readiness[readiness] : identity !== NEW_DRAFT_ID && drafts.has(identity) ? S.readiness.draft : '';
86
- node.activate.hidden = active || identity === NEW_DRAFT_ID;
87
- node.activate.disabled = item.readiness.state !== 'ready';
88
- node.probe.hidden = identity === NEW_DRAFT_ID;
85
+ const users = 'usage' in item ? item.usage : [];
86
+ const running = users.filter(user => user.running).map(user => user.name);
87
+ const stopped = users.filter(user => !user.running).map(user => user.name);
88
+ const tone = active || running.length ? 'active' : readiness === 'draft' ? 'draft' : readiness === 'ready' ? 'ready' : 'error';
89
+ node.status.dataset.tone = tone;
90
+ node.status.textContent = `${{ active: '●', ready: '✓', error: '!', draft: '✎' }[tone]} ${active ? S.active : running.length ? S.inUse(running.join('、')) : S.readiness[readiness]}`;
91
+ const notes: string[] = [];
92
+ if ((active || running.length) && readiness !== 'ready') notes.push('! ' + S.readiness[readiness]);
93
+ if (identity !== NEW_DRAFT_ID && drafts.has(identity)) notes.push('✎ ' + S.readiness.draft);
94
+ if (active && running.length) notes.push(S.inUse(running.join('、')));
95
+ if (stopped.length) notes.push(S.selectedBy(stopped.join('、')));
96
+ node.secondary.textContent = notes.join(' · ');
97
+ node.secondary.dataset.tone = (active || running.length) && readiness !== 'ready' ? 'error' : 'draft';
98
+ node.activate.parentElement!.hidden = active || identity === NEW_DRAFT_ID || readiness !== 'ready' || running.length > 0;
89
99
  if (identity === NEW_DRAFT_ID) rows.delete(identity); else rows.set(identity, item as Connection);
90
100
  }
91
101
  }
@@ -118,23 +128,9 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
118
128
  await refresh();
119
129
  if (selected === identity) await select(fallback(), true);
120
130
  }
121
- /** 探活结果挂在卡片下面,与模块页上的那份同一种呈现。 */
122
- async function runProbe(identity: string): Promise<void> {
123
- const node = nodes.get(identity);
124
- if (!node || node.probe.disabled) return;
125
- node.probe.disabled = true; node.probe.classList.add('is-busy');
126
- try {
127
- const result = await post<ProbeResult>(connectionPath(identity) + '/test', {}, opts);
128
- if (ctx.signal.aborted) return;
129
- const close = ui.button(S.probeClose, { size: 'sm', onClick: () => node.probePanel.classList.remove('is-open') });
130
- node.probeBody.replaceChildren(probeCard(ui, LANGUAGE === 'en' ? panel.en : panel.zh, result), close);
131
- node.probePanel.classList.add('is-open');
132
- } finally { node.probe.disabled = false; node.probe.classList.remove('is-busy'); }
133
- }
134
131
  async function refresh() { state = await get<HubState>('/api/providers', opts); paint(); }
135
132
  async function select(identity: string, force = false): Promise<void> {
136
133
  if (!force && identity === selected) return;
137
- if (!force && controller && !(await controller.leave())) return;
138
134
  const gen = ++renderId;
139
135
  controller?.dispose(); controller = null;
140
136
  selected = identity; paint(); detailRoot.replaceChildren();
@@ -146,9 +142,13 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
146
142
  if (gen !== renderId || ctx.signal.aborted) return;
147
143
  const detailView = ui.h('div'); detailRoot.replaceChildren(detailView);
148
144
  const mounted = await mountDetail({ ctx, root: detailView, modules, saved, draft: identity === NEW_DRAFT_ID ? newDraft : drafts.get(identity),
149
- discarded: () => { invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = drafts.get(NEW_DRAFT_ID); paint(); },
150
- saveDraft: editing => { drafts.set(editing); paint(); },
151
- changed: (editing, hasErrors) => { if (hasErrors) invalid.add(identity); else invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = editing; paint(); },
145
+ // Edits persist as a browser draft as they happen; a form back at its saved state drops the draft.
146
+ changed: (editing, hasErrors, dirty) => {
147
+ if (hasErrors) invalid.add(identity); else invalid.delete(identity);
148
+ if (identity === NEW_DRAFT_ID) { newDraft = editing; drafts.set(editing); }
149
+ else if (dirty) drafts.set(editing); else drafts.remove(identity);
150
+ paint();
151
+ },
152
152
  onSaved: async (name, show = true) => { drafts.remove(identity); invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = null; await refresh(); if (show) await select(name, true); },
153
153
  cancelled: async () => { drafts.remove(identity); invalid.delete(identity); if (identity === NEW_DRAFT_ID) newDraft = null; await select(identity === NEW_DRAFT_ID ? state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '' : identity, true); },
154
154
  deleted: async () => { drafts.remove(identity); invalid.delete(identity); await refresh(); await select(state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '', true); },
@@ -164,8 +164,8 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
164
164
  }
165
165
  async function create() {
166
166
  if (newDraft) return select(NEW_DRAFT_ID);
167
- if (controller && !(await controller.leave())) return;
168
167
  newDraft = { original: null, name: '', entry: { kind: '', baseUrl: '' }, secretValue: '', raw: {} };
168
+ drafts.set(newDraft);
169
169
  await select(NEW_DRAFT_ID, true);
170
170
  }
171
171
  const moduleName = (kind: string) => modules.find(module => module.id === kind)?.title ?? kind;
@@ -175,10 +175,16 @@ export async function mountProviders(ctx: FeatureContext): Promise<void> {
175
175
  creator.append(ui.button(S.create, { variant: 'primary', onClick: () => run(create) }), ui.h('p', 'connection-create-hint', S.createHint));
176
176
  index.append(creator, cards);
177
177
  ctx.lifecycle.own({ dispose: () => { renderId++; controller?.dispose(); } });
178
- ctx.lifecycle.own(ctx.router.addLeaveDecision(async () => controller ? controller.leave() : true));
179
178
  ctx.lifecycle.own(ctx.router.onChange(route => {
180
179
  if (route.segments[0] === 'providers' && route.segments[1]) run(() => select(route.segments[1]));
181
180
  }));
181
+ // Other deployments' selections and liveness change without this page; the detail form is left alone.
182
+ let refreshing = false;
183
+ ctx.lifecycle.interval(() => {
184
+ if (refreshing) return;
185
+ refreshing = true;
186
+ run(async () => { try { await refresh(); } finally { refreshing = false; } });
187
+ }, USAGE_REFRESH_MS);
182
188
  paint();
183
189
  const wanted = ctx.route.segments[1] ?? (newDraft ? NEW_DRAFT_ID : undefined);
184
190
  await select(wanted && (wanted === NEW_DRAFT_ID || state.providers.some(item => item.name === wanted)) ? wanted : state.providers.find(item => item.name === state.active)?.name ?? state.providers[0]?.name ?? '', true);
@@ -1,50 +1,54 @@
1
1
  import { pick } from '../../core/language.ts';
2
2
  const zh = {
3
- pageTitle: '模型供应商', navLabel: '模型供应商', navGroup: 'Core', create: '+ 新建供应商实例', newName: '新建实例',
3
+ pageTitle: '模型供应商', navLabel: '模型供应商', navGroup: 'Core', create: '+ 新建供应商实例', newName: '未命名实例',
4
4
  createHint: '实例 = 填一组供应商配置保存成卡片',
5
5
  empty: '还没有模型供应商', emptyHint: '添加一条模型连接后,Cortico 才能进行模型推理。',
6
- configure: '⚙ 配置', activate: '⇄ 设为当前', active: '当前模型', missing: '当前模型供应商不存在,配置中引用:',
7
- eraseConfirm: '确认删除', probe: '测试可用性', probeClose: '收起',
8
- readiness: { ready: '配置完成', 'needs-setup': '需要完善配置', 'runtime-unavailable': '运行环境未就绪', invalid: '配置错误', 'module-missing': '模块不可用', draft: '草稿' } as Record<string, string>,
6
+ eraseConfirm: '确认删除',
7
+ connect: '连接', activate: '设为当前供应商', active: '当前模型', missing: '当前模型供应商不存在,配置中引用:',
8
+ inUse: (names: string) => `其他实例使用中:${names}`, selectedBy: (names: string) => `已被 ${names} 选用(未运行)`,
9
+ readiness: { ready: '可连接', 'needs-setup': '需要完善配置', 'runtime-unavailable': '运行环境未就绪', invalid: '配置错误', 'module-missing': '模块不可用', draft: '草稿' } as Record<string, string>,
9
10
  basic: '基本信息', connection: '连接', modelSection: '模型与生成', moduleSection: '模块设置', pricing: '成本与计价', advanced: '高级协议',
10
11
  name: '供应商名称', module: '供应商类型', url: 'API 地址', key: 'API Key', model: '模型',
11
- moduleNote: (description: string, id: string) => `${description} 模块标识:${id}`,
12
12
  nameHint: '英文字母、数字、- 或 _,以字母或数字开头,不允许空格或系统保留名。',
13
13
  fixedModule: '供应商类型保存后不可更改。使用其他类型需新建供应商。',
14
- test: '测试连接', testOk: '测试成功', testFailed: '测试失败', savedFirst: '请先保存配置,再执行此操作。',
15
- keySet: '已配置;留空保留现有密钥', keyEmpty: '输入 API Key', fetchModels: '获取模型列表',
14
+ test: '测试连接', testOk: '测试成功', testFailed: '测试失败',
15
+ keyEmpty: '输入 API Key', fetchModels: '获取模型列表',
16
16
  reasoning: '推理强度', thinking: '开启推理', temperature: '温度', maxTokens: '最大输出 token', context: '上下文上限', tier: '服务档位', images: '接受图片',
17
+ catalogNoWindow: '目录没有给出这个模型的上下文上限;请按服务商文档手填。',
17
18
  advancedHint: '仅在使用自定义 API 网关或兼容服务时调整。', secret: '密钥环境变量名',
18
19
  extraHeaders: '附加请求头(JSON object)', extraBody: '附加请求体(JSON object)', priceRules: '完整计价规则(JSON array)',
19
20
  shared: '共享配置:此供应商可被多个 Bot 使用。修改会改变这份共享配置;若需要不同参数,建议新建一个供应商。',
20
- remove: '删除供应商', duplicate: '复制供应商', cancel: '取消', draft: '保存草稿', save: '保存', saved: '已保存', drafted: '草稿已保存到此浏览器。API Key 不保存在浏览器草稿中。',
21
+ remove: '删除供应商', duplicate: '复制供应商', cancel: '放弃更改', save: '保存', saved: '已保存',
22
+ draftNote: '改动暂存在此浏览器,保存后才写入配置。API Key 不进暂存。',
21
23
  chooseModule: '请选择供应商类型。', invalidNumber: '请输入有效范围内的数值。',
22
24
  required: '此项必填。', jsonObject: '请输入合法 JSON 对象。', jsonArray: '请输入合法 JSON 数组。',
23
- unsaved: '你有未保存的更改。', stay: '留在这里', discard: '放弃更改并切换',
25
+ unsaved: '已暂存;保存后写入配置。',
24
26
  deleteConfirm: '删除供应商及其目录中的全部文件?', referenced: '此供应商正在被以下 Bot 使用,不能删除:', reload: '重新加载',
25
27
  };
26
28
  const en: typeof zh = {
27
- pageTitle: 'Model connections', navLabel: 'Model connections', navGroup: 'Core', create: '+ New connection instance', newName: 'New instance',
29
+ pageTitle: 'Model connections', navLabel: 'Model connections', navGroup: 'Core', create: '+ New connection instance', newName: 'Unnamed instance',
28
30
  createHint: 'An instance is one set of provider settings saved as a card.',
29
31
  empty: 'No model connections yet', emptyHint: 'Add a model connection to enable inference.',
30
- configure: ' Configure', activate: '⇄ Set current', active: 'Current model', missing: 'Current model connection is missing: ',
31
- eraseConfirm: 'Confirm delete', probe: 'Test endpoint', probeClose: 'Hide',
32
- readiness: { ready: 'Configured', 'needs-setup': 'Needs setup', 'runtime-unavailable': 'Runtime unavailable', invalid: 'Invalid configuration', 'module-missing': 'Module unavailable', draft: 'Draft' },
32
+ eraseConfirm: 'Confirm delete',
33
+ connect: 'Connect', activate: 'Set current connection', active: 'Current model', missing: 'Current model connection is missing: ',
34
+ inUse: (names: string) => `In use by: ${names}`, selectedBy: (names: string) => `Selected by ${names} (not running)`,
35
+ readiness: { ready: 'Ready to connect', 'needs-setup': 'Needs setup', 'runtime-unavailable': 'Runtime unavailable', invalid: 'Invalid configuration', 'module-missing': 'Module unavailable', draft: 'Draft' },
33
36
  basic: 'Basic information', connection: 'Connection', modelSection: 'Model and generation', moduleSection: 'Module settings', pricing: 'Pricing', advanced: 'Advanced protocol',
34
37
  name: 'Connection name', module: 'Connection type', url: 'API URL', key: 'API Key', model: 'Model',
35
- moduleNote: (description: string, id: string) => `${description} Module ID: ${id}`,
36
38
  nameHint: 'English letters, digits, - or _; start with a letter or digit. No spaces or reserved system names.',
37
39
  fixedModule: 'The connection type cannot change after saving. Create a connection to use another type.',
38
- test: 'Test connection', testOk: 'Test successful', testFailed: 'Test failed', savedFirst: 'Save the configuration before this operation.',
39
- keySet: 'Configured; leave blank to keep the current key', keyEmpty: 'Enter API Key', fetchModels: 'Fetch model list',
40
+ test: 'Test connection', testOk: 'Test successful', testFailed: 'Test failed',
41
+ keyEmpty: 'Enter API Key', fetchModels: 'Fetch model list',
40
42
  reasoning: 'Reasoning effort', thinking: 'Enable reasoning', temperature: 'Temperature', maxTokens: 'Max output tokens', context: 'Context limit', tier: 'Service tier', images: 'Accept images',
43
+ catalogNoWindow: 'The model list states no context window for this model; fill it in from the provider\'s documentation.',
41
44
  advancedHint: 'Adjust only for custom API gateways or compatible services.', secret: 'Credential environment variable',
42
45
  extraHeaders: 'Extra headers (JSON object)', extraBody: 'Extra request body (JSON object)', priceRules: 'Full pricing rules (JSON array)',
43
46
  shared: 'Shared configuration: multiple bots can use this connection. Changes update this shared configuration; create another connection if you need different settings.',
44
- remove: 'Delete connection', duplicate: 'Duplicate connection', cancel: 'Cancel', draft: 'Save draft', save: 'Save', saved: 'Saved', drafted: 'Draft saved in this browser. API Keys are not stored in browser drafts.',
47
+ remove: 'Delete connection', duplicate: 'Duplicate connection', cancel: 'Discard changes', save: 'Save', saved: 'Saved',
48
+ draftNote: 'Edits are kept in this browser and written to the configuration on save. API Keys are never kept.',
45
49
  chooseModule: 'Choose a connection type.', invalidNumber: 'Enter a number within the allowed range.',
46
50
  required: 'Required.', jsonObject: 'Enter a valid JSON object.', jsonArray: 'Enter a valid JSON array.',
47
- unsaved: 'You have unsaved changes.', stay: 'Stay here', discard: 'Discard and switch',
51
+ unsaved: 'Kept as a draft; written on save.',
48
52
  deleteConfirm: 'Delete this connection and all files in its directory?', referenced: 'This connection is used by these bots and cannot be deleted: ', reload: 'Reload',
49
53
  };
50
54
  export const S = pick({ zh, en });
@@ -138,7 +138,9 @@ export function checkbox(
138
138
  */
139
139
  export function field(doc: Document, label: string, control: HTMLElement): HTMLLabelElement {
140
140
  const el = h(doc, 'label', 'fieldrow');
141
- el.append(h(doc, 'span', 'fieldlabel', label), control);
141
+ const caption = h(doc, 'span', 'fieldlabel', label.replace(/\s*\*$/, ''));
142
+ if (/\*$/.test(label)) caption.append(h(doc, 'span', 'required-mark', ' *'));
143
+ el.append(caption, control);
142
144
  return el;
143
145
  }
144
146
 
@@ -1749,17 +1749,23 @@
1749
1749
  .connection-card{min-width:0;border:1px solid var(--line);border-radius:12px;padding:14px;background:var(--paper);cursor:pointer;transition:background .15s,border-color .15s}
1750
1750
  .connection-card.is-active{border-color:var(--accent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 12%,transparent)}
1751
1751
  .connection-card.is-selected{background:color-mix(in srgb,var(--ink) 5%,var(--paper));outline:2px solid var(--ink-dim);outline-offset:1px}
1752
- .connection-name,.connection-kind,.connection-model,.connection-url,.connection-status,.connection-secondary{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1753
- .connection-name{font:700 17px/1.25 var(--sans)}.connection-kind{margin-top:4px;font:600 11.5px/1.3 var(--sans);color:var(--muted-foreground)}.connection-model{margin-top:9px;font-size:13px}.connection-url{font-size:11px;color:var(--ink-dim);margin-top:3px}
1752
+ .connection-name,.connection-status,.connection-secondary{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1753
+ .connection-name{font:700 17px/1.25 var(--sans)}
1754
+ .connection-facts{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;min-width:0}
1755
+ .connection-facts .kv{max-width:100%}.connection-facts .kv-v{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1756
+ .kv-kind{--kv-line:color-mix(in srgb,var(--ink-blue) 60%,transparent)}.kv-model{--kv-line:color-mix(in srgb,var(--accent-2) 75%,transparent)}.kv-baseUrl{--kv-line:var(--line-strong)}
1754
1757
  .connection-status{font-size:12px;margin-top:14px}.connection-secondary{font-size:11px;color:var(--ink-dim)}
1755
- .connection-card .rowbar{margin-top:12px;justify-content:space-between;gap:4px}
1756
1758
  .connection-detail{min-width:0}.connection-detail .sheet{margin-bottom:16px}
1759
+ .connection-identity{background:var(--paper-2);border-color:var(--line-2)}
1760
+ .connection-identity+.connection-flow{margin-top:22px;padding-top:18px;border-top:1px dashed var(--line-2)}
1761
+ .connection-flow{counter-reset:step}.connection-step{counter-increment:step;min-width:0}
1762
+ .connection-step td.mono{white-space:normal;overflow-wrap:anywhere}
1763
+ .connection-step table.data .btn{white-space:nowrap}
1764
+ .connection-step>.sheet>h3::before,.connection-step>details.sheet>summary>h3::before{content:counter(step);display:inline-grid;place-items:center;width:20px;height:20px;margin-right:9px;border-radius:50%;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent);font:600 11px/1 var(--mono);vertical-align:2px}
1757
1765
  .connection-detail .connection-field{margin-bottom:15px}.connection-detail input:not([type=checkbox]),.connection-detail select,.connection-detail textarea{max-width:100%;width:100%}
1758
1766
  .connection-detail .field-error{color:var(--danger,#b43c38);font-size:12px;min-height:0}.connection-shared{color:var(--ink-dim);font-size:12px;line-height:1.7;margin:20px 0 12px}
1759
1767
  .connection-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.connection-actions .grow{flex:1}
1760
1768
  @media(max-width:760px){.connection-hub{grid-template-columns:1fr}.connection-index{position:static;max-height:320px}.connection-cards{grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}}
1761
- .connection-guard{border:1px solid var(--line);border-radius:16px;padding:24px;background:var(--paper);color:var(--ink);max-width:560px;box-shadow:0 12px 60px #0003}
1762
- .connection-guard::backdrop{background:#0005}.connection-guard h3{margin-bottom:20px}
1763
1769
 
1764
1770
  .live-summary { flex-wrap:wrap; }
1765
1771
  .live-connection { display:inline-flex; align-items:center; gap:7px; min-width:0; max-width:48%; background:none; border:0; color:var(--ink-soft); cursor:pointer; padding:0; font:11.5px/1 var(--sans); }
@@ -1807,26 +1813,28 @@
1807
1813
  .coreview > .scroll { scrollbar-gutter: auto; }
1808
1814
  .panelslot > :first-child > .sheet:first-child { padding-top: 0; }
1809
1815
 
1810
- /* 卡片上的删除与探活。 */
1816
+ /* 卡片:连接在右上,删除在右下,图标底边对齐状态行。 */
1811
1817
  .connection-create { margin-bottom:16px; }
1812
1818
  .connection-create-hint { margin:7px 2px 0; font:var(--text-detail)/1.5 var(--sans); color:var(--muted-foreground); }
1813
1819
  .connection-create > .btn { width:100%; }
1814
- .connection-card { position:relative; }
1815
- .connection-erase { position:absolute; top:10px; right:10px; display:flex; align-items:center; gap:0; height:26px; padding:0 6px; border:1px solid transparent; border-radius:8px; background:none; color:var(--ink-dim); cursor:pointer; transition:color .14s ease, background .14s ease, border-color .14s ease, gap .18s ease; }
1816
- .connection-erase .icon { width:14px; height:14px; flex:none; }
1820
+ .connection-card { position:relative; padding-right:64px; }
1821
+ .connection-erase { position:absolute; bottom:13px; right:13px; display:flex; align-items:center; gap:0; height:28px; padding:0 4px; border:1px solid transparent; border-radius:8px; background:none; color:var(--ink-dim); cursor:pointer; transition:color .14s ease, background .14s ease, border-color .14s ease, gap .18s ease; }
1822
+ .connection-erase .icon { width:20px; height:20px; flex:none; }
1817
1823
  .connection-erase:hover { color:var(--danger,#b43c38); background:var(--hover); }
1818
1824
  .connection-erase-text { display:grid; grid-template-columns:0fr; overflow:hidden; font:600 12px/1 var(--sans); white-space:nowrap; transition:grid-template-columns .18s ease; }
1819
1825
  .connection-erase-text > span { overflow:hidden; }
1820
1826
  .connection-erase.is-armed { gap:6px; color:var(--danger,#b43c38); border-color:currentColor; background:var(--paper); }
1821
1827
  .connection-erase.is-armed .connection-erase-text { grid-template-columns:1fr; }
1822
- .connection-name { padding-right:34px; }
1823
- .connection-probe-btn .connection-spin { display:none; width:12px; height:12px; margin-left:6px; border:2px solid currentColor; border-right-color:transparent; border-radius:50%; animation:connection-spin .7s linear infinite; }
1824
- .connection-probe-btn.is-busy .connection-spin { display:inline-block; }
1825
- @keyframes connection-spin { to { transform:rotate(360deg); } }
1826
- .connection-probe { display:grid; grid-template-rows:0fr; margin-top:0; transition:grid-template-rows .2s ease, margin-top .2s ease, opacity .2s ease; opacity:0; }
1827
- .connection-probe.is-open { grid-template-rows:1fr; margin-top:12px; opacity:1; }
1828
- .connection-probe-body { overflow:hidden; }
1829
- .connection-probe .sheet { margin:0; padding:10px 12px; border:1px solid var(--border); border-radius:10px; background:var(--surface); }
1830
- .connection-probe .kvtable td { padding:3px 0; font-size:11.5px; word-break:break-word; }
1831
- .connection-probe .kvtable td:first-child { padding-right:8px; color:var(--muted-foreground); white-space:nowrap; }
1832
- .connection-probe > .connection-probe-body > .btn { margin-top:8px; }
1828
+ .connection-connect { position:absolute; top:12px; right:12px; width:32px; text-align:center; }
1829
+ .connection-connect[hidden] { display:none; }
1830
+ .connection-hub .btn.connection-activate { display:block; width:32px; height:28px; min-height:28px; padding:0; border:0; font-size:17px; line-height:28px; }
1831
+ .connection-connect-label { display:block; font-size:10px; line-height:14px; color:var(--muted-foreground); }
1832
+ .connection-index { scrollbar-width:none; }
1833
+ .connection-index::-webkit-scrollbar { display:none; }
1834
+ .required-mark { color:var(--danger); }
1835
+ .connection-test { gap:12px; align-items:center; }
1836
+ .connection-test .msgline { margin:0; }
1837
+ .connection-status[data-tone="active"] { color:var(--accent); }
1838
+ .connection-status[data-tone="ready"] { color:var(--ok); }
1839
+ .connection-status[data-tone="error"], .connection-secondary[data-tone="error"] { color:var(--danger); }
1840
+ .connection-status[data-tone="draft"], .connection-secondary[data-tone="draft"] { color:var(--foreground); }
package/src/web/server.ts CHANGED
@@ -1409,8 +1409,10 @@ export class WebApp {
1409
1409
  app.post('/api/providers/:name/save', express.json(), providerRoute((hub, req) => hub.save(String(req.params.name), req.body, this.languageOf(req))));
1410
1410
  app.post('/api/providers/:name/delete', express.json(), providerRoute((hub, req) => hub.delete(String(req.params.name), req.body.expectedRevision)));
1411
1411
  app.post('/api/providers/:name/activate', providerRoute((hub, req) => hub.activate(String(req.params.name), this.languageOf(req))));
1412
- app.post('/api/providers/:name/test', providerRoute((hub, req) => hub.action(String(req.params.name), 'test', this.languageOf(req))));
1413
- app.post('/api/providers/:name/models', providerRoute((hub, req) => hub.action(String(req.params.name), 'models', this.languageOf(req))));
1412
+ // A body with `entry` probes the browser's draft instead of the saved connection.
1413
+ const draftOf = (req: Request) => (req.body && typeof req.body === 'object' && req.body.entry ? { entry: req.body.entry, secretValue: req.body.secretValue } : undefined);
1414
+ app.post('/api/providers/:name/test', express.json(), providerRoute((hub, req) => hub.action(String(req.params.name), 'test', this.languageOf(req), draftOf(req))));
1415
+ app.post('/api/providers/:name/models', express.json(), providerRoute((hub, req) => hub.action(String(req.params.name), 'models', this.languageOf(req), draftOf(req))));
1414
1416
 
1415
1417
  app.get('/api/status', wrap((_req, res) => {
1416
1418
  res.json({ ...this.safeStatus(), uptimeSec: Math.round(process.uptime()) });
@@ -58,6 +58,17 @@ export function isBuiltinPanel(name: unknown): name is string {
58
58
  return typeof name === 'string' && BUILTIN_PANEL_RE.test(name);
59
59
  }
60
60
 
61
+ /**
62
+ * 端点编辑页自带的四块段落。`llm:*` 页除 `settings` 外的面板按声明顺序就是编辑页的段落:
63
+ * `builtin` 取这里的名字的由编辑页自己画,标题与说明用声明的;其余是模块自己的浏览器实现。
64
+ */
65
+ export const CONNECTION_EDITOR_BLOCKS = ['connection-endpoint', 'connection-model', 'connection-pricing', 'connection-protocol'] as const;
66
+ export type ConnectionEditorBlock = (typeof CONNECTION_EDITOR_BLOCKS)[number];
67
+
68
+ export function isConnectionEditorBlock(name: unknown): name is ConnectionEditorBlock {
69
+ return (CONNECTION_EDITOR_BLOCKS as readonly unknown[]).includes(name);
70
+ }
71
+
61
72
  /** 构建脚本与服务端注册表共用的目录到 page id 映射。 */
62
73
  export function pageIdFor(kind: ContributingKind, name: string): string {
63
74
  return `${kind}:${name}`;
@@ -85,6 +96,8 @@ export interface ConsoleAssetManifest {
85
96
  core: string | null;
86
97
  /** key 为 page id。 */
87
98
  providers: Record<string, ConsoleAssetEntry>;
99
+ /** esbuild 在仓库内读到的每个源文件的 sha256,键为仓库相对路径;启动器据此判断产物是否落后于源码。 */
100
+ sources?: Record<string, string>;
88
101
  }
89
102
 
90
103
  /** 静态资源 URL 的唯一合法前缀。服务端把它映射到 `dist/web/`。 */
@@ -65,6 +65,8 @@ interface ClientLaunch {
65
65
  nativeJars: string[];
66
66
  /** 实际用到的版本 id 链,child → root */
67
67
  versionChain: string[];
68
+ /** 版本 JSON 点名、但 libraries 目录里没有的条目,相对 libraries 目录。类路径里少了它们。 */
69
+ missingLibraries: string[];
68
70
  }
69
71
 
70
72
  const OS_NAMES: Partial<Record<NodeJS.Platform, string>> = {
@@ -202,6 +204,7 @@ export function buildClientLaunch(input: ClientLaunchInput): ClientLaunch | { er
202
204
  const librariesDir = join(gameDir, 'libraries');
203
205
  const classpath: string[] = [];
204
206
  const nativeJars: string[] = [];
207
+ const missingLibraries: string[] = [];
205
208
  const seenPaths = new Set<string>();
206
209
  for (const version of chain) {
207
210
  for (const lib of version.libraries ?? []) {
@@ -211,7 +214,10 @@ export function buildClientLaunch(input: ClientLaunchInput): ClientLaunch | { er
211
214
  const abs = join(librariesDir, rel);
212
215
  if (seenPaths.has(abs)) continue;
213
216
  seenPaths.add(abs);
214
- if (!existsSync(abs)) continue;
217
+ if (!existsSync(abs)) {
218
+ missingLibraries.push(rel);
219
+ continue;
220
+ }
215
221
  if (/natives/i.test(rel)) nativeJars.push(abs);
216
222
  classpath.push(abs);
217
223
  }
@@ -222,7 +228,9 @@ export function buildClientLaunch(input: ClientLaunchInput): ClientLaunch | { er
222
228
  const separator = os === 'windows' ? ';' : ':';
223
229
  const vars: Record<string, string> = {
224
230
  auth_player_name: input.username,
225
- version_name: versionId,
231
+ // 加载器版本靠 `-DignoreList=...,${version_name}.jar` 把主 jar 挡在模块路径外,所以这个名字
232
+ // 必须是实际加载的那个 jar;继承链上的版本没有自己的 jar,用的是 jarId 那个。
233
+ version_name: jarId,
226
234
  game_directory: gameDir,
227
235
  assets_root: join(gameDir, 'assets'),
228
236
  game_assets: join(gameDir, 'assets'),
@@ -268,5 +276,6 @@ export function buildClientLaunch(input: ClientLaunchInput): ClientLaunch | { er
268
276
  mainClass,
269
277
  nativeJars,
270
278
  versionChain: chain.map((v, i) => v.id ?? (i === 0 ? versionId : '?')),
279
+ missingLibraries,
271
280
  };
272
281
  }
@@ -215,6 +215,10 @@ export class GameClient {
215
215
  return this.state();
216
216
  }
217
217
  this.activeGameDir = gameDir;
218
+ if ('missingLibraries' in launch && launch.missingLibraries.length > 0) {
219
+ this.opts.log.warn(`${this.opts.label}版本 JSON 点名的 ${launch.missingLibraries.length} 个库`
220
+ + `不在 libraries 目录,类路径里没有它们:${launch.missingLibraries.join(' ')}`);
221
+ }
218
222
  if ('nativeJars' in launch) prepareNatives(launch.nativeJars, launch.nativesDir, this.opts.log);
219
223
  if (!this.opts.commandOverride) {
220
224
  if (this.opts.noPauseOnLostFocus()) {
@@ -132,7 +132,9 @@ export class QQWorld implements World {
132
132
  private host?: WorldHost;
133
133
  private driver?: OneBotDriver;
134
134
  private log: Logger = nullLogger();
135
- private imagePolicy: ImageRenderPolicy = () => '[图片]';
135
+ /** 主模型支持 image/png World 配置了视觉模型时启用取图;每次渲染按当前端点判断。 */
136
+ private readonly imagePolicy: ImageRenderPolicy = (data) =>
137
+ makeImagePolicy(!!this.vision || (this.host?.modelFacts.accepts('image/png') ?? false))(data);
136
138
 
137
139
  /** 外挂视觉(注入=auxVLM生效);null=无,一切保持[图片]占位现状 */
138
140
  private readonly vision?: VisionService;
@@ -548,8 +550,6 @@ export class QQWorld implements World {
548
550
  async start(host: WorldHost): Promise<void> {
549
551
  this.host = host;
550
552
  this.log = host.log;
551
- // 主模型支持 image/png 或 World 配置了视觉模型时启用取图。
552
- this.imagePolicy = makeImagePolicy(!!this.vision || host.modelFacts.accepts('image/png'));
553
553
  // 外挂视觉的用量自愿上报进 core 的成本账(不报就在成本页看不到)
554
554
  this.vision?.setUsageSink((usage, model) =>
555
555
  host.reportUsage(usage, { model, label: 'QQWorld·辅助视觉' }),