From f9318b1ae9a252653c0490d7a7d8e30fe46c0d5b Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Thu, 30 Jul 2026 01:10:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=A8=A1=E5=9E=8B=E6=94=B9?= =?UTF-8?q?=E6=88=90=E5=8F=B3=E4=BE=A7=E6=8A=BD=E5=B1=89:=E5=85=88?= =?UTF-8?q?=E6=8C=91=E4=BE=9B=E5=BA=94=E5=95=86=E3=80=81=E5=86=8D=E5=A1=AB?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E3=80=81=E5=BD=93=E5=9C=BA=E6=B5=8B=E8=BF=9E?= =?UTF-8?q?=E9=80=9A(=E4=B8=94=E5=8F=AA=E6=B5=8B=E4=B8=8D=E5=AD=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版是把字段倒在地上:整页宽的内联卡、输入框拉到 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) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../apps/api/src/modules/llm/contract.d.ts | 16 ++ .../apps/api/src/modules/llm/index.d.ts | 2 + .../contracts/src/api/governance.d.ts | 53 ++++ apps/api/src/app/routes/governance.ts | 33 +++ apps/api/src/modules/llm/contract.ts | 12 + apps/api/src/modules/llm/index.ts | 3 + .../llm/internal/usecase/llm-service.ts | 37 +++ apps/web/src/app/styles/console.css | 49 ++++ .../features/gov-system/internal/panel.tsx | 242 ++++++++++++------ apps/web/src/shared/gov/gov-api.ts | 10 + packages/contracts/src/api/governance.ts | 40 +++ packages/ui-kit/src/Ico.tsx | 1 + 12 files changed, 422 insertions(+), 76 deletions(-) diff --git a/api-report/apps/api/src/modules/llm/contract.d.ts b/api-report/apps/api/src/modules/llm/contract.d.ts index 14f2741..b8d82c5 100644 --- a/api-report/apps/api/src/modules/llm/contract.d.ts +++ b/api-report/apps/api/src/modules/llm/contract.d.ts @@ -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; diff --git a/api-report/apps/api/src/modules/llm/index.d.ts b/api-report/apps/api/src/modules/llm/index.d.ts index b335457..b354c2f 100644 --- a/api-report/apps/api/src/modules/llm/index.d.ts +++ b/api-report/apps/api/src/modules/llm/index.d.ts @@ -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'; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index c9e7637..4b19838 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -236,6 +236,31 @@ export declare const llmInstanceViewSchema: z.ZodObject<{ enabled: z.ZodDefault; }, z.core.$strip>; export type LlmInstanceView = z.infer; +/** + * Provider 一览 —— 建实例那张表要**预填默认端点**,而唯一真相在服务端的 provider 档案里。 + * 前端不自带一份 URL 表:那种表迟早与服务端漂移,漂移的那天没人会发现(端点错了只表现为「连不上」)。 + */ +export declare const listLlmProvidersRoute: { + readonly method: "GET"; + readonly path: "/api/gov/llm/providers"; + readonly output: z.ZodObject<{ + providers: z.ZodArray; + 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.core.$strip>; }; +/** + * 测试连接(草稿)—— **只测不存**:拿表单上正填的四元真发一次最小调用,一个字节不落库。 + * 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。 + * 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。 + */ +export declare const probeLlmDraftRoute: { + readonly method: "POST"; + readonly path: "/api/gov/llm/probe"; + readonly body: z.ZodObject<{ + id: z.ZodOptional; + name: z.ZodOptional; + provider: z.ZodEnum<{ + "openai-compatible": "openai-compatible"; + openai: "openai"; + deepseek: "deepseek"; + anthropic: "anthropic"; + qwen: "qwen"; + }>; + baseUrl: z.ZodDefault; + model: z.ZodString; + apiKey: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + latencyMs: z.ZodOptional; + error: z.ZodOptional; + }, z.core.$strip>; +}; export declare const probeLlmInstanceRoute: { readonly method: "POST"; readonly path: "/api/gov/llm/:id/probe"; diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index 633191b..7b42fc8 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -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, 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, 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); diff --git a/apps/api/src/modules/llm/contract.ts b/apps/api/src/modules/llm/contract.ts index d8b7179..dedd0fe 100644 --- a/apps/api/src/modules/llm/contract.ts +++ b/apps/api/src/modules/llm/contract.ts @@ -121,6 +121,18 @@ export type LlmService = { remove(id: string): Promise; /** 连通性探针 —— 治理台「测试连接」 */ 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[] }>; /** 系统用途模型 —— 叙述性小结 / 汇报生成 / 蒸馏钩子 */ diff --git a/apps/api/src/modules/llm/index.ts b/apps/api/src/modules/llm/index.ts index 3dd20de..e71cbf4 100644 --- a/apps/api/src/modules/llm/index.ts +++ b/apps/api/src/modules/llm/index.ts @@ -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'; diff --git a/apps/api/src/modules/llm/internal/usecase/llm-service.ts b/apps/api/src/modules/llm/internal/usecase/llm-service.ts index b15db8d..bce0f30 100644 --- a/apps/api/src/modules/llm/internal/usecase/llm-service.ts +++ b/apps/api/src/modules/llm/internal/usecase/llm-service.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)); diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 608ef87..6d8f1cf 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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; } diff --git a/apps/web/src/features/gov-system/internal/panel.tsx b/apps/web/src/features/gov-system/internal/panel.tsx index 7c34a64..62e6e2d 100644 --- a/apps/web/src/features/gov-system/internal/panel.tsx +++ b/apps/web/src/features/gov-system/internal/panel.tsx @@ -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(draft); + const [showKey, setShowKey] = useState(false); const [err, setErr] = useState(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(upsertLlmInstanceRoute, { @@ -112,71 +134,139 @@ 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 ( - -
- - setD({ ...d, name: e.target.value })} /> - - - - - - setD({ ...d, baseUrl: e.target.value })} - /> - - - setD({ ...d, model: e.target.value })} /> - - - setD({ ...d, apiKey: e.target.value })} - /> - - -
- - +
+ + + +
+
+

