merge: integrate Canvas plan and reference flow

# Conflicts:
#	README.md
This commit is contained in:
inman
2026-09-07 10:17:50 +08:00
18 changed files with 2141 additions and 1057 deletions

View File

@@ -27,6 +27,7 @@ export function MainLayout() {
const activeModule = getAiModuleForPath(location.pathname);
const isPaintingModule = activeModule === 'painting';
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isCanvasWorkspace = location.pathname === '/image-canvas';
const isChatWorkspace = location.pathname === '/chat';
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
@@ -79,6 +80,7 @@ export function MainLayout() {
integrated
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule && !isPromptMuseum}
showSidebarControls={!isCanvasWorkspace}
pageTitle={isPromptMuseum ? '获取灵感' : undefined}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
@@ -86,11 +88,13 @@ export function MainLayout() {
{/* Below the title bar: sidebar + content */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
{!isCanvasWorkspace ? (
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
) : null}
<main
data-testid="main-content"
className={cn(

View File

@@ -24,16 +24,17 @@ type TitleBarProps = {
integrated?: boolean;
workspaceLayout?: boolean;
overlay?: boolean;
showSidebarControls?: boolean;
pageTitle?: string;
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: SidebarPeekChange;
};
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
const platform = window.electron?.platform;
if (platform === 'darwin') {
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
}
// Linux keeps the native frame/title bar for better IME compatibility.
@@ -41,13 +42,14 @@ export function TitleBar({ integrated = false, workspaceLayout = false, overlay
return null;
}
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
}
function ProductTitleBar({
integrated,
workspaceLayout = false,
overlay = false,
showSidebarControls = true,
pageTitle,
children,
windowControlsWidth = 0,
@@ -58,6 +60,7 @@ function ProductTitleBar({
integrated: boolean;
workspaceLayout?: boolean;
overlay?: boolean;
showSidebarControls?: boolean;
pageTitle?: string;
children?: React.ReactNode;
windowControlsWidth?: number;
@@ -150,7 +153,7 @@ function ProductTitleBar({
integrated && !overlay && sidebarTitlebarHidden && !workspaceLayout && 'border-b border-border/80',
)}
>
{integrated ? (
{integrated && showSidebarControls ? (
<>
<div
data-testid="titlebar-sidebar-surface"
@@ -236,7 +239,7 @@ function ProductTitleBar({
);
}
function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
const [maximized, setMaximized] = useState(false);
useEffect(() => {
@@ -267,6 +270,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, side
integrated={integrated}
workspaceLayout={workspaceLayout}
overlay={overlay}
showSidebarControls={showSidebarControls}
pageTitle={pageTitle}
windowControlsWidth={WINDOWS_TITLEBAR_CONTROLS_WIDTH}
sidebarPeekOpen={sidebarPeekOpen}

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { Film, ImageIcon } from 'lucide-react';
import { resolveImageWorkspaceAssetUrl } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import type { DesignAsset } from '../../../shared/image-workspace';
export function DesignAssetThumbnail({
asset,
className,
alt,
}: {
asset: DesignAsset | null;
className?: string;
alt?: string;
}) {
const [resolution, setResolution] = useState<{
contentPath: string | null;
source: string | null;
failed: boolean;
}>({ contentPath: asset?.contentPath ?? null, source: null, failed: false });
const currentResolution = resolution.contentPath === (asset?.contentPath ?? null)
? resolution
: { contentPath: asset?.contentPath ?? null, source: null, failed: false };
useEffect(() => {
if (!asset) return;
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => {
if (active) setResolution({ contentPath: asset.contentPath, source: url, failed: false });
})
.catch(() => {
if (active) setResolution({ contentPath: asset.contentPath, source: null, failed: true });
});
return () => {
active = false;
};
}, [asset]);
if (!asset || !currentResolution.source || currentResolution.failed) {
return (
<span
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden bg-surface-subtle text-muted-foreground',
className,
)}
aria-hidden="true"
>
{asset?.mediaType === 'video'
? <Film className="h-5 w-5" />
: <ImageIcon className="h-5 w-5" />}
</span>
);
}
if (asset.mediaType === 'video') {
return (
<video
src={currentResolution.source}
className={cn('shrink-0 bg-surface-subtle object-cover', className)}
aria-label={alt ?? '视频作品预览'}
muted
preload="metadata"
/>
);
}
return (
<img
src={currentResolution.source}
alt={alt ?? (asset.role === 'uploaded' ? '参考图片预览' : 'AI 作品预览')}
className={cn('shrink-0 bg-surface-subtle object-cover', className)}
loading="lazy"
/>
);
}

View File

@@ -5,7 +5,6 @@ import {
MessageSquareText,
Send,
Sparkles,
WandSparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -13,7 +12,10 @@ import { Textarea } from '@/components/ui/textarea';
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignWorkspace } from '../../../shared/image-workspace';
import type { DesignCompilationIssue, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignPendingOperationsPanel } from './DesignProductionPanel';
import { DesignPlanHistory } from './DesignPlanHistory';
import { YouthCreationCard } from './YouthCreationCard';
const STARTER_MESSAGES = [
'我想做一张热闹的社团招新海报',
@@ -49,17 +51,16 @@ function chatFailureMessage(error: unknown): string {
export function DesignConversationPane({
workspace,
canRequestQuote,
onQuoteOffered,
quoteBlockers,
generationAvailable,
}: {
workspace: DesignWorkspace;
canRequestQuote: boolean;
onQuoteOffered: () => void;
quoteBlockers: DesignCompilationIssue[];
generationAvailable: boolean;
}) {
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
@@ -79,10 +80,6 @@ export function DesignConversationPane({
const submittingDesignInput = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
);
const quoteRequestPending = Object.values(pendingOperations).some(
(operation) => operation.command.kind === 'request_quote'
&& operation.command.workspaceId === workspace.workspace.workspaceId,
);
const streamingReplies = pendingChatMessages.flatMap((message) => {
const text = assistantStreams[message.operationId]?.trim();
return text ? [{ ...message, text }] : [];
@@ -101,7 +98,14 @@ export function DesignConversationPane({
if (typeof scrollAnchor?.scrollIntoView === 'function') {
scrollAnchor.scrollIntoView({ block: 'end' });
}
}, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]);
}, [
pendingChatKey,
streamingReplyKey,
workspace.form.specificationRevision,
workspace.form.activeQuotes.length,
workspace.tasks.length,
workspace.turns.length,
]);
const submit = () => {
if (!composerDraft.trim() || submittingDesignInput) return;
@@ -110,31 +114,22 @@ export function DesignConversationPane({
});
};
const prepareQuote = () => {
if (!canRequestQuote || quoteRequestPending || pendingChatMessages.length > 0) return;
void requestQuote()
.then(onQuoteOffered)
.catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
};
return (
<section
data-testid="image-workspace-conversation"
className="flex min-h-0 flex-1 flex-col bg-surface-subtle/45"
>
<header className="border-b border-border/70 bg-background px-5 py-4">
<header className="border-b border-border/70 bg-background px-5 py-3.5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-brand" />
<h2 className="text-sm font-semibold text-foreground"> AI </h2>
<h2 className="text-sm font-semibold text-foreground"> AI </h2>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
AI
</p>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
{workspace.turns.length === 0
&& pendingChatMessages.length === 0 && (
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
@@ -161,11 +156,11 @@ export function DesignConversationPane({
</div>
)}
<div className="space-y-5">
<div className="mx-auto max-w-5xl space-y-4">
{workspace.turns.map((turn) => (
<div key={turn.turnId} className="space-y-3">
{turn.userMessage && (
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-brand px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground">
{turn.userMessage}
</div>
)}
@@ -185,7 +180,7 @@ export function DesignConversationPane({
aria-atomic="true"
className="ml-auto max-w-[88%]"
>
<div className="rounded-2xl rounded-br-md bg-brand/90 px-3.5 py-2.5 text-sm leading-6 text-primary-foreground">
<div className="rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground">
{message.message}
</div>
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
@@ -214,36 +209,21 @@ export function DesignConversationPane({
</div>
))}
{canRequestQuote && pendingChatMessages.length === 0 && (
<div
data-testid="design-conversation-quote-action"
className="flex flex-col gap-3 rounded-2xl border border-brand/20 bg-brand/[0.045] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
className="min-h-11 shrink-0 rounded-xl"
disabled={quoteRequestPending}
onClick={prepareQuote}
>
{quoteRequestPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
: <WandSparkles className="mr-2 h-4 w-4" aria-hidden="true" />}
{quoteRequestPending ? '正在准备方案' : '下一步:看看制作方案'}
</Button>
</div>
<DesignPendingOperationsPanel />
<DesignPlanHistory workspace={workspace} />
{pendingChatMessages.length === 0 && (
<YouthCreationCard
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
/>
)}
</div>
<div ref={scrollAnchorRef} />
</div>
<footer className="border-t border-border/70 bg-background p-4">
<footer className="border-t border-border/70 bg-background p-3.5 sm:px-6">
<div className={cn(
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10',
submittingDesignInput && 'opacity-80',
@@ -252,10 +232,10 @@ export function DesignConversationPane({
id="design-chat-composer"
aria-label="告诉 AI 你想创作什么"
value={composerDraft}
rows={3}
rows={2}
disabled={submittingDesignInput}
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……"
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
className="min-h-[52px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => setChatDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {

View File

@@ -0,0 +1,138 @@
import { ChevronDown, Clock3, Download, Film, ImageIcon, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { saveImageWorkspaceAsset } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { youthTaskIssueMessage } from './youth-issue-copy';
const STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
queued: '等待制作',
running: '制作中',
succeeded: '已完成',
failed: '未完成',
cancelled: '已取消',
};
function formatTime(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
const now = new Date();
return date.toDateString() === now.toDateString()
? `今天 ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
: date.toLocaleDateString([], { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function taskDescription(task: DesignGenerationTask): string {
const parts = [
task.medium === 'video' ? '视频' : '图片',
task.outputSummary.aspectRatio,
];
if (task.outputSummary.durationSeconds) parts.push(`${task.outputSummary.durationSeconds}`);
parts.push(`${task.outputSummary.outputCount}`);
return parts.join(' · ');
}
export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace }) {
const tasks = [...workspace.tasks].sort(
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
);
if (tasks.length === 0) return null;
return (
<div className="space-y-2" data-testid="design-plan-history">
{tasks.map((task) => {
const resultAssets = task.resultAssetIds
.map((assetId) => workspace.assets.find((asset) => asset.assetId === assetId) ?? null)
.filter((asset): asset is NonNullable<typeof asset> => Boolean(asset));
const preview = resultAssets[0]
?? workspace.assets.find((asset) => asset.generationTaskId === task.taskId)
?? null;
const active = task.status === 'queued' || task.status === 'running';
return (
<details
key={task.taskId}
data-testid={`design-plan-history-${task.taskId}`}
className="group overflow-hidden rounded-xl border border-border/70 bg-background"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center gap-3 px-3 py-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/30 [&::-webkit-details-marker]:hidden">
<DesignAssetThumbnail asset={preview} className="h-9 w-9 rounded-lg" />
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="text-xs font-semibold text-foreground"></span>
<span className={cn(
'inline-flex items-center gap-1 text-[11px] font-medium',
task.status === 'succeeded'
? 'text-emerald-700'
: task.status === 'failed'
? 'text-destructive'
: active
? 'text-brand'
: 'text-muted-foreground',
)}>
{active && <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />}
{STATUS_LABELS[task.status]}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{taskDescription(task)} · {Math.ceil(task.maximumCustomerChargeAtoms / 100)}
</span>
</span>
<span className="hidden shrink-0 text-[11px] text-muted-foreground sm:inline">
{formatTime(task.createdAt)}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="border-t border-border/60 bg-surface-subtle/35 px-3 py-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
{task.medium === 'video'
? <Film className="h-3.5 w-3.5" />
: <ImageIcon className="h-3.5 w-3.5" />}
{taskDescription(task)}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock3 className="h-3.5 w-3.5" />
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps}
</span>
</div>
{task.issues.length > 0 && (
<div className="mt-3 space-y-1 rounded-lg bg-destructive/[0.04] px-3 py-2 text-xs leading-5 text-destructive">
{task.issues.map((issue) => <p key={issue.code}>{youthTaskIssueMessage(issue)}</p>)}
</div>
)}
{resultAssets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{resultAssets.map((asset) => (
<div key={asset.assetId} className="group/result relative">
<DesignAssetThumbnail asset={asset} className="h-20 w-20 rounded-xl" />
<button
type="button"
aria-label="保存作品到电脑"
title="保存作品到电脑"
className="absolute bottom-1 right-1 flex h-7 w-7 items-center justify-center rounded-lg bg-background/90 text-muted-foreground shadow-sm hover:text-foreground"
onClick={(event) => {
event.preventDefault();
void saveImageWorkspaceAsset(asset).catch(() => {
toast.error('作品没有保存成功,请再试一次');
});
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</details>
);
})}
</div>
);
}

View File

@@ -0,0 +1,356 @@
import { useEffect, useMemo, useState } from 'react';
import {
FolderKanban,
Lightbulb,
Loader2,
Pencil,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignWorkspace, DesignWorkspaceSummary } from '../../../shared/image-workspace';
import { DesignAssetThumbnail } from './DesignAssetThumbnail';
import { hasActiveCreationPlan } from './plan-lifecycle';
type ProjectDialogState =
| { mode: 'create'; project: null }
| { mode: 'rename'; project: DesignWorkspaceSummary };
function formatUpdatedAt(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return `今天 ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
}
function activeProjectStatus(workspace: DesignWorkspace | null): {
label: string;
tone: 'active' | 'success' | 'neutral';
} {
const activeTask = workspace?.tasks.find((task) => task.status === 'queued' || task.status === 'running');
if (activeTask) return { label: '制作中', tone: 'active' };
if (workspace?.form.activeQuotes.some((quote) => quote.status === 'offered')) {
return { label: '待确认', tone: 'active' };
}
if (workspace && hasActiveCreationPlan(workspace)) {
return { label: '刚刚更新', tone: 'neutral' };
}
const completed = workspace?.tasks.some((task) => task.status === 'succeeded');
if (completed) return { label: '已完成', tone: 'success' };
return { label: '刚刚更新', tone: 'neutral' };
}
export function DesignWorksRail({
workspace,
onProjectSelected,
}: {
workspace: DesignWorkspace | null;
onProjectSelected?: () => void;
}) {
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const deletingWorkspaceId = useImageWorkspaceStore((state) => state.deletingWorkspaceId);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
const [dialogError, setDialogError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DesignWorkspaceSummary | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState('');
const [deleteError, setDeleteError] = useState<string | null>(null);
const projects = useMemo(
() => [...(bootstrap?.workspaces ?? [])].sort(
(left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt),
),
[bootstrap?.workspaces],
);
const currentPreview = useMemo(() => {
if (!workspace) return null;
return [...workspace.assets].sort(
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
)[0] ?? null;
}, [workspace]);
const currentStatus = activeProjectStatus(workspace);
const openCreate = () => {
setProjectName('');
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
useEffect(() => {
const handleCreateRequest = () => {
setProjectName('');
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, handleCreateRequest);
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, handleCreateRequest);
}, []);
const openRename = (project: DesignWorkspaceSummary) => {
setProjectName(project.title);
setDialogError(null);
setDialog({ mode: 'rename', project });
};
const saveProject = async () => {
const title = projectName.trim();
if (!title) {
setDialogError('请输入作品名称');
return;
}
setSaving(true);
setDialogError(null);
try {
if (dialog?.mode === 'rename') {
await renameProject(dialog.project.workspaceId, title);
toast.success('作品名称已更新');
} else {
await createProject(title);
toast.success('新作品已创建');
}
setDialog(null);
onProjectSelected?.();
} catch (error) {
setDialogError(error instanceof Error ? error.message : '保存作品失败');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteConfirmation !== deleteTarget.title) {
setDeleteError('请输入完整作品名称');
return;
}
setDeleteError(null);
try {
await deleteProject(deleteTarget.workspaceId);
setDeleteTarget(null);
setDeleteConfirmation('');
toast.success('作品已删除');
} catch (error) {
setDeleteError(error instanceof Error ? error.message : '删除作品失败');
}
};
return (
<aside
data-testid="image-workspace-works-rail"
className="flex h-full min-h-0 flex-col bg-background font-sans"
aria-label="我的作品"
>
<header className="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/65 px-4">
<div>
<h2 className="text-base font-semibold tracking-tight text-foreground"></h2>
<p className="mt-0.5 text-[11px] text-muted-foreground">{projects.length} </p>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="h-9 rounded-xl border-brand/25 px-3 text-brand hover:bg-brand/[0.04] hover:text-brand"
onClick={openCreate}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{projects.length === 0 ? (
<button
type="button"
className="w-full rounded-2xl border border-dashed border-border px-4 py-8 text-left transition-colors hover:border-brand/35 hover:bg-brand/[0.025]"
onClick={openCreate}
>
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand/[0.07] text-brand">
<Plus className="h-4 w-4" />
</span>
<span className="mt-3 block text-sm font-medium text-foreground"></span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">AI </span>
</button>
) : (
<div className="space-y-1.5">
{projects.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
const pending = active && !workspace;
const status = active ? currentStatus : { label: formatUpdatedAt(project.updatedAt), tone: 'neutral' as const };
return (
<div
key={project.workspaceId}
className={cn(
'group/work relative rounded-2xl border transition-colors',
active
? 'border-brand/20 bg-brand/[0.055]'
: 'border-transparent hover:border-border/60 hover:bg-surface-subtle/55',
)}
>
<button
type="button"
aria-label={`打开作品 ${project.title}`}
className="flex min-h-[68px] w-full items-center gap-3 rounded-2xl px-2.5 py-2.5 pr-16 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => {
void selectProject(project.workspaceId);
onProjectSelected?.();
}}
>
{active && currentPreview ? (
<DesignAssetThumbnail asset={currentPreview} className="h-12 w-12 rounded-xl" />
) : (
<span className={cn(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
active ? 'bg-background text-brand' : 'bg-surface-subtle text-muted-foreground',
)}>
{pending
? <Loader2 className="h-5 w-5 animate-spin" />
: <FolderKanban className="h-5 w-5" />}
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-foreground">{project.title}</span>
<span className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className={cn(
'h-1.5 w-1.5 rounded-full',
status.tone === 'active'
? 'bg-brand'
: status.tone === 'success'
? 'bg-emerald-500'
: 'bg-muted-foreground/45',
)} />
{status.label}
</span>
</span>
</button>
<div className="absolute right-2 top-2 flex opacity-0 transition-opacity group-hover/work:opacity-100 focus-within:opacity-100">
<button
type="button"
aria-label={`重命名 ${project.title}`}
title="重命名"
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => openRename(project)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`删除 ${project.title}`}
title="删除"
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-background hover:text-destructive"
onClick={() => {
setDeleteTarget(project);
setDeleteConfirmation('');
setDeleteError(null);
}}
>
{deletingWorkspaceId === project.workspaceId
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<footer className="shrink-0 border-t border-border/60 p-3">
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-xs font-medium text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
onClick={() => {
window.location.hash = '/image-prompts';
}}
>
<Lightbulb className="h-4 w-4" />
</button>
</footer>
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dialog?.mode === 'rename' ? '重命名作品' : '新建作品'}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="canvas-work-name" className="text-sm font-medium text-foreground"></label>
<Input
id="canvas-work-name"
autoFocus
value={projectName}
placeholder="例如:小狗的快乐短片"
onChange={(event) => setProjectName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !saving) void saveProject();
}}
/>
{dialogError && <p className="text-sm text-destructive">{dialogError}</p>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={() => setDialog(null)}></Button>
<Button type="button" disabled={saving} onClick={() => void saveProject()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{dialog?.mode === 'rename' ? '保存名称' : '创建作品'}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !deletingWorkspaceId) setDeleteTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="delete-canvas-work" className="text-sm font-medium text-foreground">
{deleteTarget?.title ?? ''}
</label>
<Input
id="delete-canvas-work"
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
/>
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" disabled={Boolean(deletingWorkspaceId)} onClick={() => setDeleteTarget(null)}></Button>
<Button type="button" variant="destructive" disabled={Boolean(deletingWorkspaceId)} onClick={() => void confirmDelete()}>
{deletingWorkspaceId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Cloud,
CloudOff,
PanelRight,
Plus,
RefreshCw,
Sparkles,
@@ -20,14 +21,8 @@ import { useAuthStore } from '@/stores/auth';
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { DesignConversationPane } from './DesignConversationPane';
import { FineTuneDrawer } from './FineTuneDrawer';
import {
DesignAssetsPanel,
DesignPendingOperationsPanel,
DesignQuotePanel,
DesignTaskPanel,
} from './DesignProductionPanel';
import { YouthCreationCard } from './YouthCreationCard';
import { DesignWorksRail } from './DesignWorksRail';
import { hasActiveCreationPlan } from './plan-lifecycle';
import { projectYouthCreationCard } from './youth-form-projection';
function useDesktopLayout(): boolean {
@@ -53,16 +48,16 @@ function LoadingWorkspace() {
return (
<div className="flex h-full min-h-0 flex-col bg-background" aria-label="正在加载 AI 设计">
<div className="h-16 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
<div className="space-y-4 border-r border-border/60 bg-surface-subtle/40 p-5">
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-4 bg-surface-subtle/35 p-6 lg:border-r lg:border-border/60">
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
<div className="h-28 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="h-48 w-5/6 animate-pulse rounded-2xl bg-surface-subtle" />
</div>
<div className="space-y-3 p-6">
<div className="h-28 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="hidden space-y-3 p-4 lg:block">
<div className="h-10 animate-pulse rounded-xl bg-surface-subtle" />
{[0, 1, 2, 3].map((item) => (
<div key={item} className="h-20 animate-pulse rounded-2xl bg-surface-subtle" />
<div key={item} className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
))}
</div>
</div>
@@ -70,7 +65,7 @@ function LoadingWorkspace() {
);
}
function EmptyWorkspace() {
function EmptyWorkspace({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
<section data-testid="image-workspace-empty-state" className="w-full max-w-lg rounded-2xl border border-border/70 bg-background p-8 shadow-soft">
@@ -79,28 +74,18 @@ function EmptyWorkspace() {
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-foreground"></h1>
<p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
AI
AI
</p>
<Button
type="button"
className="mt-6 min-h-11 rounded-xl"
onClick={() => window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT))}
>
<Button type="button" className="mt-6 h-10 rounded-xl" onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</section>
</div>
);
}
function ErrorWorkspace({
message,
retry,
}: {
message: string;
retry: () => void;
}) {
function ErrorWorkspace({ message, retry }: { message: string; retry: () => void }) {
return (
<div className="flex h-full min-h-0 items-center justify-center bg-surface-subtle/35 px-6 py-10">
<section data-testid="image-workspace-unavailable" className="w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-soft">
@@ -109,7 +94,7 @@ function ErrorWorkspace({
</div>
<h1 className="mt-4 text-lg font-semibold text-foreground">AI </h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
<Button type="button" variant="outline" className="mt-5 min-h-11 rounded-xl" onClick={retry}>
<Button type="button" variant="outline" className="mt-5 h-10 rounded-xl" onClick={retry}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
@@ -134,8 +119,7 @@ export function ImageCanvas() {
const consumePendingPrompt = useImagePromptMuseumStore((state) => state.consumePendingPrompt);
const workspaceId = workspace?.workspace.workspaceId ?? null;
const desktopLayout = useDesktopLayout();
const [creationCardOpen, setCreationCardOpen] = useState(false);
const [fineTuneOpen, setFineTuneOpen] = useState(false);
const [worksOpen, setWorksOpen] = useState(false);
useEffect(() => {
if (authenticated && (status === 'idle' || status === 'auth-required')) void load();
@@ -150,9 +134,7 @@ export function ImageCanvas() {
useEffect(() => {
if (!workspaceId) return;
const pendingPrompt = consumePendingPrompt();
if (pendingPrompt) {
setChatDraft(pendingPrompt.prompt);
}
if (pendingPrompt) setChatDraft(pendingPrompt.prompt);
}, [consumePendingPrompt, setChatDraft, workspaceId]);
if (status === 'loading' || (status === 'ready' && bootstrap?.workspaces.length && !workspace)) {
@@ -160,75 +142,62 @@ export function ImageCanvas() {
}
if (status === 'unavailable' || status === 'error') {
return (
<ErrorWorkspace
message={error ?? '请检查网络后重试'}
retry={() => void load()}
/>
);
return <ErrorWorkspace message={error ?? '请检查网络后重试'} retry={() => void load()} />;
}
if (status === 'ready' && !workspace) return <EmptyWorkspace />;
if (!workspace) return <LoadingWorkspace />;
if (!workspace && status !== 'ready') return <LoadingWorkspace />;
const streamHealthy = eventState === 'connected';
const generationAvailable = bootstrap?.capabilities.generation !== false;
const cardModel = projectYouthCreationCard(workspace, { quoteBlockers });
const hasOfferedQuote = workspace.form.activeQuotes.some((quote) => quote.status === 'offered');
const focusComposer = () => {
setCreationCardOpen(false);
const readiness = workspace
? workspace.form.activeQuotes.some((quote) => quote.status === 'offered')
? '制作方案已准备好,确认前仍可修改'
: hasActiveCreationPlan(workspace)
? '想法已整理清楚,可以检查制作方案'
: projectYouthCreationCard(workspace, { quoteBlockers }).readinessMessage
: '从一个新作品开始';
const updatedAt = workspace
? new Date(workspace.workspace.updatedAt).toLocaleString([], {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: null;
const platform = window.electron?.platform;
const openCreate = () => {
setWorksOpen(true);
globalThis.setTimeout(() => {
document.getElementById('design-chat-composer')?.focus();
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
}, 0);
};
const openFineTune = () => {
setCreationCardOpen(false);
setFineTuneOpen(true);
};
const revealOfferedQuote = () => {
if (!desktopLayout) setCreationCardOpen(true);
globalThis.setTimeout(() => {
const quote = document.getElementById('design-offered-quote');
if (typeof quote?.scrollIntoView === 'function') {
quote.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 0);
};
const productionContent = (
<div className="space-y-5">
<DesignPendingOperationsPanel />
{hasOfferedQuote && <DesignQuotePanel workspace={workspace} />}
{!generationAvailable && (
<div className="rounded-2xl border border-border/70 bg-background p-4 text-sm leading-6 text-muted-foreground">
</div>
)}
<DesignTaskPanel tasks={workspace.tasks} />
<DesignAssetsPanel workspace={workspace} />
</div>
);
return (
<div data-testid="image-workspace" className="flex h-full min-h-0 flex-col overflow-hidden bg-background font-sans">
<header className="flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background px-4 py-3 sm:px-5">
<header className={cn(
'flex min-h-16 shrink-0 items-center gap-4 border-b border-border/70 bg-background py-3',
platform === 'darwin'
? 'pl-[92px] pr-[116px]'
: platform === 'win32'
? 'pl-5 pr-[260px]'
: 'px-4 sm:px-5',
)}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
<h1 className="truncate text-base font-semibold text-foreground">
{workspace.workspace.title}
{workspace?.workspace.title ?? 'Makelore Canvas'}
</h1>
</div>
<div className="mt-1 hidden sm:block">
<p className="text-xs text-muted-foreground">{cardModel.readinessMessage}</p>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{updatedAt ? `${readiness} · 更新于 ${updatedAt}` : readiness}
</p>
</div>
<div
className={cn(
'hidden items-center gap-1.5 rounded-xl px-2.5 py-1.5 text-xs sm:flex',
streamHealthy
? 'bg-brand/[0.06] text-brand'
: 'bg-surface-subtle text-muted-foreground',
'hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs sm:flex',
streamHealthy ? 'bg-brand/[0.055] text-brand' : 'bg-surface-subtle text-muted-foreground',
)}
title={streamHealthy ? '已经连接' : '正在重新连接,你的内容不会丢失'}
>
@@ -236,16 +205,32 @@ export function ImageCanvas() {
{streamHealthy ? '实时同步' : '恢复连接'}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-11 w-11 rounded-xl"
aria-label="刷新设计项目"
onClick={() => void refreshWorkspace()}
>
<RefreshCw className="h-4 w-4" />
</Button>
{workspace && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 rounded-lg"
aria-label="刷新作品"
title="刷新"
onClick={() => void refreshWorkspace()}
>
<RefreshCw className="h-4 w-4" />
</Button>
)}
{!desktopLayout && (
<Button
type="button"
variant="outline"
size="sm"
className="h-9 rounded-xl"
onClick={() => setWorksOpen(true)}
>
<PanelRight className="mr-1.5 h-4 w-4" />
</Button>
)}
</header>
{error && workspace && (
@@ -253,7 +238,7 @@ export function ImageCanvas() {
<span>{error}</span>
<button
type="button"
className="min-h-11 shrink-0 rounded-xl px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
className="h-8 shrink-0 rounded-lg px-3 font-medium hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-700/35"
onClick={() => void refreshWorkspace()}
>
@@ -261,85 +246,35 @@ export function ImageCanvas() {
</div>
)}
{!desktopLayout && (
<div className="shrink-0 border-b border-border/70 bg-background p-2">
<button
type="button"
className="flex min-h-11 w-full items-center justify-between gap-3 rounded-xl bg-brand/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-brand/[0.1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
onClick={() => setCreationCardOpen(true)}
>
<span className="flex min-w-0 items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
<span className="truncate text-sm font-semibold text-foreground"></span>
</span>
<span className="truncate text-xs text-muted-foreground">{cardModel.readinessMessage}</span>
</button>
</div>
)}
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_320px] xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="flex min-h-0 lg:border-r lg:border-border/70">
<DesignConversationPane
workspace={workspace}
canRequestQuote={generationAvailable
&& cardModel.phase === 'ready'
&& cardModel.canRequestQuote}
onQuoteOffered={revealOfferedQuote}
/>
{workspace ? (
<DesignConversationPane
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
/>
) : (
<EmptyWorkspace onCreate={openCreate} />
)}
</div>
{desktopLayout && (
<section
data-testid="image-workspace-creation-pane"
className="min-h-0 bg-surface-subtle/35"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-3xl space-y-5 px-4 py-5 sm:px-5">
<YouthCreationCard
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>
</div>
</section>
<DesignWorksRail workspace={workspace} />
)}
</main>
{!desktopLayout && (
<Sheet open={creationCardOpen} onOpenChange={setCreationCardOpen}>
<SheetContent side="bottom" className="flex max-h-[88vh] flex-col overflow-hidden rounded-t-3xl p-0">
<Sheet open={worksOpen} onOpenChange={setWorksOpen}>
<SheetContent side="right" className="flex w-[min(92vw,360px)] flex-col overflow-hidden p-0 sm:max-w-[360px]">
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
<SheetDescription> AI </SheetDescription>
<SheetTitle></SheetTitle>
<SheetDescription> AI </SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-subtle/35 px-4 py-4">
<div className="space-y-5 pb-[max(1rem,env(safe-area-inset-bottom))]">
<YouthCreationCard
workspace={workspace}
quoteBlockers={quoteBlockers}
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>
</div>
<DesignWorksRail workspace={workspace} onProjectSelected={() => setWorksOpen(false)} />
</SheetContent>
</Sheet>
)}
<FineTuneDrawer
open={fineTuneOpen}
onOpenChange={setFineTuneOpen}
workspace={workspace}
quoteBlockers={quoteBlockers}
/>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { DesignWorkspace } from '../../../shared/image-workspace';
function hasCreatorPrompt(workspace: DesignWorkspace): boolean {
const values = workspace.form.specification.values;
return Boolean(values.content.concept?.trim() || values.content.narrative?.trim());
}
export function hasActiveCreationPlan(workspace: DesignWorkspace): boolean {
if (workspace.form.activeQuotes.some((quote) => quote.status === 'offered')) return true;
if (!hasCreatorPrompt(workspace) && workspace.form.specification.values.references.length === 0) return false;
const latestTaskRevision = workspace.tasks.reduce(
(latest, task) => Math.max(latest, task.specificationRevision),
-1,
);
return latestTaskRevision < workspace.form.specificationRevision;
}

View File

@@ -0,0 +1,68 @@
const REFERENCE_TOKEN_PATTERN = /@(?:图片|图)(\d+)/g;
export function referenceToken(index: number): string {
return `@图片${index + 1}`;
}
export function promptReferencesImage(prompt: string, index: number): boolean {
const expected = index + 1;
for (const match of prompt.matchAll(REFERENCE_TOKEN_PATTERN)) {
if (Number(match[1]) === expected) return true;
}
return false;
}
export function findUnboundReferenceNumbers(prompt: string, boundReferenceCount: number): number[] {
const unbound = new Set<number>();
for (const match of prompt.matchAll(REFERENCE_TOKEN_PATTERN)) {
const number = Number(match[1]);
if (number < 1 || number > boundReferenceCount) unbound.add(number);
}
return [...unbound].sort((left, right) => left - right);
}
export function findReferenceMentionRange(prompt: string, cursor: number): {
start: number;
end: number;
} | null {
const prefix = prompt.slice(0, cursor);
const match = /@(?:图(?:片)?)?\d*$/.exec(prefix);
if (!match || match.index < 0) return null;
return { start: match.index, end: cursor };
}
export function insertReferenceToken(
prompt: string,
index: number,
range?: { start: number; end: number } | null,
): { value: string; cursor: number } {
const token = referenceToken(index);
if (range) {
const trailingSpace = prompt.slice(range.end).startsWith(' ') ? '' : ' ';
const value = `${prompt.slice(0, range.start)}${token}${trailingSpace}${prompt.slice(range.end)}`;
return { value, cursor: range.start + token.length + trailingSpace.length };
}
const trimmedRight = prompt.trimEnd();
const separator = trimmedRight ? (/[,。!?、:;,.!?;:]$/.test(trimmedRight) ? ' ' : ',参考 ') : '参考 ';
const value = `${trimmedRight}${separator}${token} `;
return { value, cursor: value.length };
}
export function removeReferenceTokenAndShift(prompt: string, removedIndex: number): string {
const removedNumber = removedIndex + 1;
const removedMarker = '__MAKEL0RE_REMOVED_REFERENCE__';
return prompt
.replace(REFERENCE_TOKEN_PATTERN, (token, rawNumber: string) => {
const number = Number(rawNumber);
if (number === removedNumber) return removedMarker;
if (number > removedNumber) return `@图片${number - 1}`;
return token.startsWith('@图') && !token.startsWith('@图片')
? `@图片${number}`
: token;
})
.replace(new RegExp(`\\s*${removedMarker}\\s*[,、,]?\\s*`, 'g'), ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\s*([,。!?、:;,.!?;:]|$)/g, '$1')
.trim();
}