@alilis/k-hat 0.2.2 → 0.2.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/dist/web-ui.js +76 -15
- package/package.json +1 -1
package/dist/web-ui.js
CHANGED
|
@@ -40,6 +40,21 @@ main.container{padding-top:22px;padding-bottom:48px}
|
|
|
40
40
|
.filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 14px;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:10px}
|
|
41
41
|
.filter-bar input[type=search]{flex:1 1 220px;min-width:180px}
|
|
42
42
|
.filter-bar .match-info{margin-left:auto}
|
|
43
|
+
.ms{position:relative;display:inline-block;min-width:240px;flex:1 1 240px}
|
|
44
|
+
.ms-toggle{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;height:34px;padding:4px 10px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--text);font:inherit;font-size:13px;cursor:pointer;text-align:left;transition:border-color .15s,box-shadow .15s}
|
|
45
|
+
.ms-toggle:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--ring)}
|
|
46
|
+
.ms-toggle.ms-empty{color:var(--faint)}
|
|
47
|
+
.ms-toggle.ms-loading{color:var(--muted)}
|
|
48
|
+
.ms-toggle::after{content:'▾';color:var(--faint);font-size:12px;flex:none}
|
|
49
|
+
.ms-panel{position:absolute;top:calc(100% + 4px);left:0;z-index:60;width:100%;min-width:260px;max-height:340px;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border-strong);border-radius:10px;box-shadow:0 12px 32px rgba(15,23,42,.2);overflow:hidden}
|
|
50
|
+
.ms-search{margin:8px;border-radius:8px}
|
|
51
|
+
.ms-all{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12.5px;color:var(--muted);border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
|
|
52
|
+
.ms-list{overflow:auto;max-height:230px;padding:4px 0}
|
|
53
|
+
.ms-opt{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:13px;cursor:pointer;transition:background .12s}
|
|
54
|
+
.ms-opt:hover{background:var(--surface-2)}
|
|
55
|
+
.ms-opt input{height:auto;margin:0}
|
|
56
|
+
.ms-opt-name{font-family:var(--mono);font-size:12.5px;word-break:break-all}
|
|
57
|
+
.ms-opt-empty{padding:14px 12px;text-align:center;color:var(--faint);font-size:12.5px}
|
|
43
58
|
input,select{height:34px;padding:4px 10px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--text);font:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}
|
|
44
59
|
input:focus-visible,select:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--ring)}
|
|
45
60
|
input[readonly]{background:var(--surface-2);color:var(--muted)}
|
|
@@ -58,6 +73,8 @@ input::placeholder{color:var(--faint)}
|
|
|
58
73
|
.btn-icon{width:34px;padding:0;font-size:15px;line-height:1}
|
|
59
74
|
.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
60
75
|
.table-wrap{overflow-x:auto}
|
|
76
|
+
.logs-wrap{max-height:440px;overflow:auto}
|
|
77
|
+
.logs-wrap thead th{position:sticky;top:0;z-index:1}
|
|
61
78
|
table{border-collapse:collapse;width:100%;font-size:13px}
|
|
62
79
|
.provider-block table{margin:0}
|
|
63
80
|
th{font-size:12px;font-weight:600;color:var(--muted);text-align:left;padding:8px 10px;border-bottom:1px solid var(--border-strong);background:var(--surface-2);white-space:nowrap}
|
|
@@ -91,8 +108,7 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
91
108
|
.provider-name{margin:0;font-size:13.5px;font-weight:650}
|
|
92
109
|
.provider-url{flex:1 1 120px;min-width:0;font-family:var(--mono);font-size:12px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}
|
|
93
110
|
.provider-count{font-size:12px;color:var(--faint);white-space:nowrap}
|
|
94
|
-
#providerForm [name=id]{width:
|
|
95
|
-
#providerForm [name=name]{width:160px}
|
|
111
|
+
#providerForm [name=id]{width:160px}
|
|
96
112
|
#providerForm [name=baseUrl]{flex:1 1 220px;min-width:200px}
|
|
97
113
|
#keyForm [name=id]{width:170px}
|
|
98
114
|
#keyForm [name=value]{flex:1 1 220px;min-width:210px}
|
|
@@ -115,14 +131,14 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
115
131
|
</div>
|
|
116
132
|
<section class="card">
|
|
117
133
|
<div class="card-head"><div><h2 class="card-title" data-i18n="cardProviders">Provider / Key 管理</h2><p class="card-sub" data-i18n="cardProvidersSub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
|
|
118
|
-
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" data-i18n-ph="phProviderId" required><input name="
|
|
134
|
+
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" data-i18n-ph="phProviderId" required><input name="baseUrl" placeholder="https://api.example.com" data-i18n-ph="phBaseUrl" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary" data-i18n="btnAddProvider">添加 Provider</button></form>
|
|
119
135
|
<form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" data-i18n-ph="phKeyId" required><input name="weight" type="number" min="1" step="1" value="1" data-i18n-title="weightTitle"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" data-i18n-ph="phKeyValue" required autocomplete="new-password"><button class="btn btn-primary" data-i18n="btnAddKey">添加 Key</button></form>
|
|
120
136
|
<div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" data-i18n-ph="phSearchKey" data-i18n-aria="keyFilterAria" aria-label="筛选 Key"><select id="keyStatus" data-i18n-aria="statusFilterAria" aria-label="状态筛选"><option value="all" data-i18n="allStatus">全部状态</option><option value="available" data-i18n="onlyAvail">仅可用</option><option value="unavailable" data-i18n="onlyUnavail">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
|
|
121
137
|
<div id="providers"></div>
|
|
122
138
|
</section>
|
|
123
139
|
<section class="card">
|
|
124
140
|
<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>
|
|
125
|
-
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider" required></select><
|
|
141
|
+
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider" required></select><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>
|
|
126
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>
|
|
127
143
|
</section>
|
|
128
144
|
<section class="card">
|
|
@@ -132,22 +148,22 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
132
148
|
<section class="card">
|
|
133
149
|
<div class="card-head"><div><h2 class="card-title" data-i18n="cardLogs">Logs</h2><p class="card-sub" data-i18n="cardLogsSub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
|
|
134
150
|
<div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…" data-i18n-ph="phFilterLog"><select id="logStatus" data-i18n-aria="resultFilterAria" aria-label="结果筛选"><option value="all" data-i18n="allResult">全部结果</option><option value="ok" data-i18n="onlyOk">仅成功</option><option value="fail" data-i18n="onlyFail">仅失败</option></select><select id="logTail" data-i18n-aria="logCountAria" aria-label="日志条数"><option value="100" selected data-i18n="logTail100">最近 100 条</option><option value="300" data-i18n="logTail300">最近 300 条</option><option value="1000" data-i18n="logTail1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
|
|
135
|
-
<div class="table-wrap"><table><thead><tr><th data-i18n="thTime">Time</th><th data-i18n="thStatus">Status</th><th data-i18n="thProvider">Provider</th><th data-i18n="thKey">Key</th><th data-i18n="thModel">Model</th><th class="num" data-i18n="thDuration">Duration</th><th class="num" data-i18n="thTokensInOut">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
151
|
+
<div class="table-wrap logs-wrap"><table><thead><tr><th data-i18n="thTime">Time</th><th data-i18n="thStatus">Status</th><th data-i18n="thProvider">Provider</th><th data-i18n="thKey">Key</th><th data-i18n="thModel">Model</th><th class="num" data-i18n="thDuration">Duration</th><th class="num" data-i18n="thTokensInOut">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
136
152
|
</section>
|
|
137
153
|
</main>
|
|
138
154
|
<script type="module">
|
|
139
155
|
const $=s=>document.querySelector(s), error=$('#error');
|
|
140
156
|
const NS='http://www.w3.org/2000/svg';
|
|
141
157
|
const pad2=n=>String(n).padStart(2,'0');
|
|
142
|
-
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all'};
|
|
158
|
+
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all',routeModels:[],routeSelected:new Set(),routeFilter:'',routeOpen:false};
|
|
143
159
|
const I18N={
|
|
144
|
-
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:'获取模型',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:'请求成功 / 失败趋势'},
|
|
145
|
-
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',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'}
|
|
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'}
|
|
146
162
|
};
|
|
147
163
|
let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
|
|
148
164
|
function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
|
|
149
165
|
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}
|
|
150
|
-
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();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
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':'中'}
|
|
151
167
|
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)}
|
|
152
168
|
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}
|
|
153
169
|
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'))}
|
|
@@ -227,6 +243,40 @@ function renderRoutes(){
|
|
|
227
243
|
tr.append(d);tb.append(tr);
|
|
228
244
|
}
|
|
229
245
|
}
|
|
246
|
+
function syncRouteModelHeader(){
|
|
247
|
+
const toggle=$('#routeModelsMs .ms-toggle'),allCb=$('#routeModelsMs .ms-all-cb');
|
|
248
|
+
if(!toggle)return;
|
|
249
|
+
const total=state.routeModels.length;
|
|
250
|
+
if(!total){toggle.textContent=t('msEmpty');toggle.classList.add('ms-empty');toggle.classList.remove('ms-loading');allCb.checked=false;allCb.indeterminate=false;return}
|
|
251
|
+
toggle.classList.remove('ms-empty','ms-loading');
|
|
252
|
+
toggle.textContent=tf('msSelected',{n:state.routeSelected.size,total:total});
|
|
253
|
+
const kw=state.routeFilter.trim().toLowerCase();
|
|
254
|
+
const items=state.routeModels.filter(m=>!kw||m.toLowerCase().includes(kw));
|
|
255
|
+
allCb.checked=items.length>0&&items.every(m=>state.routeSelected.has(m));
|
|
256
|
+
allCb.indeterminate=items.length>0&&!allCb.checked&&items.some(m=>state.routeSelected.has(m));
|
|
257
|
+
}
|
|
258
|
+
function buildRouteModelList(){
|
|
259
|
+
const list=$('#routeModelsMs .ms-list');
|
|
260
|
+
if(!list)return;
|
|
261
|
+
const kw=state.routeFilter.trim().toLowerCase();
|
|
262
|
+
const items=state.routeModels.filter(m=>!kw||m.toLowerCase().includes(kw));
|
|
263
|
+
list.replaceChildren();
|
|
264
|
+
if(!state.routeModels.length)return;
|
|
265
|
+
if(!items.length){const em=document.createElement('div');em.className='ms-opt-empty';em.textContent=t('msNoMatch');list.append(em);return}
|
|
266
|
+
for(const m of items){const lab=document.createElement('label');lab.className='ms-opt';const cb=document.createElement('input');cb.type='checkbox';cb.checked=state.routeSelected.has(m);cb.onchange=()=>{if(cb.checked)state.routeSelected.add(m);else state.routeSelected.delete(m);syncRouteModelHeader()};const sp=document.createElement('span');sp.className='ms-opt-name';sp.textContent=m;lab.append(cb,sp);list.append(lab)}
|
|
267
|
+
}
|
|
268
|
+
function renderRouteModels(){syncRouteModelHeader();buildRouteModelList()}
|
|
269
|
+
function openRouteModels(open){
|
|
270
|
+
state.routeOpen=open;
|
|
271
|
+
const panel=$('#routeModelsMs .ms-panel'),toggle=$('#routeModelsMs .ms-toggle');
|
|
272
|
+
if(panel)panel.hidden=!open;
|
|
273
|
+
if(toggle)toggle.setAttribute('aria-expanded',open?'true':'false');
|
|
274
|
+
}
|
|
275
|
+
function clearRouteModels(){
|
|
276
|
+
state.routeModels=[];state.routeSelected.clear();state.routeFilter='';state.routeOpen=false;
|
|
277
|
+
const s=$('#routeModelsMs .ms-search');if(s)s.value='';
|
|
278
|
+
openRouteModels(false);renderRouteModels();
|
|
279
|
+
}
|
|
230
280
|
function renderLogs(){
|
|
231
281
|
const tb=$('#logs');tb.replaceChildren();
|
|
232
282
|
const kw=state.logKeyword.trim().toLowerCase();
|
|
@@ -280,13 +330,18 @@ function drawTraffic(){
|
|
|
280
330
|
}
|
|
281
331
|
async function loadModels(){
|
|
282
332
|
const provider=$('#routeForm [name=provider]').value;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
333
|
+
if(!provider){toast(t('msNeedProvider'));return}
|
|
334
|
+
const toggle=$('#routeModelsMs .ms-toggle');
|
|
335
|
+
state.routeModels=[];state.routeSelected.clear();state.routeFilter='';
|
|
336
|
+
const search=$('#routeModelsMs .ms-search');if(search)search.value='';
|
|
337
|
+
if(toggle){toggle.textContent=t('msLoading');toggle.classList.add('ms-loading');toggle.classList.remove('ms-empty')}
|
|
338
|
+
buildRouteModelList();
|
|
286
339
|
try{
|
|
287
340
|
const r=await api('/_keys/providers/'+encodeURIComponent(provider)+'/models');
|
|
288
|
-
|
|
289
|
-
|
|
341
|
+
state.routeModels=r.models||[];
|
|
342
|
+
renderRouteModels();
|
|
343
|
+
openRouteModels(state.routeModels.length>0);
|
|
344
|
+
}catch(e){error.textContent=e.message;toast(e.message);syncRouteModelHeader()}
|
|
290
345
|
}
|
|
291
346
|
async function load(){
|
|
292
347
|
try{
|
|
@@ -310,9 +365,15 @@ $('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
|
|
|
310
365
|
applyTheme(document.documentElement.getAttribute('data-theme')||'light');
|
|
311
366
|
applyLang(lang);
|
|
312
367
|
$('#routeModels').onclick=()=>loadModels();
|
|
368
|
+
$('#routeForm [name=provider]').onchange=clearRouteModels;
|
|
369
|
+
$('#routeModelsMs .ms-toggle').onclick=e=>{e.preventDefault();if(!state.routeModels.length)return;openRouteModels(!state.routeOpen)};
|
|
370
|
+
$('#routeModelsMs .ms-search').oninput=e=>{state.routeFilter=e.target.value;buildRouteModelList();syncRouteModelHeader()};
|
|
371
|
+
$('#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()};
|
|
372
|
+
document.addEventListener('click',e=>{if(!state.routeOpen)return;const ms=$('#routeModelsMs');if(ms&&!ms.contains(e.target))openRouteModels(false)});
|
|
373
|
+
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&state.routeOpen){openRouteModels(false);const tg=$('#routeModelsMs .ms-toggle');if(tg)tg.focus()}});
|
|
313
374
|
$('#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()});
|
|
314
375
|
$('#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()});
|
|
315
|
-
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...
|
|
376
|
+
$('#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()});
|
|
316
377
|
$('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
|
|
317
378
|
$('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
|
|
318
379
|
$('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
|