chore: isolate shared dsh plugins into independent monorepo
This commit is contained in:
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`
|
||||
}
|
||||
Reference in New Issue
Block a user