diff --git a/activities/quote-alternative/src/manifest.ts b/activities/quote-alternative/src/manifest.ts index 7e160c1..410dfe4 100644 --- a/activities/quote-alternative/src/manifest.ts +++ b/activities/quote-alternative/src/manifest.ts @@ -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: [ { diff --git a/activities/quote-archive/src/manifest.ts b/activities/quote-archive/src/manifest.ts index 0ba3715..f57b9bb 100644 --- a/activities/quote-archive/src/manifest.ts +++ b/activities/quote-archive/src/manifest.ts @@ -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: [ { diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index 21c7855..55b99c7 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -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: [ { diff --git a/activities/quote-negotiate/src/manifest.ts b/activities/quote-negotiate/src/manifest.ts index 9785bb8..5bcd321 100644 --- a/activities/quote-negotiate/src/manifest.ts +++ b/activities/quote-negotiate/src/manifest.ts @@ -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: [ { diff --git a/activities/quote-pricing/src/manifest.ts b/activities/quote-pricing/src/manifest.ts index 880b9a3..a409d67 100644 --- a/activities/quote-pricing/src/manifest.ts +++ b/activities/quote-pricing/src/manifest.ts @@ -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: [ { diff --git a/activities/quote-sourcing/src/manifest.ts b/activities/quote-sourcing/src/manifest.ts index 482dcc4..c1fe17c 100644 --- a/activities/quote-sourcing/src/manifest.ts +++ b/activities/quote-sourcing/src/manifest.ts @@ -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: [ { diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 4b19838..30c2b6c 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -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"; diff --git a/apps/api/src/app/routes/governance.ts b/apps/api/src/app/routes/governance.ts index 7b42fc8..2960216 100644 --- a/apps/api/src/app/routes/governance.ts +++ b/apps/api/src/app/routes/governance.ts @@ -15,6 +15,7 @@ import { positionIdSchema, listLlmProvidersRoute, LLM_PROVIDERS, + deleteLlmInstanceRoute, probeLlmDraftRoute, probeLlmInstanceRoute, saveWorkGroupRoute, @@ -181,6 +182,30 @@ export function registerGovernanceRoutes(app: Hono, 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')); diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts index 1723e35..f7bee03 100644 --- a/apps/api/src/app/seed-mvp.ts +++ b/apps/api/src/app/seed-mvp.ts @@ -123,24 +123,28 @@ export async function seedMvp(container: Container): Promise { * ① 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, + provider: 'deepseek', + baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '', + model, + ...(apiKey === undefined ? {} : { apiKey }), + // 系统用途不预设:两条都能用,系统叙述回落到第一条可用的(pickSystemUse) + systemUse: false, + enabled: true, + }); } - // 用途名,不是型号名:换档只改这一处绑定,活动一行不动。系统用途(小结/汇报/蒸馏)也走它 - 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,系统用途)'); + log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`); /* ② 功能工具注册两审 + 逐槽位白名单 */ for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) { diff --git a/apps/api/src/modules/llm/__tests__/model-binding.test.ts b/apps/api/src/modules/llm/__tests__/model-binding.test.ts index 314d439..6594135 100644 --- a/apps/api/src/modules/llm/__tests__/model-binding.test.ts +++ b/apps/api/src/modules/llm/__tests__/model-binding.test.ts @@ -111,3 +111,31 @@ describe('stableStringify —— 键排序,指纹不随属性顺序漂移', () = expect(stableStringify(3)).toBe('3'); }); }); + +describe('系统用途:没人勾也要有着落(2026-07-30)', () => { + const mk = (over: Partial[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(); + }); +}); diff --git a/apps/api/src/modules/llm/internal/domain/model-binding.ts b/apps/api/src/modules/llm/internal/domain/model-binding.ts index 3edb542..3fd1699 100644 --- a/apps/api/src/modules/llm/internal/domain/model-binding.ts +++ b/apps/api/src/modules/llm/internal/domain/model-binding.ts @@ -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)); } diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 26525e9..2674ee0 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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 定宽面板 + 遮罩;头固定、身滚动、底动作栏常驻。 配置不该把货架顶下去:做完就该回到原地,所以是抽屉不是页内表单。 */ diff --git a/apps/web/src/features/gov-system/internal/panel.tsx b/apps/web/src/features/gov-system/internal/panel.tsx index 6713b10..48c3e68 100644 --- a/apps/web/src/features/gov-system/internal/panel.tsx +++ b/apps/web/src/features/gov-system/internal/panel.tsx @@ -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({
-
-
- - {i.apiKeyBound ? '已绑' : '未绑'} - 密钥 - - - {i.systemUse ? '是' : '否'} - 系统用途 - - - {hostOf(i.baseUrl)} - 端点 - +
+ {PROVIDER_LABEL[i.provider]} + {i.model}
+ + {usable ? ( +
+ + {hostOf(i.baseUrl)} + + +
+ ) : ( + /* 不可用就别摆统计:一句判词 + 一条去路,比四个假数字有用 */ +
+ {i.apiKeyBound ? '已停用' : '未绑定密钥 · 不可用'} + +
+ )} + {out !== undefined && (
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
)} + + {menu !== 'none' && ( +
+ {menu === 'drop' ? ( + <> + 删掉这条实例?活动里声明了它的名就删不掉 + + + + ) : ( + <> + + + + )} +
+ )}
); } @@ -421,11 +475,17 @@ function LlmShelf({ nav }: { nav: Nav }) { )}
{instances.map((i) => ( - setEditing(draftOf(i))} /> + setEditing(draftOf(i))} + onDropped={() => void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] })} + /> ))} -
diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index a84653c..9b7a8cc 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -155,7 +155,7 @@ export const ZM: Readonly