forked from ChaiHao/BPOS-MVP
模型实例卡重做:名字不截、密钥不撒谎、不可用就别摆统计;只留 flash 与 pro 两条
三条毛病一次改净:
**① 名字被截断**。用途名是活动里声明的那个名,看不全等于没显示 —— 卡面改成
40px 图标 + **名字独占一行、折行也不截**(overflow-wrap: anywhere),
厂商与型号退到第二行小字。
**② 「已绑定」是谎**。种子从前塞一个 `sk-placeholder` 冒充密钥,卡上写着「已绑定」,
一自检就是 `api key ... is invalid`;抽屉里那格也照「是不是新建」写占位,
于是没钥匙的实例也显示「已绑定 · 留空保持原值」。现在:
· 种子**读不到环境密钥就不绑**,如实显示未绑定;
· 抽屉的占位与判词改看 `keyBound` —— 没绑就写「还没有密钥 —— 不填这条实例调不动」。
**界面撒谎比缺功能更坏**:缺功能人会去补,撒谎的人会照着假状态做决定。
**③ 不可用还摆一排统计**。没密钥连调都调不起来,「系统用途/端点」那些格没有意义 ——
未绑定时卡上只剩一句判词 +「去配置」;绑好了才谈端点与「测试」。
系统用途从卡面撤了(用户:不要管是不是系统用途,两条都能用)。
**只留两条实例**:`deepseek-v4-flash` 与 `deepseek-v4-pro`,都不预设系统用途。
配套两件:
· `pickSystemUse` 加**回落** —— 没人勾就取第一条能用的(启用且已绑密钥);
从前非勾不可,没勾那天系统叙述整条哑掉,那是配置疏忽不该变成功能缺失(补了三条测试);
· 六个活动包里声明的用途名 `quote-extractor` 改指 `deepseek-v4-flash`,各升一个补丁版。
顺带补 `DELETE /api/gov/llm/:id`(卡上 ⋯ 菜单):还有活动声明着这个用途名就拒(409)——
删了那些活动一调模型就是「用途名未绑定」,而人当时以为只是清一条没用的记录。
实测:用它把库里遗留的 quote-extractor 删掉,现在库里就是那两条,卡上如实写「未绑定密钥 · 不可用」。
五闸绿。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -41,7 +41,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A2 · 替代选型',
|
||||
packageVersion: '0.1.2',
|
||||
packageVersion: '0.1.3',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-alternative',
|
||||
@@ -92,14 +92,14 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
skills: ALTERNATIVE_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['FAE'],
|
||||
budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 },
|
||||
},
|
||||
},
|
||||
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A7 · 情报归档',
|
||||
packageVersion: '0.1.5',
|
||||
packageVersion: '0.1.6',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-archive',
|
||||
@@ -65,14 +65,14 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [],
|
||||
skills: ARCHIVE_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 },
|
||||
},
|
||||
},
|
||||
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -43,7 +43,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: '客户RFQ受理',
|
||||
packageVersion: '0.1.8',
|
||||
packageVersion: '0.1.9',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-intake',
|
||||
@@ -104,7 +104,7 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
skills: INTAKE_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 },
|
||||
},
|
||||
@@ -112,7 +112,7 @@ export const manifest: ActivityManifest = {
|
||||
|
||||
/* 声明 */
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A6 · 报价议价',
|
||||
packageVersion: '0.1.4',
|
||||
packageVersion: '0.1.5',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-negotiate',
|
||||
@@ -72,14 +72,14 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
skills: NEGOTIATE_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 },
|
||||
},
|
||||
},
|
||||
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -62,7 +62,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A4 · 成本定价',
|
||||
packageVersion: '0.1.5',
|
||||
packageVersion: '0.1.6',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-pricing',
|
||||
@@ -117,14 +117,14 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }],
|
||||
skills: PRICING_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['BD'],
|
||||
budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 },
|
||||
},
|
||||
},
|
||||
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ export const manifest: ActivityManifest = {
|
||||
identity: {
|
||||
activityId: ACTIVITY_ID,
|
||||
name: 'A3 · 渠道寻源',
|
||||
packageVersion: '0.1.2',
|
||||
packageVersion: '0.1.3',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum: 'dev-checksum-quote-sourcing',
|
||||
@@ -97,14 +97,14 @@ export const manifest: ActivityManifest = {
|
||||
resources: {
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
skills: SOURCING_SKILLS.map((s) => s.id),
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
roles: ['PRM'],
|
||||
budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 },
|
||||
},
|
||||
},
|
||||
|
||||
declarations: {
|
||||
models: ['quote-extractor'],
|
||||
models: ['deepseek-v4-flash'],
|
||||
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
|
||||
ledger: [
|
||||
{
|
||||
|
||||
@@ -324,6 +324,20 @@ export declare const upsertLlmInstanceRoute: {
|
||||
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
|
||||
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
/**
|
||||
* 删一条模型实例 —— 还有活动声明着它的用途名就拒(409):
|
||||
* 删了那些活动一调模型就是「用途名未绑定」,而人当时以为只是清掉一条没用的记录。
|
||||
*/
|
||||
export declare const deleteLlmInstanceRoute: {
|
||||
readonly method: "DELETE";
|
||||
readonly path: "/api/gov/llm/:id";
|
||||
readonly params: z.ZodObject<{
|
||||
id: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
readonly output: z.ZodObject<{
|
||||
ok: z.ZodBoolean;
|
||||
}, z.core.$strip>;
|
||||
};
|
||||
export declare const probeLlmDraftRoute: {
|
||||
readonly method: "POST";
|
||||
readonly path: "/api/gov/llm/probe";
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
positionIdSchema,
|
||||
listLlmProvidersRoute,
|
||||
LLM_PROVIDERS,
|
||||
deleteLlmInstanceRoute,
|
||||
probeLlmDraftRoute,
|
||||
probeLlmInstanceRoute,
|
||||
saveWorkGroupRoute,
|
||||
@@ -181,6 +182,30 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
|
||||
return c.json(await container.llm.upsert(parsed.data));
|
||||
});
|
||||
|
||||
/** 删实例:被活动声明着的用途名不许删(409)—— 反查放这一层,llm 不该知道活动的存在 */
|
||||
app.delete(deleteLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const id = c.req.param('id');
|
||||
const target = (await container.llm.list()).find((i) => i.id === id);
|
||||
if (target === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '实例不存在' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const cards = await container.registry.list();
|
||||
const manifests = await Promise.all(cards.map((a) => container.registry.manifestOf(a.activityId)));
|
||||
const users = manifests.filter((m) => m.declarations.models.includes(target.name));
|
||||
if (users.length > 0) {
|
||||
const body: ApiError = {
|
||||
code: 'conflict',
|
||||
message: `还有 ${users.length} 个活动声明着用途名「${target.name}」—— 先改那些活动的声明再删`,
|
||||
};
|
||||
return c.json(body, 409);
|
||||
}
|
||||
await container.llm.remove(id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
|
||||
app.post(probeLlmDraftRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
|
||||
@@ -123,24 +123,28 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
* ① LLM 实例:四元绑定(Provider / URL / Key / Model),活动只见自定义模型名。
|
||||
* 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009)。
|
||||
*/
|
||||
const apiKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder';
|
||||
for (const [name, model, systemUse] of [
|
||||
['deepseek-v4-flash', 'deepseek-v4-flash', false],
|
||||
['deepseek-v4-pro', 'deepseek-v4-pro', false],
|
||||
/*
|
||||
* 密钥**只从环境读**;读不到就**不绑**(2026-07-30):从前塞一个 `sk-placeholder`
|
||||
* 冒充已绑定,治理台上写着「密钥已绑定」,一自检就是 `api key ... is invalid` ——
|
||||
* 界面撒谎比缺功能更坏。没钥匙就如实显示未绑定,人去抽屉里补。
|
||||
*/
|
||||
const apiKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY'];
|
||||
for (const [name, model] of [
|
||||
['deepseek-v4-flash', 'deepseek-v4-flash'],
|
||||
['deepseek-v4-pro', 'deepseek-v4-pro'],
|
||||
] as const) {
|
||||
await container.llm.upsert({ name, provider: 'deepseek', baseUrl: '', model, apiKey, systemUse, enabled: true });
|
||||
}
|
||||
// 用途名,不是型号名:换档只改这一处绑定,活动一行不动。系统用途(小结/汇报/蒸馏)也走它
|
||||
await container.llm.upsert({
|
||||
name: 'quote-extractor',
|
||||
name,
|
||||
provider: 'deepseek',
|
||||
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '',
|
||||
model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash',
|
||||
apiKey,
|
||||
systemUse: true,
|
||||
model,
|
||||
...(apiKey === undefined ? {} : { apiKey }),
|
||||
// 系统用途不预设:两条都能用,系统叙述回落到第一条可用的(pickSystemUse)
|
||||
systemUse: false,
|
||||
enabled: true,
|
||||
});
|
||||
log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)');
|
||||
}
|
||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||
|
||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) {
|
||||
|
||||
@@ -111,3 +111,31 @@ describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () =
|
||||
expect(stableStringify(3)).toBe('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('系统用途:没人勾也要有着落(2026-07-30)', () => {
|
||||
const mk = (over: Partial<Parameters<typeof pickSystemUse>[0][number]>) => ({
|
||||
id: 'x',
|
||||
name: 'n',
|
||||
provider: 'deepseek' as const,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'm',
|
||||
apiKeyCipher: 'cipher',
|
||||
systemUse: false,
|
||||
enabled: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('勾了的优先', () => {
|
||||
const flagged = mk({ id: 'flagged', systemUse: true });
|
||||
expect(pickSystemUse([mk({ id: 'first' }), flagged])?.id).toBe('flagged');
|
||||
});
|
||||
|
||||
it('一个没勾 → 取第一条能用的(启用且已绑密钥)', () => {
|
||||
const picked = pickSystemUse([mk({ id: 'nokey', apiKeyCipher: undefined }), mk({ id: 'off', enabled: false }), mk({ id: 'ok' })]);
|
||||
expect(picked?.id, '未绑密钥与已停用的都不算数').toBe('ok');
|
||||
});
|
||||
|
||||
it('一条能用的都没有 → 如实返回 undefined,不硬凑一条', () => {
|
||||
expect(pickSystemUse([mk({ apiKeyCipher: undefined })])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,13 @@ export function toPublicView(binding: ModelBinding): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账、不占活动预算 */
|
||||
/**
|
||||
* 系统用途模型:叙述性小结 / 汇报生成 / 蒸馏钩子 —— 计量入系统账、不占活动预算。
|
||||
*
|
||||
* **没人勾也要有着落**(2026-07-30 用户定「不要管是不是系统用途,都能用」):
|
||||
* 勾了的优先;一个没勾就取第一条**能用的**(启用且已绑密钥)。
|
||||
* 从前非勾不可,于是没勾的那天系统叙述整条哑掉 —— 那是配置疏忽,不该变成功能缺失。
|
||||
*/
|
||||
export function pickSystemUse(bindings: readonly ModelBinding[]): ModelBinding | undefined {
|
||||
return bindings.find((b) => b.systemUse && b.enabled);
|
||||
return bindings.find((b) => b.systemUse && b.enabled) ?? bindings.find((b) => b.enabled && isBound(b));
|
||||
}
|
||||
|
||||
@@ -314,6 +314,54 @@
|
||||
.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; }
|
||||
/* ── 模型实例卡 —— 卡面只回答一句:**这条现在能不能用** ————————————————
|
||||
名字折行也不截(它是活动里声明的那个名);不可用时不摆统计,只留判词与去路。 */
|
||||
.g-mcard { position: relative; display: flex; flex-direction: column; gap: 9px; padding: 14px; background: var(--card); border: 1px solid #e2e8ef; border-radius: 12px; box-shadow: 0 1px 2px rgba(19, 28, 38, .04); transition: border-color var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast); }
|
||||
.g-mcard:hover { border-color: #c2cfde; box-shadow: 0 8px 22px -10px rgba(19, 28, 38, .18); transform: translateY(-1px); }
|
||||
.g-mcard.idle { background: #fbfcfd; }
|
||||
.g-mcard > .hit { position: absolute; inset: 0; z-index: 0; border: 0; background: transparent; cursor: pointer; }
|
||||
.g-mcard > .hit:focus-visible { outline: 2px solid var(--brand); outline-offset: -3px; border-radius: 12px; }
|
||||
.g-mcard > .hd, .g-mcard > .meta, .g-mcard > .bar, .g-mcard > .probe { position: relative; z-index: 1; }
|
||||
.g-mcard > .hd, .g-mcard > .meta { pointer-events: none; }
|
||||
.g-mcard button { pointer-events: auto; }
|
||||
|
||||
.g-mcard > .hd { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.g-mcard .ico { flex: none; width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; color: #4954e6; background: #eef0fe; }
|
||||
.g-mcard.idle .ico { color: #7c8797; background: #f0f3f7; }
|
||||
/* 名字:两行内折行,不截断 —— 看不全等于没显示 */
|
||||
.g-mcard .nm { flex: 1; min-width: 0; font-size: 15px; font-weight: 700; line-height: 1.35; color: var(--ink); overflow-wrap: anywhere; }
|
||||
.g-mcard .mbtn { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; }
|
||||
.g-mcard .mbtn:hover, .g-mcard .mbtn.on { border-color: var(--cline); background: #f3f6fa; color: var(--ink); }
|
||||
|
||||
.g-mcard > .meta { display: flex; align-items: center; gap: 7px; padding-left: 48px; margin-top: -6px; }
|
||||
.g-mcard .prov { flex: none; padding: 2px 7px; border-radius: 4px; background: #eef1f6; font: 600 10.5px/1.5 var(--sans); color: #59677a; }
|
||||
.g-mcard .mdl { min-width: 0; font: 500 11px/1.5 var(--mono); color: var(--ink-4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 状态条:能用就报端点与自检口;不能用就一句判词 + 一条去路 */
|
||||
.g-mcard > .bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 9px; background: #f6f8fb; font-size: 11.5px; color: var(--ink-3); }
|
||||
.g-mcard > .bar.todo { background: #fdf5ec; color: #8a5f1c; font-weight: 600; }
|
||||
.g-mcard .host { flex: 1; min-width: 0; font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.g-mcard > .bar.todo > span { flex: 1; min-width: 0; }
|
||||
.g-mcard .tbtn { flex: none; display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px; border: 1px solid var(--cline); border-radius: 6px; background: #fff; font: 600 11px/1 var(--sans); color: var(--ink-3); cursor: pointer; }
|
||||
.g-mcard .tbtn:hover:not(:disabled) { border-color: #aebdcd; color: var(--ink); }
|
||||
.g-mcard .tbtn.go { border-color: #dcc39a; color: #8a5f1c; }
|
||||
.g-mcard .tbtn:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.g-mcard > .probe { 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-mcard > .probe.bad { background: #fdf3f3; color: #a83a3a; }
|
||||
.g-mcard > .probe svg { flex: none; margin-top: 2px; }
|
||||
|
||||
.g-mcard .menu { position: absolute; right: 12px; top: 46px; z-index: 3; width: 168px; display: flex; flex-direction: column; gap: 2px; padding: 5px; border: 1px solid var(--cline); border-radius: 9px; background: #fff; box-shadow: 0 10px 22px -8px rgba(19, 28, 38, .3); }
|
||||
.g-mcard .menu button { display: flex; align-items: center; gap: 6px; width: 100%; height: 27px; padding: 0 8px; border: 0; border-radius: 6px; background: transparent; font: 500 12px/1 var(--sans); color: var(--ink); text-align: left; cursor: pointer; }
|
||||
.g-mcard .menu button:hover { background: #f2f5f9; }
|
||||
.g-mcard .menu button.bad { color: var(--danger); }
|
||||
.g-mcard .menu button.bad:hover { background: #fdf2f2; }
|
||||
.g-mcard .menu .q { padding: 3px 8px 5px; font-size: 11px; line-height: 1.5; color: var(--ink-3); }
|
||||
|
||||
/* 幽灵卡:同尺寸虚线位 */
|
||||
.g-mcard.ghost { flex-direction: row; justify-content: center; align-items: center; gap: 7px; min-height: 132px; border-style: dashed; border-color: #c0cbd8; background: #fcfdfe; box-shadow: none; color: var(--ink-4); font: 600 12.5px/1 var(--sans); cursor: pointer; }
|
||||
.g-mcard.ghost:hover { border-color: var(--brand); background: var(--brand-wash); color: var(--brand-deep); box-shadow: none; transform: none; }
|
||||
|
||||
/* ── 抽屉:一次性的配置岔路(接入模型 / 改绑定)——————————————————
|
||||
右侧 520px 定宽面板 + 遮罩;头固定、身滚动、底动作栏常驻。
|
||||
配置不该把货架顶下去:做完就该回到原地,所以是抽屉不是页内表单。 */
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { LlmInstanceView, LlmProvider } from '@contracts';
|
||||
import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
|
||||
import { deleteLlmInstanceRoute, probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
||||
@@ -42,6 +42,8 @@ const hostOf = (url: string): string => {
|
||||
/** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */
|
||||
type LlmDraft = {
|
||||
id?: string;
|
||||
/** 这条实例**当前**有没有密钥 —— 占位文案与判词照它写,不照「是不是新建」写 */
|
||||
keyBound: boolean;
|
||||
name: string;
|
||||
provider: LlmProvider;
|
||||
baseUrl: string;
|
||||
@@ -53,6 +55,7 @@ type LlmDraft = {
|
||||
|
||||
const draftOf = (i: LlmInstanceView): LlmDraft => ({
|
||||
id: i.id,
|
||||
keyBound: i.apiKeyBound,
|
||||
name: i.name,
|
||||
provider: i.provider,
|
||||
baseUrl: i.baseUrl,
|
||||
@@ -63,6 +66,7 @@ const draftOf = (i: LlmInstanceView): LlmDraft => ({
|
||||
});
|
||||
|
||||
const EMPTY_DRAFT: LlmDraft = {
|
||||
keyBound: false,
|
||||
name: '',
|
||||
provider: 'deepseek',
|
||||
baseUrl: '',
|
||||
@@ -208,7 +212,7 @@ function LlmDrawer({
|
||||
<div className="g-fld2">
|
||||
<label className="g-fld">
|
||||
<span>用途名</span>
|
||||
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
<input className="g-input" value={d.name} placeholder="如:deepseek-v4-flash" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
||||
<small>活动里声明的就是这个名</small>
|
||||
</label>
|
||||
<label className="g-fld">
|
||||
@@ -239,14 +243,17 @@ function LlmDrawer({
|
||||
type={showKey ? 'text' : 'password'}
|
||||
autoComplete="off"
|
||||
value={d.apiKey}
|
||||
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'}
|
||||
placeholder={d.keyBound ? '已绑定 · 留空保持原值' : 'sk-…'}
|
||||
onChange={(e) => setD({ ...d, apiKey: e.target.value })}
|
||||
/>
|
||||
<button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}>
|
||||
<Ico name={showKey ? 'eye-off' : 'eye'} s={13} />
|
||||
</button>
|
||||
</span>
|
||||
<small>密文存治理台,永不回显</small>
|
||||
{/* 没绑就直说没绑:没有钥匙这条实例一次也调不动,不该让人以为只是「可留空」 */}
|
||||
<small className={d.keyBound ? '' : 'warn'}>
|
||||
{d.keyBound ? '密文存治理台,永不回显' : '还没有密钥 —— 不填这条实例调不动'}
|
||||
</small>
|
||||
</label>
|
||||
<div className="g-testrow">
|
||||
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
|
||||
@@ -293,65 +300,112 @@ function LlmDrawer({
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。
|
||||
* 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」
|
||||
* 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。
|
||||
* 一张实例卡(2026-07-30 重做)—— 卡面只回答一句话:**这条模型现在能不能用**。
|
||||
*
|
||||
* 三处旧毛病一并改掉:
|
||||
* · 名字被一行截断 —— 名字是这条实例的身份,宁可折行也不许截;
|
||||
* · 「已绑定」是谎:种子塞过一个占位密钥,卡上写着已绑定,一自检就是 key invalid。
|
||||
* 现在没钥匙就**不绑**,卡上如实写「未绑定密钥 · 不可用」;
|
||||
* · 没钥匙的实例还摆一排统计格(系统用途、端点…)—— 连调都调不起来,那些数没意义。
|
||||
* 未绑定时卡上只留一句判词与一枚「去配置」,绑好了才谈端点与自检。
|
||||
*/
|
||||
function LlmCard({ i, nav, onEdit }: { i: LlmInstanceView; nav: Nav; onEdit: () => void }) {
|
||||
function LlmCard({ i, nav, onEdit, onDropped }: { i: LlmInstanceView; nav: Nav; onEdit: () => void; onDropped: () => void }) {
|
||||
const [out, setOut] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
|
||||
const [menu, setMenu] = useState<'none' | 'open' | 'drop'>('none');
|
||||
const probe = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmInstanceRoute, { params: { id: i.id } }),
|
||||
onSuccess: setOut,
|
||||
onError: (e: unknown) => setOut({ ok: false, error: (e as { message?: string }).message ?? '自检失败' }),
|
||||
});
|
||||
const drop = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean }>(deleteLlmInstanceRoute, { params: { id: i.id } }),
|
||||
onSuccess: () => {
|
||||
setMenu('none');
|
||||
onDropped();
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setOut({ ok: false, error: (e as { message?: string }).message ?? '删不掉' });
|
||||
setMenu('none');
|
||||
},
|
||||
});
|
||||
const usable = i.apiKeyBound && i.enabled;
|
||||
|
||||
return (
|
||||
<div className={'g-fcard' + (i.enabled ? '' : ' off')}>
|
||||
<div className={'g-mcard' + (usable ? '' : ' idle')}>
|
||||
<button className="hit" aria-label={`打开 ${i.name}`} onClick={() => nav.push({ k: 'llm', id: i.id })} />
|
||||
<div className="hd">
|
||||
<span className="ico">
|
||||
<Ico name="cpu" s={17} />
|
||||
<Ico name="cpu" s={18} />
|
||||
</span>
|
||||
<div className="ttl">
|
||||
<b>{i.name}</b>
|
||||
{/* 副标说清「连的是哪家的什么型号」—— 端点主机名进计量块 */}
|
||||
<em>
|
||||
{PROVIDER_LABEL[i.provider]} · {i.model}
|
||||
</em>
|
||||
</div>
|
||||
<span className={'pill' + (i.enabled ? ' on' : '')}>{i.enabled ? '启用' : '停用'}</span>
|
||||
{/* 名字折行也不截 —— 它是活动里声明的那个名,看不全等于没显示 */}
|
||||
<b className="nm">{i.name}</b>
|
||||
<button
|
||||
className="mbtn"
|
||||
aria-label="连通自检"
|
||||
title="连通自检:真发一次最小调用"
|
||||
disabled={probe.isPending}
|
||||
onClick={() => probe.mutate()}
|
||||
className={'mbtn' + (menu !== 'none' ? ' on' : '')}
|
||||
aria-label="更多"
|
||||
onClick={() => setMenu(menu === 'none' ? 'open' : 'none')}
|
||||
>
|
||||
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={13} />
|
||||
</button>
|
||||
<button className="mbtn" aria-label="配置" title="配置" onClick={onEdit}>
|
||||
<Ico name="pen-line" s={13} />
|
||||
<Ico name="more-horizontal" s={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="plate">
|
||||
<span className={i.apiKeyBound ? '' : 'bad'}>
|
||||
<b>{i.apiKeyBound ? '已绑' : '未绑'}</b>
|
||||
<i>密钥</i>
|
||||
</span>
|
||||
<span>
|
||||
<b>{i.systemUse ? '是' : '否'}</b>
|
||||
<i>系统用途</i>
|
||||
</span>
|
||||
<span className="wide">
|
||||
<b>{hostOf(i.baseUrl)}</b>
|
||||
<i>端点</i>
|
||||
</span>
|
||||
<div className="meta">
|
||||
<span className="prov">{PROVIDER_LABEL[i.provider]}</span>
|
||||
<span className="mdl">{i.model}</span>
|
||||
</div>
|
||||
|
||||
{usable ? (
|
||||
<div className="bar">
|
||||
<span className="host" title={i.baseUrl}>
|
||||
{hostOf(i.baseUrl)}
|
||||
</span>
|
||||
<button className="tbtn" disabled={probe.isPending} onClick={() => probe.mutate()}>
|
||||
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={11} />
|
||||
{probe.isPending ? '测试中' : '测试'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */
|
||||
<div className="bar todo">
|
||||
<span>{i.apiKeyBound ? '已停用' : '未绑定密钥 · 不可用'}</span>
|
||||
<button className="tbtn go" onClick={onEdit}>
|
||||
去配置
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{out !== undefined && (
|
||||
<div className={'probe' + (out.ok ? ' ok' : ' bad')}>
|
||||
<Ico name={out.ok ? 'circle-check' : 'alert-circle'} s={12} />
|
||||
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu !== 'none' && (
|
||||
<div className="menu">
|
||||
{menu === 'drop' ? (
|
||||
<>
|
||||
<span className="q">删掉这条实例?活动里声明了它的名就删不掉</span>
|
||||
<button onClick={() => setMenu('open')}>算了</button>
|
||||
<button className="bad" disabled={drop.isPending} onClick={() => drop.mutate()}>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenu('none');
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Ico name="pen-line" s={12} /> 配置
|
||||
</button>
|
||||
<button className="bad" onClick={() => setMenu('drop')}>
|
||||
<Ico name="trash-2" s={12} /> 删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -421,11 +475,17 @@ function LlmShelf({ nav }: { nav: Nav }) {
|
||||
)}
|
||||
<div className="g-shelf">
|
||||
{instances.map((i) => (
|
||||
<LlmCard key={i.id} i={i} nav={nav} onEdit={() => setEditing(draftOf(i))} />
|
||||
<LlmCard
|
||||
key={i.id}
|
||||
i={i}
|
||||
nav={nav}
|
||||
onEdit={() => setEditing(draftOf(i))}
|
||||
onDropped={() => void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] })}
|
||||
/>
|
||||
))}
|
||||
<button className="g-fcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
|
||||
<button className="g-mcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
|
||||
<Ico name="plus" s={18} />
|
||||
新建模型实例
|
||||
接入模型
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
|
||||
title: '大模型',
|
||||
icon: 'cpu',
|
||||
accent: 'linear-gradient(140deg,#4b4a8c,#2f2e63)',
|
||||
desc: 'Provider / URL / Key / Model 四元绑定,就在这里配:建实例、改端点换型号、换绑密钥、开停用与系统用途,配完当场连通自检(真发一次最小调用,不通就把厂商原话摆出来)。活动只声明用途名——换厂商换型号不动活动一行代码;密钥只写不读,密文存这儿,不入包、不入留痕。',
|
||||
desc: '模型实例就在这里接:挑供应商、填端点与密钥、起个用途名,当场测连通(真发一次最小调用,不通就把厂商原话摆出来)。没绑密钥的实例卡上直说「不可用」——调不动的东西不摆假数字。活动只声明用途名,换厂商换型号不动活动一行代码;密钥只写不读,密文存这儿,不入包、不入留痕。',
|
||||
},
|
||||
system: {
|
||||
title: '系统管理',
|
||||
|
||||
@@ -134,6 +134,17 @@ export const upsertLlmInstanceRoute = {
|
||||
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
|
||||
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。
|
||||
*/
|
||||
/**
|
||||
* 删一条模型实例 —— 还有活动声明着它的用途名就拒(409):
|
||||
* 删了那些活动一调模型就是「用途名未绑定」,而人当时以为只是清掉一条没用的记录。
|
||||
*/
|
||||
export const deleteLlmInstanceRoute = {
|
||||
method: 'DELETE',
|
||||
path: '/api/gov/llm/:id',
|
||||
params: z.object({ id: z.string() }),
|
||||
output: z.object({ ok: z.boolean() }),
|
||||
} as const satisfies RouteContract;
|
||||
|
||||
export const probeLlmDraftRoute = {
|
||||
method: 'POST',
|
||||
path: '/api/gov/llm/probe',
|
||||
|
||||
Reference in New Issue
Block a user