Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/floatboat-workbench/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
37 changes: 32 additions & 5 deletions .agents/skills/floatboat-workbench/references/llm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<model>` 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

Expand All @@ -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_<status>`.
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_<status>`. 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.
98 changes: 89 additions & 9 deletions sdk/host-contracts.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/** 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<string, unknown>;
}

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;
Expand All @@ -57,7 +98,7 @@ export interface ModelCatalog {
models: ModelCatalogEntry[];
/** 各种类在未指定 model 时使用的模型。 */
defaults: Partial<Record<ModelKind, string>>;
/** 平台媒体目录状态;unavailable 时语音、图片与决策列表可能不完整。 */
/** 平台媒体目录状态;unavailable 时语音、图片、决策、向量与重排列表可能不完整。 */
media: 'available' | 'unavailable';
}

Expand Down Expand Up @@ -127,6 +168,38 @@ export interface DecisionInput {
questions: Record<string, DecisionQuestion>;
}

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 对应。 */
Expand All @@ -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<ToolCall & { inputError?: string }>;
/** 可原样追加到 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 };

Expand Down Expand Up @@ -305,17 +383,19 @@ export interface HostClient {
}>;
};
llm: {
generateText(input: ModelInput, options?: CallOptions): Promise<ModelResult>;
generateText(input: TextModelInput, options?: CallOptions): Promise<ModelResult>;
generateObject(
input: ModelInput & { schema: Record<string, unknown> },
options?: CallOptions,
): Promise<ModelResult & { object: unknown }>;
streamText(input: ModelInput, options?: CallOptions): AsyncIterable<ModelEvent>;
streamText(input: TextModelInput, options?: CallOptions): AsyncIterable<ModelEvent>;
listModels(input?: { kind?: ModelKind }): Promise<ModelCatalog>;
generateSpeech(input: SpeechInput, options?: CallOptions): Promise<SpeechResult>;
/** 生成较慢,默认请求期限 330 秒。 */
generateImage(input: ImageGenerationInput, options?: CallOptions): Promise<ImageGenerationResult>;
decide(input: DecisionInput, options?: CallOptions): Promise<DecisionResult>;
embed(input: EmbedInput, options?: CallOptions): Promise<EmbedResult>;
rerank(input: RerankInput, options?: CallOptions): Promise<RerankResult>;
};
workflows: {
start(input: WorkflowStart, options?: CallOptions): Promise<{ runId: string; idempotencyKey: string }>;
Expand Down
4 changes: 4 additions & 0 deletions sdk/host.mts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
SpeechResult,
ImageGenerationResult,
DecisionResult,
EmbedResult,
RerankResult,
WorkflowEvent,
WorkflowRun,
AgentTaskEvent,
Expand Down Expand Up @@ -276,6 +278,8 @@ export function createHostClient(
generateImage: (input, options = {}) =>
request<ImageGenerationResult>('llm', 'generateImage', input, { timeoutMs: 330_000, ...options }),
decide: (input, options) => request<DecisionResult>('llm', 'decide', input, options),
embed: (input, options) => request<EmbedResult>('llm', 'embed', input, options),
rerank: (input, options) => request<RerankResult>('llm', 'rerank', input, options),
},
workflows: {
start: (input, options) => request('workflows', 'start', input, options),
Expand Down
51 changes: 51 additions & 0 deletions sdk/vectors.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// ABOUTME: 工作台本地向量检索工具:余弦相似度、TopK 与 SQLite BLOB 互转,向量数据始终留在 App 自己的数据库。

/** 两个等长向量的余弦相似度;任一为零向量时返回 0。 */
export function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): 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<T>(
query: ArrayLike<number>,
rows: Iterable<{ vector: ArrayLike<number>; 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<number>): 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;
}
18 changes: 18 additions & 0 deletions tests/authoring.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);');
Expand Down
Loading
Loading