cortico 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/boot.ts +29 -0
- package/src/bot.ts +40 -5
- package/src/core/README.md +12 -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 +18 -6
- package/src/core/generation.ts +66 -9
- package/src/core/instance-lock.ts +6 -0
- package/src/core/loop.ts +62 -32
- package/src/core/secrets.ts +2 -4
- package/src/core/session.ts +3 -2
- package/src/core/timers.ts +8 -6
- package/src/core/types.ts +23 -4
- 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 +34 -6
- package/src/extensions/manifest.ts +21 -11
- package/src/extensions.ts +147 -23
- package/src/launcher.ts +30 -22
- package/src/protocol/open-responses/context-helpers.ts +3 -1
- package/src/protocol/open-responses/context-log.ts +37 -9
- package/src/providers/README.md +44 -12
- package/src/providers/base.ts +2 -0
- package/src/providers/console/config.ts +36 -0
- package/src/providers/console/hub.ts +307 -0
- package/src/providers/console/settings.ts +30 -26
- package/src/providers/console/strings.ts +14 -0
- 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/models-panel.ts +87 -4
- package/src/providers/llamacpp/console/runtime-panel.ts +36 -73
- package/src/providers/llamacpp/console/server.ts +47 -4
- package/src/providers/llamacpp/huggingface.ts +93 -0
- 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/llamacpp/strings.ts +42 -0
- package/src/providers/name.ts +13 -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 +135 -0
- package/src/providers/openai-responses-compat/index.ts +40 -12
- package/src/providers/openai-responses-compat/native.ts +15 -5
- package/src/providers/openai-responses-compat/strings.ts +62 -1
- package/src/providers/pricebook.ts +5 -5
- package/src/providers/registry.ts +24 -10
- package/src/providers/strings.ts +2 -0
- package/src/providers/transport/errors.ts +9 -0
- package/src/providers/transport/response-http.ts +7 -3
- package/src/providers/transport/responses-input.ts +59 -10
- package/src/web/README.md +15 -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 +309 -0
- package/src/web/client/features/providers/drafts.ts +23 -0
- package/src/web/client/features/providers/index.ts +179 -87
- package/src/web/client/features/providers/strings.ts +48 -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/fields.ts +3 -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 +130 -18
- package/src/web/server.ts +229 -21
- package/src/web/shared/client-panel.ts +3 -0
- package/src/web/shared/console-protocol.ts +16 -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/client-launch.ts +11 -2
- package/src/worlds/minecraft/client.ts +4 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
- package/src/worlds/qq/normalize.ts +14 -0
- package/src/worlds/qq/world.ts +56 -10
- package/src/worlds/terminal/world.ts +3 -4
|
@@ -1,100 +1,192 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「语言模型」页 —— 模型供应模块的入口。
|
|
3
|
-
*
|
|
4
|
-
* 左侧次级菜单列出 manifest 里 kind 为 `llm` 的页(每个供应模块一条),右侧由
|
|
5
|
-
* 嵌入的控制台页宿主渲染选中模块的面板:实例与模型、授权、托管、配置。这一页不认识
|
|
6
|
-
* 任何具体模块——名字、灯、徽标与面板全部来自 manifest。
|
|
7
|
-
*
|
|
8
|
-
* 路由 `#/providers/<pageId>/<panelId>`:第二段选模块,第三段选面板;缺省取第一个
|
|
9
|
-
* 模块及其第一个面板。面板页签由宿主渲染并指向同一前缀,所以切换留在本页内。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { PROVIDERS_LAMP_ID } from '../../../shared/console-protocol.ts';
|
|
13
|
-
import { lampRow, paintLamps, subscribeLamps } from '../../ui/lamp.ts';
|
|
14
1
|
import { pageIntro } from '../../ui/page.ts';
|
|
15
|
-
import
|
|
2
|
+
import { icon } from '../../ui/icons.ts';
|
|
3
|
+
import { NEW_DRAFT_ID, providerDrafts } from './drafts.ts';
|
|
16
4
|
import type { FeatureContext, FrameworkFeature } from '../feature.ts';
|
|
5
|
+
import { get, post } from '../../core/api.ts';
|
|
17
6
|
import { S } from './strings.ts';
|
|
7
|
+
import { connectionPath, type HubState, type Connection, type Module, type Detail, type Editing } from './types.ts';
|
|
8
|
+
import { mountDetail, type DetailController } from './detail.ts';
|
|
18
9
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const PROVIDER_KIND = 'llm';
|
|
22
|
-
|
|
10
|
+
/** How often the card list re-reads other deployments' usage. */
|
|
11
|
+
const USAGE_REFRESH_MS = 5000;
|
|
23
12
|
export async function mountProviders(ctx: FeatureContext): Promise<void> {
|
|
24
13
|
const { ui, root } = ctx;
|
|
14
|
+
root.append(pageIntro(ui, S.pageTitle));
|
|
15
|
+
const report = ui.msgline();
|
|
16
|
+
const layout = ui.h('div', 'connection-hub');
|
|
17
|
+
const index = ui.h('div', 'connection-index');
|
|
18
|
+
const cards = ui.h('div', 'connection-cards');
|
|
19
|
+
const detailRoot = ui.h('div', 'connection-detail');
|
|
20
|
+
layout.append(index, detailRoot); root.append(report, layout);
|
|
21
|
+
const opts = { signal: ctx.signal };
|
|
22
|
+
let state = await get<HubState>('/api/providers', opts);
|
|
23
|
+
state.providers.sort((a, b) => Number(b.name === state.active) - Number(a.name === state.active));
|
|
24
|
+
const modules = await get<Module[]>('/api/provider-modules', opts);
|
|
25
|
+
if (ctx.signal.aborted) return;
|
|
26
|
+
const drafts = providerDrafts(root.ownerDocument.defaultView!.localStorage, state.scope);
|
|
27
|
+
let selected = '';
|
|
28
|
+
let newDraft: Editing | null = drafts.get(NEW_DRAFT_ID);
|
|
29
|
+
const invalid = new Set<string>();
|
|
30
|
+
let controller: DetailController | null = null;
|
|
31
|
+
let renderId = 0;
|
|
32
|
+
interface CardNode {
|
|
33
|
+
el: HTMLElement; title: HTMLElement; model: HTMLElement; url: HTMLElement; status: HTMLElement; secondary: HTMLElement;
|
|
34
|
+
kind: HTMLElement; activate: HTMLButtonElement; erase: HTMLButtonElement;
|
|
35
|
+
}
|
|
36
|
+
const nodes = new Map<string, CardNode>();
|
|
37
|
+
const rows = new Map<string, Connection>();
|
|
25
38
|
const doc = root.ownerDocument;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const run = (work: () => Promise<unknown>) => { void work().catch(error => { if (!ctx.signal.aborted) report.textContent = String(error); }); };
|
|
40
|
+
// 同时只有一张卡处在「确认删除」态。
|
|
41
|
+
let armed: (() => void) | null = null;
|
|
42
|
+
const disarm = () => { armed?.(); armed = null; };
|
|
43
|
+
const fallback = () => state.providers.find(item => item.name === state.active)?.name || state.providers[0]?.name || '';
|
|
44
|
+
function paint() {
|
|
45
|
+
report.textContent = state.active && !state.providers.some(item => item.name === state.active) ? S.missing + state.active : '';
|
|
46
|
+
const all: Array<Connection | { name: string; moduleTitle: string; model: string | null; baseUrl: string; readiness: { state: string } }> = [...state.providers];
|
|
47
|
+
if (newDraft) all.unshift({ name: NEW_DRAFT_ID, moduleTitle: moduleName(newDraft.entry.kind), model: newDraft.entry.spec?.model ?? null, baseUrl: newDraft.entry.baseUrl, readiness: { state: 'draft' } });
|
|
48
|
+
for (const [name, node] of nodes) if (!all.some(item => item.name === name)) { node.el.remove(); nodes.delete(name); }
|
|
49
|
+
for (const item of all) {
|
|
50
|
+
const identity = item.name;
|
|
51
|
+
let node = nodes.get(identity);
|
|
52
|
+
if (!node) {
|
|
53
|
+
const el = ui.h('article', 'connection-card'); el.dataset.provider = identity;
|
|
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');
|
|
58
|
+
const status = ui.h('div', 'connection-status'); const secondary = ui.h('div', 'connection-secondary');
|
|
59
|
+
const activate = ui.button('⇄', { size: 'sm', onClick: () => run(async () => {
|
|
60
|
+
activate.disabled = true;
|
|
61
|
+
try { await post(connectionPath(identity) + '/activate', {}, opts); state.active = identity; paint(); }
|
|
62
|
+
finally { activate.disabled = false; }
|
|
63
|
+
}) });
|
|
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));
|
|
67
|
+
const erase = eraseButton(identity);
|
|
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);
|
|
71
|
+
el.addEventListener('click', event => { if (!(event.target as Element).closest('button')) run(() => select(identity)); }, opts);
|
|
72
|
+
if (identity === NEW_DRAFT_ID) cards.prepend(el); else cards.append(el);
|
|
73
|
+
node = { el, title, kind, model, url, status, secondary, activate, erase }; nodes.set(identity, node);
|
|
74
|
+
}
|
|
75
|
+
const active = identity === state.active;
|
|
76
|
+
node.el.classList.toggle('is-active', active); node.el.classList.toggle('is-selected', identity === selected);
|
|
77
|
+
node.title.textContent = identity === NEW_DRAFT_ID ? newDraft?.name || S.newName : identity;
|
|
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 || '—';
|
|
81
|
+
node.kind.textContent = item.moduleTitle; node.kind.title = item.moduleTitle;
|
|
82
|
+
node.model.textContent = model; node.url.textContent = url;
|
|
83
|
+
node.model.title = model; node.url.title = url;
|
|
84
|
+
const readiness = invalid.has(identity) && identity !== NEW_DRAFT_ID ? 'invalid' : item.readiness.state;
|
|
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;
|
|
99
|
+
if (identity === NEW_DRAFT_ID) rows.delete(identity); else rows.set(identity, item as Connection);
|
|
100
|
+
}
|
|
38
101
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
102
|
+
/** 删除按钮先伸成「确认删除」,第二次点击才动手;点别处或 Esc 收回。 */
|
|
103
|
+
function eraseButton(identity: string): HTMLButtonElement {
|
|
104
|
+
const el = ui.h('button', 'connection-erase') as HTMLButtonElement;
|
|
105
|
+
el.type = 'button';
|
|
106
|
+
const text = ui.h('span', 'connection-erase-text'); text.append(ui.h('span', '', S.eraseConfirm));
|
|
107
|
+
el.append(icon(doc, 'trash'), text);
|
|
108
|
+
const label = (armedNow: boolean) => { const name = armedNow ? S.eraseConfirm : S.remove; el.title = name; el.setAttribute('aria-label', name); };
|
|
109
|
+
label(false);
|
|
110
|
+
el.addEventListener('click', event => {
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
const wasArmed = el.classList.contains('is-armed');
|
|
113
|
+
disarm();
|
|
114
|
+
if (wasArmed) { run(() => remove(identity)); return; }
|
|
115
|
+
el.classList.add('is-armed'); label(true);
|
|
116
|
+
armed = () => { el.classList.remove('is-armed'); label(false); };
|
|
117
|
+
}, opts);
|
|
118
|
+
return el;
|
|
52
119
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
lampNodes.set(p.id, lamps);
|
|
64
|
-
jump.addEventListener('click', () => {
|
|
65
|
-
try { ctx.router.navigate([PROVIDERS_ROUTE, p.id]); } catch (err) { ctx.onError(err); }
|
|
66
|
-
}, { signal: ctx.signal });
|
|
67
|
-
index.appendChild(jump);
|
|
68
|
-
jumps.set(p.id, jump);
|
|
120
|
+
async function remove(identity: string): Promise<void> {
|
|
121
|
+
if (identity === NEW_DRAFT_ID) {
|
|
122
|
+
drafts.remove(NEW_DRAFT_ID); newDraft = null; invalid.delete(identity);
|
|
123
|
+
if (selected === NEW_DRAFT_ID) await select(fallback(), true); else paint();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await post(connectionPath(identity) + '/delete', { expectedRevision: rows.get(identity)?.revision }, opts);
|
|
127
|
+
drafts.remove(identity); invalid.delete(identity);
|
|
128
|
+
await refresh();
|
|
129
|
+
if (selected === identity) await select(fallback(), true);
|
|
69
130
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const on = id === provider.id;
|
|
80
|
-
jump.classList.toggle('active', on);
|
|
81
|
-
jump.setAttribute('aria-selected', String(on));
|
|
131
|
+
async function refresh() { state = await get<HubState>('/api/providers', opts); paint(); }
|
|
132
|
+
async function select(identity: string, force = false): Promise<void> {
|
|
133
|
+
if (!force && identity === selected) return;
|
|
134
|
+
const gen = ++renderId;
|
|
135
|
+
controller?.dispose(); controller = null;
|
|
136
|
+
selected = identity; paint(); detailRoot.replaceChildren();
|
|
137
|
+
if (!identity) {
|
|
138
|
+
detailRoot.append(ui.h('h3', '', S.empty), ui.msgline(S.emptyHint), ui.button(S.create, { onClick: () => run(create) }));
|
|
139
|
+
return;
|
|
82
140
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
const saved = identity === NEW_DRAFT_ID ? null : await get<Detail>(connectionPath(identity), opts);
|
|
142
|
+
if (gen !== renderId || ctx.signal.aborted) return;
|
|
143
|
+
const detailView = ui.h('div'); detailRoot.replaceChildren(detailView);
|
|
144
|
+
const mounted = await mountDetail({ ctx, root: detailView, modules, saved, draft: identity === NEW_DRAFT_ID ? newDraft : drafts.get(identity),
|
|
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
|
+
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
|
+
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
|
+
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); },
|
|
155
|
+
duplicate: async editing => {
|
|
156
|
+
if (newDraft) { await select(NEW_DRAFT_ID); return; }
|
|
157
|
+
const used = new Set(state.providers.map(item => item.name.toLowerCase()));
|
|
158
|
+
const base = editing.name.slice(0, 56); let candidate = base; let number = 2;
|
|
159
|
+
while (used.has(candidate.toLowerCase())) candidate = `${base}-${number++}`;
|
|
160
|
+
editing.name = candidate; newDraft = editing; await select(NEW_DRAFT_ID, true);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (gen !== renderId || ctx.signal.aborted) mounted.dispose(); else controller = mounted;
|
|
164
|
+
}
|
|
165
|
+
async function create() {
|
|
166
|
+
if (newDraft) return select(NEW_DRAFT_ID);
|
|
167
|
+
newDraft = { original: null, name: '', entry: { kind: '', baseUrl: '' }, secretValue: '', raw: {} };
|
|
168
|
+
drafts.set(newDraft);
|
|
169
|
+
await select(NEW_DRAFT_ID, true);
|
|
170
|
+
}
|
|
171
|
+
const moduleName = (kind: string) => modules.find(module => module.id === kind)?.title ?? kind;
|
|
172
|
+
doc.addEventListener('click', event => { if (!(event.target as Element).closest('.connection-erase')) disarm(); }, opts);
|
|
173
|
+
doc.addEventListener('keydown', event => { if (event.key === 'Escape') disarm(); }, opts);
|
|
174
|
+
const creator = ui.h('div', 'connection-create');
|
|
175
|
+
creator.append(ui.button(S.create, { variant: 'primary', onClick: () => run(create) }), ui.h('p', 'connection-create-hint', S.createHint));
|
|
176
|
+
index.append(creator, cards);
|
|
177
|
+
ctx.lifecycle.own({ dispose: () => { renderId++; controller?.dispose(); } });
|
|
178
|
+
ctx.lifecycle.own(ctx.router.onChange(route => {
|
|
179
|
+
if (route.segments[0] === 'providers' && route.segments[1]) run(() => select(route.segments[1]));
|
|
90
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);
|
|
188
|
+
paint();
|
|
189
|
+
const wanted = ctx.route.segments[1] ?? (newDraft ? NEW_DRAFT_ID : undefined);
|
|
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);
|
|
91
191
|
}
|
|
92
|
-
|
|
93
|
-
export const providersFeature: FrameworkFeature = {
|
|
94
|
-
route: PROVIDERS_ROUTE,
|
|
95
|
-
label: S.navLabel,
|
|
96
|
-
icon: 'cpu',
|
|
97
|
-
lampId: PROVIDERS_LAMP_ID,
|
|
98
|
-
navGroup: S.navGroup,
|
|
99
|
-
mount: mountProviders,
|
|
100
|
-
};
|
|
192
|
+
export const providersFeature: FrameworkFeature = { route: 'providers', label: S.navLabel, icon: 'cpu', navGroup: S.navGroup, mount: mountProviders };
|
|
@@ -1,23 +1,54 @@
|
|
|
1
1
|
import { pick } from '../../core/language.ts';
|
|
2
|
-
|
|
3
2
|
const zh = {
|
|
4
|
-
pageTitle: '
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
pageTitle: '模型供应商', navLabel: '模型供应商', navGroup: 'Core', create: '+ 新建供应商实例', newName: '未命名实例',
|
|
4
|
+
createHint: '实例 = 填一组供应商配置保存成卡片',
|
|
5
|
+
empty: '还没有模型供应商', emptyHint: '添加一条模型连接后,Cortico 才能进行模型推理。',
|
|
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>,
|
|
10
|
+
basic: '基本信息', connection: '连接', modelSection: '模型与生成', moduleSection: '模块设置', pricing: '成本与计价', advanced: '高级协议',
|
|
11
|
+
name: '供应商名称', module: '供应商类型', url: 'API 地址', key: 'API Key', model: '模型',
|
|
12
|
+
nameHint: '英文字母、数字、- 或 _,以字母或数字开头,不允许空格或系统保留名。',
|
|
13
|
+
fixedModule: '供应商类型保存后不可更改。使用其他类型需新建供应商。',
|
|
14
|
+
test: '测试连接', testOk: '测试成功', testFailed: '测试失败',
|
|
15
|
+
keyEmpty: '输入 API Key', fetchModels: '获取模型列表',
|
|
16
|
+
reasoning: '推理强度', thinking: '开启推理', temperature: '温度', maxTokens: '最大输出 token', context: '上下文上限', tier: '服务档位', images: '接受图片',
|
|
17
|
+
catalogNoWindow: '目录没有给出这个模型的上下文上限;请按服务商文档手填。',
|
|
18
|
+
advancedHint: '仅在使用自定义 API 网关或兼容服务时调整。', secret: '密钥环境变量名',
|
|
19
|
+
extraHeaders: '附加请求头(JSON object)', extraBody: '附加请求体(JSON object)', priceRules: '完整计价规则(JSON array)',
|
|
20
|
+
shared: '共享配置:此供应商可被多个 Bot 使用。修改会改变这份共享配置;若需要不同参数,建议新建一个供应商。',
|
|
21
|
+
remove: '删除供应商', duplicate: '复制供应商', cancel: '放弃更改', save: '保存', saved: '已保存',
|
|
22
|
+
draftNote: '改动暂存在此浏览器,保存后才写入配置。API Key 不进暂存。',
|
|
23
|
+
chooseModule: '请选择供应商类型。', invalidNumber: '请输入有效范围内的数值。',
|
|
24
|
+
required: '此项必填。', jsonObject: '请输入合法 JSON 对象。', jsonArray: '请输入合法 JSON 数组。',
|
|
25
|
+
unsaved: '已暂存;保存后写入配置。',
|
|
26
|
+
deleteConfirm: '删除供应商及其目录中的全部文件?', referenced: '此供应商正在被以下 Bot 使用,不能删除:', reload: '重新加载',
|
|
11
27
|
};
|
|
12
|
-
|
|
13
28
|
const en: typeof zh = {
|
|
14
|
-
pageTitle: '
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
pageTitle: 'Model connections', navLabel: 'Model connections', navGroup: 'Core', create: '+ New connection instance', newName: 'Unnamed instance',
|
|
30
|
+
createHint: 'An instance is one set of provider settings saved as a card.',
|
|
31
|
+
empty: 'No model connections yet', emptyHint: 'Add a model connection to enable inference.',
|
|
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' },
|
|
36
|
+
basic: 'Basic information', connection: 'Connection', modelSection: 'Model and generation', moduleSection: 'Module settings', pricing: 'Pricing', advanced: 'Advanced protocol',
|
|
37
|
+
name: 'Connection name', module: 'Connection type', url: 'API URL', key: 'API Key', model: 'Model',
|
|
38
|
+
nameHint: 'English letters, digits, - or _; start with a letter or digit. No spaces or reserved system names.',
|
|
39
|
+
fixedModule: 'The connection type cannot change after saving. Create a connection to use another type.',
|
|
40
|
+
test: 'Test connection', testOk: 'Test successful', testFailed: 'Test failed',
|
|
41
|
+
keyEmpty: 'Enter API Key', fetchModels: 'Fetch model list',
|
|
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.',
|
|
44
|
+
advancedHint: 'Adjust only for custom API gateways or compatible services.', secret: 'Credential environment variable',
|
|
45
|
+
extraHeaders: 'Extra headers (JSON object)', extraBody: 'Extra request body (JSON object)', priceRules: 'Full pricing rules (JSON array)',
|
|
46
|
+
shared: 'Shared configuration: multiple bots can use this connection. Changes update this shared configuration; create another connection if you need different settings.',
|
|
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.',
|
|
49
|
+
chooseModule: 'Choose a connection type.', invalidNumber: 'Enter a number within the allowed range.',
|
|
50
|
+
required: 'Required.', jsonObject: 'Enter a valid JSON object.', jsonArray: 'Enter a valid JSON array.',
|
|
51
|
+
unsaved: 'Kept as a draft; written on save.',
|
|
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',
|
|
21
53
|
};
|
|
22
|
-
|
|
23
54
|
export const S = pick({ zh, en });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProviderHubApi } from '../../../../providers/hub-api.ts';
|
|
2
|
+
export type HubState = ReturnType<ProviderHubApi['list']>;
|
|
3
|
+
export type Connection = HubState['providers'][number];
|
|
4
|
+
export type Detail = ReturnType<ProviderHubApi['detail']>;
|
|
5
|
+
export type Module = ReturnType<ProviderHubApi['moduleList']>[number];
|
|
6
|
+
export interface Editing {
|
|
7
|
+
original: string | null;
|
|
8
|
+
copyFrom?: { name: string; revision: string };
|
|
9
|
+
name: string;
|
|
10
|
+
entry: Detail['entry'];
|
|
11
|
+
revision?: string;
|
|
12
|
+
secretValue: string;
|
|
13
|
+
raw: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
export const connectionPath = (name: string) => `/api/providers/${encodeURIComponent(name)}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LANGUAGE, saveLanguage, type Language } from '../../core/language.ts';
|
|
2
|
+
import { post } from '../../core/api.ts';
|
|
2
3
|
import type { FeatureContext } from '../feature.ts';
|
|
3
4
|
import { S } from './strings.ts';
|
|
4
5
|
|
|
@@ -21,6 +22,17 @@ export function mountGeneral(ctx: FeatureContext): void {
|
|
|
21
22
|
group.append(button);
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
if (ctx.capabilities.auth) {
|
|
26
|
+
const access = ui.sheet({ title: S.access, en: 'access', desc: S.accessDesc });
|
|
27
|
+
const signOut = ui.h('button', 'btn', S.signOut);
|
|
28
|
+
signOut.type = 'button';
|
|
29
|
+
signOut.addEventListener('click', () => {
|
|
30
|
+
void post('/api/auth/logout', {}, { signal }).then(() => win.location.reload()).catch(ctx.onError);
|
|
31
|
+
}, { signal });
|
|
32
|
+
access.body.appendChild(signOut);
|
|
33
|
+
root.append(access.el);
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
async function changeLanguage(language: Language): Promise<void> {
|
|
25
37
|
if (!await ui.confirm({ title: S.reloadTitle, body: S.reloadBody }) || signal.aborted) return;
|
|
26
38
|
saveLanguage(language, win.localStorage);
|
|
@@ -7,6 +7,9 @@ const zh = {
|
|
|
7
7
|
languageDesc: '仅保存在当前浏览器,刷新页面后生效。',
|
|
8
8
|
reloadTitle: '切换界面语言?',
|
|
9
9
|
reloadBody: '页面将刷新,未保存的编辑会丢失。Bot 将继续运行。',
|
|
10
|
+
access: '访问',
|
|
11
|
+
accessDesc: '这个控制台设置了访问密码;登录态保存在当前浏览器的 Cookie 里。',
|
|
12
|
+
signOut: '退出登录',
|
|
10
13
|
appearance: '外观',
|
|
11
14
|
appearanceDesc: '控制台主题、明暗模式与自定义配色。',
|
|
12
15
|
|
|
@@ -22,6 +25,9 @@ const en: typeof zh = {
|
|
|
22
25
|
languageDesc: 'Saved in this browser; takes effect after reloading.',
|
|
23
26
|
reloadTitle: 'Change interface language?',
|
|
24
27
|
reloadBody: 'The page will reload and unsaved edits will be lost. The bot will keep running.',
|
|
28
|
+
access: 'Access',
|
|
29
|
+
accessDesc: 'This console is protected by an access password; the sign-in is kept in a cookie in this browser.',
|
|
30
|
+
signOut: 'Sign out',
|
|
25
31
|
appearance: 'Appearance',
|
|
26
32
|
appearanceDesc: 'Console theme, light/dark mode and custom palettes.',
|
|
27
33
|
|
package/src/web/client/main.ts
CHANGED
|
@@ -215,6 +215,10 @@ export function boot(doc: Document = document): { dispose(): void } {
|
|
|
215
215
|
}
|
|
216
216
|
const head = route.segments[0];
|
|
217
217
|
|
|
218
|
+
if (head === PROVIDER_ROUTE && route.segments[1]?.startsWith('llm:')) {
|
|
219
|
+
router.replace(['providers']);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
218
222
|
if (head === PROVIDER_ROUTE) {
|
|
219
223
|
unmountFeature();
|
|
220
224
|
const pageId = route.segments[1];
|
|
@@ -360,7 +360,7 @@ export function createShell(deps: ShellDeps): ConsoleShell {
|
|
|
360
360
|
|
|
361
361
|
// 只列已装配的:左栏是"能去的地方",不是全量清单。未装配的仍由各自的
|
|
362
362
|
// 总览页负责露面(那里才有"为什么没装上"的位置)。供应模块(kind `llm`)不在
|
|
363
|
-
//
|
|
363
|
+
// 这里逐个列出:它们的入口是框架的「模型提供商」页,模块清单是那一页里的次级菜单。
|
|
364
364
|
const listed = consolePages.filter((p) => p.availability === 'active');
|
|
365
365
|
// Persona 页在前,它的 Memory 页跟在后面,再是归这一组的框架页(系统提示词),同一组。
|
|
366
366
|
const personas = [
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -18,7 +18,8 @@ export type ConsoleIconName =
|
|
|
18
18
|
| 'eye'
|
|
19
19
|
| 'eye-off'
|
|
20
20
|
| 'pencil'
|
|
21
|
-
| 'refresh'
|
|
21
|
+
| 'refresh'
|
|
22
|
+
| 'trash';
|
|
22
23
|
|
|
23
24
|
type Shape = readonly [tag: 'path' | 'circle' | 'rect' | 'line', attrs: Readonly<Record<string, string>>];
|
|
24
25
|
|
|
@@ -102,6 +103,13 @@ const SHAPES: Readonly<Record<ConsoleIconName, readonly Shape[]>> = {
|
|
|
102
103
|
['path', { d: 'M21 12a9 9 0 1 1-2.6-6.4' }],
|
|
103
104
|
['path', { d: 'M21 3v6h-6' }],
|
|
104
105
|
],
|
|
106
|
+
trash: [
|
|
107
|
+
['path', { d: 'M4 7h16' }],
|
|
108
|
+
['path', { d: 'M9.5 7V5h5v2' }],
|
|
109
|
+
['path', { d: 'M6.5 7v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V7' }],
|
|
110
|
+
['path', { d: 'M10 11v6' }],
|
|
111
|
+
['path', { d: 'M14 11v6' }],
|
|
112
|
+
],
|
|
105
113
|
};
|
|
106
114
|
|
|
107
115
|
/**
|
|
@@ -44,7 +44,7 @@ const TEXTAREA_CLASS =
|
|
|
44
44
|
|
|
45
45
|
/** 两枚圆形图标钮共用的底:尺寸、焦点环、禁用态。 */
|
|
46
46
|
const ICON_BUTTON =
|
|
47
|
-
'inline-flex size-8 shrink-0 items-center justify-center rounded-full p-0 outline-none transition-[background-color,color,opacity,transform] duration-150 disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring/40 [&_svg]:pointer-events-none [&_svg]:size-4';
|
|
47
|
+
'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full p-0 outline-none transition-[background-color,color,opacity,transform] duration-150 disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring/40 [&_svg]:pointer-events-none [&_svg]:size-4';
|
|
48
48
|
|
|
49
49
|
/** 发送钮:实底,是这一栏唯一的主动作。 */
|
|
50
50
|
const SEND_CLASS = `${ICON_BUTTON} self-end bg-foreground text-background hover:bg-foreground/90 active:translate-y-px focus-visible:ring-offset-2 focus-visible:ring-offset-background`;
|
|
@@ -124,6 +124,7 @@ const PromptComposer = React.forwardRef<ComposerHandle, { opts: ConsolePromptInp
|
|
|
124
124
|
const [placeholder, setPlaceholder] = React.useState<string | null>(null);
|
|
125
125
|
const textarea = React.useRef<HTMLTextAreaElement>(null);
|
|
126
126
|
const tools = React.useRef<HTMLSpanElement>(null);
|
|
127
|
+
const leading = React.useRef<HTMLSpanElement>(null);
|
|
127
128
|
const picker = React.useRef<HTMLInputElement>(null);
|
|
128
129
|
const nextId = React.useRef(1);
|
|
129
130
|
const imagesOpts = opts.images;
|
|
@@ -137,6 +138,14 @@ const PromptComposer = React.forwardRef<ComposerHandle, { opts: ConsolePromptInp
|
|
|
137
138
|
return () => node.remove();
|
|
138
139
|
}, [opts.tools]);
|
|
139
140
|
|
|
141
|
+
React.useLayoutEffect(() => {
|
|
142
|
+
const node = opts.leading;
|
|
143
|
+
const host = leading.current;
|
|
144
|
+
if (!node || !host) return;
|
|
145
|
+
host.appendChild(node);
|
|
146
|
+
return () => node.remove();
|
|
147
|
+
}, [opts.leading]);
|
|
148
|
+
|
|
140
149
|
React.useImperativeHandle(ref, () => ({
|
|
141
150
|
focus: () => textarea.current?.focus(),
|
|
142
151
|
setDisabled,
|
|
@@ -237,10 +246,13 @@ const PromptComposer = React.forwardRef<ComposerHandle, { opts: ConsolePromptInp
|
|
|
237
246
|
/>
|
|
238
247
|
</div>
|
|
239
248
|
<div className="flex min-h-9 items-center justify-between gap-2 px-1">
|
|
240
|
-
<span className="
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
<span className="flex min-w-0 items-center gap-1">
|
|
250
|
+
<span ref={leading} className="flex items-center" />
|
|
251
|
+
<span className="truncate px-1 text-xs text-muted-foreground">
|
|
252
|
+
{note ? <span className="text-danger">{note}</span>
|
|
253
|
+
: pending > 0 ? S.processingImages(pending)
|
|
254
|
+
: opts.hint}
|
|
255
|
+
</span>
|
|
244
256
|
</span>
|
|
245
257
|
<div className="flex items-center gap-1">
|
|
246
258
|
{imagesOpts && (
|
|
@@ -27,7 +27,6 @@ const zh = {
|
|
|
27
27
|
messageInput: '消息输入',
|
|
28
28
|
typeMessage: '输入消息…',
|
|
29
29
|
processingImages: (count: number) => `处理 ${count} 张图片…`,
|
|
30
|
-
enterToSend: 'Enter 发送 · Shift+Enter 换行',
|
|
31
30
|
sendMessage: '发送消息',
|
|
32
31
|
addImage: '添加图片',
|
|
33
32
|
addImageHint: '添加图片(也可粘贴或拖入)',
|
|
@@ -61,7 +60,6 @@ const en: typeof zh = {
|
|
|
61
60
|
messageInput: 'Message input',
|
|
62
61
|
typeMessage: 'Type a message…',
|
|
63
62
|
processingImages: (count: number) => `Processing ${count} image(s)…`,
|
|
64
|
-
enterToSend: 'Enter to send · Shift+Enter for newline',
|
|
65
63
|
sendMessage: 'Send message',
|
|
66
64
|
addImage: 'Add image',
|
|
67
65
|
addImageHint: 'Add image (paste or drop also works)',
|