fix(release): 正式版本 - 修复 SSRF 与 Quality checks,发布 v1.6.6.1

This commit is contained in:
ddcat666
2026-10-13 12:55:30 +08:00
parent ede6cc1e70
commit ea6676f449
21 changed files with 1303 additions and 338 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
v1.6.6
v1.6.6.1

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)/|~/)`)

View File

@@ -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[] = [
{

View File

@@ -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<MediaValue>;
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<string, unknown> {
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<string, unknown> : {};
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
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 <div className="admin-workbench-resource-upload">
<div className="admin-workbench-resource-preview">{value.url ? kind === "video" ? <video src={value.url} controls preload="metadata" /> : <img src={value.url} alt={label} /> : <WandSparkles className="size-5" />}</div>
<div className="admin-workbench-resource-actions">
<strong>{label}</strong><small>{value.id ? value.url ? "已上传" : "已上传,预览暂不可用" : "选择文件上传"}</small>
<div><Upload disabled={disabled} accept={`${kind}/*`} showUploadList={false} beforeUpload={async (file) => {
setUploading(true);
try { onChange(await upload(file, kind)); } catch { /* Upload errors are reported by the shared uploader. */ } finally { setUploading(false); }
return false;
}}><Button size="small" disabled={disabled} loading={uploading} icon={<CloudUpload className="size-3.5" />}>{value.id ? "替换文件" : "上传文件"}</Button></Upload>
{value.id && <Button size="small" disabled={disabled} type="text" danger aria-label={`移除${label}`} icon={<Trash2 className="size-3.5" />} onClick={() => onChange({})}>移除</Button>}</div>
return (
<div className="admin-workbench-resource-upload">
<div className="admin-workbench-resource-preview">{value.url ? kind === "video" ? <video src={value.url} controls preload="metadata" /> : <img src={value.url} alt={label} /> : <WandSparkles className="size-5" />}</div>
<div className="admin-workbench-resource-actions">
<strong>{label}</strong>
<small>{value.id ? (value.url ? "已上传" : "已上传,预览暂不可用") : "选择文件上传"}</small>
<div>
<Upload
disabled={disabled}
accept={`${kind}/*`}
showUploadList={false}
beforeUpload={async (file) => {
setUploading(true);
try {
onChange(await upload(file, kind));
} catch {
/* Upload errors are reported by the shared uploader. */
} finally {
setUploading(false);
}
return false;
}}
>
<Button size="small" disabled={disabled} loading={uploading} icon={<CloudUpload className="size-3.5" />}>
{value.id ? "替换文件" : "上传文件"}
</Button>
</Upload>
{value.id && (
<Button size="small" disabled={disabled} type="text" danger aria-label={`移除${label}`} icon={<Trash2 className="size-3.5" />} onClick={() => onChange({})}>
移除
</Button>
)}
</div>
</div>
</div>
</div>;
);
}
function SlotEditor({ slot, index, kind, disabled, upload, onChange, onRemove }: { slot: WorkbenchReferenceSlot; index: number; kind: WorkbenchKind; disabled: boolean; upload: UploadMedia; onChange: (patch: Partial<WorkbenchReferenceSlot>) => void; onRemove: () => void }) {
return <div className="admin-workbench-slot-row">
<div className="admin-workbench-slot-fields">
<label>输入名称<Input aria-label={`输入 ${index + 1} 名称`} value={slot.name} placeholder="如模特、人脸、商品" onChange={(event) => onChange({ name: event.target.value })} /></label>
<label>媒体类型<Select mode="multiple" aria-label={`输入 ${index + 1} 媒体类型`} value={slot.mediaTypes} options={(kind === "image" ? ["image"] : ["image", "video", "audio"]).map((value) => ({ value, label: value === "image" ? "图片" : value === "video" ? "视频" : "音频" }))} onChange={(mediaTypes) => onChange({ mediaTypes })} /></label>
<label>最少数量<InputNumber min={0} max={slot.maxCount} aria-label={`输入 ${index + 1} 最少数量`} value={slot.minCount} onChange={(value) => onChange({ minCount: value || 0, required: Boolean(value) })} /></label>
<label>最多数量<InputNumber min={Math.max(1, slot.minCount)} max={20} aria-label={`输入 ${index + 1} 最多数量`} value={slot.maxCount} onChange={(value) => onChange({ maxCount: value || 1 })} /></label>
<label>必填<Switch aria-label={`输入 ${index + 1} 必填`} checked={slot.required} onChange={(required) => onChange({ required, minCount: required ? Math.max(1, slot.minCount) : 0 })} /></label>
<Button disabled={disabled} danger type="text" size="small" onClick={onRemove}>移除输入</Button>
function SlotEditor({
slot,
index,
kind,
disabled,
upload,
onChange,
onRemove,
}: {
slot: WorkbenchReferenceSlot;
index: number;
kind: WorkbenchKind;
disabled: boolean;
upload: UploadMedia;
onChange: (patch: Partial<WorkbenchReferenceSlot>) => void;
onRemove: () => void;
}) {
return (
<div className="admin-workbench-slot-row">
<div className="admin-workbench-slot-fields">
<label>
输入名称
<Input aria-label={`输入 ${index + 1} 名称`} value={slot.name} placeholder="如模特、人脸、商品" onChange={(event) => onChange({ name: event.target.value })} />
</label>
<label>
媒体类型
<Select
mode="multiple"
aria-label={`输入 ${index + 1} 媒体类型`}
value={slot.mediaTypes}
options={(kind === "image" ? ["image"] : ["image", "video", "audio"]).map((value) => ({ value, label: value === "image" ? "图片" : value === "video" ? "视频" : "音频" }))}
onChange={(mediaTypes) => onChange({ mediaTypes })}
/>
</label>
<label>
最少数量
<InputNumber min={0} max={slot.maxCount} aria-label={`输入 ${index + 1} 最少数量`} value={slot.minCount} onChange={(value) => onChange({ minCount: value || 0, required: Boolean(value) })} />
</label>
<label>
最多数量
<InputNumber min={Math.max(1, slot.minCount)} max={20} aria-label={`输入 ${index + 1} 最多数量`} value={slot.maxCount} onChange={(value) => onChange({ maxCount: value || 1 })} />
</label>
<label>
必填
<Switch aria-label={`输入 ${index + 1} 必填`} checked={slot.required} onChange={(required) => onChange({ required, minCount: required ? Math.max(1, slot.minCount) : 0 })} />
</label>
<Button disabled={disabled} danger type="text" size="small" onClick={onRemove}>
移除输入
</Button>
</div>
<div className="admin-workbench-resource-grid">
<ResourceUpload label={`${slot.name || "输入"}封面`} value={{ id: slot.coverResourceId, url: slot.coverUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ coverResourceId: value.id, coverUrl: value.url })} />
<ResourceUpload label={`${slot.name || "输入"}示例`} value={{ id: slot.sampleResourceId, url: slot.sampleUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ sampleResourceId: value.id, sampleUrl: value.url })} />
</div>
</div>
<div className="admin-workbench-resource-grid">
<ResourceUpload label={`${slot.name || "输入"}封面`} value={{ id: slot.coverResourceId, url: slot.coverUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ coverResourceId: value.id, coverUrl: value.url })} />
<ResourceUpload label={`${slot.name || "输入"}示例`} value={{ id: slot.sampleResourceId, url: slot.sampleUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ sampleResourceId: value.id, sampleUrl: value.url })} />
</div>
</div>;
);
}
export default function WorkbenchToolsPage() {
@@ -117,11 +224,18 @@ export default function WorkbenchToolsPage() {
const reload = useCallback(async () => {
setLoading(true);
try { const result = await listWorkbenchTools(kind, undefined, true); setTools(result.tools.sort((a, b) => a.sortOrder - b.sortOrder)); }
catch (error) { message.error(error instanceof Error ? error.message : "工作台工具加载失败"); }
finally { setLoading(false); }
try {
const result = await listWorkbenchTools(kind, undefined, true);
setTools(result.tools.sort((a, b) => a.sortOrder - b.sortOrder));
} catch (error) {
message.error(error instanceof Error ? error.message : "工作台工具加载失败");
} finally {
setLoading(false);
}
}, [kind, message]);
useEffect(() => { void reload(); }, [reload]);
useEffect(() => {
void reload();
}, [reload]);
const visibleTools = useMemo(() => tools.filter((item) => !query.trim() || `${item.name} ${item.subtitle} ${item.description}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), [query, tools]);
const openEditor = async (tool?: WorkbenchTool) => {
@@ -129,7 +243,7 @@ export default function WorkbenchToolsPage() {
if (tool) {
[next.iconUrl, next.coverUrl] = await Promise.all([resolveWorkbenchResourceURL(next.iconResourceId), resolveWorkbenchResourceURL(next.coverResourceId)]);
next.inputContract.slots = await Promise.all(next.inputContract.slots.map(async (slot) => ({ ...slot, coverUrl: await resolveWorkbenchResourceURL(slot.coverResourceId), sampleUrl: await resolveWorkbenchResourceURL(slot.sampleResourceId) })));
next.resultExample.imageUrls = await Promise.all((next.resultExample.imageResourceIds || []).map(async (id) => await resolveWorkbenchResourceURL(id) || ""));
next.resultExample.imageUrls = await Promise.all((next.resultExample.imageResourceIds || []).map(async (id) => (await resolveWorkbenchResourceURL(id)) || ""));
next.resultExample.videoUrl = await resolveWorkbenchResourceURL(next.resultExample.videoResourceId);
}
setDraft(next);
@@ -152,93 +266,381 @@ export default function WorkbenchToolsPage() {
} catch (error) {
message.error(error instanceof Error ? error.message : "文件上传失败");
throw error;
} finally { setUploadCount((count) => count - 1); }
} finally {
setUploadCount((count) => count - 1);
}
};
const save = async () => {
if (editorBusy) return;
let values: ToolDraft & { resultTitle?: string; resultDescription?: string };
try { values = await form.validateFields(); } catch { return; }
try {
values = await form.validateFields();
} catch {
return;
}
const slots = draft.inputContract.slots;
if (slots.some((slot) => !slot.name.trim() || !slot.mediaTypes.length || slot.minCount > slot.maxCount)) { message.warning("请补齐输入名称、媒体类型和有效数量范围"); return; }
if (draft.presets.some((preset) => !preset.name.trim() || !preset.promptTemplate.trim())) { message.warning("请补齐预设名称和提示词"); return; }
if (slots.some((slot) => !slot.name.trim() || !slot.mediaTypes.length || slot.minCount > slot.maxCount)) {
message.warning("请补齐输入名称、媒体类型和有效数量范围");
return;
}
if (draft.presets.some((preset) => !preset.name.trim() || !preset.promptTemplate.trim())) {
message.warning("请补齐预设名称和提示词");
return;
}
setSaving(true);
try {
const saved = await saveAdminWorkbenchTool({
id: draft.id, kind: draft.kind, categoryId: values.categoryId, name: values.name.trim(), subtitle: values.subtitle?.trim() || "", description: values.description.trim(), iconResourceId: draft.iconResourceId || "", coverResourceId: draft.coverResourceId || "", sortOrder: values.sortOrder || 0, enabled: values.enabled,
capability: { ...draft.capability, supportedModes: values.supportedModes, searchKeywords: values.searchKeywords, surfaces: values.surfaces, recommended: values.recommended, recommendationOrder: values.recommendationOrder || 0,
inputContract: { ...draft.inputContract, slots: slots.map(({ coverUrl: _coverURL, sampleUrl: _sampleURL, ...slot }) => slot) } },
guide: { ...draft.guideConfig, lines: values.guideText.trim().split("\n").filter(Boolean), inputHint: values.inputHint.trim(), resultExample: { title: values.resultTitle?.trim() || "", description: values.resultDescription?.trim() || "", imageResourceIds: draft.resultExample.imageResourceIds || [], videoResourceId: draft.resultExample.videoResourceId || "" } },
id: draft.id,
kind: draft.kind,
categoryId: values.categoryId,
name: values.name.trim(),
subtitle: values.subtitle?.trim() || "",
description: values.description.trim(),
iconResourceId: draft.iconResourceId || "",
coverResourceId: draft.coverResourceId || "",
sortOrder: values.sortOrder || 0,
enabled: values.enabled,
capability: {
...draft.capability,
supportedModes: values.supportedModes,
searchKeywords: values.searchKeywords,
surfaces: values.surfaces,
recommended: values.recommended,
recommendationOrder: values.recommendationOrder || 0,
inputContract: { ...draft.inputContract, slots: slots.map(({ coverUrl: _coverURL, sampleUrl: _sampleURL, ...slot }) => slot) },
},
guide: {
...draft.guideConfig,
lines: values.guideText.trim().split("\n").filter(Boolean),
inputHint: values.inputHint.trim(),
resultExample: { title: values.resultTitle?.trim() || "", description: values.resultDescription?.trim() || "", imageResourceIds: draft.resultExample.imageResourceIds || [], videoResourceId: draft.resultExample.videoResourceId || "" },
},
presets: draft.presets.map((preset, index) => ({ ...preset, id: draft.savedPresetIds.includes(preset.id) ? preset.id : "", sortOrder: index })),
});
setEditorOpen(false);
await reload();
message.success(saved.enabled ? "工具已保存并开放" : "工具已保存为未开放");
} catch (error) { message.error(error instanceof Error ? error.message : "工具保存失败"); }
finally { setSaving(false); }
} catch (error) {
message.error(error instanceof Error ? error.message : "工具保存失败");
} finally {
setSaving(false);
}
};
const toggle = async (tool: WorkbenchTool, enabled: boolean) => {
try { await setAdminWorkbenchToolEnabled(tool.id, enabled); await reload(); message.success(enabled ? "工具已开放" : "工具已下线"); }
catch (error) { message.error(error instanceof Error ? error.message : "工具状态更新失败"); }
try {
await setAdminWorkbenchToolEnabled(tool.id, enabled);
await reload();
message.success(enabled ? "工具已开放" : "工具已下线");
} catch (error) {
message.error(error instanceof Error ? error.message : "工具状态更新失败");
}
};
const updateSlot = (key: string, patch: Partial<WorkbenchReferenceSlot>) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.map((slot) => slot.key === key ? { ...slot, ...patch } : slot) } }));
const updatePreset = (id: string, patch: Partial<WorkbenchPromptPreset>) => setDraft((current) => ({ ...current, presets: current.presets.map((preset) => preset.id === id ? { ...preset, ...patch } : preset) }));
const updateSlot = (key: string, patch: Partial<WorkbenchReferenceSlot>) =>
setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.map((slot) => (slot.key === key ? { ...slot, ...patch } : slot)) } }));
const updatePreset = (id: string, patch: Partial<WorkbenchPromptPreset>) => setDraft((current) => ({ ...current, presets: current.presets.map((preset) => (preset.id === id ? { ...preset, ...patch } : preset)) }));
const columns: ColumnsType<WorkbenchTool> = [
{ title: "工具", dataIndex: "name", render: (_, tool) => <div className="admin-workbench-tool-main"><span className="admin-workbench-tool-icon">{tool.iconUrl ? <img src={tool.iconUrl} alt="" /> : <WandSparkles className="size-4" />}</span><span><strong>{tool.name}</strong><small>{tool.subtitle || tool.description}</small></span></div> },
{
title: "工具",
dataIndex: "name",
render: (_, tool) => (
<div className="admin-workbench-tool-main">
<span className="admin-workbench-tool-icon">{tool.iconUrl ? <img src={tool.iconUrl} alt="" /> : <WandSparkles className="size-4" />}</span>
<span>
<strong>{tool.name}</strong>
<small>{tool.subtitle || tool.description}</small>
</span>
</div>
),
},
{ title: "分类", dataIndex: "categoryId", width: 140, align: "center", render: workbenchCategoryLabel },
{ title: "预设", dataIndex: "presets", width: 90, align: "center", render: (value: WorkbenchPromptPreset[]) => `${value.filter((item) => item.enabled).length} 项启用` },
{ title: "排序", dataIndex: "sortOrder", width: 80, align: "center" },
{ title: "状态", dataIndex: "enabled", width: 120, align: "center", render: (enabled: boolean, tool) => <div className="admin-workbench-tool-status"><AdminStatusBadge label={enabled ? "已开放" : "未开放"} tone={enabled ? "success" : "neutral"} /><Switch checked={enabled} aria-label={`${enabled ? "下线" : "开放"}${tool.name}`} onChange={(checked) => void toggle(tool, checked)} /></div> },
{ title: "操作", width: 120, align: "center", render: (_, tool) => <Button size="small" icon={<Edit3 className="size-3.5" />} onClick={() => void openEditor(tool)}>编辑</Button> },
{
title: "状态",
dataIndex: "enabled",
width: 120,
align: "center",
render: (enabled: boolean, tool) => (
<div className="admin-workbench-tool-status">
<AdminStatusBadge label={enabled ? "已开放" : "未开放"} tone={enabled ? "success" : "neutral"} />
<Switch checked={enabled} aria-label={`${enabled ? "下线" : "开放"}${tool.name}`} onChange={(checked) => void toggle(tool, checked)} />
</div>
),
},
{
title: "操作",
width: 120,
align: "center",
render: (_, tool) => (
<Button size="small" icon={<Edit3 className="size-3.5" />} onClick={() => void openEditor(tool)}>
编辑
</Button>
),
},
];
return <AdminPageFrame title="工作台工具" description="管理生图与视频的工具、场景输入和示例素材" scroll actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>新建工具</Button>}>
<div className="admin-workbench-tools">
<div className="admin-workbench-tools-toolbar"><Select aria-label="工作台类型" value={kind} onChange={setKind} options={[{ label: "图片生成", value: "image" }, { label: "视频生成", value: "video" }]} /><Input aria-label="搜索工作台工具" prefix={<Search className="size-4" />} placeholder="搜索名称或说明" value={query} onChange={(event) => setQuery(event.target.value)} allowClear /><Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>刷新</Button></div>
<AdminDataTable table={{ className: "app-data-table", rowKey: "id", loading, pagination: false, columns, dataSource: visibleTools, scroll: { x: 900 } }} empty={<div className="admin-workbench-tools-empty">当前类型还没有工具,使用“新建工具”创建首个目录项。</div>} />
</div>
<Modal title={draft.id ? "编辑工作台工具" : "新建工作台工具"} open={editorOpen} onCancel={() => !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">
<Form form={form} layout="vertical" disabled={editorBusy} className="admin-workbench-tool-form">
<div className="admin-workbench-tool-grid">
<Form.Item label="工作台类型"><Select value={draft.kind} disabled={Boolean(draft.id) || editorBusy} options={[{ label: "图片生成", value: "image" }, { label: "视频生成", value: "video" }]} onChange={(value: WorkbenchKind) => {
setDraft((current) => ({ ...current, kind: value, inputContract: { ...current.inputContract, slots: value === "image" ? current.inputContract.slots.map((slot) => ({ ...slot, mediaTypes: ["image"] })) : current.inputContract.slots } }));
form.setFieldValue("supportedModes", [value]);
}} /></Form.Item>
<Form.Item name="categoryId" label="业务分类" rules={[{ required: true }]}><Select options={workbenchCategories} /></Form.Item>
<Form.Item name="name" label="工具名称" rules={[{ required: true, whitespace: true, max: 80 }]}><Input maxLength={80} /></Form.Item>
<Form.Item name="subtitle" label="副标题"><Input maxLength={160} /></Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={1000000} /></Form.Item>
<Form.Item name="enabled" label="用户端开放" valuePropName="checked"><Switch /></Form.Item>
return (
<AdminPageFrame
title="工作台工具"
description="管理生图与视频的工具、场景输入和示例素材"
scroll
actions={
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>
新建工具
</Button>
}
>
<div className="admin-workbench-tools">
<div className="admin-workbench-tools-toolbar">
<Select
aria-label="工作台类型"
value={kind}
onChange={setKind}
options={[
{ label: "图片生成", value: "image" },
{ label: "视频生成", value: "video" },
]}
/>
<Input aria-label="搜索工作台工具" prefix={<Search className="size-4" />} placeholder="搜索名称或说明" value={query} onChange={(event) => setQuery(event.target.value)} allowClear />
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
刷新
</Button>
</div>
<Form.Item name="description" label="简短说明" rules={[{ required: true, whitespace: true, max: 500 }]}><Input.TextArea rows={2} maxLength={500} /></Form.Item>
<div className="admin-workbench-resource-grid">
<ResourceUpload label="工具图标" value={{ id: draft.iconResourceId, url: draft.iconUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))} />
<ResourceUpload label="工具封面" value={{ id: draft.coverResourceId, url: draft.coverUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))} />
</div>
<div className="admin-workbench-section-title"><strong>用户需要提供什么</strong><small>配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。</small></div>
<div className="admin-workbench-contract-toolbar"><label>提示词必填 <Switch checked={draft.inputContract.promptRequired || draft.inputContract.minTextReferences > 0} onChange={(promptRequired) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, promptRequired, minTextReferences: 0 } }))} /></label><Button size="small" disabled={draft.inputContract.slots.length >= 20} icon={<Plus className="size-3.5" />} onClick={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: [...current.inputContract.slots, { key: crypto.randomUUID(), name: "", mediaTypes: ["image"], resourceKind: "asset", minCount: 0, maxCount: 1, required: false }] } }))}>添加输入</Button></div>
<div className="admin-workbench-slots">{draft.inputContract.slots.map((slot, index) => <SlotEditor key={slot.key} slot={slot} index={index} kind={draft.kind} disabled={editorBusy} upload={upload} onChange={(patch) => updateSlot(slot.key, patch)} onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))} />)}</div>
<div className="admin-workbench-section-title"><strong>使用说明与展示</strong></div>
<div className="admin-workbench-tool-grid">
<Form.Item name="inputHint" label="输入提示"><Input.TextArea rows={2} maxLength={500} /></Form.Item><Form.Item name="guideText" label="使用说明"><Input.TextArea rows={2} maxLength={5000} /></Form.Item>
<Form.Item name="supportedModes" label="创作方式" rules={[{ required: true }]}><Select mode="multiple" options={workbenchModeOptions(draft.kind)} /></Form.Item>
<Form.Item name="searchKeywords" label="搜索关键词"><Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入关键词后按回车" /></Form.Item>
<Form.Item name="surfaces" label="展示入口"><Select mode="multiple" options={[{ value: "workbench", label: "创作工作台" }]} /></Form.Item>
<Form.Item name="recommendationOrder" label="推荐排序"><InputNumber min={0} max={1000000} /></Form.Item>
<Form.Item name="recommended" label="推荐工具" valuePropName="checked"><Switch /></Form.Item>
</div>
<div className="admin-workbench-section-title"><strong>结果示例</strong><small>上传实际效果图片或视频,帮助用户了解工具用途。</small></div>
<div className="admin-workbench-tool-grid"><Form.Item name="resultTitle" label="示例标题"><Input maxLength={120} /></Form.Item><Form.Item name="resultDescription" label="示例说明"><Input.TextArea rows={2} maxLength={500} /></Form.Item></div>
<div className="admin-workbench-example-previews">{draft.resultExample.imageResourceIds?.map((id, index) => <ResourceUpload key={id} label={`图片示例 ${index + 1}`} value={{ id, url: draft.resultExample.imageUrls?.[index] }} disabled={editorBusy} upload={upload} onChange={(value) => 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] || ""]) } }))} />)}</div>
<div className="admin-workbench-resource-grid">
{(draft.resultExample.imageResourceIds?.length || 0) < 8 && <ResourceUpload label="添加图片示例" value={{}} disabled={editorBusy} upload={upload} onChange={(value) => value.id && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] } }))} />}
{draft.kind === "video" && <ResourceUpload label="视频示例" kind="video" value={{ id: draft.resultExample.videoResourceId, url: draft.resultExample.videoUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))} />}
</div>
<section className="admin-workbench-presets"><header><div><strong>预设提示词</strong><small>与工具一起保存;已保存的预设可停用,历史记录保留。</small></div><Button size="small" disabled={draft.presets.length >= 50} icon={<Plus className="size-3.5" />} onClick={() => setDraft((current) => ({ ...current, presets: [...current.presets, { id: crypto.randomUUID(), name: "", promptTemplate: "", summary: "", enabled: true }] }))}>添加预设</Button></header>
{draft.presets.map((preset, index) => <div className="admin-workbench-preset-row" key={preset.id}><Input aria-label={`预设 ${index + 1} 名称`} placeholder="预设名称" value={preset.name} onChange={(event) => updatePreset(preset.id, { name: event.target.value })} /><Input aria-label={`预设 ${index + 1} 简介`} placeholder="一句话说明" value={preset.summary} onChange={(event) => updatePreset(preset.id, { summary: event.target.value })} /><Input.TextArea aria-label={`预设 ${index + 1} 提示词`} rows={3} placeholder="提示词模板" value={preset.promptTemplate} onChange={(event) => updatePreset(preset.id, { promptTemplate: event.target.value })} /><Switch aria-label={`启用预设 ${index + 1}`} checked={preset.enabled} onChange={(enabled) => updatePreset(preset.id, { enabled })} />{!draft.savedPresetIds.includes(preset.id) && <Button danger size="small" onClick={() => setDraft((current) => ({ ...current, presets: current.presets.filter((item) => item.id !== preset.id) }))}>移除</Button>}</div>)}
</section>
</Form>
</Modal>
</AdminPageFrame>;
<AdminDataTable
table={{ className: "app-data-table", rowKey: "id", loading, pagination: false, columns, dataSource: visibleTools, scroll: { x: 900 } }}
empty={<div className="admin-workbench-tools-empty">当前类型还没有工具,使用“新建工具”创建首个目录项。</div>}
/>
</div>
<Modal
title={draft.id ? "编辑工作台工具" : "新建工作台工具"}
open={editorOpen}
onCancel={() => !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"
>
<Form form={form} layout="vertical" disabled={editorBusy} className="admin-workbench-tool-form">
<div className="admin-workbench-tool-grid">
<Form.Item label="工作台类型">
<Select
value={draft.kind}
disabled={Boolean(draft.id) || editorBusy}
options={[
{ label: "图片生成", value: "image" },
{ label: "视频生成", value: "video" },
]}
onChange={(value: WorkbenchKind) => {
setDraft((current) => ({
...current,
kind: value,
inputContract: { ...current.inputContract, slots: value === "image" ? current.inputContract.slots.map((slot) => ({ ...slot, mediaTypes: ["image"] })) : current.inputContract.slots },
}));
form.setFieldValue("supportedModes", [value]);
}}
/>
</Form.Item>
<Form.Item name="categoryId" label="业务分类" rules={[{ required: true }]}>
<Select options={workbenchCategories} />
</Form.Item>
<Form.Item name="name" label="工具名称" rules={[{ required: true, whitespace: true, max: 80 }]}>
<Input maxLength={80} />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input maxLength={160} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} max={1000000} />
</Form.Item>
<Form.Item name="enabled" label="用户端开放" valuePropName="checked">
<Switch />
</Form.Item>
</div>
<Form.Item name="description" label="简短说明" rules={[{ required: true, whitespace: true, max: 500 }]}>
<Input.TextArea rows={2} maxLength={500} />
</Form.Item>
<div className="admin-workbench-resource-grid">
<ResourceUpload
label="工具图标"
value={{ id: draft.iconResourceId, url: draft.iconUrl }}
disabled={editorBusy}
upload={upload}
onChange={(value) => setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))}
/>
<ResourceUpload
label="工具封面"
value={{ id: draft.coverResourceId, url: draft.coverUrl }}
disabled={editorBusy}
upload={upload}
onChange={(value) => setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))}
/>
</div>
<div className="admin-workbench-section-title">
<strong>用户需要提供什么</strong>
<small>配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。</small>
</div>
<div className="admin-workbench-contract-toolbar">
<label>
提示词必填{" "}
<Switch
checked={draft.inputContract.promptRequired || draft.inputContract.minTextReferences > 0}
onChange={(promptRequired) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, promptRequired, minTextReferences: 0 } }))}
/>
</label>
<Button
size="small"
disabled={draft.inputContract.slots.length >= 20}
icon={<Plus className="size-3.5" />}
onClick={() =>
setDraft((current) => ({
...current,
inputContract: { ...current.inputContract, slots: [...current.inputContract.slots, { key: crypto.randomUUID(), name: "", mediaTypes: ["image"], resourceKind: "asset", minCount: 0, maxCount: 1, required: false }] },
}))
}
>
添加输入
</Button>
</div>
<div className="admin-workbench-slots">
{draft.inputContract.slots.map((slot, index) => (
<SlotEditor
key={slot.key}
slot={slot}
index={index}
kind={draft.kind}
disabled={editorBusy}
upload={upload}
onChange={(patch) => updateSlot(slot.key, patch)}
onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))}
/>
))}
</div>
<div className="admin-workbench-section-title">
<strong>使用说明与展示</strong>
</div>
<div className="admin-workbench-tool-grid">
<Form.Item name="inputHint" label="输入提示">
<Input.TextArea rows={2} maxLength={500} />
</Form.Item>
<Form.Item name="guideText" label="使用说明">
<Input.TextArea rows={2} maxLength={5000} />
</Form.Item>
<Form.Item name="supportedModes" label="创作方式" rules={[{ required: true }]}>
<Select mode="multiple" options={workbenchModeOptions(draft.kind)} />
</Form.Item>
<Form.Item name="searchKeywords" label="搜索关键词">
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入关键词后按回车" />
</Form.Item>
<Form.Item name="surfaces" label="展示入口">
<Select mode="multiple" options={[{ value: "workbench", label: "创作工作台" }]} />
</Form.Item>
<Form.Item name="recommendationOrder" label="推荐排序">
<InputNumber min={0} max={1000000} />
</Form.Item>
<Form.Item name="recommended" label="推荐工具" valuePropName="checked">
<Switch />
</Form.Item>
</div>
<div className="admin-workbench-section-title">
<strong>结果示例</strong>
<small>上传实际效果图片或视频,帮助用户了解工具用途。</small>
</div>
<div className="admin-workbench-tool-grid">
<Form.Item name="resultTitle" label="示例标题">
<Input maxLength={120} />
</Form.Item>
<Form.Item name="resultDescription" label="示例说明">
<Input.TextArea rows={2} maxLength={500} />
</Form.Item>
</div>
<div className="admin-workbench-example-previews">
{draft.resultExample.imageResourceIds?.map((id, index) => (
<ResourceUpload
key={id}
label={`图片示例 ${index + 1}`}
value={{ id, url: draft.resultExample.imageUrls?.[index] }}
disabled={editorBusy}
upload={upload}
onChange={(value) =>
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] || ""])),
},
}))
}
/>
))}
</div>
<div className="admin-workbench-resource-grid">
{(draft.resultExample.imageResourceIds?.length || 0) < 8 && (
<ResourceUpload
label="添加图片示例"
value={{}}
disabled={editorBusy}
upload={upload}
onChange={(value) =>
value.id &&
setDraft((current) => ({
...current,
resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] },
}))
}
/>
)}
{draft.kind === "video" && (
<ResourceUpload
label="视频示例"
kind="video"
value={{ id: draft.resultExample.videoResourceId, url: draft.resultExample.videoUrl }}
disabled={editorBusy}
upload={upload}
onChange={(value) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))}
/>
)}
</div>
<section className="admin-workbench-presets">
<header>
<div>
<strong>预设提示词</strong>
<small>与工具一起保存;已保存的预设可停用,历史记录保留。</small>
</div>
<Button
size="small"
disabled={draft.presets.length >= 50}
icon={<Plus className="size-3.5" />}
onClick={() => setDraft((current) => ({ ...current, presets: [...current.presets, { id: crypto.randomUUID(), name: "", promptTemplate: "", summary: "", enabled: true }] }))}
>
添加预设
</Button>
</header>
{draft.presets.map((preset, index) => (
<div className="admin-workbench-preset-row" key={preset.id}>
<Input aria-label={`预设 ${index + 1} 名称`} placeholder="预设名称" value={preset.name} onChange={(event) => updatePreset(preset.id, { name: event.target.value })} />
<Input aria-label={`预设 ${index + 1} 简介`} placeholder="一句话说明" value={preset.summary} onChange={(event) => updatePreset(preset.id, { summary: event.target.value })} />
<Input.TextArea aria-label={`预设 ${index + 1} 提示词`} rows={3} placeholder="提示词模板" value={preset.promptTemplate} onChange={(event) => updatePreset(preset.id, { promptTemplate: event.target.value })} />
<Switch aria-label={`启用预设 ${index + 1}`} checked={preset.enabled} onChange={(enabled) => updatePreset(preset.id, { enabled })} />
{!draft.savedPresetIds.includes(preset.id) && (
<Button danger size="small" onClick={() => setDraft((current) => ({ ...current, presets: current.presets.filter((item) => item.id !== preset.id) }))}>
移除
</Button>
)}
</div>
))}
</section>
</Form>
</Modal>
</AdminPageFrame>
);
}

View File

@@ -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}}
.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;
}
}

View File

@@ -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<string, any> : {};
const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? metadata.workbench as Record<string, any> : {};
const snapshot = input.workbench && typeof input.workbench === "object" ? input.workbench as Record<string, any> : metadataSnapshot;
const metadata = input.metadata && typeof input.metadata === "object" ? (input.metadata as Record<string, any>) : {};
const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? (metadata.workbench as Record<string, any>) : {};
const snapshot = input.workbench && typeof input.workbench === "object" ? (input.workbench as Record<string, any>) : 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<CanvasResourceReference[]>(() => 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<CanvasResourceReference[]>(
() =>
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<WorkbenchReference> => ({
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<WorkbenchReference> => ({
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 ? (
<div className="workbench-settings-content">
<ImageSizePicker profile={imageProfile} size={ratio} quality={quality} onChange={updateImageSettings} />
{imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (
<label className="workbench-setting-row">
<span>图片质量</span>
<Select value={quality} onChange={(value) => { setQuality(value); rememberImageSettings({ quality: value }); }} options={imageProfile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }))} />
</label>
) : null}
<CountPicker value={count} max={imageProfile.maxOutputs} onChange={(value) => { setCount(value); rememberImageSettings({ count: value }); }} />
</div>
) : videoProfile ? (
<div className="workbench-settings-content">
<ParameterChips label="视频时长" value={seconds} options={(videoProfile.duration.selection === "enum" ? videoProfile.duration.values || [] : Array.from({ length: Math.floor(((videoProfile.duration.max || 15) - (videoProfile.duration.min || 1)) / (videoProfile.duration.step || 1)) + 1 }, (_, index) => (videoProfile.duration.min || 1) + index * (videoProfile.duration.step || 1))).map((value) => ({ value: String(value), label: `${value} 秒` }))} onChange={(value) => { setSeconds(value); rememberVideoSettings({ seconds: value }); }} />
<ParameterChips label="画面比例" value={ratio} options={videoProfile.ratios.map((value) => ({ value, label: value === "adaptive" ? "自适应" : value }))} onChange={(value) => { setRatio(value); rememberVideoSettings({ ratio: value }); }} />
<ParameterChips label="清晰度" value={videoQuality} options={videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value }))} onChange={(value) => { setVideoQuality(value); rememberVideoSettings({ videoQuality: value }); }} />
{videoProfile.generateAudio.supported || videoProfile.watermark.supported ? (
<div className="workbench-switch-row">
{videoProfile.generateAudio.supported ? <label><span>生成音频</span><Switch checked={config.videoGenerateAudio === "true"} onChange={(value) => updateConfig("videoGenerateAudio", String(value))} /></label> : null}
{videoProfile.watermark.supported ? <label><span>添加水印</span><Switch checked={config.videoWatermark === "true"} onChange={(value) => updateConfig("videoWatermark", String(value))} /></label> : null}
</div>
) : null}
</div>
) : <p className="workbench-list-empty">选择模型后,这里会显示该模型真正支持的参数。</p>;
const settingsPanel =
kind === "image" && imageProfile ? (
<div className="workbench-settings-content">
<ImageSizePicker profile={imageProfile} size={ratio} quality={quality} onChange={updateImageSettings} />
{imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (
<label className="workbench-setting-row">
<span>图片质量</span>
<Select
value={quality}
onChange={(value) => {
setQuality(value);
rememberImageSettings({ quality: value });
}}
options={imageProfile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }))}
/>
</label>
) : null}
<CountPicker
value={count}
max={imageProfile.maxOutputs}
onChange={(value) => {
setCount(value);
rememberImageSettings({ count: value });
}}
/>
</div>
) : videoProfile ? (
<div className="workbench-settings-content">
<ParameterChips
label="视频时长"
value={seconds}
options={(videoProfile.duration.selection === "enum"
? videoProfile.duration.values || []
: Array.from(
{ length: Math.floor(((videoProfile.duration.max || 15) - (videoProfile.duration.min || 1)) / (videoProfile.duration.step || 1)) + 1 },
(_, index) => (videoProfile.duration.min || 1) + index * (videoProfile.duration.step || 1),
)
).map((value) => ({ value: String(value), label: `${value} 秒` }))}
onChange={(value) => {
setSeconds(value);
rememberVideoSettings({ seconds: value });
}}
/>
<ParameterChips
label="画面比例"
value={ratio}
options={videoProfile.ratios.map((value) => ({ value, label: value === "adaptive" ? "自适应" : value }))}
onChange={(value) => {
setRatio(value);
rememberVideoSettings({ ratio: value });
}}
/>
<ParameterChips
label="清晰度"
value={videoQuality}
options={videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value }))}
onChange={(value) => {
setVideoQuality(value);
rememberVideoSettings({ videoQuality: value });
}}
/>
{videoProfile.generateAudio.supported || videoProfile.watermark.supported ? (
<div className="workbench-switch-row">
{videoProfile.generateAudio.supported ? (
<label>
<span>生成音频</span>
<Switch checked={config.videoGenerateAudio === "true"} onChange={(value) => updateConfig("videoGenerateAudio", String(value))} />
</label>
) : null}
{videoProfile.watermark.supported ? (
<label>
<span>添加水印</span>
<Switch checked={config.videoWatermark === "true"} onChange={(value) => updateConfig("videoWatermark", String(value))} />
</label>
) : null}
</div>
) : null}
</div>
) : (
<p className="workbench-list-empty">选择模型后,这里会显示该模型真正支持的参数。</p>
);
const toolLibrary = (
<div className="workbench-tool-library" id="workbench-tool-library">
<div className="workbench-tool-library-heading">
@@ -722,7 +823,11 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</div>
<Input aria-label="搜索创作工具" prefix={<Search className="size-4" />} value={toolSearch} onChange={(event) => setToolSearch(event.target.value)} placeholder="搜索工具" allowClear />
<div className="workbench-tool-categories" role="tablist" aria-label="工具分类">
{[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => <button type="button" key={category.value} role="tab" aria-selected={toolCategory === category.value} className={toolCategory === category.value ? "is-active" : ""} onClick={() => setToolCategory(category.value)}>{category.label}</button>)}
{[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => (
<button type="button" key={category.value} role="tab" aria-selected={toolCategory === category.value} className={toolCategory === category.value ? "is-active" : ""} onClick={() => setToolCategory(category.value)}>
{category.label}
</button>
))}
</div>
{directoryLoading ? (
<div className="workbench-picker-state">
@@ -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 }) {
<div className="workbench-composer">
<div className="workbench-tool-bar">
<span className="workbench-kind-label">{kind === "image" ? "图片生成" : "视频生成"}</span>
<Popover open={toolLibraryOpen} onOpenChange={setToolLibraryOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "workbench-tool-popover", container: "workbench-tool-popover-surface", content: "workbench-tool-popover-content" }} content={toolLibrary}>
<Popover
open={toolLibraryOpen}
onOpenChange={setToolLibraryOpen}
trigger="click"
placement="bottomLeft"
arrow={false}
classNames={{ root: "workbench-tool-popover", container: "workbench-tool-popover-surface", content: "workbench-tool-popover-content" }}
content={toolLibrary}
>
<button type="button" className="workbench-tool-choice" aria-expanded={toolLibraryOpen} aria-haspopup="dialog" aria-controls="workbench-tool-library">
<span className="workbench-tool-choice-mark">{tool?.iconUrl ? <img src={tool.iconUrl} alt="" /> : kind === "image" ? <FileImage /> : <Video />}</span>
<span className="workbench-tool-choice-copy">
@@ -788,106 +905,154 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</Popover>
</div>
<div className="workbench-studio">
<div className="workbench-prompt-surface">
<CanvasResourceMentionTextarea
aria-label="生成提示词,可输入 @ 引用参考素材"
className="workbench-prompt-input workbench-prompt-rich-editor"
containerClassName="workbench-prompt-mention-container"
value={prompt}
references={promptReferences}
onChange={setPrompt}
onSubmit={() => void submit()}
sendOnEnter={false}
style={{ color: "var(--wb-text)" }}
placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"}
maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
/>
<div className="workbench-prompt-meta">
<span>
{prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
<div className="workbench-prompt-surface">
<CanvasResourceMentionTextarea
aria-label="生成提示词,可输入 @ 引用参考素材"
className="workbench-prompt-input workbench-prompt-rich-editor"
containerClassName="workbench-prompt-mention-container"
value={prompt}
references={promptReferences}
onChange={setPrompt}
onSubmit={() => void submit()}
sendOnEnter={false}
style={{ color: "var(--wb-text)" }}
placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"}
maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
/>
<div className="workbench-prompt-meta">
<span>
{prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
</span>
<span>⌘ / Ctrl + Enter</span>
</div>
</div>
<div className="workbench-composer-toolbar">
<span className="workbench-ref-count">
参考素材 {references.length} /{" "}
{kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)}
</span>
<span>⌘ / Ctrl + Enter</span>
<div className="workbench-toolbar-actions">
<Tooltip title="添加参考素材">
<button type="button" aria-label="添加参考素材" onClick={() => fileRef.current?.click()}>
<Upload />
</button>
</Tooltip>
<Tooltip title="清空参考素材">
<button type="button" aria-label="清空参考素材" onClick={() => references.forEach(removeReference)} disabled={!references.length}>
<X />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="workbench-composer-toolbar">
<span className="workbench-ref-count">
参考素材 {references.length} / {kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)}
</span>
<div className="workbench-toolbar-actions">
<Tooltip title="添加参考素材">
<button type="button" aria-label="添加参考素材" onClick={() => fileRef.current?.click()}>
<Upload />
</button>
</Tooltip>
<Tooltip title="清空参考素材">
<button type="button" aria-label="清空参考素材" onClick={() => references.forEach(removeReference)} disabled={!references.length}>
<X />
</button>
</Tooltip>
</div>
</div>
{tool?.inputContract?.slots.length ? (
<div className="workbench-reference-slots">
{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 (
<section className="workbench-reference-slot" key={slot.key}>
<div className="workbench-reference-slot-heading">
<div className="workbench-reference-slot-copy">
<div className="workbench-reference-slot-title">
<strong>{slot.name || "参考素材"}</strong>
<span className={`workbench-reference-slot-requirement${slot.required ? " is-required" : ""}`}>{slot.required ? "必填" : "可选"}</span>
</div>
<div className="workbench-reference-slot-meta">
<span>{slot.minCount}-{slot.maxCount} 个</span>
<span>{mediaLabel}</span>
{tool?.inputContract?.slots.length ? (
<div className="workbench-reference-slots">
{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 (
<section className="workbench-reference-slot" key={slot.key}>
<div className="workbench-reference-slot-heading">
<div className="workbench-reference-slot-copy">
<div className="workbench-reference-slot-title">
<strong>{slot.name || "参考素材"}</strong>
<span className={`workbench-reference-slot-requirement${slot.required ? " is-required" : ""}`}>{slot.required ? "必填" : "可选"}</span>
</div>
<div className="workbench-reference-slot-meta">
<span>
{slot.minCount}-{slot.maxCount} 个
</span>
<span>{mediaLabel}</span>
</div>
</div>
{slotReferences.length ? (
<button type="button" className="workbench-slot-upload" onClick={openSlotPicker} aria-label={`为${slot.name}添加素材`} disabled={slotReferences.length >= slot.maxCount}>
<ImagePlus /> <span>添加</span>
</button>
) : null}
</div>
{slotReferences.length ? (
<button type="button" className="workbench-slot-upload" onClick={openSlotPicker} aria-label={`为${slot.name}添加素材`} disabled={slotReferences.length >= slot.maxCount}>
<ImagePlus /> <span>添加</span>
</button>
) : null}
</div>
<div className="workbench-reference-grid">
{slotReferences.map((reference) => <ReferenceCard key={reference.id} reference={reference} kind={kind} onRemove={removeReference} onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)}
{!slotReferences.length ? (
<button type="button" className="workbench-reference-slot-empty" onClick={openSlotPicker} disabled={slotReferences.length >= slot.maxCount}>
<span className="workbench-reference-slot-empty-icon"><ImagePlus /></span>
<span className="workbench-reference-slot-empty-copy"><strong>上传{slot.name || "参考"}素材</strong><small>从设备中选择文件</small></span>
</button>
) : null}
</div>
</section>
);
})}
</div>
) : (
<div className="workbench-reference-grid">
{references.map((reference) => <ReferenceCard key={reference.id} reference={reference} kind={kind} onRemove={removeReference} onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)}
{!references.length || (kind === "video" && references.length < 5) ? <button type="button" className="workbench-reference-add" onClick={() => { setUploadSlotKey(undefined); fileRef.current?.click(); }}><ImagePlus /><span>添加{kind === "image" ? "参考图" : "参考素材"}</span></button> : null}
</div>
)}
<input
ref={fileRef}
type="file"
className="sr-only"
accept={uploadSlotKey ? tool?.inputContract?.slots.find((slot) => 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 = "";
}}
/>
<section className="workbench-parameters" aria-label="生成参数">
<header>
<strong>{kind === "image" ? "图片参数" : "视频参数"}</strong>
<span>{kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"}</span>
</header>
{selectedModel ? settingsPanel : <p className="workbench-list-empty">先选择模型,再调整尺寸、数量或时长。</p>}
</section>
<div className="workbench-reference-grid">
{slotReferences.map((reference) => (
<ReferenceCard
key={reference.id}
reference={reference}
kind={kind}
onRemove={removeReference}
onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))}
/>
))}
{!slotReferences.length ? (
<button type="button" className="workbench-reference-slot-empty" onClick={openSlotPicker} disabled={slotReferences.length >= slot.maxCount}>
<span className="workbench-reference-slot-empty-icon">
<ImagePlus />
</span>
<span className="workbench-reference-slot-empty-copy">
<strong>上传{slot.name || "参考"}素材</strong>
<small>从设备中选择文件</small>
</span>
</button>
) : null}
</div>
</section>
);
})}
</div>
) : (
<div className="workbench-reference-grid">
{references.map((reference) => (
<ReferenceCard
key={reference.id}
reference={reference}
kind={kind}
onRemove={removeReference}
onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))}
/>
))}
{!references.length || (kind === "video" && references.length < 5) ? (
<button
type="button"
className="workbench-reference-add"
onClick={() => {
setUploadSlotKey(undefined);
fileRef.current?.click();
}}
>
<ImagePlus />
<span>添加{kind === "image" ? "参考图" : "参考素材"}</span>
</button>
) : null}
</div>
)}
<input
ref={fileRef}
type="file"
className="sr-only"
accept={
uploadSlotKey
? tool?.inputContract?.slots
.find((slot) => 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 = "";
}}
/>
<section className="workbench-parameters" aria-label="生成参数">
<header>
<strong>{kind === "image" ? "图片参数" : "视频参数"}</strong>
<span>{kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"}</span>
</header>
{selectedModel ? settingsPanel : <p className="workbench-list-empty">先选择模型,再调整尺寸、数量或时长。</p>}
</section>
</div>
<div className="workbench-composer-dock" aria-label="生成设置">
{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 }) {
) : (
<div className="workbench-model-empty">
<span>暂无可用的{kind === "image" ? "图片" : "视频"}模型</span>
<a href="/settings?section=models" target="_blank" rel="noreferrer">模型设置</a>
<a href="/settings?section=models" target="_blank" rel="noreferrer">
模型设置
</a>
</div>
)}
<div className="workbench-dock-actions">
@@ -1002,12 +1179,29 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
<div className="workbench-guide-empty">选择适合的模型和参考素材,写下你希望呈现的画面细节。</div>
)}
{tool.inputHint ? <p className="workbench-guide-input-hint">{tool.inputHint}</p> : null}
{tool.inputContract?.slots.length ? <div className="workbench-guide-contract"><strong>输入素材</strong>{tool.inputContract.slots.map((slot) => <span key={slot.key}>{slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个</span>)}</div> : null}
{tool.inputContract?.slots.length ? (
<div className="workbench-guide-contract">
<strong>输入素材</strong>
{tool.inputContract.slots.map((slot) => (
<span key={slot.key}>
{slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个
</span>
))}
</div>
) : null}
{tool.resultExample?.imageUrls?.length || tool.resultExample?.videoUrl ? (
<div className="workbench-example-strip">
<span>{tool.resultExample.title || "效果参考"}</span>
{tool.resultExample.imageUrls?.slice(0, 3).map((url) => <span className="workbench-example-thumb" key={url}><img src={url} alt="工具效果参考" /></span>)}
{tool.resultExample.videoUrl ? <span className="workbench-example-thumb"><video src={tool.resultExample.videoUrl} muted /></span> : null}
{tool.resultExample.imageUrls?.slice(0, 3).map((url) => (
<span className="workbench-example-thumb" key={url}>
<img src={url} alt="工具效果参考" />
</span>
))}
{tool.resultExample.videoUrl ? (
<span className="workbench-example-thumb">
<video src={tool.resultExample.videoUrl} muted />
</span>
) : null}
</div>
) : null}
</>
@@ -1109,8 +1303,14 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
</div>
</dl>
<div className="workbench-detail-actions">
<Button disabled={detailLoading} onClick={() => void restoreTaskToComposer(detailTask)}>载入到编辑器</Button>
{detailTask.status === "failed" || detailTask.status === "cancelled" ? <Button type="primary" loading={taskBusyId === detailTask.id} disabled={detailLoading} onClick={() => void retryTask(detailTask)}>重新生成</Button> : null}
<Button disabled={detailLoading} onClick={() => void restoreTaskToComposer(detailTask)}>
载入到编辑器
</Button>
{detailTask.status === "failed" || detailTask.status === "cancelled" ? (
<Button type="primary" loading={taskBusyId === detailTask.id} disabled={detailLoading} onClick={() => void retryTask(detailTask)}>
重新生成
</Button>
) : null}
</div>
</div>
</div>
@@ -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 <label className="workbench-setting-row"><span>{label}</span><Select aria-label={label} value={value} onChange={onChange} options={options} /></label>;
return (
<label className="workbench-setting-row">
<span>{label}</span>
<Select aria-label={label} value={value} onChange={onChange} options={options} />
</label>
);
}
return (
<div className="workbench-chip-field">
@@ -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 (
<div className="workbench-reference">
{reference.kind === "video" ? <video src={reference.previewUrl} muted /> : reference.kind === "audio" ? <span className="workbench-audio-reference"><Mic /></span> : <img src={reference.previewUrl} alt={reference.name} />}
{reference.kind === "video" ? (
<video src={reference.previewUrl} muted />
) : reference.kind === "audio" ? (
<span className="workbench-audio-reference">
<Mic />
</span>
) : (
<img src={reference.previewUrl} alt={reference.name} />
)}
{reference.uploading ? <Progress type="circle" percent={Math.round(reference.progress)} size={34} /> : null}
{reference.error ? <span className="workbench-reference-error" title={reference.error}>上传失败</span> : null}
<button type="button" aria-label={`移除${reference.name}`} onClick={() => onRemove(reference)}><X /></button>
{kind === "video" && reference.kind === "image" && !reference.slotKey ? <Select size="small" value={reference.role} onChange={onRoleChange} options={[{ label: "参考图", value: "reference" }, { label: "首帧", value: "first_frame" }, { label: "尾帧", value: "last_frame" }]} /> : null}
{reference.error ? (
<span className="workbench-reference-error" title={reference.error}>
上传失败
</span>
) : null}
<button type="button" aria-label={`移除${reference.name}`} onClick={() => onRemove(reference)}>
<X />
</button>
{kind === "video" && reference.kind === "image" && !reference.slotKey ? (
<Select
size="small"
value={reference.role}
onChange={onRoleChange}
options={[
{ label: "参考图", value: "reference" },
{ label: "首帧", value: "first_frame" },
{ label: "尾帧", value: "last_frame" },
]}
/>
) : null}
</div>
);
}

View File

@@ -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: "视频生成" }];
}

View File

@@ -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);

View File

@@ -168,7 +168,24 @@ export type SaveAdminWorkbenchToolInput = Pick<WorkbenchTool, "kind" | "category
export function saveAdminWorkbenchTool(input: SaveAdminWorkbenchToolInput) {
const { id, capability = {}, guide = {}, presets, ...tool } = input;
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 || {} })) } : {}) };
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);
}

View File

@@ -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);
});

View File

@@ -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(<ModelLabel config={config} model={config.model} capability="video" theme={theme} creationVariant showConfiguredModelName={false} showPrice showDescription />);
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(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} showDescription />);
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(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);

View File

@@ -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);

View File

@@ -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";

View File

@@ -39,7 +39,7 @@ describe("workspace route loading", () => {
expect(router).toContain('{ path: "/create", element: deferred(<CreatePage />) }');
expect(router).toContain("<RequireAuth allowAnonymous={isGuestWorkspacePath(pathname)}>");
expect(router).toContain('{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> }');
expect(router).toContain('{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> }');
expect(router).toMatch(/path: "\/canvas\/:id",\s+element: \(\s+<RequireAuth>\s+<CanvasProjectPage \/>\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 <video');
expect(workflow).toContain("if (playing) return <video");
});
test("uses a quiet workspace skeleton for initial hydration", () => {
@@ -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"');