Compare commits

...

3 Commits

Author SHA1 Message Date
root f23c847e13 fix(agent-threads): hide prompt reference directives in thread titles
Add a shared visiblePromptText helper and strip reference tokens,
image-generator directives, and model-selection lines from agent thread
summaries and the assistant panel header, so titles show the user's
actual prompt instead of internal directives. Covered by a server test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:55:08 +08:00
root 2ede253b39 feat(clipboard): paste images and text from the system clipboard
Replace the internal single-node clipboard with real clipboard paste:
the prompt composer uploads pasted image files as references, and the
canvas pastes clipboard images as nodes and clipboard text as text
nodes anchored at the pointer, reading from the async clipboard API on
the context-menu paste action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:56 +08:00
root 07aa33d30e feat(image): preserve transparency for alpha images end to end
Skip WebP re-encoding for PNG/AVIF/BMP uploads and any bitmap that
actually contains alpha, tag transparency-capable canvas uploads with
the transparent-image tone, and detect alpha in merged layers so the
composed node keeps a transparent tone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:54:32 +08:00
10 changed files with 364 additions and 70 deletions
@@ -3,6 +3,8 @@ export async function encodeUploadImage(file: File) {
const bitmap = await createBitmap(file); const bitmap = await createBitmap(file);
try { try {
if (imageMayContainAlpha(file) && bitmapHasAlpha(bitmap)) return file;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = bitmap.width; canvas.width = bitmap.width;
canvas.height = bitmap.height; canvas.height = bitmap.height;
@@ -29,9 +31,39 @@ function shouldEncodeAsWebp(file: File) {
if (type === "image/webp" || name.endsWith(".webp")) return false; if (type === "image/webp" || name.endsWith(".webp")) return false;
if (type === "image/gif" || name.endsWith(".gif")) return false; if (type === "image/gif" || name.endsWith(".gif")) return false;
if (type === "image/svg+xml" || name.endsWith(".svg")) return false; if (type === "image/svg+xml" || name.endsWith(".svg")) return false;
if (type === "image/png" || name.endsWith(".png")) return false;
if (type === "image/avif" || name.endsWith(".avif")) return false;
if (type === "image/bmp" || name.endsWith(".bmp")) return false;
return true; return true;
} }
function imageMayContainAlpha(file: File) {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.includes("png") || name.endsWith(".png")) return true;
if (type.includes("avif") || name.endsWith(".avif")) return true;
if (type.includes("bmp") || name.endsWith(".bmp")) return true;
return !type.includes("jpeg") && !type.includes("jpg") && !name.endsWith(".jpg") && !name.endsWith(".jpeg");
}
function bitmapHasAlpha(bitmap: ImageBitmap | HTMLImageElement) {
const width = "naturalWidth" in bitmap ? bitmap.naturalWidth || bitmap.width : bitmap.width;
const height = "naturalHeight" in bitmap ? bitmap.naturalHeight || bitmap.height : bitmap.height;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return true;
context.clearRect(0, 0, width, height);
context.drawImage(bitmap, 0, 0);
const data = context.getImageData(0, 0, width, height).data;
for (let index = 3; index < data.length; index += 4) {
if (data[index] < 255) return true;
}
return false;
}
function webpFileName(fileName: string) { function webpFileName(fileName: string) {
const name = fileName.trim() || "upload"; const name = fileName.trim() || "upload";
const dotIndex = name.lastIndexOf("."); const dotIndex = name.lastIndexOf(".");
@@ -188,6 +188,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
onReferenceRemoved={onReferenceRemoved} onReferenceRemoved={onReferenceRemoved}
onAnnotationsChange={onAnnotationsChange} onAnnotationsChange={onAnnotationsChange}
onAnnotationPreviewChange={onAnnotationPreviewChange} onAnnotationPreviewChange={onAnnotationPreviewChange}
onPasteFile={onUploadFile}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
{previewSlot} {previewSlot}
@@ -311,6 +311,45 @@ function pastedReferenceImage(reference: PromptImageReference, index: number): U
}; };
} }
function isImageFile(file: File | null): file is File {
return Boolean(file && (file.type.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)));
}
function imageExtension(type: string) {
const subtype = type.split("/")[1]?.toLowerCase().split(";")[0];
if (!subtype) return "png";
if (subtype === "jpeg") return "jpg";
if (subtype === "svg+xml") return "svg";
return subtype.replace(/[^a-z0-9]+/g, "") || "png";
}
function namedClipboardImageFile(file: File, index: number) {
if (file.name.trim()) return file;
return new File([file], `pasted-image-${index + 1}.${imageExtension(file.type)}`, {
type: file.type || "image/png",
lastModified: file.lastModified || Date.now()
});
}
function latestClipboardImageFile(dataTransfer: DataTransfer) {
const seen = new Set<string>();
const files = [
...Array.from(dataTransfer.files),
...Array.from(dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
]
.filter(isImageFile)
.filter((file) => {
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map(namedClipboardImageFile);
return files.at(-1) ?? null;
}
function closestAnnotationChip(target: EventTarget | Node | null) { function closestAnnotationChip(target: EventTarget | Node | null) {
if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]"); if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]");
return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null; return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null;
@@ -549,6 +588,7 @@ export const PromptComposer = forwardRef<
onReferenceRemoved: (reference: UploadedReferenceImage) => void; onReferenceRemoved: (reference: UploadedReferenceImage) => void;
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void; onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void; onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
onPasteFile?: (file: File | null) => void | Promise<void>;
onSubmit?: () => void; onSubmit?: () => void;
} }
>(function PromptComposer( >(function PromptComposer(
@@ -565,6 +605,7 @@ export const PromptComposer = forwardRef<
onReferenceRemoved, onReferenceRemoved,
onAnnotationsChange, onAnnotationsChange,
onAnnotationPreviewChange, onAnnotationPreviewChange,
onPasteFile,
onSubmit onSubmit
}, },
ref ref
@@ -849,7 +890,25 @@ export const PromptComposer = forwardRef<
onAnnotationPreviewChange?.(null); onAnnotationPreviewChange?.(null);
}; };
const uploadPastedImageFile = async (file: File) => {
if (!onPasteFile) return;
await onPasteFile(file);
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => { const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedImage = latestClipboardImageFile(event.clipboardData);
if (pastedImage) {
event.preventDefault();
const editor = editorRef.current;
const selection = window.getSelection();
if (editor && selection?.rangeCount) {
const range = selection.getRangeAt(0);
if (rangeBelongsToEditor(range, editor)) savedRangeRef.current = range.cloneRange();
}
void uploadPastedImageFile(pastedImage);
return;
}
const pastedText = event.clipboardData.getData("text/plain"); const pastedText = event.clipboardData.getData("text/plain");
if (!pastedText) return; if (!pastedText) return;
event.preventDefault(); event.preventDefault();
@@ -75,6 +75,22 @@ export function serializePromptImageReference(reference: Pick<PromptImageReferen
return `[@image:#${fallbackIndex}:${name}:${reference.url}]`; return `[@image:#${fallbackIndex}:${name}:${reference.url}]`;
} }
export function visiblePromptText(content: string) {
const text = parsePromptImageReferences(content)
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => {
const lower = line.toLowerCase();
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
})
.join(" ")
.replace(/\s{2,}/g, " ")
.trim();
}
function collectReferenceMatches(content: string) { function collectReferenceMatches(content: string) {
const matches: ReferenceMatch[] = []; const matches: ReferenceMatch[] = [];
+119 -63
View File
@@ -30,6 +30,7 @@ import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCata
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle"; import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments"; import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
import { canvasImageUrl } from "@/ui/lib/imageDelivery"; import { canvasImageUrl } from "@/ui/lib/imageDelivery";
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters"; import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
import { CanvasColorPopover } from "./canvas/CanvasColorPopover"; import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry"; import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
@@ -600,7 +601,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]); const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
const [canvasFileDragging, setCanvasFileDragging] = useState(false); const [canvasFileDragging, setCanvasFileDragging] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [clipboardNode, setClipboardNode] = useState<CanvasNode | null>(null);
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set()); const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set());
const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set()); const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set());
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility); const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
@@ -1837,20 +1837,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}; };
const copyNode = (node: CanvasNode) => { const copyNode = (node: CanvasNode) => {
setClipboardNode(node);
void writeCanvasNodeToSystemClipboard(node) void writeCanvasNodeToSystemClipboard(node)
.then(() => showCanvasToast(t("clipboardCopied"))) .then(() => showCanvasToast(t("clipboardCopied")))
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error")); .catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
}; };
const pasteNode = (sourceNode = clipboardNode) => {
if (!sourceNode) return;
const copy = cloneCanvasNode(sourceNode, cryptoFallbackId());
setNodes((current) => [...current, copy]);
selectOnlyNode(copy.id);
setDirty(true);
};
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => { const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
setNodes((current) => reorderNodeOp(current, nodeId, action)); setNodes((current) => reorderNodeOp(current, nodeId, action));
setDirty(true); setDirty(true);
@@ -2037,6 +2028,74 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
setDirty(true); setDirty(true);
}; };
const clipboardPasteAnchor = () => {
const pointer = lastCanvasPointerRef.current;
return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
};
const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => {
const content = value.replace(/\u00a0/g, " ");
if (!content.trim()) return false;
const currentViewport = viewportRef.current;
const zoom = Math.max(currentViewport.k, 0.01);
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
const node = fitTextNodeToContent({
id: cryptoFallbackId(),
type: "text",
title: t("newTextNode"),
x: anchor.x - size.width / 2,
y: anchor.y - size.height / 2,
width: size.width,
height: size.height,
content,
tone: "ink",
status: "draft",
layerRole: "editable-text",
fontFamily: "Inter",
fontWeight: "Regular",
fontSize: defaultTextVisibleFontSize / zoom,
color: "#000000",
textAlign: "left",
opacity: 1,
strokeColor: "#000000",
strokeWidth: 0,
lineHeight: 0,
letterSpacing: 0,
textDecoration: "none",
textTransform: "none"
});
setNodes((current) => {
const next = [...current, node];
nodesRef.current = next;
return next;
});
selectOnlyNode(node.id);
setDirty(true);
return true;
};
const pasteFromClipboardData = (dataTransfer: DataTransfer) => {
const anchor = clipboardPasteAnchor();
const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image");
if (imageFile) {
void uploadImagesToCanvas([imageFile], anchor);
return true;
}
return pasteClipboardText(dataTransfer.getData("text/plain"), anchor);
};
const pasteFromSystemClipboard = async () => {
const anchor = clipboardPasteAnchor();
const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null);
if (imageFile) {
await uploadImagesToCanvas([imageFile], anchor);
return;
}
const text = await navigator.clipboard?.readText?.().catch(() => "");
if (text) pasteClipboardText(text, anchor);
};
const sendNodeToAgentComposer = (node: CanvasNode) => { const sendNodeToAgentComposer = (node: CanvasNode) => {
if (isRealCanvasImageNode(node)) { if (isRealCanvasImageNode(node)) {
insertCanvasImageReference(node, { focus: true }); insertCanvasImageReference(node, { focus: true });
@@ -2056,7 +2115,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
copyNode(node); copyNode(node);
removeNode(node.id); removeNode(node.id);
}, },
paste: pasteNode, paste: () => void pasteFromSystemClipboard(),
duplicate: () => duplicateNode(node), duplicate: () => duplicateNode(node),
bringForward: () => reorderNode(node.id, "forward"), bringForward: () => reorderNode(node.id, "forward"),
sendBackward: () => reorderNode(node.id, "backward"), sendBackward: () => reorderNode(node.id, "backward"),
@@ -2143,33 +2202,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
useEffect(() => { useEffect(() => {
const handlePaste = (event: ClipboardEvent) => { const handlePaste = (event: ClipboardEvent) => {
if (isEditableShortcutTarget(event.target)) return; if (isEditableShortcutTarget(event.target)) return;
if (!event.clipboardData) return;
const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : []; if (pasteFromClipboardData(event.clipboardData)) {
const pasteImageFiles = () => {
const pointer = lastCanvasPointerRef.current;
const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
void uploadImagesToCanvas(imageFiles, anchor);
};
if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) {
event.preventDefault(); event.preventDefault();
void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => {
if (useInternalNode) {
pasteNode(clipboardNode);
return;
} }
pasteImageFiles();
});
return;
}
if (imageFiles.length > 0) {
event.preventDefault();
pasteImageFiles();
return;
}
if (!clipboardNode) return;
event.preventDefault();
pasteNode();
}; };
window.addEventListener("paste", handlePaste); window.addEventListener("paste", handlePaste);
@@ -3095,6 +3131,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
const previewUrl = URL.createObjectURL(file); const previewUrl = URL.createObjectURL(file);
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 })); const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
const size = fitCanvasImageSize(dimensions.width, dimensions.height); const size = fitCanvasImageSize(dimensions.width, dimensions.height);
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
preparedUploads.push({ preparedUploads.push({
file, file,
previewUrl, previewUrl,
@@ -3107,7 +3144,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
width: size.width, width: size.width,
height: size.height, height: size.height,
content: previewUrl, content: previewUrl,
tone: "natural", tone,
status: "uploading" status: "uploading"
} }
}); });
@@ -3321,7 +3358,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
}} }}
selectionScale={viewport.k} selectionScale={viewport.k}
viewportScale={viewport.k} viewportScale={viewport.k}
hasClipboard={Boolean(clipboardNode)} hasClipboard
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null} contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
onPointerDown={(event) => handleNodePointerDown(event, node)} onPointerDown={(event) => handleNodePointerDown(event, node)}
onContextMenu={() => { onContextMenu={() => {
@@ -3720,7 +3757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
<aside className="assistant-panel"> <aside className="assistant-panel">
<div className="assistant-header"> <div className="assistant-header">
<div className="assistant-header-title"> <div className="assistant-header-title">
<strong>{project.brief || project.title || t("canvasUntitled")}</strong> <strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
</div> </div>
<div className="assistant-header-actions"> <div className="assistant-header-actions">
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}> <button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
@@ -5024,21 +5061,6 @@ function canvasBackgroundStorageKey(projectId: string) {
return `fluxo:canvas-background:${projectId}`; return `fluxo:canvas-background:${projectId}`;
} }
function isTransparentClipboardNode(node: CanvasNode) {
return node.type === "image" && (node.layerRole === "background-removed" || node.tone === "transparent-image");
}
async function shouldUseInternalClipboardNode(node: CanvasNode, imageFiles: File[]) {
const imageFile = imageFiles[0];
if (!imageFile) return false;
try {
const dimensions = await readImageDimensions(imageFile);
return dimensions.width === Math.max(1, Math.round(node.width)) && dimensions.height === Math.max(1, Math.round(node.height));
} catch {
return false;
}
}
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) { async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
if (node.type === "text") { if (node.type === "text") {
await writeClipboardText(node.content || node.title); await writeClipboardText(node.content || node.title);
@@ -5095,6 +5117,23 @@ function isImageFile(file: File) {
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name); return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name);
} }
function imageMaySupportTransparency(file: File) {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return (
type.includes("png") ||
type.includes("webp") ||
type.includes("avif") ||
type.includes("gif") ||
type.includes("svg") ||
name.endsWith(".png") ||
name.endsWith(".webp") ||
name.endsWith(".avif") ||
name.endsWith(".gif") ||
name.endsWith(".svg")
);
}
function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") { function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
const candidates = [ const candidates = [
...Array.from(dataTransfer.files), ...Array.from(dataTransfer.files),
@@ -5114,6 +5153,23 @@ function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "
}, []); }, []);
} }
function latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
}
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") {
if (!navigator.clipboard?.read) return null;
const items = await navigator.clipboard.read();
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
const item = items[itemIndex];
const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1);
if (!type) continue;
const blob = await item.getType(type);
return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`);
}
return null;
}
function dataTransferHasImage(dataTransfer: DataTransfer) { function dataTransferHasImage(dataTransfer: DataTransfer) {
const files = dataTransferImageFiles(dataTransfer); const files = dataTransferImageFiles(dataTransfer);
if (files.length > 0) return true; if (files.length > 0) return true;
@@ -5234,7 +5290,7 @@ function fallbackAgentThreads(project: Project | null, hiddenThreadIds: Readonly
} }
function agentThreadTitle(thread: AgentThreadSummary) { function agentThreadTitle(thread: AgentThreadSummary) {
return thread.text.trim() || "新对话"; return visiblePromptText(thread.text) || "新对话";
} }
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) { function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
@@ -5243,14 +5299,14 @@ function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlyS
} }
function agentThreadTitleText(content: string) { function agentThreadTitleText(content: string) {
const lines = content return visiblePromptText(content);
.split("\n") }
.map((line) => line.trim())
.filter((line) => { function agentHeaderTitle(messages: AgentMessage[], project: Project, fallback: string) {
const lower = line.toLowerCase(); const firstUserMessage = messages.find((message) => message.role === "user");
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择"); const firstUserText = firstUserMessage ? visiblePromptText(firstUserMessage.content) : "";
}); if (firstUserText) return firstUserText;
return lines.join(" ").trim() || content.trim(); return visiblePromptText(project.brief) || project.title.trim() || fallback;
} }
function isProjectGenerating(project: Project) { function isProjectGenerating(project: Project) {
+1
View File
@@ -734,6 +734,7 @@ export function HomePage() {
onTextChange={setPrompt} onTextChange={setPrompt}
onReferencesChange={syncReferenceImages} onReferencesChange={syncReferenceImages}
onReferenceRemoved={handleReferenceRemoved} onReferenceRemoved={handleReferenceRemoved}
onPasteFile={attachImage}
onSubmit={() => createProject()} onSubmit={() => createProject()}
/> />
<div className="prompt-actions"> <div className="prompt-actions">
@@ -44,6 +44,44 @@ func TestAgentThreadsOnlyListsAgentConversationThreads(t *testing.T) {
} }
} }
func TestAgentThreadsSummaryHidesPromptReferenceDirectives(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
now := time.Now()
project := design.Project{
ID: "project-agent-reference-history",
Title: "Agent history",
Status: design.StatusReady,
LastThreadID: "thread-agent",
UpdatedAt: now,
Messages: []design.Message{
{
ID: "agent-user",
Role: "user",
Type: "user",
Content: "Reference image 1 (image): http://localhost:19000/canvas-assets/reference.png 灰裤子拆成单件衣服,为了后面电商配图。\nSelected model: GPT Image 2",
ThreadID: "thread-agent",
CreatedAt: now,
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
threads, err := service.AgentThreads(ctx, project.ID)
if err != nil {
t.Fatal(err)
}
if len(threads) != 1 {
t.Fatalf("expected one agent thread, got %#v", threads)
}
if threads[0].Text != "灰裤子拆成单件衣服,为了后面电商配图。" {
t.Fatalf("expected visible prompt text only, got %q", threads[0].Text)
}
}
func TestProjectScopedToAgentThreadKeepsOnlyCurrentConversationContext(t *testing.T) { func TestProjectScopedToAgentThreadKeepsOnlyCurrentConversationContext(t *testing.T) {
now := time.Now() now := time.Now()
project := design.Project{ project := design.Project{
@@ -824,6 +824,7 @@ func isNonAgentConversationMessage(message design.Message) bool {
} }
func agentThreadSummaryText(content string) string { func agentThreadSummaryText(content string) string {
content = stripPromptReferenceDirectives(content)
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
kept := make([]string, 0, len(lines)) kept := make([]string, 0, len(lines))
for _, line := range lines { for _, line := range lines {
@@ -837,7 +838,7 @@ func agentThreadSummaryText(content string) string {
if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" { if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" {
return text return text
} }
return strings.TrimSpace(content) return ""
} }
func projectScopedToAgentThread(project design.Project, threadID string) design.Project { func projectScopedToAgentThread(project design.Project, threadID string) design.Project {
@@ -4530,7 +4531,7 @@ func hasUserPromptTextInAgentMessages(messages []design.AgentChatMessage) bool {
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") { if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
continue continue
} }
text := stripPromptReferenceDirectiveLines(stripImageGeneratorDirectiveLines(content.Text)) text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text))
if design.NormalizePrompt(text) != "" { if design.NormalizePrompt(text) != "" {
return true return true
} }
@@ -4551,12 +4552,14 @@ func stripImageGeneratorDirectiveLines(text string) string {
return strings.TrimSpace(strings.Join(filtered, "\n")) return strings.TrimSpace(strings.Join(filtered, "\n"))
} }
func stripPromptReferenceDirectiveLines(text string) string { func stripPromptReferenceDirectives(text string) string {
text = promptImageTokenPattern.ReplaceAllString(text, "")
text = promptImageDirectivePattern.ReplaceAllString(text, "")
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
filtered := make([]string, 0, len(lines)) filtered := make([]string, 0, len(lines))
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " ")) trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " "))
if isPromptReferenceDirectiveLine(trimmed) { if trimmed == "" || isPromptReferenceDirectiveLine(trimmed) {
continue continue
} }
filtered = append(filtered, line) filtered = append(filtered, line)
+22 -3
View File
@@ -309,6 +309,7 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
drawTextLayer(canvas, node, bounds, scale) drawTextLayer(canvas, node, bounds, scale)
} }
} }
hasTransparency := mergedLayerHasTransparency(canvas)
var output bytes.Buffer var output bytes.Buffer
if err := png.Encode(&output, canvas); err != nil { if err := png.Encode(&output, canvas); err != nil {
@@ -338,6 +339,10 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
if title == "" { if title == "" {
title = "Merged Layer" title = "Merged Layer"
} }
tone := "natural"
if hasTransparency {
tone = "transparent-image"
}
return design.Node{ return design.Node{
ID: newID(), ID: newID(),
Type: design.NodeTypeImage, Type: design.NodeTypeImage,
@@ -347,11 +352,25 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
Width: bounds.Width, Width: bounds.Width,
Height: bounds.Height, Height: bounds.Height,
Content: content, Content: content,
Tone: "natural", Tone: tone,
Status: "success", Status: "success",
}, nil }, nil
} }
func mergedLayerHasTransparency(img *image.NRGBA) bool {
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
offset := img.PixOffset(bounds.Min.X, y)
for x := bounds.Min.X; x < bounds.Max.X; x++ {
if img.Pix[offset+3] < 255 {
return true
}
offset += 4
}
}
return false
}
func boundsForLayerMerge(nodes []design.Node) layerMergeBounds { func boundsForLayerMerge(nodes []design.Node) layerMergeBounds {
left := math.Inf(1) left := math.Inf(1)
top := math.Inf(1) top := math.Inf(1)
@@ -446,8 +465,8 @@ func sampleLayerColor(src image.Image, srcBounds image.Rectangle, srcWidth int,
} }
u = clampFloat(u, 0, 1) u = clampFloat(u, 0, 1)
v = clampFloat(v, 0, 1) v = clampFloat(v, 0, 1)
fx := u * float64(srcWidth-1) fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1))
fy := v * float64(srcHeight-1) fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1))
x0 := int(math.Floor(fx)) x0 := int(math.Floor(fx))
y0 := int(math.Floor(fy)) y0 := int(math.Floor(fy))
x1 := clampInt(x0+1, 0, srcWidth-1) x1 := clampInt(x0+1, 0, srcWidth-1)
@@ -129,6 +129,71 @@ func TestMergeLayersFlattensTextOverImage(t *testing.T) {
} }
} }
func TestMergeLayersPreservesTransparentPixels(t *testing.T) {
ctx := context.Background()
repo := memory.NewProjectRepository()
service := NewDesignService(repo, nil, nil, nil, nil)
transparentLayer := image.NewNRGBA(image.Rect(0, 0, 3, 2))
transparentLayer.SetNRGBA(1, 0, color.NRGBA{R: 255, A: 255})
project := design.Project{
ID: "project-merge-transparent",
Title: "Merge transparent",
Status: design.StatusReady,
UpdatedAt: time.Now(),
Nodes: []design.Node{
{
ID: "transparent",
Type: design.NodeTypeImage,
Title: "Transparent",
X: 0,
Y: 0,
Width: 3,
Height: 2,
Content: testPNGDataURLFromImage(transparentLayer),
Status: "success",
},
{
ID: "blue",
Type: design.NodeTypeImage,
Title: "Blue",
X: 2,
Y: 1,
Width: 1,
Height: 1,
Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 1, 1),
Status: "success",
},
},
}
if err := repo.Save(ctx, project); err != nil {
t.Fatal(err)
}
if _, err := service.MergeLayers(ctx, project.ID, []string{"transparent", "blue"}, "Transparent merge"); err != nil {
t.Fatal(err)
}
merged := waitForMergedProject(t, repo, project.ID)
node := merged.Nodes[0]
if node.Tone != "transparent-image" {
t.Fatalf("expected transparent merged image tone, got %q", node.Tone)
}
imageData, _, _, ok, err := decodeDataImage(node.Content)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatalf("expected merged content data URL, got %s", node.Content)
}
decoded, err := png.Decode(bytes.NewReader(imageData))
if err != nil {
t.Fatal(err)
}
assertPixel(t, decoded, 0, 0, color.NRGBA{})
assertPixel(t, decoded, 1, 0, color.NRGBA{R: 255, A: 255})
assertPixel(t, decoded, 2, 1, color.NRGBA{B: 255, A: 255})
}
func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project { func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
t.Helper() t.Helper()
deadline := time.After(2 * time.Second) deadline := time.After(2 * time.Second)
@@ -160,6 +225,10 @@ func testPNGDataURL(fill color.NRGBA, width int, height int) string {
img.SetNRGBA(x, y, fill) img.SetNRGBA(x, y, fill)
} }
} }
return testPNGDataURLFromImage(img)
}
func testPNGDataURLFromImage(img image.Image) string {
var buf bytes.Buffer var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil { if err := png.Encode(&buf, img); err != nil {
panic(err) panic(err)