@alilis/k-hat 0.1.0 → 0.2.0

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 CHANGED
@@ -93,7 +93,7 @@ khat stop
93
93
  | `khat doctor` | 检测本机 Agent Tool 配置并输出接入提示 |
94
94
  | `khat log [--tail <n>]` | 查看最近的脱敏请求日志 |
95
95
  | `khat ui` | 签发一次性票据并打开本地管理页 |
96
- | `khat tui` | 在交互式终端中查看 daemon 状态、Provider/Key、路由和请求日志 |
96
+ | `khat tui` | 终端监控面板:跨 Provider 的 key 健康表(不可用排最前,`e` 一键恢复、`p` 探活)、请求日志与诊断,Tab/1-4 切换区块 |
97
97
 
98
98
  > Provider、key 和路由的新增、更新、删除与恢复等管理变更通过本地 Admin API 执行;若 daemon 尚未运行,命令会自动启动它。
99
99
 
@@ -0,0 +1,59 @@
1
+ // Pure state and helpers for the TUI: key-table ordering, log filtering and
2
+ // viewport slicing. No React, no I/O — directly unit-testable.
3
+ export const SECTIONS = ['overview', 'routes', 'logs', 'diagnostics'];
4
+ export const LOG_FILTERS = ['all', 'failed', 'ok'];
5
+ export function keyRows(status) {
6
+ const rows = [];
7
+ for (const provider of status?.providers ?? []) {
8
+ for (const key of provider.keys)
9
+ rows.push({ providerId: provider.id, keyId: key.id, key });
10
+ }
11
+ return rows.sort((a, b) => {
12
+ const rank = (row) => (row.key.status === 'unavailable' ? 0 : 1);
13
+ const byRank = rank(a) - rank(b);
14
+ if (byRank !== 0)
15
+ return byRank;
16
+ const byProvider = a.providerId.localeCompare(b.providerId);
17
+ if (byProvider !== 0)
18
+ return byProvider;
19
+ return a.keyId.localeCompare(b.keyId);
20
+ });
21
+ }
22
+ export function providerOf(status, id) {
23
+ return status?.providers.find((item) => item.id === id);
24
+ }
25
+ /** Green = available, red = unavailable, yellow = available but failing often. */
26
+ export function keyTone(key) {
27
+ if (key.status === 'unavailable')
28
+ return 'red';
29
+ return key.counters.failed > 0 ? 'yellow' : 'green';
30
+ }
31
+ export function filterLogs(logs, filter) {
32
+ if (filter === 'failed')
33
+ return logs.filter((entry) => entry.status >= 400);
34
+ if (filter === 'ok')
35
+ return logs.filter((entry) => entry.status < 400);
36
+ return logs;
37
+ }
38
+ export function newestFirst(logs) {
39
+ return [...logs].reverse();
40
+ }
41
+ /** Sliding window centered on the cursor, clamped to the list bounds. */
42
+ export function viewport(length, cursor, height) {
43
+ if (height < 1 || length === 0)
44
+ return { start: 0, items: [], above: 0, below: 0 };
45
+ const size = Math.min(length, height);
46
+ const start = Math.min(Math.max(0, cursor - Math.floor(size / 2)), length - size);
47
+ const items = [];
48
+ for (let index = start; index < start + size; index++)
49
+ items.push(index);
50
+ return { start, items, above: start, below: length - (start + size) };
51
+ }
52
+ export function moveCursor(current, delta, length) {
53
+ if (length <= 0)
54
+ return 0;
55
+ return Math.min(length - 1, Math.max(0, current + delta));
56
+ }
57
+ export function clampCursor(current, length) {
58
+ return length <= 0 ? 0 : Math.min(current, length - 1);
59
+ }
package/dist/tui.js CHANGED
@@ -1,32 +1,100 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
- import { Box, Text, useApp, useInput } from 'ink';
4
- const MAIN_DASHBOARD_INDEX = 0;
3
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
+ import { LOG_FILTERS, SECTIONS, clampCursor, filterLogs, keyRows, keyTone, newestFirst, providerOf, viewport, moveCursor } from './tui-state.js';
5
+ const TAB_LABELS = { overview: '概览', routes: '路由', logs: '日志', diagnostics: '诊断' };
6
+ const FILTER_LABELS = { all: '全部', failed: '仅失败', ok: '仅成功' };
7
+ const MIN_ROWS = 12;
5
8
  function n(value) { return new Intl.NumberFormat('en-US').format(value); }
