chore: isolate shared dsh plugins into independent monorepo
This commit is contained in:
64
packages/tool-lab/package.json
Normal file
64
packages/tool-lab/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-lab",
|
||||
"description": "Model-facing laboratory tools over the LAN media lab: ComfyUI image generation, Docling PDF OCR, and Whishper speech-to-text",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.byte-mate.ru/api/packages/Coder/npm/",
|
||||
"tag": "rc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.byte-mate.ru/Coder/dsh-plugins.git",
|
||||
"directory": "packages/tool-lab"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "^3.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@deepseek-ai/schemastery": "3.18.1",
|
||||
"@deepseek-ai/dsh-fs": "0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-timeout": "0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.7",
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.7",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.8",
|
||||
"@types/node": "^22.20.0"
|
||||
}
|
||||
}
|
||||
203
packages/tool-lab/src/comfy.ts
Normal file
203
packages/tool-lab/src/comfy.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* ComfyUI image-generation tool. Submits an SDXL-turbo workflow to a shared
|
||||
* ComfyUI instance, polls `/history/{prompt_id}` until the SaveImage node
|
||||
* produced a file, and returns the served PNG URL. Services are unauthenticated.
|
||||
* @module @deepseek-ai/dsh-tool-lab/comfy
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { HttpError, deadline, sleep } from './helpers.ts'
|
||||
|
||||
/** Default SDXL-turbo checkpoint on the lab server. */
|
||||
export const DEFAULT_MODEL = 'sdxl_turbo.safetensors'
|
||||
/** Default negative prompt applied unless the caller overrides it. */
|
||||
export const DEFAULT_NEGATIVE = 'negative prompt'
|
||||
/** Default output filename prefix for SaveImage. */
|
||||
export const DEFAULT_FILENAME_PREFIX = 'dsh'
|
||||
/** Default step count for the SDXL-turbo Euler-normal pass. */
|
||||
export const DEFAULT_STEPS = 4
|
||||
/** Default generation dimensions when neither axis is supplied. */
|
||||
export const DEFAULT_SIZE = 512
|
||||
/** One history poll interval (ms) while awaiting the rendered image. */
|
||||
export const POLL_INTERVAL_MS = 1_000
|
||||
|
||||
/** Schema-validated arguments for `lab_generate_image`. */
|
||||
export interface GenerateImageArgs {
|
||||
prompt: string
|
||||
negative?: string
|
||||
width?: number
|
||||
height?: number
|
||||
steps?: number
|
||||
seed?: number
|
||||
model?: string
|
||||
filename_prefix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve optional image arguments against defaults. Positive
|
||||
* integer constraints the schema DSL cannot express are enforced here.
|
||||
*/
|
||||
export function resolveImageArgs(args: GenerateImageArgs): Required<GenerateImageArgs> {
|
||||
const resolved = {
|
||||
prompt: args.prompt,
|
||||
negative: args.negative ?? DEFAULT_NEGATIVE,
|
||||
width: args.width ?? DEFAULT_SIZE,
|
||||
height: args.height ?? DEFAULT_SIZE,
|
||||
steps: args.steps ?? DEFAULT_STEPS,
|
||||
seed: args.seed ?? 123,
|
||||
model: args.model ?? DEFAULT_MODEL,
|
||||
filename_prefix: args.filename_prefix ?? DEFAULT_FILENAME_PREFIX,
|
||||
}
|
||||
for (const [name, value] of [
|
||||
['width', resolved.width],
|
||||
['height', resolved.height],
|
||||
['steps', resolved.steps],
|
||||
['seed', resolved.seed],
|
||||
] as const) {
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`lab_generate_image: ${name} must be a positive integer`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SDXL-turbo ComfyUI workflow for a 4-step Euler-normal pass. The
|
||||
* node graph is the lab's validated shape; only the seed, dimensions, step
|
||||
* count, prompts, and output prefix vary per call.
|
||||
*/
|
||||
export function buildWorkflow(opts: {
|
||||
prompt: string
|
||||
negative: string
|
||||
width: number
|
||||
height: number
|
||||
steps: number
|
||||
seed: number
|
||||
model: string
|
||||
filenamePrefix: string
|
||||
}): Record<string, unknown> {
|
||||
const { prompt, negative, width, height, steps, seed, model, filenamePrefix } = opts
|
||||
return {
|
||||
'3': {
|
||||
class_type: 'KSampler',
|
||||
inputs: {
|
||||
seed,
|
||||
steps,
|
||||
cfg: 1.0,
|
||||
sampler_name: 'euler',
|
||||
scheduler: 'normal',
|
||||
denoise: 1.0,
|
||||
model: ['4', 0],
|
||||
positive: ['6', 0],
|
||||
negative: ['7', 0],
|
||||
latent_image: ['5', 0],
|
||||
},
|
||||
},
|
||||
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
|
||||
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
||||
'6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['4', 1] } },
|
||||
'7': { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
|
||||
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
||||
'9': { class_type: 'SaveImage', inputs: { filename_prefix: filenamePrefix, images: ['8', 0] } },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one SDXL-turbo generation against a ComfyUI server: queue the
|
||||
* workflow, poll history for the rendered image, and return its `GET /view` URL.
|
||||
*
|
||||
* @param baseUrl - the ComfyUI server base URL, e.g. `http://192.168.31.240:8188`.
|
||||
* @param resolved - schema-validated, default-resolved generation arguments.
|
||||
* @param signal - the executor's cancellation signal (fused into the deadline).
|
||||
* @param timeoutMs - cooperative timeout budget for the whole call.
|
||||
* @returns the served PNG URL.
|
||||
*/
|
||||
export async function runComfyImage(
|
||||
baseUrl: string,
|
||||
resolved: Required<GenerateImageArgs>,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
|
||||
|
||||
let response = await fetch(`${baseUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: buildWorkflow({
|
||||
prompt: resolved.prompt,
|
||||
negative: resolved.negative,
|
||||
width: resolved.width,
|
||||
height: resolved.height,
|
||||
steps: resolved.steps,
|
||||
seed: resolved.seed,
|
||||
model: resolved.model,
|
||||
filenamePrefix: resolved.filename_prefix,
|
||||
}),
|
||||
client_id: 'dsh',
|
||||
}),
|
||||
signal: d.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_generate_image: ComfyUI prompt failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
|
||||
}
|
||||
const queued = await response.json() as { prompt_id?: string; node_errors?: Record<string, unknown> }
|
||||
const nodeErrors = queued.node_errors ?? {}
|
||||
if (Object.keys(nodeErrors).length > 0) throw new Error(`lab_generate_image: workflow rejected: ${JSON.stringify(nodeErrors)}`)
|
||||
const promptId = queued.prompt_id
|
||||
if (promptId === undefined) throw new Error('lab_generate_image: /prompt returned no prompt_id')
|
||||
|
||||
while (true) {
|
||||
d.signal.throwIfAborted()
|
||||
response = await fetch(`${baseUrl}/history/${encodeURIComponent(promptId)}`, { signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_generate_image: ComfyUI history failed (HTTP ${response.status})`, response.status, 'LAB_COMFY_HTTP')
|
||||
}
|
||||
const history = await response.json() as Record<string, { outputs?: Record<string, { images?: { filename?: string }[] }> }>
|
||||
const images = history[promptId]?.outputs?.['9']?.images ?? []
|
||||
const filename = images[0]?.filename
|
||||
if (filename !== undefined) {
|
||||
return `${baseUrl}/view?filename=${encodeURIComponent(filename)}&subfolder=&type=output`
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS, d.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `lab_generate_image` tool.
|
||||
*
|
||||
* @param ctx - context whose `tools` registry receives the definition
|
||||
* (effect-scoped; unregistered on plugin dispose).
|
||||
* @param baseUrl - the ComfyUI server base URL.
|
||||
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
|
||||
* @param maxOutputChars - output cap; image generation returns a URL and ignores this.
|
||||
*/
|
||||
export function registerLabComfyTool(ctx: Context, baseUrl: string, timeoutMs: number, maxOutputChars: number): void {
|
||||
void maxOutputChars
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_generate_image',
|
||||
description:
|
||||
'Generate an image with the shared ComfyUI lab server (SDXL-turbo, 4 steps, euler/normal, no auth). '
|
||||
+ 'Returns the served PNG URL. Polls the render up to the tool timeout; use the default size for fastest results.',
|
||||
parameters: {
|
||||
prompt: { type: 'string', required: true, description: 'The positive text prompt describing the image.' },
|
||||
negative: { type: 'string', description: `Negative prompt; defaults to "${DEFAULT_NEGATIVE}".` },
|
||||
width: { type: 'integer', description: `Output width; defaults to ${DEFAULT_SIZE}.` },
|
||||
height: { type: 'integer', description: `Output height; defaults to ${DEFAULT_SIZE}.` },
|
||||
steps: { type: 'integer', description: `Sampling steps; defaults to ${DEFAULT_STEPS} (SDXL-turbo).` },
|
||||
seed: { type: 'integer', description: 'Sampling seed; defaults to 123.' },
|
||||
model: { type: 'string', description: `Checkpoint name on the server; defaults to "${DEFAULT_MODEL}".` },
|
||||
filename_prefix: { type: 'string', description: `Output filename prefix; defaults to "${DEFAULT_FILENAME_PREFIX}".` },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
timeoutMs,
|
||||
// A shared GPU render is idempotent from the caller's perspective: sibling
|
||||
// calls mutate no parent-owned state beyond the lab queue.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
return runComfyImage(baseUrl, resolveImageArgs(args as GenerateImageArgs), exec.signal, timeoutMs)
|
||||
},
|
||||
}))
|
||||
}
|
||||
150
packages/tool-lab/src/docling.ts
Normal file
150
packages/tool-lab/src/docling.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Docling PDF-OCR tool. Uploads a PDF through Docling's multipart async
|
||||
* conversion endpoint, polls the task status, and returns the extracted
|
||||
* markdown. Services are unauthenticated.
|
||||
* @module @deepseek-ai/dsh-tool-lab/docling
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { HttpError, deadline, sleep } from './helpers.ts'
|
||||
|
||||
/** Docling `/v1/status/poll` wait hint (seconds) per poll. */
|
||||
export const DOCLING_POLL_WAIT_S = 2
|
||||
/** One transport retry interval (ms) between status polls. */
|
||||
export const DOCLING_POLL_INTERVAL_MS = 2_000
|
||||
|
||||
/** Schema-validated arguments for `lab_ocr_pdf`. */
|
||||
export interface OcrPdfArgs {
|
||||
file_path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a PDF blob and poll the Docling task until it succeeds, returning
|
||||
* the extracted markdown (`document.md_content`).
|
||||
*
|
||||
* @param baseUrl - the Docling server base URL, e.g. `http://192.168.31.159:5001`.
|
||||
* @param bytes - the PDF file bytes read through `ctx.fs.readBytes`.
|
||||
* @param filename - the file name sent in the multipart upload.
|
||||
* @param signal - the executor's cancellation signal (deadline-fused).
|
||||
* @param timeoutMs - cooperative timeout budget for the whole call.
|
||||
* @returns the extracted markdown text.
|
||||
*/
|
||||
export async function runDoclingOcr(
|
||||
baseUrl: string,
|
||||
bytes: Uint8Array,
|
||||
filename: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('files', new Blob([bytes as BlobPart], { type: 'application/pdf' }), filename)
|
||||
|
||||
let response = await fetch(`${baseUrl}/v1/convert/file/async`, { method: 'POST', body: form, signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_ocr_pdf: Docling convert failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
|
||||
}
|
||||
const queued = await response.json() as { task_id?: string; task_status?: string }
|
||||
const taskId = queued.task_id
|
||||
if (taskId === undefined) throw new Error('lab_ocr_pdf: /v1/convert/file/async returned no task_id')
|
||||
|
||||
while (true) {
|
||||
d.signal.throwIfAborted()
|
||||
response = await fetch(`${baseUrl}/v1/status/poll/${encodeURIComponent(taskId)}?wait=${DOCLING_POLL_WAIT_S}`, { signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_ocr_pdf: Docling status failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
|
||||
}
|
||||
const status = await response.json() as { task_status?: string }
|
||||
if (status.task_status === 'success') break
|
||||
if (status.task_status === 'failure' || status.task_status === 'cancelled' || status.task_status === 'error') {
|
||||
throw new Error(`lab_ocr_pdf: Docling task ended with status "${status.task_status}"`)
|
||||
}
|
||||
await sleep(DOCLING_POLL_INTERVAL_MS, d.signal)
|
||||
}
|
||||
|
||||
d.signal.throwIfAborted()
|
||||
response = await fetch(`${baseUrl}/v1/result/${encodeURIComponent(taskId)}`, { signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_ocr_pdf: Docling result failed (HTTP ${response.status})`, response.status, 'LAB_DOCLING_HTTP')
|
||||
}
|
||||
const result = await response.json() as { status?: string; errors?: unknown[]; document?: { md_content?: string } }
|
||||
if (result.status !== 'success') throw new Error(`lab_ocr_pdf: Docling result status "${result.status ?? 'unknown'}"`)
|
||||
const markdown = result.document?.md_content
|
||||
if (markdown === undefined) throw new Error('lab_ocr_pdf: Docling result carried no document.md_content')
|
||||
return markdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `lab_ocr_pdf` tool. Reads the file as bytes via `ctx.fs`
|
||||
* (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
|
||||
*
|
||||
* @param ctx - context whose `tools` registry receives the definition and whose
|
||||
* `fs` provides file reads.
|
||||
* @param baseUrl - the Docling server base URL.
|
||||
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
|
||||
* @param maxBytes - inclusive cap on the PDF bytes read from disk.
|
||||
* @param maxOutputChars - cap on the returned markdown; longer output is truncated.
|
||||
*/
|
||||
export function registerLabDoclingTool(
|
||||
ctx: Context,
|
||||
baseUrl: string,
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
maxOutputChars: number,
|
||||
): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_ocr_pdf',
|
||||
description:
|
||||
'OCR a PDF file and return the extracted markdown text via the shared Docling server (no auth). '
|
||||
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until OCR succeeds or times out.`,
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to the PDF file to OCR.' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
timeoutMs,
|
||||
// Reads and an upload to the shared OCR queue: no parent-owned mutation.
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
|
||||
return runDoclingOcrFromTarget(
|
||||
baseUrl,
|
||||
ctx,
|
||||
target,
|
||||
args.file_path,
|
||||
exec.signal,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
maxOutputChars,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Read the resolved target bytes and run the Docling OCR flow. */
|
||||
async function runDoclingOcrFromTarget(
|
||||
baseUrl: string,
|
||||
ctx: Context,
|
||||
target: FsTarget,
|
||||
displayPath: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
maxOutputChars: number,
|
||||
): Promise<string> {
|
||||
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
|
||||
const filename = displayPath.split(/[\\/]/).pop() ?? 'input.pdf'
|
||||
const markdown = await runDoclingOcr(baseUrl, bytes, filename, signal, timeoutMs)
|
||||
if (markdown.length <= maxOutputChars) return markdown
|
||||
return `${markdown.slice(0, maxOutputChars)}\n\n(OCR output truncated.)`
|
||||
}
|
||||
|
||||
/** Human-readable byte bound for the tool description. */
|
||||
function formatBytes(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
|
||||
}
|
||||
68
packages/tool-lab/src/helpers.ts
Normal file
68
packages/tool-lab/src/helpers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared cooperative utilities for the lab tools: a signal-fused deadline, an
|
||||
* abortable sleep, and a structured HTTP/upstream error carrier. Deadline and
|
||||
* sleep cooperate with the caller's cancellation signal so a timed-out or
|
||||
* cancelled call unwinds promptly instead of blocking on a hung server.
|
||||
* @module @deepseek-ai/dsh-tool-lab/helpers
|
||||
*/
|
||||
|
||||
import { deadline as makeDeadline } from '@deepseek-ai/dsh-timeout'
|
||||
import type { Deadline } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Cooperative classification error for lab-service HTTP failures. Carries the
|
||||
* HTTP status (or `undefined` for transport failures) and a stable code so
|
||||
* callers and diagnostics can distinguish a rejected workflow, an unreachable
|
||||
* server, and a timed-out request without reparsing the message.
|
||||
*/
|
||||
export class HttpError extends Error {
|
||||
override name = 'HttpError'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number | undefined,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuse the executor's cancellation signal with a per-call timeout budget.
|
||||
* The returned deadline aborts on upstream cancellation OR on timeout (the
|
||||
* timeout carries a {@link TimeoutReason} with the given code). `using` clears
|
||||
* the timer at scope exit.
|
||||
*
|
||||
* @param signal - the executor's cancellation signal, if any.
|
||||
* @param timeoutMs - cooperative timeout budget in milliseconds.
|
||||
* @param code - capability-owned code stamped onto the timeout reason.
|
||||
* @returns the fused deadline (signal + timer cleanup).
|
||||
*/
|
||||
export function deadline(signal: AbortSignal | undefined, timeoutMs: number, code: string): Deadline {
|
||||
return makeDeadline(signal, timeoutMs, code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend the current coroutine for `ms` milliseconds, returning early when
|
||||
* the signal aborts. Throws the abort once registered; cooperative callers
|
||||
* check `signal.aborted` or let the next upstream call raise.
|
||||
*
|
||||
* @param ms - sleep duration in milliseconds.
|
||||
* @param signal - optional signal to observe.
|
||||
*/
|
||||
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
if (ms <= 0) return
|
||||
if (signal !== undefined && signal.aborted) signal.throwIfAborted()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (signal === undefined) {
|
||||
setTimeout(resolve, ms)
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => reject(signal.reason)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
})
|
||||
}
|
||||
90
packages/tool-lab/src/index.ts
Normal file
90
packages/tool-lab/src/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Model-facing lab tools over the LAN media lab: ComfyUI image generation,
|
||||
* Docling PDF OCR, and Whishper speech-to-text. This package owns schemas,
|
||||
* validation, defaults, and presentation; each service module owns its HTTP
|
||||
* flow. Enablement is config-driven; the three tools register together.
|
||||
* @module @deepseek-ai/dsh-tool-lab
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { registerLabComfyTool } from './comfy.ts'
|
||||
import { registerLabDoclingTool } from './docling.ts'
|
||||
import { registerLabWhishTool } from './whish.ts'
|
||||
|
||||
export { DEFAULT_MODEL as COMFY_DEFAULT_MODEL, DEFAULT_NEGATIVE as COMFY_DEFAULT_NEGATIVE } from './comfy.ts'
|
||||
export type { GenerateImageArgs as ComfyGenerateImageArgs } from './comfy.ts'
|
||||
export type { OcrPdfArgs as DoclingOcrPdfArgs } from './docling.ts'
|
||||
export type { TranscribeAudioArgs as WhishTranscribeAudioArgs } from './whish.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'lab'
|
||||
|
||||
/** Services required by the lab tool suite. */
|
||||
export const inject = ['tools', 'fs']
|
||||
|
||||
/** Default ComfyUI server base URL. */
|
||||
export const DEFAULT_COMFY_BASE_URL = 'http://192.168.31.240:8188'
|
||||
/** Default Docling server base URL. */
|
||||
export const DEFAULT_DOCLING_BASE_URL = 'http://192.168.31.159:5001'
|
||||
/** Default Whishper server base URL. */
|
||||
export const DEFAULT_WHISH_BASE_URL = 'http://192.168.31.159:8082'
|
||||
/** Default per-call cooperative timeout budget. */
|
||||
export const DEFAULT_TIMEOUT_MS = 120_000
|
||||
/** Default cap on one uploaded file. */
|
||||
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
/** Default cap on one tool's text output. */
|
||||
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
|
||||
|
||||
/** Plugin config: lab service endpoints and the shared tool bounds. */
|
||||
export interface Config {
|
||||
/** ComfyUI server base URL. */
|
||||
comfyBaseUrl?: string
|
||||
/** Docling server base URL. */
|
||||
doclingBaseUrl?: string
|
||||
/** Whishper server base URL. */
|
||||
whishBaseUrl?: string
|
||||
/** Cooperative timeout budget (ms) for one lab tool call. */
|
||||
timeoutMs?: number
|
||||
/** Inclusive cap on one uploaded file (bytes). */
|
||||
maxUploadBytes?: number
|
||||
/** Cap on one tool's text output (characters). */
|
||||
maxOutputChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
comfyBaseUrl: z.string().default(DEFAULT_COMFY_BASE_URL),
|
||||
doclingBaseUrl: z.string().default(DEFAULT_DOCLING_BASE_URL),
|
||||
whishBaseUrl: z.string().default(DEFAULT_WHISH_BASE_URL),
|
||||
timeoutMs: z.number().default(DEFAULT_TIMEOUT_MS),
|
||||
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
|
||||
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
|
||||
})
|
||||
|
||||
/** Complete config after schemastery applies every field default. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Configured timeouts and caps must be positive integers. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-lab: ${name} must be a positive integer`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the three lab tools. Each tool's cooperative timeout budget
|
||||
* (`timeoutMs`) and file/output bounds come from config and are attached to
|
||||
* the definition for `@deepseek-ai/dsh-tool-call-timeout-policy` to enforce.
|
||||
* The effect-based registry cleanup means no manual teardown is needed.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
for (const [key, value] of [
|
||||
['timeoutMs', resolved.timeoutMs],
|
||||
['maxUploadBytes', resolved.maxUploadBytes],
|
||||
['maxOutputChars', resolved.maxOutputChars],
|
||||
] as const) {
|
||||
assertPositiveInteger(key, value)
|
||||
}
|
||||
registerLabComfyTool(ctx, resolved.comfyBaseUrl, resolved.timeoutMs, resolved.maxOutputChars)
|
||||
registerLabDoclingTool(ctx, resolved.doclingBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
|
||||
registerLabWhishTool(ctx, resolved.whishBaseUrl, resolved.timeoutMs, resolved.maxUploadBytes, resolved.maxOutputChars)
|
||||
}
|
||||
30
packages/tool-lab/src/invariant.ts
Normal file
30
packages/tool-lab/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
|
||||
* @module @deepseek-ai/dsh-tool-lab/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-lab-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle
|
||||
* stream; execution relations are owned by the capability seams it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
153
packages/tool-lab/src/whish.ts
Normal file
153
packages/tool-lab/src/whish.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Whishper speech-to-text tool. Uploads an audio file through Whishper's
|
||||
* multipart `/api/transcriptions` endpoint, polls until a transcript is ready,
|
||||
* and returns `result.text`. Services are unauthenticated.
|
||||
* @module @deepseek-ai/dsh-tool-lab/whish
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { HttpError, deadline, sleep } from './helpers.ts'
|
||||
|
||||
/** One transcription status poll interval (ms). */
|
||||
export const WHISH_POLL_INTERVAL_MS = 2_000
|
||||
/** Default Whishper model size when the caller omits it. */
|
||||
export const WHISH_DEFAULT_MODEL = 'base'
|
||||
|
||||
/** Schema-validated arguments for `lab_transcribe_audio`. */
|
||||
export interface TranscribeAudioArgs {
|
||||
file_path: string
|
||||
model_size?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an audio blob and poll Whishper until the transcript text is ready,
|
||||
* returning it. A queued item reports `status: -1`; once `status >= 0` AND
|
||||
* `result.text` is non-empty the transcript is done.
|
||||
*
|
||||
* @param baseUrl - the Whishper server base URL, e.g. `http://192.168.31.159:8082`.
|
||||
* @param bytes - the audio bytes read through `ctx.fs.readBytes`.
|
||||
* @param filename - the file name sent in the multipart upload.
|
||||
* @param modelSize - optional model size hint.
|
||||
* @param language - optional spoken-language hint.
|
||||
* @param signal - the executor's cancellation signal (deadline-fused).
|
||||
* @param timeoutMs - cooperative timeout budget for the whole call.
|
||||
* @returns the transcribed text.
|
||||
*/
|
||||
export async function runWhishTranscribe(
|
||||
baseUrl: string,
|
||||
bytes: Uint8Array,
|
||||
filename: string,
|
||||
modelSize: string | undefined,
|
||||
language: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
using d = deadline(signal, timeoutMs, 'LAB_TOOL_TIMEOUT')
|
||||
|
||||
const form = new FormData()
|
||||
form.append('files', new Blob([bytes as BlobPart]), filename)
|
||||
if (modelSize !== undefined) form.append('model_size', modelSize)
|
||||
if (language !== undefined) form.append('language', language)
|
||||
|
||||
let response = await fetch(`${baseUrl}/api/transcriptions`, { method: 'POST', body: form, signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_transcribe_audio: Whishper upload failed (HTTP ${response.status})`, response.status, 'LAB_WHISH_HTTP')
|
||||
}
|
||||
const queued = await response.json() as { id?: string; status?: number }
|
||||
const id = queued.id
|
||||
if (id === undefined) throw new Error('lab_transcribe_audio: /api/transcriptions returned no id')
|
||||
|
||||
while (true) {
|
||||
d.signal.throwIfAborted()
|
||||
response = await fetch(`${baseUrl}/api/transcriptions/${encodeURIComponent(String(id))}`, { signal: d.signal })
|
||||
if (!response.ok) {
|
||||
throw new HttpError(`lab_transcribe_audio: Whishper status failed (HTTP ${response.status})`, response.status, 'LAB_WISH_HTTP')
|
||||
}
|
||||
const state = await response.json() as { status?: number; result?: { text?: string } }
|
||||
const status = state.status ?? 0
|
||||
const text = state.result?.text
|
||||
if (status >= 0 && text !== undefined && text.trim().length > 0) return text
|
||||
await sleep(WHISH_POLL_INTERVAL_MS, d.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `lab_transcribe_audio` tool. Reads the audio file as bytes via
|
||||
* `ctx.fs` (`resolve` then `readBytes`, bounded by `maxBytes`) and uploads it.
|
||||
*
|
||||
* @param ctx - context whose `tools` registry receives the definition and whose
|
||||
* `fs` provides file reads.
|
||||
* @param baseUrl - the Whishper server base URL.
|
||||
* @param timeoutMs - cooperative timeout budget attached as the tool's `timeoutMs`.
|
||||
* @param maxBytes - inclusive cap on the audio bytes read from disk.
|
||||
* @param maxOutputChars - cap on the returned transcript; longer text is truncated.
|
||||
*/
|
||||
export function registerLabWhishTool(
|
||||
ctx: Context,
|
||||
baseUrl: string,
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
maxOutputChars: number,
|
||||
): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lab_transcribe_audio',
|
||||
description:
|
||||
'Transcribe speech to text from an audio file via the shared Whishper server (no auth). '
|
||||
+ `The file is read from disk up to ${formatBytes(maxBytes)} and uploaded as multipart; the tool polls until the transcript is ready or times out.`,
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to the audio file to transcribe.' },
|
||||
model_size: { type: 'string', description: `Optional model size hint; defaults to "${WHISH_DEFAULT_MODEL}".` },
|
||||
language: { type: 'string', description: 'Optional spoken-language hint (e.g. "en" or "ru").' },
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
||||
},
|
||||
timeoutMs,
|
||||
isConcurrencySafe: () => true,
|
||||
async execute(args, exec) {
|
||||
const target = await ctx.fs.resolve(args.file_path, { signal: exec.signal })
|
||||
return runWhishFromTarget(
|
||||
baseUrl,
|
||||
ctx,
|
||||
target,
|
||||
args.file_path,
|
||||
args.model_size,
|
||||
args.language,
|
||||
exec.signal,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
maxOutputChars,
|
||||
)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/** Read the resolved target bytes and run the Whishper flow. */
|
||||
async function runWhishFromTarget(
|
||||
baseUrl: string,
|
||||
ctx: Context,
|
||||
target: FsTarget,
|
||||
displayPath: string,
|
||||
modelSize: string | undefined,
|
||||
language: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
maxOutputChars: number,
|
||||
): Promise<string> {
|
||||
const bytes = await ctx.fs.readBytes(target, signal, maxBytes)
|
||||
const filename = displayPath.split(/[\\/]/).pop() ?? 'audio.bin'
|
||||
const model = modelSize !== undefined && modelSize.trim().length > 0 ? modelSize.trim() : WHISH_DEFAULT_MODEL
|
||||
const text = await runWhishTranscribe(baseUrl, bytes, filename, model, language, signal, timeoutMs)
|
||||
if (text.length <= maxOutputChars) return text
|
||||
return `${text.slice(0, maxOutputChars)}\n\n(Transcript truncated.)`
|
||||
}
|
||||
|
||||
/** Human-readable byte bound for the tool description. */
|
||||
function formatBytes(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${Math.floor(bytes / (1024 * 1024))}MB` : `${bytes}B`
|
||||
}
|
||||
162
packages/tool-lab/tests/tool-lab.spec.ts
Normal file
162
packages/tool-lab/tests/tool-lab.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, FsPathInfo, FsWriteIntent, FsWriteOutcome, FsVersion, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
import * as ToolLab from '@deepseek-ai/dsh-tool-lab'
|
||||
import { runComfyImage, resolveImageArgs, buildWorkflow } from '@deepseek-ai/dsh-tool-lab/src/comfy.ts'
|
||||
import { runDoclingOcr } from '@deepseek-ai/dsh-tool-lab/src/docling.ts'
|
||||
import { runWhishTranscribe } from '@deepseek-ai/dsh-tool-lab/src/whish.ts'
|
||||
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** In-memory fake ctx.fs backend for upload tools. */
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, Uint8Array>()
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override processPath(target: FsTarget): string { return String(target.targetKey) }
|
||||
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
|
||||
override contains(parent: FsTarget, child: FsTarget): boolean {
|
||||
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
|
||||
}
|
||||
override async stat(): Promise<FsInfo | undefined> { return undefined }
|
||||
override async lstat(): Promise<FsPathInfo | undefined> { return undefined }
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return new TextDecoder().decode(this.files.get(target.targetKey) ?? new Uint8Array())
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const text = await this.readText(target)
|
||||
return (async function* () { yield text })()
|
||||
}
|
||||
override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
||||
const bytes = this.files.get(target.targetKey) ?? new Uint8Array()
|
||||
if (bytes.length > maxBytes) throw new Error('FS_TOO_LARGE')
|
||||
return bytes
|
||||
}
|
||||
override async listDir(): Promise<FsDirEntry[]> { return [] }
|
||||
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, new TextEncoder().encode(content))
|
||||
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
|
||||
}
|
||||
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
|
||||
const content = new TextDecoder().decode(this.files.get(target.targetKey) ?? new Uint8Array())
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, new TextEncoder().encode(after))
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(opts: { files?: Record<string, Uint8Array> } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ToolRuntime)
|
||||
const fs = new FakeFs()
|
||||
if (opts.files) for (const [k, v] of Object.entries(opts.files)) fs.files.set(`key:${k}`, v)
|
||||
await ctx.plugin(fs, {})
|
||||
await ctx.plugin(ToolLab, {})
|
||||
let counter = 0
|
||||
const call = (name: string, args: unknown) => ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`call-${++counter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
})
|
||||
return { ctx, fs, call }
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, { status = 200, headers }: { status?: number; headers?: Record<string, string> } = {}): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } })
|
||||
}
|
||||
|
||||
const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13])
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('lab_generate_image (ComfyUI)', () => {
|
||||
it('queues the workflow and returns the served PNG URL after polling history', async () => {
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
if (url.endsWith('/prompt')) return jsonResponse({ prompt_id: 'p-1', node_errors: {} })
|
||||
if (url.includes('/history/p-1')) return jsonResponse({ 'p-1': { outputs: { '9': { images: [{ filename: 'dsh_00001_.png' }] } } } })
|
||||
throw new Error(`unexpected fetch ${url}`)
|
||||
}))
|
||||
const { fs } = await mountTarget({ files: { 'x.png': pngBytes } })
|
||||
void fs
|
||||
|
||||
const out = await runComfyImage('http://192.168.31.240:8188', resolveImageArgs({ prompt: 'a cat' }), undefined, 30_000)
|
||||
expect(out).toBe('http://192.168.31.240:8188/view?filename=dsh_00001_.png&subfolder=&type=output')
|
||||
expect(calls.some(u => u.endsWith('/prompt'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a workflow with non-empty node_errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ prompt_id: 'p-2', node_errors: { '3': ['bad'] } })))
|
||||
await expect(runComfyImage('http://x', resolveImageArgs({ prompt: 'x' }), undefined, 30_000))
|
||||
.rejects.toThrow(/workflow rejected/)
|
||||
})
|
||||
|
||||
it('rejects non-positive integer dimensions and steps', () => {
|
||||
expect(() => resolveImageArgs({ prompt: 'x', width: 0 })).toThrow(/width must be a positive integer/)
|
||||
expect(() => resolveImageArgs({ prompt: 'x', steps: -1 })).toThrow(/steps must be a positive integer/)
|
||||
})
|
||||
|
||||
it('uses the documented SDXL-turbo defaults in the workflow', () => {
|
||||
const wf = buildWorkflow({ prompt: 'p', negative: 'negative prompt', width: 512, height: 512, steps: 4, seed: 123, model: 'sdxl_turbo.safetensors', filenamePrefix: 'dsh' })
|
||||
const sampler = wf['3'] as { inputs: Record<string, unknown> }
|
||||
expect(sampler.inputs.cfg).toBe(1.0)
|
||||
expect(sampler.inputs.sampler_name).toBe('euler')
|
||||
expect(sampler.inputs.scheduler).toBe('normal')
|
||||
expect(sampler.inputs.steps).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lab_ocr_pdf (Docling)', () => {
|
||||
it('uploads a PDF and returns document.md_content after polling', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/v1/convert/file/async')) return jsonResponse({ task_id: 't-1', task_status: 'pending' })
|
||||
if (url.includes('/v1/status/poll/t-1')) return jsonResponse({ task_status: 'success' })
|
||||
if (url.includes('/v1/result/t-1')) return jsonResponse({ status: 'success', errors: [], document: { filename: 'a.pdf', md_content: '# OCRed' } })
|
||||
throw new Error(`unexpected ${url}`)
|
||||
}))
|
||||
const out = await runDoclingOcr('http://192.168.31.159:5001', new TextEncoder().encode('%PDF-1.4'), 'a.pdf', undefined, 30_000)
|
||||
expect(out).toBe('# OCRed')
|
||||
expect(globalThis.fetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty task id', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ task_id: undefined })))
|
||||
await expect(runDoclingOcr('http://x', new Uint8Array(), 'a.pdf', undefined, 30_000)).rejects.toThrow(/no task_id/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lab_transcribe_audio (Whishper)', () => {
|
||||
it('polls until result.text is non-empty', async () => {
|
||||
let polls = 0
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/api/transcriptions')) return jsonResponse({ id: 'w-1', status: -1 })
|
||||
polls += 1
|
||||
if (polls === 1) return jsonResponse({ id: 'w-1', status: -1, result: { text: '' } })
|
||||
return jsonResponse({ id: 'w-1', status: 0, result: { text: 'hello world', language: 'en', duration: 1.2 } })
|
||||
}))
|
||||
const out = await runWhishTranscribe('http://192.168.31.159:8082', new TextEncoder().encode('wav'), 'a.wav', undefined, undefined, undefined, 30_000)
|
||||
expect(out).toBe('hello world')
|
||||
})
|
||||
|
||||
it('rejects an upload with no id', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ id: undefined })))
|
||||
await expect(runWhishTranscribe('http://x', new Uint8Array(), 'a.wav', undefined, undefined, undefined, 30_000))
|
||||
.rejects.toThrow(/no id/)
|
||||
})
|
||||
})
|
||||
|
||||
// keep imports referenced for the fake implementation; these types come from
|
||||
// @deepseek-ai/dsh-fs and are used by FakeFs above.
|
||||
type _ = SandboxExecutionPolicy | SandboxMode
|
||||
export { FakeFs }
|
||||
10
packages/tool-lab/tsconfig.json
Normal file
10
packages/tool-lab/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user