Add bots files handling logic
This commit is contained in:
@@ -17,6 +17,7 @@ type Params = {
|
||||
* audio: free ones often drop it)
|
||||
* - speech: a text-to-speech model
|
||||
* - image: makes images
|
||||
* - embedding: turns text into vectors (never a chat model)
|
||||
*/
|
||||
export default function modelServesHarness({ model, harness }: Params) {
|
||||
const input = new Set(model.input_modalities);
|
||||
@@ -39,5 +40,8 @@ export default function modelServesHarness({ model, harness }: Params) {
|
||||
|
||||
case "image":
|
||||
return output.has("image");
|
||||
|
||||
case "embedding":
|
||||
return output.has("embeddings");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type Params<T extends HarnessCatalogModel> = {
|
||||
*
|
||||
* Within a group, benchmarked models come first by intelligence index,
|
||||
* then the rest: full-size before small variants, newest first. None for
|
||||
* a harness that isn't `automatic` (transcription, speech).
|
||||
* a harness that isn't `automatic` (transcription, speech, embeddings).
|
||||
*/
|
||||
export default function rankAutoModels<T extends HarnessCatalogModel>({
|
||||
models,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
import fitVectorDimensions from "./fit-vector-dimensions";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The texts to embed, in order. Empty strings are embedded as they
|
||||
* are (a provider may refuse them; callers skip empty chunks).
|
||||
*/
|
||||
input: string[];
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Uses this bot's embedding model when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
/**
|
||||
* The workspace the call is in: its models win over the bot's.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type EmbeddingResult = {
|
||||
/**
|
||||
* One vector per input, `AppData.EmbeddingDimensions` wide, normalized.
|
||||
*/
|
||||
vectors: Float32Array[];
|
||||
/**
|
||||
* The width the model actually returned, before fitting. Recorded so a
|
||||
* change of model is visible.
|
||||
*/
|
||||
model_dimensions: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Embeds texts with the embedding harness, fitted to the vector table's
|
||||
* fixed width. Throws `HarnessError` when no embedding model is
|
||||
* configured or every candidate failed; callers fall back to text
|
||||
* matching then.
|
||||
*/
|
||||
export default async function embedText({
|
||||
input,
|
||||
signal,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<EmbeddingResult> {
|
||||
if (!input.length) return { vectors: [], model_dimensions: 0 };
|
||||
|
||||
const candidates = await resolveHarnessCandidates({
|
||||
harness: "embedding",
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "embedding",
|
||||
candidates,
|
||||
run: async (candidate) =>
|
||||
await grabHarnessAdapter({ kind: candidate.provider.kind }).embed({
|
||||
candidate,
|
||||
input,
|
||||
signal: signal || new AbortController().signal,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
vectors: result.vectors.map((vector) => fitVectorDimensions({ vector })),
|
||||
model_dimensions: result.vectors[0]?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same for one text, for a search query.
|
||||
*/
|
||||
export async function embedQuery({
|
||||
text,
|
||||
signal,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Omit<Params, "input"> & { text: string }) {
|
||||
const { vectors } = await embedText({ input: [text], signal, bot_id, workspace_id });
|
||||
|
||||
return vectors[0] as Float32Array;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
vector: number[];
|
||||
/**
|
||||
* Defaults to `AppData.EmbeddingDimensions`, the width of the fixed
|
||||
* `bot_file_vectors` table.
|
||||
*/
|
||||
dimensions?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a provider's vector the width the vector table takes: cut when
|
||||
* it's wider, padded with zeros when narrower, then normalized to unit
|
||||
* length so cosine distance means the same for every model.
|
||||
*/
|
||||
export default function fitVectorDimensions({
|
||||
vector,
|
||||
dimensions = AppData.EmbeddingDimensions,
|
||||
}: Params): Float32Array<ArrayBuffer> {
|
||||
const out = new Float32Array(new ArrayBuffer(dimensions * 4));
|
||||
const length = Math.min(vector.length, dimensions);
|
||||
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const value = Number(vector[i]) || 0;
|
||||
|
||||
out[i] = value;
|
||||
sum += value * value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero vector can't be normalized; leave it as zeros (it then
|
||||
* matches nothing).
|
||||
*/
|
||||
const norm = Math.sqrt(sum);
|
||||
|
||||
if (norm > 0) {
|
||||
for (let i = 0; i < dimensions; i++) {
|
||||
out[i] = out[i]! / norm;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import HarnessError from "../../harness-error";
|
||||
import type { HarnessAdapter } from "../../types";
|
||||
import grabProviderPreset from "../grab-provider-preset";
|
||||
import providerRequest from "./provider-request";
|
||||
|
||||
/**
|
||||
* Embeddings at the provider's `embeddings_path` (the OpenAI `/embeddings`
|
||||
* shape). Batched, since a large file's chunks would otherwise go in one
|
||||
* request the provider may refuse.
|
||||
*/
|
||||
const embed: HarnessAdapter["embed"] = async ({ candidate, input, signal }) => {
|
||||
const { provider, model } = candidate;
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
const vectors: number[][] = [];
|
||||
|
||||
/**
|
||||
* Every OpenAI-compatible API puts embeddings at `/embeddings`.
|
||||
*/
|
||||
const path = "/embeddings";
|
||||
|
||||
for (let start = 0; start < input.length; start += AppData.EmbeddingBatchSize) {
|
||||
const batch = input
|
||||
.slice(start, start + AppData.EmbeddingBatchSize)
|
||||
.map((text) => text.slice(0, AppData.EmbeddingMaxInputChars));
|
||||
|
||||
const res = await providerRequest({
|
||||
provider,
|
||||
path,
|
||||
json: { model: model.slug, input: batch },
|
||||
signal,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
const items: any[] = Array.isArray(json?.data)
|
||||
? json.data
|
||||
: Array.isArray(json?.embeddings)
|
||||
? json.embeddings
|
||||
: [];
|
||||
|
||||
if (items.length != batch.length) {
|
||||
throw new HarnessError({
|
||||
message: `${provider.name || preset.title}: ${items.length} of ${batch.length} texts got a vector.`,
|
||||
retryable: true,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const vector = Array.isArray(item) ? item : item?.embedding;
|
||||
|
||||
if (!Array.isArray(vector) || !vector.length) {
|
||||
throw new HarnessError({
|
||||
message: `${provider.name || preset.title} sent no embedding back.`,
|
||||
retryable: true,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
vectors.push(vector.map(Number));
|
||||
}
|
||||
}
|
||||
|
||||
return { vectors };
|
||||
};
|
||||
|
||||
export default embed;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HarnessAdapter } from "../../types";
|
||||
import chat from "./chat";
|
||||
import embed from "./embed";
|
||||
import generateImage from "./generate-image";
|
||||
import listModels from "./list-models";
|
||||
import speak from "./speak";
|
||||
@@ -15,6 +16,7 @@ const OpenAICompatibleAdapter: HarnessAdapter = {
|
||||
transcribe,
|
||||
speak,
|
||||
generateImage,
|
||||
embed,
|
||||
listModels,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import copyBotFile from "@/src/functions/backend/db/bot-files/copy-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { fileRef, findNamedScope, type StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
file_id: number;
|
||||
scope: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies a file into another of the bot's directories: the coordinator
|
||||
* bringing a worker's file into its own or into a workspace, or the other
|
||||
* way round. Only available when the run reaches more than one.
|
||||
*/
|
||||
export default function copyFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "copy_file",
|
||||
description: `Copy one of your files into another directory, by its ref id: ${context.scopes
|
||||
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||
.join(", ")}. The copy is separate: deleting one leaves the other.`,
|
||||
schema: z.object({
|
||||
file_id: z.number().int().positive(),
|
||||
scope: z.string().describe(`Where the copy goes, e.g. "bot:2" or "workspace:1"`),
|
||||
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const target = findNamedScope({ scopes: context.scopes, name: args.scope });
|
||||
|
||||
if (!target) {
|
||||
return `You can't write there. Your directories: ${context.scopes
|
||||
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||
.join(", ")}.`;
|
||||
}
|
||||
|
||||
const file = await copyBotFile({
|
||||
scopes: context.scopes,
|
||||
file_id: args.file_id,
|
||||
bot_id: context.bot_id,
|
||||
scope: target,
|
||||
path: args.path,
|
||||
user_id: context.user_id,
|
||||
});
|
||||
|
||||
return `Copied to ${target.scope} ${target.owner_id} as ${file.path}. ${fileRef({ file_id: file.id! })}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import deleteBotFile from "@/src/functions/backend/db/bot-files/delete-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import type { StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
file_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a file the bot saved, with its searchable text. Its bytes go for
|
||||
* good, so a file the user may still want is moved, not deleted.
|
||||
*/
|
||||
export default function deleteFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "delete_file",
|
||||
description: `Delete one of your files for good, by its ref id. Use it to clear space, never for something the user still needs.`,
|
||||
schema: z.object({
|
||||
file_id: z.number().int().positive(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const file = await deleteBotFile({ scopes: context.scopes, file_id: args.file_id });
|
||||
|
||||
return `${file.path} is deleted.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod";
|
||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||
import generateImage from "@/src/harness/image/generate-image";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import {
|
||||
describeScope,
|
||||
fileRef,
|
||||
findNamedScope,
|
||||
formatBytes,
|
||||
type StorageToolContext,
|
||||
} from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
prompt: string;
|
||||
path?: string;
|
||||
size?: string;
|
||||
attach?: boolean;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes an image with the image generation harness and saves it in the
|
||||
* bot's files, so it can be sent, shown again later or used by another
|
||||
* run. The bytes stay in the app's storage, never on a computer.
|
||||
*/
|
||||
export default function generateImageTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
const own = context.scopes[0];
|
||||
|
||||
return {
|
||||
name: "generate_image",
|
||||
description: `Make an image from a description and save it in your files. Describe what should be in it, in one clear prompt. The image is saved (so you can send it now and show it later) and can ride on this reply with "attach".`,
|
||||
schema: z.object({
|
||||
prompt: z.string().min(3).max(4000),
|
||||
path: z
|
||||
.string()
|
||||
.max(300)
|
||||
.optional()
|
||||
.describe(`Where to save it, e.g. "images/cover.png"; one is named for you otherwise`),
|
||||
size: z
|
||||
.string()
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe(`e.g. "1024x1024", when the model takes one`),
|
||||
attach: z.boolean().optional().describe(`Send it to the user with this reply`),
|
||||
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
|
||||
}),
|
||||
run: async ({ args, signal }) => {
|
||||
const named = args.scope
|
||||
? findNamedScope({ scopes: context.scopes, name: args.scope })
|
||||
: own;
|
||||
|
||||
if (!named) {
|
||||
return `You can't save there. Your directories: ${context.scopes
|
||||
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||
.join(", ")}.`;
|
||||
}
|
||||
|
||||
const images = await generateImage({
|
||||
prompt: args.prompt,
|
||||
size: args.size,
|
||||
signal,
|
||||
bot_id: context.bot_id,
|
||||
workspace_id: context.workspace_id,
|
||||
}).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Image generation isn't working: ${message}`);
|
||||
});
|
||||
|
||||
if (!images.length) {
|
||||
return `The image model sent nothing back. Tell the user, and try a simpler prompt.`;
|
||||
}
|
||||
|
||||
const saved: string[] = [];
|
||||
|
||||
for (let index = 0; index < images.length; index++) {
|
||||
const image = images[index]!;
|
||||
const ext = image.media_type.includes("png")
|
||||
? "png"
|
||||
: image.media_type.includes("webp")
|
||||
? "webp"
|
||||
: "jpg";
|
||||
const name =
|
||||
args.path && images.length == 1
|
||||
? args.path
|
||||
: `${stripExtension({ path: args.path || "image" })}${images.length > 1 ? `-${index + 1}` : ""}.${ext}`;
|
||||
|
||||
const file = await recordBotFile({
|
||||
bot_id: context.bot_id,
|
||||
scope: named.scope,
|
||||
owner_id: named.owner_id,
|
||||
path: name,
|
||||
data: image.data,
|
||||
purpose: "image",
|
||||
source: "tool",
|
||||
media_type: "image",
|
||||
mime_type: image.media_type,
|
||||
user_id: context.user_id,
|
||||
});
|
||||
|
||||
await enqueueFileIngest({ file_id: file.id! });
|
||||
|
||||
if (args.attach && file.media_id) {
|
||||
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||
}
|
||||
|
||||
saved.push(
|
||||
`${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) ${fileRef({ file_id: file.id! })}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `Made and saved ${saved.length} image(s) in ${describeScope(named)}:\n${saved.join("\n")}${
|
||||
args.attach ? "" : "\n\nIt isn't attached to this reply; the user sees it in your files."
|
||||
}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripExtension({ path }: { path: string }) {
|
||||
return path.replace(/\.[a-zA-Z0-9]{1,5}$/, "");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import generateImageTool from "./generate-image-tool";
|
||||
import grabStorageTools from "./grab-storage-tools";
|
||||
import makePdfTool from "./make-pdf-tool";
|
||||
import storageToolContext from "./storage-tool-context";
|
||||
|
||||
type Params = {
|
||||
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
|
||||
user_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
session_id?: number | string;
|
||||
/**
|
||||
* Called for a file the bot wrote with "attach" set, so the reply
|
||||
* carries it.
|
||||
*/
|
||||
onAttach?: (params: { media_id: number; name: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tools a bot uses to keep and search files (`src/harness/tools/
|
||||
* storage/`): listing, reading, writing, moving, deleting and searching
|
||||
* its own directory and, in a workspace or as the coordinator, the shared
|
||||
* ones, plus making images and PDFs into them. Every run gets them, so a
|
||||
* chat reply, a task and a scheduled run all work with the same files.
|
||||
*/
|
||||
export default async function grabBotFileTools({
|
||||
bot,
|
||||
user_id,
|
||||
workspace_id,
|
||||
session_id,
|
||||
onAttach,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const all_workspace_ids =
|
||||
bot.kind == "coordinator"
|
||||
? (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived).map((w) => Number(w.id))
|
||||
: [];
|
||||
|
||||
const context = storageToolContext({
|
||||
bot,
|
||||
user_id,
|
||||
workspace_id,
|
||||
session_id,
|
||||
all_workspace_ids,
|
||||
onAttach,
|
||||
});
|
||||
|
||||
return [...grabStorageTools(context), generateImageTool(context), makePdfTool(context)];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { HarnessTool } from "../../types";
|
||||
import copyFileTool from "./copy-file-tool";
|
||||
import deleteFileTool from "./delete-file-tool";
|
||||
import ingestFileTool from "./ingest-file-tool";
|
||||
import listFilesTool from "./list-files-tool";
|
||||
import moveFileTool from "./move-file-tool";
|
||||
import readFileTool from "./read-file-tool";
|
||||
import searchFilesTool from "./search-files-tool";
|
||||
import type { StorageToolContext } from "./storage-tool-context";
|
||||
import writeFileTool from "./write-file-tool";
|
||||
|
||||
/**
|
||||
* The file tools a bot works with on every run, bound to its own
|
||||
* directories (and, for the coordinator, every bot's and workspace's).
|
||||
* `copy_file` only shows when the run reaches more than one directory.
|
||||
*/
|
||||
export default function grabStorageTools(context: StorageToolContext): HarnessTool[] {
|
||||
return [
|
||||
listFilesTool(context),
|
||||
readFileTool(context),
|
||||
writeFileTool(context),
|
||||
searchFilesTool(context),
|
||||
moveFileTool(context),
|
||||
deleteFileTool(context),
|
||||
ingestFileTool(context),
|
||||
...(context.scopes.length > 1 ? [copyFileTool(context)] : []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import ingestBotFile from "@/src/functions/backend/db/bot-files/ingest-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import type { StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
file_id: number;
|
||||
read_media?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ingests a file again: reads its text, chunks it and embeds it, so a
|
||||
* changed or previously failed file is searchable. Images and audio are
|
||||
* only read when asked for, since that costs a model call.
|
||||
*/
|
||||
export default function ingestFileTool(_context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "ingest_file",
|
||||
description: `Make one of your files searchable again (a course correction when search misses something you know is in it), by its ref id. Images and scans are only read when "read_media" is true.`,
|
||||
schema: z.object({
|
||||
file_id: z.number().int().positive(),
|
||||
read_media: z.boolean().optional(),
|
||||
}),
|
||||
run: async ({ args, signal }) => {
|
||||
const result = await ingestBotFile({
|
||||
file_id: args.file_id,
|
||||
read_media: args.read_media,
|
||||
signal,
|
||||
});
|
||||
|
||||
switch (result.status) {
|
||||
case "ingested":
|
||||
return `Ingested: ${result.chunks} passage(s), searchable now.`;
|
||||
case "unavailable":
|
||||
return `Its text is stored and searchable by words, but no embedding model is set, so meaning search is off.`;
|
||||
case "skipped":
|
||||
return `It's too large to read (over ${Math.round(AppData.IngestMaxBytes / (1024 * 1024))} MB).`;
|
||||
default:
|
||||
return `It has no text to read.`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import listBotFiles from "@/src/functions/backend/db/bot-files/list-bot-files";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { fileRef, formatBytes, type StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
folder?: string;
|
||||
search?: string;
|
||||
recursive?: boolean;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists a bot's files: one folder's, or everything under it, or whatever
|
||||
* matches a name. Each file comes with its ref so the bot can read it.
|
||||
*/
|
||||
export default function listFilesTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "list_files",
|
||||
description: `List the files you have saved, newest first: one folder's with "folder", everything with "recursive", or the ones whose name or path has "search". Each line has its path, size and ref (e.g. [file:12]) to read it.`,
|
||||
schema: z.object({
|
||||
folder: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||
search: z.string().max(120).optional(),
|
||||
recursive: z.boolean().optional(),
|
||||
scope: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(`"bot:<id>" or "workspace:<id>", to look in one of your directories only`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const folder = args.folder?.trim() || "";
|
||||
|
||||
const files = await listBotFiles({
|
||||
scopes: context.scopes,
|
||||
folder,
|
||||
recursive: args.recursive,
|
||||
search: args.search,
|
||||
});
|
||||
|
||||
if (!files.length) {
|
||||
return folder
|
||||
? `Nothing in ${folder}.`
|
||||
: `You have no files yet. Write one with write_file.`;
|
||||
}
|
||||
|
||||
const lines = files.map((file) => {
|
||||
const where = file.scope == "workspace" ? ` (workspace ${file.owner_id})` : "";
|
||||
const ingested = file.ingested_at ? "" : " - not searchable yet";
|
||||
|
||||
return `- ${file.path} - ${formatBytes({ bytes: Number(file.size) || 0 })}${where}${ingested} ${fileRef({ file_id: file.id! })}`;
|
||||
});
|
||||
|
||||
const more =
|
||||
files.length >= AppData.BotFileListMaxItems
|
||||
? `\n(Showing the first ${AppData.BotFileListMaxItems}. Narrow it with "folder" or "search".)`
|
||||
: "";
|
||||
|
||||
return `${files.length} file(s):\n${lines.join("\n")}${more}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { z } from "zod";
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import {
|
||||
describeScope,
|
||||
fileRef,
|
||||
findNamedScope,
|
||||
formatBytes,
|
||||
type StorageToolContext,
|
||||
} from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
path: string;
|
||||
title?: string;
|
||||
content: string;
|
||||
attach?: boolean;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const PageWidth = 595.28;
|
||||
const PageHeight = 841.89;
|
||||
const Margin = 56;
|
||||
const BodySize = 11;
|
||||
const Leading = 15;
|
||||
|
||||
/**
|
||||
* Turns text into a PDF (headings, paragraphs and bullets) and saves it in
|
||||
* the bot's files. Made in the app with `pdf-lib`, so it needs no
|
||||
* computer.
|
||||
*/
|
||||
export default function makePdfTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
const own = context.scopes[0];
|
||||
|
||||
return {
|
||||
name: "make_pdf",
|
||||
description: `Make a PDF from text (markdown: "# " headings, "- " bullets, blank lines between paragraphs) and save it in your files. Send it to the user with "attach".`,
|
||||
schema: z.object({
|
||||
path: z.string().min(1).max(300).describe(`e.g. "reports/summary.pdf"`),
|
||||
title: z.string().max(200).optional(),
|
||||
content: z.string().min(1).max(100_000),
|
||||
attach: z.boolean().optional(),
|
||||
scope: z.string().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const named = args.scope
|
||||
? findNamedScope({ scopes: context.scopes, name: args.scope })
|
||||
: own;
|
||||
|
||||
if (!named) {
|
||||
return `You can't save there. Your directories: ${context.scopes
|
||||
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||
.join(", ")}.`;
|
||||
}
|
||||
|
||||
const bytes = await renderPdf({ title: args.title, content: args.content });
|
||||
const path = args.path.toLowerCase().endsWith(".pdf") ? args.path : `${args.path}.pdf`;
|
||||
|
||||
const file = await recordBotFile({
|
||||
bot_id: context.bot_id,
|
||||
scope: named.scope,
|
||||
owner_id: named.owner_id,
|
||||
path,
|
||||
data: bytes,
|
||||
purpose: "pdf",
|
||||
source: "tool",
|
||||
mime_type: "application/pdf",
|
||||
user_id: context.user_id,
|
||||
});
|
||||
|
||||
await enqueueFileIngest({ file_id: file.id! });
|
||||
|
||||
if (args.attach && file.media_id) {
|
||||
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||
}
|
||||
|
||||
return `Made ${file.path} (${formatBytes({ bytes: bytes.byteLength })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the text out on A4 pages. Fonts are the PDF standard ones, so
|
||||
* nothing has to be embedded.
|
||||
*/
|
||||
async function renderPdf({ title, content }: { title?: string; content: string }) {
|
||||
const pdf = await PDFDocument.create();
|
||||
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
let page = pdf.addPage([PageWidth, PageHeight]);
|
||||
let y = PageHeight - Margin;
|
||||
|
||||
const newPage = () => {
|
||||
page = pdf.addPage([PageWidth, PageHeight]);
|
||||
y = PageHeight - Margin;
|
||||
};
|
||||
|
||||
const write = ({
|
||||
text,
|
||||
size,
|
||||
use_bold,
|
||||
indent = 0,
|
||||
spacing = 0,
|
||||
}: {
|
||||
text: string;
|
||||
size: number;
|
||||
use_bold?: boolean;
|
||||
indent?: number;
|
||||
spacing?: number;
|
||||
}) => {
|
||||
const used = use_bold ? bold : font;
|
||||
const width = PageWidth - Margin * 2 - indent;
|
||||
const words = text.split(/\s+/);
|
||||
let line = "";
|
||||
|
||||
const emit = (value: string) => {
|
||||
if (y - size < Margin) newPage();
|
||||
|
||||
page.drawText(value, {
|
||||
x: Margin + indent,
|
||||
y: y - size,
|
||||
size,
|
||||
font: used,
|
||||
color: rgb(0.09, 0.09, 0.11),
|
||||
});
|
||||
|
||||
y -= size + 4;
|
||||
};
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = line ? `${line} ${word}` : word;
|
||||
|
||||
if (used.widthOfTextAtSize(candidate, size) > width && line) {
|
||||
emit(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (line) emit(line);
|
||||
|
||||
y -= spacing;
|
||||
};
|
||||
|
||||
if (title) {
|
||||
write({ text: title, size: 20, use_bold: true, spacing: 10 });
|
||||
}
|
||||
|
||||
for (const block of content.replace(/\r\n/g, "\n").split(/\n{2,}/)) {
|
||||
const trimmed = block.trim();
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
|
||||
if (heading) {
|
||||
const level = heading[1]!.length;
|
||||
|
||||
write({
|
||||
text: heading[2]!,
|
||||
size: level == 1 ? 16 : level == 2 ? 14 : 12,
|
||||
use_bold: true,
|
||||
spacing: 8,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const line of trimmed.split("\n")) {
|
||||
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
||||
|
||||
if (bullet) {
|
||||
write({ text: `- ${bullet[1]}`, size: BodySize, indent: 12 });
|
||||
} else {
|
||||
write({ text: line.trim(), size: BodySize });
|
||||
}
|
||||
}
|
||||
|
||||
y -= Leading / 2;
|
||||
}
|
||||
|
||||
return new Uint8Array(await pdf.save());
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import moveBotFile from "@/src/functions/backend/db/bot-files/move-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
file_id: number;
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renames or moves a file into another folder, inside its own directory.
|
||||
*/
|
||||
export default function moveFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "move_file",
|
||||
description: `Rename a file or move it into another folder, by its ref id. The new path stays inside the same directory (yours, or the workspace's).`,
|
||||
schema: z.object({
|
||||
file_id: z.number().int().positive(),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AppData.BotFilePathMaxChars)
|
||||
.describe(`The new path, e.g. "archive/q3.md"`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const file = await moveBotFile({
|
||||
scopes: context.scopes,
|
||||
file_id: args.file_id,
|
||||
path: args.path,
|
||||
});
|
||||
|
||||
return `Moved to ${file.path}. ${fileRef({ file_id: file.id! })}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabBotFile from "@/src/functions/backend/db/bot-files/grab-bot-file";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
import readMedia from "@/src/harness/media/read-media";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
file_id?: number;
|
||||
path?: string;
|
||||
offset?: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
const TextExtensions = [
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"csv",
|
||||
"json",
|
||||
"html",
|
||||
"htm",
|
||||
"xml",
|
||||
"yml",
|
||||
"yaml",
|
||||
"log",
|
||||
"tsv",
|
||||
];
|
||||
|
||||
/**
|
||||
* Reads one of the bot's files: text as it is (a window at a time when it
|
||||
* is long), an image or audio through the media harness, and a PDF or DOCX
|
||||
* through the text extractor.
|
||||
*/
|
||||
export default function readFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "read_file",
|
||||
description: `Read a file you saved, by its ref id or its path. Text comes back in a window of about ${Math.round(AppData.BotFileReadMaxChars / 1000)}k characters; use "offset" for the next part. Images and audio are read for you, and PDFs and Word documents are turned into text.`,
|
||||
schema: z.object({
|
||||
file_id: z.number().int().positive().optional(),
|
||||
path: z.string().max(AppData.BotFilePathMaxChars).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>", with "path"`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const [scope_name, owner] = (args.scope || "").split(":");
|
||||
|
||||
const file = await grabBotFile({
|
||||
scopes: context.scopes,
|
||||
file_id: args.file_id,
|
||||
scope: scope_name,
|
||||
owner_id: owner,
|
||||
path: args.path,
|
||||
});
|
||||
|
||||
if (!file?.id || !file.media?.media_write_path) {
|
||||
return `No file matches that. Use list_files to see what you have.`;
|
||||
}
|
||||
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
const absolute = `${DATA_DIR}/${file.media.media_write_path}`;
|
||||
const ext = (file.name || "").split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
if (TextExtensions.includes(ext)) {
|
||||
const text = await Bun.file(absolute).text();
|
||||
const offset = args.offset || 0;
|
||||
const window = text.slice(offset, offset + AppData.BotFileReadMaxChars);
|
||||
const end = offset + window.length;
|
||||
|
||||
return [
|
||||
`${file.path} ${fileRef({ file_id: file.id })}`,
|
||||
window,
|
||||
end < text.length
|
||||
? `\n[Read ${offset}-${end} of ${text.length} characters. Call read_file with offset ${end} for the rest.]`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await Bun.file(absolute).arrayBuffer());
|
||||
|
||||
const text = await readMedia({
|
||||
data: bytes,
|
||||
media_type: file.mime_type || "application/octet-stream",
|
||||
name: file.name || undefined,
|
||||
bot_id: context.bot_id,
|
||||
workspace_id: context.workspace_id,
|
||||
});
|
||||
|
||||
return `${file.path} ${fileRef({ file_id: file.id })}\n\n${text.slice(0, AppData.BotFileReadMaxChars)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import searchBotFiles from "@/src/functions/backend/db/bot-files/search-bot-files";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import { fileRef, type StorageToolContext } from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
query: string;
|
||||
file_id?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches the bot's saved files by meaning (through the embedding
|
||||
* vectors), falling back to words, and returns the passages with their
|
||||
* file's ref so the bot can read more.
|
||||
*/
|
||||
export default function searchFilesTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
return {
|
||||
name: "search_files",
|
||||
description: `Search the files you have saved (documents the user gave you and what you wrote) and get the passages that answer "query", with their file's ref. Search before saying you don't know something that may be in a file, and before writing a document from memory.`,
|
||||
schema: z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
file_id: z.number().int().positive().optional().describe(`Only this file's passages`),
|
||||
limit: z.number().int().min(1).max(20).optional(),
|
||||
}),
|
||||
run: async ({ args, signal }) => {
|
||||
const result = await searchBotFiles({
|
||||
scopes: context.scopes,
|
||||
query: args.query,
|
||||
file_id: args.file_id,
|
||||
limit: args.limit || AppData.RagTopChunks,
|
||||
signal,
|
||||
bot_id: context.bot_id,
|
||||
workspace_id: context.workspace_id,
|
||||
});
|
||||
|
||||
if (!result.hits.length) return result.note;
|
||||
|
||||
const passages = result.hits.map((hit, index) => {
|
||||
const where = hit.scope == "workspace" ? ` (workspace ${hit.scope})` : "";
|
||||
|
||||
return `## ${index + 1}. ${hit.name}${where} ${fileRef({ file_id: hit.file_id })}\n${hit.text.trim()}`;
|
||||
});
|
||||
|
||||
return `${result.note}\n\n${passages.join("\n\n")}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import grabStorageScopes, { type StorageScope } from "@/src/utils/storage-access";
|
||||
|
||||
export type StorageToolContext = {
|
||||
/**
|
||||
* The user the run is for.
|
||||
*/
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The directories the run may touch, itself first.
|
||||
*/
|
||||
scopes: StorageScope[];
|
||||
/**
|
||||
* The session being answered, when a file can ride on its reply.
|
||||
*/
|
||||
session_id?: number | string;
|
||||
/**
|
||||
* The workspace the run is in, for embeddings.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Called with a file's media id when a tool wrote it and the bot asked
|
||||
* for it to be attached to the reply.
|
||||
*/
|
||||
onAttach?: (params: { media_id: number; name: string }) => void;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
|
||||
user_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
session_id?: number | string;
|
||||
/**
|
||||
* Every workspace, for the coordinator (it reaches them all).
|
||||
*/
|
||||
all_workspace_ids?: number[];
|
||||
onAttach?: StorageToolContext["onAttach"];
|
||||
};
|
||||
|
||||
/**
|
||||
* The directories one run may touch, from the bot it's for. The
|
||||
* coordinator reaches every bot's and workspace's files; a worker its own,
|
||||
* plus its workspace's shared one while it works there.
|
||||
*/
|
||||
export default function storageToolContext({
|
||||
bot,
|
||||
user_id,
|
||||
workspace_id,
|
||||
session_id,
|
||||
all_workspace_ids,
|
||||
onAttach,
|
||||
}: Params): StorageToolContext {
|
||||
return {
|
||||
user_id,
|
||||
bot_id: bot.id || 0,
|
||||
scopes: grabStorageScopes({
|
||||
bot_id: bot.id || 0,
|
||||
bot_kind: bot.kind,
|
||||
workspace_id,
|
||||
all_workspace_ids,
|
||||
}),
|
||||
session_id,
|
||||
workspace_id,
|
||||
onAttach,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A ref the chat renders as a chip, used in tool results so a file the bot
|
||||
* names links to it.
|
||||
*/
|
||||
export function fileRef({ file_id }: { file_id: number | string }) {
|
||||
return `[file:${file_id}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A human size: "12.4 KB".
|
||||
*/
|
||||
export function formatBytes({ bytes }: { bytes: number }) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope label a tool result shows: "your files" or "the workspace's
|
||||
* shared files".
|
||||
*/
|
||||
export function describeScope({ scope, owner_id }: StorageScope) {
|
||||
return scope == "workspace" ? `the workspace's shared files (#${owner_id})` : "your files";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a scope the bot named, e.g. "workspace:3" or "bot:2".
|
||||
*/
|
||||
export function findNamedScope({
|
||||
scopes,
|
||||
name,
|
||||
}: {
|
||||
scopes: StorageScope[];
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (!name?.trim()) return undefined;
|
||||
|
||||
const [scope, owner] = name.trim().split(":");
|
||||
|
||||
return scopes.find((s) => s.scope == scope && String(s.owner_id) == owner);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotFilePurpose } from "@/src/dict/bot-file-dict";
|
||||
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import {
|
||||
describeScope,
|
||||
fileRef,
|
||||
findNamedScope,
|
||||
formatBytes,
|
||||
type StorageToolContext,
|
||||
} from "./storage-tool-context";
|
||||
|
||||
type Args = {
|
||||
path: string;
|
||||
content: string;
|
||||
scope?: string;
|
||||
attach?: boolean;
|
||||
purpose?: BotFilePurpose;
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a text file (markdown, CSV, JSON, code, plain text) into one of
|
||||
* the bot's directories, making folders as needed. The file stays for
|
||||
* later runs and becomes searchable.
|
||||
*/
|
||||
export default function writeFileTool(context: StorageToolContext): HarnessTool<Args> {
|
||||
const own = context.scopes[0];
|
||||
|
||||
return {
|
||||
name: "write_file",
|
||||
description: `Save a text file for later: markdown, CSV, JSON, code or plain text. Give it a path like "reports/q3.md"; folders are made as needed. Files belong to ${
|
||||
own?.scope == "workspace" ? "the workspace you're working in" : "you"
|
||||
} and stay between runs: write a file before telling the user something is saved, and search_files to find one you wrote before. It is readable and searchable at once.${
|
||||
context.scopes.length > 1
|
||||
? ` "scope" picks a directory: ${context.scopes.map((s) => `${s.scope}:${s.owner_id}`).join(", ")}.`
|
||||
: ""
|
||||
}`,
|
||||
schema: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AppData.BotFilePathMaxChars)
|
||||
.describe(`Where it goes, e.g. "notes.md" or "reports/q3.md"`),
|
||||
content: z.string().max(AppData.BotFileWriteMaxChars),
|
||||
scope: z.string().optional().describe(`"bot:<id>" or "workspace:<id>"`),
|
||||
attach: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(`Also send it to the user with this reply, as an attachment`),
|
||||
purpose: z
|
||||
.enum(["writing", "artifact"])
|
||||
.optional()
|
||||
.describe(`"writing" for notes and text, "artifact" for a finished file the user asked for`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const named = args.scope ? findNamedScope({ scopes: context.scopes, name: args.scope }) : own;
|
||||
|
||||
if (!named) {
|
||||
return `You can't write there. Your directories: ${context.scopes
|
||||
.map((s) => `${s.scope}:${s.owner_id}`)
|
||||
.join(", ")}.`;
|
||||
}
|
||||
|
||||
const file = await recordBotFile({
|
||||
bot_id: context.bot_id,
|
||||
scope: named.scope,
|
||||
owner_id: named.owner_id,
|
||||
path: args.path,
|
||||
data: args.content,
|
||||
purpose: args.purpose || "writing",
|
||||
source: "bot-write",
|
||||
user_id: context.user_id,
|
||||
});
|
||||
|
||||
await enqueueFileIngest({ file_id: file.id! });
|
||||
|
||||
if (args.attach && file.media_id) {
|
||||
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
|
||||
}
|
||||
|
||||
return [
|
||||
`Saved ${file.path} (${formatBytes({ bytes: Number(file.size) || 0 })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`,
|
||||
"Tell the user the file's name and what's in it.",
|
||||
].join("\n\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -203,6 +203,25 @@ export type HarnessImageOutput = {
|
||||
media_type: string;
|
||||
};
|
||||
|
||||
export type HarnessEmbedParams = {
|
||||
candidate: HarnessCandidate;
|
||||
/**
|
||||
* The texts to embed, in order. Their vectors come back in the same
|
||||
* order, at whatever width the model produces (the caller fits them
|
||||
* to `AppData.EmbeddingDimensions`).
|
||||
*/
|
||||
input: string[];
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type HarnessEmbedResult = {
|
||||
vectors: number[][];
|
||||
/**
|
||||
* The provider's usage, when it reports it.
|
||||
*/
|
||||
usage?: HarnessUsage;
|
||||
};
|
||||
|
||||
/**
|
||||
* How the harness talks to one kind of provider. Every call takes the
|
||||
* candidate it runs on and an abort signal.
|
||||
@@ -214,6 +233,7 @@ export type HarnessAdapter = {
|
||||
generateImage: (
|
||||
params: HarnessImageParams,
|
||||
) => Promise<{ images: HarnessImageOutput[] }>;
|
||||
embed: (params: HarnessEmbedParams) => Promise<HarnessEmbedResult>;
|
||||
listModels: (params: {
|
||||
provider: HarnessProviderConfig;
|
||||
signal?: AbortSignal;
|
||||
|
||||
Reference in New Issue
Block a user