mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.81.0 with full pending changes
This commit is contained in:
@@ -739,6 +739,14 @@ export interface AsterProviderConfig {
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export interface AutoContinueRequestPayload {
|
||||
enabled: boolean;
|
||||
fast_mode_enabled: boolean;
|
||||
continuation_length: number;
|
||||
sensitivity: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aster 会话信息(匹配后端 SessionInfo 结构)
|
||||
*/
|
||||
@@ -827,6 +835,7 @@ export async function sendAsterMessageStream(
|
||||
providerConfig?: AsterProviderConfig,
|
||||
executionStrategy?: AsterExecutionStrategy,
|
||||
webSearch?: boolean,
|
||||
autoContinue?: AutoContinueRequestPayload,
|
||||
): Promise<void> {
|
||||
const resolvedWorkspaceId = requireWorkspaceId(workspaceId);
|
||||
|
||||
@@ -840,6 +849,7 @@ export async function sendAsterMessageStream(
|
||||
workspace_id: resolvedWorkspaceId,
|
||||
execution_strategy: executionStrategy,
|
||||
web_search: webSearch,
|
||||
auto_continue: autoContinue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { safeInvoke } from "@/lib/dev-bridge/safeInvoke";
|
||||
|
||||
export async function saveExportedDocument(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
await safeInvoke("save_exported_document", {
|
||||
filePath,
|
||||
content,
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,46 @@ export interface AgentRun {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchRunTodoItem {
|
||||
run_id: string;
|
||||
execution_id?: string | null;
|
||||
session_id?: string | null;
|
||||
artifact_paths?: string[];
|
||||
title: string;
|
||||
gate_key?: "topic_select" | "write_mode" | "publish_confirm" | null;
|
||||
status: AgentRunStatus;
|
||||
source: AgentRunSource | string;
|
||||
source_ref: string | null;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchRunTerminalItem {
|
||||
run_id: string;
|
||||
execution_id?: string | null;
|
||||
session_id?: string | null;
|
||||
artifact_paths?: string[];
|
||||
title: string;
|
||||
gate_key?: "topic_select" | "write_mode" | "publish_confirm" | null;
|
||||
status: AgentRunStatus;
|
||||
source: AgentRunSource | string;
|
||||
source_ref: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchRunState {
|
||||
run_state: "idle" | "auto_running";
|
||||
current_gate_key?:
|
||||
| "idle"
|
||||
| "topic_select"
|
||||
| "write_mode"
|
||||
| "publish_confirm"
|
||||
| null;
|
||||
queue_items: ThemeWorkbenchRunTodoItem[];
|
||||
latest_terminal: ThemeWorkbenchRunTerminalItem | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function executionRunList(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
@@ -38,3 +78,14 @@ export async function executionRunGet(
|
||||
): Promise<AgentRun | null> {
|
||||
return await safeInvoke("execution_run_get", { runId });
|
||||
}
|
||||
|
||||
export async function executionRunGetThemeWorkbenchState(
|
||||
sessionId: string,
|
||||
limit: number = 3,
|
||||
): Promise<ThemeWorkbenchRunState> {
|
||||
return await safeInvoke("execution_run_get_theme_workbench_state", {
|
||||
sessionId,
|
||||
session_id: sessionId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,6 +188,21 @@ export interface ContentDetail extends ContentListItem {
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchVersionState {
|
||||
id: string;
|
||||
created_at: number;
|
||||
description?: string;
|
||||
status?: "in_progress" | "pending" | "merged" | "candidate";
|
||||
is_current: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchDocumentState {
|
||||
content_id: string;
|
||||
current_version_id: string;
|
||||
version_count: number;
|
||||
versions: ThemeWorkbenchVersionState[];
|
||||
}
|
||||
|
||||
/** 创建项目请求 */
|
||||
export interface CreateProjectRequest {
|
||||
name: string;
|
||||
@@ -326,6 +341,13 @@ export async function getContent(id: string): Promise<ContentDetail | null> {
|
||||
return invoke("content_get", { id });
|
||||
}
|
||||
|
||||
/** 获取主题工作台文稿版本状态(后端解析 content.metadata) */
|
||||
export async function getThemeWorkbenchDocumentState(
|
||||
id: string,
|
||||
): Promise<ThemeWorkbenchDocumentState | null> {
|
||||
return invoke("content_get_theme_workbench_document_state", { id });
|
||||
}
|
||||
|
||||
/** 获取项目的内容列表 */
|
||||
export async function listContents(
|
||||
projectId: string,
|
||||
|
||||
@@ -170,6 +170,41 @@ export async function readFile(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析会话文件绝对路径
|
||||
*/
|
||||
export async function resolveFilePath(
|
||||
sessionId: string,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
return safeInvoke<string>("session_files_resolve_file_path", {
|
||||
sessionId,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Finder/文件管理器中定位会话文件
|
||||
*/
|
||||
export async function revealFileInFinder(
|
||||
sessionId: string,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
const path = await resolveFilePath(sessionId, fileName);
|
||||
await safeInvoke("reveal_in_finder", { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认应用打开会话文件
|
||||
*/
|
||||
export async function openFileWithDefaultApp(
|
||||
sessionId: string,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
const path = await resolveFilePath(sessionId, fileName);
|
||||
await safeInvoke("open_with_default_app", { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话文件
|
||||
*/
|
||||
@@ -207,3 +242,69 @@ export async function cleanupExpired(maxAgeDays?: number): Promise<number> {
|
||||
export async function cleanupEmpty(): Promise<number> {
|
||||
return safeInvoke<number>("session_files_cleanup_empty");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 图片上传 API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 上传图片到会话
|
||||
* @param sessionId 会话 ID
|
||||
* @param filePath 本地图片文件路径
|
||||
* @returns 图片在会话中的访问路径
|
||||
*/
|
||||
export async function uploadImageToSession(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
return safeInvoke<string>("upload_image_to_session", {
|
||||
sessionId,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话中读取图片(返回 base64 编码)
|
||||
* @param sessionId 会话 ID
|
||||
* @param fileName 文件名
|
||||
* @returns base64 编码的图片数据
|
||||
*/
|
||||
export async function readImageFromSession(
|
||||
sessionId: string,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
return safeInvoke<string>("read_image_from_session", {
|
||||
sessionId,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文档导入 API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 导入文档内容
|
||||
* @param filePath 本地文档文件路径
|
||||
* @returns 文档的文本内容
|
||||
*/
|
||||
export async function importDocument(filePath: string): Promise<string> {
|
||||
return safeInvoke<string>("import_document", { filePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入文档并保存到会话
|
||||
* @param sessionId 会话 ID
|
||||
* @param filePath 本地文档文件路径
|
||||
* @returns [文档内容, 保存的文件名]
|
||||
*/
|
||||
export async function importDocumentToSession(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<[string, string]> {
|
||||
return safeInvoke<[string, string]>("import_document_to_session", {
|
||||
sessionId,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
getWorkspaceRepairHistory,
|
||||
type WorkspaceRepairRecord,
|
||||
} from "@/lib/workspaceHealthTelemetry";
|
||||
import { getActiveContentTarget } from "@/lib/activeContentTarget";
|
||||
import {
|
||||
getThemeWorkbenchDocumentState,
|
||||
type ThemeWorkbenchDocumentState,
|
||||
} from "@/lib/api/project";
|
||||
|
||||
export interface CrashDiagnosticPayload {
|
||||
generated_at: string;
|
||||
@@ -32,6 +37,7 @@ export interface CrashDiagnosticPayload {
|
||||
invoke_error_buffer?: InvokeErrorBufferEntry[];
|
||||
persisted_log_tail?: LogEntry[];
|
||||
workspace_repair_history?: WorkspaceRepairRecord[];
|
||||
theme_workbench_document_state?: ThemeWorkbenchDocumentState | null;
|
||||
diagnostic_collection_notes?: string[];
|
||||
}
|
||||
|
||||
@@ -113,6 +119,7 @@ interface BuildCrashDiagnosticPayloadParams {
|
||||
maxInvokeErrors?: number;
|
||||
maxPersistedLogs?: number;
|
||||
maxWorkspaceRepairs?: number;
|
||||
themeWorkbenchDocumentState?: ThemeWorkbenchDocumentState | null;
|
||||
}
|
||||
|
||||
export function buildCrashDiagnosticPayload(
|
||||
@@ -130,6 +137,7 @@ export function buildCrashDiagnosticPayload(
|
||||
maxInvokeErrors = 40,
|
||||
maxPersistedLogs = 200,
|
||||
maxWorkspaceRepairs = 50,
|
||||
themeWorkbenchDocumentState = null,
|
||||
} = params;
|
||||
|
||||
return {
|
||||
@@ -152,12 +160,27 @@ export function buildCrashDiagnosticPayload(
|
||||
invoke_error_buffer: getInvokeErrorBuffer(maxInvokeErrors),
|
||||
persisted_log_tail: persistedLogTail.slice(-maxPersistedLogs),
|
||||
workspace_repair_history: getWorkspaceRepairHistory(maxWorkspaceRepairs),
|
||||
theme_workbench_document_state: themeWorkbenchDocumentState,
|
||||
diagnostic_collection_notes: collectionNotes.filter((item) =>
|
||||
typeof item === "string" && item.trim().length > 0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectThemeWorkbenchDocumentStateForDiagnostic(): Promise<ThemeWorkbenchDocumentState | null> {
|
||||
const activeTarget = getActiveContentTarget();
|
||||
if (!activeTarget?.contentId || activeTarget.canvasType !== "document") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await getThemeWorkbenchDocumentState(activeTarget.contentId);
|
||||
} catch (error) {
|
||||
console.warn("[crashDiagnostic] 获取主题工作台文稿状态失败:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyCrashDiagnosticToClipboard(
|
||||
payload: CrashDiagnosticPayload,
|
||||
): Promise<void> {
|
||||
@@ -248,6 +271,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string {
|
||||
const invokeErrorCount = payload.invoke_error_buffer?.length ?? 0;
|
||||
const persistedLogCount = payload.persisted_log_tail?.length ?? 0;
|
||||
const workspaceRepairCount = payload.workspace_repair_history?.length ?? 0;
|
||||
const versionCount = payload.theme_workbench_document_state?.version_count ?? 0;
|
||||
const dsnConfigured = payload.crash_reporting.dsn ? "是" : "否";
|
||||
return [
|
||||
`- 版本:${payload.app_version}`,
|
||||
@@ -259,6 +283,7 @@ function buildDiagnosticSummary(payload: CrashDiagnosticPayload): string {
|
||||
`- 命令调用失败缓存条数:${invokeErrorCount}`,
|
||||
`- 持久化日志尾部行数:${persistedLogCount}`,
|
||||
`- Workspace 自动修复记录条数:${workspaceRepairCount}`,
|
||||
`- 主题工作台文稿版本数:${versionCount}`,
|
||||
`- 崩溃上报已启用:${payload.crash_reporting.enabled ? "是" : "否"}(DSN 已配置:${dsnConfigured})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const DOCUMENT_EDITOR_FOCUS_EVENT = "proxycast:document-editor-focus";
|
||||
|
||||
export interface DocumentEditorFocusDetail {
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
export function emitDocumentEditorFocus(focused: boolean): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<DocumentEditorFocusDetail>(DOCUMENT_EDITOR_FOCUS_EVENT, {
|
||||
detail: { focused },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeDocumentEditorFocus(
|
||||
callback: (focused: boolean) => void,
|
||||
): () => void {
|
||||
if (typeof window === "undefined") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handle = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<DocumentEditorFocusDetail>;
|
||||
callback(Boolean(customEvent.detail?.focused));
|
||||
};
|
||||
|
||||
window.addEventListener(DOCUMENT_EDITOR_FOCUS_EVENT, handle);
|
||||
return () => {
|
||||
window.removeEventListener(DOCUMENT_EDITOR_FOCUS_EVENT, handle);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findImageProviderById,
|
||||
findImageProviderForSelection,
|
||||
getImageModelsForProvider,
|
||||
isImageProvider,
|
||||
pickImageModelBySelection,
|
||||
type ImageProviderCandidate,
|
||||
} from "./imageGeneration";
|
||||
|
||||
interface MockProvider extends ImageProviderCandidate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const providers: MockProvider[] = [
|
||||
{ id: "new-api", type: "openai", name: "OpenAI 兼容" },
|
||||
{ id: "doubao-image", type: "openai", name: "即梦" },
|
||||
{ id: "kling", type: "openai", name: "可灵" },
|
||||
];
|
||||
|
||||
describe("imageGeneration", () => {
|
||||
it("应识别图片 Provider", () => {
|
||||
expect(isImageProvider("new-api", "openai")).toBe(true);
|
||||
expect(isImageProvider("tts-only", "audio")).toBe(false);
|
||||
});
|
||||
|
||||
it("应按项目配置优先匹配指定 Provider", () => {
|
||||
expect(findImageProviderById(providers, "doubao-image")?.name).toBe("即梦");
|
||||
expect(findImageProviderById(providers, "missing-provider")).toBeNull();
|
||||
});
|
||||
|
||||
it("应按预设模型偏好自动选择 Provider", () => {
|
||||
expect(findImageProviderForSelection(providers, "basic")?.id).toBe("new-api");
|
||||
expect(findImageProviderForSelection(providers, "jimeng")?.id).toBe(
|
||||
"doubao-image",
|
||||
);
|
||||
expect(findImageProviderForSelection(providers, "kling")?.id).toBe("kling");
|
||||
});
|
||||
|
||||
it("应按预设模型偏好自动选择模型", () => {
|
||||
expect(pickImageModelBySelection([], "basic")).toBe("gpt-image-1");
|
||||
expect(
|
||||
pickImageModelBySelection(["flux-pro", "gpt-image-1"], "basic"),
|
||||
).toBe("gpt-image-1");
|
||||
expect(pickImageModelBySelection([], "jimeng")).toBe("seedream-3.0");
|
||||
});
|
||||
|
||||
it("应解析 Provider 可用模型列表", () => {
|
||||
expect(getImageModelsForProvider("new-api", "openai")[0]?.id).toBe("dall-e-3");
|
||||
expect(
|
||||
getImageModelsForProvider("custom-provider", "openai", ["gpt-image-1"])[0]
|
||||
?.id,
|
||||
).toBe("gpt-image-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file 图片生成 Provider 选择辅助
|
||||
* @description 统一图片 Provider 的识别、优先级选择与模型自动匹配
|
||||
* @module lib/imageGeneration
|
||||
*/
|
||||
|
||||
import {
|
||||
IMAGE_GEN_MODELS,
|
||||
type ImageGenModel,
|
||||
} from "@/components/image-gen/types";
|
||||
|
||||
export type ImageModelPreset = "basic" | "jimeng" | "kling";
|
||||
|
||||
export interface ImageProviderCandidate {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ImageProviderModelCandidate extends ImageProviderCandidate {
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
function normalizeProviderSignature(providerId: string, providerType: string): string {
|
||||
return `${providerId}:${providerType}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function isImageProvider(providerId: string, providerType: string): boolean {
|
||||
const normalized = normalizeProviderSignature(providerId, providerType);
|
||||
return (
|
||||
normalized.includes("new-api") ||
|
||||
normalized.includes("openai") ||
|
||||
normalized.includes("doubao") ||
|
||||
normalized.includes("volc") ||
|
||||
normalized.includes("dashscope") ||
|
||||
normalized.includes("alibaba") ||
|
||||
normalized.includes("qwen") ||
|
||||
normalized.includes("kling") ||
|
||||
normalized.includes("minimax") ||
|
||||
normalized.includes("hailuo") ||
|
||||
normalized.includes("image")
|
||||
);
|
||||
}
|
||||
|
||||
export function findImageProviderById<T extends ImageProviderCandidate>(
|
||||
providers: T[],
|
||||
providerId?: string | null,
|
||||
): T | null {
|
||||
const normalizedProviderId = providerId?.trim().toLowerCase();
|
||||
if (!normalizedProviderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
providers.find((provider) => provider.id.trim().toLowerCase() === normalizedProviderId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function findImageProviderForSelection<T extends ImageProviderCandidate>(
|
||||
providers: T[],
|
||||
modelType: ImageModelPreset,
|
||||
): T | null {
|
||||
const preferredKeywords: string[] =
|
||||
modelType === "jimeng"
|
||||
? ["doubao", "volc"]
|
||||
: modelType === "kling"
|
||||
? ["kling", "minimax", "hailuo"]
|
||||
: ["new-api", "openai", "dashscope", "alibaba", "qwen"];
|
||||
|
||||
for (const keyword of preferredKeywords) {
|
||||
const matched = providers.find((provider) =>
|
||||
normalizeProviderSignature(provider.id, provider.type).includes(keyword),
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] ?? null;
|
||||
}
|
||||
|
||||
export function pickImageModelBySelection(
|
||||
models: string[],
|
||||
modelType: ImageModelPreset,
|
||||
): string {
|
||||
if (models.length === 0) {
|
||||
return modelType === "jimeng"
|
||||
? "seedream-3.0"
|
||||
: modelType === "kling"
|
||||
? "kling-2.6"
|
||||
: "gpt-image-1";
|
||||
}
|
||||
|
||||
const normalizedModels = models.map((model) => model.toLowerCase());
|
||||
const priorities =
|
||||
modelType === "jimeng"
|
||||
? ["jimeng", "seedream", "doubao", "volc", "pro"]
|
||||
: modelType === "kling"
|
||||
? ["kling", "hailuo", "minimax"]
|
||||
: ["gpt-image", "flux", "sd", "image", "wanx", "seedream"];
|
||||
|
||||
for (const keyword of priorities) {
|
||||
const index = normalizedModels.findIndex((model) => model.includes(keyword));
|
||||
if (index >= 0) {
|
||||
return models[index] ?? models[0] ?? "gpt-image-1";
|
||||
}
|
||||
}
|
||||
|
||||
return models[0] ?? "gpt-image-1";
|
||||
}
|
||||
|
||||
export function getImageModelsForProvider(
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
customModels?: string[],
|
||||
): ImageGenModel[] {
|
||||
if (customModels && customModels.length > 0) {
|
||||
return customModels.map((modelId) => ({
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
supportedSizes: [
|
||||
"1024x1024",
|
||||
"768x1344",
|
||||
"1344x768",
|
||||
"1792x1024",
|
||||
"1024x1792",
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
if (IMAGE_GEN_MODELS[providerId]) {
|
||||
return IMAGE_GEN_MODELS[providerId] ?? [];
|
||||
}
|
||||
|
||||
if (IMAGE_GEN_MODELS[providerType]) {
|
||||
return IMAGE_GEN_MODELS[providerType] ?? [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getImageModelIdsForProvider(
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
customModels?: string[],
|
||||
): string[] {
|
||||
return getImageModelsForProvider(providerId, providerType, customModels).map(
|
||||
(model) => model.id,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPersistedMediaGenerationPreference,
|
||||
findMediaProviderById,
|
||||
findTtsProviderForSelection,
|
||||
findVideoProviderForSelection,
|
||||
getTtsModelsForProvider,
|
||||
getVideoModelsForProvider,
|
||||
hasMediaGenerationPreferenceOverride,
|
||||
isTtsProvider,
|
||||
isVideoProvider,
|
||||
pickTtsModel,
|
||||
pickVideoModelByVersion,
|
||||
resolveMediaGenerationPreference,
|
||||
} from "./mediaGeneration";
|
||||
|
||||
const providers = [
|
||||
{ id: "doubao-video", type: "openai", customModels: [] },
|
||||
{ id: "openai-tts", type: "openai", customModels: ["gpt-4o-mini-tts"] },
|
||||
{ id: "qwen-video", type: "openai", customModels: [] },
|
||||
];
|
||||
|
||||
describe("mediaGeneration", () => {
|
||||
it("应按 ID 查找媒体 Provider", () => {
|
||||
expect(findMediaProviderById(providers, "doubao-video")?.id).toBe(
|
||||
"doubao-video",
|
||||
);
|
||||
expect(findMediaProviderById(providers, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("应识别视频和语音 Provider", () => {
|
||||
expect(isVideoProvider("doubao-video")).toBe(true);
|
||||
expect(isVideoProvider("openai-tts")).toBe(false);
|
||||
expect(isTtsProvider("openai-tts", "openai")).toBe(true);
|
||||
expect(isTtsProvider("runway-video", "video")).toBe(false);
|
||||
});
|
||||
|
||||
it("应解析视频 Provider 模型并按版本挑选", () => {
|
||||
expect(getVideoModelsForProvider("doubao-video")[0]).toBe(
|
||||
"seedance-1-5-pro-251215",
|
||||
);
|
||||
expect(
|
||||
pickVideoModelByVersion(
|
||||
["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
|
||||
"v2-1-master",
|
||||
),
|
||||
).toBe("seedance-1-5-pro-251215");
|
||||
});
|
||||
|
||||
it("应解析语音 Provider 模型并挑选 TTS 模型", () => {
|
||||
expect(getTtsModelsForProvider()[0]).toBe("gpt-4o-mini-tts");
|
||||
expect(pickTtsModel(["gpt-4o-mini-tts", "gpt-image-1"])).toBe(
|
||||
"gpt-4o-mini-tts",
|
||||
);
|
||||
});
|
||||
|
||||
it("应按默认策略选择视频与语音 Provider", () => {
|
||||
expect(findVideoProviderForSelection(providers, "jimeng")?.id).toBe(
|
||||
"doubao-video",
|
||||
);
|
||||
expect(findTtsProviderForSelection([providers[1]])?.id).toBe("openai-tts");
|
||||
});
|
||||
|
||||
it("应优先使用项目覆盖,否则回退到全局默认", () => {
|
||||
expect(
|
||||
resolveMediaGenerationPreference(
|
||||
{
|
||||
preferredProviderId: "project-provider",
|
||||
preferredModelId: "project-model",
|
||||
allowFallback: false,
|
||||
},
|
||||
{
|
||||
preferredProviderId: "global-provider",
|
||||
preferredModelId: "global-model",
|
||||
allowFallback: true,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
preferredProviderId: "project-provider",
|
||||
preferredModelId: "project-model",
|
||||
allowFallback: false,
|
||||
source: "project",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMediaGenerationPreference(undefined, {
|
||||
preferredProviderId: "global-provider",
|
||||
preferredModelId: "global-model",
|
||||
allowFallback: false,
|
||||
}),
|
||||
).toEqual({
|
||||
preferredProviderId: "global-provider",
|
||||
preferredModelId: "global-model",
|
||||
allowFallback: false,
|
||||
source: "global",
|
||||
});
|
||||
});
|
||||
|
||||
it("应在无有效覆盖时不持久化默认值", () => {
|
||||
expect(hasMediaGenerationPreferenceOverride(undefined)).toBe(false);
|
||||
expect(
|
||||
buildPersistedMediaGenerationPreference({
|
||||
preferredProviderId: "",
|
||||
preferredModelId: "model-only",
|
||||
allowFallback: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildPersistedMediaGenerationPreference({
|
||||
allowFallback: false,
|
||||
}),
|
||||
).toEqual({
|
||||
preferredProviderId: undefined,
|
||||
preferredModelId: undefined,
|
||||
allowFallback: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @file 视频与语音生成选择辅助
|
||||
* @description 统一视频 / 语音 Provider 识别、模型解析与默认选择策略
|
||||
* @module lib/mediaGeneration
|
||||
*/
|
||||
|
||||
export interface MediaProviderCandidate {
|
||||
id: string;
|
||||
type?: string;
|
||||
customModels?: string[];
|
||||
}
|
||||
|
||||
export interface MediaGenerationPreference {
|
||||
preferredProviderId?: string;
|
||||
preferredModelId?: string;
|
||||
allowFallback?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaGenerationDefaults {
|
||||
image?: MediaGenerationPreference;
|
||||
video?: MediaGenerationPreference;
|
||||
voice?: MediaGenerationPreference;
|
||||
}
|
||||
|
||||
export interface ResolvedMediaGenerationPreference {
|
||||
preferredProviderId?: string;
|
||||
preferredModelId?: string;
|
||||
allowFallback: boolean;
|
||||
source: "project" | "global" | "auto";
|
||||
}
|
||||
|
||||
export type VideoModelPreset = "keling" | "jimeng" | "wan-2-5";
|
||||
export type VideoModelVersion = "v2-1-master" | "v2" | "v1-6";
|
||||
|
||||
const VIDEO_MODEL_PRESETS: Record<string, string[]> = {
|
||||
doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
|
||||
volcengine: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
|
||||
dashscope: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
|
||||
alibaba: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
|
||||
qwen: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
|
||||
sora: ["sora-2", "sora-2-pro"],
|
||||
openai: ["sora-2", "sora-2-pro"],
|
||||
veo: ["veo-3.1"],
|
||||
google: ["veo-3.1"],
|
||||
vertex: ["veo-3.1"],
|
||||
kling: ["kling-2.6"],
|
||||
minimax: ["minimax-hailuo-2.3", "minimax-hailuo-02"],
|
||||
hailuo: ["minimax-hailuo-2.3", "minimax-hailuo-02"],
|
||||
runway: ["runway-gen-4-turbo"],
|
||||
};
|
||||
|
||||
function normalizeMaybeString(value?: string | null): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function normalizeMediaGenerationPreference(
|
||||
preference?: MediaGenerationPreference | null,
|
||||
): MediaGenerationPreference {
|
||||
return {
|
||||
preferredProviderId: normalizeMaybeString(preference?.preferredProviderId),
|
||||
preferredModelId: normalizeMaybeString(preference?.preferredModelId),
|
||||
allowFallback: preference?.allowFallback,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMediaGenerationPreferenceOverride(
|
||||
preference?: MediaGenerationPreference | null,
|
||||
): boolean {
|
||||
const normalized = normalizeMediaGenerationPreference(preference);
|
||||
return Boolean(
|
||||
normalized.preferredProviderId ||
|
||||
normalized.preferredModelId ||
|
||||
normalized.allowFallback === false,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPersistedMediaGenerationPreference(
|
||||
preference?: MediaGenerationPreference | null,
|
||||
): MediaGenerationPreference | undefined {
|
||||
const normalized = normalizeMediaGenerationPreference(preference);
|
||||
|
||||
if (!normalized.preferredProviderId) {
|
||||
normalized.preferredModelId = undefined;
|
||||
}
|
||||
|
||||
if (!hasMediaGenerationPreferenceOverride(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
preferredProviderId: normalized.preferredProviderId,
|
||||
preferredModelId: normalized.preferredModelId,
|
||||
allowFallback: normalized.allowFallback ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMediaGenerationPreference(
|
||||
projectPreference?: MediaGenerationPreference | null,
|
||||
globalPreference?: MediaGenerationPreference | null,
|
||||
): ResolvedMediaGenerationPreference {
|
||||
const normalizedProject =
|
||||
normalizeMediaGenerationPreference(projectPreference);
|
||||
const normalizedGlobal = normalizeMediaGenerationPreference(globalPreference);
|
||||
|
||||
const projectSelectsModel = Boolean(
|
||||
normalizedProject.preferredProviderId || normalizedProject.preferredModelId,
|
||||
);
|
||||
const globalSelectsModel = Boolean(
|
||||
normalizedGlobal.preferredProviderId || normalizedGlobal.preferredModelId,
|
||||
);
|
||||
|
||||
const preferredProviderId =
|
||||
normalizedProject.preferredProviderId ??
|
||||
normalizedGlobal.preferredProviderId;
|
||||
|
||||
const preferredModelId = normalizedProject.preferredProviderId
|
||||
? normalizedProject.preferredModelId
|
||||
: (normalizedProject.preferredModelId ?? normalizedGlobal.preferredModelId);
|
||||
|
||||
return {
|
||||
preferredProviderId,
|
||||
preferredModelId,
|
||||
allowFallback:
|
||||
normalizedProject.allowFallback ?? normalizedGlobal.allowFallback ?? true,
|
||||
source: projectSelectsModel
|
||||
? "project"
|
||||
: globalSelectsModel
|
||||
? "global"
|
||||
: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
export function findMediaProviderById<T extends MediaProviderCandidate>(
|
||||
providers: T[],
|
||||
providerId?: string | null,
|
||||
): T | null {
|
||||
const normalizedProviderId = providerId?.trim().toLowerCase();
|
||||
if (!normalizedProviderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
providers.find(
|
||||
(provider) => provider.id.trim().toLowerCase() === normalizedProviderId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function isVideoProvider(providerId: string): boolean {
|
||||
const normalized = providerId.toLowerCase();
|
||||
return (
|
||||
normalized.includes("doubao") ||
|
||||
normalized.includes("volc") ||
|
||||
normalized.includes("dashscope") ||
|
||||
normalized.includes("alibaba") ||
|
||||
normalized.includes("qwen") ||
|
||||
normalized.includes("video") ||
|
||||
normalized.includes("runway") ||
|
||||
normalized.includes("minimax") ||
|
||||
normalized.includes("kling") ||
|
||||
normalized.includes("sora") ||
|
||||
normalized.includes("veo")
|
||||
);
|
||||
}
|
||||
|
||||
export function getVideoModelsForProvider(
|
||||
providerId: string,
|
||||
customModels?: string[],
|
||||
): string[] {
|
||||
if (customModels && customModels.length > 0) {
|
||||
return customModels;
|
||||
}
|
||||
|
||||
const normalizedId = providerId.toLowerCase();
|
||||
for (const [key, models] of Object.entries(VIDEO_MODEL_PRESETS)) {
|
||||
if (normalizedId.includes(key)) {
|
||||
return models;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function findVideoProviderForSelection<T extends MediaProviderCandidate>(
|
||||
providers: T[],
|
||||
modelType: VideoModelPreset,
|
||||
): T | null {
|
||||
const preferredKeywords: string[] =
|
||||
modelType === "keling"
|
||||
? ["kling", "hailuo", "minimax"]
|
||||
: modelType === "jimeng"
|
||||
? ["doubao", "volc"]
|
||||
: ["dashscope", "alibaba", "qwen"];
|
||||
|
||||
for (const keyword of preferredKeywords) {
|
||||
const matched = providers.find((provider) =>
|
||||
provider.id.toLowerCase().includes(keyword),
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0] ?? null;
|
||||
}
|
||||
|
||||
export function pickVideoModelByVersion(
|
||||
models: string[],
|
||||
version: VideoModelVersion,
|
||||
): string {
|
||||
if (models.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const normalizedModels = models.map((model) => model.toLowerCase());
|
||||
const priorities =
|
||||
version === "v2-1-master"
|
||||
? ["2.1", "master", "pro", "turbo"]
|
||||
: version === "v2"
|
||||
? ["v2", "2.0", "pro", "turbo"]
|
||||
: ["1.6", "v1-6", "lite", "1.5"];
|
||||
|
||||
for (const keyword of priorities) {
|
||||
const index = normalizedModels.findIndex((model) =>
|
||||
model.includes(keyword),
|
||||
);
|
||||
if (index >= 0) {
|
||||
return models[index] ?? models[0] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return models[0] ?? "";
|
||||
}
|
||||
|
||||
export function isTtsProvider(
|
||||
providerId: string,
|
||||
providerType: string,
|
||||
): boolean {
|
||||
const normalized = `${providerId}:${providerType}`.toLowerCase();
|
||||
return (
|
||||
normalized.includes("openai") ||
|
||||
normalized.includes("new-api") ||
|
||||
normalized.includes("azure") ||
|
||||
normalized.includes("google") ||
|
||||
normalized.includes("voice") ||
|
||||
normalized.includes("tts")
|
||||
);
|
||||
}
|
||||
|
||||
export function findTtsProviderForSelection<T extends MediaProviderCandidate>(
|
||||
providers: T[],
|
||||
): T | null {
|
||||
const preferredKeywords = ["openai", "new-api", "azure", "google", "tts"];
|
||||
for (const keyword of preferredKeywords) {
|
||||
const matched = providers.find((provider) =>
|
||||
`${provider.id}:${provider.type ?? ""}`.toLowerCase().includes(keyword),
|
||||
);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
return providers[0] ?? null;
|
||||
}
|
||||
|
||||
export function getTtsModelsForProvider(customModels?: string[]): string[] {
|
||||
if (customModels && customModels.length > 0) {
|
||||
return customModels;
|
||||
}
|
||||
return ["gpt-4o-mini-tts"];
|
||||
}
|
||||
|
||||
export function pickTtsModel(models: string[]): string {
|
||||
if (models.length === 0) {
|
||||
return "gpt-4o-mini-tts";
|
||||
}
|
||||
const normalized = models.map((model) => model.toLowerCase());
|
||||
const preferredKeywords = ["tts", "speech", "audio", "gpt-4o-mini-tts"];
|
||||
for (const keyword of preferredKeywords) {
|
||||
const index = normalized.findIndex((model) => model.includes(keyword));
|
||||
if (index >= 0) {
|
||||
return models[index] ?? "gpt-4o-mini-tts";
|
||||
}
|
||||
}
|
||||
const nonImageModel = models.find(
|
||||
(model) => !model.toLowerCase().includes("image"),
|
||||
);
|
||||
if (nonImageModel) {
|
||||
return nonImageModel;
|
||||
}
|
||||
return "gpt-4o-mini-tts";
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export const EDITOR_PERF_DEBUG_KEY = "proxycast:editor-perf-debug";
|
||||
export const INPUT_LATENCY_DEBUG_KEY = "proxycast:input-latency-debug";
|
||||
|
||||
interface RenderPerfPayload {
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function isEditorPerfDebugEnabled(): boolean {
|
||||
return isDebugFlagEnabled(EDITOR_PERF_DEBUG_KEY);
|
||||
}
|
||||
|
||||
export function isInputLatencyDebugEnabled(): boolean {
|
||||
return isDebugFlagEnabled(INPUT_LATENCY_DEBUG_KEY);
|
||||
}
|
||||
|
||||
export function isDebugFlagEnabled(key: string): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function logRenderPerf(
|
||||
component: string,
|
||||
renderCount: number,
|
||||
sinceLastCommitMs: number | null,
|
||||
payload: RenderPerfPayload,
|
||||
): void {
|
||||
if (!isEditorPerfDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
const shouldLog =
|
||||
renderCount <= 5 ||
|
||||
renderCount % 20 === 0 ||
|
||||
(sinceLastCommitMs !== null && sinceLastCommitMs < 8);
|
||||
if (!shouldLog) {
|
||||
return;
|
||||
}
|
||||
console.debug(
|
||||
`[RenderPerf] ${component}`,
|
||||
JSON.stringify({
|
||||
renderCount,
|
||||
sinceLastCommitMs:
|
||||
sinceLastCommitMs === null ? null : Number(sinceLastCommitMs.toFixed(2)),
|
||||
...payload,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1053,6 +1053,14 @@ const defaultMocks: Record<string, any> = {
|
||||
generate_content_creator_tasks: () => 0,
|
||||
execution_run_list: () => [],
|
||||
execution_run_get: () => null,
|
||||
execution_run_get_theme_workbench_state: () => ({
|
||||
run_state: "idle",
|
||||
current_gate_key: "idle",
|
||||
queue_items: [],
|
||||
latest_terminal: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
content_get_theme_workbench_document_state: () => null,
|
||||
|
||||
// Workspace 相关
|
||||
workspace_list: () => [],
|
||||
|
||||
Reference in New Issue
Block a user