fix(create): 参考内容 - 支持添加、独立删除与筛选管理

- 支持参考内容按稳定 ID 删除、筛选、清空、拖拽与触屏交互
- 保留 Vite 生产环境模型 Logo 加载并兼容 Bun 测试
- 同步最新 main,修正相关格式和陈旧测试断言

Co-authored-by: echoD886 <dingxiaojie66@gmail.com>
This commit is contained in:
echoD886
2026-08-28 09:07:33 +08:00
committed by GitHub
parent ce448c26cb
commit 19ebfbb3c1
11 changed files with 266 additions and 144 deletions

View File

@@ -118,10 +118,11 @@ func TestS3EndpointAndDigestsEnforceStorageContract(t *testing.T) {
}
func TestOSSSettingKeepsS3SecretsWhenLocationChanges(t *testing.T) {
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
current := ossSettingValue{
Provider: s3Provider,
Region: "us-east-1",
Endpoint: "https://s3.example.com",
Endpoint: "https://127.0.0.1",
Bucket: "old-bucket",
AccessKeyID: "access-id",
AccessKeySecret: "secret-value",
@@ -133,7 +134,7 @@ func TestOSSSettingKeepsS3SecretsWhenLocationChanges(t *testing.T) {
Provider: s3Provider,
S3Preset: "custom",
Region: "us-east-1",
Endpoint: "https://s3.example.com",
Endpoint: "https://127.0.0.1",
Bucket: "new-bucket",
AccessKeyID: "access-id",
PathPrefix: "changed-prefix",

View File

@@ -5,6 +5,14 @@ description: 影策已实现功能的手工验证清单与未确认边界
# 待测试
## 创作输入区参考内容管理
- [x] 自动化覆盖首个、中间和末尾参考内容按稳定 ID 独立删除,且删除后其余顺序保持不变。
- [x] 折叠态提供明确的“查看全部”入口,展开态提供图片、视频、音频、文件筛选、清空全部和收起入口。
- [x] 在桌面浏览器验证上方“参考内容”打开素材选择、折叠堆叠态中间卡片删除、展开态任意卡片删除、筛选、拖动排序和收起。
- [ ] 在真实登录数据下验证“清空全部素材”同步移除全部附件引用,不影响技能引用和素材库原始文件。
- [ ] 在触屏及窄窗口验证删除按钮无需悬停也可见,素材轨道可横向浏览且不会遮挡提示词和底部控制区。
## Agnes Video 2.5 JSON 协议
- [x] 前端专项测试确认 Agnes 创建请求使用 `application/json`,不再发送 `input_reference[]`、`resolution_name`、`preset` 或像素型 `size`。

View File

@@ -672,7 +672,15 @@ async function saveFailureCloseGuard(cdp, baseUrl) {
const stayClicked = await cdp.clickText("留在导演台");
if (!stayClicked) throw new Error("F: 留在导演台 button not clickable");
const modalGone = await cdp.poll(`!document.querySelector('.ant-modal-confirm')`, "modal dismissed", 20000);
const modalGone = await cdp.poll(
`![...document.querySelectorAll('.ant-modal-confirm')].some((modal) => {
const rect = modal.getBoundingClientRect();
const style = getComputedStyle(modal);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0;
})`,
"modal dismissed",
20000,
);
assert(modalGone, "F6 confirm dialog dismissed after choosing 留在导演台");
await sleep(1000);

View File

@@ -10,7 +10,7 @@ type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
const iconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
const iconLoaders = Object.fromEntries(
Object.entries(iconModules)
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])

View File

@@ -13,16 +13,7 @@ type ModelCreditCost = {
logicalPriceTiers?: ModelPriceTier[];
};
export function requestCreditCost(options: {
channelMode: string;
modelCosts?: ModelCreditCost[];
model: string;
count?: string | number;
seconds?: string | number;
capability?: ModelCapability;
config?: AiConfig;
requirements?: ModelRequirements;
}) {
export function requestCreditCost(options: { channelMode: string; modelCosts?: ModelCreditCost[]; model: string; count?: string | number; seconds?: string | number; capability?: ModelCapability; config?: AiConfig; requirements?: ModelRequirements }) {
if (options.channelMode !== "remote") return null;
const cost = options.modelCosts?.find((item) => item.model === options.model) || null;
if (!cost) return null;
@@ -86,9 +77,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
}
function creditAmount(billingMode: "fixed_request" | "per_second", unitPriceMicrocredits: number, count?: string | number, seconds?: string | number) {
const quantity = billingMode === "per_second"
? Math.max(1, Math.floor(Math.abs(Number(seconds)) || 1))
: Math.max(1, Math.floor(Math.abs(Number(count)) || 1));
const quantity = billingMode === "per_second" ? Math.max(1, Math.floor(Math.abs(Number(seconds)) || 1)) : Math.max(1, Math.floor(Math.abs(Number(count)) || 1));
return (unitPriceMicrocredits / 1_000_000) * quantity;
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type FocusEvent, type MouseEvent, type PointerEvent, type ReactNode, type RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { Reorder } from "motion/react";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
import { Link } from "react-router";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
@@ -44,7 +44,7 @@ import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
type CreationMode = "text" | "image" | "video";
type CreationViewMode = "chat" | "storyboard";
@@ -472,10 +472,17 @@ export default function CreatePage() {
const removeAttachment = (id: string) => {
const reference = mentionReferences.find((item) => item.attachmentId === id);
setAttachments((current) => current.filter((item) => item.id !== id));
setAttachments((current) => removeCreationAttachment(current, id));
if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference]));
};
const clearAttachments = () => {
const attachmentIds = new Set(attachments.map((item) => item.id));
const references = mentionReferences.filter((item) => item.attachmentId && attachmentIds.has(item.attachmentId));
setAttachments([]);
if (references.length) setPrompt((current) => removeCreationReferenceTokens(current, references));
};
const reorderAttachments = useCallback((next: CreationAttachment[]) => {
attachmentsRef.current = next;
setAttachments(next);
@@ -886,6 +893,7 @@ export default function CreatePage() {
maxReferences,
references: mentionReferences,
onRemoveAttachment: removeAttachment,
onClearAttachments: clearAttachments,
onReorderAttachments: reorderAttachments,
onReplaceAttachment: replaceReferenceFromTrack,
onReplaceReferenceFiles: replaceReferenceFromFiles,
@@ -1132,12 +1140,10 @@ function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
const kind = creationAttachmentKind(item);
const previewable = kind === "image" || kind === "video";
const url = (kind === "video" ? item.url : item.previewUrl) || "";
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
return <div className="creation-reference-card-content">
<button type="button" className={`creation-reference-card-preview${previewable ? "" : " is-file"}`} onClick={() => { if (previewable) onPreview(kind === "video" ? "video" : "image", url); }} aria-label={previewable ? `放大预览 ${item.name}` : item.name} disabled={previewable && !url}>
{kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>}
{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}
</button>
<button type="button" className="creation-reference-card-remove" onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", url)} aria-label={`放大预览 ${item.name}`} disabled={!url}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
</div>;
}
@@ -1153,6 +1159,7 @@ type ComposerProps = {
maxReferences: number;
references: CreationReference[];
onRemoveAttachment: (id: string) => void;
onClearAttachments: () => void;
onReorderAttachments: (attachments: CreationAttachment[]) => void;
onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void;
onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void;
@@ -1182,10 +1189,14 @@ type ComposerProps = {
onSubmit: () => void;
};
type CreationReferenceFilter = "all" | "image" | "video" | "audio" | "file";
function CreationComposer(props: ComposerProps) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const [referenceFilter, setReferenceFilter] = useState<CreationReferenceFilter>("all");
const [canDragReferences, setCanDragReferences] = useState(false);
const [dropTargetReferenceId, setDropTargetReferenceId] = useState<string | null>(null);
const attachmentTrackRef = useRef<HTMLUListElement>(null);
const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null);
@@ -1216,6 +1227,15 @@ function CreationComposer(props: ComposerProps) {
const imageReferencesSupported = props.imageProfile.references.maxImages > 0;
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video");
const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences;
const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`;
const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => {
const kind = creationAttachmentKind(attachment);
counts[kind] += 1;
return counts;
}, { image: 0, video: 0, audio: 0, file: 0 }), [props.attachments]);
const visibleAttachments = useMemo(() => referenceFilter === "all"
? props.attachments
: props.attachments.filter((attachment) => creationAttachmentKind(attachment) === referenceFilter), [props.attachments, referenceFilter]);
const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1;
const updateTrackScrollState = useCallback(() => {
const track = attachmentTrackRef.current;
@@ -1226,31 +1246,27 @@ function CreationComposer(props: ComposerProps) {
canScrollRight: track.scrollLeft + track.clientWidth < track.scrollWidth - 1,
}));
}, []);
const handleTrackMouseEnter = useCallback(() => {
if (window.matchMedia("(hover: hover)").matches) setTrackState((current) => ({ ...current, isExpanded: true }));
const setReferencePanelExpanded = useCallback((isExpanded: boolean) => {
setTrackState((current) => ({ ...current, isExpanded }));
if (!isExpanded) setReferenceFilter("all");
}, []);
const handleTrackMouseLeave = useCallback(() => {
if (!trackState.isDragging) setTrackState((current) => ({ ...current, isExpanded: false }));
}, [trackState.isDragging]);
const handleTrackFocus = useCallback(() => {
setTrackState((current) => ({ ...current, isExpanded: true }));
}, []);
const handleTrackBlur = useCallback((event: FocusEvent<HTMLElement>) => {
if (!trackState.isDragging && !event.currentTarget.contains(event.relatedTarget as Node | null)) {
setTrackState((current) => ({ ...current, isExpanded: false }));
}
}, [trackState.isDragging]);
useEffect(() => {
const touchOnly = window.matchMedia("(hover: none)").matches;
setTrackState((current) => ({ ...current, isExpanded: touchOnly || current.isDragging }));
if (!props.attachments.length) setReferencePanelExpanded(false);
updateTrackScrollState();
}, [props.attachments.length, updateTrackScrollState]);
}, [props.attachments.length, setReferencePanelExpanded, updateTrackScrollState]);
useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanDragReferences(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
const frame = window.requestAnimationFrame(updateTrackScrollState);
return () => window.cancelAnimationFrame(frame);
}, [trackState.isExpanded, updateTrackScrollState]);
}, [referenceFilter, trackState.isExpanded, updateTrackScrollState, visibleAttachments.length]);
const beginCardDrag = (event: PointerEvent<HTMLElement>) => {
if (event.button !== 0 || props.busy) return;
if (event.button !== 0 || interactionBusy || !trackState.isExpanded) return;
if ((event.target as HTMLElement).closest(".creation-reference-card-remove")) return;
cardDragRef.current = { startX: event.clientX, startY: event.clientY, moved: false };
};
@@ -1277,6 +1293,15 @@ function CreationComposer(props: ComposerProps) {
setPreviewType(type);
setPreviewUrl(url);
};
const reorderVisibleAttachments = useCallback((next: CreationAttachment[]) => {
if (referenceFilter === "all") {
props.onReorderAttachments(next);
return;
}
const visibleIds = new Set(visibleAttachments.map((attachment) => attachment.id));
const reordered = [...next];
props.onReorderAttachments(props.attachments.map((attachment) => visibleIds.has(attachment.id) ? reordered.shift() || attachment : attachment));
}, [props.attachments, props.onReorderAttachments, referenceFilter, visibleAttachments]);
useEffect(() => {
if (!canOptimizePrompt) setPromptOptimizerOpen(false);
}, [canOptimizePrompt]);
@@ -1301,49 +1326,68 @@ function CreationComposer(props: ComposerProps) {
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} 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.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
{props.attachments.length || canAddMoreReferences ? <div className="creation-reference-track-wrapper">
<div className="creation-reference-stack-shell" onMouseEnter={handleTrackMouseEnter} onMouseLeave={handleTrackMouseLeave} onFocus={handleTrackFocus} onBlur={handleTrackBlur}>
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
<Reorder.Group<CreationAttachment[]>
as="ul"
ref={attachmentTrackRef}
className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${props.attachments.length ? "" : " is-empty"}`}
axis="x"
values={props.attachments}
onReorder={props.onReorderAttachments}
layoutScroll
role="list"
aria-label="参考内容轨道"
onScroll={updateTrackScrollState}
>
{props.attachments.map((item) => <Reorder.Item<CreationAttachment>
key={item.id}
value={item}
layout="position"
drag={!interactionBusy}
className="creation-reference-stack-card"
onPointerDown={beginCardDrag}
onPointerMove={moveCardDrag}
onPointerUp={endCardDrag}
onPointerCancel={endCardDrag}
onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }}
onDrag={(_, info) => {
if (creationAttachmentKind(item) !== "image") return;
const target = imageReferenceAtPoint(info.point.x, info.point.y);
setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null);
}}
onDragEnd={(_, info) => {
const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined;
setDropTargetReferenceId(null);
setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }));
if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item);
}}
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
{trackState.isExpanded ? <div className="creation-reference-panel-header">
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
{([
{ id: "all", label: "全部", count: props.attachments.length },
{ id: "image", label: "图片", count: referenceCounts.image },
{ id: "video", label: "视频", count: referenceCounts.video },
{ id: "audio", label: "音频", count: referenceCounts.audio },
{ id: "file", label: "文件", count: referenceCounts.file },
] as const).map((filter) => <button key={filter.id} type="button" aria-pressed={referenceFilter === filter.id} className={referenceFilter === filter.id ? "is-active" : undefined} onClick={() => setReferenceFilter(filter.id)}>{filter.label}{filter.count ? ` (${filter.count})` : ""}</button>)}
</div>
<div className="creation-reference-panel-actions">
{props.attachments.length ? <button type="button" onClick={props.onClearAttachments} disabled={interactionBusy}>清空全部素材</button> : null}
<Tooltip title="收起素材面板"><button type="button" className="creation-reference-panel-collapse" onClick={() => setReferencePanelExpanded(false)} aria-label="收起素材面板"><Minimize2 aria-hidden="true" /></button></Tooltip>
</div>
</div> : null}
<div className="creation-reference-track-wrapper">
<div className="creation-reference-stack-shell">
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
<Reorder.Group<CreationAttachment[]>
as="ul"
ref={attachmentTrackRef}
className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${visibleAttachments.length ? "" : " is-empty"}`}
axis="x"
values={visibleAttachments}
onReorder={reorderVisibleAttachments}
layoutScroll
role="list"
aria-label="参考内容轨道"
onScroll={updateTrackScrollState}
>
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
</Reorder.Item>)}
{canAddMoreReferences ? <li className="creation-reference-add-slot"><Tooltip title="添加更多参考内容"><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={interactionBusy} aria-label="添加更多参考内容"><Plus aria-hidden="true" /></button></Tooltip></li> : null}
</Reorder.Group>
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
{visibleAttachments.map((item) => <Reorder.Item<CreationAttachment>
key={item.id}
value={item}
layout="position"
drag={trackState.isExpanded && canDragReferences && !interactionBusy}
className="creation-reference-stack-card"
onPointerDown={beginCardDrag}
onPointerMove={moveCardDrag}
onPointerUp={endCardDrag}
onPointerCancel={endCardDrag}
onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }}
onDrag={(_, info) => {
if (creationAttachmentKind(item) !== "image") return;
const target = imageReferenceAtPoint(info.point.x, info.point.y);
setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null);
}}
onDragEnd={(_, info) => {
const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined;
setDropTargetReferenceId(null);
setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }));
if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item);
}}
>
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
</Reorder.Item>)}
{!visibleAttachments.length && props.attachments.length ? <li className="creation-reference-filter-empty">该类型暂无参考内容</li> : null}
{referencesSupported ? <li className="creation-reference-add-slot"><Tooltip title={addReferenceLabel}><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={interactionBusy || !canAddMoreReferences} aria-label={addReferenceLabel}><Plus aria-hidden="true" /><span>参考内容</span></button></Tooltip></li> : null}
</Reorder.Group>
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
{!trackState.isExpanded && props.attachments.length ? <Tooltip title="查看全部"><button type="button" className="creation-reference-panel-expand" onClick={() => setReferencePanelExpanded(true)} aria-label={`查看全部 ${props.attachments.length} 个参考内容`} aria-expanded="false"><Maximize2 aria-hidden="true" /></button></Tooltip> : null}
</div>
</div>
</div> : null}
</div>

View File

@@ -10301,8 +10301,26 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
gap: 0;
}
.creation-home .creation-reference-track-wrapper { position: relative; min-width: 0; min-height: 85px; margin: -2px 0 var(--space-2); }
.creation-home .creation-reference-stack-shell { position: relative; width: fit-content; min-width: 0; max-width: 100%; }
.creation-home .creation-reference-panel { width: fit-content; min-width: 0; max-width: 100%; margin: -2px 0 var(--space-2); }
.creation-home .creation-reference-panel.is-expanded { width: 100%; }
.creation-home .creation-reference-panel-header { display: flex; min-height: var(--space-8); flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-2); }
.creation-home .creation-reference-filter-tabs { display: flex; min-width: 0; flex: 1 1 auto; align-items: center; gap: var(--space-1); overflow-x: auto; scrollbar-width: none; }
.creation-home .creation-reference-filter-tabs::-webkit-scrollbar { display: none; }
.creation-home .creation-reference-filter-tabs button,
.creation-home .creation-reference-panel-actions button { min-height: var(--space-7); border: 0; border-radius: var(--r-md); background: transparent; color: var(--creation-muted); padding: 0 var(--space-3); font-size: var(--fs-caption); line-height: 1; white-space: nowrap; transition: background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-filter-tabs button:hover,
.creation-home .creation-reference-filter-tabs button.is-active,
.creation-home .creation-reference-panel-actions button:hover:not(:disabled) { background: var(--creation-surface-hover); color: var(--creation-text); }
.creation-home .creation-reference-filter-tabs button:focus-visible,
.creation-home .creation-reference-panel-actions button:focus-visible,
.creation-home .creation-reference-panel-expand:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); }
.creation-home .creation-reference-panel-actions { display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-1); }
.creation-home .creation-reference-panel-actions button:disabled { cursor: wait; opacity: .5; }
.creation-home .creation-reference-panel-actions .creation-reference-panel-collapse { display: grid; width: var(--space-7); padding: 0; place-items: center; }
.creation-home .creation-reference-panel-collapse svg { width: var(--space-4); height: var(--space-4); }
.creation-home .creation-reference-track-wrapper { position: relative; min-width: 0; min-height: 85px; }
.creation-home .creation-reference-stack-shell { position: relative; display: flex; width: fit-content; min-width: 0; max-width: 100%; align-items: flex-start; gap: var(--space-2); }
.creation-home .creation-reference-panel.is-expanded .creation-reference-stack-shell { width: 100%; }
.creation-home .creation-reference-track {
display: flex;
width: fit-content;
@@ -10338,12 +10356,17 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
transition: margin-left var(--motion-state) var(--motion-ease-out), box-shadow var(--motion-dur-fast-calc) ease;
}
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { margin-left: -48px; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(1) { --stack-rotate: -7deg; margin-left: 0; z-index: 4; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(2) { --stack-rotate: 2deg; z-index: 3; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(3) { --stack-rotate: -5deg; z-index: 2; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(1) { --stack-rotate: -7deg; margin-left: 0; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(2) { --stack-rotate: 2deg; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(3) { --stack-rotate: -5deg; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(4),
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { --stack-rotate: 3deg; z-index: 1; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { --stack-rotate: 3deg; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: none; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(1) .creation-reference-card-content { z-index: 4; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(2) .creation-reference-card-content { z-index: 3; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(3) .creation-reference-card-content { z-index: 2; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+4) .creation-reference-card-content { z-index: 1; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:is(:hover, :focus-within) .creation-reference-card-content { z-index: var(--z-popover); }
.creation-home .creation-reference-track.is-expanded .creation-reference-stack-card { width: 52px; height: 69px; flex-basis: 52px; margin-left: 0; z-index: auto; }
.creation-home .creation-reference-card-content {
position: relative;
@@ -10357,10 +10380,11 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
transform-origin: center bottom;
transition: transform var(--motion-state) var(--motion-ease-out), box-shadow var(--motion-dur-fast-calc) ease;
}
.creation-home .creation-reference-track.is-expanded .creation-reference-card-content { transform: rotate(0deg); }
.creation-home .creation-reference-track.is-expanded .creation-reference-card-content { z-index: auto; transform: rotate(0deg); }
.creation-home .creation-reference-stack-card[data-framer-dragging="true"] { margin-left: 0; box-shadow: var(--elevation-overlay); z-index: var(--z-popover); }
.creation-home .creation-reference-stack-card[data-framer-dragging="true"] .creation-reference-card-content { transform: rotate(0deg) scale(1.05); }
.creation-home .creation-reference-stack-card[data-framer-dragging="true"] .creation-reference-card-content { z-index: var(--z-popover); transform: rotate(0deg) scale(1.05); }
.creation-home .creation-reference-card-preview { display: block; width: 100%; height: 100%; border: 0; background: transparent; padding: 0; cursor: zoom-in; }
.creation-home .creation-reference-card-preview.is-file { cursor: default; }
.creation-home .creation-reference-card-preview img,
.creation-home .creation-reference-card-preview video { width: 100%; height: 100%; object-fit: cover; transition: transform var(--motion-dur-fast-calc) var(--motion-ease-out); }
.creation-home .creation-reference-card-preview:hover img,
@@ -10375,13 +10399,18 @@ html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage
.creation-home .creation-reference-card-remove:hover { background: var(--status-error); }
.creation-home .creation-reference-card-remove svg { width: var(--space-3); height: var(--space-3); }
.creation-home .creation-reference-add-slot { display: flex; flex: 0 0 auto; list-style: none; }
.creation-home .creation-reference-add-button { display: grid; width: 58px; height: 77px; flex: 0 0 58px; place-items: center; border: var(--stroke-1) dashed var(--creation-border); border-radius: var(--r-sm); background: transparent; color: var(--creation-muted); padding: 0; transition: border-color var(--motion-dur-fast-calc) ease, background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease, width var(--motion-state) var(--motion-ease-out), height var(--motion-state) var(--motion-ease-out); }
.creation-home .creation-reference-add-button { display: flex; width: 58px; height: 77px; flex: 0 0 58px; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-1); border: var(--stroke-1) dashed var(--creation-border); border-radius: var(--r-sm); background: transparent; color: var(--creation-muted); padding: 0; transition: border-color var(--motion-dur-fast-calc) ease, background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease, width var(--motion-state) var(--motion-ease-out), height var(--motion-state) var(--motion-ease-out); }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-add-slot { margin-left: var(--space-2); }
.creation-home .creation-reference-track.is-empty .creation-reference-add-slot { margin-left: 0; }
.creation-home .creation-reference-track.is-expanded .creation-reference-add-button { width: 52px; height: 69px; flex-basis: 52px; }
.creation-home .creation-reference-add-button:hover:not(:disabled) { border-color: var(--creation-text); background: var(--creation-surface-hover); color: var(--creation-text); }
.creation-home .creation-reference-add-button:disabled { cursor: wait; opacity: .5; }
.creation-home .creation-reference-add-button svg { width: var(--space-5); height: var(--space-5); }
.creation-home .creation-reference-add-button span { font-size: 9px; line-height: 1; }
.creation-home .creation-reference-filter-empty { display: flex; min-height: 69px; align-items: center; list-style: none; color: var(--creation-muted); padding: 0 var(--space-3); font-size: var(--fs-caption); white-space: nowrap; }
.creation-home .creation-reference-panel-expand { display: grid; width: var(--space-8); height: var(--space-8); flex: 0 0 var(--space-8); place-items: center; align-self: center; border: var(--stroke-1) solid var(--creation-border); border-radius: var(--r-md); background: var(--creation-surface-muted); color: var(--creation-muted); padding: 0; transition: background-color var(--motion-dur-fast-calc) ease, color var(--motion-dur-fast-calc) ease, transform var(--motion-dur-fast-calc) var(--motion-ease-out); }
.creation-home .creation-reference-panel-expand:hover { background: var(--creation-surface-hover); color: var(--creation-text); transform: translateY(-1px); }
.creation-home .creation-reference-panel-expand svg { width: var(--space-4); height: var(--space-4); }
.creation-home .creation-reference-track-button { position: absolute; z-index: 1; top: 50%; display: grid; width: var(--space-6); height: var(--space-6); place-items: center; border: var(--stroke-1) solid color-mix(in srgb, var(--color-neutral-0) 12%, transparent); border-radius: var(--r-xs); background: color-mix(in srgb, var(--color-neutral-950) 76%, transparent); color: var(--color-neutral-0); padding: 0; transform: translateY(-50%); transition: background-color var(--motion-dur-fast-calc) ease; }
.creation-home .creation-reference-track-button:hover { background: color-mix(in srgb, var(--color-neutral-950) 92%, transparent); }
.creation-home .creation-reference-track-button:focus-visible { outline: var(--stroke-2) solid var(--control-focus-ring); outline-offset: var(--space-half); }
@@ -10392,8 +10421,11 @@ html:not(.dark) .creation-home .creation-reference-track-button { border-color:
html:not(.dark) .creation-home .creation-reference-add-button:hover:not(:disabled) { background: rgba(28, 32, 37, .055); }
@media (max-width: 680px) {
.creation-home .creation-reference-track-wrapper { min-height: 69px; margin-bottom: var(--space-2); }
.creation-home .creation-reference-panel { margin-bottom: var(--space-2); }
.creation-home .creation-reference-panel-header { align-items: flex-start; }
.creation-home .creation-reference-track-wrapper { min-height: 69px; }
.creation-home .creation-reference-stack-card { width: 46px; height: 61px; flex-basis: 46px; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { margin-left: -36px; }
.creation-home .creation-reference-track.is-expanded .creation-reference-stack-card { width: 42px; height: 56px; flex-basis: 42px; }
.creation-home .creation-reference-add-button { width: 46px; height: 61px; flex-basis: 46px; }
.creation-home .creation-reference-track.is-expanded .creation-reference-add-button { width: 42px; height: 56px; flex-basis: 42px; }
@@ -10403,12 +10435,10 @@ html:not(.dark) .creation-home .creation-reference-add-button:hover:not(:disable
}
@media (hover: none) {
.creation-home .creation-reference-track { gap: var(--space-2); }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { display: block; margin-left: 0; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: block; }
.creation-home .creation-reference-track:not(.is-expanded) { gap: var(--space-2); }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-stack-card { margin-left: 0; }
.creation-home .creation-reference-track:not(.is-expanded) .creation-reference-card-content { transform: rotate(0deg); }
.creation-home .creation-reference-stack-card { width: 52px; height: 69px; flex-basis: 52px; margin-left: 0; }
.creation-home .creation-reference-add-button { width: 52px; height: 69px; flex-basis: 52px; }
.creation-home .creation-reference-card-remove { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -47,10 +47,11 @@ describe("canvas resource mention editor", () => {
const project = source("../src/pages/canvas/project.tsx");
expect(editor).toContain("useResolvedCanvasResourceReferences");
expect(panel).toContain("<LoaderCircle className=\"size-3 animate-spin");
expect(panel).toContain("<LoaderCircle className=");
expect(panel).toContain("animate-spin motion-reduce:animate-none");
expect(panel).not.toContain("isRunning ? theme.accent.danger");
expect(configComposer).toContain("wrapper.dataset.referenceToken");
expect(configComposer).not.toContain('result += `@[node:');
expect(configComposer).not.toContain("result += `@[node:");
expect(project).not.toContain("removeCanvasResourceMention");
});
});