6
- function mask(value) { return value ? '*'.repeat(Math.min(value.length, 24)) : ''; }
7
- function providerOf(status, id) { return status?.providers.find((item) => item.id === id); }
8
- function Menu({ title, items, selected }) { return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: title }), items.map((item, index) => _jsxs(Text, { color: index === selected ? 'yellow' : undefined, children: [index === selected ? '› ' : ' ', item] }, `${item}-${index}`))] }); }
9
- function FormView({ form }) { return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "yellow", children: [form.action === 'edit' ? 'Edit' : 'Add', " ", form.kind] }), form.fields.map((field, index) => _jsxs(Text, { color: index === form.cursor ? 'cyan' : undefined, children: [index === form.cursor ? '› ' : ' ', field, ": ", form.secret?.[index] ? mask(form.values[index]) : form.values[index]] }, field)), _jsx(Text, { dimColor: true, children: "Tab/\u2191\u2193 next field \u00B7 Enter submit \u00B7 Esc cancel" })] }); }
10
- function Details({ log }) { return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Log details" }), Object.entries(log).map(([key, value]) => _jsxs(Text, { children: [key, ": ", String(value)] }, key))] }); }
9
+ function compact(value) { return value >= 10_000 ? `${(value / 1_000).toFixed(1)}k` : n(value); }
10
+ function bytes(value) { return value >= 1_048_576 ? `${(value / 1_048_576).toFixed(1)}MB` : value >= 1_024 ? `${(value / 1_024).toFixed(1)}KB` : `${value}B`; }
11
+ function cell(value, width) { const text = String(value); return text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text.padEnd(width); }
12
+ function numCell(value, width) { const text = compact(value); return text.length > width ? text.slice(0, width) : text.padStart(width); }
13
+ function clock(ts) { const date = new Date(ts); return isNaN(date.getTime()) ? ts : date.toLocaleTimeString('en-GB', { hour12: false }); }
14
+ function day(ts) { const date = new Date(ts); return isNaN(date.getTime()) ? String(ts) : date.toLocaleString('sv-SE').replace('T', ' '); }
15
+ function duration(ms) { return ms < 1_000 ? `${ms}ms` : `${(ms / 1_000).toFixed(1)}s`; }
16
+ function Dot({ tone }) { return _jsx(Text, { color: tone, children: "\u25CF" }); }
17
+ function Tabs({ section }) {
18
+ return _jsx(Box, { gap: 1, children: SECTIONS.map((item, index) => (_jsx(Text, { color: item === section ? 'cyan' : 'gray', bold: item === section, children: item === section ? `[${index + 1} ${TAB_LABELS[item]}]` : ` ${index + 1} ${TAB_LABELS[item]} ` }, item))) });
19
+ }
20
+ function Edge({ text }) { return _jsx(Text, { dimColor: true, children: text }); }
21
+ function KeyDetail({ row, status }) {
22
+ const provider = providerOf(status, row.providerId);
23
+ return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", row.providerId, "/", row.keyId, " (", provider?.protocol ?? '?', ") ", provider?.baseUrl ?? ''] }), _jsxs(Text, { children: ["\u72B6\u6001 ", _jsx(Dot, { tone: keyTone(row.key) }), " ", row.key.status === 'available' ? '可用' : '不可用', " \u00B7 \u6743\u91CD ", row.key.weight, " \u00B7 \u5BC6\u94A5 ", row.key.secret ?? '(缺失)'] }), _jsxs(Text, { children: ["\u8BF7\u6C42 ", n(row.key.counters.requests), " \u00B7 \u5931\u8D25 ", n(row.key.counters.failed), " \u00B7 \u5B57\u8282 ", bytes(row.key.counters.bytesOut), " \u00B7 tokens ", compact(row.key.counters.tokensIn), " / ", compact(row.key.counters.tokensOut)] }), _jsxs(Text, { children: ["\u6700\u8FD1\u9519\u8BEF ", row.key.lastError ? `HTTP ${row.key.lastError.http} · ${day(row.key.lastError.at)}` : '—'] })] });
24
+ }
25
+ function LogDetail({ log }) {
26
+ return _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500 ", day(log.ts), " \u00B7 ", log.model] }), _jsxs(Text, { children: ["provider ", log.provider, " \u00B7 key ", log.key, " \u00B7 HTTP ", log.status, log.ttfbMs !== undefined ? ` · ttfb ${duration(log.ttfbMs)}` : ''] }), _jsxs(Text, { children: ["\u8017\u65F6 ", log.durationMs !== undefined ? duration(log.durationMs) : '—', " \u00B7 \u5927\u5C0F ", log.bytes !== undefined ? bytes(log.bytes) : '—', " \u00B7 tokens ", log.tokensIn !== undefined ? n(log.tokensIn) : '—', " / ", log.tokensOut !== undefined ? n(log.tokensOut) : '—'] })] });
27
+ }
28
+ function OverviewBody({ status, rows, cursor, detailOpen, height }) {
29
+ const keys = rows.map((row) => row.key);
30
+ const requests = keys.reduce((sum, item) => sum + item.counters.requests, 0);
31
+ const failed = keys.reduce((sum, item) => sum + item.counters.failed, 0);
32
+ const tokensIn = keys.reduce((sum, item) => sum + item.counters.tokensIn, 0);
33
+ const tokensOut = keys.reduce((sum, item) => sum + item.counters.tokensOut, 0);
34
+ const unavailable = keys.filter((item) => item.status === 'unavailable').length;
35
+ const tableHeight = Math.max(3, height - 5 - (detailOpen ? 5 : 0));
36
+ const view = viewport(rows.length, cursor, tableHeight);
37
+ return _jsxs(Box, { flexDirection: "column", gap: 1, children: [status ? _jsxs(Text, { children: ["daemon ", _jsx(Dot, { tone: "green" }), " \u5728\u7EBF ", status.listen, " \u00B7 keys ", keys.length, unavailable > 0 ? _jsxs(Text, { color: "red", children: ["\uFF08", unavailable, " \u4E0D\u53EF\u7528\uFF09"] }) : null, " \u00B7 \u8BF7\u6C42 ", compact(requests), " \u00B7 \u5931\u8D25 ", compact(failed), " \u00B7 tokens ", compact(tokensIn), "/", compact(tokensOut)] }) : _jsx(Text, { dimColor: true, children: "\u7B49\u5F85 daemon \u6570\u636E\u2026" }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0 key \u00B7 \u7528 khat provider add / khat key add \u6DFB\u52A0" }) : _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` ${cell('PROVIDER', 14)}${cell('KEY', 14)}${'WT'.padStart(3)} · ${'REQ'.padStart(8)}${'FAIL'.padStart(8)}${'ERR'.padStart(9)}` }), view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
38
+ const row = rows[index];
39
+ const selected = index === cursor;
40
+ const tone = keyTone(row.key);
41
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : undefined, children: `${selected ? '›' : ' '} ${cell(row.providerId, 14)}${cell(row.keyId, 14)} ${String(row.key.weight).padStart(2)} ` }), _jsx(Dot, { tone: tone }), _jsx(Text, { color: selected ? 'yellow' : row.key.status === 'unavailable' ? 'red' : undefined, children: ` ${numCell(row.key.counters.requests, 8)}${numCell(row.key.counters.failed, 8)}${(row.key.lastError ? `HTTP ${row.key.lastError.http}` : '—').padStart(9)}` })] }, `${row.providerId}/${row.keyId}`));
42
+ }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` }), detailOpen && rows[cursor] && _jsx(KeyDetail, { row: rows[cursor], status: status })] })] });
43
+ }
44
+ function RoutesBody({ status, cursor, height }) {
45
+ const routes = status?.routes ?? [];
46
+ const view = viewport(routes.length, cursor, Math.max(3, height - 3));
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
+ const route = routes[index];
49
+ const selected = index === cursor;
50
+ return (_jsx(Text, { color: selected ? 'yellow' : undefined, children: `${selected ? '›' : ' '} ${cell(route.model, 32)}→ ${route.provider}` }, route.model));
51
+ }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` })] }) });
52
+ }
53
+ function LogsBody({ logs, filter, cursor, detailOpen, height }) {
54
+ const rows = newestFirst(filterLogs(logs, filter));
55
+ const view = viewport(rows.length, cursor, Math.max(3, height - 3 - (detailOpen ? 5 : 0)));
56
+ return _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [`过滤: ${FILTER_LABELS[filter]} · ${rows.length} 条`, " ", cursor > 0 || detailOpen ? _jsx(Text, { color: "yellow", children: "\u00B7 \u5DF2\u6682\u505C\u5237\u65B0" }) : null] }), rows.length === 0 ? _jsx(Text, { dimColor: true, children: "\u6682\u65E0\u65E5\u5FD7" }) : _jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` ${cell('TIME', 8)} ${cell('ST', 3)} ${cell('MODEL', 26)}${cell('KEY', 14)}${'耗时'.padStart(8)}` }), view.above > 0 && _jsx(Edge, { text: ` ↑ ${view.above} more` }), view.items.map((index) => {
57
+ const log = rows[index];
58
+ const selected = index === cursor;
59
+ const failed = log.status >= 400;
60
+ return (_jsx(Text, { color: selected ? 'yellow' : failed ? 'red' : undefined, children: `${selected ? '›' : ' '} ${cell(clock(log.ts), 8)} ${cell(String(log.status), 3)} ${cell(log.model, 26)}${cell(log.key, 14)}${(log.durationMs !== undefined ? duration(log.durationMs) : '—').padStart(8)}` }, `${log.ts}-${index}`));
61
+ }), view.below > 0 && _jsx(Edge, { text: ` ↓ ${view.below} more` }), detailOpen && rows[cursor] && _jsx(LogDetail, { log: rows[cursor] })] })] });
62
+ }
63
+ function DiagnosticsBody({ snapshot }) {
64
+ const rows = keyRows(snapshot.status).filter((row) => row.key.status === 'unavailable');
65
+ return _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: ["daemon ", snapshot.error ? _jsxs(_Fragment, { children: [_jsx(Dot, { tone: "red" }), " \u4E0D\u53EF\u8FBE"] }) : _jsxs(_Fragment, { children: [_jsx(Dot, { tone: "green" }), " \u5728\u7EBF"] }), " \u00B7 \u5237\u65B0 ", snapshot.refreshedAt?.toLocaleTimeString('en-GB', { hour12: false }) ?? '—'] }), _jsxs(Text, { bold: true, color: rows.length ? 'red' : 'green', children: ["\u4E0D\u53EF\u7528 key\uFF1A", rows.length ? rows.length : '无'] }), rows.map((row) => _jsxs(Text, { color: "red", children: [" ", row.providerId, "/", row.keyId, " ", row.key.lastError ? `HTTP ${row.key.lastError.http} · ${day(row.key.lastError.at)}` : ''] }, `${row.providerId}/${row.keyId}`)), _jsx(Text, { dimColor: true, children: "\u63A2\u6D3B\u4E0E\u542F\u7528\u8BF7\u5728\u300C\u6982\u89C8\u300D\u9009\u4E2D key \u540E\u6309 p / e" }), _jsx(Text, { children: "[t] \u8F6E\u6362\u8BBF\u95EE\u4EE4\u724C" })] });
66
+ }
11
67
  export function Tui({ client }) {
12
68
  const { exit } = useApp();
13
- const [view, setView] = useState({ kind: 'main' });
14
- const [selected, setSelected] = useState(MAIN_DASHBOARD_INDEX);
69
+ const { stdout } = useStdout();
70
+ const rows = Math.max(1, stdout?.rows ?? 24);
71
+ const [section, setSection] = useState('overview');
15
72
  const [snapshot, setSnapshot] = useState({ status: null, logs: [], error: null, refreshedAt: null });
16
73
  const [loading, setLoading] = useState(false);
17
74
  const loadingRef = useRef(false);
18
75
  const [notice, setNotice] = useState(null);
19
76
  const [confirm, setConfirm] = useState(null);
20
- const [form, setForm] = useState(null);
21
- const [filters, setFilters] = useState({ model: '', key: '', status: '' });
22
- const refresh = useCallback(async () => {
77
+ const [tokenScreen, setTokenScreen] = useState(null);
78
+ const [keyCursor, setKeyCursor] = useState(0);
79
+ const [routeCursor, setRouteCursor] = useState(0);
80
+ const [logCursor, setLogCursor] = useState(0);
81
+ const [keyDetail, setKeyDetail] = useState(false);
82
+ const [logDetail, setLogDetail] = useState(false);
83
+ const [logFilter, setLogFilter] = useState('all');
84
+ const logCursorRef = useRef(0);
85
+ const logDetailRef = useRef(false);
86
+ logCursorRef.current = logCursor;
87
+ logDetailRef.current = logDetail;
88
+ const refresh = useCallback(async (force = false) => {
23
89
  if (loadingRef.current)
24
90
  return;
25
91
  loadingRef.current = true;
26
92
  setLoading(true);
27
93
  try {
28
- const [status, logs] = await Promise.all([client.status(), client.logs(50, filters)]);
29
- setSnapshot({ status, logs, error: null, refreshedAt: new Date() });
94
+ const status = await client.status();
95
+ const frozen = !force && (logCursorRef.current > 0 || logDetailRef.current);
96
+ const logs = frozen ? undefined : await client.logs(50);
97
+ setSnapshot((current) => ({ status, logs: logs ?? current.logs, error: null, refreshedAt: new Date() }));
30
98
  }
31
99
  catch (error) {
32
100
  setSnapshot((current) => ({ ...current, error: error?.message ?? String(error) }));
@@ -35,252 +103,130 @@ export function Tui({ client }) {
35
103
  loadingRef.current = false;
36
104
  setLoading(false);
37
105
  }
38
- }, [client, filters]);
106
+ }, [client]);
39
107
  useEffect(() => { void refresh(); }, [refresh]);
40
108
  useEffect(() => { const timer = setInterval(() => void refresh(), 5_000); return () => clearInterval(timer); }, [refresh]);
41
- const providers = snapshot.status?.providers ?? [];
109
+ const keyRowsValue = keyRows(snapshot.status);
42
110
  const routes = snapshot.status?.routes ?? [];
43
- const currentProvider = view.kind === 'provider-menu' || view.kind === 'keys' || view.kind === 'key-menu' ? providerOf(snapshot.status, view.providerId) : undefined;
44
- const currentKey = view.kind === 'key-menu' ? currentProvider?.keys.find((item) => item.id === view.keyId) : undefined;
45
- const mutation = async (operation, message) => { setConfirm(null); setForm(null); setNotice('Working…'); try {
46
- await operation();
47
- setNotice(message);
48
- await refresh();
49
- }
50
- catch (error) {
51
- setNotice(error?.message ?? String(error));
52
- } };
53
- const submit = async (current) => {
54
- const values = current.values.map((value) => value.trim());
55
- const required = current.kind === 'key' && current.action === 'edit' ? values.slice(0, 3) : values;
56
- if (required.some((value) => !value)) {
57
- setNotice('Required fields are missing.');
58
- return;
59
- }
60
- if (current.kind === 'provider') {
61
- if (!['openai', 'anthropic'].includes(values[2])) {
62
- setNotice('Protocol must be openai or anthropic.');
63
- return;
64
- }
65
- const input = { name: values[1], protocol: values[2], baseUrl: values[3].replace(/\/+$/, '') };
66
- await mutation(() => current.action === 'edit' && current.target?.providerId ? client.updateProvider(current.target.providerId, input) : client.createProvider({ id: values[0], ...input }), `${current.action === 'edit' ? 'Updated' : 'Added'} provider ${values[0]}`);
67
- }
68
- else if (current.kind === 'key') {
69
- const weight = Number(values[2]);
70
- if (!Number.isInteger(weight) || weight < 1) {
71
- setNotice('Weight must be a positive integer.');
72
- return;
73
- }
74
- const providerId = current.target?.providerId;
75
- const keyId = current.target?.keyId;
76
- if (current.action === 'edit' && providerId && keyId)
77
- await mutation(() => client.updateKey(providerId, keyId, { weight, ...(values[3] ? { value: values[3] } : {}) }), `Updated key ${values[1]}`);
78
- else
79
- await mutation(() => client.createKey(values[0], { id: values[1], value: values[3], weight }), `Added key ${values[0]}/${values[1]}`);
111
+ const logRowsLength = filterLogs(snapshot.logs, logFilter).length;
112
+ useEffect(() => { setKeyCursor((value) => clampCursor(value, keyRowsValue.length)); }, [keyRowsValue.length]);
113
+ useEffect(() => { setRouteCursor((value) => clampCursor(value, routes.length)); }, [routes.length]);
114
+ useEffect(() => { setLogCursor((value) => clampCursor(value, logRowsLength)); }, [logRowsLength, logFilter]);
115
+ const mutate = async (operation, message) => {
116
+ setConfirm(null);
117
+ setNotice({ text: '执行中…', tone: 'green' });
118
+ try {
119
+ await operation();
120
+ setNotice({ text: message, tone: 'green' });
121
+ await refresh(true);
80
122
  }
81
- else if (current.kind === 'route')
82
- await mutation(() => current.action === 'edit' && current.target?.model ? client.updateRoute(current.target.model, values[1]) : client.createRoute(values[0], values[1]), `${current.action === 'edit' ? 'Updated' : 'Added'} route ${values[0]}`);
83
- else {
84
- setFilters({ model: values[0], key: values[1], status: values[2] });
85
- setForm(null);
86
- setNotice('Log filters updated');
123
+ catch (error) {
124
+ setNotice({ text: error?.message ?? String(error), tone: 'red' });
87
125
  }
88
126
  };
89
- const items = view.kind === 'main' ? ['Dashboard', 'Providers', 'Routes', 'Logs', 'Diagnostics', 'Quit']
90
- : view.kind === 'providers' ? ['Add provider', ...providers.map((item) => `${item.id} (${item.protocol})`), 'Back']
91
- : view.kind === 'provider-menu' ? ['View details', 'Keys', 'Edit provider', 'Delete provider', 'Back']
92
- : view.kind === 'keys' ? ['Add key', ...(currentProvider?.keys ?? []).map((item) => `${item.id} (${item.status})`), 'Back']
93
- : view.kind === 'key-menu' ? ['View details', 'Edit key', 'Enable key', 'Probe key', 'Delete key', 'Back']
94
- : view.kind === 'routes' ? ['Add route', ...routes.map((item) => `${item.model} → ${item.provider}`), 'Back']
95
- : view.kind === 'route-menu' ? ['Edit route', 'Delete route', 'Back']
96
- : view.kind === 'logs' ? ['Browse recent logs', 'Set filters', 'Clear filters', 'Back']
97
- : view.kind === 'diagnostics' ? ['Health', 'Unavailable keys', 'Rotate access token', 'Back'] : [];
127
+ const selectSection = (next) => { setSection(next); setKeyDetail(false); setLogDetail(false); };
98
128
  useInput((input, key) => {
99
- if (form) {
100
- if (key.escape)
101
- setForm(null);
102
- else if (key.return)
103
- void submit(form);
104
- else if (key.tab || key.downArrow || key.rightArrow)
105
- setForm((current) => current && ({ ...current, cursor: (current.cursor + 1) % current.fields.length }));
106
- else if (key.upArrow || key.leftArrow)
107
- setForm((current) => current && ({ ...current, cursor: (current.cursor + current.fields.length - 1) % current.fields.length }));
108
- else if (key.backspace || key.delete)
109
- setForm((current) => current && ({ ...current, values: current.values.map((value, index) => index === current.cursor ? value.slice(0, -1) : value) }));
110
- else if (input && !key.ctrl && !key.meta)
111
- setForm((current) => current && ({ ...current, values: current.values.map((value, index) => index === current.cursor ? value + input : value) }));
112
- return;
113
- }
114
129
  if (confirm) {
115
130
  if (input === 'y' || key.return)
116
- void mutation(confirm.run, `${confirm.label} completed`);
131
+ void mutate(confirm.run, `${confirm.label} 完成`);
117
132
  else if (input === 'n' || key.escape)
118
133
  setConfirm(null);
119
134
  return;
120
135
  }
136
+ if (tokenScreen) {
137
+ if (key.escape || input === 't')
138
+ setTokenScreen(null);
139
+ return;
140
+ }
121
141
  if (input === 'q' || (key.ctrl && input === 'c'))
122
142
  return exit();
123
- if (key.escape) {
124
- setView((current) => current.kind === 'main' ? current : { kind: 'main' });
125
- setSelected(MAIN_DASHBOARD_INDEX);
143
+ if (key.tab) {
144
+ selectSection(SECTIONS[(SECTIONS.indexOf(section) + 1) % SECTIONS.length]);
126
145
  return;
127
146
  }
128
- if (key.upArrow) {
129
- setSelected((value) => Math.max(0, value - 1));
147
+ const digit = ['1', '2', '3', '4'].indexOf(input);
148
+ if (digit >= 0) {
149
+ selectSection(SECTIONS[digit]);
130
150
  return;
131
151
  }
132
- if (key.downArrow) {
133
- setSelected((value) => Math.min(Math.max(0, items.length - 1), value + 1));
152
+ if (key.escape) {
153
+ if (keyDetail)
154
+ setKeyDetail(false);
155
+ else if (logDetail)
156
+ setLogDetail(false);
134
157
  return;
135
158
  }
136
159
  if (input === 'r') {
137
- void refresh();
138
- return;
139
- }
140
- if (input === 'd') {
141
- setView({ kind: 'dashboard' });
142
- setSelected(0);
160
+ void refresh(true);
143
161
  return;
144
162
  }
145
- if (input === 'p') {
146
- setView({ kind: 'providers' });
147
- setSelected(0);
148
- return;
149
- }
150
- if (input === 'g') {
151
- setView({ kind: 'routes' });
152
- setSelected(0);
153
- return;
154
- }
155
- if (input === 'l') {
156
- setView({ kind: 'logs' });
157
- setSelected(0);
158
- return;
159
- }
160
- if (input === 'i') {
161
- setView({ kind: 'diagnostics' });
162
- setSelected(0);
163
- return;
164
- }
165
- if (input !== '\r' && input !== '\n' && key.return)
166
- return;
167
- if (!key.return)
168
- return;
169
- if (view.kind === 'main') {
170
- const target = items[selected];
171
- setView({ kind: target === 'Dashboard' ? 'dashboard' : target === 'Providers' ? 'providers' : target === 'Routes' ? 'routes' : target === 'Logs' ? 'logs' : target === 'Diagnostics' ? 'diagnostics' : 'main' });
172
- setSelected(0);
173
- if (target === 'Quit')
174
- exit();
175
- }
176
- else if (view.kind === 'providers') {
177
- if (selected === 0)
178
- setForm({ kind: 'provider', action: 'create', fields: ['id', 'name', 'protocol', 'base URL'], values: ['', '', 'openai', ''], cursor: 0 });
179
- else if (selected <= providers.length)
180
- setView({ kind: 'provider-menu', providerId: providers[selected - 1].id });
181
- else
182
- setView({ kind: 'main' });
183
- setSelected(0);
184
- }
185
- else if (view.kind === 'provider-menu' && currentProvider) {
186
- if (selected === 0)
187
- setNotice(`${currentProvider.id}: ${currentProvider.baseUrl}`);
188
- else if (selected === 1)
189
- setView({ kind: 'keys', providerId: currentProvider.id });
190
- else if (selected === 2)
191
- setForm({ kind: 'provider', action: 'edit', target: { providerId: currentProvider.id }, fields: ['id', 'name', 'protocol', 'base URL'], values: [currentProvider.id, currentProvider.name, currentProvider.protocol, currentProvider.baseUrl], cursor: 1 });
192
- else if (selected === 3)
193
- setConfirm({ label: `delete provider ${currentProvider.id} and its keys/routes`, run: () => client.deleteProvider(currentProvider.id) });
194
- else
195
- setView({ kind: 'providers' });
196
- setSelected(0);
197
- }
198
- else if (view.kind === 'keys' && currentProvider) {
199
- if (selected === 0)
200
- setForm({ kind: 'key', action: 'create', fields: ['provider', 'id', 'weight', 'secret'], values: [currentProvider.id, '', '1', ''], cursor: 1, secret: [false, false, false, true] });
201
- else if (selected <= currentProvider.keys.length)
202
- setView({ kind: 'key-menu', providerId: currentProvider.id, keyId: currentProvider.keys[selected - 1].id });
203
- else
204
- setView({ kind: 'provider-menu', providerId: currentProvider.id });
205
- setSelected(0);
206
- }
207
- else if (view.kind === 'key-menu' && currentProvider && currentKey) {
208
- if (selected === 0)
209
- setNotice(`${currentKey.id}: ${currentKey.secret ?? '(missing)'}`);
210
- else if (selected === 1)
211
- setForm({ kind: 'key', action: 'edit', target: { providerId: currentProvider.id, keyId: currentKey.id }, fields: ['provider', 'id', 'weight', 'new secret'], values: [currentProvider.id, currentKey.id, String(currentKey.weight), ''], cursor: 2, secret: [false, false, false, true] });
212
- else if (selected === 2)
213
- setConfirm(currentKey.status === 'unavailable' ? { label: `enable ${currentProvider.id}/${currentKey.id}`, run: () => client.enableKey(currentProvider.id, currentKey.id) } : { label: 'Key is already available', run: async () => undefined });
214
- else if (selected === 3)
215
- setConfirm({ label: `probe ${currentProvider.id}/${currentKey.id}`, run: async () => { const result = await client.probeKey(currentProvider.id, currentKey.id); if (!result.ok)
216
- throw new Error(`probe returned HTTP ${result.status}`); } });
217
- else if (selected === 4)
218
- setConfirm({ label: `delete key ${currentProvider.id}/${currentKey.id}`, run: () => client.deleteKey(currentProvider.id, currentKey.id) });
219
- else
220
- setView({ kind: 'keys', providerId: currentProvider.id });
221
- setSelected(0);
222
- }
223
- else if (view.kind === 'routes') {
224
- if (selected === 0)
225
- setForm({ kind: 'route', action: 'create', fields: ['model', 'provider'], values: ['', providers[0]?.id ?? ''], cursor: 0 });
226
- else if (selected <= routes.length)
227
- setView({ kind: 'route-menu', model: routes[selected - 1].model });
228
- else
229
- setView({ kind: 'main' });
230
- setSelected(0);
231
- }
232
- else if (view.kind === 'route-menu') {
233
- const route = routes.find((item) => item.model === view.model);
234
- if (!route)
235
- return;
236
- if (selected === 0)
237
- setForm({ kind: 'route', action: 'edit', target: { model: route.model }, fields: ['model', 'provider'], values: [route.model, route.provider], cursor: 1 });
238
- else if (selected === 1)
239
- setConfirm({ label: `delete route ${route.model}`, run: () => client.deleteRoute(route.model) });
240
- else
241
- setView({ kind: 'routes' });
242
- setSelected(0);
243
- }
244
- else if (view.kind === 'logs') {
245
- if (selected === 0)
246
- setView({ kind: 'log-details', index: snapshot.logs.length - 1 });
247
- else if (selected === 1)
248
- setForm({ kind: 'logs', fields: ['model', 'key', 'status'], values: [filters.model, filters.key, filters.status], cursor: 0 });
249
- else if (selected === 2) {
250
- setFilters({ model: '', key: '', status: '' });
251
- void refresh();
163
+ if (section === 'overview') {
164
+ const row = keyRowsValue[keyCursor];
165
+ if (key.upArrow)
166
+ setKeyCursor((value) => moveCursor(value, -1, keyRowsValue.length));
167
+ else if (key.downArrow)
168
+ setKeyCursor((value) => moveCursor(value, 1, keyRowsValue.length));
169
+ else if (key.return)
170
+ setKeyDetail((value) => !value);
171
+ else if (row && input === 'e') {
172
+ if (row.key.status === 'unavailable')
173
+ void mutate(() => client.enableKey(row.providerId, row.keyId), `已启用 ${row.providerId}/${row.keyId}`);
174
+ }
175
+ else if (row && input === 'p') {
176
+ setNotice({ text: `探活 ${row.providerId}/${row.keyId}…`, tone: 'green' });
177
+ client.probeKey(row.providerId, row.keyId)
178
+ .then((result) => setNotice({ text: result.ok ? `探活成功 · HTTP ${result.status}` : `探活失败 · HTTP ${result.status}`, tone: result.ok ? 'green' : 'red' }))
179
+ .catch((error) => setNotice({ text: error?.message ?? String(error), tone: 'red' }));
180
+ }
181
+ else if (row && input === 'd')
182
+ setConfirm({ label: `删除 key ${row.providerId}/${row.keyId}`, run: () => client.deleteKey(row.providerId, row.keyId) });
183
+ }
184
+ else if (section === 'routes') {
185
+ const route = routes[routeCursor];
186
+ if (key.upArrow)
187
+ setRouteCursor((value) => moveCursor(value, -1, routes.length));
188
+ else if (key.downArrow)
189
+ setRouteCursor((value) => moveCursor(value, 1, routes.length));
190
+ else if (route && input === 'd')
191
+ setConfirm({ label: `删除路由 ${route.model}`, run: () => client.deleteRoute(route.model) });
192
+ }
193
+ else if (section === 'logs') {
194
+ if (key.upArrow)
195
+ setLogCursor((value) => moveCursor(value, -1, logRowsLength));
196
+ else if (key.downArrow)
197
+ setLogCursor((value) => moveCursor(value, 1, logRowsLength));
198
+ else if (key.return)
199
+ setLogDetail((value) => !value);
200
+ else if (input === 'f') {
201
+ setLogFilter((current) => LOG_FILTERS[(LOG_FILTERS.indexOf(current) + 1) % LOG_FILTERS.length]);
202
+ setLogCursor(0);
203
+ setLogDetail(false);
252
204
  }
253
- else
254
- setView({ kind: 'main' });
255
- setSelected(0);
256
205
  }
257
- else if (view.kind === 'diagnostics') {
258
- if (selected === 2)
259
- setConfirm({ label: 'rotate access token', run: async () => { const result = await client.rotateToken(); setNotice(`New token: ${result.token}`); } });
260
- else if (selected === 3)
261
- setView({ kind: 'main' });
206
+ else if (section === 'diagnostics') {
207
+ if (input === 't')
208
+ setConfirm({ label: '轮换访问令牌(旧令牌立即失效)', run: async () => { const result = await client.rotateToken(); setTokenScreen(result.token); } });
262
209
  }
263
210
  });
264
- let content;
265
- if (form)
266
- content = _jsx(FormView, { form: form });
267
- else if (confirm)
268
- content = _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Confirm: ", confirm.label, "?"] }), _jsx(Text, { children: "Press y/Enter to confirm, n/Esc to cancel." })] });
269
- else if (view.kind === 'dashboard') {
270
- const keys = providers.flatMap((item) => item.keys);
271
- const requests = keys.reduce((sum, item) => sum + item.counters.requests, 0);
272
- const failed = keys.reduce((sum, item) => sum + item.counters.failed, 0);
273
- content = _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Dashboard" }), _jsxs(Text, { children: ["daemon: ", snapshot.error ? 'offline' : 'online', " \u00B7 ", snapshot.status?.listen ?? '—'] }), _jsxs(Text, { children: ["providers: ", providers.length, " \u00B7 keys: ", keys.length, " \u00B7 unavailable: ", keys.filter((item) => item.status === 'unavailable').length] }), _jsxs(Text, { children: ["requests: ", n(requests), " \u00B7 failed: ", n(failed), " \u00B7 success: ", requests ? `${(((requests - failed) / requests) * 100).toFixed(1)}%` : '—'] }), _jsxs(Text, { children: ["tokens: ", n(keys.reduce((sum, item) => sum + item.counters.tokensIn, 0)), " in / ", n(keys.reduce((sum, item) => sum + item.counters.tokensOut, 0)), " out"] })] });
274
- }
275
- else if (view.kind === 'log-details')
276
- content = snapshot.logs[view.index] ? _jsx(Details, { log: snapshot.logs[view.index] }) : _jsx(Text, { children: "No log selected." });
277
- else if (view.kind === 'diagnostics')
278
- content = _jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { title: "Diagnostics", items: items, selected: selected }), _jsxs(Text, { dimColor: true, children: ["health: ", snapshot.error ? 'unreachable' : 'ok', " \u00B7 refreshed: ", snapshot.refreshedAt?.toLocaleTimeString() ?? '—'] })] });
279
- else if (view.kind === 'logs')
280
- content = _jsxs(Box, { flexDirection: "column", children: [_jsx(Menu, { title: "Logs", items: items, selected: selected }), _jsxs(Text, { dimColor: true, children: ["filters: ", filters.model || '*', " / ", filters.key || '*', " / ", filters.status || '*'] }), snapshot.logs.length > 0 && _jsxs(Text, { children: [snapshot.logs.length, " log(s) loaded"] })] });
281
- else if (view.kind === 'main' || view.kind === 'providers' || view.kind === 'provider-menu' || view.kind === 'keys' || view.kind === 'key-menu' || view.kind === 'routes' || view.kind === 'route-menu')
282
- content = _jsx(Menu, { title: view.kind === 'main' ? 'khat manager' : view.kind, items: items, selected: selected });
211
+ const bodyHeight = rows - 8;
212
+ let body;
213
+ if (rows < MIN_ROWS)
214
+ body = _jsxs(Text, { color: "yellow", children: ["\u7EC8\u7AEF\u592A\u77EE\uFF08", rows, " \u884C\uFF09\uFF0C\u8BF7\u8C03\u9AD8\u7A97\u53E3"] });
215
+ else if (section === 'overview')
216
+ body = _jsx(OverviewBody, { status: snapshot.status, rows: keyRowsValue, cursor: keyCursor, detailOpen: keyDetail, height: bodyHeight });
217
+ else if (section === 'routes')
218
+ body = _jsx(RoutesBody, { status: snapshot.status, cursor: routeCursor, height: bodyHeight });
219
+ else if (section === 'logs')
220
+ body = _jsx(LogsBody, { logs: snapshot.logs, filter: logFilter, cursor: logCursor, detailOpen: logDetail, height: bodyHeight });
283
221
  else
284
- content = _jsx(Text, { children: "Loading\u2026" });
285
- return _jsxs(Box, { flexDirection: "column", minHeight: 14, children: [_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "khat manager" }), _jsx(Text, { children: loading ? 'refreshing' : snapshot.error ? 'offline' : 'online' })] }), _jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [content, snapshot.error && !form && !confirm && _jsx(Text, { color: "red", children: snapshot.error }), notice && _jsx(Text, { color: "green", children: notice })] }), _jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Enter open \u00B7 Esc back \u00B7 r refresh \u00B7 q quit" }) })] });
222
+ body = _jsx(DiagnosticsBody, { snapshot: snapshot });
223
+ const HELP = {
224
+ overview: 'Tab/1-4 区块 · ↑↓ 选择 · e 启用 · p 探活 · d 删除 · Enter 详情 · r 刷新 · q 退出',
225
+ routes: 'Tab/1-4 区块 · ↑↓ 选择 · d 删除 · r 刷新 · q 退出',
226
+ logs: 'Tab/1-4 区块 · ↑↓ 选择 · f 过滤 · Enter 详情 · r 刷新 · q 退出',
227
+ diagnostics: 'Tab/1-4 区块 · t 轮换令牌 · r 刷新 · q 退出'
228
+ };
229
+ const help = confirm ? 'y 确认 · n/Esc 取消' : tokenScreen ? 'Esc 关闭' : HELP[section];
230
+ const statusBadge = loading ? _jsx(Text, { color: "yellow", children: "\u5237\u65B0\u4E2D" }) : snapshot.error ? _jsx(Text, { color: "red", children: "\u79BB\u7EBF" }) : _jsx(Text, { color: "green", children: "\u5728\u7EBF" });
231
+ return _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "khat" }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: snapshot.refreshedAt?.toLocaleTimeString('en-GB', { hour12: false }) ?? '' }), statusBadge] })] }), _jsx(Tabs, { section: section }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [body, snapshot.error && _jsx(Text, { color: "red", children: snapshot.error })] }), tokenScreen ? _jsxs(Box, { borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "\u65B0\u8BBF\u95EE\u4EE4\u724C\uFF08\u4EC5\u672C\u6B21\u5C55\u793A\uFF09" }), _jsx(Text, { children: tokenScreen }), _jsx(Text, { dimColor: true, children: "\u5173\u95ED\u540E\u53EF\u968F\u65F6\u7528 khat token show \u91CD\u770B \u00B7 Esc \u5173\u95ED" })] }) : notice ? _jsx(Text, { color: notice.tone, wrap: "truncate", children: notice.text }) : _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: help })] });
286
232
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alilis/k-hat",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },