Archived
接入模型改成右侧抽屉:先挑供应商、再填连接、当场测连通(且只测不存)
上一版是把字段倒在地上:整页宽的内联卡、输入框拉到 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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user