View File

@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { removeCreationAttachment } from "../src/pages/create/creation-assets";
function compactSource(source: string) {
return source.replace(/\s+/g, " ").trim();
}
@@ -61,19 +63,26 @@ describe("creation library button", () => {
expect(source).toContain('import { Reorder } from "motion/react"');
expect(source).toContain("<Reorder.Group");
expect(source).toContain('axis="x"');
expect(source).toContain("values={props.attachments}");
expect(source).toContain("onReorder={props.onReorderAttachments}");
expect(source).toContain('className="creation-reference-card-remove" onPointerDown={(event) => event.stopPropagation()}');
expect(source).toContain("values={visibleAttachments}");
expect(source).toContain("onReorder={reorderVisibleAttachments}");
expect(source).toContain('className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()}');
expect(source).toContain("<Reorder.Item");
expect(source).toContain('layout="position"');
expect(source).toContain("isExpanded");
expect(source).toContain("handleTrackMouseEnter");
expect(source).toContain("handleTrackMouseLeave");
expect(source).toContain("handleTrackFocus");
expect(source).toContain("handleTrackBlur");
expect(source).toContain("setReferencePanelExpanded");
expect(source).toContain("aria-label={`查看全部 ${props.attachments.length} 个参考内容`}");
expect(source).toContain('aria-label="收起素材面板"');
expect(source).toContain("清空全部素材");
expect(source).toContain('role="group"');
expect(source).toContain("aria-pressed={referenceFilter === filter.id}");
expect(source).toContain('{ id: "file", label: "文件", count: referenceCounts.file }');
expect(source).toContain("canDragReferences");
expect(source).toContain("creation-reference-track-wrapper");
expect(source).toContain("creation-reference-stack-card");
expect(source).toContain("creation-reference-add-button");
expect(source).toContain("addReferenceLabel");
expect(source).toContain("aria-busy={interactionBusy}");
expect(source).toContain("disabled={interactionBusy || !canAddMoreReferences}");
expect(source).toContain("creation-reference-track-button");
expect(source).toContain("imageReferenceAtPoint");
expect(source).toContain("setDropTargetReferenceId");
@@ -86,6 +95,30 @@ describe("creation library button", () => {
expect(styles).toContain(".creation-reference-stack-card");
expect(styles).toContain("--stack-rotate: -7deg");
expect(styles).toContain(".creation-reference-track.is-expanded");
expect(styles).toContain(".creation-reference-stack-card:is(:hover, :focus-within) .creation-reference-card-content");
expect(styles).toContain("@media (hover: none)");
expect(styles).toContain(".creation-reference-card-remove { opacity: 1; }");
expect(styles).not.toContain(".creation-reference-track:not(.is-expanded) .creation-reference-stack-card:nth-child(n+5) { display: block; }");
});
test("首个、中间和末尾参考内容都按稳定 id 独立删除并保留顺序", () => {
const attachments = [
{ id: "first", name: "首个" },
{ id: "middle", name: "中间" },
{ id: "last", name: "末尾" },
];
expect(removeCreationAttachment(attachments, "first").map((item) => item.id)).toEqual(["middle", "last"]);
expect(removeCreationAttachment(attachments, "middle").map((item) => item.id)).toEqual(["first", "last"]);
expect(removeCreationAttachment(attachments, "last").map((item) => item.id)).toEqual(["first", "middle"]);
});
test("删除按钮在指针按下阶段隔离拖拽,附件与素材库入口职责独立", () => {
const source = compactSource(readFileSync(resolve(import.meta.dir, "../src/pages/create/index.tsx"), "utf8"));
expect(source).toContain("onPointerDownCapture={(event) => event.stopPropagation()}");
expect(source).toContain("onRemove(item.id)");
expect(source).toContain("onClick={() => props.fileInputRef.current?.click()}");
expect(source).toContain("onClick={props.onOpenLibrary}");
});
});

