From 13764faccc41a75344d90b5241ef20dd6d9a71c4 Mon Sep 17 00:00:00 2001 From: ketd <94940923+ketd@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:16:55 -0500 Subject: [PATCH] feat(sdk): add llm conversations, tool calls, retrieval and required operations - llm.generateText/streamText accept messages, tools, toolChoice and reasoning; results carry toolCalls and an assistant message with encrypted hostState - llm.embed (explicit model) and llm.rerank, plus sdk/vectors.mts for local cosine similarity, top-k and SQLite BLOB conversion - workbench:check scans server code and records Host operations beyond the baseline in manifest.requires.operations so older clients ask to update - references/llm.md documents the tool loop, retrieval and required operations Co-Authored-By: Claude Opus 5.5 --- .agents/skills/floatboat-workbench/SKILL.md | 2 +- .../floatboat-workbench/references/llm.md | 37 +++++- sdk/host-contracts.mts | 98 ++++++++++++-- sdk/host.mts | 4 + sdk/vectors.mts | 51 +++++++ tests/authoring.test.mjs | 18 +++ tooling/build.mjs | 19 ++- tooling/compiler/host-operations.ts | 125 ++++++++++++++++++ tooling/compiler/typecheck.ts | 15 +++ tooling/compiler/worker.ts | 4 +- tooling/contracts/workbenchCompilation.ts | 2 + tooling/contracts/workbenchManifest.ts | 15 +++ tooling/contracts/workbenchOperations.ts | 117 ++++++++++++++++ 13 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 sdk/vectors.mts create mode 100644 tooling/compiler/host-operations.ts create mode 100644 tooling/contracts/workbenchOperations.ts diff --git a/.agents/skills/floatboat-workbench/SKILL.md b/.agents/skills/floatboat-workbench/SKILL.md index c7e3b1f..da5f122 100644 --- a/.agents/skills/floatboat-workbench/SKILL.md +++ b/.agents/skills/floatboat-workbench/SKILL.md @@ -36,7 +36,7 @@ These topics are also available without installing dependencies through `node to | Browser connection, theme and Host contributions | [ui](references/ui.md) | | Business tables, migrations and transaction boundaries | [database](references/database.md) | | Workflow/Combo declarations and portable file resources | [resources](references/resources.md) | -| Model text, structured output, streaming, image input, speech, image generation, Jev decisions or model list | [llm](references/llm.md) | +| Model text, structured output, streaming, conversations, tool calls, reasoning, image input, speech, image generation, Jev decisions, embeddings, reranking or model list | [llm](references/llm.md) | | Agent tasks with tools and an explicit execution directory | [agent](references/agent.md) | | Streaming speech recognition | [asr](references/asr.md) | | Starting or observing a declared Workflow | [workflows](references/workflows.md) | diff --git a/.agents/skills/floatboat-workbench/references/llm.md b/.agents/skills/floatboat-workbench/references/llm.md index 535fcb3..e833797 100644 --- a/.agents/skills/floatboat-workbench/references/llm.md +++ b/.agents/skills/floatboat-workbench/references/llm.md @@ -4,16 +4,33 @@ Declare `llm` in manifest.capabilities. Call the SDK on the server; the browser ## Text, structured output and streaming -- `app.host.llm.generateText({ prompt, ...options })` returns text, model, requestId, finishReason and optional usage. +- `app.host.llm.generateText({ prompt, ...options })` returns text, model, requestId, finishReason, optional usage and an `assistant` message you can append to a conversation. - `generateObject({ prompt, schema })` validates output against JSON Schema. Treat the returned object as unknown until checked against the business type. -- `streamText({ prompt })` returns an async iterable with delta, result and done events. Preserve incomplete/interrupted state if the stream ends without the required terminal result. -- All three accept `images` (up to 8, each at most 10 MiB): `{ resource: { resourceId, relativePath } }` for a file in a declared directory resource, `{ url: 'https://…' }` that the Host downloads, or a small `{ dataUrl: 'data:image/png;base64,…' }` (the whole request is limited to 2 MiB). Only PNG, JPEG, WebP and GIF are accepted. A model or protocol without image input returns `LLM_IMAGE_INPUT_UNSUPPORTED` before any charge; check `inputs` from `listModels` first. +- `streamText({ prompt })` returns an async iterable with delta, tool-call, result and done events. Preserve incomplete/interrupted state if the stream ends without the required terminal result. +- With `prompt`, all three accept `images` (up to 8, each at most 10 MiB): `{ resource: { resourceId, relativePath } }` for a file in a declared directory resource, `{ url: 'https://…' }` that the Host downloads, or a small `{ dataUrl: 'data:image/png;base64,…' }` (the whole request is limited to 2 MiB). Only PNG, JPEG, WebP and GIF are accepted. A model or protocol without image input returns `LLM_IMAGE_INPUT_UNSUPPORTED` before any charge; check `inputs` from `listModels` first. Calls that name no model use `DEFAULT_LLM_MODEL` (`gemini-3.8-flash`, exported by `sdk/host.mts`); omit `model` unless the product needs a different one. To choose one, pass a Host-supported model ID, for example `generateText({ model: 'gemini-3.8-flash', prompt: 'Hello' })`. The Host resolves the model catalog and selects Chat Completions, Responses, Anthropic or Gemini protocol, endpoint and authorization automatically. Configured `local/` IDs use their registered endpoint. Do not infer a provider from a model-name prefix or supply your own endpoint. Text, streaming and JSON Schema generation follow the same routing. Real verification of one model does not prove every upstream account/model is available. +## Conversations, tools and reasoning + +Pass `messages` instead of `prompt` (one or the other) for multi-turn calls: `{ role: 'system' | 'user', content }`, `{ role: 'user', content: [{ type: 'text', text }, { type: 'image', image }] }` and `{ role: 'tool', toolCallId, content }`. Append the previous result's `assistant` unchanged; its `hostState` is encrypted reasoning material the Host needs for the next turn. Do not edit it; if it is altered or the account changes, the Host continues without it instead of failing. `reasoning: 'off' | 'low' | 'medium' | 'high'` sets reasoning effort and falls back to off on models without it. + +Tools run in your app, not in the Host. Pass `tools: [{ name, description?, parameters }]` (`parameters` is a JSON Schema with `type: 'object'`) and optionally `toolChoice: 'auto' | 'none' | 'required' | { name }` to `generateText` or `streamText`. When `finishReason` is `TOOL_CALLS`, run each entry of `toolCalls` (`{ id, name, input, inputError? }`) yourself, then call again with the history plus `result.assistant` and one tool message per call. `inputError` means the arguments failed the schema; return it as the tool result so the model can correct itself. Cap the number of rounds and check permissions before any tool writes data. + +```ts +const messages: ChatMessage[] = [{ role: 'user', content: 'Where is order A1?' }]; +for (let round = 0; round < 5; round++) { + const result = await app.host.llm.generateText({ messages, tools: [lookupOrder] }); + messages.push(result.assistant); + if (result.finishReason !== 'TOOL_CALLS') break; + for (const call of result.toolCalls ?? []) + messages.push({ role: 'tool', toolCallId: call.id, content: call.inputError ?? JSON.stringify(await runTool(call)) }); +} +``` + ## Model catalog -`listModels({ kind? })` returns `models` (`id`, `kinds` of `text`/`speech`/`image`/`decision`, `inputs` of `text`/`image`, `source`), per-kind `defaults`, and `media`. Text models follow the same release, membership and local-model rules as the Floatboat chat model picker. Speech, image and decision models come from the platform catalog; when `media` is `unavailable` that part may be incomplete, so keep an explicit retry instead of treating it as "no models". Choose models from this result rather than hard-coding names. +`listModels({ kind? })` returns `models` (`id`, `kinds` of `text`/`speech`/`image`/`decision`/`embedding`/`rerank`, `inputs` of `text`/`image`, `source`), per-kind `defaults`, and `media`. Text models follow the same release, membership and local-model rules as the Floatboat chat model picker. Speech, image, decision, embedding and rerank models come from the platform catalog; when `media` is `unavailable` that part may be incomplete, so keep an explicit retry instead of treating it as "no models". Choose models from this result rather than hard-coding names. ## Speech and images @@ -35,8 +52,18 @@ const result = await app.host.llm.decide({ }); ``` +## Retrieval: embeddings and reranking + +- `embed({ model, input, dimensions? })` requires an explicit model (pick one from `listModels({ kind: 'embedding' })`) and takes up to 256 texts. Vectors from different models are not comparable, so store the model ID next to each vector. +- `rerank({ query, documents, topN?, model? })` returns `{ index, relevanceScore }` sorted by score, with `index` pointing into `documents` (up to 1000). +- The Host does not store vectors. Keep them in your app's SQLite as BLOBs and compare them with `sdk/vectors.mts`: `vectorToBlob`, `vectorFromBlob`, `cosineSimilarity` and `topK`. A full scan of 10,000 vectors at 1536 dimensions takes about 20 ms; use `topK` for recall, then `rerank` the shortlist. + +## Required Host operations + +`pnpm workbench:check` and Floatboat builds scan the server code and write Host operations newer than the baseline (for example `llm.embed`, or `llm.messages` / `llm.tools` / `llm.reasoning` / `llm.images` when text calls use those fields) to `manifest.requires.operations`. Older Floatboat clients then ask the user to update instead of failing at runtime. Commit the field with your code; the build only adds entries, so remove one by hand only when the code no longer uses it. + ## Errors and billing -The platform owns model routing, credentials and billing; there is no app-supplied provider API key field. When credits run out every call returns `HOST_CREDITS_EXHAUSTED` with `execution: 'not-executed'`, and Floatboat shows its own "out of credits" prompt in the main window. Show a retryable state in the app and do not loop retries. Other upstream failures return `LLM_HTTP_`. +The platform owns model routing, credentials and billing; there is no app-supplied provider API key field. When credits run out every call returns `HOST_CREDITS_EXHAUSTED` with `execution: 'not-executed'`, and Floatboat shows its own "out of credits" prompt in the main window. Show a retryable state in the app and do not loop retries. Other upstream failures return `LLM_HTTP_`. Users can see the last 30 days of calls per workbench in Floatboat under Me → Workbenches; it is a call count, not a credit statement. Use `app.host.capabilities.list({ domain: 'llm' })` to inspect the connected contract; older Hosts report the newer operations as unavailable. Read `sdk/host-contracts.mts` (or generated `app/sdk/host-contracts.d.mts`) for supported options. Match business UI loading/cancel/error behavior to actual returned state rather than fabricated text. diff --git a/sdk/host-contracts.mts b/sdk/host-contracts.mts index 3739a1b..dd957fe 100644 --- a/sdk/host-contracts.mts +++ b/sdk/host-contracts.mts @@ -34,17 +34,58 @@ export interface ResourceReference { /** 图片输入三选一:声明资源文件、https 地址(由 Host 下载)或图片 data URL;单张不超过 10 MiB。 */ export type ImageInput = { resource: ResourceReference } | { url: string } | { dataUrl: string }; -export interface ModelInput { - prompt: string; +export type ChatContentPart = { type: 'text'; text: string } | { type: 'image'; image: ImageInput }; + +export interface ToolCall { + id: string; + name: string; + input: Record; +} + +/** assistant 消息直接使用上一轮结果的 `assistant` 字段原样追加,hostState 不可修改。 */ +export type ChatMessage = + | { role: 'system'; content: string } + | { role: 'user'; content: string | ChatContentPart[] } + | { role: 'assistant'; content?: string; toolCalls?: ToolCall[]; hostState?: string } + | { role: 'tool'; toolCallId: string; content: string }; + +export interface ToolDefinition { + /** ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$ */ + name: string; + description?: string; + /** JSON Schema,顶层必须是 type: 'object'。 */ + parameters: Record; +} + +export type ToolChoice = 'auto' | 'none' | 'required' | { name: string }; +export type ReasoningEffort = 'off' | 'low' | 'medium' | 'high'; + +/** prompt 与 messages 二选一;图片在 prompt 模式用 images,在 messages 模式放进 user 消息。 */ +export type ModelConversation = + | { + prompt: string; + messages?: never; + /** 最多 8 张;模型或其协议不支持图片时返回 LLM_IMAGE_INPUT_UNSUPPORTED 且不计费。 */ + images?: ImageInput[]; + } + | { messages: ChatMessage[]; prompt?: never; images?: never }; + +export type ModelInput = ModelConversation & { model?: string; system?: string; maxOutputTokens?: number; temperature?: number; - /** 最多 8 张;模型或其协议不支持图片时返回 LLM_IMAGE_INPUT_UNSUPPORTED 且不计费。 */ - images?: ImageInput[]; -} + /** 默认 off;模型不支持时按 off 处理。 */ + reasoning?: ReasoningEffort; +}; -export type ModelKind = 'text' | 'speech' | 'image' | 'decision'; +/** 工具由工作台自己执行:结果含 toolCalls 时执行后把 assistant 与 tool 消息追加到 messages 再调用。 */ +export type TextModelInput = ModelInput & { + tools?: ToolDefinition[]; + toolChoice?: ToolChoice; +}; + +export type ModelKind = 'text' | 'speech' | 'image' | 'decision' | 'embedding' | 'rerank'; export interface ModelCatalogEntry { id: string; @@ -57,7 +98,7 @@ export interface ModelCatalog { models: ModelCatalogEntry[]; /** 各种类在未指定 model 时使用的模型。 */ defaults: Partial>; - /** 平台媒体目录状态;unavailable 时语音、图片与决策列表可能不完整。 */ + /** 平台媒体目录状态;unavailable 时语音、图片、决策、向量与重排列表可能不完整。 */ media: 'available' | 'unavailable'; } @@ -127,6 +168,38 @@ export interface DecisionInput { questions: Record; } +export interface EmbedInput { + /** 必填:不同模型的向量不可混用,请连同向量一起保存模型 ID。 */ + model: string; + /** 最多 256 条,单条不超过 32000 字符。 */ + input: string | string[]; + dimensions?: number; +} + +export interface EmbedResult { + model: string; + /** 与 input 顺序一致。 */ + embeddings: number[][]; + usage?: ModelUsage; + requestId: string; +} + +export interface RerankInput { + model?: string; + query: string; + /** 最多 1000 篇,单篇不超过 32000 字符。 */ + documents: string[]; + topN?: number; +} + +export interface RerankResult { + model: string; + /** 按 relevanceScore 降序;index 指向 documents 中的位置。 */ + results: Array<{ index: number; relevanceScore: number }>; + usage?: ModelUsage; + requestId: string; +} + export interface DecisionResult { model: string; /** Jev 原样返回的回答,键与 questions 对应。 */ @@ -142,10 +215,15 @@ export interface ModelResult { finishReason: string; requestId: string; usage?: { inputTokens: number; outputTokens: number; totalTokens: number }; + /** finishReason 为 TOOL_CALLS 时出现;inputError 表示参数不符合工具 schema。 */ + toolCalls?: Array; + /** 可原样追加到 messages 的 assistant 消息,含推理续聊所需的加密 hostState。 */ + assistant: { role: 'assistant'; content: string; toolCalls?: ToolCall[]; hostState?: string }; } export type ModelEvent = | { type: 'delta'; text: string; model: string; requestId: string } + | { type: 'tool-call'; toolCall: ToolCall & { inputError?: string }; model: string; requestId: string } | ModelResult | { type: 'done'; requestId: string }; @@ -305,17 +383,19 @@ export interface HostClient { }>; }; llm: { - generateText(input: ModelInput, options?: CallOptions): Promise; + generateText(input: TextModelInput, options?: CallOptions): Promise; generateObject( input: ModelInput & { schema: Record }, options?: CallOptions, ): Promise; - streamText(input: ModelInput, options?: CallOptions): AsyncIterable; + streamText(input: TextModelInput, options?: CallOptions): AsyncIterable; listModels(input?: { kind?: ModelKind }): Promise; generateSpeech(input: SpeechInput, options?: CallOptions): Promise; /** 生成较慢,默认请求期限 330 秒。 */ generateImage(input: ImageGenerationInput, options?: CallOptions): Promise; decide(input: DecisionInput, options?: CallOptions): Promise; + embed(input: EmbedInput, options?: CallOptions): Promise; + rerank(input: RerankInput, options?: CallOptions): Promise; }; workflows: { start(input: WorkflowStart, options?: CallOptions): Promise<{ runId: string; idempotencyKey: string }>; diff --git a/sdk/host.mts b/sdk/host.mts index df9e838..e0d2851 100644 --- a/sdk/host.mts +++ b/sdk/host.mts @@ -11,6 +11,8 @@ import type { SpeechResult, ImageGenerationResult, DecisionResult, + EmbedResult, + RerankResult, WorkflowEvent, WorkflowRun, AgentTaskEvent, @@ -276,6 +278,8 @@ export function createHostClient( generateImage: (input, options = {}) => request('llm', 'generateImage', input, { timeoutMs: 330_000, ...options }), decide: (input, options) => request('llm', 'decide', input, options), + embed: (input, options) => request('llm', 'embed', input, options), + rerank: (input, options) => request('llm', 'rerank', input, options), }, workflows: { start: (input, options) => request('workflows', 'start', input, options), diff --git a/sdk/vectors.mts b/sdk/vectors.mts new file mode 100644 index 0000000..a9eefbd --- /dev/null +++ b/sdk/vectors.mts @@ -0,0 +1,51 @@ +// ABOUTME: 工作台本地向量检索工具:余弦相似度、TopK 与 SQLite BLOB 互转,向量数据始终留在 App 自己的数据库。 + +/** 两个等长向量的余弦相似度;任一为零向量时返回 0。 */ +export function cosineSimilarity(a: ArrayLike, b: ArrayLike): number { + if (a.length !== b.length) throw new RangeError('VECTOR_DIMENSION_MISMATCH'); + let dot = 0; + let normA = 0; + let normB = 0; + for (let index = 0; index < a.length; index++) { + const x = a[index]!; + const y = b[index]!; + dot += x * y; + normA += x * x; + normB += y * y; + } + return normA === 0 || normB === 0 ? 0 : dot / Math.sqrt(normA * normB); +} + +/** 对候选逐一计算相似度,返回得分最高的 k 项(降序)。适合万级以内的本地全量比对。 */ +export function topK( + query: ArrayLike, + rows: Iterable<{ vector: ArrayLike; item: T }>, + k: number, +): Array<{ item: T; score: number }> { + if (!Number.isInteger(k) || k < 1) throw new RangeError('VECTOR_TOP_K_INVALID'); + const best: Array<{ item: T; score: number }> = []; + for (const row of rows) { + const score = cosineSimilarity(query, row.vector); + if (best.length === k && score <= best[k - 1]!.score) continue; + let index = best.findIndex((entry) => score > entry.score); + if (index < 0) index = best.length; + best.splice(index, 0, { item: row.item, score }); + if (best.length > k) best.pop(); + } + return best; +} + +/** 以 Float32 小端序编码,便于存入 SQLite BLOB 列。 */ +export function vectorToBlob(vector: ArrayLike): Uint8Array { + const view = new DataView(new ArrayBuffer(vector.length * 4)); + for (let index = 0; index < vector.length; index++) view.setFloat32(index * 4, vector[index]!, true); + return new Uint8Array(view.buffer); +} + +export function vectorFromBlob(blob: Uint8Array): Float32Array { + if (blob.byteLength % 4 !== 0) throw new RangeError('VECTOR_BLOB_INVALID'); + const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength); + const vector = new Float32Array(blob.byteLength / 4); + for (let index = 0; index < vector.length; index++) vector[index] = view.getFloat32(index * 4, true); + return vector; +} diff --git a/tests/authoring.test.mjs b/tests/authoring.test.mjs index f489faa..c090e3a 100644 --- a/tests/authoring.test.mjs +++ b/tests/authoring.test.mjs @@ -197,6 +197,24 @@ test('类型错误和浏览器越界导入保持阻断', async (t) => { assert.ok(diagnostics.some((issue) => issue.code === 'WORKBENCH_BROWSER_SERVER_IMPORT')); }); +test('检查扫描服务端新 Host 操作写入 requires,只用基线操作时不写该字段', async (t) => { + const { root } = await fixture(t); + await checkApplication(root); + assert.equal(JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')).requires, undefined); + const entry = join(root, 'src/server/index.ts'); + await writeFile(entry, (await readFile(entry, 'utf8')).replace('await app.listen();', ` +app.route('POST', '/api/search', async ({ host }) => { + const { embed } = host.llm; + const vectors = await embed({ model: 'embed-test', input: ['a'] }); + const answer = await host.llm.generateText({ messages: [{ role: 'user', content: 'hi' }] }); + return { size: vectors.embeddings.length, text: answer.text }; +}); +await app.listen();`)); + const checked = await checkApplication(root); + assert.deepEqual(checked.requires, { operations: ['llm.embed', 'llm.messages'] }); + assert.deepEqual(JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8')).requires, checked.requires); +}); + test('独立无凭据 HTTP、平台未连接、Origin 校验、SQLite 持久化和退出清理', async (t) => { const { root } = await fixture(t); await writeFile(join(root, 'migrations/0002_test.sql'), 'CREATE TABLE preview_notes (text TEXT NOT NULL);'); diff --git a/tooling/build.mjs b/tooling/build.mjs index 3b3a244..c859440 100644 --- a/tooling/build.mjs +++ b/tooling/build.mjs @@ -30,6 +30,7 @@ export async function prepareTools() { ['compiler/runtime-artifact.ts', 'runtime-artifact.mjs'], ['compiler/publication-notices.ts', 'publication-notices.mjs'], ['contracts/workbenchManifest.ts', 'manifest.mjs'], + ['contracts/workbenchOperations.ts', 'operations.mjs'], ['transfer/WorkbenchTransferArchive.ts', 'archive.mjs'], ['transfer/WorkbenchAssetTransfer.ts', 'assets.mjs'], ['transfer/WorkbenchDataSnapshot.ts', 'data-snapshot.mjs'], @@ -74,6 +75,22 @@ async function checkSourceTree(root, path = '') { } } +/** 与 Floatboat 受管构建一致:超出基线的 Host 操作写入 manifest.requires,旧客户端据此提示升级。 */ +async function recordRequirements(root, manifest, detected) { + const { mergeWorkbenchRequirements } = await import('../.generated/operations.mjs'); + const operations = mergeWorkbenchRequirements(manifest.requires?.operations, detected); + if (JSON.stringify(operations) === JSON.stringify(manifest.requires?.operations)) return manifest; + const path = join(root, 'manifest.json'); + const raw = await readFile(path, 'utf8'); + const value = JSON.parse(raw); + if (operations) value.requires = { operations }; + else delete value.requires; + const { writeFile } = await import('node:fs/promises'); + await writeFile(path, `${JSON.stringify(value, null, /^\{\r?\n([ \t]+)"/u.exec(raw)?.[1] ?? ' ')}\n`); + const { requires: _previous, ...rest } = manifest; + return operations ? { ...rest, requires: { operations } } : rest; +} + export async function checkApplication(root = application, onDiagnostic = (issue) => console.error(`${issue.severity}: ${issue.path ?? ''}:${issue.line ?? ''} ${issue.code} ${issue.message}`), { publication } = {}) { const manifest = await readManifest(root); @@ -92,6 +109,6 @@ export async function checkApplication(root = application, onDiagnostic = (issue const report = JSON.parse(await readFile(reportPath, 'utf8')); for (const issue of report.diagnostics) onDiagnostic(issue); if (!report.passed) throw new Error('WORKBENCH_CHECK_FAILED'); - return manifest; + return await recordRequirements(root, manifest, report.operations ?? []); } finally { await rm(operation, { recursive: true, force: true }); } } diff --git a/tooling/compiler/host-operations.ts b/tooling/compiler/host-operations.ts new file mode 100644 index 0000000..f2bc704 --- /dev/null +++ b/tooling/compiler/host-operations.ts @@ -0,0 +1,125 @@ +// ABOUTME: 借助类型检查器识别工作台服务端实际使用的 Host SDK 方法与能力特性,供构建生成 requires.operations。 +// ABOUTME: 以 SDK 契约中 HostClient 的声明为准,别名、解构与变量转存都能识别;无法确定时偏向多记。 + +import type tsModule from 'typescript'; + +type TypeScript = typeof tsModule; + +const CONTRACTS = /(?:^|\/)sdk\/host-contracts(?:\.d)?\.mts$/u; +const TEXT_OPERATIONS = new Set(['llm.generateText', 'llm.streamText', 'llm.generateObject']); +/** 旧客户端严格校验会拒收的文本调用字段。 */ +const FEATURE_FIELDS: Readonly> = { + images: 'llm.images', + messages: 'llm.messages', + tools: 'llm.tools', + toolChoice: 'llm.tools', + reasoning: 'llm.reasoning', +}; + +/** 方法声明位于 HostClient 内时返回点分路径,如 agent.conversations.create。 */ +function operationPath(ts: TypeScript, declaration: tsModule.Declaration): string | undefined { + if (!ts.isMethodSignature(declaration)) return undefined; + if (!CONTRACTS.test(declaration.getSourceFile().fileName.replaceAll('\\', '/'))) return undefined; + const names: string[] = []; + let node: tsModule.Node = declaration; + while (node) { + if (ts.isMethodSignature(node) || ts.isPropertySignature(node)) { + if (!ts.isIdentifier(node.name)) return undefined; + names.unshift(node.name.text); + } else if (ts.isInterfaceDeclaration(node)) { + return node.name.text === 'HostClient' && names.length >= 2 ? names.join('.') : undefined; + } else if (!ts.isTypeLiteralNode(node)) return undefined; + node = node.parent; + } + return undefined; +} + +export function collectWorkbenchHostOperations( + ts: TypeScript, + program: tsModule.Program, + files: readonly tsModule.SourceFile[], +): string[] { + const checker = program.getTypeChecker(); + const found = new Set(); + + const fromSymbol = (symbol: tsModule.Symbol | undefined): string | undefined => { + let operation: string | undefined; + for (const declaration of symbol?.declarations ?? []) { + const path = operationPath(ts, declaration); + if (path) { + found.add(path); + operation ??= path; + } + } + return operation; + }; + + const bindingProperty = (element: tsModule.BindingElement): tsModule.Symbol | undefined => { + if (!ts.isObjectBindingPattern(element.parent)) return undefined; + const name = element.propertyName ?? element.name; + if (!ts.isIdentifier(name) && !ts.isStringLiteral(name)) return undefined; + return checker.getTypeAtLocation(element.parent).getProperty(name.text); + }; + + /** 解析调用目标;只跟随一层局部变量,覆盖 `const run = host.llm.generateText` 与解构写法。 */ + const resolveCallee = (expression: tsModule.Expression): string | undefined => { + if (ts.isPropertyAccessExpression(expression)) return fromSymbol(checker.getSymbolAtLocation(expression.name)); + if (ts.isElementAccessExpression(expression) && ts.isStringLiteralLike(expression.argumentExpression)) + return fromSymbol(checker.getSymbolAtLocation(expression.argumentExpression)); + if (!ts.isIdentifier(expression)) return undefined; + for (const declaration of checker.getSymbolAtLocation(expression)?.declarations ?? []) { + if (ts.isBindingElement(declaration)) return fromSymbol(bindingProperty(declaration)); + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + const initializer = declaration.initializer; + if (ts.isPropertyAccessExpression(initializer) || ts.isElementAccessExpression(initializer)) + return resolveCallee(initializer); + } + } + return undefined; + }; + + /** 非字面量参数按类型判断:字段类型不只是 undefined/never 即视为可能使用。 */ + const typeUses = (type: tsModule.Type, field: string, location: tsModule.Node): boolean => + (type.isUnion() ? type.types : [type]).some((member) => { + const property = member.getProperty(field); + if (!property) return false; + const propertyType = checker.getTypeOfSymbolAtLocation(property, location); + const parts = propertyType.isUnion() ? propertyType.types : [propertyType]; + return parts.some((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never))); + }); + + const recordFeatures = (argument: tsModule.Expression | undefined) => { + if (!argument) return; + if (ts.isObjectLiteralExpression(argument)) { + for (const property of argument.properties) { + if (ts.isSpreadAssignment(property)) { + const type = checker.getTypeAtLocation(property.expression); + for (const [field, feature] of Object.entries(FEATURE_FIELDS)) + if (typeUses(type, field, property)) found.add(feature); + continue; + } + const name = property.name; + if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) && Object.hasOwn(FEATURE_FIELDS, name.text)) + found.add(FEATURE_FIELDS[name.text]!); + } + return; + } + const type = checker.getTypeAtLocation(argument); + for (const [field, feature] of Object.entries(FEATURE_FIELDS)) + if (typeUses(type, field, argument)) found.add(feature); + }; + + const visit = (node: tsModule.Node): void => { + if (ts.isPropertyAccessExpression(node)) fromSymbol(checker.getSymbolAtLocation(node.name)); + else if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) + fromSymbol(checker.getSymbolAtLocation(node.argumentExpression)); + else if (ts.isBindingElement(node)) fromSymbol(bindingProperty(node)); + if (ts.isCallExpression(node)) { + const operation = resolveCallee(node.expression); + if (operation && TEXT_OPERATIONS.has(operation)) recordFeatures(node.arguments[0]); + } + ts.forEachChild(node, visit); + }; + for (const file of files) visit(file); + return [...found].sort(); +} diff --git a/tooling/compiler/typecheck.ts b/tooling/compiler/typecheck.ts index 3982a38..f606663 100644 --- a/tooling/compiler/typecheck.ts +++ b/tooling/compiler/typecheck.ts @@ -7,13 +7,16 @@ import type { WorkbenchManifest } from '../contracts/workbenchManifest'; import type { WorkbenchCompilationDiagnostic } from '../contracts/workbenchCompilation'; import { diagnostic, inside, typeDiagnostic } from './diagnostics'; import { checkModuleCycles } from './module-structure'; +import { collectWorkbenchHostOperations } from './host-operations'; const nodeModules = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); +/** operations 用于收集服务端源码实际调用的 Host SDK 操作;浏览器端不能访问 Host SDK,不参与扫描。 */ export function checkTypes( root: string, toolchain: string, manifest: WorkbenchManifest, + operations?: Set, ): WorkbenchCompilationDiagnostic[] { const configPath = join(root, 'tsconfig.json'); const config = ts.readConfigFile(configPath, ts.sys.readFile); @@ -76,6 +79,18 @@ export function checkTypes( const program = ts.createProgram(entryFiles, options, host); results.push(...ts.getPreEmitDiagnostics(program).map((issue) => typeDiagnostic(ts, issue, root))); results.push(...checkModuleCycles(program, root, options, host)); + if (kind === 'server' && operations) { + const appFiles = program + .getSourceFiles() + .filter( + (file) => + inside(root, file.fileName) && + !file.isDeclarationFile && + !file.fileName.includes('/node_modules/') && + !relative(root, file.fileName).replaceAll('\\', '/').startsWith('sdk/'), + ); + for (const operation of collectWorkbenchHostOperations(ts, program, appFiles)) operations.add(operation); + } for (const file of program.getSourceFiles()) { if (!inside(root, file.fileName) || file.isDeclarationFile || file.fileName.includes('/node_modules/')) continue; const path = relative(root, file.fileName).replaceAll('\\', '/'); diff --git a/tooling/compiler/worker.ts b/tooling/compiler/worker.ts index 438af82..3833d00 100644 --- a/tooling/compiler/worker.ts +++ b/tooling/compiler/worker.ts @@ -29,10 +29,11 @@ let result: WorkbenchCompilationResult = { diagnostics: [], }; let staticDirectory: string | false = false; +const operations = new Set(); try { const issues = [ ...checkPageEntry(root, input.manifest.build.staticAssets), - ...checkTypes(root, toolchain, input.manifest), + ...checkTypes(root, toolchain, input.manifest, operations), ].slice(0, 100); result = { ...result, diagnostics: issues }; if (!issues.some((issue) => issue.severity === 'error')) { @@ -145,6 +146,7 @@ try { }); result.passed = true; result.compilation = 'passed'; + result.operations = [...operations].sort().slice(0, 256); } } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/tooling/contracts/workbenchCompilation.ts b/tooling/contracts/workbenchCompilation.ts index 5c4c12a..c2761d0 100644 --- a/tooling/contracts/workbenchCompilation.ts +++ b/tooling/contracts/workbenchCompilation.ts @@ -22,6 +22,8 @@ export const workbenchCompilationResultSchema = z typecheck: z.enum(['passed', 'failed', 'not-applicable']), compilation: z.enum(['passed', 'failed', 'not-run']), diagnostics: z.array(workbenchCompilationDiagnosticSchema).max(100), + /** 服务端源码使用的 Host SDK 操作与能力特性;仅构建成功时给出。 */ + operations: z.array(z.string().max(160)).max(256).optional(), }) .strict(); diff --git a/tooling/contracts/workbenchManifest.ts b/tooling/contracts/workbenchManifest.ts index 6132648..d97a554 100644 --- a/tooling/contracts/workbenchManifest.ts +++ b/tooling/contracts/workbenchManifest.ts @@ -1,6 +1,7 @@ // ABOUTME: 工作台 manifest、安装记录与本地运行状态的跨进程契约。 import { z } from 'zod'; +import { workbenchRequirementNameSchema } from './workbenchOperations'; import { workbenchBrowserPolicySchema, workbenchStaticAssetSchema } from './workbenchBrowserPolicy'; import { workbenchServiceDeclarationSchema } from './workbenchConnections'; import { workbenchDependencySchema, workbenchResourceSchema, validateWorkbenchAssetDeclarations } from './workbenchAssets'; @@ -86,6 +87,20 @@ export const workbenchManifestSchema = z dependencies: z.array(workbenchDependencySchema).max(64).optional(), resources: z.array(workbenchResourceSchema).max(64).optional(), connectedServices: z.array(workbenchServiceDeclarationSchema).max(32).optional(), + /** + * 依赖的、超出基线的 Host SDK 操作与能力特性,由受管构建扫描生成,作者可手动补充。 + * 字段只在需要时出现:旧客户端的严格校验会拒收含该字段的 manifest。 + */ + requires: z + .object({ + operations: z + .array(workbenchRequirementNameSchema) + .min(1) + .max(128) + .refine((values) => new Set(values).size === values.length, '依赖操作不能重复'), + }) + .strict() + .optional(), contributions: z.array(workbenchContributionSchema).max(30).default([]), license: z.string().max(100).optional(), }) diff --git a/tooling/contracts/workbenchOperations.ts b/tooling/contracts/workbenchOperations.ts new file mode 100644 index 0000000..720a502 --- /dev/null +++ b/tooling/contracts/workbenchOperations.ts @@ -0,0 +1,117 @@ +// ABOUTME: 工作台依赖的 Host SDK 操作与能力特性清单,用于构建时生成 requires.operations 和安装前兼容校验。 +// ABOUTME: 清单与 SDK HostClient 接口由单测保持一致;基线是本机制上线前已发布客户端支持的全部操作。 + +import { z } from 'zod'; + +/** 已发布客户端(0.6.x)支持的 HostClient 方法;只用到这些的工作台不写 requires,保证旧客户端可安装。 */ +export const WORKBENCH_BASELINE_OPERATIONS = [ + 'workspaces.list', + 'workspaces.create', + 'workspaces.activate', + 'workspaces.openUrl', + 'agent.conversations.create', + 'agent.conversations.list', + 'agent.conversations.get', + 'agent.conversations.messages', + 'agent.conversations.send', + 'agent.start', + 'agent.get', + 'agent.list', + 'agent.findByIdempotencyKey', + 'agent.events', + 'agent.cancel', + 'asr.start', + 'asr.sendFrame', + 'asr.get', + 'asr.events', + 'asr.stop', + 'asr.cancel', + 'audio.probe', + 'audio.denoise', + 'audio.transcribeFile', + 'audio.get', + 'audio.events', + 'audio.cancel', + 'capabilities.list', + 'llm.generateText', + 'llm.generateObject', + 'llm.streamText', + 'workflows.start', + 'workflows.get', + 'workflows.list', + 'workflows.cancel', + 'workflows.findByIdempotencyKey', + 'workflows.events', + 'connectedServices.searchProviders', + 'connectedServices.getConnection', + 'connectedServices.getConnectionRequest', + 'connectedServices.cancelConnectionRequest', + 'connectedServices.connect', + 'connectedServices.search', + 'connectedServices.describe', + 'connectedServices.listConnections', + 'connectedServices.requestConnection', + 'connectedServices.call', + 'calendar.search', + 'calendar.get', + 'calendar.create', + 'calendar.update', + 'calendar.delete', + 'automation.preview', + 'automation.create', + 'automation.update', + 'automation.archive', + 'automation.list', + 'automation.get', + 'automation.runs', +] as const; + +/** 本客户端随包 SDK 的 HostClient 方法。 */ +export const WORKBENCH_SDK_OPERATIONS = [ + ...WORKBENCH_BASELINE_OPERATIONS, + 'llm.listModels', + 'llm.generateSpeech', + 'llm.generateImage', + 'llm.decide', + 'llm.embed', + 'llm.rerank', +] as const; + +/** + * 不新增方法、但旧客户端会因严格入参校验拒绝的能力特性。 + * 构建扫描在 llm 文本调用的参数中发现对应字段时记录。 + */ +export const WORKBENCH_SDK_FEATURES = ['llm.images', 'llm.messages', 'llm.tools', 'llm.reasoning'] as const; + +export const workbenchRequirementNameSchema = z + .string() + .regex(/^[a-z][a-zA-Z]{0,63}(?:\.[a-z][a-zA-Z0-9]{0,63}){1,3}$/u); + +const baseline = new Set(WORKBENCH_BASELINE_OPERATIONS); +const supported = new Set([...WORKBENCH_SDK_OPERATIONS, ...WORKBENCH_SDK_FEATURES]); + +export function isSupportedWorkbenchRequirement(name: string): boolean { + return supported.has(name); +} + +/** + * 合并已声明与本次扫描到的依赖:保留作者手写的项(只增不减,删除需作者手动改 manifest), + * 去掉基线内的操作;返回 undefined 表示 manifest 不应出现 requires 字段。 + */ +export function mergeWorkbenchRequirements( + declared: readonly string[] | undefined, + detected: readonly string[], +): string[] | undefined { + const operations = [...new Set([...(declared ?? []), ...detected])].filter((name) => !baseline.has(name)).sort(); + return operations.length > 0 ? operations : undefined; +} + +export function missingWorkbenchRequirements(manifest: { requires?: { operations: readonly string[] } }): string[] { + return (manifest.requires?.operations ?? []).filter((name) => !supported.has(name)); +} + +/** 导入、商店安装、更新、构建与启动前调用:缺少声明的操作时要求升级客户端,错误信息附带缺失项。 */ +export function assertWorkbenchRequirementsSupported(manifest: { requires?: { operations: readonly string[] } }): void { + const missing = missingWorkbenchRequirements(manifest); + if (missing.length > 0) throw new Error(`WORKBENCH_HOST_UPGRADE_REQUIRED:${missing.join(',')}`); +}