接入模型改成右侧抽屉:先挑供应商、再填连接、当场测连通(且只测不存)
上一版是把字段倒在地上:整页宽的内联卡、输入框拉到 1100px、动作钮掉出屏幕。 照模型接入面的通行做法重排 —— 520px 右侧抽屉,头固定 / 身滚动 / 底动作栏常驻: ① **供应商**:一排可点的牌子,挑中即预填它的默认端点(没有默认的标「端点自填」); ② **连接**:API 端点 + API Key(密码框带显示切换),下面就是「测试连接」; ③ **型号与用途**:模型(厂商侧型号名)+ 用途名(活动里声明的那个)+ 两枚开关。 遮罩点击与 Esc 都关得掉 —— 抽屉盖住整屏,不给键盘出路是把人关在里面。 **默认端点从服务端取**(新 `GET /api/gov/llm/providers`):前端不自带一份 URL 表 —— 那种表迟早与 provider 档案漂移,而漂移那天没人会发现(端点错了只表现为「连不上」)。 **「测试连接」只测不存**(新 `POST /api/gov/llm/probe` + `llm.probeDraft`): 拿表单上正填的四元真发一次最小调用,一个字节不落库;密钥留空则取该实例已存的密文, 改端点、换型号不必重打钥匙。上一版是「先存再试」—— 人只想知道钥匙对不对, 结果端点被改了、实例被建了,点取消也收不回来。实测:改错端点后测试, 库里 instances 逐字节未变(测过 before/after 全等)。 顺带补 `eye-off` 图标。五闸绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -109,6 +109,22 @@ export type LlmService = {
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
/**
|
||||
* 草稿探针 —— **只测不存**:拿表单上正填的四元去试,一个字节都不落库。
|
||||
* 密钥现填就用现填的,留空则取该实例已存的密文(改端点不必重打钥匙)。
|
||||
*/
|
||||
probeDraft(draft: {
|
||||
id?: string | undefined;
|
||||
name?: string | undefined;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string | undefined;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
/** 安装静态审:核对活动声明的模型名已绑定(AM 11.3) */
|
||||
assertNamesBound(names: readonly string[]): Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -9,6 +9,8 @@ export { createLlmService } from './internal/usecase/llm-service.ts';
|
||||
export type { ModelGatewayDeps } from './internal/usecase/model-gateway.ts';
|
||||
export { createModelGateway } from './internal/usecase/model-gateway.ts';
|
||||
export { isBound, pickSystemUse, toPublicView, validateBinding } from './internal/domain/model-binding.ts';
|
||||
export { PROVIDER_PROFILES, profileOf, resolveBaseUrl } from './internal/domain/provider-profile.ts';
|
||||
export type { ProviderProfile } from './internal/domain/provider-profile.ts';
|
||||
export { callFingerprint, replayKey, stableStringify } from './internal/domain/replay-key.ts';
|
||||
export { createLlmInstanceRepository, createReplayStore } from './internal/adapter/llm-repository.ts';
|
||||
export { createProviderInvoker, parseJsonLoose } from './internal/adapter/ai-sdk-provider.ts';
|
||||
|
||||
@@ -236,6 +236,31 @@ export declare const llmInstanceViewSchema: z.ZodObject<{
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
|
||||
/**
|
||||
* Provider 一览 —— 建实例那张表要**预填默认端点**,而唯一真相在服务端的 provider 档案里。
|
||||
* 前端不自带一份 URL 表:那种表迟早与服务端漂移,漂移的那天没人会发现(端点错了只表现为「连不上」)。
|
||||
*/
|
||||
export declare const listLlmProvidersRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/llm/providers";
|
||||
readonly output: z.ZodObject<{
|
||||
providers: z.ZodArray<z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
label: z.ZodString;
|
||||
defaultBaseUrl: z.ZodString;
|
||||
structuredOutput: z.ZodEnum<{
|
||||
"json-object": "json-object";
|
||||
"prompt-only": "prompt-only";
|
||||
}>;
|
||||
}, z.core.$strip>>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const listLlmInstancesRoute: {
|
||||
readonly method: "GET";
|
||||
readonly path: "/api/gov/llm";
|
||||
@@ -294,6 +319,34 @@ export declare const upsertLlmInstanceRoute: {
|
||||
enabled: z.ZodDefault<z.ZodBoolean>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
/**
|
||||
* 测试连接(草稿)—— **只测不存**:拿表单上正填的四元真发一次最小调用,一个字节不落库。
|
||||
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
|
||||
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
export declare const probeLlmDraftRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/probe";
|
||||
readonly body: z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
provider: z.ZodEnum<{
|
||||
"openai-compatible": "openai-compatible";
|
||||
openai: "openai";
|
||||
deepseek: "deepseek";
|
||||
anthropic: "anthropic";
|
||||
qwen: "qwen";
|
||||
}>;
|
||||
baseUrl: z.ZodDefault<z.ZodString>;
|
||||
model: z.ZodString;
|
||||
apiKey: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
latencyMs: z.ZodOptional<z.ZodNumber>;
|
||||
error: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const probeLlmInstanceRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/:id/probe";
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
listStaffRoute,
|
||||
listWorkGroupsRoute,
|
||||
positionIdSchema,
|
||||
listLlmProvidersRoute,
|
||||
LLM_PROVIDERS,
|
||||
probeLlmDraftRoute,
|
||||
probeLlmInstanceRoute,
|
||||
saveWorkGroupRoute,
|
||||
setGrantsRoute,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
upsertLlmInstanceRoute,
|
||||
} from '@contracts';
|
||||
import { DisableBlockedError } from '@modules/org';
|
||||
import { PROVIDER_PROFILES } from '@modules/llm';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
@@ -143,6 +147,23 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
|
||||
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||
|
||||
/** Provider 档案:默认端点与出参口径 —— 建实例的表单据它预填(唯一真相在服务端) */
|
||||
app.get(listLlmProvidersRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json({
|
||||
providers: LLM_PROVIDERS.map((provider) => {
|
||||
const profile = PROVIDER_PROFILES[provider];
|
||||
return {
|
||||
provider,
|
||||
label: profile.label,
|
||||
defaultBaseUrl: profile.defaultBaseUrl,
|
||||
structuredOutput: profile.structuredOutput,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
@@ -160,6 +181,18 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
return c.json(await container.llm.upsert(parsed.data));
|
||||
});
|
||||
|
||||
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
||||
app.post(probeLlmDraftRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = probeLlmDraftRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.llm.probeDraft(parsed.data));
|
||||
});
|
||||
|
||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
|
||||
@@ -121,6 +121,18 @@ export type LlmService = {
|
||||
remove(id: string): Promise<void>;
|
||||
/** 连通性探针 —— 治理台「测试连接」 */
|
||||
probe(id: string): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
/**
|
||||
* 草稿探针 —— **只测不存**:拿表单上正填的四元去试,一个字节都不落库。
|
||||
* 密钥现填就用现填的,留空则取该实例已存的密文(改端点不必重打钥匙)。
|
||||
*/
|
||||
probeDraft(draft: {
|
||||
id?: string | undefined;
|
||||
name?: string | undefined;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string | undefined;
|
||||
}): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
/** 安装静态审:核对活动声明的模型名已绑定(AM 11.3) */
|
||||
assertNamesBound(names: readonly string[]): Promise<{ ok: boolean; missing: string[] }>;
|
||||
/** 系统用途模型 —— 叙述性小结 / 汇报生成 / 蒸馏钩子 */
|
||||
|
||||
@@ -33,6 +33,9 @@ export type { ModelGatewayDeps } from './internal/usecase/model-gateway.ts';
|
||||
export { createModelGateway } from './internal/usecase/model-gateway.ts';
|
||||
|
||||
export { isBound, pickSystemUse, toPublicView, validateBinding } from './internal/domain/model-binding.ts';
|
||||
// Provider 档案:默认端点与结构化出参口径 —— 治理台建实例时要预填,前端不复制一份 URL 表
|
||||
export { PROVIDER_PROFILES, profileOf, resolveBaseUrl } from './internal/domain/provider-profile.ts';
|
||||
export type { ProviderProfile } from './internal/domain/provider-profile.ts';
|
||||
export { callFingerprint, replayKey, stableStringify } from './internal/domain/replay-key.ts';
|
||||
|
||||
export { createLlmInstanceRepository, createReplayStore } from './internal/adapter/llm-repository.ts';
|
||||
|
||||
@@ -71,6 +71,43 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 测试连接(草稿)—— **只测不存**。
|
||||
*
|
||||
* 「测试」按下去就把表单写进库,是个会咬人的副作用:人只想知道这把钥匙对不对,
|
||||
* 结果端点被改了、实例被建了,取消也收不回来。所以这里拿**临时绑定**去试:
|
||||
* 密钥现填就用现填的,没填就取这条实例已存的密文(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
async probeDraft(draft) {
|
||||
const stored = draft.id === undefined ? undefined : await deps.instances.findById(draft.id);
|
||||
const cipher = draft.apiKey === undefined || draft.apiKey === '' ? stored?.apiKeyCipher : deps.secrets.seal(draft.apiKey);
|
||||
const binding: ModelBinding = {
|
||||
id: stored?.id ?? 'draft',
|
||||
name: draft.name ?? stored?.name ?? draft.model,
|
||||
provider: draft.provider,
|
||||
baseUrl: resolveBaseUrl(draft.provider, draft.baseUrl),
|
||||
model: draft.model,
|
||||
apiKeyCipher: cipher,
|
||||
systemUse: false,
|
||||
enabled: true,
|
||||
};
|
||||
const violations = validateBinding(binding);
|
||||
if (violations.length > 0) {
|
||||
return { ok: false, error: violations.map((v) => `${v.field}:${v.message}`).join('; ') };
|
||||
}
|
||||
const started = deps.clock.now().getTime();
|
||||
try {
|
||||
await deps.provider.invoke({
|
||||
binding,
|
||||
apiKey: deps.secrets.open(binding.apiKeyCipher!),
|
||||
call: { model: binding.name, prompt: 'ping', maxOutputTokens: 8, label: 'probe' },
|
||||
});
|
||||
return { ok: true, latencyMs: deps.clock.now().getTime() - started };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
async assertNamesBound(names) {
|
||||
const all = await deps.instances.list();
|
||||
const bound = new Set(all.filter((b) => validateBinding(b).length === 0 && b.enabled).map((b) => b.name));
|
||||
|
||||
@@ -314,6 +314,55 @@
|
||||
.g-fcard .probe { position: relative; z-index: 1; display: flex; align-items: flex-start; gap: 6px; padding: 8px 10px; border-radius: 9px; background: #f2f7f4; font: 500 11px/1.5 var(--sans); color: #0c8154; }
|
||||
.g-fcard .probe.bad { background: #fdf3f3; color: #a83a3a; }
|
||||
.g-fcard .probe svg { flex: none; margin-top: 2px; }
|
||||
/* ── 抽屉:一次性的配置岔路(接入模型 / 改绑定)——————————————————
|
||||
右侧 520px 定宽面板 + 遮罩;头固定、身滚动、底动作栏常驻。
|
||||
配置不该把货架顶下去:做完就该回到原地,所以是抽屉不是页内表单。 */
|
||||
.g-drawer { position: fixed; inset: 0; z-index: 60; display: flex; justify-content: flex-end; }
|
||||
.g-drawer-veil { flex: 1; border: 0; padding: 0; background: rgba(19, 28, 38, .38); backdrop-filter: blur(1.5px); cursor: default; animation: g-fade .16s ease-out; }
|
||||
.g-drawer-p { position: relative; display: flex; flex-direction: column; width: min(520px, 100vw); background: var(--card); border-left: 1px solid var(--cline); box-shadow: -18px 0 44px -24px rgba(19, 28, 38, .5); animation: g-slidein .2s cubic-bezier(.2, .8, .2, 1); }
|
||||
@keyframes g-fade { from { opacity: 0; } }
|
||||
@keyframes g-slidein { from { transform: translateX(24px); opacity: .6; } }
|
||||
@media (prefers-reduced-motion: reduce) { .g-drawer-veil, .g-drawer-p { animation: none; } }
|
||||
|
||||
.g-drawer-p > header { flex: none; display: flex; align-items: flex-start; gap: 12px; padding: 18px 20px 15px; border-bottom: 1px solid var(--line-soft); }
|
||||
.g-drawer-p > header b { display: block; font-size: 16px; font-weight: 700; color: var(--ink); }
|
||||
.g-drawer-p > header span { display: block; margin-top: 4px; font-size: 11.5px; line-height: 1.6; color: var(--ink-4); }
|
||||
.g-drawer-p > header .x { flex: none; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; }
|
||||
.g-drawer-p > header .x:hover { border-color: var(--cline); background: #f3f6fa; color: var(--ink); }
|
||||
|
||||
.g-drawer-b { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 20px 18px; }
|
||||
.g-drawer-b section { padding: 16px 0 4px; }
|
||||
.g-drawer-b section + section { border-top: 1px solid var(--line-soft); }
|
||||
.g-drawer-b h4 { margin: 0 0 12px; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-4); }
|
||||
|
||||
.g-drawer-p > footer { flex: none; display: flex; justify-content: flex-end; gap: 8px; padding: 13px 20px; border-top: 1px solid var(--line-soft); background: #fbfcfd; }
|
||||
|
||||
/* 供应商牌子:挑一个即预填它的默认端点 —— 接入面的第一步本来就是「谁家的」 */
|
||||
.g-picktiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(146px, 1fr)); gap: 8px; }
|
||||
.g-picktile { position: relative; display: flex; flex-direction: column; gap: 3px; padding: 10px 11px; border: 1px solid var(--cline); border-radius: 9px; background: #fff; text-align: left; cursor: pointer; transition: border-color var(--t-fast), background var(--t-fast); }
|
||||
.g-picktile:hover { border-color: #aebdcd; }
|
||||
.g-picktile.on { border-color: var(--brand); background: var(--brand-wash); }
|
||||
.g-picktile .n { font-size: 12.5px; font-weight: 600; color: var(--ink); }
|
||||
.g-picktile .s { font: 500 10px/1.3 var(--mono); color: var(--ink-4); }
|
||||
.g-picktile.on .s { color: var(--brand-deep); }
|
||||
.g-picktile svg { position: absolute; right: 9px; top: 9px; color: var(--brand); }
|
||||
|
||||
/* 竖排字段:标签在上、输入居中、说明在下 —— 抽屉里一列到底,不做两栏 */
|
||||
.g-fld { display: block; margin-bottom: 14px; }
|
||||
.g-fld > span { display: block; margin-bottom: 5px; font-size: 12px; font-weight: 600; color: var(--ink-2, var(--ink)); }
|
||||
.g-fld small { display: block; margin-top: 5px; font-size: 10.5px; line-height: 1.6; color: var(--ink-4); }
|
||||
.g-inwrap { position: relative; display: block; }
|
||||
.g-inwrap .g-input { width: 100%; padding-right: 34px; }
|
||||
.g-inwrap .eye { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: var(--ink-4); cursor: pointer; }
|
||||
.g-inwrap .eye:hover { background: #f1f4f8; color: var(--ink); }
|
||||
|
||||
/* 测试连接:钮 + 读数同一行 —— 填完密钥当场知道通不通 */
|
||||
.g-testrow { display: flex; align-items: center; gap: 10px; margin: 2px 0 6px; }
|
||||
.g-testrow .r { min-width: 0; font-size: 11px; line-height: 1.5; color: var(--ink-4); }
|
||||
.g-testrow .r.ok { color: #0c8154; font-weight: 600; }
|
||||
.g-testrow .r.bad { color: #a83a3a; }
|
||||
.g-hint { display: block; margin-top: 8px; font-size: 10.5px; line-height: 1.6; color: var(--ink-4); }
|
||||
|
||||
/* 开关行:两枚复选框并排 —— 表单里唯一的横排 */
|
||||
.g-swrow { display: flex; align-items: center; gap: 18px; }
|
||||
.g-swrow label { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-2, var(--ink)); cursor: pointer; }
|
||||
|
||||
@@ -5,18 +5,15 @@
|
||||
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
||||
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { LlmInstanceView, LlmProvider } from '@contracts';
|
||||
import { LLM_PROVIDERS, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
|
||||
import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Acts, Card, Chip, Field, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import {
|
||||
useGovChannels,
|
||||
useGovLlm,
|
||||
} from '@web-shared/gov/gov-api.ts';
|
||||
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
|
||||
|
||||
export type HomeProps = { tab: string; nav: Nav };
|
||||
|
||||
@@ -75,12 +72,15 @@ const EMPTY_DRAFT: LlmDraft = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 实例配置表单 —— 四元绑定就在这里改(Provider / 端点 / 型号 / 密钥)+ 两枚开关。
|
||||
* 接入模型 —— **右侧抽屉**,照模型接入面的通行做法排:
|
||||
* ① 先挑供应商(一排可点的牌子,选中即预填它的默认端点)
|
||||
* ② 再填连接(端点 + 密钥,密钥旁边就是「测试连接」—— 填完当场知道通不通)
|
||||
* ③ 最后是型号与用途。底部一条固定动作栏。
|
||||
*
|
||||
* **密钥只写不读**(OS 7.3 凭证隔离):服务端只回「绑没绑」,永不回显明文;
|
||||
* 所以这一格留空的意思是「保持原值」,不是「清空」。
|
||||
* 为什么是抽屉不是页内卡:配置是**一次性的岔路**,做完就该回到货架。
|
||||
* 从前那版把表单摊在页面上,输入框拉到 1100px 宽、动作钮掉出屏幕 —— 那不是表单,是把字段倒在地上。
|
||||
*/
|
||||
function LlmForm({
|
||||
function LlmDrawer({
|
||||
draft,
|
||||
onCancel,
|
||||
onSaved,
|
||||
@@ -90,10 +90,32 @@ function LlmForm({
|
||||
onSaved: (saved: LlmInstanceView) => void;
|
||||
}) {
|
||||
const [d, setD] = useState<LlmDraft>(draft);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [err, setErr] = useState<string | undefined>(undefined);
|
||||
const [probed, setProbed] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
|
||||
const providers = useGovLlmProviders();
|
||||
const isNew = draft.id === undefined;
|
||||
// Esc 关抽屉 —— 抽屉盖住整屏,不给一条键盘出路是把人关在里面
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
const profile = (providers.data?.providers ?? []).find((p) => p.provider === d.provider);
|
||||
// openai-compatible 没有默认端点:不自填就连不上,这条闸前端先拦一道(服务端也拦)
|
||||
const urlNeeded = d.provider === 'openai-compatible' && d.baseUrl.trim() === '';
|
||||
const urlNeeded = (profile?.defaultBaseUrl ?? '') === '' && d.baseUrl.trim() === '';
|
||||
const invalid = d.name.trim() === '' || d.model.trim() === '' || urlNeeded;
|
||||
|
||||
/** 换供应商时把端点跟着换成它的默认值 —— 除非用户已经手填过别的 */
|
||||
const pickProvider = (next: string, nextDefault: string) => {
|
||||
const prevDefault = profile?.defaultBaseUrl ?? '';
|
||||
const untouched = d.baseUrl.trim() === '' || d.baseUrl.trim() === prevDefault;
|
||||
setD({ ...d, provider: next as LlmProvider, ...(untouched ? { baseUrl: nextDefault } : {}) });
|
||||
setProbed(undefined);
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
call<LlmInstanceView>(upsertLlmInstanceRoute, {
|
||||
@@ -112,43 +134,113 @@ function LlmForm({
|
||||
onError: (e: unknown) => setErr((e as { message?: string }).message ?? '存不下'),
|
||||
});
|
||||
|
||||
/**
|
||||
* 测试连接 —— **只测不存**:拿表单上正填的四元去试,一个字节不落库。
|
||||
* 密钥留空就用这条实例已存的那把(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
const test = useMutation({
|
||||
mutationFn: () =>
|
||||
call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmDraftRoute, {
|
||||
body: {
|
||||
...(d.id === undefined ? {} : { id: d.id }),
|
||||
name: d.name.trim(),
|
||||
provider: d.provider,
|
||||
baseUrl: d.baseUrl.trim(),
|
||||
model: d.model.trim(),
|
||||
...(d.apiKey === '' ? {} : { apiKey: d.apiKey }),
|
||||
},
|
||||
}),
|
||||
onSuccess: setProbed,
|
||||
onError: (e: unknown) => setProbed({ ok: false, error: (e as { message?: string }).message ?? '测试失败' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card ico="cpu" title={isNew ? '新建模型实例' : `配置 ${draft.name}`}>
|
||||
<div className="g-newflow">
|
||||
<Field k="自定义模型名" hint="活动只声明这个名 —— 换厂商换型号都不动活动一行代码">
|
||||
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="Provider" hint="决定端点默认值与结构化出参的口径">
|
||||
<select className="g-tbsel" value={d.provider} onChange={(e) => setD({ ...d, provider: e.target.value as LlmProvider })}>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PROVIDER_LABEL[p]} · {p}
|
||||
</option>
|
||||
<div className="g-drawer" role="dialog" aria-modal="true" aria-label={isNew ? '接入模型' : `配置 ${draft.name}`}>
|
||||
<button className="g-drawer-veil" aria-label="关闭" onClick={onCancel} />
|
||||
<div className="g-drawer-p">
|
||||
<header>
|
||||
<div>
|
||||
<b>{isNew ? '接入模型' : `配置 ${draft.name}`}</b>
|
||||
<span>活动只声明用途名 —— 换厂商、换型号、换密钥都不动活动一行代码</span>
|
||||
</div>
|
||||
<button className="x" aria-label="关闭" onClick={onCancel}>
|
||||
<Ico name="x" s={15} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="g-drawer-b">
|
||||
<section>
|
||||
<h4>供应商</h4>
|
||||
{/* 一排牌子:挑一个,端点自动填它的默认值 —— 接入面的第一步本来就是「谁家的」 */}
|
||||
<div className="g-picktiles">
|
||||
{(providers.data?.providers ?? []).map((p) => (
|
||||
<button
|
||||
key={p.provider}
|
||||
className={'g-picktile' + (p.provider === d.provider ? ' on' : '')}
|
||||
onClick={() => pickProvider(p.provider, p.defaultBaseUrl)}
|
||||
>
|
||||
<span className="n">{p.label}</span>
|
||||
<span className="s">{p.defaultBaseUrl === '' ? '端点自填' : p.provider}</span>
|
||||
{p.provider === d.provider && <Ico name="circle-check" s={13} />}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field k="Base URL" hint="留空 = 取该 Provider 的默认端点;OpenAI 兼容端点没有默认,必须自填">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>连接</h4>
|
||||
<label className="g-fld">
|
||||
<span>API 端点</span>
|
||||
<input
|
||||
className="g-input mono"
|
||||
value={d.baseUrl}
|
||||
placeholder={d.provider === 'openai-compatible' ? '必填,如 https://your-gateway/v1' : '留空取默认端点'}
|
||||
placeholder={urlNeeded ? '必填,如 https://your-gateway/v1' : '留空取默认端点'}
|
||||
onChange={(e) => setD({ ...d, baseUrl: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field k="Model" hint="厂商侧的型号名,如 deepseek-chat">
|
||||
<input className="g-input mono" value={d.model} placeholder="如:deepseek-chat" onChange={(e) => setD({ ...d, model: e.target.value })} />
|
||||
</Field>
|
||||
<Field k="API-Key" hint="只写不读:密文存治理台,永不回显;留空 = 保持原值">
|
||||
<small>{urlNeeded ? '这家没有默认端点 —— 不填连不上' : '一般不用改;自建网关或代理时填你自己的地址'}</small>
|
||||
</label>
|
||||
<label className="g-fld">
|
||||
<span>API Key</span>
|
||||
<span className="g-inwrap">
|
||||
<input
|
||||
className="g-input mono"
|
||||
type="password"
|
||||
type={showKey ? 'text' : 'password'}
|
||||
autoComplete="off"
|
||||
value={d.apiKey}
|
||||
placeholder={isNew ? '粘贴密钥' : '留空保持原值'}
|
||||
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'}
|
||||
onChange={(e) => setD({ ...d, apiKey: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field k="开关" hint="系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它,计量入系统账">
|
||||
<button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}>
|
||||
<Ico name={showKey ? 'eye-off' : 'eye'} s={13} />
|
||||
</button>
|
||||
</span>
|
||||
<small>密文存治理台,永不回显、不入包、不入留痕</small>
|
||||
</label>
|
||||
<div className="g-testrow">
|
||||
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
|
||||
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
|
||||
{test.isPending ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
{probed !== undefined && (
|
||||
<span className={'r' + (probed.ok ? ' ok' : ' bad')}>
|
||||
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>型号与用途</h4>
|
||||
<label className="g-fld">
|
||||
<span>模型</span>
|
||||
<input className="g-input mono" value={d.model} placeholder="如:deepseek-chat" onChange={(e) => setD({ ...d, model: e.target.value })} />
|
||||
<small>厂商侧的型号名,照厂商文档填</small>
|
||||
</label>
|
||||
<label className="g-fld">
|
||||
<span>用途名</span>
|
||||
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
<small>活动里声明的就是这个名 —— 起个说得清用途的名字,不要写型号</small>
|
||||
</label>
|
||||
<div className="g-swrow">
|
||||
<label>
|
||||
<input type="checkbox" checked={d.enabled} onChange={(e) => setD({ ...d, enabled: e.target.checked })} />
|
||||
@@ -159,24 +251,22 @@ function LlmForm({
|
||||
系统用途
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
{urlNeeded && <Note tone="warn">OpenAI 兼容端点没有默认地址 —— 不填 Base URL,这个实例连不上。</Note>}
|
||||
<small className="g-hint">系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它,计量入系统账</small>
|
||||
</section>
|
||||
|
||||
{err !== undefined && <Note tone="warn">{err}</Note>}
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '取消', ico: 'x', onClick: onCancel },
|
||||
{
|
||||
label: save.isPending ? '保存中…' : '保存',
|
||||
ico: 'send',
|
||||
primary: true,
|
||||
disabled: save.isPending || d.name.trim() === '' || d.model.trim() === '' || urlNeeded,
|
||||
onClick: () => save.mutate(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<footer>
|
||||
<button className="g-btn" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}>
|
||||
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,7 +388,7 @@ function LlmShelf({ nav }: { nav: Nav }) {
|
||||
<QueryGate query={llm}>
|
||||
<>
|
||||
{editing !== null && (
|
||||
<LlmForm
|
||||
<LlmDrawer
|
||||
draft={editing}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
@@ -343,7 +433,7 @@ export function LlmPage({ id }: { id: string }) {
|
||||
desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。"
|
||||
/>
|
||||
{editing ? (
|
||||
<LlmForm
|
||||
<LlmDrawer
|
||||
draft={draftOf(i)}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSaved={() => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
listGrantsRoute,
|
||||
listLedgerObjectsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listLlmProvidersRoute,
|
||||
listProcessPackagesRoute,
|
||||
listStaffRoute,
|
||||
listToolsRoute,
|
||||
@@ -158,6 +159,15 @@ export const useGovMetrics = (): UseQueryResult<GovMetrics> =>
|
||||
export const useGovChannels = (): UseQueryResult<GovChannels> =>
|
||||
useQuery({ queryKey: ['gov', 'channels'], queryFn: () => call<GovChannels>(listChannelsRoute) });
|
||||
|
||||
/** Provider 档案 —— 建实例表单预填默认端点用;一次取,基本不变 */
|
||||
export type GovLlmProviders = { providers: ReadonlyArray<{ provider: string; label: string; defaultBaseUrl: string; structuredOutput: string }> };
|
||||
export const useGovLlmProviders = (): UseQueryResult<GovLlmProviders> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'llm-providers'],
|
||||
queryFn: () => call<GovLlmProviders>(listLlmProvidersRoute),
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
export const useGovLlm = (): UseQueryResult<GovLlm> =>
|
||||
useQuery({ queryKey: ['gov', 'llm'], queryFn: () => call<GovLlm>(listLlmInstancesRoute) });
|
||||
|
||||
|
||||
@@ -84,6 +84,27 @@ export const llmInstanceViewSchema = z.object({
|
||||
});
|
||||
export type LlmInstanceView = z.infer<typeof llmInstanceViewSchema>;
|
||||
|
||||
/**
|
||||
* Provider 一览 —— 建实例那张表要**预填默认端点**,而唯一真相在服务端的 provider 档案里。
|
||||
* 前端不自带一份 URL 表:那种表迟早与服务端漂移,漂移的那天没人会发现(端点错了只表现为「连不上」)。
|
||||
*/
|
||||
export const listLlmProvidersRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/llm/providers',
|
||||
output: z.object({
|
||||
providers: z.array(
|
||||
z.object({
|
||||
provider: llmProviderSchema,
|
||||
label: z.string(),
|
||||
/** 留空即取它;`openai-compatible` 没有默认,回空串 —— 界面据此要求自填 */
|
||||
defaultBaseUrl: z.string(),
|
||||
/** 结构化出参口径:json-object = 厂商原生支持;prompt-only = 靠提示约束 */
|
||||
structuredOutput: z.enum(['json-object', 'prompt-only']),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const listLlmInstancesRoute = {
|
||||
method: 'GET',
|
||||
path: '/api/gov/llm',
|
||||
@@ -108,6 +129,25 @@ export const upsertLlmInstanceRoute = {
|
||||
output: llmInstanceViewSchema,
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
/**
|
||||
* 测试连接(草稿)—— **只测不存**:拿表单上正填的四元真发一次最小调用,一个字节不落库。
|
||||
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
|
||||
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
export const probeLlmDraftRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/llm/probe',
|
||||
body: z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
provider: llmProviderSchema,
|
||||
baseUrl: z.string().default(''),
|
||||
model: z.string().min(1),
|
||||
apiKey: z.string().optional(),
|
||||
}),
|
||||
output: z.object({ ok: z.boolean(), latencyMs: z.number().int().optional(), error: z.string().optional() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const probeLlmInstanceRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/llm/:id/probe',
|
||||
|
||||
@@ -124,6 +124,7 @@ const GLYPHS: Record<string, React.ReactNode> = {
|
||||
"undo-2": (<><path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" /></>),
|
||||
"redo-2": (<><path d="m15 14 5-5-5-5" /><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13" /></>),
|
||||
"maximize-2": (<><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" x2="14" y1="3" y2="10" /><line x1="3" x2="10" y1="21" y2="14" /></>),
|
||||
"eye-off": (<><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /><path d="m2 2 20 20" /></>),
|
||||
x: (<><path d="M18 6 6 18" /><path d="m6 6 12 12" /></>),
|
||||
square: (<rect width="18" height="18" x="3" y="3" rx="2" />),
|
||||
library: (<><path d="m16 6 4 14" /><path d="M12 6v14" /><path d="M8 8v12" /><path d="M4 4v16" /></>),
|
||||
|
||||
Reference in New Issue
Block a user