View File

@@ -10,6 +10,8 @@ describe("模型 Logo 选择器", () => {
expect(source).toContain('import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js"');
expect(source).not.toContain("import.meta.glob?.(");
expect(source).toContain('"Bun" in globalThis');
expect(source).not.toContain("typeof import.meta.glob");
expect(options.length).toBeGreaterThan(300);
expect(options.some((item) => item.id === "OpenAI")).toBe(true);
expect(options.some((item) => item.id === "AgnesAI")).toBe(true);

View File

@@ -15,22 +15,24 @@ function systemConfig(input: { capability?: "image" | "video"; logicalModelId?:
apiKey: "system",
apiFormat: "openai",
models: [model],
modelCosts: [{
model,
capability,
pricePolicy: "channel",
billingMode: input.tiers[0]?.billingMode || "fixed_request",
unitPriceMicrocredits: input.tiers[0]?.unitPriceMicrocredits || 0,
logicalModelId: input.logicalModelId,
logicalPriceTiers: input.tiers.map((tier) => ({
...tier,
resolution: tier.selector.vquality || "*",
videoSeconds: Number(tier.selector.videoSeconds || 0),
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
})),
}],
modelCosts: [
{
model,
capability,
pricePolicy: "channel",
billingMode: input.tiers[0]?.billingMode || "fixed_request",
unitPriceMicrocredits: input.tiers[0]?.unitPriceMicrocredits || 0,
logicalModelId: input.logicalModelId,
logicalPriceTiers: input.tiers.map((tier) => ({
...tier,
resolution: tier.selector.vquality || "*",
videoSeconds: Number(tier.selector.videoSeconds || 0),
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
})),
},
],
});
return normalizeConfigSnapshot({
config: {
@@ -66,15 +68,17 @@ describe("model request pricing", () => {
});
const channel = resolveModelChannel(config, config.model);
expect(requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "agnes-video-2.5",
capability: "video",
config,
requirements: textVideoRequirements,
seconds: "5",
})).toBe(0.125);
expect(
requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "agnes-video-2.5",
capability: "video",
config,
requirements: textVideoRequirements,
seconds: "5",
}),
).toBe(0.125);
});
test("uses reference count and operation to avoid a text-video price tier", () => {
@@ -101,15 +105,17 @@ describe("model request pricing", () => {
});
const channel = resolveModelChannel(config, config.model);
expect(requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "image-model",
capability: "image",
config,
requirements: { capability: "image" },
count: 3,
})).toBe(0.03);
expect(
requestCreditCost({
channelMode: "remote",
modelCosts: channel.modelCosts,
model: "image-model",
capability: "image",
config,
requirements: { capability: "image" },
count: 3,
}),
).toBe(0.03);
});
test("builds a logical-model quote using the normalized current request", () => {