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:
chenxin committed 2026-08-27 18:39:17 +08:00
1 parent 462d570751
commit 144415247e
51 files changed
+3533 -90

No files matched your search

+45
View File
@@ -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>
);
}
+211
View File
@@ -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>
);
}
+1
View File
@@ -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";
+48
View File
@@ -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");
});
});
+28
View File
@@ -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");
}
+51
View File
@@ -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);
+45
View File
@@ -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";