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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/plugins/xiaoyunque-generation/convax-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"id": "xiaoyunque-generation",
"name": "小云雀生成",
"description": "通过宿主管理的小云雀网页授权展示服务状态,并使用小云雀第一方网页能力生成图片和视频。",
"version": "0.3.5",
"version": "0.3.6",
"license": "MIT",
"compatibility": {
"pluginSchema": "convax.plugin/3",
Expand All @@ -13,7 +13,7 @@
"companions": [
{
"command": "convax-xiaoyunque-mcp",
"version": "0.3.3",
"version": "0.3.4",
"source": "packages/tools/xiaoyunque-mcp",
"targets": [
{
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/xiaoyunque-generation/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microvoid/convax-plugin-xiaoyunque-generation",
"version": "0.3.5",
"version": "0.3.6",
"private": true,
"type": "module",
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"id": "xiaoyunque-generation",
"name": "小云雀生成",
"description": "通过宿主管理的小云雀网页授权展示服务状态,并使用小云雀第一方网页能力生成图片和视频。",
"version": "0.3.5",
"version": "0.3.6",
"contributes": {
"generation": {
"models": [
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/xiaoyunque-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microvoid/convax-xiaoyunque-mcp",
"version": "0.3.3",
"version": "0.3.4",
"private": true,
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/xiaoyunque-mcp/scripts/live-image-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function notify(method: string, params: unknown) {
try {
const initialized = await request("initialize", {
capabilities: {},
clientInfo: { name: "convax-xiaoyunque-live-smoke", version: "0.3.3" },
clientInfo: { name: "convax-xiaoyunque-live-smoke", version: "0.3.4" },
protocolVersion: "2025-03-26",
})
if ((initialized.result as Record<string, unknown> | undefined)?.protocolVersion !== "2025-03-26") {
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/xiaoyunque-mcp/scripts/live-video-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ try {
progress("mcp.initialize")
const initialized = await request("initialize", {
capabilities: {},
clientInfo: { name: "convax-xiaoyunque-live-video-smoke", version: "0.3.3" },
clientInfo: { name: "convax-xiaoyunque-live-video-smoke", version: "0.3.4" },
protocolVersion: "2025-03-26",
})
if ((initialized.result as Record<string, unknown> | undefined)?.protocolVersion !== "2025-03-26") {
Expand Down
26 changes: 25 additions & 1 deletion packages/tools/xiaoyunque-mcp/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ const authorizationIdPattern = /^[A-Za-z0-9_-]{16,128}$/u
const cookieNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}$/u
const maximumCookieValueBytes = 16 * 1024
const maximumCookieBytes = 32 * 1024
export const maximumGenerationPromptBytes = 20_000

export class XiaoYunquePromptTooLongError extends Error {
override name = "XiaoYunquePromptTooLongError"

constructor() {
super("XiaoYunque generation prompt is too long")
}
}

export type GenerationOutput = "image" | "video"
export type GenerationReferenceRole =
Expand Down Expand Up @@ -215,6 +224,21 @@ function requireTrimmedText(value: unknown, label: string, maxLength: number) {
return value
}

export function parseGenerationPrompt(value: unknown) {
if (
typeof value !== "string"
|| value.length === 0
|| value !== value.trim()
|| /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)
) {
throw new Error("generation prompt must be non-empty trimmed text")
}
if (Buffer.byteLength(value, "utf8") > maximumGenerationPromptBytes) {
throw new XiaoYunquePromptTooLongError()
}
return value
}

const referenceRoles = new Set<GenerationReferenceRole>([
"reference_image",
"reference_video",
Expand Down Expand Up @@ -268,7 +292,7 @@ export function parseGenerationCall(value: unknown, expectedOutput: GenerationOu
operation_id: requireTrimmedString(input.operation_id, "generation operation_id", 256),
output: expectedOutput,
output_directory: requireTrimmedString(input.output_directory, "generation output_directory", 4_096),
prompt: requireTrimmedText(input.prompt, "generation prompt", 20_000),
prompt: parseGenerationPrompt(input.prompt),
references,
schema: generationCallSchema,
}
Expand Down
22 changes: 19 additions & 3 deletions packages/tools/xiaoyunque-mcp/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ export class XiaoYunqueUnsupportedImageModelError extends Error {
}
}

export type XiaoYunqueTerminalGenerationReason =
| "failed"
| "cancelled"
| "additional-input"
| "human-input"

export class XiaoYunqueTerminalGenerationError extends Error {
override name = "XiaoYunqueTerminalGenerationError"
readonly reason: XiaoYunqueTerminalGenerationReason

constructor(reason: XiaoYunqueTerminalGenerationReason) {
super(`XiaoYunque generation entered terminal state: ${reason}`)
this.reason = reason
}
}

function abortError() {
return new DOMException("Generation was cancelled", "AbortError")
}
Expand Down Expand Up @@ -313,13 +329,13 @@ export class GenerationEngine {
if (result.terminalDiagnosticCode === "unsupported-image-model") {
throw new XiaoYunqueUnsupportedImageModelError()
}
throw new Error(result.error || `XiaoYunque ${output} generation failed`)
throw new XiaoYunqueTerminalGenerationError(result.state === 4 ? "failed" : "cancelled")
}
if (result.state === 6) {
throw new Error(`XiaoYunque ${output} generation requires additional input`)
throw new XiaoYunqueTerminalGenerationError("additional-input")
}
if (result.state === 9) {
throw new Error(result.error || `XiaoYunque ${output} generation was interrupted for human input`)
throw new XiaoYunqueTerminalGenerationError("human-input")
}
await abortableDelay(this.#pollIntervalMs, signal)
}
Expand Down
34 changes: 33 additions & 1 deletion packages/tools/xiaoyunque-mcp/src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type GenerationCall,
type JsonRpcRequest,
parseGenerationCall,
XiaoYunquePromptTooLongError,
parsePluginServiceBrowserAuthorizationCompletion,
type PluginServiceBrowserAuthorizationCompletion,
type PluginServiceBrowserAuthorizationRequest,
Expand All @@ -14,6 +15,7 @@ import { XiaoYunqueCredentialConfigurationError } from "./configuration-error.ts
import {
XiaoYunqueGenerationInputError,
XiaoYunqueObservationRejectedError,
XiaoYunqueTerminalGenerationError,
XiaoYunqueUnsupportedImageModelError,
} from "./generator.ts"
import {
Expand Down Expand Up @@ -140,6 +142,9 @@ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest {
export function publicGenerationErrorMessage(error: unknown) {
if (error instanceof XiaoYunqueCredentialConfigurationError) return error.publicMessage
if (error instanceof XiaoYunqueGenerationInputError) return error.publicMessage
if (error instanceof XiaoYunquePromptTooLongError) {
return "The XiaoYunque prompt is too long. Shorten the prompt or remove some @ text context and try again."
}
if (error instanceof XiaoYunqueAuthenticationError) {
return "XiaoYunque sign-in expired. Open Convax Services and reconnect XiaoYunque."
}
Expand All @@ -152,6 +157,18 @@ export function publicGenerationErrorMessage(error: unknown) {
if (error instanceof XiaoYunqueUnsupportedImageModelError) {
return "The selected XiaoYunque image model is no longer available. Choose another image model and try again."
}
if (error instanceof XiaoYunqueTerminalGenerationError) {
if (error.reason === "failed") {
return "XiaoYunque accepted the generation, but the service reported that it failed. Check XiaoYunque before starting another paid generation."
}
if (error.reason === "cancelled") {
return "XiaoYunque accepted the generation, but the service reported that it was cancelled. Check XiaoYunque before starting another paid generation."
}
if (error.reason === "additional-input") {
return "XiaoYunque accepted the generation, but it requires additional input in XiaoYunque. Complete it there before starting another paid generation."
}
return "XiaoYunque accepted the generation, but it was interrupted for human input. Check XiaoYunque before starting another paid generation."
}
if (error instanceof XiaoYunqueReferenceAssetRegistrationError) {
return error.referenceType === "video"
? "XiaoYunque could not prepare the reference video for generation. No generation was submitted; try again."
Expand All @@ -166,10 +183,15 @@ export function publicGenerationErrorMessage(error: unknown) {
export const safeGenerationDiagnosticCodes = [
"local-setup-required",
"local-input-rejected",
"prompt-too-long",
"sign-in-expired",
"status-check-rejected",
"status-check-timeout",
"unsupported-image-model",
"generation-failed",
"generation-cancelled",
"generation-input-required",
"generation-interrupted",
"reference-image-registration-failed",
"reference-video-registration-failed",
"upstream-envelope-rejected",
Expand All @@ -183,10 +205,20 @@ export type SafeGenerationDiagnosticCode = typeof safeGenerationDiagnosticCodes[
export function safeGenerationDiagnosticCode(error: unknown): SafeGenerationDiagnosticCode {
if (error instanceof XiaoYunqueCredentialConfigurationError) return "local-setup-required"
if (error instanceof XiaoYunqueGenerationInputError) return "local-input-rejected"
if (error instanceof XiaoYunquePromptTooLongError) return "prompt-too-long"
if (error instanceof XiaoYunqueAuthenticationError) return "sign-in-expired"
if (error instanceof XiaoYunqueObservationRejectedError) return "status-check-rejected"
if (error instanceof XiaoYunqueQueryTimeoutError) return "status-check-timeout"
if (error instanceof XiaoYunqueUnsupportedImageModelError) return "unsupported-image-model"
if (error instanceof XiaoYunqueTerminalGenerationError) {
return error.reason === "failed"
? "generation-failed"
: error.reason === "cancelled"
? "generation-cancelled"
: error.reason === "additional-input"
? "generation-input-required"
: "generation-interrupted"
}
if (error instanceof XiaoYunqueReferenceAssetRegistrationError) {
return error.referenceType === "video"
? "reference-video-registration-failed"
Expand Down Expand Up @@ -309,7 +341,7 @@ export class McpServer {
this.#sendResult(request.id, {
capabilities: { tools: {} },
protocolVersion,
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.3" },
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.4" },
})
return
}
Expand Down
14 changes: 11 additions & 3 deletions packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { randomUUID } from "node:crypto"
import { lstat } from "node:fs/promises"
import type { FileGenerationReference, GenerationOutput } from "./contracts.ts"
import {
type FileGenerationReference,
type GenerationOutput,
parseGenerationPrompt,
} from "./contracts.ts"
import type { XiaoYunqueImageModel, XiaoYunqueVideoModel } from "./models.ts"
import {
type StoredWebSession,
Expand Down Expand Up @@ -274,6 +278,10 @@ async function boundedJson(response: Response, label: string) {
await response.body?.cancel().catch(() => undefined)
throw new XiaoYunqueAuthenticationError("XiaoYunque authorization is no longer valid")
}
if (response.status < 200 || response.status >= 300) {
await response.body?.cancel().catch(() => undefined)
throw new XiaoYunqueRequestRejectedError(`${label} was rejected`, "upstream-http-rejected")
}
const declaredLength = response.headers.get("content-length")
if (declaredLength !== null) {
const parsed = Number(declaredLength)
Expand Down Expand Up @@ -697,7 +705,7 @@ export class XiaoYunqueApi {
session: StoredWebSession,
signal: AbortSignal,
): Promise<RemoteTask> {
const prompt = boundedText(options.prompt.trim(), "XiaoYunque image prompt", 32_768)
const prompt = parseGenerationPrompt(options.prompt.trim())
if (options.assets.length > 9) throw new Error("XiaoYunque accepts at most nine reference images")
const task = { runId: randomUUID(), threadId: randomUUID() }
const agentName = "pippit_novel_agent_cn_v2"
Expand Down Expand Up @@ -744,7 +752,7 @@ export class XiaoYunqueApi {
session: StoredWebSession,
signal: AbortSignal,
): Promise<RemoteTask> {
const prompt = boundedText(options.prompt.trim(), "XiaoYunque video prompt", 32_768)
const prompt = parseGenerationPrompt(options.prompt.trim())
const maximumImages = options.model === "Seedance_1.0_fast" ? 1 : 9
if (options.imageAssets.length > maximumImages) {
throw new Error(`XiaoYunque ${options.model} accepts at most ${maximumImages} reference image${maximumImages === 1 ? "" : "s"}`)
Expand Down
18 changes: 17 additions & 1 deletion packages/tools/xiaoyunque-mcp/test/contracts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import { parseGenerationCall } from "../src/contracts.ts"
import {
maximumGenerationPromptBytes,
parseGenerationCall,
XiaoYunquePromptTooLongError,
} from "../src/contracts.ts"

function generationCall(prompt: string, text = "reference line one\n\treference line two") {
return {
Expand Down Expand Up @@ -36,4 +40,16 @@ describe("XiaoYunque generation call contract", () => {
.toThrow()
},
)

test("bounds prompts by UTF-8 bytes instead of JavaScript characters", () => {
const exactAscii = "a".repeat(maximumGenerationPromptBytes)
const exactChinese = "画".repeat(Math.floor(maximumGenerationPromptBytes / 3))

expect(parseGenerationCall(generationCall(exactAscii), "image").prompt).toBe(exactAscii)
expect(parseGenerationCall(generationCall(exactChinese), "image").prompt).toBe(exactChinese)
expect(() => parseGenerationCall(generationCall(`${exactAscii}a`), "image"))
.toThrow(XiaoYunquePromptTooLongError)
expect(() => parseGenerationCall(generationCall(`${exactChinese}画`), "image"))
.toThrow(XiaoYunquePromptTooLongError)
})
})
20 changes: 16 additions & 4 deletions packages/tools/xiaoyunque-mcp/test/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GenerationEngine,
XiaoYunqueGenerationInputError,
XiaoYunqueObservationRejectedError,
XiaoYunqueTerminalGenerationError,
XiaoYunqueUnsupportedImageModelError,
} from "../src/generator.ts"
import { fingerprintGenerationCall, OperationStore } from "../src/operation-store.ts"
Expand Down Expand Up @@ -1082,8 +1083,17 @@ describe("XiaoYunque generation engine", () => {
output_directory: output,
}), videoModel, new AbortController().signal).catch((error: unknown) => error)

expect(failure).toBeInstanceOf(Error)
expect((failure as Error).message).toBe(terminal.message)
expect(failure).toBeInstanceOf(XiaoYunqueTerminalGenerationError)
expect((failure as XiaoYunqueTerminalGenerationError).reason).toBe(
terminal.state === "4"
? "failed"
: terminal.state === "5"
? "cancelled"
: terminal.state === "6"
? "additional-input"
: "human-input",
)
expect((failure as Error).message).not.toContain("at the vendor")
expect(queryCount).toBe(1)
})
}
Expand Down Expand Up @@ -1176,8 +1186,10 @@ describe("XiaoYunque generation engine", () => {
output_directory: output,
}), imageModel, new AbortController().signal).catch((error: unknown) => error)

expect(failure).toBeInstanceOf(Error)
expect((failure as Error).message).toBe(terminal.message)
expect(failure).toBeInstanceOf(XiaoYunqueTerminalGenerationError)
expect((failure as XiaoYunqueTerminalGenerationError).reason).toBe(
terminal.state === 6 ? "additional-input" : "human-input",
)
expect(queryCount).toBe(1)
})
}
Expand Down
2 changes: 1 addition & 1 deletion packages/tools/xiaoyunque-mcp/test/mcp-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ describe("MCP process", () => {
})
expect(initialized).toMatchObject({
result: {
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.3" },
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.4" },
},
})
const authorization = await harness.request("tools/call", { name: "service.authorize", arguments: {} })
Expand Down
Loading