@alilis/k-hat 0.2.4 → 0.2.6
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/dist/admin.js +40 -7
- package/dist/cli.js +20 -10
- package/dist/config.js +4 -1
- package/dist/portable-vault.js +22 -1
- package/dist/router.js +8 -1
- package/dist/server.js +4 -2
- package/dist/store.js +25 -7
- package/dist/tui-client.js +4 -4
- package/dist/tui.js +1 -1
- package/dist/web-ui.js +24 -15
- package/package.json +1 -1
package/dist/admin.js
CHANGED
|
@@ -4,6 +4,8 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { UiSessions, serveUi } from './web-ui.js';
|
|
5
5
|
import { ACCESS_TOKEN_REF, generateAccessToken } from './vault.js';
|
|
6
6
|
import { providerUrl } from './config.js';
|
|
7
|
+
import { findRoute, routeUpstreamModel } from './router.js';
|
|
8
|
+
import { readExport, applyImport } from './portable-vault.js';
|
|
7
9
|
// Admin API is the daemon's single-writer surface (ADR-0005). It is reachable
|
|
8
10
|
// only from loopback and behind the proxy access token, so a local non-loopback
|
|
9
11
|
// client or a process without the token cannot mutate config/state/vault.
|
|
@@ -76,7 +78,12 @@ function buildStatus(store) {
|
|
|
76
78
|
};
|
|
77
79
|
})
|
|
78
80
|
})),
|
|
79
|
-
routes: store.config.routes.map((route) =>
|
|
81
|
+
routes: store.config.routes.map((route) => ({
|
|
82
|
+
model: route.model,
|
|
83
|
+
provider: route.provider,
|
|
84
|
+
...(route.upstreamModel ? { upstreamModel: route.upstreamModel } : {}),
|
|
85
|
+
...(route.enabled === false ? { enabled: false } : {})
|
|
86
|
+
}))
|
|
80
87
|
};
|
|
81
88
|
}
|
|
82
89
|
async function route(req, res, store, sessionAuthorized = false) {
|
|
@@ -112,6 +119,18 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
112
119
|
}
|
|
113
120
|
if (sub[0] === 'status' && method === 'GET')
|
|
114
121
|
return json(res, 200, buildStatus(store));
|
|
122
|
+
if (sub[0] === 'import' && sub.length === 1 && method === 'POST') {
|
|
123
|
+
const body = await readJsonBody(req);
|
|
124
|
+
if (typeof body.content !== 'string' || !body.content)
|
|
125
|
+
throw new Error('content (the export file text) is required');
|
|
126
|
+
if (typeof body.password !== 'string' || !body.password)
|
|
127
|
+
throw new Error('password is required');
|
|
128
|
+
if (body.content.length > 24 * 1024 * 1024)
|
|
129
|
+
throw new Error('export file is too large');
|
|
130
|
+
const payload = readExport(body.content, body.password);
|
|
131
|
+
const result = await store.mutate(() => applyImport(store, payload));
|
|
132
|
+
return json(res, 200, { ok: true, ...result });
|
|
133
|
+
}
|
|
115
134
|
if (sub[0] === 'logs' && method === 'GET') {
|
|
116
135
|
const tail = Number.parseInt(url.searchParams.get('tail') ?? '50', 10);
|
|
117
136
|
const logs = await new LogWriter(join(store.dir, 'logs')).recent(Number.isFinite(tail) ? tail : 50);
|
|
@@ -181,13 +200,18 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
181
200
|
if (!provider || !key)
|
|
182
201
|
throw new Error('unknown provider or key');
|
|
183
202
|
const secret = store.vault.get(key.vaultRef);
|
|
184
|
-
const
|
|
185
|
-
|
|
203
|
+
const requestedModel = url.searchParams.get('model');
|
|
204
|
+
const route = requestedModel
|
|
205
|
+
? findRoute(store.config, requestedModel)
|
|
206
|
+
: store.config.routes.find((item) => item.provider === provider.id);
|
|
207
|
+
if (!secret || !route || route.provider !== provider.id)
|
|
186
208
|
throw new Error('a key and route model are required for probing');
|
|
209
|
+
const model = routeUpstreamModel(route);
|
|
187
210
|
const path = provider.protocol === 'anthropic' ? '/v1/messages' : '/v1/chat/completions';
|
|
188
211
|
const headers = upstreamHeaders(provider.protocol, secret, true);
|
|
189
212
|
const body = provider.protocol === 'anthropic' ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }] } : { model, max_tokens: 1, messages: [{ role: 'user', content: 'ping' }], stream: false };
|
|
190
213
|
const response = await fetch(providerUrl(provider.baseUrl, path), { method: 'POST', headers: headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
214
|
+
await response.arrayBuffer();
|
|
191
215
|
if (response.ok)
|
|
192
216
|
await store.mutate(() => enableKey(store, provider.id, key.id));
|
|
193
217
|
return json(res, response.ok ? 200 : 502, { ok: response.ok, status: response.status });
|
|
@@ -198,15 +222,24 @@ async function route(req, res, store, sessionAuthorized = false) {
|
|
|
198
222
|
const body = await readJsonBody(req);
|
|
199
223
|
if (!body.model || !body.provider)
|
|
200
224
|
throw new Error('model and provider are required');
|
|
201
|
-
|
|
225
|
+
if (body.upstreamModel !== undefined && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
|
|
226
|
+
throw new Error('upstreamModel must be a non-empty string');
|
|
227
|
+
await store.mutate(() => addRoute(store, body.model, body.provider, body.upstreamModel));
|
|
202
228
|
return json(res, 200, { ok: true });
|
|
203
229
|
}
|
|
204
230
|
if (sub.length === 2 && method === 'PUT') {
|
|
205
231
|
const body = await readJsonBody(req);
|
|
206
|
-
if (body.provider !== undefined)
|
|
207
|
-
|
|
232
|
+
if (body.provider !== undefined && typeof body.provider !== 'string')
|
|
233
|
+
throw new Error('provider must be a string');
|
|
234
|
+
if (body.upstreamModel !== undefined && body.upstreamModel !== null && (typeof body.upstreamModel !== 'string' || !body.upstreamModel.trim()))
|
|
235
|
+
throw new Error('upstreamModel must be a non-empty string or null');
|
|
236
|
+
if (body.enabled !== undefined && typeof body.enabled !== 'boolean')
|
|
237
|
+
throw new Error('enabled must be a boolean');
|
|
238
|
+
const model = decodeURIComponent(sub[1]);
|
|
239
|
+
if (body.provider !== undefined || body.upstreamModel !== undefined)
|
|
240
|
+
await store.mutate(() => updateRoute(store, model, { provider: body.provider, upstreamModel: body.upstreamModel }));
|
|
208
241
|
if (body.enabled !== undefined)
|
|
209
|
-
await store.mutate(() => setRouteEnabled(store,
|
|
242
|
+
await store.mutate(() => setRouteEnabled(store, model, body.enabled));
|
|
210
243
|
return json(res, 200, { ok: true });
|
|
211
244
|
}
|
|
212
245
|
if (sub.length === 2 && (sub[1] === 'enable' || sub[1] === 'disable') && method === 'POST') {
|
package/dist/cli.js
CHANGED
|
@@ -688,28 +688,38 @@ try {
|
|
|
688
688
|
case 'route': {
|
|
689
689
|
const action = positionals[0];
|
|
690
690
|
if (action === 'add') {
|
|
691
|
-
const [model, providerId] = [positionals[1], positionals[2]];
|
|
691
|
+
const [model, providerId, upstreamModel] = [positionals[1], positionals[2], positionals[3]];
|
|
692
692
|
if (!model || !providerId) {
|
|
693
|
-
console.error('usage: khat route add <model> <provider>');
|
|
693
|
+
console.error('usage: khat route add <model> <provider> [upstream-model]');
|
|
694
694
|
process.exitCode = 1;
|
|
695
695
|
break;
|
|
696
696
|
}
|
|
697
|
-
await adminRequest('/_keys/routes', { method: 'POST', body: JSON.stringify({ model, provider: providerId }) });
|
|
698
|
-
console.log(`route added: ${model} -> ${providerId}`);
|
|
697
|
+
await adminRequest('/_keys/routes', { method: 'POST', body: JSON.stringify({ model, provider: providerId, ...(upstreamModel ? { upstreamModel } : {}) }) });
|
|
698
|
+
console.log(`route added: ${model}${upstreamModel ? ` -> ${upstreamModel}` : ''} -> ${providerId}`);
|
|
699
699
|
}
|
|
700
700
|
else if (action === 'list') {
|
|
701
701
|
const store = await openInitializedStore();
|
|
702
702
|
if (!store.config.routes.length)
|
|
703
703
|
console.log('(no routes)');
|
|
704
704
|
for (const route of store.config.routes)
|
|
705
|
-
console.log(`${route.model} -> ${route.provider}`);
|
|
705
|
+
console.log(`${route.model}${route.upstreamModel ? ` -> ${route.upstreamModel}` : ''} -> ${route.provider}`);
|
|
706
706
|
}
|
|
707
707
|
else if (action === 'update') {
|
|
708
|
-
const [model, providerId] = [positionals[1], positionals[2]];
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
708
|
+
const [model, providerId, upstreamModel] = [positionals[1], positionals[2], positionals[3]];
|
|
709
|
+
const clearUpstream = flags['clear-upstream'] === true;
|
|
710
|
+
if (!model)
|
|
711
|
+
throw new Error('usage: khat route update <model> [provider] [upstream-model] [--clear-upstream]');
|
|
712
|
+
if (!providerId && !clearUpstream)
|
|
713
|
+
throw new Error('usage: khat route update <model> [provider] [upstream-model] [--clear-upstream]');
|
|
714
|
+
const body = {};
|
|
715
|
+
if (providerId)
|
|
716
|
+
body.provider = providerId;
|
|
717
|
+
if (clearUpstream)
|
|
718
|
+
body.upstreamModel = null;
|
|
719
|
+
else if (upstreamModel)
|
|
720
|
+
body.upstreamModel = upstreamModel;
|
|
721
|
+
await adminRequest(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
722
|
+
console.log(`route updated: ${model}${clearUpstream ? '' : (upstreamModel ? ` -> ${upstreamModel}` : '')} -> ${providerId ?? '(unchanged)'}`);
|
|
713
723
|
}
|
|
714
724
|
else if (action === 'remove') {
|
|
715
725
|
const model = positionals[1];
|
package/dist/config.js
CHANGED
|
@@ -35,7 +35,8 @@ export function providerUrl(baseUrl, path) {
|
|
|
35
35
|
const base = new URL(baseUrl);
|
|
36
36
|
const prefix = base.pathname.replace(/\/+$/, '');
|
|
37
37
|
const suffix = path.startsWith('/') ? path : `/${path}`;
|
|
38
|
-
|
|
38
|
+
const overlap = prefix.endsWith('/v1') && (suffix === '/v1' || suffix.startsWith('/v1/')) ? '/v1'.length : 0;
|
|
39
|
+
base.pathname = `${prefix}${suffix.slice(overlap)}` || '/';
|
|
39
40
|
return base;
|
|
40
41
|
}
|
|
41
42
|
export function validateConfig(config) {
|
|
@@ -78,6 +79,8 @@ export function validateConfig(config) {
|
|
|
78
79
|
for (const route of config.routes) {
|
|
79
80
|
if (!route.model || !route.provider)
|
|
80
81
|
throw new Error('Invalid route: model and provider are required');
|
|
82
|
+
if (route.upstreamModel !== undefined && (typeof route.upstreamModel !== 'string' || !route.upstreamModel.trim()))
|
|
83
|
+
throw new Error(`Invalid route ${route.model}: upstreamModel must be a non-empty string`);
|
|
81
84
|
if (route.enabled !== undefined && typeof route.enabled !== 'boolean')
|
|
82
85
|
throw new Error(`Invalid route ${route.model}: enabled must be boolean`);
|
|
83
86
|
if (!config.providers.some((item) => item.id === route.provider))
|
package/dist/portable-vault.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
2
2
|
import { access, mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { saveJsonAtomic, validateConfig } from './config.js';
|
|
4
|
+
import { saveJsonAtomic, validateConfig, defaultConfig } from './config.js';
|
|
5
5
|
import { Vault } from './vault.js';
|
|
6
6
|
const FORMAT = 'khat-vault-export';
|
|
7
7
|
const VERSION = 1;
|
|
@@ -144,3 +144,24 @@ export async function importPortable(path, targetDir, protector, password, force
|
|
|
144
144
|
await rm(stage, { recursive: true, force: true });
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
|
+
/** Apply a decrypted export payload to a live store: replaces secrets, config and per-key state in place, so references held by the running server stay valid. */
|
|
148
|
+
export async function applyImport(store, payload) {
|
|
149
|
+
validatePayload(payload);
|
|
150
|
+
const config = payload.config ?? structuredClone(defaultConfig);
|
|
151
|
+
// bind/port describe the socket the daemon is already listening on; the import must not claim a different one.
|
|
152
|
+
const listen = { bind: store.config.bind, port: store.config.port };
|
|
153
|
+
Object.assign(store.config, config, listen);
|
|
154
|
+
for (const ref of Object.keys(store.vault.secrets))
|
|
155
|
+
store.vault.delete(ref);
|
|
156
|
+
for (const [ref, value] of Object.entries(payload.secrets))
|
|
157
|
+
store.vault.set(ref, value);
|
|
158
|
+
for (const key of Object.keys(store.states))
|
|
159
|
+
delete store.states[key];
|
|
160
|
+
for (const key of Object.keys(store.counters))
|
|
161
|
+
delete store.counters[key];
|
|
162
|
+
// Vault first: the daemon's config watcher fires on config.json and then resolves secrets from the vault.
|
|
163
|
+
await store.vault.save();
|
|
164
|
+
await store.saveConfig();
|
|
165
|
+
await store.saveState();
|
|
166
|
+
return { secrets: Object.keys(payload.secrets).length, providers: config.providers.length, routes: config.routes.length };
|
|
167
|
+
}
|
package/dist/router.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
export function findRoute(config, model) {
|
|
2
|
+
// Exact model ids take precedence over provider/model shorthand.
|
|
3
|
+
const exact = config.routes.find((route) => route.model === model);
|
|
4
|
+
if (exact)
|
|
5
|
+
return exact;
|
|
2
6
|
const slash = model.indexOf('/');
|
|
3
7
|
if (slash > 0) {
|
|
4
8
|
const providerId = model.slice(0, slash);
|
|
5
9
|
const routeModel = model.slice(slash + 1);
|
|
6
10
|
return config.routes.find((route) => route.provider === providerId && route.model === routeModel);
|
|
7
11
|
}
|
|
8
|
-
return
|
|
12
|
+
return undefined;
|
|
9
13
|
}
|
|
10
14
|
export function resolveProvider(config, model) {
|
|
11
15
|
const route = findRoute(config, model);
|
|
@@ -13,6 +17,9 @@ export function resolveProvider(config, model) {
|
|
|
13
17
|
return undefined;
|
|
14
18
|
return config.providers.find((item) => item.id === route.provider);
|
|
15
19
|
}
|
|
20
|
+
export function routeUpstreamModel(route) {
|
|
21
|
+
return route.upstreamModel ?? route.model;
|
|
22
|
+
}
|
|
16
23
|
export function isRouteDisabled(config, model) {
|
|
17
24
|
return findRoute(config, model)?.enabled === false;
|
|
18
25
|
}
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { watch } from 'node:fs';
|
|
|
3
3
|
import { readJsonFile, saveJsonAtomic, validateConfig, providerUrl, defaultTimeouts } from './config.js';
|
|
4
4
|
import { LogWriter } from './logger.js';
|
|
5
5
|
import { join, basename } from 'node:path';
|
|
6
|
-
import { resolveProvider, isRouteDisabled } from './router.js';
|
|
6
|
+
import { findRoute, routeUpstreamModel, resolveProvider, isRouteDisabled } from './router.js';
|
|
7
7
|
import { WeightedSelector } from './selector.js';
|
|
8
8
|
import { handleAdmin } from './admin.js';
|
|
9
9
|
import { ACCESS_TOKEN_REF } from './vault.js';
|
|
@@ -101,6 +101,7 @@ export function createKhatServer(options) {
|
|
|
101
101
|
}
|
|
102
102
|
if (!parsed.model || typeof parsed.model !== 'string')
|
|
103
103
|
return json(res, 400, { error: { message: 'model is required' } });
|
|
104
|
+
const route = findRoute(options.config, parsed.model);
|
|
104
105
|
const provider = resolveProvider(options.config, parsed.model);
|
|
105
106
|
if (!provider) {
|
|
106
107
|
if (isRouteDisabled(options.config, parsed.model))
|
|
@@ -109,6 +110,7 @@ export function createKhatServer(options) {
|
|
|
109
110
|
}
|
|
110
111
|
if (provider.protocol !== protocol)
|
|
111
112
|
return json(res, 400, { error: { message: `Model ${parsed.model} resolves to a ${provider.protocol} provider, but ${req.url} speaks ${protocol}` } });
|
|
113
|
+
const upstreamBody = JSON.stringify({ ...parsed, model: routeUpstreamModel(route) });
|
|
112
114
|
const tried = new Set();
|
|
113
115
|
while (true) {
|
|
114
116
|
const key = selector.select(provider.id, provider.keys.filter((item) => !tried.has(item.id)), states);
|
|
@@ -124,7 +126,7 @@ export function createKhatServer(options) {
|
|
|
124
126
|
const headerTimer = setTimeout(() => upstreamAbort(abort, 'upstream response header timeout'), timeouts().headerMs);
|
|
125
127
|
let upstream;
|
|
126
128
|
try {
|
|
127
|
-
upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body:
|
|
129
|
+
upstream = await fetch(providerUrl(provider.baseUrl, req.url), { method: 'POST', headers: upstreamHeaders(protocol, secret, req.headers.accept), body: upstreamBody, signal: abort.signal });
|
|
128
130
|
}
|
|
129
131
|
catch (error) {
|
|
130
132
|
if (error?.upstreamTimeout)
|
package/dist/store.js
CHANGED
|
@@ -227,12 +227,17 @@ export async function enableKey(store, providerId, keyId) {
|
|
|
227
227
|
delete store.states[`${providerId}/${keyId}`];
|
|
228
228
|
await store.saveState();
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
function normalizeUpstreamModel(upstreamModel) {
|
|
231
|
+
const normalized = upstreamModel?.trim();
|
|
232
|
+
return normalized || undefined;
|
|
233
|
+
}
|
|
234
|
+
export async function addRoute(store, model, providerId, upstreamModel) {
|
|
231
235
|
findProvider(store, providerId);
|
|
232
236
|
const existing = store.config.routes.find((route) => route.model === model);
|
|
233
237
|
if (existing)
|
|
234
238
|
throw new Error(`route already exists: ${model} -> ${existing.provider}`);
|
|
235
|
-
|
|
239
|
+
const normalizedUpstreamModel = normalizeUpstreamModel(upstreamModel);
|
|
240
|
+
store.config.routes.push({ model, provider: providerId, ...(normalizedUpstreamModel && normalizedUpstreamModel !== model ? { upstreamModel: normalizedUpstreamModel } : {}) });
|
|
236
241
|
try {
|
|
237
242
|
await persistConfig(store);
|
|
238
243
|
}
|
|
@@ -241,18 +246,31 @@ export async function addRoute(store, model, providerId) {
|
|
|
241
246
|
throw error;
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
|
-
export async function updateRoute(store, model,
|
|
245
|
-
|
|
249
|
+
export async function updateRoute(store, model, input) {
|
|
250
|
+
if (input.provider !== undefined)
|
|
251
|
+
findProvider(store, input.provider);
|
|
246
252
|
const route = store.config.routes.find((item) => item.model === model);
|
|
247
253
|
if (!route)
|
|
248
254
|
throw new Error(`unknown route: ${model}`);
|
|
249
|
-
const previous = route.provider;
|
|
250
|
-
|
|
255
|
+
const previous = { provider: route.provider, upstreamModel: route.upstreamModel };
|
|
256
|
+
if (input.provider !== undefined)
|
|
257
|
+
route.provider = input.provider;
|
|
258
|
+
if (input.upstreamModel !== undefined) {
|
|
259
|
+
const normalizedUpstreamModel = normalizeUpstreamModel(input.upstreamModel);
|
|
260
|
+
if (normalizedUpstreamModel && normalizedUpstreamModel !== route.model)
|
|
261
|
+
route.upstreamModel = normalizedUpstreamModel;
|
|
262
|
+
else
|
|
263
|
+
delete route.upstreamModel;
|
|
264
|
+
}
|
|
251
265
|
try {
|
|
252
266
|
await persistConfig(store);
|
|
253
267
|
}
|
|
254
268
|
catch (error) {
|
|
255
|
-
route.provider = previous;
|
|
269
|
+
route.provider = previous.provider;
|
|
270
|
+
if (previous.upstreamModel === undefined)
|
|
271
|
+
delete route.upstreamModel;
|
|
272
|
+
else
|
|
273
|
+
route.upstreamModel = previous.upstreamModel;
|
|
256
274
|
throw error;
|
|
257
275
|
}
|
|
258
276
|
}
|
package/dist/tui-client.js
CHANGED
|
@@ -60,11 +60,11 @@ export class AdminClient {
|
|
|
60
60
|
async probeKey(providerId, keyId) {
|
|
61
61
|
return this.request(`/_keys/providers/${encodeURIComponent(providerId)}/keys/${encodeURIComponent(keyId)}/probe`, { method: 'POST' });
|
|
62
62
|
}
|
|
63
|
-
async createRoute(model, provider) {
|
|
64
|
-
await this.request('/_keys/routes', { method: 'POST', body: { model, provider } });
|
|
63
|
+
async createRoute(model, provider, upstreamModel) {
|
|
64
|
+
await this.request('/_keys/routes', { method: 'POST', body: { model, provider, ...(upstreamModel ? { upstreamModel } : {}) } });
|
|
65
65
|
}
|
|
66
|
-
async updateRoute(model, provider) {
|
|
67
|
-
await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { provider } });
|
|
66
|
+
async updateRoute(model, provider, upstreamModel) {
|
|
67
|
+
await this.request(`/_keys/routes/${encodeURIComponent(model)}`, { method: 'PUT', body: { ...(provider ? { provider } : {}), ...(upstreamModel !== undefined ? { upstreamModel } : {}) } });
|
|
68
68
|
}
|
|
69
69
|
async enableRoute(model) { await this.request(`/_keys/routes/enable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
|
70
70
|
async disableRoute(model) { await this.request(`/_keys/routes/disable?model=${encodeURIComponent(model)}`, { method: 'POST' }); }
|
package/dist/tui.js
CHANGED
|
@@ -47,7 +47,7 @@ function RoutesBody({ status, cursor, height }) {
|
|
|
47
47
|
return _jsx(Box, { flexDirection: "column", children: routes.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0\u8DEF\u7531 \u00B7 \u7528 khat route add \u6DFB\u52A0" }) : _jsxs(_Fragment, { children: [view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
|
|
48
48
|
const route = routes[index];
|
|
49
49
|
const selected = index === cursor;
|
|
50
|
-
return (_jsx(Text, { color: selected ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(route.model, 32)}→ ${route.provider}` }, route.model));
|
|
50
|
+
return (_jsx(Text, { color: selected ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(route.model, 32)}${route.upstreamModel ? ` → ${route.upstreamModel}` : ''} → ${route.provider}` }, route.model));
|
|
51
51
|
}), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` })] }) });
|
|
52
52
|
}
|
|
53
53
|
function LogsBody({ logs, filter, cursor, detailOpen, height }) {
|
package/dist/web-ui.js
CHANGED
|
@@ -123,6 +123,10 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
123
123
|
<main class="container">
|
|
124
124
|
<p id="error" class="alert alert-error"></p>
|
|
125
125
|
<p id="poolWarn" class="alert alert-warn"></p>
|
|
126
|
+
<section class="card">
|
|
127
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardImport">导入加密备份</h2><p class="card-sub" data-i18n="cardImportSub">选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。</p></div></div>
|
|
128
|
+
<form id="importForm" class="toolbar" autocomplete="off"><input id="importFile" type="file" accept=".khat,.json,application/json" required data-i18n-aria="importFileAria" aria-label="选择导出文件"><input id="importPassword" type="password" placeholder="导出密码" data-i18n-ph="phImportPassword" autocomplete="new-password" required><button class="btn btn-primary" data-i18n="btnImport">导入</button></form>
|
|
129
|
+
</section>
|
|
126
130
|
<div class="stats">
|
|
127
131
|
<div class="stat"><div class="stat-label" data-i18n="statProviders">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub" data-i18n="statProvidersSub">已配置的服务商</div></div>
|
|
128
132
|
<div class="stat"><div class="stat-label" data-i18n="statKeys">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
@@ -138,8 +142,8 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
138
142
|
</section>
|
|
139
143
|
<section class="card">
|
|
140
144
|
<div class="card-head"><div><h2 class="card-title" data-i18n="cardRoutes">Routes</h2><p class="card-sub" data-i18n="cardRoutesSub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
141
|
-
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider"
|
|
142
|
-
<div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
145
|
+
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider"></select><button type="button" id="clearRouteProvider" class="btn btn-secondary" data-i18n="btnClearProvider">显示全部 Provider</button><div class="ms" id="routeModelsMs"><button type="button" class="ms-toggle ms-empty" aria-haspopup="listbox" aria-expanded="false">请先获取模型</button><div class="ms-panel" hidden><input class="ms-search" type="search" placeholder="搜索 model…" data-i18n-ph="msPlaceholder" autocomplete="off"><label class="ms-all"><input type="checkbox" class="ms-all-cb"><span data-i18n="msSelectAll">全选</span></label><div class="ms-list" role="listbox"></div></div></div><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
|
|
146
|
+
<div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th>Upstream Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
143
147
|
</section>
|
|
144
148
|
<section class="card">
|
|
145
149
|
<div class="card-head"><div><h2 class="card-title" data-i18n="cardTraffic">请求成功 / 失败趋势</h2><p class="card-sub" data-i18n="cardTrafficSub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i><span data-i18n="legendOk">成功</span></span><span><i class="swatch bad"></i><span data-i18n="legendFail">失败</span></span></span></div>
|
|
@@ -155,15 +159,15 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
155
159
|
const $=s=>document.querySelector(s), error=$('#error');
|
|
156
160
|
const NS='http://www.w3.org/2000/svg';
|
|
157
161
|
const pad2=n=>String(n).padStart(2,'0');
|
|
158
|
-
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false};
|
|
162
|
+
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false,routeProvider:''};
|
|
159
163
|
const I18N={
|
|
160
|
-
zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
|
|
161
|
-
en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 30s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allStatus:'All status',onlyAvail:'Available only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
|
|
164
|
+
zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allProviders:'全部 Provider',btnClearProvider:'显示全部 Provider',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',msEmpty:'请先获取模型',msLoading:'加载中…',msPlaceholder:'搜索 model…',msSelectAll:'全选',msSelected:'已选 {n} / {total}',msNoMatch:'无匹配模型',msNeedProvider:'请先选择 Provider',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',cardImport:'导入加密备份',cardImportSub:'选择 khat export 导出的 .khat 文件并输入导出密码;导入会替换当前的 Provider、Route 与密钥配置。',phImportPassword:'导出密码',btnImport:'导入',confirmImport:'导入将替换当前的 Provider、Route 与密钥配置,且无法撤销。确定继续?',importFileAria:'选择导出文件',imported:'导入成功:{s} 个密钥 · {p} 个 Provider · {r} 条 Route',errChooseFile:'请先选择导出文件',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
|
|
165
|
+
en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 30s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allProviders:'All providers',btnClearProvider:'Show all providers',allStatus:'All status',onlyAvail:'Available only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',msEmpty:'Load models first',msLoading:'Loading…',msPlaceholder:'Search model…',msSelectAll:'Select all',msSelected:'{n} / {total} selected',msNoMatch:'No matching models',msNeedProvider:'Select a provider first',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',cardImport:'Import encrypted backup',cardImportSub:'Pick a .khat file produced by khat export and enter its password; importing replaces the current providers, routes and keys.',phImportPassword:'Export password',btnImport:'Import',confirmImport:'Importing replaces the current providers, routes and keys. This cannot be undone. Continue?',importFileAria:'Choose export file',imported:'Imported: {s} secrets · {p} providers · {r} routes',errChooseFile:'Choose an export file first',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
|
|
162
166
|
};
|
|
163
167
|
let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
|
|
164
168
|
function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
|
|
165
169
|
function tf(key,vars){let s=t(key);if(vars)for(const k in vars)s=s.split('{'+k+'}').join(String(vars[k]));return s}
|
|
166
|
-
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});renderConnection();renderStats();renderProviders();renderRoutes();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
170
|
+
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});const allProvider=$('#routeForm [name=provider] option[value=""]');if(allProvider)allProvider.textContent=t('allProviders');renderConnection();renderStats();renderProviders();renderRoutes();renderRouteModels();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
167
171
|
function toast(msg,type,ms){type=type||'error';ms=ms||3500;const c=$('#toasts');if(!c)return;const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;c.append(el);setTimeout(()=>{el.classList.add('fade');setTimeout(()=>el.remove(),300)},ms)}
|
|
168
172
|
async function api(path,init){init=init||{};const r=await fetch(path,{...init,headers:{authorization:'Bearer '+sessionStorage.khatSession,'content-type':'application/json',...(init.headers||{})}});const body=await r.json().catch(()=>({}));if(!r.ok)throw Error(body.error?.message||r.statusText);return body}
|
|
169
173
|
async function exchange(){const ticket=new URLSearchParams(location.search).get('ticket');if(sessionStorage.khatSession){if(ticket)window.history.replaceState({},'', '/_keys/ui');return}if(ticket){const r=await fetch('/_keys/ticket/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})});if(!r.ok)throw Error(t('errTicket'));sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error(t('errOpenViaUi'))}
|
|
@@ -175,7 +179,7 @@ function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined
|
|
|
175
179
|
function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
|
|
176
180
|
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
|
|
177
181
|
function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
|
|
178
|
-
function syncProviderSelect(sel,providers){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids)return;sel.dataset.ids=ids;const prev=sel.value;
|
|
182
|
+
function syncProviderSelect(sel,providers,allowEmpty){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids&&(!allowEmpty||sel.dataset.allowEmpty==='1'))return;sel.dataset.ids=ids;if(allowEmpty)sel.dataset.allowEmpty='1';const prev=sel.value;const options=providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o});if(allowEmpty){const o=document.createElement('option');o.value='';o.textContent=t('allProviders');options.unshift(o)}sel.replaceChildren(...options);if(ids.split(',').includes(prev)||allowEmpty&&!prev)sel.value=prev}
|
|
179
183
|
function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
|
|
180
184
|
function renderStats(){const st=state.status;if(!st)return;const keys=[];for(const p of st.providers)for(const k of p.keys)keys.push(k);const avail=keys.filter(k=>k.enabled!==false&&k.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent=keys.length?tf('statKeysSubTpl',{a:avail,t:keys.length}):'—';let ok=0,fail=0,tIn=0,tOut=0;for(const x of state.logs){if(Number(x.status)<400)ok++;else fail++;tIn+=x.tokensIn||0;tOut+=x.tokensOut||0}const total=ok+fail;$('#statReq').textContent=total?Math.round(ok*100/total)+'%':'—';$('#statReqSub').textContent=tf('statReqSubTpl',{ok:ok,fail:fail});$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):'—'}
|
|
181
185
|
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=tf('keyCountTpl',{n:keys.length+(keys.length!==p.keys.length?(' / '+p.keys.length):'')});head.append(cnt);return head}
|
|
@@ -231,11 +235,11 @@ function renderProviders(){
|
|
|
231
235
|
}
|
|
232
236
|
function renderRoutes(){
|
|
233
237
|
const tb=$('#routes');tb.replaceChildren();
|
|
234
|
-
const rs=state.status?state.status.routes:[];
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
238
|
+
const rs=(state.status?state.status.routes:[]).filter(rt=>!state.routeProvider||rt.provider===state.routeProvider);
|
|
239
|
+
if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=5;ed.textContent=t('noRoutes');er.append(ed);tb.append(er);return}
|
|
240
|
+
for(const rt of rs){
|
|
241
|
+
const tr=document.createElement('tr');
|
|
242
|
+
tr.append(cell(rt.model,'mono-cell'),cell(rt.upstreamModel||rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
|
|
239
243
|
const d=document.createElement('td');
|
|
240
244
|
const toggle=rt.enabled!==false?'disable':'enable';
|
|
241
245
|
d.append(btn(rt.enabled!==false?t('btnDisable'):t('btnRecover'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/routes/'+toggle+'?model='+encodeURIComponent(rt.model),{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
@@ -352,7 +356,10 @@ async function load(){
|
|
|
352
356
|
state.status=statusResultValue.value;
|
|
353
357
|
renderConnection();
|
|
354
358
|
syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
|
|
355
|
-
syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
|
|
359
|
+
syncProviderSelect($('#routeForm [name=provider]'),state.status.providers,true);
|
|
360
|
+
const routeProvider=$('#routeForm [name=provider]');
|
|
361
|
+
if(state.routeProvider&&state.status.providers.some(p=>p.id===state.routeProvider))routeProvider.value=state.routeProvider;
|
|
362
|
+
else {state.routeProvider='';routeProvider.value='';}
|
|
356
363
|
renderProviders();
|
|
357
364
|
renderRoutes();
|
|
358
365
|
}else{error.textContent=statusResultValue.reason?.message||String(statusResultValue.reason);toast(error.textContent)}
|
|
@@ -372,7 +379,8 @@ $('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
|
|
|
372
379
|
applyTheme(document.documentElement.getAttribute('data-theme')||'light');
|
|
373
380
|
applyLang(lang);
|
|
374
381
|
$('#routeModels').onclick=()=>loadModels();
|
|
375
|
-
$('#routeForm [name=provider]').onchange=clearRouteModels;
|
|
382
|
+
$('#routeForm [name=provider]').onchange=e=>{state.routeProvider=e.target.value;clearRouteModels();renderRoutes()};
|
|
383
|
+
$('#clearRouteProvider').onclick=()=>{state.routeProvider='';$('#routeForm [name=provider]').value='';clearRouteModels();renderRoutes()};
|
|
376
384
|
$('#routeModelsMs .ms-toggle').onclick=e=>{e.preventDefault();if(!state.routeModels.length)return;openRouteModels(!state.routeOpen)};
|
|
377
385
|
$('#routeModelsMs .ms-search').oninput=e=>{state.routeFilter=e.target.value;buildRouteModelList();syncRouteModelHeader()};
|
|
378
386
|
$('#routeModelsMs .ms-all-cb').onchange=e=>{const kw=state.routeFilter.trim().toLowerCase();const items=state.routeModels.filter(m=>!kw||m.toLowerCase().includes(kw));if(e.target.checked)items.forEach(m=>state.routeSelected.add(m));else items.forEach(m=>state.routeSelected.delete(m));buildRouteModelList();syncRouteModelHeader()};
|
|
@@ -380,7 +388,8 @@ document.addEventListener('click',e=>{if(!state.routeOpen)return;const ms=$('#ro
|
|
|
380
388
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&state.routeOpen){openRouteModels(false);const tg=$('#routeModelsMs .ms-toggle');if(tg)tg.focus()}});
|
|
381
389
|
$('#providerForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers',{method:'POST',body:JSON.stringify(x)});e.target.reset();toast(t('saved'),'ok');load()});
|
|
382
390
|
$('#keyForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers/'+encodeURIComponent(x.provider)+'/keys',{method:'POST',body:JSON.stringify({id:x.id,value:x.value,weight:Number(x.weight)||1})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
383
|
-
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
|
|
391
|
+
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...state.routeSelected];if(!provider){toast(t('msNeedProvider'));return}if(!models.length){toast(t('msEmpty'));return}for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});clearRouteModels();e.target.reset();toast(t('saved'),'ok');load()});
|
|
392
|
+
$('#importForm').onsubmit=run(async e=>{e.preventDefault();const file=$('#importFile').files[0];if(!file){toast(t('errChooseFile'));return}if(!confirm(t('confirmImport')))return;const content=await file.text();const r=await api('/_keys/import',{method:'POST',body:JSON.stringify({content,password:$('#importPassword').value})});e.target.reset();toast(tf('imported',{s:r.secrets,p:r.providers,r:r.routes}),'ok',6000);load()});
|
|
384
393
|
$('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
|
|
385
394
|
$('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
|
|
386
395
|
$('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
|