模型实例卡重做:名字不截、密钥不撒谎、不可用就别摆统计;只留 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:
2026-07-30 01:53:42 +08:00
co-authored by Claude Opus 5
parent fffb46eae7
commit da8ac8f9bc
15 changed files with 276 additions and 80 deletions
+3 -3
View File
@@ -41,7 +41,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A2 · 替代选型', name: 'A2 · 替代选型',
packageVersion: '0.1.2', packageVersion: '0.1.3',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-alternative', checksum: 'dev-checksum-quote-alternative',
@@ -92,14 +92,14 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
skills: ALTERNATIVE_SKILLS.map((s) => s.id), skills: ALTERNATIVE_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['FAE'], roles: ['FAE'],
budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 }, budget: { steps: 100, tokens: 260_000, activeMinutes: 360, humanCalls: 6 },
}, },
}, },
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
ledger: [ ledger: [
{ {
+3 -3
View File
@@ -16,7 +16,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A7 · 情报归档', name: 'A7 · 情报归档',
packageVersion: '0.1.5', packageVersion: '0.1.6',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-archive', checksum: 'dev-checksum-quote-archive',
@@ -65,14 +65,14 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [], tools: [],
skills: ARCHIVE_SKILLS.map((s) => s.id), skills: ARCHIVE_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['BD'], roles: ['BD'],
budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 }, budget: { steps: 30, tokens: 60_000, activeMinutes: 60, humanCalls: 2 },
}, },
}, },
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [], tools: [],
ledger: [ ledger: [
{ {
+3 -3
View File
@@ -43,7 +43,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: '客户RFQ受理', name: '客户RFQ受理',
packageVersion: '0.1.8', packageVersion: '0.1.9',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-intake', checksum: 'dev-checksum-quote-intake',
@@ -104,7 +104,7 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
skills: INTAKE_SKILLS.map((s) => s.id), skills: INTAKE_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['BD'], roles: ['BD'],
budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 }, budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 },
}, },
@@ -112,7 +112,7 @@ export const manifest: ActivityManifest = {
/* 声明 */ /* 声明 */
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }], tools: [{ name: 'crm.history-price-read' }],
ledger: [ ledger: [
{ {
+3 -3
View File
@@ -18,7 +18,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A6 · 报价议价', name: 'A6 · 报价议价',
packageVersion: '0.1.4', packageVersion: '0.1.5',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-negotiate', checksum: 'dev-checksum-quote-negotiate',
@@ -72,14 +72,14 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
skills: NEGOTIATE_SKILLS.map((s) => s.id), skills: NEGOTIATE_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['BD'], roles: ['BD'],
budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 }, budget: { steps: 50, tokens: 120_000, activeMinutes: 60, humanCalls: 3 },
}, },
}, },
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
ledger: [ ledger: [
{ {
+3 -3
View File
@@ -62,7 +62,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A4 · 成本定价', name: 'A4 · 成本定价',
packageVersion: '0.1.5', packageVersion: '0.1.6',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-pricing', checksum: 'dev-checksum-quote-pricing',
@@ -117,14 +117,14 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }], tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }],
skills: PRICING_SKILLS.map((s) => s.id), skills: PRICING_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['BD'], roles: ['BD'],
budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 }, budget: { steps: 60, tokens: 150_000, activeMinutes: 120, humanCalls: 3 },
}, },
}, },
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }], tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
ledger: [ ledger: [
{ {
+3 -3
View File
@@ -45,7 +45,7 @@ export const manifest: ActivityManifest = {
identity: { identity: {
activityId: ACTIVITY_ID, activityId: ACTIVITY_ID,
name: 'A3 · 渠道寻源', name: 'A3 · 渠道寻源',
packageVersion: '0.1.2', packageVersion: '0.1.3',
category: '统一报价', category: '统一报价',
sdkVersion: '0.1.0-m5', sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-sourcing', checksum: 'dev-checksum-quote-sourcing',
@@ -97,14 +97,14 @@ export const manifest: ActivityManifest = {
resources: { resources: {
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
skills: SOURCING_SKILLS.map((s) => s.id), skills: SOURCING_SKILLS.map((s) => s.id),
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
roles: ['PRM'], roles: ['PRM'],
budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 }, budget: { steps: 120, tokens: 300_000, activeMinutes: 480, humanCalls: 8 },
}, },
}, },
declarations: { declarations: {
models: ['quote-extractor'], models: ['deepseek-v4-flash'],
tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }], tools: [{ name: 'mail.send' }, { name: 'crm.history-price-read' }],
ledger: [ ledger: [
{ {
+14
View File
@@ -324,6 +324,20 @@ export declare const upsertLlmInstanceRoute: {
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。 * 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。 * 留空 `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: { export declare const probeLlmDraftRoute: {
readonly method: "POST"; readonly method: "POST";
readonly path: "/api/gov/llm/probe"; readonly path: "/api/gov/llm/probe";
+25
View File
@@ -15,6 +15,7 @@ import {
positionIdSchema, positionIdSchema,
listLlmProvidersRoute, listLlmProvidersRoute,
LLM_PROVIDERS, LLM_PROVIDERS,
deleteLlmInstanceRoute,
probeLlmDraftRoute, probeLlmDraftRoute,
probeLlmInstanceRoute, probeLlmInstanceRoute,
saveWorkGroupRoute, saveWorkGroupRoute,
@@ -181,6 +182,30 @@ export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container
return c.json(await container.llm.upsert(parsed.data)); 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` 之前,否则会被那条路由吃掉 */ /** 测试连接(草稿):只测不存 —— 必须排在 `/:id/probe` 之前,否则会被那条路由吃掉 */
app.post(probeLlmDraftRoute.path, async (c) => { app.post(probeLlmDraftRoute.path, async (c) => {
const denied = await guardSystem(c.get('account')); const denied = await guardSystem(c.get('account'));
+20 -16
View File
@@ -123,24 +123,28 @@ export async function seedMvp(container: Container): Promise<void> {
* ① LLM 实例:四元绑定(Provider / URL / Key / Model),活动只见自定义模型名。 * ① LLM 实例:四元绑定(Provider / URL / Key / Model),活动只见自定义模型名。
* 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009)。 * 密钥**只从环境读**,不入源码、不入包、不入留痕(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 [ * 密钥**只从环境读**;读不到就**不绑**(2026-07-30):从前塞一个 `sk-placeholder`
['deepseek-v4-flash', 'deepseek-v4-flash', false], * 冒充已绑定,治理台上写着「密钥已绑定」,一自检就是 `api key ... is invalid` ——
['deepseek-v4-pro', 'deepseek-v4-pro', false], * 界面撒谎比缺功能更坏。没钥匙就如实显示未绑定,人去抽屉里补。
*/
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) { ] as const) {
await container.llm.upsert({ name, provider: 'deepseek', baseUrl: '', model, apiKey, systemUse, enabled: true }); await container.llm.upsert({
name,
provider: 'deepseek',
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '',
model,
...(apiKey === undefined ? {} : { apiKey }),
// 系统用途不预设:两条都能用,系统叙述回落到第一条可用的(pickSystemUse)
systemUse: false,
enabled: true,
});
} }
// 用途名,不是型号名:换档只改这一处绑定,活动一行不动。系统用途(小结/汇报/蒸馏)也走它 log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
await container.llm.upsert({
name: 'quote-extractor',
provider: 'deepseek',
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '',
model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash',
apiKey,
systemUse: true,
enabled: true,
});
log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)');
/* ② 功能工具注册两审 + 逐槽位白名单 */ /* ② 功能工具注册两审 + 逐槽位白名单 */
for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) { for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) {
@@ -111,3 +111,31 @@ describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () =
expect(stableStringify(3)).toBe('3'); 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 { 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));
} }
+48
View File
@@ -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 { 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.bad { background: #fdf3f3; color: #a83a3a; }
.g-fcard .probe svg { flex: none; margin-top: 2px; } .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 定宽面板 + 遮罩;头固定、身滚动、底动作栏常驻。 右侧 520px 定宽面板 + 遮罩;头固定、身滚动、底动作栏常驻。
配置不该把货架顶下去:做完就该回到原地,所以是抽屉不是页内表单。 */ 配置不该把货架顶下去:做完就该回到原地,所以是抽屉不是页内表单。 */
@@ -9,7 +9,7 @@ import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { LlmInstanceView, LlmProvider } from '@contracts'; 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 { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { Card, Chip, 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';
@@ -42,6 +42,8 @@ const hostOf = (url: string): string => {
/** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */ /** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */
type LlmDraft = { type LlmDraft = {
id?: string; id?: string;
/** 这条实例**当前**有没有密钥 —— 占位文案与判词照它写,不照「是不是新建」写 */
keyBound: boolean;
name: string; name: string;
provider: LlmProvider; provider: LlmProvider;
baseUrl: string; baseUrl: string;
@@ -53,6 +55,7 @@ type LlmDraft = {
const draftOf = (i: LlmInstanceView): LlmDraft => ({ const draftOf = (i: LlmInstanceView): LlmDraft => ({
id: i.id, id: i.id,
keyBound: i.apiKeyBound,
name: i.name, name: i.name,
provider: i.provider, provider: i.provider,
baseUrl: i.baseUrl, baseUrl: i.baseUrl,
@@ -63,6 +66,7 @@ const draftOf = (i: LlmInstanceView): LlmDraft => ({
}); });
const EMPTY_DRAFT: LlmDraft = { const EMPTY_DRAFT: LlmDraft = {
keyBound: false,
name: '', name: '',
provider: 'deepseek', provider: 'deepseek',
baseUrl: '', baseUrl: '',
@@ -208,7 +212,7 @@ function LlmDrawer({
<div className="g-fld2"> <div className="g-fld2">
<label className="g-fld"> <label className="g-fld">
<span>用途名</span> <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> <small>活动里声明的就是这个名</small>
</label> </label>
<label className="g-fld"> <label className="g-fld">
@@ -239,14 +243,17 @@ function LlmDrawer({
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
autoComplete="off" autoComplete="off"
value={d.apiKey} value={d.apiKey}
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'} placeholder={d.keyBound ? '已绑定 · 留空保持原值' : 'sk-…'}
onChange={(e) => setD({ ...d, apiKey: e.target.value })} onChange={(e) => setD({ ...d, apiKey: e.target.value })}
/> />
<button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}> <button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}>
<Ico name={showKey ? 'eye-off' : 'eye'} s={13} /> <Ico name={showKey ? 'eye-off' : 'eye'} s={13} />
</button> </button>
</span> </span>
<small>密文存治理台,永不回显</small> {/* 没绑就直说没绑:没有钥匙这条实例一次也调不动,不该让人以为只是「可留空」 */}
<small className={d.keyBound ? '' : 'warn'}>
{d.keyBound ? '密文存治理台,永不回显' : '还没有密钥 —— 不填这条实例调不动'}
</small>
</label> </label>
<div className="g-testrow"> <div className="g-testrow">
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}> <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 [out, setOut] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
const [menu, setMenu] = useState<'none' | 'open' | 'drop'>('none');
const probe = useMutation({ const probe = useMutation({
mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmInstanceRoute, { params: { id: i.id } }), mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmInstanceRoute, { params: { id: i.id } }),
onSuccess: setOut, onSuccess: setOut,
onError: (e: unknown) => setOut({ ok: false, error: (e as { message?: string }).message ?? '自检失败' }), 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 ( 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 })} /> <button className="hit" aria-label={`打开 ${i.name}`} onClick={() => nav.push({ k: 'llm', id: i.id })} />
<div className="hd"> <div className="hd">
<span className="ico"> <span className="ico">
<Ico name="cpu" s={17} /> <Ico name="cpu" s={18} />
</span> </span>
<div className="ttl"> {/* 名字折行也不截 —— 它是活动里声明的那个名,看不全等于没显示 */}
<b>{i.name}</b> <b className="nm">{i.name}</b>
{/* 副标说清「连的是哪家的什么型号」—— 端点主机名进计量块 */}
<em>
{PROVIDER_LABEL[i.provider]} · {i.model}
</em>
</div>
<span className={'pill' + (i.enabled ? ' on' : '')}>{i.enabled ? '启用' : '停用'}</span>
<button <button
className="mbtn" className={'mbtn' + (menu !== 'none' ? ' on' : '')}
aria-label="连通自检" aria-label="更多"
title="连通自检:真发一次最小调用" onClick={() => setMenu(menu === 'none' ? 'open' : 'none')}
disabled={probe.isPending}
onClick={() => probe.mutate()}
> >
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={13} /> <Ico name="more-horizontal" s={14} />
</button>
<button className="mbtn" aria-label="配置" title="配置" onClick={onEdit}>
<Ico name="pen-line" s={13} />
</button> </button>
</div> </div>
<div className="plate"> <div className="meta">
<span className={i.apiKeyBound ? '' : 'bad'}> <span className="prov">{PROVIDER_LABEL[i.provider]}</span>
<b>{i.apiKeyBound ? '已绑' : '未绑'}</b> <span className="mdl">{i.model}</span>
<i>密钥</i>
</span>
<span>
<b>{i.systemUse ? '是' : '否'}</b>
<i>系统用途</i>
</span>
<span className="wide">
<b>{hostOf(i.baseUrl)}</b>
<i>端点</i>
</span>
</div> </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 && ( {out !== undefined && (
<div className={'probe' + (out.ok ? ' ok' : ' bad')}> <div className={'probe' + (out.ok ? ' ok' : ' bad')}>
<Ico name={out.ok ? 'circle-check' : 'alert-circle'} s={12} /> <Ico name={out.ok ? 'circle-check' : 'alert-circle'} s={12} />
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')} {out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
</div> </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> </div>
); );
} }
@@ -421,11 +475,17 @@ function LlmShelf({ nav }: { nav: Nav }) {
)} )}
<div className="g-shelf"> <div className="g-shelf">
{instances.map((i) => ( {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} /> <Ico name="plus" s={18} />
新建模型实例 接入模型
</button> </button>
</div> </div>
</> </>
+1 -1
View File
@@ -155,7 +155,7 @@ export const ZM: Readonly<Record<GovZone, { title: string; desc: string; accent:
title: '大模型', title: '大模型',
icon: 'cpu', icon: 'cpu',
accent: 'linear-gradient(140deg,#4b4a8c,#2f2e63)', accent: 'linear-gradient(140deg,#4b4a8c,#2f2e63)',
desc: 'Provider / URL / Key / Model 四元绑定,就在这里配:建实例、改端点换型号、换绑密钥、开停用与系统用途,配完当场连通自检(真发一次最小调用,不通就把厂商原话摆出来)。活动只声明用途名——换厂商换型号不动活动一行代码;密钥只写不读,密文存这儿,不入包、不入留痕。', desc: '模型实例就在这里接:挑供应商、填端点与密钥、起个用途名,当场测连通(真发一次最小调用,不通就把厂商原话摆出来)。没绑密钥的实例卡上直说「不可用」——调不动的东西不摆假数字。活动只声明用途名,换厂商换型号不动活动一行代码;密钥只写不读,密文存这儿,不入包、不入留痕。',
}, },
system: { system: {
title: '系统管理', title: '系统管理',
+11
View File
@@ -134,6 +134,17 @@ export const upsertLlmInstanceRoute = {
* 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。 * 「测试」把表单写进库是个会咬人的副作用:人只想知道钥匙对不对,取消却收不回已改的端点。
* 留空 `apiKey` 时取该实例已存的密钥(改端点、换型号不必重打钥匙)。 * 留空 `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 = { export const probeLlmDraftRoute = {
method: 'POST', method: 'POST',
path: '/api/gov/llm/probe', path: '/api/gov/llm/probe',