forked from ChaiHao/BPOS-MVP
上一版是把字段倒在地上:整页宽的内联卡、输入框拉到 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
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
/**
|
|
* llm 用例 —— LLM 实例的四元绑定管理(编码规范 §5.3;语义出处 OS 02 系统管理)。
|
|
*/
|
|
import type { LlmInstanceView } from '@contracts';
|
|
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
|
|
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
|
|
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
|
|
import { resolveBaseUrl } from '../domain/provider-profile.ts';
|
|
import type { ModelBinding } from '../domain/model-binding.ts';
|
|
|
|
export type LlmServiceDeps = {
|
|
readonly instances: LlmInstanceRepository;
|
|
readonly provider: ProviderInvoker;
|
|
readonly secrets: SecretBox;
|
|
readonly clock: Clock;
|
|
readonly ids: IdGenerator;
|
|
};
|
|
|
|
export function createLlmService(deps: LlmServiceDeps): LlmService {
|
|
return {
|
|
async list(): Promise<LlmInstanceView[]> {
|
|
const all = await deps.instances.list();
|
|
return all.map(toPublicView);
|
|
},
|
|
|
|
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
|
|
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
|
|
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
|
|
const apiKeyCipher =
|
|
input.apiKey !== undefined && input.apiKey !== ''
|
|
? deps.secrets.seal(input.apiKey)
|
|
: existing?.apiKeyCipher;
|
|
|
|
const binding: ModelBinding = {
|
|
id: existing?.id ?? input.id ?? deps.ids.next(),
|
|
name: input.name,
|
|
provider: input.provider,
|
|
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
|
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
|
baseUrl: resolveBaseUrl(input.provider, input.baseUrl),
|
|
model: input.model,
|
|
apiKeyCipher,
|
|
systemUse: input.systemUse,
|
|
enabled: input.enabled,
|
|
};
|
|
const saved = await deps.instances.upsert(binding);
|
|
return toPublicView(saved);
|
|
},
|
|
|
|
async remove(id) {
|
|
await deps.instances.remove(id);
|
|
},
|
|
|
|
async probe(id) {
|
|
const binding = await deps.instances.findById(id);
|
|
if (binding === undefined) return { ok: false, error: '实例不存在' };
|
|
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 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));
|
|
const missing = [...new Set(names)].filter((n) => !bound.has(n));
|
|
return { ok: missing.length === 0, missing };
|
|
},
|
|
|
|
async systemUseBinding() {
|
|
return pickSystemUse(await deps.instances.list());
|
|
},
|
|
};
|
|
}
|