diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 1be8c0c82..0c5816af1 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -214,8 +214,11 @@ jobs: shell: bash run: | prerelease_args=() - if [[ "$GITHUB_REF_NAME" == *-* || "$GITHUB_REF_NAME" == *.rc* || "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + latest_args=() + if [[ "$GITHUB_REF_NAME" == *-* || "$GITHUB_REF_NAME" == *.rc* || ( "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && "$GITHUB_REF_NAME" != "v1.6.6.1" ) ]]; then prerelease_args+=(--prerelease) + else + latest_args+=(--latest) fi assets=( dist/open-ai-canvas-host-updater-linux-amd64 @@ -230,5 +233,6 @@ jobs: --verify-tag \ --title "$GITHUB_REF_NAME" \ --notes-file dist/RELEASE_NOTES.md \ - "${prerelease_args[@]}" + "${prerelease_args[@]}" \ + "${latest_args[@]}" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ac40a70..c57dc315c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# CHANGELOG +# CHANGELOG + +## v1.6.6.1 + +- 修复服务端出站 URL 对 DNS 解析结果的私网 IP 拦截被注释的问题,恢复对环回、内网和链路本地地址的拒绝,避免 SSRF。 +- 修复质量检查:工作台测试模块路径、懒加载画布路由合同和模型促销标签断言;修正影视剧作工坊内置脚本树完整性基线。 +- 修复创作页上一条任务仍在生成时无法提交下一条任务的问题。 +- 随后端镜像内置影视剧作工坊;服务启动时同步技能目录、版本和文件记录,保留用户加入/收藏状态。 +- 升级注意:数据库 Schema 从 v50 升至 v54,包含个人资料安全、头像预设、登录日志和工作台工具/预设迁移。生产 Compose 会在启动 Backend 前执行 `migrate-schema up`;无需手动建表。 ## v1.6.6 diff --git a/VERSION b/VERSION index 0591f3366..631f57b5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.6.6 +v1.6.6.1 diff --git a/backend/internal/app/project_settings_test.go b/backend/internal/app/project_settings_test.go index 63fcb346c..c00a20d19 100644 --- a/backend/internal/app/project_settings_test.go +++ b/backend/internal/app/project_settings_test.go @@ -24,7 +24,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) { &model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.CreationRun{}, &model.CreationSubmission{}, - &model.CloudAgentResourceLease{}, &model.Tool{}, + &model.CloudAgentResourceLease{}, &model.Tool{}, &model.WorkbenchTool{}, &model.Announcement{}, &model.AnnouncementImageDraft{}, ); err != nil { t.Fatal(err) diff --git a/backend/internal/app/service.go b/backend/internal/app/service.go index b8d74cebc..ceef2640e 100644 --- a/backend/internal/app/service.go +++ b/backend/internal/app/service.go @@ -101,9 +101,9 @@ const taskWorkerConcurrency = 3 const taskLogPayloadLimit = 4000 type CreateTaskRequest struct { - creationPrepare *creationTaskPreparation - admission *taskAdmission - workbenchKind string + creationPrepare *creationTaskPreparation + admission *taskAdmission + workbenchKind string // callerHoldsStorageMu 只给已经持有 Service.storageMu 的内部调用设置。 // 文件容量检查不能再锁一次,否则审批改参数会自锁。 callerHoldsStorageMu bool diff --git a/backend/internal/app/task_creation_test.go b/backend/internal/app/task_creation_test.go index 0ef4ea8c3..524999539 100644 --- a/backend/internal/app/task_creation_test.go +++ b/backend/internal/app/task_creation_test.go @@ -14,7 +14,7 @@ import ( func TestWorkbenchKindFromTaskInputRequiresWorkbenchMetadataAndMatchingTaskType(t *testing.T) { input := map[string]any{ - "mode": "image", + "mode": "image", "metadata": map[string]any{"source": "workbench", "workbenchKind": "image"}, } if got := workbenchKindFromTaskInput(input, "canvas_image"); got != model.WorkbenchKindImage { diff --git a/backend/internal/outbound/outbound.go b/backend/internal/outbound/outbound.go index 6ff0ac7d7..0dbe749dc 100644 --- a/backend/internal/outbound/outbound.go +++ b/backend/internal/outbound/outbound.go @@ -455,13 +455,13 @@ func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivat failure.Reason = kernel.ReasonUpstreamDNSFailed return nil, failure } - // if !allowPrivateHost { - // for _, ip := range addresses { - // if blockedOutboundIP(ip) { - // return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址") - // } - // } - // } + if !allowPrivateHost { + for _, ip := range addresses { + if blockedOutboundIP(ip) { + return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址") + } + } + } return addresses, nil } diff --git a/backend/internal/repository/workbench_tasks.go b/backend/internal/repository/workbench_tasks.go index fcb04e608..ebc3d334b 100644 --- a/backend/internal/repository/workbench_tasks.go +++ b/backend/internal/repository/workbench_tasks.go @@ -10,14 +10,27 @@ type TaskPage struct { } func (r *Repository) WorkbenchTasks(userID, kind string, activeOnly bool, cursor string, limit int) (TaskPage, error) { - if limit < 1 || limit > 100 { limit = 30 } - if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { return TaskPage{}, nil } + if limit < 1 || limit > 100 { + limit = 30 + } + if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { + return TaskPage{}, nil + } query := r.db.Model(&model.Task{}).Where("user_id = ? AND workbench_kind = ?", userID, kind) - if activeOnly { query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) } - if cursor != "" { query = query.Where("id < ?", cursor) } + if activeOnly { + query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) + } + if cursor != "" { + query = query.Where("id < ?", cursor) + } var tasks []model.Task - if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil { return TaskPage{}, err } + if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil { + return TaskPage{}, err + } page := TaskPage{Tasks: tasks} - if len(tasks) > limit { page.Tasks = tasks[:limit]; page.NextCursor = page.Tasks[len(page.Tasks)-1].ID } + if len(tasks) > limit { + page.Tasks = tasks[:limit] + page.NextCursor = page.Tasks[len(page.Tasks)-1].ID + } return page, nil } diff --git a/backend/internal/skills/skills_yingshi_builtin_test.go b/backend/internal/skills/skills_yingshi_builtin_test.go index f500bec53..fd37fe788 100644 --- a/backend/internal/skills/skills_yingshi_builtin_test.go +++ b/backend/internal/skills/skills_yingshi_builtin_test.go @@ -15,7 +15,7 @@ import ( ) const yingshiSkillID = "yingshi-drama-workshop" -const yingshiPythonTreeSHA256 = "15b6dbe0a6fad34e37694bc394428159bb18f438dc56971ba2055620079a037c" +const yingshiPythonTreeSHA256 = "731838b084b3efbfda806378e5a6a2a83a0bf2040f8c627c29b88d32c904ff88" var pythonScriptCommand = regexp.MustCompile("(?i)^\\s*(?:\\$\\s*)?(?:python(?:3(?:\\.\\d+)?)?|py(?:\\s+-3(?:\\.\\d+)?)?)\\s+[^`\\n]*(?:\\.py\\b|-m\\s+wfkit)") var machineSpecificPath = regexp.MustCompile(`(?i)(?:[a-z]:\\|\\\\[^\\]+\\|/(?:Users|home|private/Users)/|~/)`) diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx index caf640113..de18f5763 100644 --- a/web/src/pages/admin/components/feature-availability-panel.tsx +++ b/web/src/pages/admin/components/feature-availability-panel.tsx @@ -8,7 +8,8 @@ import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/s import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store"; import { AdminStatusBadge } from "./admin-ui"; -type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled"; +type FeatureKey = + "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled"; type FeatureRow = { key: FeatureKey; title: string; @@ -17,7 +18,18 @@ type FeatureRow = { dependsOn?: FeatureKey; }; -const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "inspirationSourcesVisible", "imageWorkbenchEnabled", "videoWorkbenchEnabled", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"]; +const editableFeatureKeys: FeatureKey[] = [ + "shortDramaEnabled", + "taskCenterEnabled", + "creditsEnabled", + "customChannelsEnabled", + "inspirationSourcesVisible", + "imageWorkbenchEnabled", + "videoWorkbenchEnabled", + "frontendModelsEnabled", + "pluginCenterEnabled", + "systemPluginsVisibleToUsers", +]; const workspaceFeatureRows: FeatureRow[] = [ { diff --git a/web/src/pages/admin/workbench-tools/workbench-tools-page.tsx b/web/src/pages/admin/workbench-tools/workbench-tools-page.tsx index ba7530e89..44f5a82e9 100644 --- a/web/src/pages/admin/workbench-tools/workbench-tools-page.tsx +++ b/web/src/pages/admin/workbench-tools/workbench-tools-page.tsx @@ -7,7 +7,20 @@ import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { AdminDataTable, AdminStatusBadge } from "@/pages/admin/components/admin-ui"; import { workbenchCategories, workbenchCategoryLabel, workbenchModeOptions } from "@/pages/workbench/workbench-catalog"; import { uploadResourceFile } from "@/services/api/resources"; -import { capabilitySupportedModes, listWorkbenchTools, normalizeWorkbenchGuide, resolveWorkbenchResourceURL, saveAdminWorkbenchTool, setAdminWorkbenchToolEnabled, type WorkbenchInputContract, type WorkbenchKind, type WorkbenchReferenceSlot, type WorkbenchTool, type WorkbenchPromptPreset, type WorkbenchResultExample } from "@/services/api/workbench"; +import { + capabilitySupportedModes, + listWorkbenchTools, + normalizeWorkbenchGuide, + resolveWorkbenchResourceURL, + saveAdminWorkbenchTool, + setAdminWorkbenchToolEnabled, + type WorkbenchInputContract, + type WorkbenchKind, + type WorkbenchReferenceSlot, + type WorkbenchTool, + type WorkbenchPromptPreset, + type WorkbenchResultExample, +} from "@/services/api/workbench"; import "./workbench-tools.css"; @@ -43,63 +56,157 @@ type MediaValue = { id?: string; url?: string }; type UploadMedia = (file: File, kind: "image" | "video") => Promise; const emptyDraft = (kind: WorkbenchKind = "image"): ToolDraft => ({ - kind, categoryId: "recommended", name: "", subtitle: "", description: "", guideText: "", inputHint: "", sortOrder: 0, enabled: false, - supportedModes: [kind], searchKeywords: [], surfaces: ["workbench"], recommended: false, recommendationOrder: 0, - capability: {}, guideConfig: {}, inputContract: { promptRequired: true, minTextReferences: 0, slots: [] }, resultExample: { imageResourceIds: [], imageUrls: [] }, presets: [], savedPresetIds: [], + kind, + categoryId: "recommended", + name: "", + subtitle: "", + description: "", + guideText: "", + inputHint: "", + sortOrder: 0, + enabled: false, + supportedModes: [kind], + searchKeywords: [], + surfaces: ["workbench"], + recommended: false, + recommendationOrder: 0, + capability: {}, + guideConfig: {}, + inputContract: { promptRequired: true, minTextReferences: 0, slots: [] }, + resultExample: { imageResourceIds: [], imageUrls: [] }, + presets: [], + savedPresetIds: [], }); function record(value: unknown): Record { if (typeof value === "string") { - try { return record(JSON.parse(value)); } catch { return {}; } + try { + return record(JSON.parse(value)); + } catch { + return {}; + } } - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function makeDraft(tool: WorkbenchTool): ToolDraft { const capability = record(tool.capability); const contract = tool.inputContract || { promptRequired: true, minTextReferences: 0, slots: [] }; return { - ...emptyDraft(tool.kind), ...tool, capability, guideConfig: record(tool.guide), guideText: normalizeWorkbenchGuide(tool.guide), inputHint: tool.inputHint || "", + ...emptyDraft(tool.kind), + ...tool, + capability, + guideConfig: record(tool.guide), + guideText: normalizeWorkbenchGuide(tool.guide), + inputHint: tool.inputHint || "", supportedModes: capabilitySupportedModes(tool).length ? capabilitySupportedModes(tool) : [tool.kind], - searchKeywords: tool.searchKeywords || [], surfaces: tool.surfaces?.length ? tool.surfaces : ["workbench"], - recommended: Boolean(tool.recommended), recommendationOrder: tool.recommendationOrder || 0, + searchKeywords: tool.searchKeywords || [], + surfaces: tool.surfaces?.length ? tool.surfaces : ["workbench"], + recommended: Boolean(tool.recommended), + recommendationOrder: tool.recommendationOrder || 0, inputContract: { ...contract, slots: contract.slots.map((slot) => ({ ...slot })) }, resultExample: { ...tool.resultExample, imageResourceIds: tool.resultExample?.imageResourceIds || [] }, - presets: tool.presets.map((preset) => ({ ...preset })), savedPresetIds: tool.presets.map((preset) => preset.id), + presets: tool.presets.map((preset) => ({ ...preset })), + savedPresetIds: tool.presets.map((preset) => preset.id), }; } function ResourceUpload({ label, value, kind = "image", disabled, upload, onChange }: { label: string; value: MediaValue; kind?: "image" | "video"; disabled: boolean; upload: UploadMedia; onChange: (value: MediaValue) => void }) { const [uploading, setUploading] = useState(false); - return
-
{value.url ? kind === "video" ?
-
- {label}{value.id ? value.url ? "已上传" : "已上传,预览暂不可用" : "选择文件上传"} -
{ - setUploading(true); - try { onChange(await upload(file, kind)); } catch { /* Upload errors are reported by the shared uploader. */ } finally { setUploading(false); } - return false; - }}> - {value.id && }
+ return ( +
+
{value.url ? kind === "video" ?
+
+ {label} + {value.id ? (value.url ? "已上传" : "已上传,预览暂不可用") : "选择文件上传"} +
+ { + setUploading(true); + try { + onChange(await upload(file, kind)); + } catch { + /* Upload errors are reported by the shared uploader. */ + } finally { + setUploading(false); + } + return false; + }} + > + + + {value.id && ( + + )} +
+
-
; + ); } -function SlotEditor({ slot, index, kind, disabled, upload, onChange, onRemove }: { slot: WorkbenchReferenceSlot; index: number; kind: WorkbenchKind; disabled: boolean; upload: UploadMedia; onChange: (patch: Partial) => void; onRemove: () => void }) { - return
-
- - +
- 当前类型还没有工具,使用“新建工具”创建首个目录项。
} /> -
- !editorBusy && setEditorOpen(false)} onOk={() => void save()} okText={uploadCount ? "等待上传完成" : "保存"} okButtonProps={{ disabled: uploadCount > 0 }} cancelButtonProps={{ disabled: editorBusy }} confirmLoading={saving} maskClosable={!editorBusy} keyboard={!editorBusy} closable={!editorBusy} width={1080} destroyOnHidden className="admin-workbench-tool-editor"> -
-
- - - - - + return ( + } onClick={() => void openEditor()}> + 新建工具 + + } + > +
+
+ } placeholder="搜索名称或说明" value={query} onChange={(event) => setQuery(event.target.value)} allowClear /> +
- -
- setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))} /> - setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))} /> -
-
用户需要提供什么配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。
-
-
{draft.inputContract.slots.map((slot, index) => updateSlot(slot.key, patch)} onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))} />)}
-
使用说明与展示
-
- - -
-
{draft.resultExample.imageResourceIds?.map((id, index) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: current.resultExample.imageResourceIds?.flatMap((item, i) => i === index ? value.id ? [value.id] : [] : [item]), imageUrls: current.resultExample.imageResourceIds?.flatMap((_, i) => i === index ? value.id ? [value.url || ""] : [] : [current.resultExample.imageUrls?.[i] || ""]) } }))} />)}
-
- {(draft.resultExample.imageResourceIds?.length || 0) < 8 && value.id && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] } }))} />} - {draft.kind === "video" && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))} />} -
-
预设提示词与工具一起保存;已保存的预设可停用,历史记录保留。
- {draft.presets.map((preset, index) =>
updatePreset(preset.id, { name: event.target.value })} /> updatePreset(preset.id, { summary: event.target.value })} /> updatePreset(preset.id, { promptTemplate: event.target.value })} /> updatePreset(preset.id, { enabled })} />{!draft.savedPresetIds.includes(preset.id) && }
)} -
- - - ; + 当前类型还没有工具,使用“新建工具”创建首个目录项。
} + /> +
+ !editorBusy && setEditorOpen(false)} + onOk={() => void save()} + okText={uploadCount ? "等待上传完成" : "保存"} + okButtonProps={{ disabled: uploadCount > 0 }} + cancelButtonProps={{ disabled: editorBusy }} + confirmLoading={saving} + maskClosable={!editorBusy} + keyboard={!editorBusy} + closable={!editorBusy} + width={1080} + destroyOnHidden + className="admin-workbench-tool-editor" + > +
+
+ + + + + + + + + + + + + + + +
+ + + +
+ setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))} + /> + setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))} + /> +
+
+ 用户需要提供什么 + 配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。 +
+
+ + +
+
+ {draft.inputContract.slots.map((slot, index) => ( + updateSlot(slot.key, patch)} + onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))} + /> + ))} +
+
+ 使用说明与展示 +
+
+ + + + + + + + + + + + + + + +
+
+ {draft.resultExample.imageResourceIds?.map((id, index) => ( + + setDraft((current) => ({ + ...current, + resultExample: { + ...current.resultExample, + imageResourceIds: current.resultExample.imageResourceIds?.flatMap((item, i) => (i === index ? (value.id ? [value.id] : []) : [item])), + imageUrls: current.resultExample.imageResourceIds?.flatMap((_, i) => (i === index ? (value.id ? [value.url || ""] : []) : [current.resultExample.imageUrls?.[i] || ""])), + }, + })) + } + /> + ))} +
+
+ {(draft.resultExample.imageResourceIds?.length || 0) < 8 && ( + + value.id && + setDraft((current) => ({ + ...current, + resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] }, + })) + } + /> + )} + {draft.kind === "video" && ( + setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))} + /> + )} +
+
+
+
+ 预设提示词 + 与工具一起保存;已保存的预设可停用,历史记录保留。 +
+ +
+ {draft.presets.map((preset, index) => ( +
+ updatePreset(preset.id, { name: event.target.value })} /> + updatePreset(preset.id, { summary: event.target.value })} /> + updatePreset(preset.id, { promptTemplate: event.target.value })} /> + updatePreset(preset.id, { enabled })} /> + {!draft.savedPresetIds.includes(preset.id) && ( + + )} +
+ ))} +
+
+
+ + ); } diff --git a/web/src/pages/admin/workbench-tools/workbench-tools.css b/web/src/pages/admin/workbench-tools/workbench-tools.css index cecc23274..de9f57528 100644 --- a/web/src/pages/admin/workbench-tools/workbench-tools.css +++ b/web/src/pages/admin/workbench-tools/workbench-tools.css @@ -1 +1,256 @@ -.admin-workbench-tools{display:flex;min-height:0;flex-direction:column;gap:14px}.admin-workbench-tools-toolbar{display:flex;align-items:center;gap:10px}.admin-workbench-tools-toolbar .ant-select{width:150px}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:360px}.admin-workbench-tool-main{display:flex;align-items:center;gap:10px;text-align:left}.admin-workbench-tool-icon{display:grid;width:38px;height:38px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-tool-icon img{width:100%;height:100%;object-fit:cover}.admin-workbench-tool-main strong,.admin-workbench-tool-main small{display:block}.admin-workbench-tool-main small{max-width:420px;overflow:hidden;color:var(--admin-text-muted);text-overflow:ellipsis;white-space:nowrap}.admin-workbench-tool-status{display:flex;align-items:center;justify-content:center;gap:10px}.admin-workbench-tools-empty{padding:48px 16px;text-align:center;color:var(--admin-text-muted)}.admin-workbench-tool-editor .ant-modal-body{max-height:min(76vh,860px);overflow:auto}.admin-workbench-tool-form{padding:4px 2px}.admin-workbench-tool-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.admin-workbench-resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:20px}.admin-workbench-resource-upload{display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--admin-border-subtle);border-radius:12px;background:var(--admin-layer-1)}.admin-workbench-resource-preview{display:grid;width:72px;height:72px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-resource-preview img,.admin-workbench-resource-preview video{width:100%;height:100%;object-fit:cover}.admin-workbench-resource-actions{display:flex;min-width:0;flex:1;flex-direction:column;gap:4px}.admin-workbench-resource-actions strong,.admin-workbench-resource-actions small{display:block}.admin-workbench-resource-actions small{color:var(--admin-text-muted)}.admin-workbench-resource-actions>div{display:flex;gap:8px;margin-top:4px}.admin-workbench-section-title{display:flex;flex-direction:column;gap:3px;margin:20px 0 12px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-section-title small{color:var(--admin-text-muted)}.admin-workbench-contract-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:10px}.admin-workbench-contract-toolbar .ant-input-number,.admin-workbench-contract-toolbar .ant-input{max-width:180px}.admin-workbench-slots{display:flex;flex-direction:column;gap:10px}.admin-workbench-slot-row{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:12px;background:var(--admin-layer-2)}.admin-workbench-slot-fields{display:grid;grid-template-columns:1fr 1fr 1.25fr 130px 130px auto;align-items:center;gap:8px}.admin-workbench-slot-assets{display:flex;align-items:center;gap:8px}.admin-workbench-slot-assets small{color:var(--admin-text-muted)}.admin-workbench-example-actions{display:flex;gap:8px;margin:4px 0 12px}.admin-workbench-example-previews{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px}.admin-workbench-example-previews>div{position:relative;display:flex;flex-direction:column;gap:5px}.admin-workbench-example-previews img,.admin-workbench-example-previews video{width:120px;height:84px;object-fit:cover;border-radius:8px;background:var(--admin-layer-2)}.admin-workbench-presets{margin-top:22px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-presets>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.admin-workbench-presets>header strong,.admin-workbench-presets>header small{display:block}.admin-workbench-presets>header small{margin-top:3px;color:var(--admin-text-muted)}.admin-workbench-preset-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(140px,1fr) minmax(240px,2fr) auto auto;align-items:start;gap:8px;margin-bottom:10px;padding:12px;border-radius:10px;background:var(--admin-layer-2)}@media(max-width:900px){.admin-workbench-slot-fields{grid-template-columns:1fr 1fr 1fr}.admin-workbench-contract-toolbar{flex-wrap:wrap}}@media(max-width:760px){.admin-workbench-tools-toolbar{flex-wrap:wrap}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:none;flex:1}.admin-workbench-tool-grid,.admin-workbench-resource-grid{grid-template-columns:1fr}.admin-workbench-slot-fields{grid-template-columns:1fr 1fr}.admin-workbench-preset-row{grid-template-columns:1fr 1fr}.admin-workbench-preset-row textarea{grid-column:1/-1}} \ No newline at end of file +.admin-workbench-tools { + display: flex; + min-height: 0; + flex-direction: column; + gap: 14px; +} +.admin-workbench-tools-toolbar { + display: flex; + align-items: center; + gap: 10px; +} +.admin-workbench-tools-toolbar .ant-select { + width: 150px; +} +.admin-workbench-tools-toolbar .ant-input-affix-wrapper { + max-width: 360px; +} +.admin-workbench-tool-main { + display: flex; + align-items: center; + gap: 10px; + text-align: left; +} +.admin-workbench-tool-icon { + display: grid; + width: 38px; + height: 38px; + flex: none; + place-items: center; + overflow: hidden; + border-radius: 10px; + background: var(--admin-layer-2); + color: var(--admin-text-muted); +} +.admin-workbench-tool-icon img { + width: 100%; + height: 100%; + object-fit: cover; +} +.admin-workbench-tool-main strong, +.admin-workbench-tool-main small { + display: block; +} +.admin-workbench-tool-main small { + max-width: 420px; + overflow: hidden; + color: var(--admin-text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.admin-workbench-tool-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} +.admin-workbench-tools-empty { + padding: 48px 16px; + text-align: center; + color: var(--admin-text-muted); +} +.admin-workbench-tool-editor .ant-modal-body { + max-height: min(76vh, 860px); + overflow: auto; +} +.admin-workbench-tool-form { + padding: 4px 2px; +} +.admin-workbench-tool-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 16px; +} +.admin-workbench-resource-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.admin-workbench-resource-upload { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border: 1px solid var(--admin-border-subtle); + border-radius: 12px; + background: var(--admin-layer-1); +} +.admin-workbench-resource-preview { + display: grid; + width: 72px; + height: 72px; + flex: none; + place-items: center; + overflow: hidden; + border-radius: 10px; + background: var(--admin-layer-2); + color: var(--admin-text-muted); +} +.admin-workbench-resource-preview img, +.admin-workbench-resource-preview video { + width: 100%; + height: 100%; + object-fit: cover; +} +.admin-workbench-resource-actions { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 4px; +} +.admin-workbench-resource-actions strong, +.admin-workbench-resource-actions small { + display: block; +} +.admin-workbench-resource-actions small { + color: var(--admin-text-muted); +} +.admin-workbench-resource-actions > div { + display: flex; + gap: 8px; + margin-top: 4px; +} +.admin-workbench-section-title { + display: flex; + flex-direction: column; + gap: 3px; + margin: 20px 0 12px; + padding-top: 16px; + border-top: 1px solid var(--admin-border-subtle); +} +.admin-workbench-section-title small { + color: var(--admin-text-muted); +} +.admin-workbench-contract-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.admin-workbench-contract-toolbar .ant-input-number, +.admin-workbench-contract-toolbar .ant-input { + max-width: 180px; +} +.admin-workbench-slots { + display: flex; + flex-direction: column; + gap: 10px; +} +.admin-workbench-slot-row { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border-radius: 12px; + background: var(--admin-layer-2); +} +.admin-workbench-slot-fields { + display: grid; + grid-template-columns: 1fr 1fr 1.25fr 130px 130px auto; + align-items: center; + gap: 8px; +} +.admin-workbench-slot-assets { + display: flex; + align-items: center; + gap: 8px; +} +.admin-workbench-slot-assets small { + color: var(--admin-text-muted); +} +.admin-workbench-example-actions { + display: flex; + gap: 8px; + margin: 4px 0 12px; +} +.admin-workbench-example-previews { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; +} +.admin-workbench-example-previews > div { + position: relative; + display: flex; + flex-direction: column; + gap: 5px; +} +.admin-workbench-example-previews img, +.admin-workbench-example-previews video { + width: 120px; + height: 84px; + object-fit: cover; + border-radius: 8px; + background: var(--admin-layer-2); +} +.admin-workbench-presets { + margin-top: 22px; + padding-top: 16px; + border-top: 1px solid var(--admin-border-subtle); +} +.admin-workbench-presets > header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} +.admin-workbench-presets > header strong, +.admin-workbench-presets > header small { + display: block; +} +.admin-workbench-presets > header small { + margin-top: 3px; + color: var(--admin-text-muted); +} +.admin-workbench-preset-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(140px, 1fr) minmax(240px, 2fr) auto auto; + align-items: start; + gap: 8px; + margin-bottom: 10px; + padding: 12px; + border-radius: 10px; + background: var(--admin-layer-2); +} +@media (max-width: 900px) { + .admin-workbench-slot-fields { + grid-template-columns: 1fr 1fr 1fr; + } + .admin-workbench-contract-toolbar { + flex-wrap: wrap; + } +} +@media (max-width: 760px) { + .admin-workbench-tools-toolbar { + flex-wrap: wrap; + } + .admin-workbench-tools-toolbar .ant-input-affix-wrapper { + max-width: none; + flex: 1; + } + .admin-workbench-tool-grid, + .admin-workbench-resource-grid { + grid-template-columns: 1fr; + } + .admin-workbench-slot-fields { + grid-template-columns: 1fr 1fr; + } + .admin-workbench-preset-row { + grid-template-columns: 1fr 1fr; + } + .admin-workbench-preset-row textarea { + grid-column: 1/-1; + } +} diff --git a/web/src/pages/workbench/image-workbench-page.tsx b/web/src/pages/workbench/image-workbench-page.tsx index cf1e8243a..2d2634df8 100644 --- a/web/src/pages/workbench/image-workbench-page.tsx +++ b/web/src/pages/workbench/image-workbench-page.tsx @@ -45,7 +45,11 @@ export type WorkbenchReference = { export function filterWorkbenchTools(tools: WorkbenchTool[], category: string, query: string) { const normalized = query.trim().toLocaleLowerCase(); - return tools.filter((tool) => (category === "all" || (category === "recommended" ? tool.recommended || tool.categoryId === category : tool.categoryId === category)) && (!normalized || `${tool.name} ${tool.subtitle} ${tool.description} ${workbenchCategoryLabel(tool.categoryId)} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase().includes(normalized))); + return tools.filter( + (tool) => + (category === "all" || (category === "recommended" ? tool.recommended || tool.categoryId === category : tool.categoryId === category)) && + (!normalized || `${tool.name} ${tool.subtitle} ${tool.description} ${workbenchCategoryLabel(tool.categoryId)} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase().includes(normalized)), + ); } function parseInput(inputJson?: string) { @@ -76,9 +80,9 @@ type WorkbenchTaskSnapshot = { export function workbenchTaskSnapshot(task: GenerationTask): WorkbenchTaskSnapshot | null { const input = parseInput(task.inputJson); if (!input) return null; - const metadata = input.metadata && typeof input.metadata === "object" ? input.metadata as Record : {}; - const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? metadata.workbench as Record : {}; - const snapshot = input.workbench && typeof input.workbench === "object" ? input.workbench as Record : metadataSnapshot; + const metadata = input.metadata && typeof input.metadata === "object" ? (input.metadata as Record) : {}; + const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? (metadata.workbench as Record) : {}; + const snapshot = input.workbench && typeof input.workbench === "object" ? (input.workbench as Record) : metadataSnapshot; const kind = snapshot.kind || metadata.workbenchKind; if (kind !== "image" && kind !== "video") return null; const rawReferences = Array.isArray(snapshot.references) ? snapshot.references : []; @@ -195,21 +199,22 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const activeTasks = useMemo(() => tasks.filter((task) => task.status === "queued" || task.status === "running"), [tasks]); const historyTasks = useMemo(() => tasks.filter((task) => task.status !== "queued" && task.status !== "running"), [tasks]); const selectedCount = Number(count) || 1; - const promptReferences = useMemo(() => references.map((reference) => ({ - id: reference.id, - nodeId: reference.id, - kind: reference.kind, - label: reference.name, - title: reference.name, - previewUrl: reference.previewUrl, - ...(reference.resourceId ? { storageKey: resourceStorageKey(reference.resourceId) } : {}), - active: true, - })), [references]); - const composerConfig = useMemo( + const promptReferences = useMemo( () => - kind === "video" && selectedModel - ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) - : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count }, + references.map((reference) => ({ + id: reference.id, + nodeId: reference.id, + kind: reference.kind, + label: reference.name, + title: reference.name, + previewUrl: reference.previewUrl, + ...(reference.resourceId ? { storageKey: resourceStorageKey(reference.resourceId) } : {}), + active: true, + })), + [references], + ); + const composerConfig = useMemo( + () => (kind === "video" && selectedModel ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count }), [config, count, kind, quality, ratio, seconds, selectedModel, videoQuality], ); @@ -435,7 +440,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const slot = tool?.inputContract?.slots.find((item) => item.key === targetSlotKey); const allowedTypes = slot?.mediaTypes || (kind === "image" ? ["image"] : ["image", "video", "audio"]); const accepted = list.filter((file) => allowedTypes.some((type) => file.type.startsWith(`${type}/`))); - if (accepted.length !== list.length) message.warning(`该输入槽位仅支持${allowedTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join("、")}`); + if (accepted.length !== list.length) message.warning(`该输入槽位仅支持${allowedTypes.map((type) => (type === "image" ? "图片" : type === "video" ? "视频" : "音频")).join("、")}`); const currentCount = references.filter((reference) => reference.slotKey === targetSlotKey).length; const maxCount = slot?.maxCount || (kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)); const picked = accepted.slice(0, Math.max(0, maxCount - currentCount)); @@ -510,7 +515,13 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const videoRequest = kind === "video" && videoProfile ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : null; const parameters = imageRequest ? { size: imageRequest.size, quality: imageRequest.quality, count: Number(imageRequest.count) } - : { seconds: Number(videoRequest?.seconds || seconds), ratio: videoRequest?.ratio || ratio, resolution: videoRequest?.resolution || videoQuality, generateAudio: composerConfig.videoGenerateAudio === "true", watermark: composerConfig.videoWatermark === "true" }; + : { + seconds: Number(videoRequest?.seconds || seconds), + ratio: videoRequest?.ratio || ratio, + resolution: videoRequest?.resolution || videoQuality, + generateAudio: composerConfig.videoGenerateAudio === "true", + watermark: composerConfig.videoWatermark === "true", + }; const generationConfig = { ...composerConfig, model: selectedModel, @@ -519,32 +530,63 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { }; const readyReferences = references.filter((reference) => reference.resourceId); const referenceSnapshot = readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })); - const createdTask = logicalModelId || channel?.scope === "system" - ? await createWorkbenchTask({ - kind, - ...(tool ? { toolId: tool.id } : {}), - ...(tool && preset ? { presetId: preset.id } : {}), - prompt: submittedPrompt, - model: modelOptionName(selectedModel), - ...(logicalModelId ? { logicalModelId } : { channelId: channel.id }), - parameters, - references: readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), - }) - : await submitBackendGenerationTask({ - mode: kind, - prompt: submittedPrompt, - config: generationConfig, - referenceImages: readyReferences.filter((reference) => reference.kind === "image").map((reference) => ({ id: reference.id, name: reference.name, type: "image/*", dataUrl: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), - referenceVideos: readyReferences.filter((reference) => reference.kind === "video").map((reference) => ({ id: reference.id, name: reference.name, type: "video/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), - referenceAudios: readyReferences.filter((reference) => reference.kind === "audio").map((reference) => ({ id: reference.id, name: reference.name, type: "audio/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), - metadata: { - source: "workbench", - workbenchKind: kind, - ...(tool ? { workbenchToolId: tool.id, workbenchToolName: tool.name } : {}), - ...(tool && preset ? { workbenchPresetId: preset.id } : {}), - workbench: { kind, ...(tool ? { toolId: tool.id } : {}), ...(tool && preset ? { presetId: preset.id } : {}), parameters, references: referenceSnapshot }, - }, - }); + const createdTask = + logicalModelId || channel?.scope === "system" + ? await createWorkbenchTask({ + kind, + ...(tool ? { toolId: tool.id } : {}), + ...(tool && preset ? { presetId: preset.id } : {}), + prompt: submittedPrompt, + model: modelOptionName(selectedModel), + ...(logicalModelId ? { logicalModelId } : { channelId: channel.id }), + parameters, + references: readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })), + }) + : await submitBackendGenerationTask({ + mode: kind, + prompt: submittedPrompt, + config: generationConfig, + referenceImages: readyReferences + .filter((reference) => reference.kind === "image") + .map((reference) => ({ + id: reference.id, + name: reference.name, + type: "image/*", + dataUrl: "", + storageKey: resourceStorageKey(reference.resourceId!), + role: reference.role, + ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), + })), + referenceVideos: readyReferences + .filter((reference) => reference.kind === "video") + .map((reference) => ({ + id: reference.id, + name: reference.name, + type: "video/*", + url: "", + storageKey: resourceStorageKey(reference.resourceId!), + role: reference.role, + ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), + })), + referenceAudios: readyReferences + .filter((reference) => reference.kind === "audio") + .map((reference) => ({ + id: reference.id, + name: reference.name, + type: "audio/*", + url: "", + storageKey: resourceStorageKey(reference.resourceId!), + role: reference.role, + ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), + })), + metadata: { + source: "workbench", + workbenchKind: kind, + ...(tool ? { workbenchToolId: tool.id, workbenchToolName: tool.name } : {}), + ...(tool && preset ? { workbenchPresetId: preset.id } : {}), + workbench: { kind, ...(tool ? { toolId: tool.id } : {}), ...(tool && preset ? { presetId: preset.id } : {}), parameters, references: referenceSnapshot }, + }, + }); const task = await queryGenerationTask(createdTask.id); setActiveTask(task); setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]); @@ -609,17 +651,19 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { setUploadSlotKey(undefined); referencesRef.current.forEach(revokeReferencePreview); setReferences([]); - const restoredReferences = await Promise.all(snapshot.references.map(async (reference, index): Promise => ({ - id: crypto.randomUUID(), - name: `${reference.kind === "image" ? "图片" : reference.kind === "video" ? "视频" : "音频"} ${index + 1}`, - kind: reference.kind, - resourceId: reference.resourceId, - previewUrl: await resolveWorkbenchResourceURL(reference.resourceId, "original") || "", - progress: 100, - uploading: false, - role: reference.role || "reference", - ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), - }))); + const restoredReferences = await Promise.all( + snapshot.references.map(async (reference, index): Promise => ({ + id: crypto.randomUUID(), + name: `${reference.kind === "image" ? "图片" : reference.kind === "video" ? "视频" : "音频"} ${index + 1}`, + kind: reference.kind, + resourceId: reference.resourceId, + previewUrl: (await resolveWorkbenchResourceURL(reference.resourceId, "original")) || "", + progress: 100, + uploading: false, + role: reference.role || "reference", + ...(reference.slotKey ? { slotKey: reference.slotKey } : {}), + })), + ); setReferences(restoredReferences); const params = new URLSearchParams(searchParams); if (restoredTool) params.set("tool", restoredTool.id); @@ -683,30 +727,87 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { : "报价暂不可用"; const resultTask = activeTask || tasks.find((task) => task.status === "succeeded") || null; const resultUrl = resultTask ? taskPreview(resultTask) : ""; - const settingsPanel = kind === "image" && imageProfile ? ( -
- - {imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? ( - + ) : null} + { + setCount(value); + rememberImageSettings({ count: value }); + }} + /> +
+ ) : videoProfile ? ( +
+ (videoProfile.duration.min || 1) + index * (videoProfile.duration.step || 1), + ) + ).map((value) => ({ value: String(value), label: `${value} 秒` }))} + onChange={(value) => { + setSeconds(value); + rememberVideoSettings({ seconds: value }); + }} + /> + ({ value, label: value === "adaptive" ? "自适应" : value }))} + onChange={(value) => { + setRatio(value); + rememberVideoSettings({ ratio: value }); + }} + /> + ({ value: value.replace(/p$/i, ""), label: value }))} + onChange={(value) => { + setVideoQuality(value); + rememberVideoSettings({ videoQuality: value }); + }} + /> + {videoProfile.generateAudio.supported || videoProfile.watermark.supported ? ( +
+ {videoProfile.generateAudio.supported ? ( + + ) : null} + {videoProfile.watermark.supported ? ( + + ) : null} +
+ ) : null} +
+ ) : ( +

选择模型后,这里会显示该模型真正支持的参数。

+ ); const toolLibrary = (
@@ -722,7 +823,11 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
} value={toolSearch} onChange={(event) => setToolSearch(event.target.value)} placeholder="搜索工具" allowClear />
- {[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => )} + {[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => ( + + ))}
{directoryLoading ? (
@@ -737,7 +842,11 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { setDirectoryError(""); setDirectoryLoading(true); listWorkbenchTools(kind) - .then((directory) => setTools(directory.tools.filter((item) => item.kind === kind && item.enabled !== false && (!capabilitySupportedModes(item).length || capabilitySupportedModes(item).includes(kind))).sort((a, b) => a.sortOrder - b.sortOrder))) + .then((directory) => + setTools( + directory.tools.filter((item) => item.kind === kind && item.enabled !== false && (!capabilitySupportedModes(item).length || capabilitySupportedModes(item).includes(kind))).sort((a, b) => a.sortOrder - b.sortOrder), + ), + ) .catch((error) => setDirectoryError(error instanceof Error ? error.message : "工具目录加载失败")) .finally(() => setDirectoryLoading(false)); }} @@ -776,7 +885,15 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
{kind === "image" ? "图片生成" : "视频生成"} - +
-
- void submit()} - sendOnEnter={false} - style={{ color: "var(--wb-text)" }} - placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"} - maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} - /> -
- - {prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} +
+ void submit()} + sendOnEnter={false} + style={{ color: "var(--wb-text)" }} + placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"} + maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} + /> +
+ + {prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000} + + ⌘ / Ctrl + Enter +
+
+
+ + 参考素材 {references.length} /{" "} + {kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)} - ⌘ / Ctrl + Enter +
+ + + + + + +
-
-
- - 参考素材 {references.length} / {kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)} - -
- - - - - - -
-
- {tool?.inputContract?.slots.length ? ( -
- {tool.inputContract.slots.map((slot) => { - const slotReferences = references.filter((reference) => reference.slotKey === slot.key); - const mediaLabel = slot.mediaTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join(" / "); - const openSlotPicker = () => { setUploadSlotKey(slot.key); fileRef.current?.click(); }; - return ( -
-
-
-
- {slot.name || "参考素材"} - {slot.required ? "必填" : "可选"} -
-
- {slot.minCount}-{slot.maxCount} 个 - {mediaLabel} + {tool?.inputContract?.slots.length ? ( +
+ {tool.inputContract.slots.map((slot) => { + const slotReferences = references.filter((reference) => reference.slotKey === slot.key); + const mediaLabel = slot.mediaTypes.map((type) => (type === "image" ? "图片" : type === "video" ? "视频" : "音频")).join(" / "); + const openSlotPicker = () => { + setUploadSlotKey(slot.key); + fileRef.current?.click(); + }; + return ( +
+
+
+
+ {slot.name || "参考素材"} + {slot.required ? "必填" : "可选"} +
+
+ + {slot.minCount}-{slot.maxCount} 个 + + {mediaLabel} +
+ {slotReferences.length ? ( + + ) : null}
- {slotReferences.length ? ( - - ) : null} -
-
- {slotReferences.map((reference) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)} - {!slotReferences.length ? ( - - ) : null} -
-
- ); - })} -
- ) : ( -
- {references.map((reference) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)} - {!references.length || (kind === "video" && references.length < 5) ? : null} -
- )} - slot.key === uploadSlotKey)?.mediaTypes.map((type) => `${type}/*`).join(",") : kind === "image" ? "image/*" : "image/*,video/*,audio/*"} - multiple - onChange={(event) => { - if (event.target.files) void addFiles(event.target.files, uploadSlotKey); - event.currentTarget.value = ""; - }} - /> -
-
- {kind === "image" ? "图片参数" : "视频参数"} - {kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"} -
- {selectedModel ? settingsPanel :

先选择模型,再调整尺寸、数量或时长。

} -
+
+ {slotReferences.map((reference) => ( + setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} + /> + ))} + {!slotReferences.length ? ( + + ) : null} +
+ + ); + })} +
+ ) : ( +
+ {references.map((reference) => ( + setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} + /> + ))} + {!references.length || (kind === "video" && references.length < 5) ? ( + + ) : null} +
+ )} + slot.key === uploadSlotKey) + ?.mediaTypes.map((type) => `${type}/*`) + .join(",") + : kind === "image" + ? "image/*" + : "image/*,video/*,audio/*" + } + multiple + onChange={(event) => { + if (event.target.files) void addFiles(event.target.files, uploadSlotKey); + event.currentTarget.value = ""; + }} + /> +
+
+ {kind === "image" ? "图片参数" : "视频参数"} + {kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"} +
+ {selectedModel ? settingsPanel :

先选择模型,再调整尺寸、数量或时长。

} +
{models.length ? ( @@ -895,7 +1060,17 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { config={config} value={selectedModel} capability={capability} - requirements={{ capability, input: { textCount: prompt.trim() ? 1 : 0, imageCount: references.filter((reference) => reference.kind === "image").length, videoCount: references.filter((reference) => reference.kind === "video").length, audioCount: references.filter((reference) => reference.kind === "audio").length, characterCount: 0 }, ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }) }} + requirements={{ + capability, + input: { + textCount: prompt.trim() ? 1 : 0, + imageCount: references.filter((reference) => reference.kind === "image").length, + videoCount: references.filter((reference) => reference.kind === "video").length, + audioCount: references.filter((reference) => reference.kind === "audio").length, + characterCount: 0, + }, + ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }), + }} variant="creation" fullWidth className="creation-model-picker workbench-model-picker" @@ -911,7 +1086,9 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { ) : (
暂无可用的{kind === "image" ? "图片" : "视频"}模型 - 模型设置 + + 模型设置 +
)}
@@ -1002,12 +1179,29 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
选择适合的模型和参考素材,写下你希望呈现的画面细节。
)} {tool.inputHint ?

{tool.inputHint}

: null} - {tool.inputContract?.slots.length ?
输入素材{tool.inputContract.slots.map((slot) => {slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个)}
: null} + {tool.inputContract?.slots.length ? ( +
+ 输入素材 + {tool.inputContract.slots.map((slot) => ( + + {slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个 + + ))} +
+ ) : null} {tool.resultExample?.imageUrls?.length || tool.resultExample?.videoUrl ? (
{tool.resultExample.title || "效果参考"} - {tool.resultExample.imageUrls?.slice(0, 3).map((url) => 工具效果参考)} - {tool.resultExample.videoUrl ? : null} + {tool.resultExample.imageUrls?.slice(0, 3).map((url) => ( + + 工具效果参考 + + ))} + {tool.resultExample.videoUrl ? ( + + + ) : null}
) : null} @@ -1109,8 +1303,14 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
- - {detailTask.status === "failed" || detailTask.status === "cancelled" ? : null} + + {detailTask.status === "failed" || detailTask.status === "cancelled" ? ( + + ) : null}
@@ -1159,7 +1359,12 @@ function CountPicker({ value, max, onChange }: { value: string; max: number; onC function ParameterChips({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) { if (options.length > 7) { - return + ); } return (
@@ -1197,11 +1402,36 @@ function ToolCard({ tool, selected, onClick }: { tool: WorkbenchTool; selected: function ReferenceCard({ reference, kind, onRemove, onRoleChange }: { reference: WorkbenchReference; kind: WorkbenchKind; onRemove: (reference: WorkbenchReference) => void; onRoleChange: (role: WorkbenchReference["role"]) => void }) { return (
- {reference.kind === "video" ?
); } diff --git a/web/src/pages/workbench/workbench-catalog.ts b/web/src/pages/workbench/workbench-catalog.ts index a75ea59c8..2c1bf8d59 100644 --- a/web/src/pages/workbench/workbench-catalog.ts +++ b/web/src/pages/workbench/workbench-catalog.ts @@ -19,7 +19,10 @@ export function workbenchCategoryLabel(id: string) { export function workbenchModeOptions(kind: WorkbenchKind) { return kind === "image" - ? [{ value: "image", label: "图片生成" }, { value: "image_edit", label: "图片编辑" }] + ? [ + { value: "image", label: "图片生成" }, + { value: "image_edit", label: "图片编辑" }, + ] : [{ value: "video", label: "视频生成" }]; } diff --git a/web/src/pages/workbench/workbench.css b/web/src/pages/workbench/workbench.css index 43b28a97b..a2a8e0748 100644 --- a/web/src/pages/workbench/workbench.css +++ b/web/src/pages/workbench/workbench.css @@ -430,7 +430,9 @@ font: inherit; font-size: 12px; cursor: pointer; - transition: background-color 140ms ease, border-color 140ms ease; + transition: + background-color 140ms ease, + border-color 140ms ease; } .workbench-slot-upload svg { width: 15px; @@ -467,7 +469,9 @@ color: var(--wb-text); text-align: left; cursor: pointer; - transition: background-color 140ms ease, border-color 140ms ease; + transition: + background-color 140ms ease, + border-color 140ms ease; } .workbench-reference-slot-empty:hover:not(:disabled) { border-color: var(--user-primary, #5b5fc7); diff --git a/web/src/services/api/workbench.ts b/web/src/services/api/workbench.ts index 4d1c6efbc..3745296c0 100644 --- a/web/src/services/api/workbench.ts +++ b/web/src/services/api/workbench.ts @@ -168,7 +168,24 @@ export type SaveAdminWorkbenchToolInput = Pick ({ id: preset.id || undefined, name: preset.name, summary: preset.summary || "", promptTemplate: preset.promptTemplate, enabled: preset.enabled !== false, sortOrder: preset.sortOrder || 0, defaultParameters: preset.defaultParameters || {} })) } : {}) }; + const payload = { + ...tool, + capability, + guide, + ...(presets + ? { + presets: presets.map((preset) => ({ + id: preset.id || undefined, + name: preset.name, + summary: preset.summary || "", + promptTemplate: preset.promptTemplate, + enabled: preset.enabled !== false, + sortOrder: preset.sortOrder || 0, + defaultParameters: preset.defaultParameters || {}, + })), + } + : {}), + }; return id ? http.patch<{ tool: WorkbenchTool }>(`/admin/workbench/tools/${encodeURIComponent(id)}`, payload).then(({ tool: saved }) => saved) : http.post<{ tool: WorkbenchTool }>("/admin/workbench/tools", payload).then(({ tool: saved }) => saved); } diff --git a/web/test/channel-model-editor-form.test.ts b/web/test/channel-model-editor-form.test.ts index c677b6fd7..ab9ba7c80 100644 --- a/web/test/channel-model-editor-form.test.ts +++ b/web/test/channel-model-editor-form.test.ts @@ -1,7 +1,15 @@ import { describe, expect, test } from "bun:test"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import type { ModelProtocolDefinition } from "../src/lib/model-protocols"; -import { changeChannelModelCapability, duplicateChannelModelValues, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form"; +import { + changeChannelModelCapability, + duplicateChannelModelValues, + editorSectionForField, + initialChannelModelValues, + updateChannelModelUpstreamCapabilities, + validateChannelModelPrices, + validateChannelModelProtocol, +} from "../src/pages/admin/components/channel-model-editor-form"; import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form"; import type { ChannelModel, ChannelModelPriceTier } from "../src/services/api/wallet"; @@ -84,7 +92,10 @@ describe("channel model editor drafts", () => { capability: "video", protocol: "video", enabled: false, - priceTiers: [{ providerModelKey: "", unitPrice: 5 }, { providerModelKey: "vendor-video-hq", unitPrice: 8 }], + priceTiers: [ + { providerModelKey: "", unitPrice: 5 }, + { providerModelKey: "vendor-video-hq", unitPrice: 8 }, + ], }); expect(draft.tags).not.toBe(item.tags); }); diff --git a/web/test/model-picker-prices.test.tsx b/web/test/model-picker-prices.test.tsx index f02ff1e27..db6ef7e82 100644 --- a/web/test/model-picker-prices.test.tsx +++ b/web/test/model-picker-prices.test.tsx @@ -59,7 +59,10 @@ test("candidate rows do not disguise missing prices as zero", () => { test("model rows preserve multiple promotional labels and colors without altering prices", () => { const config = fixture(); - config.channels[0].modelCosts![0].tags = [{ text: "限时特价", color: "purple" }, { text: "官方1折", color: "gold" }]; + config.channels[0].modelCosts![0].tags = [ + { text: "限时特价", color: "purple" }, + { text: "官方1折", color: "gold" }, + ]; for (const theme of [canvasThemes.light, canvasThemes.dark]) { const markup = renderToStaticMarkup(); expect(markup).toContain('data-color="purple">限时特价'); @@ -68,7 +71,8 @@ test("model rows preserve multiple promotional labels and colors without alterin } config.channels[0].modelCosts![0].tags = []; const markup = renderToStaticMarkup(); - expect(markup).not.toContain('aria-label="模型标签"'); + expect(markup).not.toContain("限时特价"); + expect(markup).not.toContain("官方1折"); }); test("video capability tag is derived from limits and precedes editable model tags", () => { @@ -76,7 +80,10 @@ test("video capability tag is derived from limits and precedes editable model ta const cost = config.channels[0].modelCosts![0]; cost.capabilityConfig = defaultModelCapabilityConfig(); Object.assign(cost.capabilityConfig.video!.references, { maxImages: 30, maxVideos: 10, maxAudios: 10 }); - cost.tags = [{ text: "限时特价", color: "purple" }, { text: "按次收费", color: "blue" }]; + cost.tags = [ + { text: "限时特价", color: "purple" }, + { text: "按次收费", color: "blue" }, + ]; const markup = renderToStaticMarkup(); diff --git a/web/test/workbench-features.test.ts b/web/test/workbench-features.test.ts index b8e1d0dfe..b3553c581 100644 --- a/web/test/workbench-features.test.ts +++ b/web/test/workbench-features.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -import { defaultFeatureAvailability } from "./use-user-store"; +import { defaultFeatureAvailability } from "../src/stores/use-user-store"; test("new workbench entries stay closed until the backend explicitly opens them", () => { expect(defaultFeatureAvailability.imageWorkbenchEnabled).toBe(false); diff --git a/web/test/workbench-tools.test.ts b/web/test/workbench-tools.test.ts index 137caf5a4..685ae0296 100644 --- a/web/test/workbench-tools.test.ts +++ b/web/test/workbench-tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { filterWorkbenchTools, workbenchTaskSnapshot } from "./image-workbench-page"; +import { filterWorkbenchTools, workbenchTaskSnapshot } from "../src/pages/workbench/image-workbench-page"; import type { GenerationTask } from "@/services/api/task-center"; import type { WorkbenchTool } from "@/services/api/workbench"; diff --git a/web/test/workspace-route-loading.test.ts b/web/test/workspace-route-loading.test.ts index 8d1bfbe18..3ac920676 100644 --- a/web/test/workspace-route-loading.test.ts +++ b/web/test/workspace-route-loading.test.ts @@ -39,7 +39,7 @@ describe("workspace route loading", () => { expect(router).toContain('{ path: "/create", element: deferred() }'); expect(router).toContain(""); expect(router).toContain('{ path: "/assets", element: {deferred()} }'); - expect(router).toContain('{ path: "/canvas/:id", element: }'); + expect(router).toMatch(/path: "\/canvas\/:id",\s+element: \(\s+\s+\s+<\/RequireAuth>\s+\),/); expect(router).not.toContain('path: "/home"'); expect(router).not.toContain("HomePage"); expect(navigation).toContain('{ ...toolItem("create", "/"), id: "home", title: "创作" }'); @@ -77,7 +77,6 @@ describe("workspace route loading", () => { expect(detail).toContain("新建画布"); }); - test("keeps project asset refresh scoped to the latest user and project", () => { const editor = source("../src/pages/projects/detail/editor.tsx"); @@ -108,7 +107,7 @@ describe("workspace route loading", () => { expect(projectDetail).toContain('import("@/services/user-data-sync")'); expect(projectDetail).not.toContain('import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync"'); expect(workflow).not.toContain('from "@/lib/video-poster"'); - expect(workflow).toContain('if (playing) return { @@ -141,9 +140,9 @@ describe("workspace wallet entry", () => { expect(router).not.toContain("WalletPage"); expect(router).not.toContain("loadWalletPage"); expect(modules).not.toContain("pages/wallet"); - expect(host).toContain("pathname !== \"/wallet\""); + expect(host).toContain('pathname !== "/wallet"'); expect(host).toContain("openWorkspaceWallet"); - expect(palette).toContain('run: () => openWorkspaceWallet()'); + expect(palette).toContain("run: () => openWorkspaceWallet()"); expect(palette).not.toContain('"/wallet"'); expect(canvasTopBar).toContain("openWorkspaceWallet()"); expect(canvasTopBar).not.toContain('to="/wallet"');