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:
1 parent
0d383725b5
commit
462d570751
129 files changed
+81
-15867
No files matched your search
+1
-14
@@ -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 [];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)。
|
||||
* 业务层偏好:默认对话逻辑模型 + 温度 / 采样 / 输出上限 / 流式开关。不含密钥。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in new issue
Block a user