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.3",
"version": "0.3.4",
"license": "MIT",
"compatibility": {
"pluginSchema": "convax.plugin/3",
Expand All @@ -13,7 +13,7 @@
"companions": [
{
"command": "convax-xiaoyunque-mcp",
"version": "0.3.1",
"version": "0.3.2",
"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.3",
"version": "0.3.4",
"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.3",
"version": "0.3.4",
"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.1",
"version": "0.3.2",
"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.1" },
clientInfo: { name: "convax-xiaoyunque-live-smoke", version: "0.3.2" },
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.1" },
clientInfo: { name: "convax-xiaoyunque-live-video-smoke", version: "0.3.2" },
protocolVersion: "2025-03-26",
})
if ((initialized.result as Record<string, unknown> | undefined)?.protocolVersion !== "2025-03-26") {
Expand Down
10 changes: 9 additions & 1 deletion packages/tools/xiaoyunque-mcp/src/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import {
XiaoYunqueAuthenticationError,
XiaoYunqueQueryTimeoutError,
XiaoYunqueReferenceAssetRegistrationError,
XiaoYunqueRequestRejectedError,
} from "./xiaoyunque-api.ts"

Expand Down Expand Up @@ -151,6 +152,9 @@ 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 XiaoYunqueReferenceAssetRegistrationError) {
return "XiaoYunque could not prepare the reference image for generation. No generation was submitted; try again."
}
if (error instanceof XiaoYunqueRequestRejectedError) {
return "XiaoYunque did not accept this generation request. Refresh Services and try a model listed for this capability."
}
Expand All @@ -164,6 +168,7 @@ export const safeGenerationDiagnosticCodes = [
"status-check-rejected",
"status-check-timeout",
"unsupported-image-model",
"reference-image-registration-failed",
"upstream-envelope-rejected",
"upstream-http-rejected",
"upstream-request-rejected",
Expand All @@ -179,6 +184,9 @@ export function safeGenerationDiagnosticCode(error: unknown): SafeGenerationDiag
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 XiaoYunqueReferenceAssetRegistrationError) {
return "reference-image-registration-failed"
}
if (error instanceof XiaoYunqueRequestRejectedError) return error.diagnosticCode
return "unclassified-failure"
}
Expand Down Expand Up @@ -296,7 +304,7 @@ export class McpServer {
this.#sendResult(request.id, {
capabilities: { tools: {} },
protocolVersion,
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.1" },
serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.2" },
})
return
}
Expand Down
86 changes: 80 additions & 6 deletions packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
const odinUserInfoPath = "/api/biz/v1/common/get_odin_user_info"
const userWorkspacePath = "/api/web/v1/workspace/get_user_workspace"
const uploadPath = "/api/web/v1/common/upload_file"
const assetCreateV2Path = "/api/biz/v1/asset/create_v2"
const submitPath = "/api/biz/v1/agent/submit_run"
const getThreadPath = "/api/biz/v1/agent/get_thread"

Expand All @@ -28,6 +29,20 @@ export class XiaoYunqueQueryTimeoutError extends Error {
override name = "XiaoYunqueQueryTimeoutError"
}

/**
* The first-party upload succeeded, but its EverPhoto asset could not be
* registered as the Pippit asset identity required by image and video generation.
* Never attach upstream response details to this error because it crosses the
* MCP diagnostic boundary.
*/
export class XiaoYunqueReferenceAssetRegistrationError extends Error {
override name = "XiaoYunqueReferenceAssetRegistrationError"

constructor() {
super("XiaoYunque reference image asset registration failed")
}
}

