diff --git a/docs/extensions.md b/docs/extensions.md index 1ce5c19a..282c623d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -295,7 +295,8 @@ provider 是 `providers.<端点名>.options.`。写回的三道闸(服务端的 与 `spec`。这些值存在端点条目里,不经 `defaults()`,因此只核对段。 自描述检查:定义没写 `describe`、实例的工具名不在 `describe.tools` 里(bot 按 `persona.ownToolNames()`)、 -package.json 没有 `cortico.describe`、它指的文件不在或与重新生成的内容不一致,都是失败。`cortico.describe` +package.json 没有 `cortico.describe`、它指的文件不在或与重新生成的内容不一致,都是失败。World 实例在默认配置下 +改写了声明的 `description` 或 `parameters` 时只给 warning:扩展市场按声明显示工具数与工具定义的 token 数。`cortico.describe` 须是包内的 `.json`;`tags` 与 `screenshots` 须是字符串数组,`locales` 须是 `{ zh?, en? }` 且每项只含字符串 `displayName` 与 `description`。这几项不合格时只给 warning,包照常加载。 diff --git a/scripts/dev-webui.ts b/scripts/dev-webui.ts index 6c6aefab..6f99ee02 100644 --- a/scripts/dev-webui.ts +++ b/scripts/dev-webui.ts @@ -1251,11 +1251,11 @@ const devCatalog: Record> = { '@acme/cortico-world-discord': { displayName: 'Discord', author: 'Acme Labs', icon: devSvg(64, 64, '#5865f2', 'D'), screenshots: [devSvg(320, 180, '#3b4252', '频道列表'), devSvg(320, 180, '#434c5e', '消息投递')], - summary: { tools: 6, events: 9 }, + summary: { tools: 6, events: 9, toolTokens: 1820, envPromptTokens: 640 }, changelog: { versions: ['0.3.2', '0.3.1', '0.3.0'], url: 'https://github.com/acme/cortico-world-discord/blob/HEAD/CHANGELOG.md' }, }, - 'cortico-world-matrix': { displayName: 'Matrix', author: 'Kyoka', os: ['!darwin'], summary: { tools: 4, events: 2 } }, - 'cortico-world-obs': { author: 'StreamKit', os: ['win32'], icon: devSvg(64, 64, '#302e31', 'OBS'), summary: { tools: 3, events: 0 } }, + 'cortico-world-matrix': { displayName: 'Matrix', author: 'Kyoka', os: ['!darwin'], summary: { tools: 14, events: 2, toolTokens: 3150, envPromptTokens: 1420 } }, + 'cortico-world-obs': { author: 'StreamKit', os: ['win32'], icon: devSvg(64, 64, '#302e31', 'OBS'), summary: { tools: 3, events: 0, toolTokens: 7230, envPromptTokens: 12600 } }, 'cortico-provider-example': { summary: { protocols: ['openai-chat'], auth: ['api-key', 'oauth'], input: ['text', 'image'], local: false } }, 'cortico-bot-example': { displayName: 'Example Bot', summary: { worlds: 3, memory: 'workspace' } }, }; diff --git a/src/extensions/describe-export.ts b/src/extensions/describe-export.ts index e16fdc5a..e5a963e6 100644 --- a/src/extensions/describe-export.ts +++ b/src/extensions/describe-export.ts @@ -5,7 +5,7 @@ */ import { existsSync, readFileSync } from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; -import type { CoreConfig, PromptDocDecl } from '../core/types.ts'; +import type { CoreConfig, PromptDocDecl, ToolSchema } from '../core/types.ts'; import type { BotDefinition } from '../bot.ts'; import type { WorldDefinition, WorldSection } from '../world.ts'; import type { ProviderModule } from '../providers/base.ts'; @@ -21,6 +21,8 @@ interface Exported { file: ExtensionDescribeFile; /** 构造出的实例实际给出的工具名;provider 与没有 `ownToolNames()` 的 Persona 为 null。 */ toolNames: string[] | null; + /** World 实例在默认配置下发给模型的工具定义;其他类别缺席。 */ + liveTools?: ToolSchema[]; } /** 模板文件不存在的项跳过。 */ @@ -45,9 +47,11 @@ async function exportFrom(kind: ExtensionKind, exported: unknown, opts: BotDryMo if (!def.describe) return null; const world = def.create(fakeWorldContext(def, opts)); const docs = (world.webUi?.('zh')?.promptDocs ?? []).filter((doc) => doc.role === 'envPrompt'); + const liveTools = world.tools().map(({ name, description, parameters }) => ({ name, description, parameters })); return { file: withPrompts({ tools: [...def.describe.tools], events: [...def.describe.events] }, promptsOf(docs)), - toolNames: world.tools().map((tool) => tool.name), + toolNames: liveTools.map((tool) => tool.name), + liveTools, }; } if (kind === 'provider') { @@ -133,6 +137,20 @@ export async function checkDescribe( } else if (kind === 'bot') { warnings.push('The Persona has no ownToolNames(), so its tools cannot be compared with describe.tools.'); } + if (result.liveTools) { + const byName = new Map((result.file.tools ?? []).map((tool) => [tool.name, tool])); + const changed = result.liveTools.flatMap((tool) => { + const decl = byName.get(tool.name); + if (!decl) return []; + const fields = [ + ...(decl.description === tool.description ? [] : ['description']), + ...(isDeepStrictEqual(decl.parameters, tool.parameters) ? [] : ['parameters']), + ]; + return fields.length ? [`${tool.name} (${fields.join(', ')})`] : []; + }); + if (changed.length) warnings.push(`With the default config, tools() returns a description or parameters that differ from describe.tools: ${changed.join(', ')}. The extension market shows the declared tools and their token count. Update the declaration if it is stale; leave it if the difference comes from config.`); + else ok.push('tools(): descriptions and parameters match describe.tools under the default config.'); + } if (describePath === undefined) { failures.push(`package.json has no cortico.describe. ${rerun} It writes ${DEFAULT_DESCRIBE_FILE} and adds "describe": "${DEFAULT_DESCRIBE_FILE}" to the cortico block.`); diff --git a/src/web/client/features/extensions/index.ts b/src/web/client/features/extensions/index.ts index 833a52a0..5b8e0b86 100644 --- a/src/web/client/features/extensions/index.ts +++ b/src/web/client/features/extensions/index.ts @@ -102,6 +102,8 @@ export interface ChangelogView { export interface SummaryView { tools?: number; events?: number; + toolTokens?: number; + envPromptTokens?: number; protocols?: string[]; auth?: Array<'api-key' | 'oauth' | 'device-code' | 'web-session'>; input?: Array<'text' | 'image' | 'audio' | 'video'>; @@ -411,6 +413,12 @@ export const iconUrl = (name: string, version: string | null | undefined): strin const categories: ExtensionKindView[] = ['world', 'provider', 'bot']; const TAB_ICONS = { world: 'boxes', provider: 'cpu', bot: 'bot' } as const; const LINK_ICONS = { npm: 'package', repository: 'code', homepage: 'home', bugs: 'alert' } as const; +/** World 卡片能力摘要的黄 / 红两档:超过前一个数标黄,超过后一个数标红。token 数按 catalog 里的估算。 */ +const SPEC_LEVELS = { + toolCount: [10, 20], + toolTokens: [2500, 6000], + envPromptTokens: [1000, 2500], +} as const satisfies Record; const stateLabels = { loaded: S.disabled, failed: S.stateFailed, 'pending-restart': S.statePendingRestart, removed: S.stateRemoved, idle: S.template }; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -699,11 +707,16 @@ export function mountExtensions(ctx: FeatureContext): void { }, { signal }); more.addEventListener('keydown', event => event.stopPropagation(), { signal }); } - /** 能力摘要:World 是工具与事件数,供应商是协议、登录与输入,Bot 是 World 数与 Memory。 */ + /** + * 能力摘要:World 是工具数、事件数与环境提示词 token 数,供应商是协议、登录与输入,Bot 是 World 数与 Memory。 + * 工具数按 SPEC_LEVELS 取个数与工具定义 token 两档里较严的一档着色。 + */ function summaryLine(owner: ExtensionKindView, summary?: SummaryView) { const line = ui.h('div', 'extension-card-spec'); if (!summary) return line; - const part = (name: Parameters[1] | null, text: string) => { const el = ui.h('span', ''); if (name) el.append(icon(doc, name)); el.append(ui.h('span', '', text)); line.append(el); }; + const part = (name: Parameters[1] | null, text: string) => { const el = ui.h('span', ''); if (name) el.append(icon(doc, name)); el.append(ui.h('span', '', text)); line.append(el); return el; }; + const level = (value: number | undefined, [caution, alert]: readonly [number, number]) => value === undefined ? 0 : value > alert ? 2 : value > caution ? 1 : 0; + const tint = (el: HTMLElement, rank: number) => { if (rank) el.classList.add(rank === 2 ? 'is-alert' : 'is-caution'); }; if (owner === 'provider') { const text = [summary.protocols?.join(S.listSeparator), summary.auth?.map(a => S.authNames[a]).join(S.listSeparator), summary.input?.map(i => S.inputNames[i]).join(S.listSeparator), summary.local ? S.runsLocal : ''].filter(Boolean).join(S.metaSeparator); if (text) part('cpu', text); @@ -711,8 +724,17 @@ export function mountExtensions(ctx: FeatureContext): void { if (summary.worlds !== undefined) part('boxes', S.worldCount(summary.worlds)); if (summary.memory) part(null, S.labelled(S.memory, summary.memory)); } else { - if (summary.tools) part('wrench', S.toolCount(summary.tools)); + if (summary.tools) { + const el = part('wrench', S.toolCount(summary.tools)); + tint(el, Math.max(level(summary.tools, SPEC_LEVELS.toolCount), level(summary.toolTokens, SPEC_LEVELS.toolTokens))); + if (summary.toolTokens !== undefined) attachHint(el, S.toolsHint(summary.tools, summary.toolTokens)); + } if (summary.events) part('zap', S.eventCount(summary.events)); + if (summary.envPromptTokens) { + const el = part('text', S.prefixSize(ui.fmt.count(summary.envPromptTokens))); + tint(el, level(summary.envPromptTokens, SPEC_LEVELS.envPromptTokens)); + attachHint(el, S.prefixHint(summary.envPromptTokens)); + } } return line; } diff --git a/src/web/client/features/extensions/strings.ts b/src/web/client/features/extensions/strings.ts index 07a77fd9..8f51c36e 100644 --- a/src/web/client/features/extensions/strings.ts +++ b/src/web/client/features/extensions/strings.ts @@ -218,6 +218,9 @@ const zh = { promptsTabBot: '提示词模板', toolCount: (n: number) => `${n} 个工具`, eventCount: (n: number) => `${n} 种事件`, + toolsHint: (n: number, tokens: number) => `包含 ${n} 个工具,工具定义占用了约 ${tokens} token`, + prefixSize: (tokens: string) => `前缀 ≈${tokens}`, + prefixHint: (tokens: number) => `环境提示词占用了约 ${tokens} token`, worldCount: (n: number) => `${n} 个 World`, sectionWorld: '工具与事件', sectionProvider: '接入方式', @@ -453,6 +456,9 @@ export const en: typeof zh = { promptsTabBot: 'Prompt templates', toolCount: (n: number) => `${n} tools`, eventCount: (n: number) => `${n} event types`, + toolsHint: (n: number, tokens: number) => `Includes ${n} tools; their definitions take about ${tokens} tokens`, + prefixSize: (tokens: string) => `Prefix ≈${tokens}`, + prefixHint: (tokens: number) => `The environment prompt takes about ${tokens} tokens`, worldCount: (n: number) => `${n} Worlds`, sectionWorld: 'Tools and events', sectionProvider: 'Connection', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index facb08c8..f760deee 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2084,6 +2084,8 @@ button.extension-tag { cursor: pointer; } .extension-card-spec > span { display: inline-flex; align-items: center; gap: 5px; min-width: 0; } .extension-card-spec > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .extension-card-spec .icon { width: 13px; height: 13px; flex: none; } +.extension-card-spec .is-caution { color: var(--extension-caution); } +.extension-card-spec .is-alert { color: var(--extension-alert); } .extension-describe { margin-top: 0; } .extension-describe h5 { display: flex; align-items: baseline; gap: 8px; margin: 14px 0 8px; font-size: 12.5px; font-weight: 600; color: var(--ink-soft); } .extension-describe-count { font-weight: 400; color: var(--ink-dim); font-variant-numeric: tabular-nums; } diff --git a/src/web/server.ts b/src/web/server.ts index a37c48ce..41dc609b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -255,6 +255,10 @@ export interface ExtensionSearchHit { export interface ExtensionSummary { tools?: number; events?: number; + /** 全部声明工具的定义估算的 token 数。 */ + toolTokens?: number; + /** 环境提示词模板原文估算的 token 数。 */ + envPromptTokens?: number; protocols?: string[]; auth?: ProviderAuth[]; input?: Array; diff --git a/tests/extensions/describe-export.test.ts b/tests/extensions/describe-export.test.ts index 570a3a04..d4a3e7f9 100644 --- a/tests/extensions/describe-export.test.ts +++ b/tests/extensions/describe-export.test.ts @@ -1,6 +1,6 @@ /** * describe.json 从定义的 describe 与干装载的提示词模板生成;check:extension 要求定义写了 describe、 - * 实例工具名都已声明、包里的文件与重新生成的一致。 + * 实例工具名都已声明、包里的文件与重新生成的一致;实例改写了声明的描述或参数时警告。 */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -26,7 +26,7 @@ afterEach(() => { const declared = (name: string): ToolDescribe => ({ name, description: name, parameters: { type: 'object', properties: {} }, tags: ['read'] }); const tool = (name: string): ToolDef => ({ ...declared(name), handler: async () => 'ok' }); -function world(names: string[], withDescribe = true): WorldDefinition { +function world(names: string[], withDescribe = true, rewrite: Partial = {}): WorldDefinition { return { id: 'sample', label: 'Sample', @@ -34,7 +34,7 @@ function world(names: string[], withDescribe = true): WorldDefinition ({ id: 'sample', envPromptVars: () => ({}), - tools: () => names.map(tool), + tools: () => names.map((name) => ({ ...tool(name), ...rewrite })), start: async () => {}, stop: async () => {}, webUi: () => ({ @@ -72,6 +72,14 @@ describe('World', () => { expect((await checkDescribe('world', world(['sample_ping'], false), 'describe.json', opts)).failures.join('\n')).toContain('has no describe'); expect((await checkDescribe('world', world(['sample_ping', 'sample_extra']), undefined, opts)).failures.join('\n')).toMatch(/sample_extra[\s\S]*cortico\.describe/); }); + + it('the check warns without failing when tools() under the default config rewrites a declared description or parameters', async () => { + const opts = { scratchDir, packageDir: pkgDir }; + expect((await checkDescribe('world', world(['sample_ping']), undefined, opts)).warnings).toEqual([]); + const rewritten = await checkDescribe('world', world(['sample_ping'], true, { parameters: { type: 'object', properties: { n: { type: 'number' } } } }), undefined, opts); + expect(rewritten.warnings.join('\n')).toContain('sample_ping (parameters)'); + expect(rewritten.failures.join('\n')).not.toContain('sample_ping'); + }); }); it('a bot describe.json lists the declared Worlds, the Memory name and the Persona templates; Persona tools missing from describe.tools fail the check', async () => {