M4 收口:神识用户面与计量闭环——BYOK/配额三段式/usage 落库/Anthropic+Gemini 适配器/神识阁
- 神识 Go:store 双实现(PG 自动建表+60s 就绪重试 / 内存回退)承载 usage_logs·byok_keys·quota_balances;BYOK 密钥库(AES-GCM 加密、API 脱敏、撤回即失效、主密钥未配置 fail-closed);配额三段式(预扣 max_tokens 钳位 1M→结算实际 token→全败幂等退款,HJ-2004);计量归属(IR user_id→usage_logs,/me/usage 用户维度聚合+配额总览,/admin/usage/sample 对账抽样);Anthropic/Gemini 出站适配器(SSE 解析、system 提升、用量映射);stub 端点补名修复计量空 endpoint - IR 契约:user_id/byok_key_id 双端同步(Go types.go + Py types.py) - 灵台:runs.owner_id(迁移 005,server_default 兼容存量)经 kernel 全链透传为 IR user_id;shenshi_client 扩展用户面方法(X-Hunjing-User-Id 代理,Echo 桩带内存实现);/me/byok-keys 登记·列表·撤回与 /me/usage 端点(4 用例) - 前端神识阁(/models 重建):偏好(生成参数表单)/自带密钥(登记加密保存、脱敏列表、撤回二次确认)/用量(聚合表+配额进度条)三区;me API 库+mock+5 用例;侧栏恢复入口 - 配置与部署:.env.example 增 SHENSHI_DATABASE_URL/SHENSHI_BYOK_MASTER_KEY/SHENSHI_USER_BUDGET_TOKENS;compose 神识容器接自有库(init-shenshi.sql 预建 hunjing_shenshi,与灵台不共库共表) - 证据:Go 34 用例(BYOK 独占/配额拒绝/结算退款/归属聚合)、后端 532、前端 210、BFF 4 全绿;跨进程 E2E 四场景(灵台 HttpShenshiClient→真 Go 神识:登记脱敏/BYOK 独占不可达整体失败/用量归属聚合/撤回即失效)通过;rewrite-plan M4 标记完成、能力矩阵神识行升已验证
This commit is contained in:
1 parent
462d570751
commit
144415247e
51 files changed
+3533
-90
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { PageContainer } from "@/components/layout/PageContainer";
|
||||
import { ShenshiPavilion } from "@/components/shenshi/ShenshiPavilion";
|
||||
import { serverApi } from "@/lib/api/server-client";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* 神识阁:模型偏好 / 自带密钥(BYOK)/ 用量。
|
||||
*
|
||||
* 偏好来自灵台 /settings/preferences;BYOK 与用量经灵台 /me/* 代理神识。
|
||||
*/
|
||||
export default async function ShenshiPage() {
|
||||
let preferences: GenerationPreferences | null = null;
|
||||
let keys: ByokKey[] = [];
|
||||
let usage: MyUsage | null = null;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const [prefs, byok, myUsage] = await Promise.all([
|
||||
serverApi.get<GenerationPreferences>("/settings/preferences"),
|
||||
serverApi.get<{ keys: ByokKey[] }>("/me/byok-keys"),
|
||||
serverApi.get<MyUsage>("/me/usage"),
|
||||
]);
|
||||
preferences = prefs;
|
||||
keys = byok.keys ?? [];
|
||||
usage = myUsage;
|
||||
} catch (e) {
|
||||
error = describeError(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="神识阁"
|
||||
description="模型偏好 · 自带密钥 · 用量与配额(神识网关统一接管)"
|
||||
>
|
||||
<ShenshiPavilion
|
||||
initialPreferences={preferences}
|
||||
initialKeys={keys}
|
||||
initialUsage={usage}
|
||||
error={error}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CharacterIcon,
|
||||
CosmosIcon,
|
||||
DriveIcon,
|
||||
ModelIcon,
|
||||
PersonalityIcon,
|
||||
RunIcon,
|
||||
SettingsIcon,
|
||||
@@ -93,6 +94,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
title: "百工",
|
||||
items: [
|
||||
{ href: "/tools", label: "工具", icon: <ToolsIcon /> },
|
||||
{ href: "/models", label: "神识阁", icon: <ModelIcon /> },
|
||||
{ href: "/settings", label: "设置", icon: <SettingsIcon /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { ShenshiPavilion } from "./ShenshiPavilion";
|
||||
import { meApi, settingsApi } from "@/lib/api";
|
||||
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
meApi: {
|
||||
registerByokKey: vi.fn(),
|
||||
revokeByokKey: vi.fn(),
|
||||
listByokKeys: vi.fn(),
|
||||
getMyUsage: vi.fn(),
|
||||
},
|
||||
settingsApi: {
|
||||
updateGenerationPreferences: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const prefs: GenerationPreferences = {
|
||||
default_model_hint: "deepseek-v4-flash",
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
max_output_tokens: 4096,
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
const keys: ByokKey[] = [
|
||||
{
|
||||
id: "byok_1",
|
||||
name: "主力 DeepSeek",
|
||||
type: "openai_compatible",
|
||||
base_url: "https://api.deepseek.com",
|
||||
model: "deepseek-v4-flash",
|
||||
api_key_mask: "sk-****",
|
||||
},
|
||||
];
|
||||
|
||||
const usage: MyUsage = {
|
||||
rows: [
|
||||
{
|
||||
model: "deepseek-v4-flash",
|
||||
endpoint: "deepseek",
|
||||
requests: 12,
|
||||
ok: 11,
|
||||
prompt_tokens: 8400,
|
||||
completion_tokens: 3100,
|
||||
},
|
||||
],
|
||||
quota: { budget: 1000000, used: 11500, reserved: 0 },
|
||||
};
|
||||
|
||||
describe("ShenshiPavilion(神识阁)", () => {
|
||||
it("三区渲染:偏好 / BYOK 脱敏列表 / 用量聚合", () => {
|
||||
render(
|
||||
<ShenshiPavilion
|
||||
initialPreferences={prefs}
|
||||
initialKeys={keys}
|
||||
initialUsage={usage}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("生成参数")).toBeInTheDocument();
|
||||
expect(screen.getByText("自带密钥")).toBeInTheDocument();
|
||||
expect(screen.getByText("用量")).toBeInTheDocument();
|
||||
// 密钥只显脱敏掩码,不显明文(初始数据本就脱敏,这里钉死展示语义)
|
||||
expect(screen.getByText(/sk-\*\*\*\*/)).toBeInTheDocument();
|
||||
// 用量行与配额呈现
|
||||
expect(screen.getByText("deepseek-v4-flash")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/11,500/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("登记 BYOK:必填校验与提交载荷", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ShenshiPavilion
|
||||
initialPreferences={prefs}
|
||||
initialKeys={[]}
|
||||
initialUsage={null}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
// 空列表时表单默认展开
|
||||
expect(screen.getByLabelText(/Base URL/i)).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: /登记并加密保存/ }));
|
||||
expect(await screen.findByText(/均为必填/)).toBeInTheDocument();
|
||||
|
||||
vi.mocked(meApi.registerByokKey).mockResolvedValue(keys[0]);
|
||||
vi.mocked(meApi.listByokKeys).mockResolvedValue({ keys });
|
||||
await user.type(screen.getByLabelText(/备注名/), "主力 DeepSeek");
|
||||
await user.type(
|
||||
screen.getByLabelText(/Base URL/i),
|
||||
"https://api.deepseek.com",
|
||||
);
|
||||
await user.type(screen.getByLabelText(/API Key/), "sk-secret-value");
|
||||
await user.type(screen.getByLabelText(/上游模型名/), "deepseek-v4-flash");
|
||||
await user.click(screen.getByRole("button", { name: /登记并加密保存/ }));
|
||||
await waitFor(() => {
|
||||
expect(meApi.registerByokKey).toHaveBeenCalledWith({
|
||||
name: "主力 DeepSeek",
|
||||
type: "openai_compatible",
|
||||
base_url: "https://api.deepseek.com",
|
||||
api_key: "sk-secret-value",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("撤回需二次确认", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(meApi.revokeByokKey).mockResolvedValue(undefined);
|
||||
render(
|
||||
<ShenshiPavilion
|
||||
initialPreferences={prefs}
|
||||
initialKeys={keys}
|
||||
initialUsage={null}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
const first = screen.getByRole("button", { name: "撤回" });
|
||||
await user.click(first); // 一次点击 → 进入确认态,不发起请求
|
||||
expect(meApi.revokeByokKey).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /确认撤回/ }));
|
||||
await waitFor(() =>
|
||||
expect(meApi.revokeByokKey).toHaveBeenCalledWith("byok_1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("偏好保存走 settings API", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(settingsApi.updateGenerationPreferences).mockResolvedValue(prefs);
|
||||
render(
|
||||
<ShenshiPavilion
|
||||
initialPreferences={prefs}
|
||||
initialKeys={keys}
|
||||
initialUsage={null}
|
||||
error={null}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /保存偏好/ }));
|
||||
await waitFor(() =>
|
||||
expect(settingsApi.updateGenerationPreferences).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("加载失败呈现错误态", () => {
|
||||
render(
|
||||
<ShenshiPavilion
|
||||
initialPreferences={null}
|
||||
initialKeys={[]}
|
||||
initialUsage={null}
|
||||
error="后端不可达"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/神识阁数据加载失败/)).toBeInTheDocument();
|
||||
expect(screen.getByText("后端不可达")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
ChartColumnIcon,
|
||||
CheckIcon,
|
||||
CircleAlertIcon,
|
||||
GaugeIcon,
|
||||
KeyRoundIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@/components/ui/Icons";
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
|
||||
import { meApi, settingsApi } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { ByokKey, GenerationPreferences, MyUsage } from "@/types/api";
|
||||
|
||||
interface Props {
|
||||
initialPreferences: GenerationPreferences | null;
|
||||
initialKeys: ByokKey[];
|
||||
initialUsage: MyUsage | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
const BYOK_TYPES: { value: string; label: string }[] = [
|
||||
{ value: "openai_compatible", label: "OpenAI 兼容" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 神识阁:模型偏好 / 自带密钥(BYOK)/ 用量 三区。
|
||||
*
|
||||
* 数据源:偏好=灵台 /settings/preferences;BYOK 与用量经灵台代理神识 /me/*。
|
||||
* 密钥明文只在登记表单中出现,响应永远脱敏。
|
||||
*/
|
||||
export function ShenshiPavilion({
|
||||
initialPreferences,
|
||||
initialKeys,
|
||||
initialUsage,
|
||||
error,
|
||||
}: Props) {
|
||||
const [prefs, setPrefs] = useState<GenerationPreferences | null>(
|
||||
initialPreferences,
|
||||
);
|
||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const [keys, setKeys] = useState<ByokKey[]>(initialKeys);
|
||||
const [showForm, setShowForm] = useState(initialKeys.length === 0);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
type: "openai_compatible",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
model: "",
|
||||
});
|
||||
const [byokError, setByokError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const [usage, setUsage] = useState<MyUsage | null>(initialUsage);
|
||||
const [usageAt] = useState(() => new Date());
|
||||
|
||||
async function handleSave() {
|
||||
if (!prefs) return;
|
||||
setSaveState("saving");
|
||||
setMessage(null);
|
||||
try {
|
||||
const updated = await settingsApi.updateGenerationPreferences(prefs);
|
||||
setPrefs(updated);
|
||||
setSaveState("saved");
|
||||
} catch (e) {
|
||||
setSaveState("error");
|
||||
setMessage(e instanceof Error ? e.message : "保存失败,请重试。");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
setByokError(null);
|
||||
if (!form.name || !form.base_url || !form.api_key || !form.model) {
|
||||
setByokError("备注名、Base URL、密钥与模型名均为必填。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await meApi.registerByokKey(form);
|
||||
const { keys: latest } = await meApi.listByokKeys();
|
||||
setKeys(latest);
|
||||
setForm({
|
||||
name: "",
|
||||
type: form.type,
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
model: "",
|
||||
});
|
||||
setShowForm(false);
|
||||
} catch (e) {
|
||||
setByokError(e instanceof Error ? e.message : "登记失败,请重试。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(keyId: string) {
|
||||
if (confirmDelete !== keyId) {
|
||||
setConfirmDelete(keyId);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await meApi.revokeByokKey(keyId);
|
||||
setKeys((prev) => prev.filter((k) => k.id !== keyId));
|
||||
setConfirmDelete(null);
|
||||
} catch (e) {
|
||||
setByokError(e instanceof Error ? e.message : "撤回失败,请重试。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshUsage() {
|
||||
try {
|
||||
setUsage(await meApi.getMyUsage());
|
||||
} catch {
|
||||
/* 保持上次数据 */
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 text-sm text-muted">
|
||||
<div className="mb-2 flex items-center gap-2 text-cinnabar">
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<span>神识阁数据加载失败</span>
|
||||
</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = usage?.rows.reduce(
|
||||
(acc, r) => acc + r.prompt_tokens + r.completion_tokens,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ═══ 生成参数 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<GaugeIcon className="size-4 text-patina" />
|
||||
生成参数
|
||||
</h3>
|
||||
<span className="set-card-eng">Preferences</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
默认逻辑模型与采样参数;实际路由与故障切换由神识决定。
|
||||
</p>
|
||||
{prefs ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="set-row block">
|
||||
<div className="lab">
|
||||
<div className="t">默认逻辑模型</div>
|
||||
<div className="h">model_hint(逻辑模型名或能力槽位)</div>
|
||||
</div>
|
||||
<input
|
||||
className={cn("ctrl", inputCls)}
|
||||
value={prefs.default_model_hint}
|
||||
onChange={(e) =>
|
||||
setPrefs({ ...prefs, default_model_hint: e.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="set-row block">
|
||||
<div className="lab">
|
||||
<div className="t">采样温度</div>
|
||||
<div className="h">temperature(0 – 2)</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
className={cn("ctrl", inputCls)}
|
||||
value={prefs.temperature}
|
||||
onChange={(e) =>
|
||||
setPrefs({ ...prefs, temperature: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="set-row block">
|
||||
<div className="lab">
|
||||
<div className="t">核采样</div>
|
||||
<div className="h">top_p(0 – 1)</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
className={cn("ctrl", inputCls)}
|
||||
value={prefs.top_p}
|
||||
onChange={(e) =>
|
||||
setPrefs({ ...prefs, top_p: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="set-row block">
|
||||
<div className="lab">
|
||||
<div className="t">输出上限</div>
|
||||
<div className="h">max_output_tokens</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200000}
|
||||
className={cn("ctrl", inputCls)}
|
||||
value={prefs.max_output_tokens}
|
||||
onChange={(e) =>
|
||||
setPrefs({
|
||||
...prefs,
|
||||
max_output_tokens: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="set-row sm:col-span-2">
|
||||
<div className="lab">
|
||||
<div className="t">流式输出</div>
|
||||
<div className="h">逐字回流(SSE)</div>
|
||||
</div>
|
||||
<div className="ctrl">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={prefs.streaming}
|
||||
onClick={() =>
|
||||
setPrefs({ ...prefs, streaming: !prefs.streaming })
|
||||
}
|
||||
className={cn("toggle", prefs.streaming && "on")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saveState === "saving"}
|
||||
>
|
||||
{saveState === "saving" ? "保存中" : "保存偏好"}
|
||||
</Button>
|
||||
{saveState === "saved" && (
|
||||
<span className="flex items-center gap-1 text-sm text-patina">
|
||||
<CheckIcon className="size-4" /> 已保存
|
||||
</span>
|
||||
)}
|
||||
{message && (
|
||||
<span className="text-sm text-cinnabar">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="set-card-desc">偏好数据不可用。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 自带密钥 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<KeyRoundIcon className="size-4 text-gold" />
|
||||
自带密钥
|
||||
</h3>
|
||||
<span className="set-card-eng">BYOK</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
登记你的 Provider
|
||||
密钥,请求携带该密钥引用时独占路由(不回落平台端点)。
|
||||
密钥加密存储,仅脱敏信息回显;撤回立即生效。
|
||||
</p>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs text-muted">有效密钥 {keys.length} 把</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
>
|
||||
<PlusIcon className="mr-1 size-4" />
|
||||
{showForm ? "收起" : "登记密钥"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-4 grid gap-3 rounded-xl border border-rule bg-surface p-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted">备注名</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="如:主力 DeepSeek"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted">端点类型</span>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={form.type}
|
||||
onChange={(e) => setForm({ ...form, type: e.target.value })}
|
||||
>
|
||||
{BYOK_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="mb-1 block text-xs text-muted">Base URL</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="https://api.deepseek.com"
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted">
|
||||
API Key(仅本次提交可见)
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
className={inputCls}
|
||||
placeholder="sk-…"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-muted">上游模型名</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
placeholder="deepseek-v4-flash"
|
||||
value={form.model}
|
||||
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleRegister}
|
||||
disabled={busy}
|
||||
>
|
||||
登记并加密保存
|
||||
</Button>
|
||||
{byokError && (
|
||||
<span className="text-sm text-cinnabar">{byokError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 && !showForm ? (
|
||||
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||
尚未登记自带密钥;未登记时全部请求走平台端点。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||
{keys.map((k) => (
|
||||
<li key={k.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-serif text-sm text-fg">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
|
||||
{k.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted">
|
||||
{k.api_key_mask} · {k.model} · {k.base_url}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={confirmDelete === k.id ? "danger" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(k.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
<TrashIcon className="mr-1 size-4" />
|
||||
{confirmDelete === k.id ? "确认撤回" : "撤回"}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 用量 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<ChartColumnIcon className="size-4 text-patina" />
|
||||
用量
|
||||
</h3>
|
||||
<span className="set-card-eng">Usage</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
归属你的模型用量(用户 × 模型 × 端点);与 Provider
|
||||
对账以原始记录为准。
|
||||
</p>
|
||||
|
||||
{usage?.quota && usage.quota.budget > 0 && (
|
||||
<div className="mb-3 rounded-xl border border-rule bg-surface p-3">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted">
|
||||
<span>
|
||||
配额{" "}
|
||||
{(usage.quota.used + usage.quota.reserved).toLocaleString()} /{" "}
|
||||
{usage.quota.budget.toLocaleString()} tokens
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
预扣中 {usage.quota.reserved.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-rule">
|
||||
<div
|
||||
className="h-full rounded-full bg-patina transition-all"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
((usage.quota.used + usage.quota.reserved) /
|
||||
usage.quota.budget) *
|
||||
100,
|
||||
100,
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-mono text-xs text-muted">
|
||||
累计 {totalTokens?.toLocaleString() ?? 0} tokens
|
||||
{usageAt && ` · ${usageAt.toLocaleTimeString("zh-CN")}`}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleRefreshUsage}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!usage || usage.rows.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||
暂无归属用量记录。
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-rule">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-surface text-left text-xs text-muted">
|
||||
<th className="px-3 py-2 font-normal">模型</th>
|
||||
<th className="px-3 py-2 font-normal">端点</th>
|
||||
<th className="px-3 py-2 text-right font-normal">请求</th>
|
||||
<th className="px-3 py-2 text-right font-normal">成功</th>
|
||||
<th className="px-3 py-2 text-right font-normal">
|
||||
输入 tokens
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-normal">
|
||||
输出 tokens
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-rule">
|
||||
{usage.rows.map((r, i) => (
|
||||
<tr
|
||||
key={`${r.model}-${r.endpoint}-${i}`}
|
||||
className="hover:bg-surface-hi"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{r.model || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-muted">
|
||||
{r.endpoint || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{r.requests}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-patina">
|
||||
{r.ok}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">
|
||||
{r.prompt_tokens.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">
|
||||
{r.completion_tokens.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -632,3 +632,214 @@ export function WuxingIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 密钥(Lucide KeyRound) */
|
||||
export function KeyRoundIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="16.5" cy="7.5" r="0.5" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 仪表(Lucide Gauge) */
|
||||
export function GaugeIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="m12 14 4-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M3.34 19a10 10 0 1 1 17.32 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 柱状图(Lucide ChartColumnBig) */
|
||||
export function ChartColumnIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 3v18h18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M18 17V9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 17V5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 17v-3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 加号(Lucide Plus) */
|
||||
export function PlusIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除(Lucide Trash2) */
|
||||
export function TrashIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 6h18"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 11v6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 11v6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 对勾(Lucide Check) */
|
||||
export function CheckIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M20 6 9 17l-5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 警示(Lucide CircleAlert) */
|
||||
export function CircleAlertIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M12 8v4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 16h.01"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * as toolsApi from "./tools";
|
||||
export * as safetyApi from "./safety";
|
||||
export * as adminApi from "./admin";
|
||||
export * as settingsApi from "./settings";
|
||||
export * as meApi from "./me";
|
||||
export * as importsApi from "./imports";
|
||||
export * as extractionApi from "./extraction";
|
||||
export * as worldApi from "./world";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "./client";
|
||||
import { getMyUsage, listByokKeys, registerByokKey, revokeByokKey } from "./me";
|
||||
|
||||
describe("me api(神识阁数据源)", () => {
|
||||
it("listByokKeys GET /me/byok-keys", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue({ keys: [] });
|
||||
await listByokKeys();
|
||||
expect(apiGet).toHaveBeenCalledWith("/me/byok-keys");
|
||||
});
|
||||
|
||||
it("registerByokKey POST 密钥登记", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({ id: "byok_1" });
|
||||
await registerByokKey({
|
||||
name: "主力",
|
||||
type: "openai_compatible",
|
||||
base_url: "https://api.deepseek.com",
|
||||
api_key: "sk-secret",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/me/byok-keys", {
|
||||
name: "主力",
|
||||
type: "openai_compatible",
|
||||
base_url: "https://api.deepseek.com",
|
||||
api_key: "sk-secret",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
});
|
||||
|
||||
it("revokeByokKey DELETE 带 URI 编码", async () => {
|
||||
vi.mocked(apiDelete).mockResolvedValue(undefined);
|
||||
await revokeByokKey("byok/a b");
|
||||
expect(apiDelete).toHaveBeenCalledWith("/me/byok-keys/byok%2Fa%20b");
|
||||
});
|
||||
|
||||
it("getMyUsage GET /me/usage", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue({ rows: [] });
|
||||
await getMyUsage();
|
||||
expect(apiGet).toHaveBeenCalledWith("/me/usage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 用户面 API Client(神识阁数据源:BYOK 密钥与用量)。
|
||||
*
|
||||
* 密钥明文只在登记请求中出现,响应永远脱敏(byok api-key-design §3)。
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "./client";
|
||||
import type { ByokKey, MyUsage, RegisterByokKeyRequest } from "@/types/api";
|
||||
|
||||
/** BYOK 密钥清单(GET /me/byok-keys,脱敏) */
|
||||
export function listByokKeys() {
|
||||
return apiGet<{ keys: ByokKey[] }>("/me/byok-keys");
|
||||
}
|
||||
|
||||
/** 登记自带密钥(POST /me/byok-keys) */
|
||||
export function registerByokKey(body: RegisterByokKeyRequest) {
|
||||
return apiPost<ByokKey>("/me/byok-keys", body);
|
||||
}
|
||||
|
||||
/** 撤回密钥(DELETE /me/byok-keys/{id},立即失效) */
|
||||
export function revokeByokKey(keyId: string) {
|
||||
return apiDelete<void>(`/me/byok-keys/${encodeURIComponent(keyId)}`);
|
||||
}
|
||||
|
||||
/** 我的用量(GET /me/usage:聚合行 + 配额总览) */
|
||||
export function getMyUsage() {
|
||||
return apiGet<MyUsage>("/me/usage");
|
||||
}
|
||||
@@ -82,6 +82,57 @@ export function resolveMock(
|
||||
// —— 健康检查 ——
|
||||
if (m === "GET" && path === "/health") return ok(data.health);
|
||||
|
||||
// —— 神识阁:BYOK 密钥(脱敏)——
|
||||
if (m === "GET" && path === "/me/byok-keys") {
|
||||
return ok({
|
||||
keys: [
|
||||
{
|
||||
id: "byok_mock_001",
|
||||
name: "主力 DeepSeek",
|
||||
type: "openai_compatible",
|
||||
base_url: "https://api.deepseek.com",
|
||||
model: "deepseek-v4-flash",
|
||||
api_key_mask: "sk-****",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (m === "POST" && path === "/me/byok-keys") {
|
||||
const body = (json ?? {}) as Record<string, unknown>;
|
||||
return ok(
|
||||
{
|
||||
id: "byok_mock_002",
|
||||
name: String(body.name ?? ""),
|
||||
type: String(body.type ?? ""),
|
||||
base_url: String(body.base_url ?? ""),
|
||||
model: String(body.model ?? ""),
|
||||
api_key_mask: "sk-****",
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
if (m === "DELETE" && path.startsWith("/me/byok-keys/")) {
|
||||
return ok(null, 204);
|
||||
}
|
||||
|
||||
// —— 神识阁:用量 ——
|
||||
if (m === "GET" && path === "/me/usage") {
|
||||
return ok({
|
||||
rows: [
|
||||
{
|
||||
user_id: "user_mock",
|
||||
model: "deepseek-v4-flash",
|
||||
endpoint: "deepseek",
|
||||
requests: 12,
|
||||
ok: 11,
|
||||
prompt_tokens: 8400,
|
||||
completion_tokens: 3100,
|
||||
},
|
||||
],
|
||||
quota: { budget: 1_000_000, used: 11_500, reserved: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
// —— 设置:生成参数偏好(GET / PATCH /settings/preferences)——
|
||||
if (m === "GET" && path === "/settings/preferences") {
|
||||
return ok(data.generationPreferences);
|
||||
|
||||
@@ -655,6 +655,51 @@ export interface GenerationPreferences {
|
||||
/** 更新生成偏好(PATCH /settings/preferences) */
|
||||
export type UpdateGenerationPreferencesRequest = Partial<GenerationPreferences>;
|
||||
|
||||
/** BYOK 密钥脱敏形态(GET/POST /me/byok-keys;API 永不回明文) */
|
||||
export interface ByokKey {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "openai_compatible" | "anthropic" | "gemini" | string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_mask: string;
|
||||
created_at?: string;
|
||||
last_used?: string | null;
|
||||
}
|
||||
|
||||
/** 登记用户自带密钥(POST /me/byok-keys;明文仅本次请求可见,落库即加密) */
|
||||
export interface RegisterByokKeyRequest {
|
||||
name: string;
|
||||
type: string;
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** 用量聚合行(用户×模型×端点,GET /me/usage) */
|
||||
export interface UsageAggRow {
|
||||
user_id?: string;
|
||||
model: string;
|
||||
endpoint: string;
|
||||
requests: number;
|
||||
ok: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
}
|
||||
|
||||
/** 配额总览(用户级软配额;budget=0 表示不限制) */
|
||||
export interface UsageQuota {
|
||||
budget: number;
|
||||
used: number;
|
||||
reserved: number;
|
||||
}
|
||||
|
||||
/** 用量查询响应 */
|
||||
export interface MyUsage {
|
||||
rows: UsageAggRow[];
|
||||
quota?: UsageQuota | null;
|
||||
}
|
||||
|
||||
/** 工具权限策略(契约字典 §5.6) */
|
||||
export type PermissionPolicy = "allow" | "ask" | "always_ask" | "forbidden";
|
||||
|
||||
|
||||
Reference in new issue
Block a user