feat(*): 创作体验 - 支持资源技能引用并统一生成反馈与画布列表展示
This commit is contained in:
@@ -886,16 +886,16 @@ function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; v
|
||||
value={current || undefined}
|
||||
className="agent-text-model-select w-full"
|
||||
popupMatchSelectWidth={288}
|
||||
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${modelOptionName(model)} ${resolveModelChannel(config, model).name}` }))}
|
||||
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${resolveModelChannel(config, model).name}` }))}
|
||||
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
|
||||
optionRender={(option) => {
|
||||
const model = String(option.value);
|
||||
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1"><span className="block truncate">{modelDisplayName(config, model)}</span><span className="block truncate text-[10px] opacity-45">{modelOptionName(model)}</span></span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
|
||||
return <span className="flex min-w-0 items-center gap-2"><AgentModelIcon model={model} /><span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span><span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span></span>;
|
||||
}}
|
||||
labelRender={() => <span className="flex min-w-0 items-center gap-1.5"><AgentModelIcon model={current} /><span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}</span>}
|
||||
onChange={onChange}
|
||||
aria-label="选择 Agent 文本模型"
|
||||
title={current ? `${modelDisplayName(config, current)} · ${modelOptionName(current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
|
||||
title={current ? `${modelDisplayName(config, current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
@@ -546,7 +546,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
|
||||
{node.metadata?.errorDetails ? (
|
||||
<div className="rounded-2xl border p-3 text-red-500" style={{ borderColor: theme.node.stroke }}>
|
||||
{node.metadata.errorDetails}
|
||||
{generationErrorMessage(node.metadata.errorDetails)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
|
||||
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl, getCachedResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
@@ -633,7 +633,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
const moderationFailure = node.metadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(node.metadata?.errorDetails);
|
||||
return (
|
||||
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
|
||||
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{node.metadata?.errorDetails || "生成失败"}</div>
|
||||
<div className="text-xs leading-5" style={{ color: theme.accent.danger }}>{generationErrorMessage(node.metadata?.errorDetails)}</div>
|
||||
{moderationFailure ? (
|
||||
<div className="rounded-md border px-3 py-2 text-[11px] leading-4" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
修改节点提示词后,可重新点击生成。
|
||||
|
||||
@@ -32,11 +32,11 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
|
||||
const compact = variant === "recent";
|
||||
return (
|
||||
<article className={cn("app-canvas-project-card group h-full cursor-pointer", selected && "is-selected")} onClick={() => !editing && open()}>
|
||||
<article className={cn("app-canvas-project-card group h-full cursor-pointer", compact ? "is-recent" : "is-library overflow-hidden rounded-lg border", selected && "is-selected")} onClick={() => !editing && open()}>
|
||||
<div className="app-canvas-project-preview relative">
|
||||
<button
|
||||
type="button"
|
||||
className="block aspect-[16/10] w-full overflow-hidden text-left"
|
||||
className={cn("block w-full overflow-hidden text-left", compact ? "aspect-[16/10]" : "aspect-video")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
@@ -48,20 +48,20 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
<span className="absolute bottom-2 right-2 rounded-md border border-white/15 bg-stone-950/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-xl">{project.nodes.length} 节点</span>
|
||||
</div>
|
||||
|
||||
<div className="px-1 pb-1 pt-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("app-canvas-project-body", compact ? "px-1 pb-1 pt-2.5" : "px-3 py-2.5")}>
|
||||
<div className="flex min-h-7 items-center justify-between gap-2">
|
||||
{editing ? (
|
||||
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 cursor-pointer text-left"
|
||||
className="min-w-0 flex-1 cursor-pointer text-left focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<h2 className="line-clamp-1 text-[13px] font-semibold leading-5 text-foreground">{project.title}</h2>
|
||||
<h2 className="truncate text-[13px] font-semibold leading-5 text-foreground">{project.title}</h2>
|
||||
</button>
|
||||
)}
|
||||
{editing ? (
|
||||
@@ -70,27 +70,30 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md hover:bg-black/5 dark:hover:bg-white/10" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
|
||||
</div>
|
||||
) : (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
onClick: ({ domEvent }) => domEvent.stopPropagation(),
|
||||
items: [
|
||||
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
|
||||
{ key: "rename", icon: <Pencil className="size-3.5" />, label: "重命名", onClick: () => startEditing(project.id, project.title) },
|
||||
{ type: "divider" },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/42 opacity-100 transition hover:bg-foreground/[.06] hover:text-foreground sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 dark:hover:bg-white/10" onClick={(event) => event.stopPropagation()} aria-label={`${project.title} 画布操作`} title="画布操作"><MoreHorizontal className="size-4" /></button>
|
||||
</Dropdown>
|
||||
<div className="flex shrink-0 items-center" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
onClick: ({ domEvent }) => domEvent.stopPropagation(),
|
||||
items: [
|
||||
{ key: "export", icon: <Download className="size-3.5" />, label: "导出画布", onClick: () => void exportCanvasProjects([project], project.title || "影策画布") },
|
||||
{ type: "divider" },
|
||||
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: () => setDeleteIds([project.id]) },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/52">
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-foreground/52">
|
||||
<span className="truncate">{projectName || "自由画布"}</span>
|
||||
<span className="shrink-0">{project.connections.length} 条连线</span>
|
||||
<span className="shrink-0 text-foreground/28" aria-hidden="true">·</span>
|
||||
<span className="shrink-0 tabular-nums">{project.connections.length} 条连线</span>
|
||||
</div>
|
||||
<p className="text-[10px] tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
|
||||
<p className="mt-1 text-[10px] leading-4 tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ModelPicker } from "@/components/model-picker";
|
||||
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { pipelineStatusLabel, type CanvasStoryboardPipelineProgress, type StoryboardPipelineStage } from "@/lib/canvas/canvas-storyboard-progress";
|
||||
import { isContentModerationError } from "@/lib/generation-error";
|
||||
import { generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -106,7 +106,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
|
||||
const hasActiveBatchItems = Boolean(batch?.items.some((item) => item.status === "waiting" || item.status === "submitting" || item.status === "queued" || item.status === "running"));
|
||||
const taskFeedback = node.metadata?.status === "loading"
|
||||
? `${node.metadata.taskStage || "正在创建任务"}${typeof node.metadata.taskProgress === "number" ? ` · ${node.metadata.taskProgress}%` : ""}`
|
||||
: node.metadata?.status === "error" ? node.metadata.errorDetails : "";
|
||||
: node.metadata?.status === "error" ? generationErrorMessage(node.metadata.errorDetails) : "";
|
||||
const submitPrompt = () => {
|
||||
const value = prompt.trim();
|
||||
if (value && node.metadata?.status !== "loading") onGenerateScript(value);
|
||||
@@ -344,7 +344,7 @@ function GenerationBatchDetails({ batch, rows, onRetryItem, onCancelItem }: { ba
|
||||
const requiresPromptChange = isContentModerationError(item.errorDetails);
|
||||
return <div key={item.id} className="flex min-h-9 items-center gap-2 border-t border-foreground/10 py-1.5 first:border-t-0">
|
||||
<span className="w-14 shrink-0 text-xs font-medium">镜头 {shotByRowId.get(item.rowId) || "--"}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground/60" title={item.errorDetails ? generationErrorMessage(item.errorDetails) : undefined}>{generationBatchItemLabel(item)}{item.retryCount ? ` · 重试 ${item.retryCount}` : ""}</span>
|
||||
{item.status === "failed" ? <Tooltip title={requiresPromptChange ? "请先修改提示词,再重试这个镜头" : "只重试这个镜头"}><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" onClick={() => onRetryItem(item.id)} aria-label={`重试镜头 ${shotByRowId.get(item.rowId) || ""}`}><RefreshCw className="size-3.5" /></button></Tooltip> : null}
|
||||
{cancellable ? <Tooltip title="取消这个后台任务"><button type="button" className="grid size-7 shrink-0 place-items-center rounded outline-none transition hover:bg-red-500/10 focus-visible:ring-2" onClick={() => onCancelItem(item.id)} aria-label={`取消镜头 ${shotByRowId.get(item.rowId) || ""} 任务`}><X className="size-3.5" /></button></Tooltip> : null}
|
||||
</div>;
|
||||
|
||||
@@ -31,9 +31,9 @@ export function PageHeader({ title, description, meta, actions, icon }: { title:
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({ children, trailing, active, onReset }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void }) {
|
||||
export function ListToolbar({ children, trailing, active, onReset, className }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void; className?: string }) {
|
||||
return (
|
||||
<div className="mt-3 flex min-h-12 flex-col gap-2 border-b border-border/75 pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className={cn("mt-3 flex min-h-12 flex-col gap-2 border-b border-border/75 pb-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2.5">{children}</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{active && onReset ? <Button type="text" icon={<RotateCcw className="size-3.5" />} onClick={onReset}>重置</Button> : null}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} />
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
@@ -182,7 +182,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean }) {
|
||||
function ModelLabel({ config, model, capability, theme }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
@@ -191,7 +191,7 @@ function ModelLabel({ config, model, capability, theme, creationVariant }: { con
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[11px] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={`${modelOptionName(model)} · ${meta.description}`}>{creationVariant ? meta.description : `${modelOptionName(model)} · ${meta.description}`}</span>
|
||||
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||||
</span>
|
||||
<ModelPrice price={modelMenuPrice(config, model)} />
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
|
||||
@@ -2,6 +2,9 @@ export const CONTENT_MODERATION_ERROR_CODE = "sensitive_words_detected";
|
||||
|
||||
export const CONTENT_MODERATION_MESSAGE = "内容审核未通过,本次平台积分未扣除或已退还。请修改提示词后重新生成。";
|
||||
|
||||
const DEFAULT_GENERATION_ERROR_MESSAGE = "生成失败,请稍后重试。";
|
||||
const NETWORK_ERROR_MESSAGE = "网络异常。";
|
||||
|
||||
export type GenerationFailureMetadata = {
|
||||
errorDetails: string;
|
||||
generationErrorCode?: string;
|
||||
@@ -9,8 +12,8 @@ export type GenerationFailureMetadata = {
|
||||
};
|
||||
|
||||
export function generationFailureMetadata(error: unknown, prompt: string): GenerationFailureMetadata {
|
||||
const raw = error instanceof Error ? error.message : String(error || "生成失败");
|
||||
if (!isContentModerationError(raw)) return { errorDetails: raw || "生成失败" };
|
||||
const raw = rawGenerationError(error);
|
||||
if (!isContentModerationError(raw)) return { errorDetails: generationErrorMessage(error) };
|
||||
return {
|
||||
errorDetails: CONTENT_MODERATION_MESSAGE,
|
||||
generationErrorCode: CONTENT_MODERATION_ERROR_CODE,
|
||||
@@ -19,8 +22,20 @@ export function generationFailureMetadata(error: unknown, prompt: string): Gener
|
||||
}
|
||||
|
||||
export function generationErrorMessage(error: unknown) {
|
||||
const raw = error instanceof Error ? error.message : String(error || "生成失败");
|
||||
return isContentModerationError(raw) ? CONTENT_MODERATION_MESSAGE : raw || "生成失败";
|
||||
const raw = rawGenerationError(error);
|
||||
if (isContentModerationError(raw)) return CONTENT_MODERATION_MESSAGE;
|
||||
|
||||
const providerMessage = extractStructuredProviderMessage(raw) || extractWrappedProviderMessage(raw);
|
||||
const displayMessage = providerMessage || raw;
|
||||
if (isContentModerationError(displayMessage)) return CONTENT_MODERATION_MESSAGE;
|
||||
if (isNetworkFailure(displayMessage)) return NETWORK_ERROR_MESSAGE;
|
||||
if (!providerMessage) {
|
||||
if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。";
|
||||
if (hasHttpStatus(raw, 401, 403)) return "生成服务鉴权失败,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 404)) return "生成服务地址不可用,请检查渠道配置。";
|
||||
if (hasHttpStatus(raw, 500, 502, 503, 504) || containsInfrastructureDetails(raw)) return NETWORK_ERROR_MESSAGE;
|
||||
}
|
||||
return displayMessage || DEFAULT_GENERATION_ERROR_MESSAGE;
|
||||
}
|
||||
|
||||
export function isContentModerationError(value: unknown) {
|
||||
@@ -45,3 +60,59 @@ export function generationPromptFingerprint(value: string) {
|
||||
}
|
||||
return `${normalized.length}:${(hash >>> 0).toString(36)}`;
|
||||
}
|
||||
|
||||
function rawGenerationError(error: unknown) {
|
||||
if (error instanceof Error) return error.message.trim();
|
||||
if (typeof error === "string") return error.trim();
|
||||
return providerPayloadMessage(error);
|
||||
}
|
||||
|
||||
function extractStructuredProviderMessage(raw: string) {
|
||||
for (let index = raw.indexOf("{"); index >= 0; index = raw.indexOf("{", index + 1)) {
|
||||
try {
|
||||
const message = providerPayloadMessage(JSON.parse(raw.slice(index).trim()));
|
||||
if (message) return message;
|
||||
} catch {
|
||||
// 上游常把 JSON 拼在 HTTP 状态后;不是完整 JSON 时继续尝试下一个对象起点。
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractWrappedProviderMessage(raw: string) {
|
||||
const interfaceFailure = raw.match(/^接口请求失败[::]\s*(.*)$/s);
|
||||
const requestFailure = raw.match(/^Request failed with status code \d{3}\s*[::-]?\s*(.+)$/is);
|
||||
const wrapped = interfaceFailure?.[1] ?? requestFailure?.[1];
|
||||
if (!wrapped) return "";
|
||||
const message = wrapped
|
||||
.replace(/^\d{3}(?:\s+(?:Bad Gateway|Service Unavailable|Gateway Timeout|Internal Server Error|Not Found|Unauthorized|Forbidden|Too Many Requests))?\s*[::-]?\s*/i, "")
|
||||
.trim();
|
||||
return message && !containsInfrastructureDetails(message) ? message : "";
|
||||
}
|
||||
|
||||
function providerPayloadMessage(payload: unknown): string {
|
||||
if (typeof payload === "string") return payload.trim();
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "";
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (record.error && typeof record.error === "object") {
|
||||
const nested = providerPayloadMessage(record.error);
|
||||
if (nested) return nested;
|
||||
}
|
||||
for (const key of ["message", "msg", "detail"] as const) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return typeof record.error === "string" ? record.error.trim() : "";
|
||||
}
|
||||
|
||||
function isNetworkFailure(value: string) {
|
||||
return /\b(?:dial tcp|connection refused|connection reset|no such host|i\/o timeout|context deadline exceeded|network error|failed to fetch|fetch failed|socket hang up|econnrefused|econnreset|etimedout)\b/i.test(value);
|
||||
}
|
||||
|
||||
function hasHttpStatus(value: string, ...statuses: number[]) {
|
||||
return statuses.some((status) => new RegExp(`\\b${status}\\b`).test(value));
|
||||
}
|
||||
|
||||
function containsInfrastructureDetails(value: string) {
|
||||
return /(?:接口请求失败|Request failed with status code|https?:\/\/|\b(?:GET|POST|PUT|PATCH|DELETE)\s+["']?|Bad Gateway|Service Unavailable|Gateway Timeout|upstream_error)/i.test(value);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TaskDetailItem, taskStatusText } from "./canvas-project-feedback";
|
||||
import type { GenerationTask, TaskLog } from "@/services/api/task-center";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { VideoPlayer } from "@/components/video-player";
|
||||
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
|
||||
type CanvasProjectStatusDialogsProps = {
|
||||
theme: { node: { stroke: string; panel: string; muted: string; fill: string } };
|
||||
@@ -21,6 +22,7 @@ type CanvasProjectStatusDialogsProps = {
|
||||
};
|
||||
|
||||
export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading, superResolveNode, previewNode, clearConfirmOpen, onCloseTask, onCloseSuperResolve, onClosePreview, onCancelClear, onConfirmClear }: CanvasProjectStatusDialogsProps) {
|
||||
const config = useEffectiveConfig();
|
||||
return (
|
||||
<>
|
||||
<Modal title="任务详情" open={Boolean(task)} footer={null} width={760} onCancel={onCloseTask}>
|
||||
@@ -29,7 +31,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
<TaskDetailItem label="当前阶段" value={task.stage || taskStatusText(task.status)} />
|
||||
<TaskDetailItem label="进度" value={`${task.progress ?? 0}%`} />
|
||||
<TaskDetailItem label="模型" value={task.model || "默认模型"} />
|
||||
<TaskDetailItem label="模型" value={task.model ? modelDisplayName(config, task.model) : "默认模型"} />
|
||||
<TaskDetailItem label="任务 ID" value={task.id} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -154,11 +154,12 @@ export default function CanvasPage() {
|
||||
/>
|
||||
|
||||
<ListToolbar
|
||||
className="canvas-library-toolbar"
|
||||
active={Boolean(keyword || projectFilter !== "all" || sort !== "updated")}
|
||||
trailing={<span className="text-xs tabular-nums text-foreground/42">显示 {visibleProjects.length} / {filteredProjects.length}</span>}
|
||||
onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }}
|
||||
>
|
||||
<div className="min-w-[220px] flex-1 sm:max-w-[380px]">
|
||||
<div className="canvas-library-search min-w-[220px] flex-[1_1_420px]">
|
||||
<Input allowClear className="w-full" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
|
||||
</div>
|
||||
<Select aria-label="按所属项目筛选" className="w-[168px]" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部项目", value: "all" }, { label: "自由画布", value: "independent" }, ...(projectQuery.data?.projects || []).map(({ project }) => ({ label: project.name, value: project.id }))]} />
|
||||
@@ -178,7 +179,7 @@ export default function CanvasPage() {
|
||||
{!hydrated ? (
|
||||
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
|
||||
) : visibleProjects.length ? (
|
||||
<CollectionGrid className="sm:grid-cols-[repeat(auto-fill,minmax(272px,320px))] sm:justify-start">
|
||||
<CollectionGrid className="canvas-library-grid">
|
||||
{visibleProjects.map((project) => (
|
||||
<CanvasProjectCard key={project.id} project={project} projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined} />
|
||||
))}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudi
|
||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
@@ -132,7 +132,7 @@ export function useCanvasGenerationExecutor({
|
||||
mode === "video" && supportsVideoReferenceAudio(generationConfig),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorDetails = error instanceof Error ? error.message : "生成任务准备失败";
|
||||
const errorDetails = generationErrorMessage(error);
|
||||
if (isPreparingEmptyImage) {
|
||||
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails: controller.signal.aborted ? undefined : errorDetails } } : node)));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { compositeEmotionImage, emotionGenerationSize } from "@/lib/canvas/canva
|
||||
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
|
||||
import { captureVideoLastFrame } from "@/lib/canvas/canvas-video-frame";
|
||||
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { storeGeneratedVideo } from "@/services/api/video";
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
@@ -349,7 +350,7 @@ export function useCanvasMediaTools({
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item));
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = error instanceof Error ? error.message : "局部修改失败";
|
||||
const details = generationErrorMessage(error);
|
||||
message.error(details);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally {
|
||||
@@ -402,7 +403,7 @@ export function useCanvasMediaTools({
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item));
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = error instanceof Error ? error.message : "生成失败";
|
||||
const details = generationErrorMessage(error);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally {
|
||||
finishGenerationRequest(childId, controller);
|
||||
@@ -455,7 +456,7 @@ export function useCanvasMediaTools({
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt: payload.prompt, ...generationMetadata, emotionEdit } } : item));
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = error instanceof Error ? error.message : "表情生成失败";
|
||||
const details = generationErrorMessage(error);
|
||||
message.error(details);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
} finally { finishGenerationRequest(childId, controller); setRunningNodeId(null); }
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
storyboardRowsFromTask,
|
||||
} from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
|
||||
import { modelOptionName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasConnection,
|
||||
@@ -60,14 +61,14 @@ export function useCanvasStoryboard({
|
||||
if (!count) return resolve(false);
|
||||
modal.confirm({
|
||||
title: `确认提交 ${count} 个${taskLabel}任务`,
|
||||
content: `任务数:${count};模型:${modelOptionName(model) || model}。当前没有可用价格数据,将提交 ${count} 个外部模型任务。`,
|
||||
content: `任务数:${count};模型:${modelDisplayName(effectiveConfig, model)}。当前没有可用价格数据,将提交 ${count} 个外部模型任务。`,
|
||||
okText: "确认生成",
|
||||
cancelText: "取消",
|
||||
centered: true,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
}), [modal]);
|
||||
}), [effectiveConfig, modal]);
|
||||
|
||||
const updateScriptRows = useCallback((nodeId: string, updater: (rows: StoryboardRow[]) => StoryboardRow[]) => {
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? {
|
||||
@@ -159,7 +160,7 @@ export function useCanvasStoryboard({
|
||||
message.success(`已生成 ${result.rows.length} 个镜头`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : "脚本生成失败";
|
||||
const details = generationErrorMessage(error);
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : node));
|
||||
message.error(details);
|
||||
return false;
|
||||
|
||||
162
web/src/pages/create/creation-references.ts
Normal file
162
web/src/pages/create/creation-references.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { buildSkillMentionReferences, renderSkillPrompt } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
type MentionableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audio" }>;
|
||||
|
||||
export type CreationReference = CanvasResourceReference & {
|
||||
asset?: MentionableAsset;
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
export function buildCreationMentionReferences(assets: Asset[], skills: Skill[], mode: CreationMode, attachments: ReferenceImage[] = [], snapshots: CreationReference[] = []) {
|
||||
const attachmentReferences = attachments.map(attachmentReference);
|
||||
const assetReferences = assets
|
||||
.filter((asset): asset is MentionableAsset => isMentionableAsset(asset) && supportsMode(asset.kind, mode))
|
||||
.map(assetReference);
|
||||
const skillReferences = buildSkillMentionReferences(skills) as CreationReference[];
|
||||
const current = [...attachmentReferences, ...assetReferences, ...skillReferences];
|
||||
const currentIDs = new Set(current.map((reference) => reference.id));
|
||||
return [...current, ...snapshots.filter((reference) => !currentIDs.has(reference.id))].map((reference) => ({ ...reference, active: true }));
|
||||
}
|
||||
|
||||
export function selectedCreationReferences(prompt: string, references: CreationReference[]) {
|
||||
return references.filter((reference) => prompt.includes(canvasResourceMentionToken(reference)));
|
||||
}
|
||||
|
||||
export function displayCreationPrompt(prompt: string, references: CreationReference[]) {
|
||||
return references.reduce((value, reference) => value.split(canvasResourceMentionToken(reference)).join(`@${reference.label}`), prompt);
|
||||
}
|
||||
|
||||
export function expandCreationPrompt(prompt: string, references: CreationReference[], attachments: ReferenceImage[] = []) {
|
||||
const visiblePrompt = displayCreationPrompt(prompt, references).trim();
|
||||
if (!references.length) return visiblePrompt;
|
||||
|
||||
const contexts: string[] = [];
|
||||
const mediaMappings: string[] = [];
|
||||
const attachmentPositions = new Map(attachments.map((attachment, index) => [attachment.id, index + 1]));
|
||||
const counts = { image: attachments.length, video: 0, audio: 0 };
|
||||
references.forEach((reference) => {
|
||||
if (reference.kind === "skill" && reference.skill) {
|
||||
contexts.push(renderSkillPrompt(reference.skill));
|
||||
return;
|
||||
}
|
||||
if (reference.attachmentId) {
|
||||
const position = attachmentPositions.get(reference.attachmentId);
|
||||
mediaMappings.push(`- @${reference.label}:参考图片 ${position || 1}`);
|
||||
return;
|
||||
}
|
||||
const asset = reference.asset;
|
||||
if (!asset) return;
|
||||
if (asset.kind === "text") {
|
||||
contexts.push(`【引用资源:${reference.label}】\n${asset.data.content}`);
|
||||
return;
|
||||
}
|
||||
counts[asset.kind] += 1;
|
||||
const label = asset.kind === "image" ? "参考图片" : asset.kind === "video" ? "参考视频" : "参考音频";
|
||||
mediaMappings.push(`- @${reference.label}:${label} ${counts[asset.kind]}`);
|
||||
});
|
||||
|
||||
if (mediaMappings.length) contexts.push(`【资源对应关系】\n${mediaMappings.join("\n")}`);
|
||||
return [...contexts, `【创作要求】\n${visiblePrompt}`].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
export function creationReferenceImages(references: CreationReference[]): ReferenceImage[] {
|
||||
return references.flatMap((reference) => {
|
||||
const asset = reference.asset;
|
||||
if (!asset || asset.kind !== "image") return [];
|
||||
return [{
|
||||
id: asset.id,
|
||||
name: asset.title,
|
||||
type: asset.data.mimeType || "image/png",
|
||||
dataUrl: asset.data.dataUrl,
|
||||
url: asset.data.dataUrl,
|
||||
storageKey: asset.data.storageKey,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function creationReferenceVideos(references: CreationReference[]): ReferenceVideo[] {
|
||||
return references.flatMap((reference) => {
|
||||
const asset = reference.asset;
|
||||
if (!asset || asset.kind !== "video") return [];
|
||||
return [{
|
||||
id: asset.id,
|
||||
name: asset.title,
|
||||
type: asset.data.mimeType || "video/mp4",
|
||||
url: asset.data.url,
|
||||
storageKey: asset.data.storageKey,
|
||||
bytes: asset.data.bytes,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
durationMs: asset.data.durationMs,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function creationReferenceAudios(references: CreationReference[]): ReferenceAudio[] {
|
||||
return references.flatMap((reference) => {
|
||||
const asset = reference.asset;
|
||||
if (!asset || asset.kind !== "audio") return [];
|
||||
return [{
|
||||
id: asset.id,
|
||||
name: asset.title,
|
||||
type: asset.data.mimeType || "audio/mpeg",
|
||||
url: asset.data.url,
|
||||
storageKey: asset.data.storageKey,
|
||||
durationMs: asset.data.durationMs,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function creationReferenceMetadata(references: CreationReference[]) {
|
||||
return {
|
||||
assetIds: references.flatMap((reference) => reference.asset ? [reference.asset.id] : []),
|
||||
skillIds: references.flatMap((reference) => reference.skill?.skill_id ? [reference.skill.skill_id] : []),
|
||||
};
|
||||
}
|
||||
|
||||
function assetReference(asset: MentionableAsset): CreationReference {
|
||||
const kindLabel = asset.kind === "image" ? "图片资源" : asset.kind === "video" ? "视频资源" : asset.kind === "audio" ? "音频资源" : "文本资源";
|
||||
const previewUrl = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : asset.kind === "video" ? asset.data.url : "");
|
||||
return {
|
||||
id: `asset:${asset.id}`,
|
||||
nodeId: `asset:${asset.id}`,
|
||||
kind: asset.kind,
|
||||
label: asset.title.trim() || kindLabel,
|
||||
title: kindLabel,
|
||||
previewUrl: previewUrl || undefined,
|
||||
storageKey: asset.kind === "text" ? undefined : asset.data.storageKey,
|
||||
text: asset.kind === "text" ? asset.data.content : undefined,
|
||||
active: true,
|
||||
asset,
|
||||
};
|
||||
}
|
||||
|
||||
function attachmentReference(attachment: ReferenceImage, index: number): CreationReference {
|
||||
const label = attachment.name.replace(/\.[^.]+$/, "").trim() || `上传图片${index + 1}`;
|
||||
return {
|
||||
id: `upload:${attachment.id}`,
|
||||
nodeId: `upload:${attachment.id}`,
|
||||
kind: "image",
|
||||
label,
|
||||
title: "本次上传",
|
||||
previewUrl: attachment.dataUrl || attachment.url,
|
||||
storageKey: attachment.storageKey,
|
||||
active: true,
|
||||
attachmentId: attachment.id,
|
||||
};
|
||||
}
|
||||
|
||||
function isMentionableAsset(asset: Asset): asset is MentionableAsset {
|
||||
return asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio";
|
||||
}
|
||||
|
||||
function supportsMode(kind: MentionableAsset["kind"], mode: CreationMode) {
|
||||
if (kind === "text" || kind === "image") return true;
|
||||
return mode === "video";
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type RefObject } from "react";
|
||||
import localforage from "localforage";
|
||||
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowUp, Check, ChevronDown, Clock3, Download, Film, History, Image as ImageIcon, Maximize2, MessageSquareText, Plus, RefreshCw, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import { modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { buildCreationMentionReferences, creationReferenceAudios, creationReferenceImages, creationReferenceMetadata, creationReferenceVideos, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
||||
@@ -27,6 +33,7 @@ type CreationMessage = {
|
||||
resultUrls?: string[];
|
||||
error?: string;
|
||||
attachments?: CreationAttachment[];
|
||||
references?: CreationReference[];
|
||||
settings?: CreationSettings;
|
||||
};
|
||||
type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] };
|
||||
@@ -63,12 +70,15 @@ export default function CreatePage() {
|
||||
const { message: toast } = App.useApp();
|
||||
const config = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [conversations, setConversations] = useState<CreationConversation[]>([]);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [mode, setMode] = useState<CreationMode>("video");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
||||
const [draftReferences, setDraftReferences] = useState<CreationReference[]>([]);
|
||||
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
|
||||
const [ratio, setRatio] = useState("16:9");
|
||||
const [seconds, setSeconds] = useState("6");
|
||||
const [quality, setQuality] = useState("auto");
|
||||
@@ -87,6 +97,7 @@ export default function CreatePage() {
|
||||
[activeId, conversations],
|
||||
);
|
||||
const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel;
|
||||
const mentionReferences = useMemo(() => buildCreationMentionReferences(assets, addedSkills, mode, attachments, draftReferences), [addedSkills, assets, attachments, draftReferences, mode]);
|
||||
const isEmpty = !activeConversation?.messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +119,18 @@ export default function CreatePage() {
|
||||
if (hydrated) void localforage.setItem(STORAGE_KEY, conversations);
|
||||
}, [conversations, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
listAddedSkills().then(({ skills }) => {
|
||||
if (!cancelled) setAddedSkills(skills);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setAddedSkills([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followLatestMessageRef.current) return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
@@ -130,6 +153,10 @@ export default function CreatePage() {
|
||||
}, [updateActive]);
|
||||
|
||||
const selectMode = (next: CreationMode) => {
|
||||
if (next !== "video") {
|
||||
const unsupported = mentionReferences.filter((reference) => reference.kind === "video" || reference.kind === "audio");
|
||||
if (unsupported.length) setPrompt((current) => removeReferenceTokens(current, unsupported));
|
||||
}
|
||||
setMode(next);
|
||||
const nextModels = selectableModelsByCapability(config, next);
|
||||
const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel;
|
||||
@@ -152,6 +179,12 @@ export default function CreatePage() {
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const reference = mentionReferences.find((item) => item.attachmentId === id);
|
||||
setAttachments((current) => current.filter((item) => item.id !== id));
|
||||
if (reference) setPrompt((current) => removeReferenceTokens(current, [reference]));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = prompt.trim();
|
||||
if (!text || busy || !activeConversation) return;
|
||||
@@ -160,8 +193,14 @@ export default function CreatePage() {
|
||||
return;
|
||||
}
|
||||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||||
const references = selectedCreationReferences(text, mentionReferences);
|
||||
const expandedPrompt = expandCreationPrompt(text, references, attachments);
|
||||
const referencedImages = creationReferenceImages(references);
|
||||
const referencedVideos = creationReferenceVideos(references);
|
||||
const referencedAudios = creationReferenceAudios(references);
|
||||
const referenceMetadata = creationReferenceMetadata(references);
|
||||
followLatestMessageRef.current = true;
|
||||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, settings });
|
||||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
|
||||
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
|
||||
updateActive((conversation) => ({
|
||||
...conversation,
|
||||
@@ -171,6 +210,7 @@ export default function CreatePage() {
|
||||
}));
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setDraftReferences([]);
|
||||
setBusy(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -179,8 +219,8 @@ export default function CreatePage() {
|
||||
if (mode === "text") {
|
||||
const history = [...(activeConversation.messages || []), userMessage].map((item) => ({
|
||||
role: item.role,
|
||||
content: item.role === "user" && item.attachments?.length
|
||||
? [{ type: "text" as const, text: item.content }, ...item.attachments.map((attachment) => ({ type: "image_url" as const, image_url: { url: attachment.dataUrl || attachment.url || attachment.previewUrl } }))]
|
||||
content: item.role === "user"
|
||||
? buildTextMessageContent(item)
|
||||
: item.content,
|
||||
}));
|
||||
await requestImageQuestion(requestConfig, history, (delta) => updateAssistant(assistantMessage.id, (item) => ({ ...item, content: item.content + delta })), { signal: controller.signal });
|
||||
@@ -188,11 +228,11 @@ export default function CreatePage() {
|
||||
const taskCount = Math.max(1, Math.min(15, Math.floor(Number(count) || 1)));
|
||||
const settled = await runBackendGenerationTaskBatch({
|
||||
mode: "image",
|
||||
prompt: text,
|
||||
prompt: expandedPrompt,
|
||||
config: { ...requestConfig, count: "1" },
|
||||
referenceImages: attachments,
|
||||
referenceImages: [...attachments, ...referencedImages],
|
||||
signal: controller.signal,
|
||||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id },
|
||||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
|
||||
count: taskCount,
|
||||
});
|
||||
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
@@ -207,11 +247,13 @@ export default function CreatePage() {
|
||||
} else {
|
||||
const result = await runBackendGenerationTask({
|
||||
mode: "video",
|
||||
prompt: text,
|
||||
prompt: expandedPrompt,
|
||||
config: requestConfig,
|
||||
referenceImages: attachments,
|
||||
referenceImages: [...attachments, ...referencedImages],
|
||||
referenceVideos: referencedVideos,
|
||||
referenceAudios: referencedAudios,
|
||||
signal: controller.signal,
|
||||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video" },
|
||||
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length || referencedImages.length ? "image_to_video" : "text_to_video", ...referenceMetadata },
|
||||
});
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const videoUrl = result.video.dataUrl;
|
||||
@@ -223,7 +265,7 @@ export default function CreatePage() {
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "cancelled", content: "已停止" }));
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "生成失败,请重试";
|
||||
const message = generationErrorMessage(error);
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "error", error: message, content: "生成失败" }));
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -238,6 +280,7 @@ export default function CreatePage() {
|
||||
setActiveId(next.id);
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setDraftReferences([]);
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
@@ -246,15 +289,48 @@ export default function CreatePage() {
|
||||
setActiveId(conversation.id);
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setDraftReferences([]);
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
const retryMessage = (item: CreationMessage, index: number) => {
|
||||
const restoreMessageDraft = (item: CreationMessage) => {
|
||||
const nextMode = item.mode || "text";
|
||||
const nextSettings = item.settings;
|
||||
setMode(nextMode);
|
||||
setPrompt(item.content);
|
||||
setAttachments(item.attachments ? [...item.attachments] : []);
|
||||
setDraftReferences(item.references ? [...item.references] : []);
|
||||
if (item.model) updateConfig(nextMode === "text" ? "textModel" : nextMode === "image" ? "imageModel" : "videoModel", item.model);
|
||||
if (!nextSettings) return;
|
||||
setRatio(nextSettings.ratio);
|
||||
setSeconds(nextSettings.seconds);
|
||||
setQuality(nextSettings.quality);
|
||||
setVideoQuality(nextSettings.videoQuality);
|
||||
setCount(nextSettings.count);
|
||||
};
|
||||
|
||||
const retryFailedMessage = (item: CreationMessage, index: number) => {
|
||||
const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item;
|
||||
if (!previous?.content) return;
|
||||
setMode(previous.mode || "text");
|
||||
setPrompt(previous.content);
|
||||
setAttachments(previous.attachments || []);
|
||||
if (!previous?.content || busy) return;
|
||||
followLatestMessageRef.current = true;
|
||||
restoreMessageDraft(previous);
|
||||
const removedIds = new Set([item.id, previous.id]);
|
||||
updateActive((conversation) => {
|
||||
const messages = conversation.messages.filter((message) => !removedIds.has(message.id));
|
||||
const firstPrompt = messages.find((message) => message.role === "user")?.content.trim();
|
||||
return {
|
||||
...conversation,
|
||||
title: firstPrompt ? firstPrompt.slice(0, 24) : "新创作",
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const createVariant = (item: CreationMessage, index: number) => {
|
||||
const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item;
|
||||
if (!previous?.content || busy) return;
|
||||
restoreMessageDraft(previous);
|
||||
};
|
||||
|
||||
if (!hydrated || !activeConversation) return <div className="grid h-full place-items-center"><Spin /></div>;
|
||||
@@ -271,7 +347,8 @@ export default function CreatePage() {
|
||||
setPrompt,
|
||||
busy,
|
||||
attachments,
|
||||
onRemoveAttachment: (id: string) => setAttachments((current) => current.filter((item) => item.id !== id)),
|
||||
references: mentionReferences,
|
||||
onRemoveAttachment: removeAttachment,
|
||||
fileInputRef,
|
||||
onFileChange: handleFileChange,
|
||||
onModeChange: selectMode,
|
||||
@@ -293,21 +370,23 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
return <>
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-home creation-scrollbar relative flex h-full min-h-0 flex-col overflow-y-scroll overscroll-contain">
|
||||
<div className="creation-home relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="creation-top-actions">
|
||||
{!isEmpty ? <Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-top-action" onClick={startNewConversation}><Plus /></button></Tooltip> : null}
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
|
||||
</div>
|
||||
{isEmpty ? <section className="creation-empty-workspace">
|
||||
<CreationIntro mode={mode} />
|
||||
<div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>
|
||||
</section> : <>
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView key={item.id} item={item} onRetry={() => retryMessage(item, index)} />)}</div></section>
|
||||
<section className="creation-thread-composer">
|
||||
<CreationComposer {...composerProps} variant="thread" />
|
||||
</section>
|
||||
</>}
|
||||
</main>
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-scrollbar flex h-full min-h-0 flex-col overflow-y-scroll overscroll-contain">
|
||||
{isEmpty ? <section className="creation-empty-workspace">
|
||||
<CreationIntro mode={mode} />
|
||||
<div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>
|
||||
</section> : <>
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView key={item.id} item={item} modelName={item.model ? modelDisplayName(config, item.model) : ""} onRetryFailure={() => retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} />)}</div></section>
|
||||
<section className="creation-thread-composer">
|
||||
<CreationComposer {...composerProps} variant="thread" />
|
||||
</section>
|
||||
</>}
|
||||
</main>
|
||||
</div>
|
||||
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
|
||||
</>;
|
||||
}
|
||||
@@ -323,7 +402,7 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec
|
||||
<button type="button" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
|
||||
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
|
||||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||||
<span className="creation-history-snippet">{latest?.content.trim() || "还没有开始创作"}</span>
|
||||
<span className="creation-history-snippet">{latest ? displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作" : "还没有开始创作"}</span>
|
||||
</button>
|
||||
</li>;
|
||||
})}
|
||||
@@ -331,21 +410,28 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
function CreationMessageView({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) {
|
||||
if (item.role === "user") return <div className="creation-user-message"><div>{item.content}</div>{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => <img key={attachment.id} src={attachment.previewUrl} alt={attachment.name} />)}</div> : null}</div>;
|
||||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
if (item.role === "user") return <div className="creation-user-message"><div>{displayCreationPrompt(item.content, item.references || [])}</div>{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => <img key={attachment.id} src={attachment.previewUrl} alt={attachment.name} />)}</div> : null}</div>;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : "";
|
||||
return <article className="creation-assistant-message"><div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><span>{modeLabels[mode]}</span>{item.model ? <span className="creation-message-model">{modelOptionName(item.model)}</span> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>{mode === "text" ? <div className="creation-message-content">{item.content ? <ReactMarkdown>{item.content}</ReactMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetry={onRetry} />}{item.error ? <div className="creation-message-error"><span>{item.error}</span><button type="button" onClick={onRetry}><RefreshCw />重新生成</button></div> : null}</article>;
|
||||
return <article className="creation-assistant-message"><div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><span>{modeLabels[mode]}</span>{modelName ? <span className="creation-message-model">{modelName}</span> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>{mode === "text" ? <div className="creation-message-content">{item.content ? <ReactMarkdown>{item.content}</ReactMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} />}{item.error ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) {
|
||||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
||||
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
||||
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && (reference.kind === "image" || reference.kind === "video") ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
|
||||
})}</div>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const resultUrls = item.resultUrls;
|
||||
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
|
||||
if (item.status === "pending") return <div className="creation-media-pending"><Spin size="small" />正在生成{item.mode === "video" ? "视频" : "图片"}…</div>;
|
||||
if (!resultUrls?.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetry}>重试</button></div>;
|
||||
return <div className="creation-media-result">{item.mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(resultUrls[0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}<div className="creation-media-actions"><span>{item.mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onRetry}><RefreshCw />生成变体</button><Link to="/canvas">添加到画布</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div><Modal open={Boolean(previewUrl)} title={null} footer={null} centered destroyOnHidden width={previewType === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={() => setPreviewUrl("")} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{previewUrl ? previewType === "video" ? <video controls autoPlay className="creation-media-preview-video" src={previewUrl} /> : <img className="creation-media-preview-image" src={previewUrl} alt="生成图片预览" /> : null}</Modal></div>;
|
||||
if (!resultUrls?.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
return <div className="creation-media-result">{item.mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(resultUrls[0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}<div className="creation-media-actions"><span>{item.mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成变体</button><Link to="/canvas">添加到画布</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div><Modal open={Boolean(previewUrl)} title={null} footer={null} centered destroyOnHidden width={previewType === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={() => setPreviewUrl("")} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{previewUrl ? previewType === "video" ? <video controls autoPlay className="creation-media-preview-video" src={previewUrl} /> : <img className="creation-media-preview-image" src={previewUrl} alt="生成图片预览" /> : null}</Modal></div>;
|
||||
}
|
||||
|
||||
type ComposerProps = {
|
||||
@@ -355,6 +441,7 @@ type ComposerProps = {
|
||||
setPrompt: (value: string) => void;
|
||||
busy: boolean;
|
||||
attachments: CreationAttachment[];
|
||||
references: CreationReference[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -387,9 +474,9 @@ function CreationComposer(props: ComposerProps) {
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept="image/*" multiple onChange={props.onFileChange} />
|
||||
<Tooltip title="添加参考图片"><button type="button" className={`creation-chat-reference ${props.variant === "empty" ? "is-paper" : ""}`} onClick={() => props.fileInputRef.current?.click()} disabled={props.busy} aria-label="添加参考图片"><Plus />{props.variant === "empty" ? <span>参考内容</span> : null}</button></Tooltip>
|
||||
<Tooltip title="添加参考图片"><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
|
||||
<div className="creation-chat-editor">
|
||||
<textarea value={props.prompt} onChange={(event) => props.setPrompt(event.target.value)} onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); props.onSubmit(); } }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} disabled={props.busy} />
|
||||
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用资源或技能" spellCheck disabled={props.busy} />
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment"><img src={item.previewUrl} alt={item.name} /><button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -475,6 +562,17 @@ function conversationPreviewMessage(conversation: CreationConversation) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildTextMessageContent(item: CreationMessage) {
|
||||
const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []);
|
||||
const images = [...(item.attachments || []), ...creationReferenceImages(item.references || [])];
|
||||
if (!images.length) return content;
|
||||
return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))];
|
||||
}
|
||||
|
||||
function removeReferenceTokens(value: string, references: CreationReference[]) {
|
||||
return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value);
|
||||
}
|
||||
|
||||
function conversationTimestamp(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
|
||||
@@ -70,7 +70,6 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[10px] text-foreground/45">
|
||||
<span className="max-w-full truncate font-mono">{modelOptionName(model)}</span>
|
||||
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
|
||||
<span className="rounded border border-border/70 px-1 py-px text-[9px]">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[9px] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-
|
||||
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
@@ -287,7 +287,7 @@ export default function TasksPage() {
|
||||
title: "类型|模型",
|
||||
width: 180,
|
||||
responsive: ["md"],
|
||||
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[10px] text-foreground/42" title={formatModelName(task)}>{formatModelName(task)}</div></div>,
|
||||
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[10px] text-foreground/42" title={formatModelName(effectiveConfig, task)}>{formatModelName(effectiveConfig, task)}</div></div>,
|
||||
},
|
||||
{
|
||||
title: "任务名称",
|
||||
@@ -303,7 +303,7 @@ export default function TasksPage() {
|
||||
{task.status === "failed" || task.status === "cancelled" ? taskAttentionReason(task) : task.stage || statusLabel[task.status]}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 md:hidden">
|
||||
<div className="truncate text-[10px] text-foreground/45">{formatTaskKind(task)} · {formatModelName(task)}</div>
|
||||
<div className="truncate text-[10px] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
|
||||
<div className="truncate text-[10px] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-[11px]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span><TaskBilling billing={task.billing} /></div>
|
||||
@@ -369,7 +369,7 @@ export default function TasksPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[actingId, canvasById, domainProjectNameById, openTaskDetail],
|
||||
[actingId, canvasById, domainProjectNameById, effectiveConfig, openTaskDetail],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -442,7 +442,7 @@ export default function TasksPage() {
|
||||
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
|
||||
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
|
||||
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
|
||||
<InfoItem label="模型" value={formatModelName(detailTask)} />
|
||||
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
|
||||
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
|
||||
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
|
||||
</div>
|
||||
@@ -601,7 +601,7 @@ function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
|
||||
return <div className="text-right"><div className="text-xs font-medium tabular-nums text-foreground/78">{amount}</div><div className={`mt-1 text-[10px] ${billing.status === "uncertain" ? "text-amber-600 dark:text-amber-300" : "text-foreground/38"}`}>{note}</div></div>;
|
||||
}
|
||||
|
||||
function formatModelName(task: GenerationTask) {
|
||||
function formatModelName(config: AiConfig, task: GenerationTask) {
|
||||
const raw = (task.model || task.provider || "").trim();
|
||||
const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw;
|
||||
|
||||
@@ -610,7 +610,7 @@ function formatModelName(task: GenerationTask) {
|
||||
if (model === "workflow-router") return "工作流路由";
|
||||
if (model === "internal-agent") return "内置工作流";
|
||||
if (model === "openai-compatible") return "OpenAI 兼容接口";
|
||||
return model;
|
||||
return modelDisplayName(config, raw);
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { checkinCredits, getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { modelDisplayName, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type LedgerFilter = "all" | "income" | "consume" | "refund";
|
||||
|
||||
@@ -25,6 +26,7 @@ export default function WalletPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const config = useEffectiveConfig();
|
||||
const [wallet, setWallet] = useState<WalletSummary | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [filter, setFilter] = useState<LedgerFilter>("all");
|
||||
@@ -99,8 +101,8 @@ export default function WalletPage() {
|
||||
width: 400,
|
||||
ellipsis: true,
|
||||
render: (_, entry) => (
|
||||
<div className="min-w-0 max-w-full overflow-hidden" title={[entry.model || ledgerTitle(entry), [sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ")].filter(Boolean).join("\n")}>
|
||||
<div className="truncate font-medium">{entry.model || ledgerTitle(entry)}</div>
|
||||
<div className="min-w-0 max-w-full overflow-hidden" title={[ledgerModelName(config, entry), [sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ")].filter(Boolean).join("\n")}>
|
||||
<div className="truncate font-medium">{ledgerModelName(config, entry)}</div>
|
||||
<div className="mt-1 truncate text-xs text-foreground/50">{[sceneLabel(entry.scene), entry.note].filter(Boolean).join(" · ") || "无补充说明"}</div>
|
||||
</div>
|
||||
),
|
||||
@@ -206,7 +208,7 @@ export default function WalletPage() {
|
||||
<Table className="app-data-table wallet-ledger-table" rowKey="id" size="middle" loading={loading} columns={columns} dataSource={entries} pagination={false} tableLayout="fixed" scroll={{ x: 990 }} />
|
||||
</TableSurface>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border/70 bg-background">{entries.length ? entries.map((entry) => <LedgerMobileRow key={entry.id} entry={entry} />) : <WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />}</div>
|
||||
<div className="overflow-hidden rounded-md border border-border/70 bg-background">{entries.length ? entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />) : <WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />}</div>
|
||||
)}
|
||||
<PaginationBar
|
||||
current={page}
|
||||
@@ -237,7 +239,7 @@ function BalanceMetric({ label, description, value, icon }: { label: string; des
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerMobileRow({ entry }: { entry: CreditLedgerEntry }) {
|
||||
function LedgerMobileRow({ config, entry }: { config: AiConfig; entry: CreditLedgerEntry }) {
|
||||
const meta = ledgerTypeMeta(entry.type);
|
||||
return (
|
||||
<article className="flex items-start gap-3 border-b border-border px-4 py-4 last:border-b-0">
|
||||
@@ -245,7 +247,7 @@ function LedgerMobileRow({ entry }: { entry: CreditLedgerEntry }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{entry.model || ledgerTitle(entry)}</div>
|
||||
<div className="truncate text-sm font-medium">{ledgerModelName(config, entry)}</div>
|
||||
<div className="mt-1 text-xs text-foreground/45">{formatTime(entry.createdAt)}</div>
|
||||
</div>
|
||||
<CreditDelta value={entry.amountMicrocredits} />
|
||||
@@ -298,6 +300,10 @@ function ledgerTitle(entry: CreditLedgerEntry) {
|
||||
return entry.note || "积分调整";
|
||||
}
|
||||
|
||||
function ledgerModelName(config: AiConfig, entry: CreditLedgerEntry) {
|
||||
return entry.model ? modelDisplayName(config, entry.model) : ledgerTitle(entry);
|
||||
}
|
||||
|
||||
function sceneLabel(scene?: string) {
|
||||
const labels: Record<string, string> = { image: "图片生成", text: "文本生成", video: "视频生成", audio: "音频生成", storyboard: "分镜生成" };
|
||||
return scene ? labels[scene] || "其他场景" : "";
|
||||
|
||||
@@ -333,7 +333,9 @@ export function modelOptionName(value: string) {
|
||||
export function modelDisplayName(config: AiConfig, value: string) {
|
||||
const model = modelOptionName(value);
|
||||
const channel = resolveModelChannel(config, value);
|
||||
return channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim() || model;
|
||||
const displayName = channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim();
|
||||
if (displayName) return displayName;
|
||||
return channel.scope === "system" ? "系统模型" : model;
|
||||
}
|
||||
|
||||
export function modelOptionLabel(config: AiConfig, value: string) {
|
||||
|
||||
@@ -2627,13 +2627,80 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 画布文件采用“缩略图 + 元信息”结构,避免整张卡片形成笨重的白色容器。 */
|
||||
.canvas-library-search {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.canvas-library-page .app-page-header {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.canvas-library-toolbar {
|
||||
min-height: 56px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 66%, transparent);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.canvas-library-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
|
||||
align-items: start;
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
.canvas-library-page .app-pagination-bar {
|
||||
position: static;
|
||||
margin-top: 28px;
|
||||
border-color: var(--workspace-border);
|
||||
background: transparent;
|
||||
padding: 16px 0 8px;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* 画布文件是单个可点击对象,缩略图与元信息共用一个容器,避免视觉割裂。 */
|
||||
.app-canvas-project-card {
|
||||
min-width: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-canvas-project-preview {
|
||||
.app-canvas-project-card.is-library {
|
||||
border-color: var(--workspace-border);
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 76%, transparent);
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 0 260px;
|
||||
transition:
|
||||
border-color var(--motion-instant) ease-out,
|
||||
background-color var(--motion-instant) ease-out,
|
||||
box-shadow var(--motion-instant) ease-out,
|
||||
transform var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-library .app-canvas-project-preview {
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
background: var(--workspace-surface-strong);
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-library:hover,
|
||||
.app-canvas-project-card.is-library:focus-within {
|
||||
border-color: color-mix(in srgb, var(--foreground) 24%, var(--workspace-border));
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 90%, transparent);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-library.is-selected {
|
||||
border-color: var(--workspace-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-library .app-canvas-project-body {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-recent .app-canvas-project-preview {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: 8px;
|
||||
@@ -2644,17 +2711,12 @@
|
||||
box-shadow var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.app-canvas-project-card:hover .app-canvas-project-preview,
|
||||
.app-canvas-project-card:focus-within .app-canvas-project-preview {
|
||||
.app-canvas-project-card.is-recent:hover .app-canvas-project-preview,
|
||||
.app-canvas-project-card.is-recent:focus-within .app-canvas-project-preview {
|
||||
border-color: color-mix(in srgb, var(--foreground) 24%, var(--workspace-border));
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-canvas-project-card.is-selected .app-canvas-project-preview {
|
||||
border-color: var(--workspace-accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.app-canvas-project-checkbox {
|
||||
accent-color: var(--workspace-accent);
|
||||
}
|
||||
@@ -2665,15 +2727,30 @@
|
||||
color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground));
|
||||
}
|
||||
|
||||
.dark .app-canvas-project-preview {
|
||||
.dark .app-canvas-project-card.is-library:hover,
|
||||
.dark .app-canvas-project-card.is-library:focus-within {
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.dark .app-canvas-project-card.is-recent .app-canvas-project-preview {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.dark .app-canvas-project-card:hover .app-canvas-project-preview,
|
||||
.dark .app-canvas-project-card:focus-within .app-canvas-project-preview {
|
||||
.dark .app-canvas-project-card.is-recent:hover .app-canvas-project-preview,
|
||||
.dark .app-canvas-project-card.is-recent:focus-within .app-canvas-project-preview {
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.canvas-library-search {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.canvas-library-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-data-table .ant-table-pagination {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
@@ -4261,6 +4338,26 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
|
||||
|
||||
.creation-chat-editor textarea::placeholder { color: var(--creation-muted); opacity: .72; }
|
||||
|
||||
.creation-chat-mention-container { min-height: 76px; }
|
||||
.creation-chat-mention-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--creation-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.creation-chat-mention-editor:empty::before { color: var(--creation-muted); opacity: .72; }
|
||||
.creation-chat-mention-editor[aria-disabled="true"],
|
||||
.creation-chat-mention-editor:disabled { cursor: wait; opacity: .55; }
|
||||
|
||||
.creation-chat-dock {
|
||||
display: flex;
|
||||
min-height: 46px;
|
||||
@@ -4523,22 +4620,27 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
|
||||
}
|
||||
|
||||
.creation-chat-reference.is-paper {
|
||||
width: 56px;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
width: 58px;
|
||||
height: 74px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-self: start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
gap: 7px;
|
||||
border: 1px dashed rgba(255, 255, 255, .09);
|
||||
border-radius: 4px;
|
||||
background: #242730;
|
||||
color: #c7ccd4;
|
||||
color: #9097a1;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
.creation-chat-reference.is-paper span { display: block; font-size: 9px; font-weight: 600; }
|
||||
.creation-chat-reference.is-paper svg { width: 21px; height: 21px; }
|
||||
.creation-chat-reference.is-paper span { display: block; font-size: 10px; font-weight: 520; line-height: 1; white-space: nowrap; }
|
||||
.creation-chat-reference.is-paper:hover:not(:disabled) { background: #2b2e37; color: #f1f3f5; transform: rotate(-4deg) translateY(-2px); }
|
||||
.creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; }
|
||||
.creation-chat-reference.is-paper:active:not(:disabled) { transform: rotate(-4deg) translateY(0) scale(.97); }
|
||||
.creation-chat-reference.is-paper:focus-visible { outline: 2px solid color-mix(in srgb, #83d6ec 68%, transparent); outline-offset: 3px; }
|
||||
.creation-chat-composer .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; }
|
||||
|
||||
.creation-custom-value {
|
||||
display: flex;
|
||||
@@ -4586,7 +4688,7 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
|
||||
.creation-duration-choices button.is-selected { background: rgba(255, 255, 255, .11); color: #fff; }
|
||||
.creation-custom-value.is-duration { grid-column: 2; width: 100%; margin-top: 0; }
|
||||
|
||||
html:not(.dark) .creation-chat-reference.is-paper { background: #e9ebed; color: #626b75; }
|
||||
html:not(.dark) .creation-chat-reference.is-paper { border-color: rgba(28, 32, 37, .10); background: #e9ebed; color: #626b75; }
|
||||
html:not(.dark) .creation-chat-reference.is-paper:hover:not(:disabled) { background: #dfe3e6; color: #1c2025; }
|
||||
html:not(.dark) .creation-custom-value { background: rgba(28, 32, 37, .055); }
|
||||
html:not(.dark) .creation-custom-value input,
|
||||
@@ -4618,10 +4720,12 @@ html:not(.dark) .creation-duration-choices button.is-selected { background: rgba
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; }
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-writing-surface { grid-template-columns: 34px minmax(0, 1fr); gap: 12px; }
|
||||
.creation-home .creation-chat-composer.is-empty .creation-chat-writing-surface,
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-writing-surface { grid-template-columns: 62px minmax(0, 1fr); gap: 16px; }
|
||||
.creation-home .creation-chat-editor textarea,
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-editor textarea { min-height: 76px; }
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-editor textarea,
|
||||
.creation-home .creation-chat-mention-container,
|
||||
.creation-home .creation-chat-mention-editor { min-height: 76px; }
|
||||
.creation-home .creation-chat-dock { min-height: 48px; padding: 4px 12px 12px 16px; }
|
||||
|
||||
.creation-home .creation-chat-control,
|
||||
@@ -4676,6 +4780,13 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.creation-user-message-references { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.creation-user-message-reference { display: inline-flex; min-width: 0; max-width: 180px; height: 25px; align-items: center; gap: 5px; border-radius: 6px; background: rgba(255, 255, 255, .075); padding: 0 7px; color: var(--creation-muted); font-size: 10px; line-height: 1; }
|
||||
.creation-user-message-reference img { width: 17px; height: 17px; flex: 0 0 auto; border-radius: 4px; object-fit: cover; }
|
||||
.creation-user-message-reference svg { width: 12px; height: 12px; flex: 0 0 auto; }
|
||||
.creation-user-message-reference span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .07); }
|
||||
|
||||
.creation-assistant-message { max-width: 900px; margin: 0 0 48px; }
|
||||
.creation-message-heading { gap: 7px; color: var(--creation-faint); font-size: 11px; }
|
||||
.creation-message-mark { width: 26px; height: 26px; border-radius: 8px; background: #eff1f3; color: #202329; }
|
||||
@@ -4743,7 +4854,9 @@ html:not(.dark) .creation-message-content code { background: rgba(28, 32, 37, .0
|
||||
.creation-home .creation-chat-composer.is-empty .creation-chat-writing-surface,
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-writing-surface { min-height: 96px; padding: 14px 13px 6px; }
|
||||
.creation-home .creation-chat-editor textarea,
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-editor textarea { min-height: 64px; }
|
||||
.creation-home .creation-chat-composer.is-thread .creation-chat-editor textarea,
|
||||
.creation-home .creation-chat-mention-container,
|
||||
.creation-home .creation-chat-mention-editor { min-height: 64px; }
|
||||
.creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); }
|
||||
.creation-thread-stage { padding-top: 36px; }
|
||||
.creation-user-message { max-width: 78%; }
|
||||
|
||||
Reference in New Issue
Block a user