/**
* The first-party Web API returned a non-success status or envelope. Keep the
* raw response inside the API boundary while still letting the MCP surface
Expand Down Expand Up @@ -152,6 +167,16 @@ function optionalBoundedString(value: unknown, label: string, maximumBytes = 4_0
return boundedString(value, label, maximumBytes)
}

function optionalPippitAssetId(value: unknown) {
if (value === undefined || value === null) return undefined
if (typeof value !== "string") {
return boundedString(value, "XiaoYunque uploaded Pippit asset id", 1_024)
}
const candidate = value.trim()
if (candidate.length === 0) return undefined
return boundedString(candidate, "XiaoYunque uploaded Pippit asset id", 1_024)
}

function nonNegativeInteger(value: unknown, label: string) {
const number = typeof value === "string" && /^(?:0|[1-9]\d*)$/u.test(value)
? Number(value)
Expand Down Expand Up @@ -209,6 +234,24 @@ function requireSuccess(value: unknown, status: number, label: string) {
return envelope
}

function requireAssetRegistrationSuccess(value: unknown, status: number) {
const label = "XiaoYunque reference image asset registration"
const envelope = record(value)
if (!envelope) throw new Error(`${label} returned an invalid response`)
if (looksLikeAuthenticationFailure(envelope, status)) {
throw new XiaoYunqueAuthenticationError("XiaoYunque authorization is no longer valid")
}
if (status < 200 || status >= 300) {
throw new XiaoYunqueRequestRejectedError(`${label} was rejected`, "upstream-http-rejected")
}
// Unlike generation envelopes, the first-party AssetCreateV2 contract may
// omit `ret`; an explicit value must still be the canonical numeric zero.
if (envelope.ret !== undefined && envelope.ret !== 0 && envelope.ret !== "0") {
throw new XiaoYunqueRequestRejectedError(`${label} was rejected`, "upstream-envelope-rejected")
}
return envelope
}

async function boundedJson(response: Response, label: string) {
if (response.status === 401) {
await response.body?.cancel().catch(() => undefined)
Expand Down Expand Up @@ -292,11 +335,7 @@ function uploadedAsset(
const format = optionalBoundedString(data.format, "XiaoYunque uploaded asset format", 128)
const md5 = optionalBoundedString(data.md5, "XiaoYunque uploaded asset md5", 256)
const mime = optionalBoundedString(data.mime, "XiaoYunque uploaded asset MIME type", 256)
const pippitAssetId = optionalBoundedString(
data.pippit_asset_id,
"XiaoYunque uploaded Pippit asset id",
1_024,
)
const pippitAssetId = optionalPippitAssetId(data.pippit_asset_id)
const ratio = width && height ? `${width}:${height}` : undefined
return {
assetId,
Expand Down Expand Up @@ -578,7 +617,42 @@ export class XiaoYunqueApi {
body.append("asset_type", String(assetType))
const response = await this.#request(uploadPath, session, signal, { body, method: "POST" })
const payload = requireSuccess(response.value, response.status, "XiaoYunque reference upload")
return uploadedAsset(payload.data, reference, assetType, this.#allowLoopbackTest)
const asset = uploadedAsset(payload.data, reference, assetType, this.#allowLoopbackTest)
if (assetType !== 2 || asset.pippitAssetId !== undefined) return asset

return {
...asset,
pippitAssetId: await this.#registerUploadedImage(asset.assetId, session, signal),
}
}

async #registerUploadedImage(
assetId: string,
session: StoredWebSession,
signal: AbortSignal,
) {
try {
// `upload_file.asset_id` is an EverPhoto source id. The first-party Web
// product converts it into the identity consumed by image and video generation via
// AssetCreateV2; the two ids are deliberately never treated as aliases.
const response = await this.#jsonRequest(assetCreateV2Path, {
asset_source_type: 3,
asset_source_id: assetId,
asset_type: 1,
Base: { Client: "web" },
}, session, signal)
const payload = requireAssetRegistrationSuccess(response.value, response.status)
const data = record(payload.data)
return boundedString(
data?.PippitAssetID,
"XiaoYunque registered Pippit asset id",
1_024,
)
} catch (error) {
if (signal.aborted) throw cancellationError(signal)
if (error instanceof XiaoYunqueAuthenticationError) throw error
throw new XiaoYunqueReferenceAssetRegistrationError()
}
}

async submitImage(
Expand Down
101 changes: 97 additions & 4 deletions packages/tools/xiaoyunque-mcp/test/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
} from "../src/generator.ts"
import { fingerprintGenerationCall, OperationStore } from "../src/operation-store.ts"
import { webSessionSchema, type StoredWebSession } from "../src/web-session-store.ts"
import { XiaoYunqueApi, XiaoYunqueRequestRejectedError } from "../src/xiaoyunque-api.ts"
import {
XiaoYunqueApi,
XiaoYunqueReferenceAssetRegistrationError,
XiaoYunqueRequestRejectedError,
} from "../src/xiaoyunque-api.ts"

const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0])
const mp4 = Uint8Array.from([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 0, 0])
Expand Down Expand Up @@ -290,13 +294,92 @@ describe("XiaoYunque generation engine", () => {
}
})

test("uses one first frame as a normal image-to-video reference", async () => {
test("does not submit or persist an operation when reference image registration is rejected or incomplete", async () => {
const cases = [
{
name: "rejected",
response: { ret: 4001, errmsg: "private upstream registration detail" },
},
{
name: "missing-id",
response: { data: {} },
},
]

for (const registrationCase of cases) {
const directory = await mkdtemp(path.join(os.tmpdir(), `xiaoyunque-reference-${registrationCase.name}-`))
directories.push(directory)
const output = path.join(directory, "output")
const referencePath = path.join(directory, "reference.png")
await Promise.all([mkdir(output), writeFile(referencePath, png)])
const requestedPaths: string[] = []
let submitCount = 0
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: async (request): Promise<Response> => {
const url = new URL(request.url)
requestedPaths.push(url.pathname)
if (url.pathname === "/api/web/v1/common/upload_file") {
return Response.json({
ret: 0,
data: {
asset_id: "everphoto-reference",
download_url: `${url.origin}/uploaded-reference.png`,
},
})
}
if (url.pathname === "/api/biz/v1/asset/create_v2") {
return Response.json(registrationCase.response)
}
if (url.pathname === "/api/biz/v1/agent/submit_run") submitCount += 1
return new Response("unexpected", { status: 500 })
},
})
const operationId = `reference-registration-${registrationCase.name}`
const operationStore = new OperationStore(path.join(directory, "state", "operations.json"))
const engine = new GenerationEngine({
api: new XiaoYunqueApi(`http://127.0.0.1:${server.port}`),
authorizer: { session: async () => testSession },
operationStore,
pollIntervalMs: 1,
})
try {
const error = await engine.generate(call({
operation_id: operationId,
output: "image",
output_directory: output,
references: [{
kind: "file",
mime_type: "image/png",
name: "reference.png",
node_id: "reference-node",
path: referencePath,
role: "reference_image",
}],
}), imageModel, new AbortController().signal).catch((reason: unknown) => reason)

expect(error).toBeInstanceOf(XiaoYunqueReferenceAssetRegistrationError)
expect(requestedPaths).toEqual([
"/api/web/v1/common/upload_file",
"/api/biz/v1/asset/create_v2",
])
expect(submitCount).toBe(0)
expect(await operationStore.find(operationId)).toBeNull()
} finally {
server.stop(true)
}
}
})

test("registers and submits one Canvas reference image for video generation", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "xiaoyunque-first-frame-video-"))
directories.push(directory)
const output = path.join(directory, "output")
const firstFrame = path.join(directory, "first.png")
await Promise.all([mkdir(output), writeFile(firstFrame, png)])
let uploadCount = 0
let registrationCount = 0
let submittedBody: Record<string, unknown> | undefined
let submittedTask: ReturnType<typeof taskFromSubmitBody> | undefined
const server = Bun.serve({
Expand All @@ -314,10 +397,19 @@ describe("XiaoYunque generation engine", () => {
data: {
asset_id: "first-frame-asset",
download_url: `${url.origin}/uploaded-first.png`,
pippit_asset_id: "pippit-first-frame",
},
})
}
if (url.pathname === "/api/biz/v1/asset/create_v2") {
registrationCount += 1
expect(await request.json()).toEqual({
asset_source_type: 3,
asset_source_id: "first-frame-asset",
asset_type: 1,
Base: { Client: "web" },
})
return Response.json({ data: { PippitAssetID: "pippit-first-frame" } })
}
const accountPreflight = await accountPreflightResponse(request)
if (accountPreflight) return accountPreflight
if (url.pathname === "/api/biz/v1/agent/submit_run") {
Expand Down Expand Up @@ -368,12 +460,13 @@ describe("XiaoYunque generation engine", () => {
name: "first.png",
node_id: "ordinary-canvas-image-node",
path: firstFrame,
role: "first_frame",
role: "reference_image",
}],
}), videoModel, new AbortController().signal)

expect(artifacts[0]?.mimeType).toBe("video/mp4")
expect(uploadCount).toBe(1)
expect(registrationCount).toBe(1)
expect(submittedBody).toMatchObject({
agent_name: "pippit_novel_video_part_agent",
})
Expand Down
Loading