供应商

+ {/* 一排牌子:挑一个,端点自动填它的默认值 —— 接入面的第一步本来就是「谁家的」 */} +
+ {(providers.data?.providers ?? []).map((p) => ( + + ))} +
+
+ +
+

连接

+ + +
+ + {probed !== undefined && ( + + {probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')} + + )} +
+
+ +
+

型号与用途

+ + +
+ + +
+ 系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它,计量入系统账 +
+ + {err !== undefined && {err}} +
+ +
+ + +
- +
); } @@ -298,7 +388,7 @@ function LlmShelf({ nav }: { nav: Nav }) { <> {editing !== null && ( - setEditing(null)} onSaved={() => { @@ -343,7 +433,7 @@ export function LlmPage({ id }: { id: string }) { desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。" /> {editing ? ( - setEditing(false)} onSaved={() => { diff --git a/apps/web/src/shared/gov/gov-api.ts b/apps/web/src/shared/gov/gov-api.ts index bbc967a..e0c5aef 100644 --- a/apps/web/src/shared/gov/gov-api.ts +++ b/apps/web/src/shared/gov/gov-api.ts @@ -40,6 +40,7 @@ import { listGrantsRoute, listLedgerObjectsRoute, listLlmInstancesRoute, + listLlmProvidersRoute, listProcessPackagesRoute, listStaffRoute, listToolsRoute, @@ -158,6 +159,15 @@ export const useGovMetrics = (): UseQueryResult => export const useGovChannels = (): UseQueryResult => useQuery({ queryKey: ['gov', 'channels'], queryFn: () => call(listChannelsRoute) }); +/** Provider 档案 —— 建实例表单预填默认端点用;一次取,基本不变 */ +export type GovLlmProviders = { providers: ReadonlyArray<{ provider: string; label: string; defaultBaseUrl: string; structuredOutput: string }> }; +export const useGovLlmProviders = (): UseQueryResult => + useQuery({ + queryKey: ['gov', 'llm-providers'], + queryFn: () => call(listLlmProvidersRoute), + staleTime: 10 * 60_000, + }); + export const useGovLlm = (): UseQueryResult => useQuery({ queryKey: ['gov', 'llm'], queryFn: () => call(listLlmInstancesRoute) }); diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index dc26f04..1a15d24 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -84,6 +84,27 @@ export const llmInstanceViewSchema = z.object({ }); export type LlmInstanceView = z.infer; +/** + * 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', diff --git a/packages/ui-kit/src/Ico.tsx b/packages/ui-kit/src/Ico.tsx index 6ad8e93..b274660 100644 --- a/packages/ui-kit/src/Ico.tsx +++ b/packages/ui-kit/src/Ico.tsx @@ -124,6 +124,7 @@ const GLYPHS: Record = { "undo-2": (<>), "redo-2": (<>), "maximize-2": (<>), + "eye-off": (<>), x: (<>), square: (), library: (<>),