Integrate ComfyUI agents and video workflows
This commit is contained in:
+7
-1
@@ -37,6 +37,7 @@ const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
|
||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||
const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||
@@ -252,6 +253,7 @@ export const sidebarGroups = [
|
||||
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||
{ to: '/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
],
|
||||
@@ -276,8 +278,9 @@ export const userSidebarGroups = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '视频应用',
|
||||
label: '创作应用',
|
||||
items: [
|
||||
{ to: '/user/picture-agent', label: '生图助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-5-6h.01M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
],
|
||||
@@ -286,6 +289,7 @@ export const userSidebarGroups = [
|
||||
const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '生图助手', to: '/picture-agent' },
|
||||
{ label: '视频工作台', to: '/video-agent' },
|
||||
{ label: '视频助手', to: '/comfyui-agent' },
|
||||
{ label: '关于', to: '/about' },
|
||||
@@ -757,6 +761,7 @@ function AppShell() {
|
||||
<Route path="/settings/import-export" element={<ImportExport />} />
|
||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/video-agent" element={<ComfyUI />} />
|
||||
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
@@ -770,6 +775,7 @@ function AppShell() {
|
||||
<Route path="/user/dashboard/keys" element={<UserKeys />} />
|
||||
<Route path="/user/dashboard/profile" element={<UserProfile />} />
|
||||
<Route path="/user/usage" element={<UsageRecords />} />
|
||||
<Route path="/user/picture-agent" element={<PictureAgent />} />
|
||||
<Route path="/user/video-agent" element={<ComfyUI />} />
|
||||
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
|
||||
|
||||
+18
-2
@@ -73,6 +73,12 @@ function parseContentDispositionFilename(
|
||||
return bareMatch?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function shouldSetJsonContentType(body: BodyInit | null | undefined): boolean {
|
||||
if (!body) return false;
|
||||
if (typeof FormData !== "undefined" && body instanceof FormData) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
if (nodeBuffer) {
|
||||
return nodeBuffer.from(buffer).toString("base64");
|
||||
@@ -116,7 +122,7 @@ async function fetchAuthenticatedResponse(
|
||||
const token = requireAuthToken();
|
||||
const headers = new Headers(fetchOptions.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (fetchOptions.body && !headers.has("Content-Type")) {
|
||||
if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
@@ -189,7 +195,7 @@ async function fetchUserResponse(
|
||||
}
|
||||
const headers = new Headers(fetchOptions.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (fetchOptions.body && !headers.has("Content-Type")) {
|
||||
if (shouldSetJsonContentType(fetchOptions.body) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
@@ -221,6 +227,16 @@ async function fetchUserResponse(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSessionResponse(
|
||||
audience: "admin" | "user",
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<Response> {
|
||||
return audience === "user"
|
||||
? fetchUserResponse(url, options)
|
||||
: fetchAuthenticatedResponse(url, options);
|
||||
}
|
||||
|
||||
async function userRequest<T = any>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
|
||||
@@ -2846,6 +2846,568 @@ textarea {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.picture-agent-page {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.picture-agent-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 26%, var(--color-border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg-card));
|
||||
color: var(--color-success);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - var(--topbar-height) - 120px);
|
||||
}
|
||||
|
||||
.picture-agent-panel,
|
||||
.picture-agent-board {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.picture-agent-panel {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.picture-agent-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.picture-agent-panel-head div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-panel-head strong {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.picture-agent-panel-head span {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picture-agent-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
background: color-mix(in srgb, var(--color-bg) 74%, var(--color-bg-card));
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.picture-agent-tabs button {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.picture-agent-tabs button.active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.picture-agent-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-field span {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.picture-agent-field textarea,
|
||||
.picture-agent-field select,
|
||||
.picture-agent-field input,
|
||||
.picture-agent-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picture-agent-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 150px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.picture-agent-field textarea:focus,
|
||||
.picture-agent-field select:focus,
|
||||
.picture-agent-field input:focus,
|
||||
.picture-agent-select:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.picture-agent-field select,
|
||||
.picture-agent-field input,
|
||||
.picture-agent-select {
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.picture-agent-field input:disabled {
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-bg) 70%, var(--color-bg-card));
|
||||
}
|
||||
|
||||
.picture-agent-select {
|
||||
min-width: min(360px, 70vw);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.picture-agent-grid-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr minmax(92px, 0.8fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview {
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-info) 7%, var(--color-bg-card));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview strong {
|
||||
color: var(--color-info);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-presets button {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.picture-agent-presets button:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.picture-agent-upload {
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
background: color-mix(in srgb, var(--color-bg) 52%, var(--color-bg-card));
|
||||
}
|
||||
|
||||
.picture-agent-upload-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-upload-bar span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-upload img,
|
||||
.picture-agent-upload-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.picture-agent-upload img {
|
||||
object-fit: contain;
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.picture-agent-upload-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
background: var(--color-bg-card);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.picture-agent-submit {
|
||||
min-height: 40px;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.picture-agent-canvas {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-template-strip {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-template-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.picture-agent-template-title strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-template-title span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-template-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.picture-agent-template-empty {
|
||||
width: 100%;
|
||||
min-height: 116px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 50%, var(--color-bg-card));
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-template-card {
|
||||
width: 156px;
|
||||
min-height: 168px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-rows: 112px auto;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picture-agent-template-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||
}
|
||||
|
||||
.picture-agent-template-card img {
|
||||
width: 100%;
|
||||
height: 112px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-bg) 70%, var(--color-bg-card));
|
||||
}
|
||||
|
||||
.picture-agent-template-card span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-template-card strong,
|
||||
.picture-agent-template-card em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-template-card strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-template-card em {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.picture-agent-board {
|
||||
min-height: 520px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--color-border) 32%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--color-border) 32%, transparent) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
.picture-agent-image-frame {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picture-agent-image-frame img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.picture-agent-board-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.picture-agent-board-meta div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picture-agent-board-meta strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-board-meta span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picture-agent-empty {
|
||||
min-height: 420px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picture-agent-history {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.picture-agent-history-wrap {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-history-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.picture-agent-history-title strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picture-agent-history-title span,
|
||||
.picture-agent-history-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-history-empty {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-bg) 50%, var(--color-bg-card));
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.picture-agent-history button {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.picture-agent-history button.active {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.picture-agent-history img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.picture-agent-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.picture-agent-board {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.picture-agent-panel {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.picture-agent-canvas {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.picture-agent-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.picture-agent-template-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.picture-agent-template-card {
|
||||
width: 132px;
|
||||
min-height: 150px;
|
||||
grid-template-rows: 92px auto;
|
||||
}
|
||||
|
||||
.picture-agent-template-card img {
|
||||
height: 92px;
|
||||
}
|
||||
|
||||
.picture-agent-grid-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.picture-agent-size-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.picture-agent-size-preview {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.picture-agent-board {
|
||||
min-height: 340px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.picture-agent-empty {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-toolbar-card {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
+92
-23
@@ -9,29 +9,40 @@ import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
|
||||
const VERSION = '1.3.0';
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
|
||||
{ icon: '🧠', title: '智能路由引擎', desc: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
|
||||
{ icon: '📡', title: '多站点聚合', desc: '集中管理 New API / One API / OneHub / DoneHub / Veloera 等' },
|
||||
{ icon: '🔍', title: '自动模型发现', desc: '上游新增模型自动出现在模型列表,零配置路由生成' },
|
||||
{ icon: '🏪', title: '模型广场', desc: '跨站模型覆盖、定价对比、延迟与成功率实测数据' },
|
||||
{ icon: '✅', title: '自动签到', desc: '定时签到 + 余额刷新,不再手动操心' },
|
||||
{ icon: '🔔', title: '多渠道告警', desc: 'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒' },
|
||||
{ icon: '📦', title: '轻量部署', desc: '单 Docker 容器,内置 SQLite,无外部依赖' },
|
||||
{ icon: 'GW', title: '统一模型网关', desc: '一个入口聚合 OpenAI / Claude / Gemini / 方舟等兼容上游,统一鉴权、计费与日志。' },
|
||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
||||
{ icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
|
||||
{ icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
||||
{ icon: 'NT', title: '通知与审计', desc: 'Webhook / Bark / Server酱 / Telegram / SMTP 告警,配合代理日志和程序事件排障。' },
|
||||
];
|
||||
|
||||
const TECH_STACK = [
|
||||
{ name: 'Fastify', desc: '高性能 Node.js 后端框架' },
|
||||
{ name: 'React', desc: '用户界面库' },
|
||||
{ name: 'TypeScript', desc: '端到端类型安全' },
|
||||
{ name: 'Tailwind CSS v4', desc: '原子化样式框架' },
|
||||
{ name: 'Drizzle ORM', desc: '轻量 TypeScript ORM' },
|
||||
{ name: 'SQLite', desc: '零配置嵌入式数据库' },
|
||||
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
|
||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
||||
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
|
||||
];
|
||||
|
||||
const LINKS = [
|
||||
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' },
|
||||
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' },
|
||||
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
|
||||
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: 'GH' },
|
||||
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: 'DH' },
|
||||
{ label: '站点文档', href: SITE_DOCS_URL, icon: 'DOC' },
|
||||
];
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
|
||||
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
|
||||
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
|
||||
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
|
||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
@@ -99,7 +110,24 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。')}
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 9px',
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
background: 'var(--color-bg-secondary)',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +165,22 @@ export default function About() {
|
||||
display: 'flex', gap: 10, padding: '8px 0',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
}}>
|
||||
<span style={{ fontSize: 18, lineHeight: '24px', flexShrink: 0 }}>{f.icon}</span>
|
||||
<span style={{
|
||||
width: 34,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-primary)',
|
||||
background: 'var(--color-bg-secondary)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{f.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
|
||||
@@ -149,8 +192,34 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('兼容入口')}</h3>
|
||||
<div style={{ display: 'grid', gap: 9 }}>
|
||||
{ENDPOINTS.map((endpoint) => (
|
||||
<div
|
||||
key={endpoint.path}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(190px, 260px) 1fr',
|
||||
gap: 12,
|
||||
alignItems: 'start',
|
||||
padding: '9px 0',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
}}
|
||||
>
|
||||
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-primary)' }}>
|
||||
{endpoint.path}
|
||||
</code>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.6 }}>
|
||||
{tr(endpoint.desc)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tech Stack */}
|
||||
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
@@ -174,7 +243,7 @@ export default function About() {
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<div className="card animate-slide-up stagger-5" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{LINKS.map((l) => (
|
||||
@@ -195,7 +264,7 @@ export default function About() {
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
|
||||
>
|
||||
<span>{l.icon}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}>{l.icon}</span>
|
||||
<span>{tr(l.label)}</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -206,7 +275,7 @@ export default function About() {
|
||||
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
|
||||
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1371,7 +1371,15 @@ export default function ModelTester() {
|
||||
rawMode: customRequestMode,
|
||||
...(customRequestMode
|
||||
? { rawJsonText: customRequestBody }
|
||||
: { jsonBody: { model: inputs.model, prompt: assetPrompt.trim() } }),
|
||||
: {
|
||||
jsonBody: {
|
||||
model: inputs.model,
|
||||
prompt: assetPrompt.trim(),
|
||||
ratio: '16:9',
|
||||
fps: 24,
|
||||
duration: 5,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1379,7 +1387,7 @@ export default function ModelTester() {
|
||||
if (!videoInspectId.trim()) return null;
|
||||
return {
|
||||
method: videoInspectAction,
|
||||
path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}`,
|
||||
path: `/v1/videos/${encodeURIComponent(videoInspectId.trim())}?model=${encodeURIComponent(inputs.model)}`,
|
||||
requestKind: 'empty',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchSessionResponse } from '../api.js';
|
||||
import { clearSession, getSessionToken, type SessionAudience } from '../authSession.js';
|
||||
|
||||
type AgentImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
mode: 'generate' | 'edit';
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type PictureMode = 'generate' | 'edit';
|
||||
|
||||
type PictureAgentStatus = {
|
||||
success?: boolean;
|
||||
mode?: string;
|
||||
role?: string;
|
||||
keyName?: string;
|
||||
upstreamLabel?: string;
|
||||
};
|
||||
|
||||
type PromptTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
tag: string;
|
||||
size: string;
|
||||
image: string;
|
||||
prompt: string;
|
||||
source?: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
postNumber?: number;
|
||||
username?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GalleryItem = {
|
||||
post_number?: number;
|
||||
username?: string;
|
||||
prompt?: string;
|
||||
images?: string[];
|
||||
image_host_url?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
|
||||
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
|
||||
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
|
||||
const ASPECT_OPTIONS = [
|
||||
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
|
||||
{ value: '2:3', label: '2:3 竖图', width: 1024, height: 1536 },
|
||||
{ value: '3:2', label: '3:2 横图', width: 1536, height: 1024 },
|
||||
{ value: '9:16', label: '9:16 竖屏', width: 1024, height: 1792 },
|
||||
{ value: '16:9', label: '16:9 宽屏', width: 1792, height: 1024 },
|
||||
{ value: 'auto', label: '自动', width: 0, height: 0 },
|
||||
{ value: 'custom', label: '自定义', width: 1024, height: 1024 },
|
||||
];
|
||||
const STYLE_PRESETS = [
|
||||
{
|
||||
label: '商业摄影',
|
||||
prompt: 'premium commercial product photography, clean studio lighting, sharp details, refined composition',
|
||||
},
|
||||
{
|
||||
label: '电影感',
|
||||
prompt: 'cinematic lighting, 35mm lens, dramatic atmosphere, rich color grading, high detail',
|
||||
},
|
||||
{
|
||||
label: '角色设定',
|
||||
prompt: 'character concept art, full body, clear silhouette, detailed outfit, neutral background',
|
||||
},
|
||||
{
|
||||
label: '国潮插画',
|
||||
prompt: 'modern Chinese illustration style, elegant linework, vivid but balanced colors, poster composition',
|
||||
},
|
||||
];
|
||||
|
||||
function makeId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `picture-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: any): string[] {
|
||||
const data = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return data
|
||||
.map((item: any) => {
|
||||
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
|
||||
if (typeof item?.b64_json === 'string' && item.b64_json.trim()) {
|
||||
return `data:image/png;base64,${item.b64_json.trim()}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function parseError(response: Response) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return `HTTP ${response.status}`;
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return value?.error?.message || value?.error || value?.message || text.slice(0, 180);
|
||||
} catch {
|
||||
return text.slice(0, 180);
|
||||
}
|
||||
}
|
||||
|
||||
function getFilename(image: AgentImage) {
|
||||
const safeModel = image.model.replace(/[^a-z0-9_-]+/gi, '-').slice(0, 32) || 'image';
|
||||
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
|
||||
}
|
||||
|
||||
function clampDimension(value: number) {
|
||||
if (!Number.isFinite(value)) return 1024;
|
||||
return Math.min(4096, Math.max(256, Math.round(value)));
|
||||
}
|
||||
|
||||
function parseSizeValue(value: string): { width: number; height: number } | null {
|
||||
const match = /^(\d{2,5})x(\d{2,5})$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
width: clampDimension(Number(match[1])),
|
||||
height: clampDimension(Number(match[2])),
|
||||
};
|
||||
}
|
||||
|
||||
function aspectFromSize(size: string) {
|
||||
if (size === 'auto') return 'auto';
|
||||
const parsed = parseSizeValue(size);
|
||||
if (!parsed) return 'custom';
|
||||
const match = ASPECT_OPTIONS.find((option) => option.width === parsed.width && option.height === parsed.height);
|
||||
return match?.value || 'custom';
|
||||
}
|
||||
|
||||
function buildSize(aspect: string, width: number, height: number) {
|
||||
if (aspect === 'auto') return 'auto';
|
||||
if (aspect === 'custom') return `${clampDimension(width)}x${clampDimension(height)}`;
|
||||
const option = ASPECT_OPTIONS.find((item) => item.value === aspect);
|
||||
if (!option || !option.width || !option.height) return 'auto';
|
||||
return `${option.width}x${option.height}`;
|
||||
}
|
||||
|
||||
function templateTitle(prompt: string, postNumber?: number) {
|
||||
const clean = prompt.split(/\s+/).join(' ').trim();
|
||||
let title = Array.from(clean).slice(0, 18).join('');
|
||||
if (!title) title = postNumber ? `#${postNumber}` : '参考模板';
|
||||
if (Array.from(clean).length > 18) title += '...';
|
||||
return title;
|
||||
}
|
||||
|
||||
function templateTag(prompt: string) {
|
||||
const lower = prompt.toLowerCase();
|
||||
if (prompt.includes('海报') || lower.includes('poster')) return '海报';
|
||||
if (prompt.includes('漫画') || lower.includes('comic')) return '漫画';
|
||||
if (prompt.includes('图鉴') || prompt.includes('信息图') || lower.includes('infographic')) return '图鉴';
|
||||
if (prompt.includes('贴纸') || lower.includes('sticker') || lower.includes('mascot')) return '贴纸';
|
||||
if (prompt.includes('分镜') || lower.includes('storyboard')) return '分镜';
|
||||
return '参考';
|
||||
}
|
||||
|
||||
function templateSize(prompt: string) {
|
||||
const lower = prompt.toLowerCase();
|
||||
if (lower.includes('16:9') || prompt.includes('横版')) return '1536x1024';
|
||||
if (lower.includes('9:16') || lower.includes('--ar 9:16') || prompt.includes('竖版')) return '1024x1536';
|
||||
if (lower.includes('1:1') || prompt.includes('方图')) return '1024x1024';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
|
||||
return items
|
||||
.map<PromptTemplate | null>((item) => {
|
||||
const prompt = (item.prompt || '').trim();
|
||||
const image = (item.images || []).find((value) => value.startsWith('http'))
|
||||
|| (item.image_host_url || '').trim();
|
||||
if (Array.from(prompt).length < 20 || !image.startsWith('http')) return null;
|
||||
return {
|
||||
id: item.post_number ? `linux-do-${item.post_number}` : `linux-do-${templateTitle(prompt)}`,
|
||||
title: templateTitle(prompt, item.post_number),
|
||||
tag: templateTag(prompt),
|
||||
size: templateSize(prompt),
|
||||
image,
|
||||
prompt,
|
||||
source: {
|
||||
name: 'Linux Do GPT Image Gallery',
|
||||
url: 'https://weiyyin.github.io/linux-do-gpt-image/prompt_gallery.html',
|
||||
postNumber: item.post_number,
|
||||
username: item.username,
|
||||
},
|
||||
} satisfies PromptTemplate;
|
||||
})
|
||||
.filter((value): value is PromptTemplate => value !== null)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
|
||||
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
|
||||
const items = await response.json();
|
||||
return normalizeGalleryItems(Array.isArray(items) ? items : [], limit);
|
||||
}
|
||||
|
||||
export default function PictureAgent() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const token = getSessionToken(audience, localStorage);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [status, setStatus] = useState<PictureAgentStatus | null>(null);
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [model, setModel] = useState('');
|
||||
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
|
||||
const [mode, setMode] = useState<PictureMode>('generate');
|
||||
const [aspect, setAspect] = useState('1:1');
|
||||
const [customWidth, setCustomWidth] = useState(1024);
|
||||
const [customHeight, setCustomHeight] = useState(1024);
|
||||
const [quality, setQuality] = useState('auto');
|
||||
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [templatesError, setTemplatesError] = useState('');
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState('');
|
||||
const [images, setImages] = useState<AgentImage[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bootError, setBootError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
||||
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
||||
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
||||
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
||||
|
||||
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
|
||||
return fetchSessionResponse(audience, url, options);
|
||||
}, [audience]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function load() {
|
||||
if (!token) {
|
||||
clearSession(audience, localStorage);
|
||||
setBootError(audience === 'user' ? '用户会话已过期,请重新登录。' : '管理员会话已过期,请重新登录。');
|
||||
return;
|
||||
}
|
||||
setBootError('');
|
||||
setTemplatesLoading(true);
|
||||
setTemplatesError('');
|
||||
try {
|
||||
const [statusRes, modelsRes, templatesRes] = await Promise.all([
|
||||
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
|
||||
]);
|
||||
if (!statusRes.ok) throw new Error(await parseError(statusRes));
|
||||
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
|
||||
const nextStatus = await statusRes.json();
|
||||
const nextModels = await modelsRes.json();
|
||||
let nextTemplates: PromptTemplate[] = [];
|
||||
if (templatesRes.ok) {
|
||||
const templatePayload = await templatesRes.json();
|
||||
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
|
||||
} else {
|
||||
try {
|
||||
nextTemplates = await loadGalleryTemplatesDirectly(48);
|
||||
} catch {
|
||||
setTemplatesError(await parseError(templatesRes));
|
||||
}
|
||||
}
|
||||
if (ignore) return;
|
||||
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
|
||||
setStatus(nextStatus);
|
||||
setModels(availableModels);
|
||||
setTemplates(nextTemplates);
|
||||
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
|
||||
} catch (err: any) {
|
||||
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
|
||||
} finally {
|
||||
if (!ignore) setTemplatesLoading(false);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [audience, authedFetch, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceFile) {
|
||||
setSourcePreview('');
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(sourceFile);
|
||||
setSourcePreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [sourceFile]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (loading) return false;
|
||||
if (!prompt.trim()) return false;
|
||||
if (!model.trim()) return false;
|
||||
if (mode === 'edit' && !sourceFile) return false;
|
||||
return true;
|
||||
}, [loading, mode, model, prompt, sourceFile]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
let response: Response;
|
||||
if (mode === 'edit') {
|
||||
const form = new FormData();
|
||||
form.set('model', model.trim());
|
||||
form.set('prompt', prompt.trim());
|
||||
form.set('size', resolvedSize);
|
||||
form.set('quality', quality);
|
||||
if (sourceFile) form.set('image', sourceFile, sourceFile.name || 'source.png');
|
||||
response = await authedFetch('/api/picture-agent/images/edits', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
} else {
|
||||
response = await authedFetch('/api/picture-agent/images/generations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: model.trim(),
|
||||
prompt: prompt.trim(),
|
||||
size: resolvedSize,
|
||||
quality,
|
||||
}),
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
const urls = extractImageUrls(payload);
|
||||
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
|
||||
|
||||
const createdAt = Date.now();
|
||||
const nextImages = urls.map((url) => ({
|
||||
id: makeId(),
|
||||
url,
|
||||
prompt: prompt.trim(),
|
||||
model: model.trim(),
|
||||
mode,
|
||||
createdAt,
|
||||
}));
|
||||
setImages((current) => [...nextImages, ...current].slice(0, 48));
|
||||
setSelectedId(nextImages[0]?.id || '');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '图片生成失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (image: AgentImage | null) => {
|
||||
if (!image) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = image.url;
|
||||
link.download = getFilename(image);
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
};
|
||||
|
||||
const applyPreset = (preset: string) => {
|
||||
setPrompt((current) => {
|
||||
const clean = current.trim();
|
||||
if (!clean) return preset;
|
||||
if (clean.includes(preset)) return clean;
|
||||
return `${clean}, ${preset}`;
|
||||
});
|
||||
};
|
||||
|
||||
const applyTemplate = (template: PromptTemplate) => {
|
||||
setPrompt(template.prompt);
|
||||
const templateAspect = aspectFromSize(template.size);
|
||||
setAspect(templateAspect);
|
||||
const parsed = parseSizeValue(template.size);
|
||||
if (parsed) {
|
||||
setCustomWidth(parsed.width);
|
||||
setCustomHeight(parsed.height);
|
||||
}
|
||||
setMode('generate');
|
||||
setError('');
|
||||
};
|
||||
|
||||
if (bootError) {
|
||||
return (
|
||||
<div className="empty-state" style={{ minHeight: 320 }}>
|
||||
<div className="empty-state-title">生图助手暂不可用</div>
|
||||
<div className="empty-state-desc">{bootError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="picture-agent-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">生图助手</h1>
|
||||
<p className="page-subtitle">
|
||||
{modeLabel} · {status?.upstreamLabel || 'BoosAPI 本地图片路由'} · {status?.keyName || '加载密钥中'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<span className="picture-agent-status-pill">{audience === 'user' ? '普通用户' : '管理员'}</span>
|
||||
<select className="picture-agent-select" value={model} onChange={(event) => setModel(event.target.value)}>
|
||||
{models.length === 0 && <option value={model || 'gpt-image-2'}>{model || 'gpt-image-2'}</option>}
|
||||
{models.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-shell">
|
||||
<aside className="picture-agent-panel">
|
||||
<div className="picture-agent-panel-head">
|
||||
<div>
|
||||
<strong>创作参数</strong>
|
||||
<span>{mode === 'edit' ? '上传参考图后进行局部或整体改写' : '输入提示词生成第一张画布图片'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-tabs" aria-label="picture mode">
|
||||
<button className={mode === 'generate' ? 'active' : ''} onClick={() => setMode('generate')} type="button">文生图</button>
|
||||
<button className={mode === 'edit' ? 'active' : ''} onClick={() => setMode('edit')} type="button">图像编辑</button>
|
||||
</div>
|
||||
|
||||
<label className="picture-agent-field">
|
||||
<span>提示词</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="描述主体、镜头、风格、材质、光线和构图"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="picture-agent-presets">
|
||||
{STYLE_PRESETS.map((preset) => (
|
||||
<button key={preset.label} type="button" onClick={() => applyPreset(preset.prompt)}>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-grid-fields">
|
||||
<label className="picture-agent-field">
|
||||
<span>比例</span>
|
||||
<select
|
||||
value={aspect}
|
||||
onChange={(event) => {
|
||||
const nextAspect = event.target.value;
|
||||
setAspect(nextAspect);
|
||||
const option = ASPECT_OPTIONS.find((item) => item.value === nextAspect);
|
||||
if (option?.width && option?.height) {
|
||||
setCustomWidth(option.width);
|
||||
setCustomHeight(option.height);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ASPECT_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>质量</span>
|
||||
<select value={quality} onChange={(event) => setQuality(event.target.value)}>
|
||||
{QUALITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-size-row">
|
||||
<label className="picture-agent-field">
|
||||
<span>宽度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customWidth}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomWidth(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="picture-agent-field">
|
||||
<span>高度</span>
|
||||
<input
|
||||
type="number"
|
||||
min={256}
|
||||
max={4096}
|
||||
step={64}
|
||||
value={customHeight}
|
||||
disabled={aspect === 'auto'}
|
||||
onChange={(event) => {
|
||||
setAspect('custom');
|
||||
setCustomHeight(clampDimension(Number(event.target.value)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="picture-agent-size-preview">
|
||||
<span>提交尺寸</span>
|
||||
<strong>{resolvedSize}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div className="picture-agent-upload">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="picture-agent-upload-bar">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
选择图片
|
||||
</button>
|
||||
{sourceFile && <span>{sourceFile.name}</span>}
|
||||
</div>
|
||||
{sourcePreview ? (
|
||||
<img src={sourcePreview} alt="source" />
|
||||
) : (
|
||||
<button type="button" className="picture-agent-upload-empty" onClick={() => fileInputRef.current?.click()}>
|
||||
上传一张参考图
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="picture-agent-canvas">
|
||||
<div className="picture-agent-template-strip">
|
||||
<div className="picture-agent-template-title">
|
||||
<strong>参考模板</strong>
|
||||
<span>{templatesLoading ? '正在刷新模板...' : '每次打开自动获取 Linux Do GPT Image Gallery'}</span>
|
||||
</div>
|
||||
<div className="picture-agent-template-list">
|
||||
{templatesError && templates.length === 0 ? (
|
||||
<div className="picture-agent-template-empty">模板加载失败:{templatesError}</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="picture-agent-template-empty">{templatesLoading ? '正在加载参考模板...' : '暂无可用模板'}</div>
|
||||
) : templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className="picture-agent-template-card"
|
||||
onClick={() => applyTemplate(template)}
|
||||
title={template.prompt}
|
||||
>
|
||||
<img src={template.image} alt="" loading="lazy" referrerPolicy="no-referrer" />
|
||||
<span>
|
||||
<strong>{template.title}</strong>
|
||||
<em>{template.tag}{template.source?.postNumber ? ` · #${template.source.postNumber}` : ''}</em>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-board">
|
||||
{selectedImage ? (
|
||||
<>
|
||||
<div className="picture-agent-image-frame">
|
||||
<img src={selectedImage.url} alt={selectedImage.prompt} />
|
||||
</div>
|
||||
<div className="picture-agent-board-meta">
|
||||
<div>
|
||||
<strong>{resultLabel}</strong>
|
||||
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="picture-agent-empty">
|
||||
<div style={emptyIconStyle} />
|
||||
<div className="empty-state-title">画布等待第一张图片</div>
|
||||
<div className="empty-state-desc">选择模型、调整参数,然后开始生成或上传参考图进行编辑。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="picture-agent-history-wrap">
|
||||
<div className="picture-agent-history-title">
|
||||
<strong>历史结果</strong>
|
||||
<span>{images.length} 张</span>
|
||||
</div>
|
||||
<div className="picture-agent-history">
|
||||
{images.length === 0 ? (
|
||||
<div className="picture-agent-history-empty">生成结果会保留在这里,便于快速对比。</div>
|
||||
) : images.map((image) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
className={image.id === selectedImage?.id ? 'active' : ''}
|
||||
onClick={() => setSelectedId(image.id)}
|
||||
title={image.prompt}
|
||||
>
|
||||
<img src={image.url} alt="" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyIconStyle: CSSProperties = {
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background:
|
||||
'linear-gradient(135deg, color-mix(in srgb, var(--color-info) 16%, var(--color-bg-card)), color-mix(in srgb, var(--color-success) 12%, var(--color-bg-card)))',
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
|
||||
import {
|
||||
createCodexDefaultHighReasoningVisualPreset,
|
||||
createVolcengineVideoTaskVisualPreset,
|
||||
createVisualPayloadRule,
|
||||
isVisualPayloadRuleBlank,
|
||||
payloadRulesToVisualRules,
|
||||
@@ -1004,6 +1005,15 @@ export default function Settings() {
|
||||
toast.success('已填入 Codex 默认高推理预设');
|
||||
};
|
||||
|
||||
const applyVolcengineVideoTaskPreset = () => {
|
||||
applyVisualPayloadRules((currentRules) => [
|
||||
...currentRules.filter((rule) => !isVisualPayloadRuleBlank(rule)),
|
||||
...createVolcengineVideoTaskVisualPreset(),
|
||||
]);
|
||||
setShowPayloadRulesEditor(true);
|
||||
toast.success('已填入火山视频任务 Step1/Step2 模板');
|
||||
};
|
||||
|
||||
const addPayloadVisualRule = () => {
|
||||
applyVisualPayloadRules((currentRules) => [
|
||||
...currentRules,
|
||||
@@ -1564,6 +1574,14 @@ export default function Settings() {
|
||||
>
|
||||
Codex 默认高推理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
onClick={applyVolcengineVideoTaskPreset}
|
||||
>
|
||||
火山视频任务 Step1/Step2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
|
||||
@@ -1329,7 +1329,13 @@ export const buildVideoCreateRequestEnvelope = (
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: files.length > 0 ? undefined : { model: inputs.model, prompt: modeState.videosPrompt },
|
||||
jsonBody: files.length > 0 ? undefined : {
|
||||
model: inputs.model,
|
||||
prompt: modeState.videosPrompt,
|
||||
ratio: '16:9',
|
||||
fps: 24,
|
||||
duration: 5,
|
||||
},
|
||||
multipartFields: files.length > 0 ? { model: inputs.model, prompt: modeState.videosPrompt } : undefined,
|
||||
multipartFiles: files.length > 0 ? files : undefined,
|
||||
});
|
||||
@@ -1339,7 +1345,7 @@ export const buildVideoInspectRequestEnvelope = (
|
||||
modeState: ModelTesterModeState,
|
||||
): TesterProxyEnvelope => ({
|
||||
method: inputs.videoInspectAction === 'delete' ? 'DELETE' : 'GET',
|
||||
path: `/v1/videos/${encodeURIComponent(modeState.videosInspectId.trim())}`,
|
||||
path: `/v1/videos/${encodeURIComponent(modeState.videosInspectId.trim())}?model=${encodeURIComponent(inputs.model)}`,
|
||||
requestKind: 'empty',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
|
||||
@@ -66,6 +66,24 @@ export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule
|
||||
];
|
||||
}
|
||||
|
||||
export function createVolcengineVideoTaskVisualPreset(): VisualPayloadRule[] {
|
||||
return [
|
||||
createVisualPayloadRule({
|
||||
action: 'default',
|
||||
modelPattern: 'doubao-seedance-*',
|
||||
protocol: 'openai',
|
||||
path: 'content',
|
||||
value: JSON.stringify([
|
||||
{
|
||||
type: 'text',
|
||||
text: '一只橘猫在赛博朋克城市屋顶奔跑 --ratio 16:9 --fps 24 --dur 5',
|
||||
},
|
||||
], null, 2),
|
||||
valueMode: 'json',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
|
||||
if (!isRecord(value)) return [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user