M4b:ai_provider 删除手术①-⑥——神识全面接管模型出口

- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件)
- ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好
- ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied)
- ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知
- ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_*
- ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
This commit is contained in:
chenxin committed 2026-08-27 10:02:44 +08:00
1 parent 0d383725b5
commit 462d570751
129 files changed
+81 -15867

No files matched your search

+1 -14
View File
@@ -1,30 +1,17 @@
import type { NextConfig } from "next";
/** 玄关网关地址(Go MCP 网关,默认 8080)。 */
const GATEWAY_URL =
process.env.GATEWAY_URL ??
process.env.NEXT_PUBLIC_GATEWAY_URL ??
"http://localhost:8080";
module.exports = {
allowedDevOrigins: ["192.168.1.5"],
};
const nextConfig: NextConfig = {
/**
* 仅 /api/gateway/* 代理到 Go 玄关网关(MCP)。
*
* 业务 REST / SSE / 认证请求统一经 BFF 路由(/api/proxy、/api/sse、/api/auth),
* 在服务端从 httpOnly cookie 读取 token 并注入 Authorization,不再经 rewrites。
* 后端绝对地址由 BFF route 通过 lib/config 的 API_BASE_URL 解析。
*/
async rewrites() {
return [
{
source: "/api/gateway/:path*",
destination: `${GATEWAY_URL}/:path*`,
},
];
return [];
},
};
-52
View File
@@ -1,52 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ModelGatewayView } from "@/components/models/ModelGatewayView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences, ModelGatewayStatus } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 模型网关页(神识):模型与 endpoint 配置。
* base_url/api_key/status/weight/priority 可经 DB 覆盖(优先 .env),密钥脱敏不回显。
* 设置页不再含模型网关——生成参数留 /settings,默认模型槽位移至此。
*/
export default async function ModelsPage() {
let gateway: ModelGatewayStatus | null = null;
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
[gateway, preferences] = await Promise.all([
serverApi.get<ModelGatewayStatus>("/settings/models"),
serverApi.get<GenerationPreferences>("/settings/preferences"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="模型网关"
description="神识 · 模型与 endpoint 配置。base_url/api_key 可经 DB 覆盖(优先 .env),密钥脱敏不回显。"
>
{error ? (
<ErrorState message={error} />
) : gateway && preferences ? (
<ModelGatewayView gateway={gateway} preferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-4" />
<TextSkeleton lines={1} className="w-1/2" />
</div>
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={3} />
</div>
</div>
)}
</PageContainer>
);
}
+4 -31
View File
@@ -1,43 +1,16 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { SettingsView } from "@/components/settings/SettingsView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 设置页:外观偏好(主题)+ 生成参数。模型网关配置(模型/endpoint/默认槽位)在 /models。
* 设置页:外观偏好(主题/语言/通知)。生成参数与模型偏好在神识阁(/models)。
*/
export default async function SettingsPage() {
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
preferences = await serverApi.get("/settings/preferences");
} catch (e) {
error = describeError(e);
}
export default function SettingsPage() {
return (
<PageContainer
title="设置"
description="外观偏好与生成参数;模型网关配置在 /models。"
description="外观偏好与通知;生成参数与模型偏好在神识阁。"
>
{error ? (
<ErrorState message={error} />
) : preferences ? (
<SettingsView initialPreferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-6" />
<TextSkeleton lines={4} />
</div>
</div>
)}
<SettingsView />
</PageContainer>
);
}
@@ -13,7 +13,6 @@ import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
@@ -210,18 +209,6 @@ async function ToolRuntimeDetail() {
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
+2 -16
View File
@@ -2,7 +2,6 @@ import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
@@ -15,11 +14,10 @@ export const dynamic = "force-dynamic";
/**
* 百工枢 · 器用技艺治理(对应设计稿 admin-preview.html 百工枢 视图)。
*
* 四标签页:
* 三标签页:
* ① 工具注册表 — 清单与管理
* ② MCP 服务器 — 外部能力接入
* ③ 玄关网关 — 路由与代理
* ④ Skill 能力包 — 可插拔能力
* ③ Skill 能力包 — 可插拔能力
*/
export default async function AdminToolsPage() {
let tools: ToolInfo[] = [];
@@ -82,18 +80,6 @@ export default async function AdminToolsPage() {
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
@@ -1,743 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { gatewayApi } from "@/lib/api/gateway";
import { cn } from "@/lib/utils/cn";
import type {
AddGatewayServiceRequest,
GatewayHealth,
GatewayService,
GatewayTool,
} from "@/lib/api/gateway";
const RISK_DOT: Record<string, string> = {
L0: "bg-patina",
L1: "bg-patina",
L2: "bg-gold",
L3: "bg-cinnabar",
L4: "bg-cinnabar",
};
/** 玄关网关管理面板 */
export function GatewayPanel() {
const [health, setHealth] = useState<GatewayHealth | null>(null);
const [services, setServices] = useState<GatewayService[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [expandedService, setExpandedService] = useState<string | null>(null);
const [editingService, setEditingService] = useState<GatewayService | null>(
null,
);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
async function fetchAll() {
setError(null);
try {
const [h, svcs] = await Promise.all([
gatewayApi.getHealth(),
gatewayApi.listServices(),
]);
setHealth(h);
setServices(svcs);
} catch (e) {
setError(e instanceof Error ? e.message : "网关连接失败");
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAll();
const id = setInterval(() => {
fetchAll();
}, 15_000);
return () => clearInterval(id);
}, []);
async function handleAdd(body: AddGatewayServiceRequest) {
setBusy(true);
setError(null);
try {
await gatewayApi.addService(body);
setShowAdd(false);
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "注册失败");
} finally {
setBusy(false);
}
}
async function handleToggleEnabled(svc: GatewayService) {
setBusy(true);
setError(null);
try {
await gatewayApi.updateService(svc.name, { disabled: !svc.disabled });
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败");
} finally {
setBusy(false);
}
}
async function handleDelete(name: string) {
setBusy(true);
setError(null);
try {
await gatewayApi.deleteService(name);
setDeleteConfirm(null);
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-cinnabar/10 px-4 py-2.5 text-sm text-cinnabar">
{error}
<button type="button" onClick={fetchAll} className="ml-3 underline">
重试
</button>
</div>
)}
{/* 网关状态 */}
{health && (
<div className="glass grid grid-cols-2 gap-3 rounded-2xl px-4 py-3 lg:grid-cols-4">
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
状态
</span>
<p className="mt-0.5 font-mono text-sm text-patina">运行中</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
版本
</span>
<p className="mt-0.5 font-mono text-sm text-fg">{health.version}</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
服务数
</span>
<p className="mt-0.5 font-mono text-sm text-fg">
{health.services}
</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
工具数
</span>
<p className="mt-0.5 font-mono text-sm text-fg">
{health.tool_count}
</p>
</div>
</div>
)}
{/* 操作栏 */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted">{services.length} 个注册服务</p>
{!showAdd && !editingService && (
<button
type="button"
onClick={() => setShowAdd(true)}
className="rounded-xl border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 注册服务
</button>
)}
</div>
{/* 注册/编辑表单 */}
{showAdd && (
<AddServiceForm
busy={busy}
onSubmit={handleAdd}
onCancel={() => setShowAdd(false)}
/>
)}
{editingService && (
<EditServiceForm
service={editingService}
busy={busy}
onSubmit={async (body) => {
await gatewayApi.updateService(editingService.name, body);
setEditingService(null);
await fetchAll();
}}
onCancel={() => setEditingService(null)}
/>
)}
{/* 删除确认 */}
{deleteConfirm && (
<div className="glass rounded-2xl border-cinnabar/20 px-4 py-3">
<p className="mb-2 text-sm text-fg">
确认删除服务{" "}
<span className="font-mono font-medium">{deleteConfirm}</span>?
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleDelete(deleteConfirm)}
disabled={busy}
className="rounded-lg bg-cinnabar/20 px-3 py-1.5 text-xs font-medium text-cinnabar disabled:opacity-50"
>
确认删除
</button>
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
)}
{/* 服务列表 */}
{services.length === 0 && !error ? (
<div className="glass flex items-center justify-center rounded-2xl py-12">
<p className="text-sm text-muted">
暂无已注册的服务。点击「+ 注册服务」添加。
</p>
</div>
) : (
<div className="space-y-2">
{services.map((svc) => {
const expanded = expandedService === svc.name;
const isEnabled = !svc.disabled;
return (
<div
key={svc.name}
className={cn(
"glass rounded-2xl transition-all duration-200",
!isEnabled && "opacity-60",
)}
>
{/* 折叠态 */}
<div className="flex items-center gap-3 px-4 py-3">
{/* 状态灯 */}
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
isEnabled
? "bg-patina shadow-[0_0_6px] shadow-patina/40"
: "bg-muted/30",
)}
/>
<span className="font-serif text-sm font-semibold text-fg">
{svc.name}
</span>
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-medium",
isEnabled
? "bg-patina/10 text-patina"
: "bg-cinnabar/10 text-cinnabar",
)}
>
{isEnabled ? "已启用" : "已禁用"}
</span>
<span className="rounded-md bg-gold/10 px-1.5 py-0.5 font-mono text-[10px] text-gold">
{svc.tools.length} 工具
</span>
<span className="truncate text-[11px] text-muted">
{svc.description}
</span>
{/* 操作按钮 */}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleToggleEnabled(svc)}
disabled={busy}
className={cn(
"rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40",
isEnabled
? "border-cinnabar/20 text-cinnabar hover:bg-cinnabar/10"
: "border-patina/30 text-patina hover:bg-patina/10",
)}
>
{isEnabled ? "禁用" : "启用"}
</button>
<button
type="button"
onClick={() =>
setExpandedService(expanded ? null : svc.name)
}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
{expanded ? "收起" : "详情"}
</button>
<button
type="button"
onClick={() => setEditingService(svc)}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
编辑
</button>
<button
type="button"
onClick={() => setDeleteConfirm(svc.name)}
disabled={busy}
className="shrink-0 rounded-lg px-2 py-1 text-[11px] text-cinnabar transition-colors hover:bg-cinnabar/10 disabled:opacity-40"
>
✕ 删除
</button>
</div>
</div>
{/* 展开态 */}
{expanded && (
<div className="border-t border-border-glass px-4 pb-4 pt-3">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<span className="text-muted">endpoint</span>
<span className="font-mono text-fg break-all">
{svc.endpoint}
</span>
<span className="text-muted">timeout</span>
<span className="font-mono text-fg">{svc.timeout}</span>
<span className="text-muted">状态</span>
<span
className={cn(
"font-mono",
isEnabled ? "text-patina" : "text-cinnabar",
)}
>
{isEnabled ? "已启用" : "已禁用"}
</span>
</div>
{/* 工具清单 */}
<div className="mt-3">
<p className="mb-1 text-[10px] text-muted uppercase tracking-wider">
工具清单({svc.tools.length})
</p>
<div className="flex flex-wrap gap-1.5">
{svc.tools.map((t) => (
<span
key={t.name}
className="inline-flex items-center gap-1.5 rounded-md border border-border-glass bg-glass/50 px-2 py-0.5 font-mono text-[10px] text-fg/80"
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
RISK_DOT[t.risk_level] ?? "bg-muted",
)}
/>
{t.name}
<span className="text-[9px] text-muted/60">
{t.visibility === "public" ? "" : t.visibility}
</span>
</span>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ─── 注册表单 ─── */
function AddServiceForm({
busy,
onSubmit,
onCancel,
}: {
busy: boolean;
onSubmit: (body: AddGatewayServiceRequest) => void | Promise<void>;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [endpoint, setEndpoint] = useState("");
const [detectedTools, setDetectedTools] = useState<GatewayTool[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [detecting, setDetecting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
async function handleDetect() {
if (!endpoint.trim()) {
setLocalError("请先填写 endpoint");
return;
}
setDetecting(true);
setLocalError(null);
try {
const result = await gatewayApi.detectTools(endpoint.trim());
setDetectedTools(result.tools);
// 默认全选
setSelectedTools(new Set(result.tools.map((t) => t.name)));
} catch (e) {
setLocalError(e instanceof Error ? e.message : "检测失败");
} finally {
setDetecting(false);
}
}
function toggleTool(name: string) {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function toggleAll() {
if (selectedTools.size === detectedTools.length) {
setSelectedTools(new Set());
} else {
setSelectedTools(new Set(detectedTools.map((t) => t.name)));
}
}
async function handleSubmit() {
setLocalError(null);
if (!name.trim() || !endpoint.trim()) {
setLocalError("名称与 endpoint 必填");
return;
}
const selected = detectedTools
.filter((t) => selectedTools.has(t.name))
.map((t) => ({
name: t.name,
description: t.description,
risk_level: t.risk_level || "L2",
}));
await onSubmit({
name: name.trim(),
description: desc.trim(),
endpoint: endpoint.trim(),
tools: selected,
});
}
return (
<div className="glass rounded-2xl px-4 py-4">
<p className="mb-3 font-serif text-sm font-semibold text-fg">
注册新服务
</p>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-muted">
名称 <span className="text-cinnabar">*</span>
</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如 web-search"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
endpoint <span className="text-cinnabar">*</span>
</span>
<div className="flex gap-2">
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:8081"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
<button
type="button"
onClick={handleDetect}
disabled={detecting}
className="shrink-0 rounded-lg border border-patina/30 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20 disabled:opacity-50"
>
{detecting ? "检测中…" : "检测工具"}
</button>
</div>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="服务功能描述…"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
{/* 检测到的工具列表 */}
{detectedTools.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-muted">
工具({selectedTools.size}/{detectedTools.length})
</span>
<button
type="button"
onClick={toggleAll}
className="text-[11px] text-patina hover:underline"
>
{selectedTools.size === detectedTools.length
? "取消全选"
: "全选"}
</button>
</div>
<div className="space-y-1">
{detectedTools.map((t) => (
<label
key={t.name}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border-glass bg-glass px-3 py-1.5 transition-colors hover:bg-glass-strong"
>
<input
type="checkbox"
checked={selectedTools.has(t.name)}
onChange={() => toggleTool(t.name)}
className="accent-[var(--patina)]"
/>
<span className="font-mono text-xs text-fg">{t.name}</span>
<span className="truncate text-[11px] text-muted">
{t.description}
</span>
</label>
))}
</div>
</div>
)}
{detectedTools.length === 0 && !detecting && (
<p className="text-xs text-muted">
点击「检测工具」自动拉取 endpoint 上的 MCP 工具清单。
</p>
)}
{localError && <p className="text-xs text-cinnabar">{localError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{busy ? "注册中…" : "注册"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
);
}
/* ─── 编辑服务表单 ─── */
function EditServiceForm({
service,
busy,
onSubmit,
onCancel,
}: {
service: GatewayService;
busy: boolean;
onSubmit: (body: {
description?: string;
endpoint?: string;
tools?: {
name: string;
description: string;
risk_level: string;
visibility?: string;
}[];
}) => void | Promise<void>;
onCancel: () => void;
}) {
const [desc, setDesc] = useState(service.description);
const [endpoint, setEndpoint] = useState(service.endpoint);
const [tools, setTools] = useState<GatewayTool[]>(service.tools);
const [selectedTools, setSelectedTools] = useState<Set<string>>(
new Set(service.tools.map((t) => t.name)),
);
const [detecting, setDetecting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
function toggleTool(name: string) {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function toggleAll() {
if (selectedTools.size === tools.length) {
setSelectedTools(new Set());
} else {
setSelectedTools(new Set(tools.map((t) => t.name)));
}
}
async function handleDetect() {
if (!endpoint.trim()) return;
setDetecting(true);
try {
const result = await gatewayApi.detectTools(endpoint.trim());
if (result.tools.length > 0) {
setTools(result.tools);
setSelectedTools(new Set(result.tools.map((t) => t.name)));
}
} catch {
// ignore
} finally {
setDetecting(false);
}
}
async function handleSubmit() {
setLocalError(null);
if (!endpoint.trim()) {
setLocalError("endpoint 不能为空");
return;
}
const selected = tools
.filter((t) => selectedTools.has(t.name))
.map((t) => ({
name: t.name,
description: t.description,
risk_level: t.risk_level || "L2",
visibility: t.visibility,
}));
await onSubmit({
description: desc.trim() || undefined,
endpoint: endpoint.trim(),
tools: selected,
});
}
return (
<div className="glass rounded-2xl px-4 py-4">
<p className="mb-3 font-serif text-sm font-semibold text-fg">
编辑服务 — {service.name}
</p>
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
名称(不可修改)
</span>
<p className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-muted">
{service.name}
</p>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="服务功能描述…"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">endpoint</span>
<div className="flex gap-2">
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:8081"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
<button
type="button"
onClick={handleDetect}
disabled={detecting}
className="shrink-0 rounded-lg border border-patina/30 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{detecting ? "检测中…" : "重新检测"}
</button>
</div>
</label>
{/* 工具管理 */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-muted">
工具({selectedTools.size}/{tools.length})
</span>
<button
type="button"
onClick={toggleAll}
className="text-[11px] text-patina hover:underline"
>
{selectedTools.size === tools.length ? "取消全选" : "全选"}
</button>
</div>
<div className="space-y-1">
{tools.map((t) => (
<label
key={t.name}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border-glass bg-glass px-3 py-1.5 transition-colors hover:bg-glass-strong"
>
<input
type="checkbox"
checked={selectedTools.has(t.name)}
onChange={() => toggleTool(t.name)}
className="accent-[var(--patina)]"
/>
<span className="font-mono text-xs text-fg">{t.name}</span>
<span className="text-[10px] text-muted">
{t.visibility === "public" ? "" : t.visibility}
</span>
</label>
))}
</div>
{tools.length === 0 && (
<p className="text-xs text-muted">
该服务暂无工具。点击「重新检测」从 endpoint 拉取工具清单。
</p>
)}
</div>
{localError && <p className="text-xs text-cinnabar">{localError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{busy ? "保存中…" : "保存"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
);
}
@@ -12,7 +12,6 @@ import {
CharacterIcon,
CosmosIcon,
DriveIcon,
ModelIcon,
PersonalityIcon,
RunIcon,
SettingsIcon,
@@ -94,7 +93,6 @@ const NAV_GROUPS: NavGroup[] = [
title: "百工",
items: [
{ href: "/tools", label: "工具", icon: <ToolsIcon /> },
{ href: "/models", label: "模型网关", icon: <ModelIcon /> },
{ href: "/settings", label: "设置", icon: <SettingsIcon /> },
],
},
@@ -1,347 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { settingsApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import type {
GenerationPreferences,
LogicalModel,
ModelEndpointStatus,
ModelGatewayStatus,
UpdateEndpointOverrideRequest,
} from "@/types/api";
import type { EndpointStatus } from "@/types/domain";
const STATUS_OPTIONS: EndpointStatus[] = [
"active",
"inactive",
"unhealthy",
"cooldown",
];
/**
* 模型网关视图(神识):
* - 供应商配置状态(.env 是否已配 key,脱敏)
* - 默认对话模型槽位(select,updateGenerationPreferences)
* - 逻辑模型 endpoint 池:status/weight/priority/base_url/api_key 可 DB 覆盖(优先 .env)
* base_url/api_key 原值不回显(脱敏);api_key 用 password 输入。留空=不改/回退 env。
*/
export function ModelGatewayView({
gateway,
preferences,
}: {
gateway: ModelGatewayStatus;
preferences: GenerationPreferences;
}) {
const router = useRouter();
const [defaultModel, setDefaultModel] = useState(
preferences.default_model_hint,
);
const [savingDefault, setSavingDefault] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveDefault() {
if (defaultModel === preferences.default_model_hint) return;
setSavingDefault(true);
setError(null);
try {
await settingsApi.updateGenerationPreferences({
default_model_hint: defaultModel,
});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setSavingDefault(false);
}
}
return (
<div className="space-y-5">
<Card className="p-5">
<CardHeader
title="供应商配置"
subtitle="API Key 是否已在 .env 配置(脱敏,不回值)"
/>
<div className="flex flex-wrap gap-2">
{gateway.providers.map((p) => (
<span
key={p.provider}
className={cn(
"rounded-lg border px-2.5 py-1 text-xs",
p.configured
? "border-patina/40 bg-patina/10 text-patina"
: "border-border-glass bg-glass text-muted",
)}
>
{p.provider} {p.configured ? "✓" : "✗"}
</span>
))}
</div>
</Card>
<Card className="p-5">
<CardHeader
title="默认对话模型"
subtitle="对话默认使用的逻辑模型槽位"
/>
<div className="flex items-center gap-2">
<select
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
>
{gateway.models.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name} ({m.provider})
</option>
))}
</select>
<Button
variant="primary"
size="sm"
loading={savingDefault}
disabled={defaultModel === preferences.default_model_hint}
onClick={saveDefault}
>
保存
</Button>
</div>
</Card>
{gateway.models.map((model) => (
<ModelCard key={model.name} model={model} />
))}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
function ModelCard({ model }: { model: LogicalModel }) {
return (
<Card className="p-5">
<CardHeader
title={model.display_name}
subtitle={`${model.provider} · ${model.routing_strategy} · 上下文 ${model.context_window}`}
/>
<div className="mb-3 flex flex-wrap gap-1.5">
{model.capabilities.map((c) => (
<span
key={c}
className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold"
>
{c}
</span>
))}
</div>
{model.endpoints && model.endpoints.length > 0 ? (
<ul className="space-y-2">
{model.endpoints.map((ep) => (
<li key={`${model.name}/${ep.name}`}>
<EndpointEditor modelName={model.name} endpoint={ep} />
</li>
))}
</ul>
) : (
<p className="text-xs text-muted">无 endpoint</p>
)}
</Card>
);
}
function EndpointEditor({
modelName,
endpoint,
}: {
modelName: string;
endpoint: ModelEndpointStatus;
}) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<UpdateEndpointOverrideRequest>({});
function startEdit() {
setDraft({
status: endpoint.status,
weight: endpoint.weight,
priority: endpoint.priority,
base_url: "",
api_key: "",
});
setError(null);
setEditing(true);
}
async function save() {
setBusy(true);
setError(null);
try {
const body: UpdateEndpointOverrideRequest = {
status: draft.status,
weight: draft.weight,
priority: draft.priority,
};
if (draft.base_url) body.base_url = draft.base_url;
if (draft.api_key) body.api_key = draft.api_key;
await settingsApi.updateModelEndpoint(modelName, endpoint.name, body);
setEditing(false);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"mt-0.5 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<div className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm text-fg">{endpoint.name}</span>
<span className="rounded bg-fg/10 px-1.5 py-0.5 text-[10px] text-muted">
{endpoint.provider}
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px]",
endpoint.status === "active"
? "bg-patina/15 text-patina"
: "bg-cinnabar/15 text-cinnabar",
)}
>
{endpoint.status}
</span>
{endpoint.priority !== undefined && (
<span className="text-[10px] text-muted">p={endpoint.priority}</span>
)}
{endpoint.weight !== undefined && (
<span className="text-[10px] text-muted">w={endpoint.weight}</span>
)}
{endpoint.is_overridden && (
<span className="rounded bg-cinnabar/15 px-1.5 py-0.5 text-[10px] text-cinnabar">
已覆盖
</span>
)}
{endpoint.has_url_override && (
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
url(DB)
</span>
)}
{endpoint.has_key_override && (
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
key(DB)
</span>
)}
<button
type="button"
onClick={() => (editing ? setEditing(false) : startEdit())}
className="ml-auto text-[11px] text-patina hover:underline"
>
{editing ? "取消" : "编辑覆盖"}
</button>
</div>
{(endpoint.base_url_env || endpoint.api_key_env) && (
<p className="mt-1 pl-1 font-mono text-[10px] text-muted">
{endpoint.base_url_env ? `env: ${endpoint.base_url_env}` : ""}
{endpoint.api_key_env ? ` / ${endpoint.api_key_env}` : ""}
</p>
)}
{editing && (
<div className="mt-2 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label className="text-[10px] text-muted">
状态
<select
value={draft.status ?? "active"}
onChange={(e) =>
setDraft({
...draft,
status: e.target.value as EndpointStatus,
})
}
className={inputCls}
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="text-[10px] text-muted">
优先级
<input
type="number"
value={draft.priority ?? ""}
onChange={(e) =>
setDraft({
...draft,
priority: e.target.value
? Number(e.target.value)
: undefined,
})
}
className={inputCls}
/>
</label>
<label className="text-[10px] text-muted">
权重
<input
type="number"
value={draft.weight ?? ""}
onChange={(e) =>
setDraft({
...draft,
weight: e.target.value ? Number(e.target.value) : undefined,
})
}
className={inputCls}
/>
</label>
</div>
<label className="block text-[10px] text-muted">
base_url 覆盖(留空不改 / 回退 env)
<input
value={draft.base_url ?? ""}
onChange={(e) => setDraft({ ...draft, base_url: e.target.value })}
placeholder={
endpoint.has_url_override
? "已覆盖(留空不改)"
: "留空使用 env"
}
className={inputCls}
/>
</label>
<label className="block text-[10px] text-muted">
api_key 覆盖(password,留空不改 / 回退 env;不回显原值)
<input
type="password"
value={draft.api_key ?? ""}
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
placeholder={
endpoint.has_key_override
? "已覆盖(留空不改)"
: "留空使用 env"
}
className={inputCls}
/>
</label>
<Button variant="primary" size="sm" loading={busy} onClick={save}>
保存覆盖
</Button>
</div>
)}
{error && <p className="mt-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,164 +0,0 @@
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { Card, CardHeader } from "@/components/ui/Card";
import {
ENDPOINT_STATUS_LABEL,
MODEL_CAPABILITY_LABEL,
MODEL_PROVIDER_LABEL,
ROUTING_STRATEGY_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
import type { ModelGatewayStatus } from "@/types/api";
import type { EndpointStatus } from "@/types/domain";
const ENDPOINT_TONE: Record<EndpointStatus, BadgeTone> = {
active: "success",
inactive: "neutral",
unhealthy: "danger",
cooldown: "warning",
};
/**
* 模型网关只读面板(GET /settings/models)。
* 展示逻辑模型清单与供应商密钥脱敏状态;真实 model 名、endpoint URL、
* API Key 不暴露给前端(environment-config-design §3)。
*/
export function ModelGatewayPanel({
gateway,
}: {
gateway: ModelGatewayStatus;
}) {
return (
<Card className="p-4">
<CardHeader
title="模型网关"
subtitle="逻辑模型清单与供应商密钥脱敏状态(只读;密钥在后端 .env 配置)"
/>
<div className="flex flex-col gap-3">
{gateway.models.map((model) => {
const configured = gateway.providers.find(
(p) => p.provider === model.provider,
)?.configured;
return (
<div
key={model.name}
className="rounded-xl border border-border-glass bg-glass/60 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium text-fg">
{model.name}
</span>
{model.is_default && <Badge tone="accent">默认</Badge>}
<span className="text-xs text-muted">{model.display_name}</span>
<span className="ml-auto text-xs text-muted">
{MODEL_PROVIDER_LABEL[model.provider]}
{configured === false && (
<span className="text-cinnabar"> · 未配置密钥</span>
)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{model.capabilities.map((cap) => (
<Badge key={cap} tone="accent">
{MODEL_CAPABILITY_LABEL[cap]}
</Badge>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs sm:grid-cols-3">
<Spec
label="路由"
value={ROUTING_STRATEGY_LABEL[model.routing_strategy]}
/>
<Spec
label="上下文"
value={`${(model.context_window / 1000).toFixed(0)}K`}
/>
<Spec
label="最大输出"
value={
model.max_output_tokens
? model.max_output_tokens.toLocaleString()
: "—"
}
/>
<Spec
label="流式"
value={model.supports_streaming ? "支持" : "否"}
/>
<Spec
label="思考"
value={model.supports_thinking ? "支持" : "否"}
/>
<Spec
label="结构化"
value={model.supports_structured_output ? "支持" : "否"}
/>
</div>
{model.endpoints && model.endpoints.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted">
端点池
</span>
{model.endpoints.map((ep) => (
<span
key={ep.name}
className="inline-flex items-center gap-1 text-[11px] text-muted"
>
<span className="font-mono">{ep.name}</span>
<Badge tone={ENDPOINT_TONE[ep.status]}>
{ENDPOINT_STATUS_LABEL[ep.status]}
</Badge>
</span>
))}
</div>
)}
</div>
);
})}
</div>
<div className="mt-4 border-t border-border-glass pt-3">
<p className="mb-2 text-[10px] uppercase tracking-wider text-muted">
供应商密钥状态
</p>
<div className="flex flex-wrap gap-2">
{gateway.providers.map((p) => (
<span
key={p.provider}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs",
p.configured
? "border-patina/25 bg-patina/10 text-patina"
: "border-cinnabar/25 bg-cinnabar/10 text-cinnabar",
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
p.configured ? "bg-patina" : "bg-cinnabar",
)}
/>
{MODEL_PROVIDER_LABEL[p.provider]}
{p.configured ? "已配置" : "未配置"}
</span>
))}
</div>
<p className="mt-2 text-xs text-muted">
密钥状态仅反映后端 .env 是否就绪;真实值不进入前端。
</p>
</div>
</Card>
);
}
function Spec({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center gap-1">
<span className="text-muted">{label}</span>
<span className="text-fg">{value}</span>
</div>
);
}
@@ -1,144 +0,0 @@
"use client";
import { cn } from "@/lib/utils/cn";
import type { GenerationPreferences } from "@/types/api";
export type SaveStatus = "idle" | "saved" | "error";
interface Props {
preferences: GenerationPreferences;
onChange: (next: GenerationPreferences) => void;
onSave: () => void;
saving: boolean;
dirty: boolean;
status: SaveStatus;
message: string | null;
}
const INPUT_CLASS =
"w-full rounded-xl border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none focus:border-patina/40";
/**
* 生成参数偏好表单(PATCH /settings/preferences)。
* 温度 / 采样 / 输出上限 / 流式为业务层偏好。默认对话模型槽位在 /models 模型网关页管理。
*/
export function ModelStrategyForm({
preferences,
onChange,
onSave,
saving,
dirty,
status,
message,
}: Props) {
const update = <K extends keyof GenerationPreferences>(
key: K,
value: GenerationPreferences[K],
) => onChange({ ...preferences, [key]: value });
return (
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title">生成参数</h3>
<span className="set-card-eng">Generation</span>
</div>
<p className="set-card-desc">
采样温度、核采样、输出上限、流式开关。不含密钥,模型网关配置在 /models。
</p>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 flex items-center justify-between text-xs text-muted">
<span>温度(temperature)</span>
<span className="font-mono text-fg">
{preferences.temperature.toFixed(2)}
</span>
</span>
<input
type="range"
min={0}
max={2}
step={0.05}
value={preferences.temperature}
onChange={(e) => update("temperature", Number(e.target.value))}
className="w-full accent-[var(--patina)]"
/>
</label>
<label className="block">
<span className="mb-1 flex items-center justify-between text-xs text-muted">
<span>采样(top_p)</span>
<span className="font-mono text-fg">
{preferences.top_p.toFixed(2)}
</span>
</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={preferences.top_p}
onChange={(e) => update("top_p", Number(e.target.value))}
className="w-full accent-[var(--patina)]"
/>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs text-muted">最大输出 token</span>
<input
type="number"
min={1}
value={preferences.max_output_tokens}
onChange={(e) =>
update(
"max_output_tokens",
Math.max(1, Number(e.target.value) || 1),
)
}
className={INPUT_CLASS}
/>
</label>
<div className="set-row">
<div className="lab">
<div className="t">流式输出</div>
<div className="h">实时流式生成过程中逐 token 展示</div>
</div>
<div className="ctrl">
<button
type="button"
role="switch"
aria-checked={preferences.streaming}
aria-label="流式输出"
onClick={() => update("streaming", !preferences.streaming)}
className={cn("toggle", preferences.streaming && "on")}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-3 border-t border-rule pt-3">
<button
type="button"
onClick={onSave}
disabled={!dirty || saving}
className="rounded border border-patina-dim bg-transparent px-4 py-1.5 text-sm tracking-[0.08em] text-patina transition-colors hover:border-patina hover:text-patina-bright disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint font-serif"
>
{saving ? "保存中…" : "保存偏好"}
</button>
{dirty && <span className="text-xs text-muted">有未保存的改动</span>}
{status === "saved" && message && (
<span className="save-hint">
<span className="dot" />
{message}
</span>
)}
{status === "error" && message && (
<span className="text-xs text-cinnabar">{message}</span>
)}
</div>
</div>
);
}
@@ -2,20 +2,10 @@
import { useState } from "react";
import {
ModelStrategyForm,
type SaveStatus,
} from "@/components/settings/ModelStrategyForm";
import { settingsApi } from "@/lib/api";
import { useSettingsStore } from "@/lib/stores/settings-store";
import { cn } from "@/lib/utils/cn";
import type { GenerationPreferences } from "@/types/api";
import type { ThemeMode } from "@/types/domain";
interface Props {
initialPreferences: GenerationPreferences;
}
const THEME_OPTIONS: {
value: ThemeMode;
label: string;
@@ -50,46 +40,15 @@ const THEME_OPTIONS: {
* set-card-desc: 描述文字
* set-row: 逐行设置项(标签 + 控制器)
*
* 分组:外观 / 语言 / 通知 / 生成参数
* 分组:外观 / 语言 / 通知(生成参数偏好归神识阁)
*/
export function SettingsView({ initialPreferences }: Props) {
const [preferences, setPreferences] = useState(initialPreferences);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<SaveStatus>("idle");
const [message, setMessage] = useState<string | null>(null);
export function SettingsView() {
const [lang, setLang] = useState("zh-CN");
const [notifications, setNotifications] = useState(true);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
function handleChange(next: GenerationPreferences) {
setPreferences(next);
setDirty(true);
setStatus("idle");
setMessage(null);
}
async function handleSave() {
setSaving(true);
setStatus("idle");
setMessage(null);
try {
const updated =
await settingsApi.updateGenerationPreferences(preferences);
setPreferences(updated);
setDirty(false);
setStatus("saved");
setMessage("已保存");
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-4">
{/* ═══ 外观 ═══ */}
@@ -178,17 +137,6 @@ export function SettingsView({ initialPreferences }: Props) {
</div>
</div>
</div>
{/* ═══ 生成参数 ═══ */}
<ModelStrategyForm
preferences={preferences}
onChange={handleChange}
onSave={handleSave}
saving={saving}
dirty={dirty}
status={status}
message={message}
/>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
/**
* 音频 API Client — TTS(文本转语音) / ASR(语音转文本)。
*
* TTS 端点返回原始音频字节,不走标准 JSON client 封装。
* ASR 端点接收 multipart/form-data 上传音频,走 OpenAI Whisper 兼容的
* ``POST /v1/audio/transcriptions``,同样绕过 JSON client 直连 fetch。
*/
import { API_PROXY_ROOT } from "@/lib/config";
/** TTS 合成选项 */
export interface TTSOptions {
text: string;
voice?: string;
format?: "wav" | "mp3" | "opus" | "flac";
}
/**
* 调用后端 TTS 接口合成语音,返回音频 Blob。
*
* @returns audio/mpeg 或 audio/wav 的 Blob(基于请求的 format)。
*/
export async function synthesizeSpeech(
options: TTSOptions,
signal?: AbortSignal,
): Promise<Blob> {
const { text, voice = "default", format = "wav" } = options;
const response = await fetch(`${API_PROXY_ROOT}/audio/tts`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text, voice, format }),
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "TTS 合成失败");
throw new Error(
`TTS 请求失败(HTTP ${response.status}):${body.slice(0, 200)}`,
);
}
return response.blob();
}
/** ASR 转写结果(OpenAI Whisper 兼容响应) */
export interface ASRTranscriptionResult {
text: string;
}
/**
* 调用后端 ASR 接口转写音频,返回识别文本。
*
* 走 OpenAI Whisper 兼容端点 ``POST /v1/audio/transcriptions``,以
* multipart/form-data 上传音频文件。经 BFF ``/api/proxy`` 同源代理转发,
* content-type(含 boundary)由 proxy 白名单透传给后端。
*
* @param blob 音频数据(wav / mp3 / webm / m4a / ogg 等)。
* @param filename 上传文件名,扩展名决定后端格式校验。
* @param language 可选语言提示(ISO-639-1:zh/en/ja/ko 等)。
* @param signal 可选 AbortSignal,用于取消请求。
* @param model 可选,指定后端 ASR 模型逻辑名(如 ``funasr-sensevoice``),
* 省略时后端取注册表中第一个活跃 ASR 模型。
*/
export async function transcribeAudio(
blob: Blob,
filename: string,
language?: string,
signal?: AbortSignal,
model?: string,
): Promise<ASRTranscriptionResult> {
const form = new FormData();
form.append("file", blob, filename);
if (language) form.append("language", language);
if (model) form.append("model", model);
const response = await fetch(`${API_PROXY_ROOT}/audio/transcriptions`, {
method: "POST",
body: form,
signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "语音转写失败");
let message = body.slice(0, 200);
try {
const parsed = JSON.parse(body);
if (parsed?.error?.message) message = parsed.error.message;
else if (parsed?.detail?.message) message = parsed.detail.message;
} catch {
/* 保留原始 body 文本 */
}
throw new Error(`语音转写失败(HTTP ${response.status}):${message}`);
}
return (await response.json()) as ASRTranscriptionResult;
}
-135
View File
@@ -1,135 +0,0 @@
/**
* 玄关网关 API(Go MCP 网关管理)。
* 通过 Next.js rewrite (/api/gateway/*) 代理到网关端口,规避跨域。
*/
const GATEWAY_PREFIX = "/api/gateway";
export interface GatewayHealth {
status: string;
tool_count: number;
services: number;
version: string;
uptime: string;
}
export interface GatewayService {
name: string;
description: string;
endpoint: string;
timeout: string;
disabled: boolean;
tools: GatewayTool[];
status: string;
}
export interface GatewayTool {
name: string;
description: string;
visibility: string;
min_role: string;
risk_level: string;
rate_limit?: string;
tags?: string[];
output_limits?: Record<string, unknown>;
}
export interface AddGatewayServiceRequest {
name: string;
description: string;
endpoint: string;
timeout?: string;
tools?: {
name: string;
description: string;
visibility?: string;
min_role?: string;
risk_level?: string;
tags?: string[];
}[];
}
async function gatewayGet<T>(path: string): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
async function gatewayPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
async function gatewayDelete(path: string): Promise<void> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
}
async function gatewayPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${GATEWAY_PREFIX}${path}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `网关请求失败 (${res.status})`);
}
return res.json();
}
export const gatewayApi = {
/** GET /admin/health — 网关健康状态 */
getHealth: () => gatewayGet<GatewayHealth>("/admin/health"),
/** GET /admin/services — 服务清单 */
listServices: () => gatewayGet<GatewayService[]>("/admin/services"),
/** POST /admin/services — 注册新服务 */
addService: (body: AddGatewayServiceRequest) =>
gatewayPost<{ name: string; status: string; tools: number }>(
"/admin/services",
body,
),
/** DELETE /admin/services/{name} — 删除服务 */
deleteService: (name: string) => gatewayDelete(`/admin/services/${name}`),
/** PATCH /admin/services/{name} — 更新服务 */
updateService: (
name: string,
body: {
disabled?: boolean;
description?: string;
endpoint?: string;
tools?: {
name: string;
description: string;
risk_level: string;
visibility?: string;
}[];
},
) =>
gatewayPatch<{ status: string; name: string }>(
`/admin/services/${name}`,
body,
),
/** POST /admin/detect-tools — 检测指定端点的 MCP 工具清单 */
detectTools: (endpoint: string) =>
gatewayPost<{ tools: GatewayTool[] }>("/admin/detect-tools", { endpoint }),
};
-1
View File
@@ -6,7 +6,6 @@
*/
export * from "./client";
export * as audioApi from "./audio";
export * as charactersApi from "./characters";
export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox";
-19
View File
@@ -13,9 +13,7 @@ import { getHealth } from "./health";
import { deleteMcpServer, listMcpServers, registerMcpServer } from "./mcp";
import {
getGenerationPreferences,
getModelGateway,
updateGenerationPreferences,
updateModelEndpoint,
} from "./settings";
import { createSkill, deleteSkill, listSkills, updateSkill } from "./skills";
@@ -58,12 +56,6 @@ describe("mcp api", () => {
});
describe("settings api", () => {
it("getModelGateway GET /settings/models", async () => {
vi.mocked(apiGet).mockResolvedValue({ models: [] });
await getModelGateway();
expect(apiGet).toHaveBeenCalledWith("/settings/models");
});
it("getGenerationPreferences GET /settings/preferences", async () => {
vi.mocked(apiGet).mockResolvedValue({ temperature: 0.7 });
await getGenerationPreferences();
@@ -77,17 +69,6 @@ describe("settings api", () => {
temperature: 0.8,
});
});
it("updateModelEndpoint PATCH 带 URI 编码", async () => {
vi.mocked(apiPatch).mockResolvedValue({ name: "default-chat" });
await updateModelEndpoint("default-chat", "openai-gpt4", {
status: "inactive",
});
expect(apiPatch).toHaveBeenCalledWith(
"/settings/models/default-chat/endpoints/openai-gpt4",
{ status: "inactive" },
);
});
});
describe("skills api", () => {
+3 -26
View File
@@ -1,26 +1,16 @@
/**
* 设置 API Client(对应 后端生成的 OpenAPI 契约 · 设置)。
* 设置 API Client(生成参数偏好)。
*
* 前端只见逻辑模型、provider 脱敏状态与生成参数偏好;真实 API Key、endpoint URL
* 由后端 .env 持有,不进入前端(environment-config-design §3)。模型网关端点已由
* 后端 settings 路由实现(从 config/models.yaml 派生,provider 密钥脱敏);
* Mock 模式(NEXT_PUBLIC_ENABLE_MOCK=true)下由 lib/mock 提供示例数据。
* 模型网关状态与 endpoint 覆盖端点已随 ai_provider 退役(神识承接);
* 偏好设置页与神识阁经此读写生成参数。
*/
import { apiGet, apiPatch } from "./client";
import type {
GenerationPreferences,
ModelEndpointStatus,
ModelGatewayStatus,
UpdateEndpointOverrideRequest,
UpdateGenerationPreferencesRequest,
} from "@/types/api";
/** 模型网关状态(GET /settings/models),只读:逻辑模型清单 + provider 密钥脱敏状态 */
export function getModelGateway() {
return apiGet<ModelGatewayStatus>("/settings/models");
}
/** 生成参数偏好(GET /settings/preferences) */
export function getGenerationPreferences() {
return apiGet<GenerationPreferences>("/settings/preferences");
@@ -32,16 +22,3 @@ export function updateGenerationPreferences(
) {
return apiPatch<GenerationPreferences>("/settings/preferences", body);
}
/** 更新 endpoint 覆盖(PATCH /settings/models/{model}/endpoints/{endpoint},DB 优先 env;写后热重载)。
* 响应脱敏,不回 base_url/api_key 原值。 */
export function updateModelEndpoint(
model: string,
endpoint: string,
body: UpdateEndpointOverrideRequest,
) {
return apiPatch<ModelEndpointStatus>(
`/settings/models/${encodeURIComponent(model)}/endpoints/${encodeURIComponent(endpoint)}`,
body,
);
}
-44
View File
@@ -18,10 +18,6 @@ import type {
MemorySource,
MemoryStatus,
MemoryType,
ModelCapability,
ModelProvider,
EndpointStatus,
RoutingStrategy,
PerceptionChannelType,
Pivot,
PivotHealth,
@@ -299,43 +295,3 @@ export const AGENT_PRESENCE_LABEL: Record<"idle" | "active", string> = {
idle: "待命",
active: "在岗",
};
/** 模型供应商(契约字典 §16.1),网关面板与策略选择共用 */
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
openai: "OpenAI",
deepseek: "DeepSeek",
anthropic: "Anthropic",
google: "Google",
minimax: "MiniMax",
zhipu: "智谱",
tencentcloud: "腾讯云",
volcengine: "火山引擎",
bailian: "阿里云百炼",
};
/** 路由策略(契约字典 §16.2) */
export const ROUTING_STRATEGY_LABEL: Record<RoutingStrategy, string> = {
single: "单点路由",
priority_failover: "优先级故障转移",
round_robin: "轮询",
weighted_random: "权重随机",
sticky_weighted_random: "粘性权重随机",
};
/** endpoint 状态(契约字典 §16.3),网关面板端点池用 */
export const ENDPOINT_STATUS_LABEL: Record<EndpointStatus, string> = {
active: "可用",
inactive: "已停用",
unhealthy: "不健康",
cooldown: "冷却中",
};
/** 模型能力(契约字典 §16.4),逻辑模型能力标签用 */
export const MODEL_CAPABILITY_LABEL: Record<ModelCapability, string> = {
chat: "对话",
tool_use: "工具调用",
structured_output: "结构化输出",
reasoning: "深度推理",
long_context: "长上下文",
vision: "图像理解",
};
@@ -1,117 +0,0 @@
/**
* useVoiceInput 测试 — mock MediaRecorder 与 ASR client。
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useVoiceInput } from "./useVoiceInput";
vi.mock("@/lib/api/audio", () => ({
transcribeAudio: vi.fn().mockResolvedValue({ text: "你好世界" }),
}));
import { transcribeAudio } from "@/lib/api/audio";
/** 最小 MediaRecorder stub:start/stop 并触发 ondataavailable / onstop。 */
class RecorderStub {
state = "inactive";
ondataavailable: ((e: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob([new Uint8Array([1, 2, 3])]) });
this.onstop?.();
}
}
describe("useVoiceInput", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("toggle 录音并转写后回调 onResult", async () => {
const onResult = vi.fn();
const onError = vi.fn();
const stopTracks = vi.fn();
const getTracks = () =>
[{ stop: stopTracks }] as unknown as MediaStreamTrack[];
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: {
getUserMedia: vi.fn().mockResolvedValue({ getTracks }),
},
configurable: true,
});
// 以 function 构造器作为 MediaRecorder,返回 stub 实例
function MR(this: unknown) {
return new RecorderStub();
}
Object.defineProperty(globalThis, "MediaRecorder", {
value: MR,
configurable: true,
});
Object.defineProperty(globalThis.MediaRecorder, "isTypeSupported", {
value: () => true,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
expect(result.current.status).toBe("idle");
await act(async () => {
await result.current.toggle();
});
expect(result.current.status).toBe("recording");
await act(async () => {
await result.current.toggle();
});
await waitFor(() => {
expect(result.current.status).toBe("idle");
});
expect(transcribeAudio).toHaveBeenCalledOnce();
expect(transcribeAudio).toHaveBeenCalledWith(
expect.any(Blob),
expect.stringMatching(/^voice-\d+\.webm$/),
"zh",
expect.any(AbortSignal),
"funasr-sensevoice",
);
expect(onResult).toHaveBeenCalledWith("你好世界");
expect(onError).not.toHaveBeenCalled();
expect(stopTracks).toHaveBeenCalled();
});
it("不支持 MediaRecorder 时触发 onError", async () => {
const onResult = vi.fn();
const onError = vi.fn();
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: { getUserMedia: vi.fn() },
configurable: true,
});
// 设为 undefined 模拟不支持
Object.defineProperty(globalThis, "MediaRecorder", {
value: undefined,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
await act(async () => {
await result.current.start();
});
expect(onError).toHaveBeenCalledWith("当前浏览器不支持 MediaRecorder。");
expect(onResult).not.toHaveBeenCalled();
});
});
-204
View File
@@ -1,204 +0,0 @@
/**
* useVoiceInput — 麦克风语音录入 hook。
*
* 流程:申请麦克风权限 → MediaRecorder 采集音频 → 停止后产出 Blob →
* 调用后端 ASR(OpenAI Whisper 兼容)转写为文本。组件层只关心三态:
* idle / recording / transcribing,以及最终文本与错误。
*
* 后端 ASR 引擎懒加载:首次请求可能返回 503(model_loading),调用方应
* 提示用户稍后重试(见 yuanqi/src/endpoints/asr.py)。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { transcribeAudio } from "@/lib/api/audio";
export type VoiceInputStatus = "idle" | "recording" | "transcribing";
export interface UseVoiceInputOptions {
/** 转写语言提示(ISO-639-1),默认 "zh" */
language?: string;
/** ASR 模型逻辑名,默认 "funasr-sensevoice"(本地 yuanqi 引擎) */
model?: string;
/** 转写成功回调(在组件内更新输入框,避免 effect 内 setState) */
onResult?: (text: string) => void;
/** 错误回调 */
onError?: (message: string) => void;
}
export interface UseVoiceInputReturn {
/** 当前状态 */
status: VoiceInputStatus;
/** 是否正在录音或转写 */
busy: boolean;
/** 开始录音 */
start: () => Promise<void>;
/** 停止录音并触发转写 */
stop: () => Promise<void>;
/** 切换录音状态 */
toggle: () => Promise<void>;
}
/** 选择一个浏览器支持的录音 MIME 类型,优先 webm/opus。 */
function pickMimeType(): string {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
if (typeof MediaRecorder === "undefined") return "";
for (const t of candidates) {
if (MediaRecorder.isTypeSupported(t)) return t;
}
return "";
}
export function useVoiceInput(
options: UseVoiceInputOptions = {},
): UseVoiceInputReturn {
const { language = "zh", model = "funasr-sensevoice" } = options;
const onResultRef = useRef(options.onResult);
const onErrorRef = useRef(options.onError);
// 保持回调为最新引用,但不参与录音流程的依赖
useEffect(() => {
onResultRef.current = options.onResult;
onErrorRef.current = options.onError;
});
const [status, setStatus] = useState<VoiceInputStatus>("idle");
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const mimeRef = useRef<string>("");
const abortRef = useRef<AbortController | null>(null);
/** 释放麦克风轨道与 recorder 引用。 */
const release = useCallback(() => {
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
} catch {
/* 忽略 */
}
}
recorderRef.current = null;
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
}
streamRef.current = null;
chunksRef.current = [];
}, []);
// 卸载时清理
useEffect(() => () => release(), [release]);
const start = useCallback(async () => {
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia
) {
onErrorRef.current?.("当前环境不支持麦克风录音。");
return;
}
if (typeof MediaRecorder === "undefined") {
onErrorRef.current?.("当前浏览器不支持 MediaRecorder。");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = pickMimeType();
mimeRef.current = mime;
const recorder = mime
? new MediaRecorder(stream, { mimeType: mime })
: new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
};
recorderRef.current = recorder;
recorder.start();
setStatus("recording");
} catch (err) {
release();
const name = (err as DOMException)?.name;
let msg: string;
if (name === "NotAllowedError" || name === "SecurityError") {
msg = "麦克风权限被拒绝,请在浏览器设置中允许后重试。";
} else if (name === "NotFoundError") {
msg = "未检测到可用的麦克风设备。";
} else {
msg = "无法启动录音:" + ((err as Error).message ?? String(err));
}
onErrorRef.current?.(msg);
setStatus("idle");
}
}, [release]);
const stop = useCallback(async () => {
const recorder = recorderRef.current;
if (!recorder || recorder.state === "inactive") {
setStatus("idle");
return;
}
/** 录音停止后产出的 Blob。 */
const finalize = (): Promise<Blob> =>
new Promise((resolve) => {
recorder.onstop = () => {
const mime = mimeRef.current || "audio/webm";
resolve(new Blob(chunksRef.current, { type: mime }));
};
recorder.stop();
});
setStatus("transcribing");
try {
const blob = await finalize();
release();
const ext = mimeToExtension(blob.type);
const controller = new AbortController();
abortRef.current = controller;
const result = await transcribeAudio(
blob,
`voice-${Date.now()}.${ext}`,
language,
controller.signal,
model,
);
const text = result.text ?? "";
if (text) onResultRef.current?.(text);
} catch (err) {
if ((err as Error).name === "AbortError") return;
const msg = (err as Error).message ?? "语音转写失败。";
onErrorRef.current?.(msg);
} finally {
abortRef.current = null;
setStatus("idle");
}
}, [language, model, release]);
const toggle = useCallback(async () => {
if (status === "recording") await stop();
else if (status === "idle") await start();
}, [status, start, stop]);
return {
status,
busy: status !== "idle",
start,
stop,
toggle,
};
}
/** 根据 MediaRecorder 产出的 MIME 推断文件扩展名。 */
function mimeToExtension(mime: string): string {
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("ogg")) return "ogg";
if (mime.includes("wav")) return "wav";
return "webm";
}
-114
View File
@@ -26,7 +26,6 @@ import type {
GoalNode,
HealthStatus,
GenerationPreferences,
ModelGatewayStatus,
UpdateGenerationPreferencesRequest,
AuditEntry,
Memory,
@@ -1997,119 +1996,6 @@ export function listRunEvents(runId: string): RunEvent[] {
];
}
/**
* 模型网关状态(GET /settings/models,只读总览)。
*
* 前端只见逻辑模型与 provider 密钥脱敏状态(environment-config-design §3):
* 真实 model 名、endpoint URL、API Key 不在此暴露。deepseek / zhipu 已配置密钥,
* anthropic / openai 未配置(演示"缺失"态,其端点 inactive)。接入真实模型网关端点后移除。
*/
export const modelGateway: ModelGatewayStatus = {
default_model_hint: "default-chat",
models: [
{
name: "default-chat",
display_name: "DeepSeek Flash · 对话主力",
provider: "deepseek",
capabilities: ["chat", "tool_use", "structured_output", "reasoning"],
routing_strategy: "priority_failover",
supports_streaming: true,
supports_thinking: true,
supports_structured_output: true,
context_window: 1_000_000,
max_output_tokens: 384_000,
is_default: true,
endpoints: [
{
name: "primary",
provider: "deepseek",
status: "active",
weight: 100,
},
{
name: "fallback",
provider: "deepseek",
status: "active",
weight: 50,
},
],
},
{
name: "deepseek-pro",
display_name: "DeepSeek Pro · 深度推理",
provider: "deepseek",
capabilities: [
"chat",
"tool_use",
"structured_output",
"reasoning",
"long_context",
],
routing_strategy: "single",
supports_streaming: true,
supports_thinking: true,
supports_structured_output: true,
context_window: 1_000_000,
max_output_tokens: 384_000,
endpoints: [
{
name: "primary",
provider: "deepseek",
status: "active",
weight: 100,
},
],
},
{
name: "glm-4",
display_name: "智谱 GLM-4 · 国产通用",
provider: "zhipu",
capabilities: ["chat", "tool_use", "structured_output"],
routing_strategy: "single",
supports_streaming: true,
supports_thinking: false,
supports_structured_output: true,
context_window: 128_000,
max_output_tokens: 4096,
endpoints: [
{ name: "primary", provider: "zhipu", status: "cooldown", weight: 100 },
],
},
{
name: "claude-sonnet",
display_name: "Claude · Anthropic",
provider: "anthropic",
capabilities: [
"chat",
"tool_use",
"structured_output",
"reasoning",
"vision",
],
routing_strategy: "single",
supports_streaming: true,
supports_thinking: true,
supports_structured_output: true,
context_window: 200_000,
max_output_tokens: 8192,
endpoints: [
{
name: "primary",
provider: "anthropic",
status: "inactive",
weight: 100,
},
],
},
],
providers: [
{ provider: "deepseek", configured: true },
{ provider: "zhipu", configured: true },
{ provider: "anthropic", configured: false },
{ provider: "openai", configured: false },
],
};
/**
* 生成参数偏好(GET /settings/preferences)。
* 业务层偏好:默认对话逻辑模型 + 温度 / 采样 / 输出上限 / 流式开关。不含密钥。
-54
View File
@@ -64,12 +64,6 @@ function paginate<T>(items: T[]) {
};
}
/** mock 内存态:模型 endpoint 覆盖(模拟 DB 覆盖,refresh 重置) */
const _mockModelOverrides: Record<
string,
Record<string, Record<string, unknown>>
> = {};
/**
* 路由匹配:命中返回 MockResult,未命中返回 undefined。
* @param method HTTP 方法
@@ -88,54 +82,6 @@ export function resolveMock(
// —— 健康检查 ——
if (m === "GET" && path === "/health") return ok(data.health);
// —— 设置:模型网关状态(GET /settings/models,provider 密钥脱敏 + endpoint 覆盖标记)——
if (m === "GET" && path === "/settings/models") {
const gw = JSON.parse(JSON.stringify(data.modelGateway));
for (const model of gw.models) {
for (const ep of model.endpoints ?? []) {
const ov = _mockModelOverrides[model.name]?.[ep.name];
ep.priority = ov?.priority ?? ep.priority ?? 10;
ep.base_url_env = `${(ep.provider ?? "").toUpperCase()}_BASE_URL`;
ep.api_key_env = `${(ep.provider ?? "").toUpperCase()}_API_KEY`;
ep.is_overridden = Boolean(ov);
ep.has_url_override = Boolean(ov && ov.base_url);
ep.has_key_override = Boolean(ov && ov.api_key);
if (ov) {
if (ov.status) ep.status = ov.status;
if (ov.weight !== undefined) ep.weight = ov.weight;
}
}
}
return ok(gw);
}
{
const mt = path.match(/^\/settings\/models\/([^/]+)\/endpoints\/([^/]+)$/);
if (mt && m === "PATCH") {
const model = mt[1];
const endpoint = mt[2];
const body = (json ?? {}) as Record<string, unknown>;
_mockModelOverrides[model] = _mockModelOverrides[model] || {};
_mockModelOverrides[model][endpoint] = {
...(_mockModelOverrides[model][endpoint] ?? {}),
...body,
};
const ov = _mockModelOverrides[model][endpoint];
// 返回脱敏 endpoint(不回 base_url/api_key 原值)
return ok({
name: endpoint,
provider: "",
status: (ov.status as string | undefined) ?? "active",
weight: ov.weight as number | undefined,
priority: ov.priority as number | undefined,
is_overridden: true,
has_url_override: Boolean(ov.base_url),
has_key_override: Boolean(ov.api_key),
base_url_env: "",
api_key_env: "",
});
}
}
// —— 设置:生成参数偏好(GET / PATCH /settings/preferences)——
if (m === "GET" && path === "/settings/preferences") {
return ok(data.generationPreferences);
-80
View File
@@ -21,10 +21,6 @@ import type {
MemorySource,
MemoryStatus,
MemoryType,
ModelCapability,
ModelProvider,
EndpointStatus,
RoutingStrategy,
AuditDecision,
Pivot,
PivotHealth,
@@ -646,82 +642,6 @@ export interface HealthStatus {
[key: string]: unknown;
}
/**
* endpoint 脱敏状态(GET /settings/models)。
* 不含 base_url / api_key —— 前端只见可用性与厂商归属(环境配置 §3)。
*/
export interface ModelEndpointStatus {
name: string;
provider: ModelProvider;
status: EndpointStatus;
/** 权重(weighted_random 路由用) */
weight?: number;
/** 优先级(priority_failover 用,数值越小越优先) */
priority?: number;
/** 是否存在 DB 覆盖 */
is_overridden?: boolean;
/** 是否用 DB 覆盖了 base_url(优先于 .env) */
has_url_override?: boolean;
/** 是否用 DB 覆盖了 api_key(优先于 .env) */
has_key_override?: boolean;
/** base_url 的 .env 变量名(只读展示) */
base_url_env?: string;
/** api_key 的 .env 变量名(只读展示) */
api_key_env?: string;
}
/**
* 更新 endpoint 覆盖请求(PATCH /settings/models/{model}/endpoints/{endpoint})。
* 字段全可选;传 undefined 清除覆盖、回退 yaml/env。base_url/api_key 经 DB 覆盖优先于 .env。
* 响应脱敏,不回 base_url/api_key 原值。
*/
export interface UpdateEndpointOverrideRequest {
base_url?: string;
api_key?: string;
status?: EndpointStatus;
weight?: number;
priority?: number;
}
/**
* 供应商密钥脱敏状态。
* configured 表示后端 .env 是否已配置该 provider 的 API Key(仅 true/false,绝不暴露值)。
*/
export interface ProviderKeyStatus {
provider: ModelProvider;
configured: boolean;
}
/**
* 逻辑模型(契约字典 §16)。前端可见的模型策略单元:
* model_hint 引用 name;provider / 能力 / 上下文窗口为公开信息;
* 真实 model 名、endpoint URL、API Key 不暴露给前端。
*/
export interface LogicalModel {
name: string;
display_name: string;
provider: ModelProvider;
capabilities: ModelCapability[];
routing_strategy: RoutingStrategy;
supports_streaming: boolean;
supports_thinking: boolean;
supports_structured_output: boolean;
context_window: number;
max_output_tokens?: number;
/** endpoint 池脱敏状态(不含 URL / 密钥) */
endpoints?: ModelEndpointStatus[];
/** 是否为默认对话模型槽位 */
is_default?: boolean;
}
/** 模型网关状态(GET /settings/models),只读总览 */
export interface ModelGatewayStatus {
models: LogicalModel[];
providers: ProviderKeyStatus[];
/** 默认逻辑模型名,业务层 model_hint 引用(契约字典 §5) */
default_model_hint: string;
}
/** 生成参数偏好(前端可编辑的业务层偏好,不含密钥) */
export interface GenerationPreferences {
/** 默认对话逻辑模型名(契约字典 §5 model_hint) */
-35
View File
@@ -230,41 +230,6 @@ export type PerceptionChannelType =
/** 主题 */
export type ThemeMode = "light" | "dark" | "system";
// —— 模型配置(契约字典 §16)——
// 前端只见逻辑模型 / provider / 能力 / 脱敏状态,真实 model、base URL、API Key 由后端持有。
/** 模型供应商(契约字典 §16.1) */
export type ModelProvider =
| "openai"
| "deepseek"
| "anthropic"
| "google"
| "minimax"
| "zhipu"
| "tencentcloud"
| "volcengine"
| "bailian";
/** 路由策略(契约字典 §16.2) */
export type RoutingStrategy =
| "single"
| "priority_failover"
| "round_robin"
| "weighted_random"
| "sticky_weighted_random";
/** endpoint 状态(契约字典 §16.3),前端只见脱敏状态,不见 URL / 密钥 */
export type EndpointStatus = "active" | "inactive" | "unhealthy" | "cooldown";
/** 模型能力(契约字典 §16.4) */
export type ModelCapability =
| "chat"
| "tool_use"
| "structured_output"
| "reasoning"
| "long_context"
| "vision";
// —— 资料导入流水线(契约字典 §10)——
/** 资料类型(契约字典 §10.1) */