fix(batch-generation): 修复批量生成只执行第一个任务的问题

批量生成时(通过 AIInputBar 指定数量 n),之前只会执行 1 个任务,其余被跳过为"重复"。
修复包括:
- 跳过 batchId 存在时的重复检测逻辑
- 在工作流转换和执行时正确传递批量参数 (batchId, batchIndex, batchTotal)
- 添加跨层数据转换的编码规则文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
liquidandClaude committed 2026-01-26 00:13:03 +08:00
1 parent 78acdbf1f7
commit ebca700560
33 files changed
+486 -1253

No files matched your search

+1
View File
@@ -151,6 +151,7 @@ Service Worker (后台执行)
4. 更新后禁止自动刷新页面,需用户确认
5. SW 枚举值使用小写(`'completed'`、`'image'`、`'video'`),读取 SW 数据时注意匹配
6. 无效配置下创建的任务不应在后续被执行,首次初始化时应清除"孤儿任务"
7. 跨层数据转换必须传递所有字段,特别是 `options`、`metadata` 等可选字段,遗漏会导致功能静默失败
### React 规则
+1 -1
View File
@@ -107,7 +107,7 @@
</script>
<!-- base href 由构建工具处理,CDN 模式下使用相对路径 -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="app-version" content="0.5.50" />
<meta name="app-version" content="0.5.51" />
<link
rel="icon"
+48
View File
@@ -138,3 +138,51 @@ export function getSpeedClass(duration) {
if (duration >= 1000) return 'slow';
return 'normal';
}
/**
* Format and syntax highlight JSON string
* @param {string} jsonStr - JSON string to format
* @returns {string} - HTML with syntax highlighting
*/
export function formatJsonWithHighlight(jsonStr) {
if (!jsonStr) return '';
try {
// Try to parse and re-stringify with indentation
const parsed = JSON.parse(jsonStr);
const formatted = JSON.stringify(parsed, null, 2);
// Apply syntax highlighting
return escapeHtml(formatted)
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span class="json-string">"$1"</span>')
.replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
.replace(/: (true|false)/g, ': <span class="json-boolean">$1</span>')
.replace(/: (null)/g, ': <span class="json-null">$1</span>');
} catch {
// If not valid JSON, just escape and return
return escapeHtml(jsonStr);
}
}
/**
* Parse request body and extract key parameters for quick preview
* @param {string} requestBody - JSON string of request body
* @returns {object} - Extracted parameters { model, size, response_format, etc. }
*/
export function extractRequestParams(requestBody) {
if (!requestBody) return {};
try {
const parsed = JSON.parse(requestBody);
return {
model: parsed.model,
size: parsed.size,
response_format: parsed.response_format,
seconds: parsed.seconds,
n: parsed.n,
};
} catch {
return {};
}
}
+24 -3
View File
@@ -4,7 +4,7 @@
*/
import { state, elements } from './state.js';
import { escapeHtml, formatBytes } from './common.js';
import { escapeHtml, formatBytes, formatJsonWithHighlight, extractRequestParams } from './common.js';
import { downloadJson } from './utils.js';
import { showToast } from './toast.js';
@@ -138,6 +138,9 @@ function createLLMApiEntry(log, isExpanded, onToggle) {
// Show full prompt in header (will wrap if needed)
const promptPreview = log.prompt || '-';
// Extract request parameters from requestBody
const reqParams = extractRequestParams(log.requestBody);
// Render reference images preview
let referenceImagesHtml = '';
@@ -207,6 +210,24 @@ function createLLMApiEntry(log, isExpanded, onToggle) {
<td class="form-data-name">耗时</td>
<td><span class="form-data-value">${durationText}</span></td>
</tr>
${reqParams.size ? `
<tr>
<td class="form-data-name">尺寸</td>
<td><span class="form-data-value">${reqParams.size}</span></td>
</tr>
` : ''}
${reqParams.response_format ? `
<tr>
<td class="form-data-name">响应格式</td>
<td><span class="form-data-value">${reqParams.response_format}</span></td>
</tr>
` : ''}
${reqParams.seconds ? `
<tr>
<td class="form-data-name">时长</td>
<td><span class="form-data-value">${reqParams.seconds}s</span></td>
</tr>
` : ''}
${log.hasReferenceImages ? `
<tr>
<td class="form-data-name">参考图</td>
@@ -256,8 +277,8 @@ function createLLMApiEntry(log, isExpanded, onToggle) {
` : ''}
${log.requestBody ? `
<div class="detail-section">
<h4>请求体 (Request Body)</h4>
<pre>${escapeHtml(log.requestBody)}</pre>
<h4>请求参数 (Request Parameters)</h4>
<pre class="json-highlight">${formatJsonWithHighlight(log.requestBody)}</pre>
</div>
` : ''}
${log.resultText ? `
+56
View File
@@ -2401,3 +2401,59 @@ body.analysis-mode .log-entry {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* JSON Syntax Highlighting */
.json-highlight {
background: var(--bg-tertiary);
padding: 12px;
border-radius: 6px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.json-key {
color: #9cdcfe;
}
.json-string {
color: #ce9178;
}
.json-number {
color: #b5cea8;
}
.json-boolean {
color: #569cd6;
}
.json-null {
color: #569cd6;
}
/* Light mode JSON highlighting */
@media (prefers-color-scheme: light) {
.json-key {
color: #0451a5;
}
.json-string {
color: #a31515;
}
.json-number {
color: #098658;
}
.json-boolean {
color: #0000ff;
}
.json-null {
color: #0000ff;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "0.5.51",
"buildTime": "2026-01-25T13:53:49.739Z",
"buildTime": "2026-01-25T14:54:21.596Z",
"gitCommit": "unknown",
"changelog": [
"add alignment, distribution, and boolean operation buttons",
@@ -150,12 +150,20 @@ export class CharacterHandler implements TaskHandler {
const { debugFetch } = await import('../debug-fetch');
const { startLLMApiLog, completeLLMApiLog, failLLMApiLog } = await import('../llm-api-logger');
// 构建请求参数的日志表示
const requestParamsForLog = {
character_from_task: params.sourceVideoTaskId || '',
model: characterModel,
...(params.characterTimestamps && { character_timestamps: params.characterTimestamps }),
};
const startTime = Date.now();
const logId = startLLMApiLog({
endpoint: '/videos',
model: characterModel,
taskType: 'character',
prompt: `角色提取: ${params.sourceVideoTaskId}`,
requestBody: JSON.stringify(requestParamsForLog, null, 2),
taskId: task.id,
});
@@ -131,11 +131,22 @@ export class ImageHandler implements TaskHandler {
config.onProgress(task.id, 10, TaskExecutionPhase.SUBMITTING);
const startTime = Date.now();
// 为日志记录构建完整的请求体(不包含参考图片的 base64 数据以节省空间)
const requestBodyForLog = {
...requestBody,
// 如果有参考图片,只记录数量而不记录 base64 数据
...(processedRefImages && processedRefImages.length > 0
? { reference_images: `[${processedRefImages.length} images - data omitted]` }
: {}),
};
const logId = startLLMApiLog({
endpoint: '/images/generations',
model: geminiConfig.modelName || 'unknown',
taskType: 'image',
prompt: params.prompt as string,
requestBody: JSON.stringify(requestBodyForLog, null, 2),
hasReferenceImages: !!processedRefImages && processedRefImages.length > 0,
referenceImageCount: processedRefImages?.length,
referenceImages: referenceImageInfos,
@@ -214,11 +214,22 @@ export class VideoHandler implements TaskHandler {
const startTime = Date.now();
const model = (params.model as string) || 'veo3';
// 构建请求参数的日志表示(FormData 无法直接序列化)
const requestParamsForLog = {
model,
prompt: params.prompt,
...(params.duration && { seconds: params.duration }),
...(params.size && { size: params.size }),
...(refUrls.length > 0 && { input_reference: `[${refUrls.length} images]` }),
};
const logId = startLLMApiLog({
endpoint: '/videos',
model,
taskType: 'video',
prompt: params.prompt as string,
requestBody: JSON.stringify(requestParamsForLog, null, 2),
hasReferenceImages: refUrls.length > 0,
referenceImageCount: refUrls.length,
referenceImages: referenceImageInfos,
+7 -6
View File
@@ -26,8 +26,8 @@ export interface LLMApiLog {
taskType: 'image' | 'video' | 'chat' | 'character' | 'other';
// 请求参数(脱敏后)
prompt?: string; // 提示词(截断)
requestBody?: string; // 完整请求体(仅 chat 类型,不截断)
prompt?: string; // 提示词
requestBody?: string; // 完整请求体(JSON 格式,用于调试)
hasReferenceImages?: boolean; // 是否有参考图
referenceImageCount?: number; // 参考图数量
referenceImages?: LLMReferenceImage[]; // 参考图详情
@@ -299,7 +299,7 @@ export function startLLMApiLog(params: {
model: string;
taskType: LLMApiLog['taskType'];
prompt?: string;
requestBody?: string; // 完整请求体(仅 chat 类型使用,不截断)
requestBody?: string; // 完整请求体(JSON 格式,用于调试)
hasReferenceImages?: boolean;
referenceImageCount?: number;
referenceImages?: LLMReferenceImage[];
@@ -437,11 +437,12 @@ export function failLLMApiLog(
}
/**
* 截断提示词(保护隐私,减少存储)
* 截断提示词(减少存储)
* 增加到 2000 字符以便调试时查看完整 prompt
*/
function truncatePrompt(prompt: string): string {
if (prompt.length <= 200) return prompt;
return prompt.substring(0, 200) + '...';
if (prompt.length <= 2000) return prompt;
return prompt.substring(0, 2000) + '...';
}
/**
+104 -43
View File
@@ -198,7 +198,7 @@ export class SWTaskQueue {
return true;
}
// Resume pending tasks
// Resume legacy PENDING tasks (deprecated: new tasks start as PROCESSING)
if (task.status === TaskStatus.PENDING) {
return true;
}
@@ -290,8 +290,26 @@ export class SWTaskQueue {
this.executeResume(task, task.remoteId);
} else if (task.status === TaskStatus.PENDING) {
// Re-execute pending tasks
this.processQueue();
// Handle legacy PENDING tasks - convert to PROCESSING and execute directly
const now = Date.now();
task.status = TaskStatus.PROCESSING;
task.startedAt = now;
task.updatedAt = now;
task.executionPhase = TaskExecutionPhase.SUBMITTING;
this.tasks.set(task.id, task);
this.runningTasks.add(task.id);
await taskQueueStorage.saveTask(task);
this.broadcastToClients({
type: 'TASK_STATUS',
taskId: task.id,
status: task.status,
phase: task.executionPhase,
updatedAt: task.updatedAt,
});
this.executeTaskInternal(task);
} else if (
// Video/Character task in processing but no remoteId - try to recover from LLM API logs
(task.type === TaskType.VIDEO || task.type === TaskType.CHARACTER) &&
@@ -394,18 +412,23 @@ export class SWTaskQueue {
await this.storageRestorePromise;
// If this is first-time initialization (no saved config before),
// clear all PENDING tasks created without valid API key
// clear orphan tasks created without valid API key:
// - PENDING tasks (legacy)
// - PROCESSING tasks without remoteId (never actually started execution)
if (!this.hadSavedConfig) {
const pendingTasksToRemove: string[] = [];
const orphanTasksToRemove: string[] = [];
for (const task of this.tasks.values()) {
if (task.status === TaskStatus.PENDING) {
pendingTasksToRemove.push(task.id);
const isOrphan =
task.status === TaskStatus.PENDING ||
(task.status === TaskStatus.PROCESSING && !task.remoteId && !this.runningTasks.has(task.id));
if (isOrphan) {
orphanTasksToRemove.push(task.id);
}
}
if (pendingTasksToRemove.length > 0) {
console.log(`[SWTaskQueue] First-time init: clearing ${pendingTasksToRemove.length} orphan PENDING tasks created without API key`);
for (const taskId of pendingTasksToRemove) {
if (orphanTasksToRemove.length > 0) {
console.log(`[SWTaskQueue] First-time init: clearing ${orphanTasksToRemove.length} orphan tasks created without API key`);
for (const taskId of orphanTasksToRemove) {
this.tasks.delete(taskId);
await taskQueueStorage.deleteTask(taskId);
}
@@ -484,25 +507,29 @@ export class SWTaskQueue {
// Check for similar task with same prompt that's already processing or pending
// This prevents duplicate submissions after page refresh
const existingTask = Array.from(this.tasks.values()).find(
t => (t.status === TaskStatus.PROCESSING || t.status === TaskStatus.PENDING) &&
t.type === taskType &&
t.params.prompt === params.prompt
);
if (existingTask) {
console.warn(
`[SWTaskQueue] Similar task ${existingTask.id} with same prompt already ${existingTask.status}, skipping duplicate`
// NOTE: Skip this check for batch generation (tasks with batchId)
// batchId indicates intentional batch generation, not accidental duplicate submission
if (!params.batchId) {
const existingTask = Array.from(this.tasks.values()).find(
t => (t.status === TaskStatus.PROCESSING || t.status === TaskStatus.PENDING) &&
t.type === taskType &&
t.params.prompt === params.prompt
);
// Broadcast the existing task's status to sync the client
this.broadcastToClients({
type: 'TASK_STATUS',
taskId: existingTask.id,
status: existingTask.status,
progress: existingTask.progress,
phase: existingTask.executionPhase,
updatedAt: existingTask.updatedAt,
});
return;
if (existingTask) {
console.warn(
`[SWTaskQueue] Similar task ${existingTask.id} with same prompt already ${existingTask.status}, skipping duplicate`
);
// Broadcast the existing task's status to sync the client
this.broadcastToClients({
type: 'TASK_STATUS',
taskId: existingTask.id,
status: existingTask.status,
progress: existingTask.progress,
phase: existingTask.executionPhase,
updatedAt: existingTask.updatedAt,
});
return;
}
}
// Debug logging for task submission
@@ -514,13 +541,16 @@ export class SWTaskQueue {
const task: SWTask = {
id: taskId,
type: taskType,
status: TaskStatus.PENDING,
status: TaskStatus.PROCESSING,
params,
createdAt: now,
updatedAt: now,
startedAt: now,
executionPhase: TaskExecutionPhase.SUBMITTING,
};
this.tasks.set(taskId, task);
this.runningTasks.add(taskId);
// Persist to IndexedDB
await taskQueueStorage.saveTask(task);
@@ -531,8 +561,8 @@ export class SWTaskQueue {
task,
});
// console.log(`[SWTaskQueue] Task submitted: ${taskId}`);
this.processQueue();
// Execute task immediately (no PENDING state, direct execution)
this.executeTaskInternal(task);
}
/**
@@ -585,10 +615,14 @@ export class SWTaskQueue {
}
}
task.status = TaskStatus.PENDING;
const now = Date.now();
task.status = TaskStatus.PROCESSING;
task.error = undefined;
task.updatedAt = Date.now();
task.startedAt = now;
task.updatedAt = now;
task.executionPhase = TaskExecutionPhase.SUBMITTING;
this.tasks.set(taskId, task);
this.runningTasks.add(taskId);
// Persist
await taskQueueStorage.saveTask(task);
@@ -598,10 +632,12 @@ export class SWTaskQueue {
type: 'TASK_STATUS',
taskId: task.id,
status: task.status,
phase: task.executionPhase,
updatedAt: task.updatedAt,
});
this.processQueue();
// Execute task immediately (no PENDING state, direct execution)
this.executeTaskInternal(task);
}
/**
@@ -856,7 +892,9 @@ export class SWTaskQueue {
// ============================================================================
/**
* Process the task queue
* Process the task queue (legacy compatibility only)
* Note: New tasks are created with PROCESSING status and executed immediately.
* This method only handles legacy PENDING tasks from older versions.
*/
private processQueue(): void {
if (!this.initialized) {
@@ -864,7 +902,7 @@ export class SWTaskQueue {
return;
}
// Get pending tasks that are not already running
// Get legacy pending tasks that are not already running
const pendingTasks = Array.from(this.tasks.values())
.filter((t) => t.status === TaskStatus.PENDING && !this.runningTasks.has(t.id))
.sort((a, b) => a.createdAt - b.createdAt);
@@ -876,7 +914,8 @@ export class SWTaskQueue {
}
/**
* Execute a single task
* Execute a single task (called from processQueue for legacy PENDING tasks)
* For new tasks, use executeTaskInternal directly after setting up status
*/
private async executeTask(task: SWTask): Promise<void> {
if (!this.geminiConfig || !this.videoConfig) {
@@ -890,20 +929,28 @@ export class SWTaskQueue {
return;
}
// Also check if task status has changed (might have been executed by another call)
// Check if task is ready for execution (PENDING or PROCESSING without being in runningTasks)
const currentTask = this.tasks.get(task.id);
if (currentTask && currentTask.status !== TaskStatus.PENDING) {
console.warn(`[SWTaskQueue] Task ${task.id} is no longer PENDING (${currentTask.status}), skipping`);
if (!currentTask) {
console.warn(`[SWTaskQueue] Task ${task.id} not found, skipping`);
return;
}
// Skip if task is in terminal state
if (currentTask.status === TaskStatus.COMPLETED ||
currentTask.status === TaskStatus.FAILED ||
currentTask.status === TaskStatus.CANCELLED) {
console.warn(`[SWTaskQueue] Task ${task.id} is in terminal state (${currentTask.status}), skipping`);
return;
}
this.runningTasks.add(task.id);
// Update status to processing
// Update status to processing (handles legacy PENDING tasks)
task.status = TaskStatus.PROCESSING;
task.startedAt = Date.now();
task.startedAt = task.startedAt || Date.now();
task.updatedAt = Date.now();
task.executionPhase = TaskExecutionPhase.SUBMITTING;
task.executionPhase = task.executionPhase || TaskExecutionPhase.SUBMITTING;
this.tasks.set(task.id, task);
// Persist status change
@@ -917,9 +964,23 @@ export class SWTaskQueue {
updatedAt: task.updatedAt,
});
// Execute the task
await this.executeTaskInternal(task);
}
/**
* Internal task execution - assumes task is already set to PROCESSING and in runningTasks
*/
private async executeTaskInternal(task: SWTask): Promise<void> {
if (!this.geminiConfig || !this.videoConfig) {
console.warn('[SWTaskQueue] Config not set, cannot execute task:', task.id);
return;
}
// 如果任务已经有 remoteId(视频/角色),则直接进入恢复流程,跳过提交阶段,只重试获取进度的接口
if (task.remoteId && (task.type === TaskType.VIDEO || task.type === TaskType.CHARACTER)) {
task.executionPhase = TaskExecutionPhase.POLLING;
task.updatedAt = Date.now();
// 更新存储
await taskQueueStorage.saveTask(task);
@@ -1278,6 +1278,8 @@ export async function parseImageGenerationResponse(data: any, taskId?: string):
// 检查是否包含违禁内容错误
if (imageData.revised_prompt?.includes('PROHIBITED_CONTENT')) {
throw new Error('内容被拒绝:包含违禁内容');
} else if (imageData.revised_prompt?.includes('NO_IMAGE')) {
throw new Error('该模型为多模态模型,未生成图片,可更换提示词明确生成图片试试');
}
throw new Error('No image URL in response');
}
@@ -762,11 +762,19 @@ export class WorkflowExecutor {
// Check if this tool needs to run in main thread
if (requiresMainThread(step.mcp) || !getSWMCPTool(step.mcp)) {
// Delegate to main thread
// Merge batch options into args for main thread (batchId, batchIndex, batchTotal)
const argsWithOptions = {
...step.args,
...(step.options?.batchId && { batchId: step.options.batchId }),
...(step.options?.batchIndex && { batchIndex: step.options.batchIndex }),
...(step.options?.batchTotal && { batchTotal: step.options.batchTotal }),
...(step.options?.globalIndex && { globalIndex: step.options.globalIndex }),
};
const response = await this.requestMainThreadTool(
workflow.id,
step.id,
step.mcp,
step.args
argsWithOptions
);
if (!response.success) {
@@ -16,6 +16,22 @@ import type { TaskStatus, TaskExecutionPhase, GeminiConfig, VideoAPIConfig } fro
*/
export type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
/**
* Workflow step execution options (batch generation etc.)
*/
export interface WorkflowStepOptions {
/** Execution mode */
mode?: 'async' | 'queue';
/** Batch ID for batch generation */
batchId?: string;
/** Batch index (1-based) */
batchIndex?: number;
/** Total batch count */
batchTotal?: number;
/** Global index */
globalIndex?: number;
}
/**
* A single step in a workflow
*/
@@ -38,6 +54,8 @@ export interface WorkflowStep {
duration?: number;
/** Dependencies - step IDs that must complete before this step */
dependsOn?: string[];
/** Execution options (batch info etc.) */
options?: WorkflowStepOptions;
}
/**
+46
View File
@@ -1125,6 +1125,52 @@ async initialize(): Promise<boolean> {
**原因**: `settingsManager` 使用异步方法 `decryptSensitiveDataForLoading()` 解密敏感数据(如 API Key)。如果在解密完成前调用 `geminiSettings.get()`,会返回加密的 JSON 对象而不是解密后的字符串,导致 API 请求失败。
### 跨层数据转换必须传递所有字段
**场景**: 在主线程和 Service Worker 之间传递数据对象时,需要将内部类型转换为传输格式
❌ **错误示例**:
```typescript
// 错误:转换时漏掉了 options 字段
const swWorkflow = {
id: legacyWorkflow.id,
name: legacyWorkflow.name,
steps: legacyWorkflow.steps.map(step => ({
id: step.id,
mcp: step.mcp,
args: step.args,
description: step.description,
status: step.status,
// 漏掉了 step.options!导致批量信息(batchId 等)丢失
})),
};
```
✅ **正确示例**:
```typescript
// 正确:显式传递所有字段,包括可选字段
const swWorkflow = {
id: legacyWorkflow.id,
name: legacyWorkflow.name,
steps: legacyWorkflow.steps.map(step => ({
id: step.id,
mcp: step.mcp,
args: step.args,
description: step.description,
status: step.status,
options: step.options, // 包含 batchId, batchIndex, batchTotal 等
})),
};
```
**常见遗漏的字段**:
- `options` - 批量参数、执行模式等
- `metadata` - 元数据信息
- `context` - 上下文信息
- 任何 `?:` 可选字段
**原因**: 跨层通信时,如果源类型有可选字段,在转换时很容易遗漏。这会导致功能静默失败(如批量生成只执行第一个),且很难排查。建议在转换函数中显式列出所有字段,或使用 TypeScript 的类型检查确保字段完整。
### Service Worker 初始化时序
**场景**: 提交工作流到 Service Worker 执行前
@@ -1,237 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>使用 AI 生成图片 - Aitu 用户手册</title>
<style>
:root {
--primary-color: #F39C12;
--secondary-color: #5A4FCF;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--primary-color);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.header .version {
color: #666;
font-size: 0.9rem;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #fafafa;
border-right: 1px solid var(--border-color);
padding: 2rem 1rem;
overflow-y: auto;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
color: var(--text-color);
text-decoration: none;
display: block;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #eee;
}
.sidebar-nav .category {
font-weight: bold;
color: var(--secondary-color);
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.main-content {
margin-left: 270px;
padding: 2rem;
}
.page-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.page-section h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.page-section p {
margin-bottom: 1rem;
}
.steps {
counter-reset: step;
}
.step {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: #fafafa;
border-radius: 8px;
}
.step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.step-content {
flex: 1;
}
.step-content h4 {
margin-bottom: 0.5rem;
}
.step-screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 1rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="index.html"><strong>首页</strong></a></li>
<li class="category">绘图功能</li>
<li><a href="使用画笔工具绘制.html">使用画笔工具</a></li>
<li><a href="创建形状.html">创建形状</a></li>
<li><a href="添加文本.html">添加文本</a></li>
<li class="category">AI 生成</li>
<li><a href="使用-ai-生成图片.html">使用 AI 生成图片</a></li>
<li><a href="使用灵感创意板.html">使用灵感创意板</a></li>
</ul>
</nav>
<main class="main-content">
<article class="page-section">
<h2>使用 AI 生成图片</h2>
<p>学习如何使用 AI 功能生成图片</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>在底部输入框中输入图片描述</h4>
<p>在底部输入框中输入图片描述</p>
<img class="step-screenshot" src="screenshots/初始状态-ee00aa3f861c0d28b5eb6b286f451bb9ecb8a936.png" alt="在底部输入框中输入图片描述">
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>点击 # 选择生成模型</h4>
<p>点击 # 选择生成模型</p>
<img class="step-screenshot" src="screenshots/步骤-1-输入提示词-61531907dc06887a91661c4a68f025c87e4694e0.png" alt="点击 # 选择生成模型">
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>点击发送按钮或按 Enter 键</h4>
<p>点击发送按钮或按 Enter 键</p>
<img class="step-screenshot" src="screenshots/步骤-2-选择模型-f3728aeeadbe90a7bd9e5bdbf4f4ff0c34f99f54.png" alt="点击发送按钮或按 Enter 键">
</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-content">
<h4>等待 AI 生成完成</h4>
<p>等待 AI 生成完成</p>
<img class="step-screenshot" src="screenshots/步骤-3-准备发送-feb981734e01b38cb7e4bb2563870af8d3bcb422.png" alt="等待 AI 生成完成">
</div>
</div>
</div>
</article>
</main>
<footer class="footer">
<p>Aitu 用户手册 v0.5.48 | 由 E2E 测试自动生成</p>
<p>生成时间: 2026/1/24 12:59:05</p>
</footer>
</body>
</html>
-237
View File
@@ -1,237 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>使用灵感创意板 - Aitu 用户手册</title>
<style>
:root {
--primary-color: #F39C12;
--secondary-color: #5A4FCF;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--primary-color);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.header .version {
color: #666;
font-size: 0.9rem;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #fafafa;
border-right: 1px solid var(--border-color);
padding: 2rem 1rem;
overflow-y: auto;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
color: var(--text-color);
text-decoration: none;
display: block;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #eee;
}
.sidebar-nav .category {
font-weight: bold;
color: var(--secondary-color);
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.main-content {
margin-left: 270px;
padding: 2rem;
}
.page-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.page-section h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.page-section p {
margin-bottom: 1rem;
}
.steps {
counter-reset: step;
}
.step {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: #fafafa;
border-radius: 8px;
}
.step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.step-content {
flex: 1;
}
.step-content h4 {
margin-bottom: 0.5rem;
}
.step-screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 1rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="index.html"><strong>首页</strong></a></li>
<li class="category">绘图功能</li>
<li><a href="使用画笔工具绘制.html">使用画笔工具</a></li>
<li><a href="创建形状.html">创建形状</a></li>
<li><a href="添加文本.html">添加文本</a></li>
<li class="category">AI 生成</li>
<li><a href="使用-ai-生成图片.html">使用 AI 生成图片</a></li>
<li><a href="使用灵感创意板.html">使用灵感创意板</a></li>
</ul>
</nav>
<main class="main-content">
<article class="page-section">
<h2>使用灵感创意板</h2>
<p>当画布为空时,灵感创意板会显示推荐的创作模板</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>在空画布上,灵感创意板自动显示</h4>
<p>在空画布上,灵感创意板自动显示</p>
<img class="step-screenshot" src="screenshots/步骤-1-灵感创意板-0ace33f54c0ccddb7a586762e85d2a6ab8e302f5.png" alt="在空画布上,灵感创意板自动显示">
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>浏览不同的创意模板</h4>
<p>浏览不同的创意模板</p>
<img class="step-screenshot" src="screenshots/步骤-2-选择模板后-5ed995df265ec4c7602e5d5fa5392354b8729cc1.png" alt="浏览不同的创意模板">
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>点击感兴趣的模板</h4>
<p>点击感兴趣的模板</p>
</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-content">
<h4>模板的提示词会自动填充到输入框</h4>
<p>模板的提示词会自动填充到输入框</p>
</div>
</div>
</div>
</article>
</main>
<footer class="footer">
<p>Aitu 用户手册 v0.5.48 | 由 E2E 测试自动生成</p>
<p>生成时间: 2026/1/24 12:59:05</p>
</footer>
</body>
</html>
@@ -1,228 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>使用画笔工具 - Aitu 用户手册</title>
<style>
:root {
--primary-color: #F39C12;
--secondary-color: #5A4FCF;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--primary-color);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.header .version {
color: #666;
font-size: 0.9rem;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #fafafa;
border-right: 1px solid var(--border-color);
padding: 2rem 1rem;
overflow-y: auto;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
color: var(--text-color);
text-decoration: none;
display: block;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #eee;
}
.sidebar-nav .category {
font-weight: bold;
color: var(--secondary-color);
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.main-content {
margin-left: 270px;
padding: 2rem;
}
.page-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.page-section h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.page-section p {
margin-bottom: 1rem;
}
.steps {
counter-reset: step;
}
.step {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: #fafafa;
border-radius: 8px;
}
.step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.step-content {
flex: 1;
}
.step-content h4 {
margin-bottom: 0.5rem;
}
.step-screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 1rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="index.html"><strong>首页</strong></a></li>
<li class="category">绘图功能</li>
<li><a href="使用画笔工具绘制.html">使用画笔工具</a></li>
<li><a href="创建形状.html">创建形状</a></li>
<li><a href="添加文本.html">添加文本</a></li>
<li class="category">AI 生成</li>
<li><a href="使用-ai-生成图片.html">使用 AI 生成图片</a></li>
<li><a href="使用灵感创意板.html">使用灵感创意板</a></li>
</ul>
</nav>
<main class="main-content">
<article class="page-section">
<h2>使用画笔工具</h2>
<p>学习如何使用画笔工具在画布上自由绘制</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>点击左侧工具栏的画笔图标</h4>
<p>点击左侧工具栏的画笔图标</p>
<img class="step-screenshot" src="screenshots/初始状态-ed5fe35074b8f3a288f8493bd3cdf4b45091ebb2.png" alt="点击左侧工具栏的画笔图标">
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>在画布上按住鼠标左键并拖动</h4>
<p>在画布上按住鼠标左键并拖动</p>
<img class="step-screenshot" src="screenshots/步骤-1-选择画笔工具-8631038478d440f3e891d2426ef4774902c06450.png" alt="在画布上按住鼠标左键并拖动">
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>松开鼠标完成绘制</h4>
<p>松开鼠标完成绘制</p>
<img class="step-screenshot" src="screenshots/步骤-2-绘制线条-dab6cc0bc18592e88db1a1f86e4e294c1a2dcc6c.png" alt="松开鼠标完成绘制">
</div>
</div>
</div>
</article>
</main>
<footer class="footer">
<p>Aitu 用户手册 v0.5.48 | 由 E2E 测试自动生成</p>
<p>生成时间: 2026/1/24 12:59:05</p>
</footer>
</body>
</html>
-228
View File
@@ -1,228 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>创建形状 - Aitu 用户手册</title>
<style>
:root {
--primary-color: #F39C12;
--secondary-color: #5A4FCF;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--primary-color);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.header .version {
color: #666;
font-size: 0.9rem;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #fafafa;
border-right: 1px solid var(--border-color);
padding: 2rem 1rem;
overflow-y: auto;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
color: var(--text-color);
text-decoration: none;
display: block;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #eee;
}
.sidebar-nav .category {
font-weight: bold;
color: var(--secondary-color);
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.main-content {
margin-left: 270px;
padding: 2rem;
}
.page-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.page-section h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.page-section p {
margin-bottom: 1rem;
}
.steps {
counter-reset: step;
}
.step {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: #fafafa;
border-radius: 8px;
}
.step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.step-content {
flex: 1;
}
.step-content h4 {
margin-bottom: 0.5rem;
}
.step-screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 1rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="index.html"><strong>首页</strong></a></li>
<li class="category">绘图功能</li>
<li><a href="使用画笔工具绘制.html">使用画笔工具</a></li>
<li><a href="创建形状.html">创建形状</a></li>
<li><a href="添加文本.html">添加文本</a></li>
<li class="category">AI 生成</li>
<li><a href="使用-ai-生成图片.html">使用 AI 生成图片</a></li>
<li><a href="使用灵感创意板.html">使用灵感创意板</a></li>
</ul>
</nav>
<main class="main-content">
<article class="page-section">
<h2>创建形状</h2>
<p>学习如何在画布上创建各种形状</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>点击左侧工具栏的形状图标</h4>
<p>点击左侧工具栏的形状图标</p>
<img class="step-screenshot" src="screenshots/步骤-1-选择形状工具-ec381680b235070b0ea939f0ed84f2b53d7b8c49.png" alt="点击左侧工具栏的形状图标">
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>在画布上点击要放置形状的位置</h4>
<p>在画布上点击要放置形状的位置</p>
<img class="step-screenshot" src="screenshots/步骤-2-创建形状-5445f0b842e82f308fb0327fce414199ad9691b2.png" alt="在画布上点击要放置形状的位置">
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>形状将自动创建,可以拖动调整大小</h4>
<p>形状将自动创建,可以拖动调整大小</p>
</div>
</div>
</div>
</article>
</main>
<footer class="footer">
<p>Aitu 用户手册 v0.5.48 | 由 E2E 测试自动生成</p>
<p>生成时间: 2026/1/24 12:59:05</p>
</footer>
</body>
</html>
-237
View File
@@ -1,237 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>添加文本 - Aitu 用户手册</title>
<style>
:root {
--primary-color: #F39C12;
--secondary-color: #5A4FCF;
--text-color: #333;
--bg-color: #fff;
--border-color: #e0e0e0;
--code-bg: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--text-color);
background: var(--bg-color);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 2px solid var(--primary-color);
}
.header h1 {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 0.5rem;
}
.header .version {
color: #666;
font-size: 0.9rem;
}
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #fafafa;
border-right: 1px solid var(--border-color);
padding: 2rem 1rem;
overflow-y: auto;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 0.5rem;
}
.sidebar-nav a {
color: var(--text-color);
text-decoration: none;
display: block;
padding: 0.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.sidebar-nav a:hover {
background: #eee;
}
.sidebar-nav .category {
font-weight: bold;
color: var(--secondary-color);
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.main-content {
margin-left: 270px;
padding: 2rem;
}
.page-section {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.page-section h2 {
color: var(--secondary-color);
margin-bottom: 1rem;
}
.page-section p {
margin-bottom: 1rem;
}
.steps {
counter-reset: step;
}
.step {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: #fafafa;
border-radius: 8px;
}
.step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.step-content {
flex: 1;
}
.step-content h4 {
margin-bottom: 0.5rem;
}
.step-screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 1rem;
}
.footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<nav class="sidebar">
<ul class="sidebar-nav">
<li><a href="index.html"><strong>首页</strong></a></li>
<li class="category">绘图功能</li>
<li><a href="使用画笔工具绘制.html">使用画笔工具</a></li>
<li><a href="创建形状.html">创建形状</a></li>
<li><a href="添加文本.html">添加文本</a></li>
<li class="category">AI 生成</li>
<li><a href="使用-ai-生成图片.html">使用 AI 生成图片</a></li>
<li><a href="使用灵感创意板.html">使用灵感创意板</a></li>
</ul>
</nav>
<main class="main-content">
<article class="page-section">
<h2>添加文本</h2>
<p>学习如何在画布上添加文本</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<h4>点击左侧工具栏的文本图标</h4>
<p>点击左侧工具栏的文本图标</p>
<img class="step-screenshot" src="screenshots/步骤-1-选择文本工具-12e5c2b674a1cde71cc43118e7725db772e425f5.png" alt="点击左侧工具栏的文本图标">
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<h4>在画布上点击要添加文本的位置</h4>
<p>在画布上点击要添加文本的位置</p>
<img class="step-screenshot" src="screenshots/步骤-2-输入文本-8163f84062744e0fa2af5a687879c940edc299f0.png" alt="在画布上点击要添加文本的位置">
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<h4>输入您想要的文字内容</h4>
<p>输入您想要的文字内容</p>
</div>
</div>
<div class="step">
<div class="step-number">4</div>
<div class="step-content">
<h4>点击画布空白处完成编辑</h4>
<p>点击画布空白处完成编辑</p>
</div>
</div>
</div>
</article>
</main>
<footer class="footer">
<p>Aitu 用户手册 v0.5.48 | 由 E2E 测试自动生成</p>
<p>生成时间: 2026/1/24 12:59:05</p>
</footer>
</body>
</html>
+1 -1
View File
@@ -50,7 +50,7 @@
"version:patch": "node scripts/safe-version-bump.js patch",
"version:minor": "node scripts/safe-version-bump.js minor",
"version:major": "node scripts/safe-version-bump.js major",
"release:skip": "node scripts/deploy-hybrid.js --skip-npm",
"release:skip": "node scripts/deploy-hybrid.js --skip-build --skip-e2e --skip-npm --skip-manual",
"release": "npm run version:patch && node scripts/deploy-hybrid.js --skip-e2e",
"release:minor": "npm run version:minor && node scripts/deploy-hybrid.js",
"release:major": "npm run version:major && node scripts/deploy-hybrid.js",
@@ -57,8 +57,9 @@ export const DialogTaskList: React.FC<DialogTaskListProps> = ({
const taskMatchesQuery = (task: any, query: string) => {
if (!query.trim()) return true;
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
// Note: PENDING is deprecated, displayed as '处理中' for legacy compatibility
const statusLabelMap: Record<TaskStatus, string> = {
[TaskStatus.PENDING]: '待处理',
[TaskStatus.PENDING]: '处理中',
[TaskStatus.PROCESSING]: '处理中',
[TaskStatus.COMPLETED]: '已完成',
[TaskStatus.FAILED]: '失败',
@@ -50,11 +50,11 @@ export interface TaskItemProps {
/**
* Gets the appropriate status tag color based on task status
* Note: PENDING is deprecated, treated same as PROCESSING for legacy compatibility
*/
function getStatusTagTheme(status: TaskStatus): 'default' | 'primary' | 'success' | 'warning' | 'danger' {
switch (status) {
case TaskStatus.PENDING:
return 'default';
case TaskStatus.PROCESSING:
return 'primary';
case TaskStatus.COMPLETED:
@@ -70,11 +70,11 @@ function getStatusTagTheme(status: TaskStatus): 'default' | 'primary' | 'success
/**
* Gets the status label in Chinese
* Note: PENDING is deprecated, displayed as '处理中' for legacy compatibility
*/
function getStatusLabel(status: TaskStatus): string {
switch (status) {
case TaskStatus.PENDING:
return '待处理';
case TaskStatus.PROCESSING:
return '处理中';
case TaskStatus.COMPLETED:
@@ -233,7 +233,7 @@ const AIImageGeneration = ({
};
const handleGenerate = async (count: number = 1) => {
if (!prompt.trim()) {
if (!prompt || !prompt.trim()) {
setError(
language === 'zh' ? '请输入图像描述' : 'Please enter image description'
);
@@ -274,7 +274,7 @@ const AIImageGeneration = ({
for (let i = 0; i < count; i++) {
const taskParams = {
prompt: prompt.trim(),
prompt: (prompt || '').trim(),
width: finalWidth,
height: finalHeight,
aspectRatio,
@@ -323,7 +323,7 @@ const AIImageGeneration = ({
// 创建任务参数
const taskParams = {
prompt: prompt.trim(),
prompt: (prompt || '').trim(),
width: finalWidth,
height: finalHeight,
aspectRatio,
@@ -449,7 +449,7 @@ const AIImageGeneration = ({
type="image"
isGenerating={isGenerating}
hasGenerated={false}
canGenerate={!!prompt.trim()}
canGenerate={!!(prompt && prompt.trim())}
onGenerate={handleGenerate}
onReset={handleReset}
leftContent={
@@ -473,7 +473,7 @@ const AIVideoGeneration = ({
}
} else {
// 普通模式验证
if (!prompt.trim()) {
if (!prompt || !prompt.trim()) {
setError(language === 'zh' ? '请输入视频描述' : 'Please enter video description');
return;
}
@@ -507,7 +507,7 @@ const AIVideoGeneration = ({
// 构建最终提示词
const finalPrompt = storyboardEnabled
? formatStoryboardPrompt(storyboardScenes)
: prompt.trim();
: (prompt || '').trim();
// 批量生成逻辑
const batchTaskIds: string[] = [];
@@ -694,7 +694,7 @@ const AIVideoGeneration = ({
type="video"
isGenerating={isGenerating}
hasGenerated={false}
canGenerate={storyboardEnabled ? storyboardScenes.length > 0 : !!prompt.trim()}
canGenerate={storyboardEnabled ? storyboardScenes.length > 0 : !!(prompt && prompt.trim())}
onGenerate={handleGenerate}
onReset={handleReset}
/>
@@ -112,8 +112,9 @@ export const PromptInput: React.FC<PromptInputProps> = ({
}
}, [isPresetOpen]);
// 选择提示词
const handleSelect = useCallback((content: string) => {
// 选择提示词 - 接收 PromptItem 对象或字符串
const handleSelect = useCallback((item: PromptItem | string) => {
const content = typeof item === 'string' ? item : item.content;
onPromptChange(content);
onError?.(null);
setIsPresetOpen(false);
@@ -503,6 +503,8 @@ export function useWorkflowSubmission(
args: step.args,
description: step.description,
status: step.status as any,
// Include batch options for batch generation
options: step.options,
})),
status: 'pending',
createdAt: legacyWorkflow.createdAt,
@@ -47,6 +47,14 @@ export interface ImageGenerationParams {
model?: string;
/** 生成数量(仅 queue 模式支持) */
count?: number;
/** 批次 ID(批量生成时) */
batchId?: string;
/** 批次索引(1-based) */
batchIndex?: number;
/** 批次总数 */
batchTotal?: number;
/** 全局索引 */
globalIndex?: number;
}
/**
@@ -137,7 +145,11 @@ async function executeAsync(params: ImageGenerationParams): Promise<MCPResult> {
* 支持批量创建任务(通过 count 参数)
*/
function executeQueue(params: ImageGenerationParams, options: MCPExecuteOptions): MCPTaskResult {
const { prompt, size, referenceImages, model, count = 1 } = params;
const {
prompt, size, referenceImages, model, count = 1,
// 批量参数(可能从工作流步骤传入)
batchId: paramsBatchId, batchIndex: paramsBatchIndex, batchTotal: paramsBatchTotal, globalIndex: paramsGlobalIndex,
} = params;
if (!prompt || typeof prompt !== 'string') {
return {
@@ -155,9 +167,12 @@ function executeQueue(params: ImageGenerationParams, options: MCPExecuteOptions)
name: `reference-${index + 1}`,
}));
// 生成批次 ID(如果有多个任务)
// 批量参数:优先使用 params 中的(工作流场景),否则根据 count 生成
const actualCount = Math.min(Math.max(1, count), 10); // 限制 1-10 个
const batchId = actualCount > 1 ? `batch_${Date.now()}` : options.batchId;
const batchId = paramsBatchId || (actualCount > 1 ? `batch_${Date.now()}` : options.batchId);
const batchIndex = paramsBatchIndex;
const batchTotal = paramsBatchTotal || (actualCount > 1 ? actualCount : undefined);
const globalIndex = paramsGlobalIndex || options.globalIndex;
const createdTasks: any[] = [];
@@ -170,8 +185,27 @@ function executeQueue(params: ImageGenerationParams, options: MCPExecuteOptions)
throw new Error(`重试任务不存在: ${options.retryTaskId}`);
}
createdTasks.push(task);
} else if (paramsBatchId && paramsBatchIndex) {
// 工作流场景:每个步骤创建一个任务,批量信息已从工作流传入
const task = taskQueueService.createTask(
{
prompt,
size: size || '1x1',
uploadedImages: uploadedImages && uploadedImages.length > 0 ? uploadedImages : undefined,
model: model || getCurrentImageModel(),
// 使用工作流传入的批量参数
batchId,
batchIndex,
batchTotal,
globalIndex,
// 自动插入画布
autoInsertToCanvas: true,
},
TaskType.IMAGE
);
createdTasks.push(task);
} else {
// 创建任务(支持批量)
// 直接调用场景(如弹窗):根据 count 创建多个任务
for (let i = 0; i < actualCount; i++) {
const task = taskQueueService.createTask(
{
@@ -183,7 +217,7 @@ function executeQueue(params: ImageGenerationParams, options: MCPExecuteOptions)
batchId: batchId,
batchIndex: i + 1,
batchTotal: actualCount,
globalIndex: options.globalIndex ? options.globalIndex + i : i + 1,
globalIndex: globalIndex ? globalIndex + i : i + 1,
// 自动插入画布
autoInsertToCanvas: true,
},
@@ -63,6 +63,14 @@ export interface VideoGenerationParams {
referenceImages?: string[];
/** 生成数量(仅 queue 模式支持) */
count?: number;
/** 批次 ID(批量生成时) */
batchId?: string;
/** 批次索引(1-based) */
batchIndex?: number;
/** 批次总数 */
batchTotal?: number;
/** 全局索引 */
globalIndex?: number;
}
/**
@@ -166,7 +174,11 @@ async function executeAsync(params: VideoGenerationParams): Promise<MCPResult> {
* 支持批量创建任务(通过 count 参数)
*/
function executeQueue(params: VideoGenerationParams, options: MCPExecuteOptions): MCPTaskResult {
const { prompt, model = 'veo3', seconds, size, referenceImages, count = 1 } = params;
const {
prompt, model = 'veo3', seconds, size, referenceImages, count = 1,
// 批量参数(可能从工作流步骤传入)
batchId: paramsBatchId, batchIndex: paramsBatchIndex, batchTotal: paramsBatchTotal, globalIndex: paramsGlobalIndex,
} = params;
if (!prompt || typeof prompt !== 'string') {
return {
@@ -187,9 +199,12 @@ function executeQueue(params: VideoGenerationParams, options: MCPExecuteOptions)
name: `reference-${index + 1}`,
}));
// 生成批次 ID(如果有多个任务)
// 批量参数:优先使用 params 中的(工作流场景),否则根据 count 生成
const actualCount = Math.min(Math.max(1, count), 10); // 限制 1-10 个
const batchId = actualCount > 1 ? `batch_${Date.now()}` : options.batchId;
const batchId = paramsBatchId || (actualCount > 1 ? `batch_${Date.now()}` : options.batchId);
const batchIndex = paramsBatchIndex;
const batchTotal = paramsBatchTotal || (actualCount > 1 ? actualCount : undefined);
const globalIndex = paramsGlobalIndex || options.globalIndex;
const createdTasks: any[] = [];
@@ -202,8 +217,28 @@ function executeQueue(params: VideoGenerationParams, options: MCPExecuteOptions)
throw new Error(`重试任务不存在: ${options.retryTaskId}`);
}
createdTasks.push(task);
} else if (paramsBatchId && paramsBatchIndex) {
// 工作流场景:每个步骤创建一个任务,批量信息已从工作流传入
const task = taskQueueService.createTask(
{
prompt,
size: size || '16x9',
duration: parseInt(seconds || modelConfig.defaultDuration, 10),
model,
uploadedImages: uploadedImages && uploadedImages.length > 0 ? uploadedImages : undefined,
// 使用工作流传入的批量参数
batchId,
batchIndex,
batchTotal,
globalIndex,
// 自动插入画布
autoInsertToCanvas: true,
},
TaskType.VIDEO
);
createdTasks.push(task);
} else {
// 创建任务(支持批量)
// 直接调用场景(如弹窗):根据 count 创建多个任务
for (let i = 0; i < actualCount; i++) {
const task = taskQueueService.createTask(
{
@@ -216,7 +251,7 @@ function executeQueue(params: VideoGenerationParams, options: MCPExecuteOptions)
batchId: batchId,
batchIndex: i + 1,
batchTotal: actualCount,
globalIndex: options.globalIndex ? options.globalIndex + i : i + 1,
globalIndex: globalIndex ? globalIndex + i : i + 1,
// 自动插入画布
autoInsertToCanvas: true,
},
@@ -172,6 +172,14 @@ export interface ImageGenerationParams {
quality?: '1k' | '2k' | '4k';
model?: string;
count?: number;
/** 批次 ID(批量生成时) */
batchId?: string;
/** 批次索引(1-based) */
batchIndex?: number;
/** 批次总数 */
batchTotal?: number;
/** 全局索引 */
globalIndex?: number;
}
/**
@@ -184,6 +192,14 @@ export interface VideoGenerationParams {
size?: string;
referenceImages?: string[];
count?: number;
/** 批次 ID(批量生成时) */
batchId?: string;
/** 批次索引(1-based) */
batchIndex?: number;
/** 批次总数 */
batchTotal?: number;
/** 全局索引 */
globalIndex?: number;
}
/**
@@ -102,14 +102,17 @@ class SWTaskQueueService {
const sanitizedParams = sanitizeGenerationParams(params);
// Create task locally for immediate UI feedback
// Task starts as PROCESSING since it will be executed immediately by SW
const now = Date.now();
const task: Task = {
id: generateTaskId(),
type,
status: TaskStatus.PENDING,
status: TaskStatus.PROCESSING,
params: sanitizedParams,
createdAt: now,
updatedAt: now,
startedAt: now,
executionPhase: TaskExecutionPhase.SUBMITTING,
...(type === TaskType.VIDEO && { progress: 0 }),
};
@@ -130,6 +133,10 @@ class SWTaskQueueService {
return Array.from(this.tasks.values());
}
/**
* @deprecated Tasks no longer use PENDING status. New tasks start as PROCESSING.
* Kept for backward compatibility with legacy data.
*/
getPendingTasks(): Task[] {
return this.getAllTasks().filter((t) => t.status === TaskStatus.PENDING);
}
@@ -6,7 +6,7 @@
*/
import { Subject, Observable } from 'rxjs';
import { Task, TaskStatus, TaskType, TaskEvent, GenerationParams } from '../types/task.types';
import { Task, TaskStatus, TaskType, TaskEvent, GenerationParams, TaskExecutionPhase } from '../types/task.types';
import { generateTaskId, isTaskActive } from '../utils/task-utils';
import { validateGenerationParams, sanitizeGenerationParams } from '../utils/validation-utils';
@@ -52,15 +52,17 @@ class TaskQueueService {
// Sanitize parameters
const sanitizedParams = sanitizeGenerationParams(params);
// Create new task
// Create new task - starts as PROCESSING since it will be executed immediately
const now = Date.now();
const task: Task = {
id: generateTaskId(),
type,
status: TaskStatus.PENDING,
status: TaskStatus.PROCESSING,
params: sanitizedParams,
createdAt: now,
updatedAt: now,
startedAt: now,
executionPhase: TaskExecutionPhase.SUBMITTING,
// Initialize progress for video tasks
...(type === TaskType.VIDEO && { progress: 0 }),
};
@@ -213,13 +215,14 @@ class TaskQueueService {
return;
}
// Reset task for retry - clear timing fields to prevent immediate timeout
this.updateTaskStatus(taskId, TaskStatus.PENDING, {
// Reset task for retry - set to PROCESSING for immediate execution
const now = Date.now();
this.updateTaskStatus(taskId, TaskStatus.PROCESSING, {
error: undefined,
startedAt: undefined, // Reset start time so timeout is recalculated
startedAt: now, // Set new start time
completedAt: undefined, // Clear completion time
remoteId: undefined, // Clear remote ID for fresh submission
executionPhase: undefined, // Clear execution phase
executionPhase: TaskExecutionPhase.SUBMITTING,
progress: task.type === TaskType.VIDEO ? 0 : undefined, // Reset progress for video
});
@@ -27,6 +27,14 @@ import {
export type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
export interface WorkflowStepOptions {
mode?: 'async' | 'queue';
batchId?: string;
batchIndex?: number;
batchTotal?: number;
globalIndex?: number;
}
export interface WorkflowStep {
id: string;
mcp: string;
@@ -36,6 +44,7 @@ export interface WorkflowStep {
result?: unknown;
error?: string;
duration?: number;
options?: WorkflowStepOptions;
}
export interface WorkflowDefinition {