Compare commits
5 Commits
main
...
codex/48-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daad7cbcef | ||
|
|
4bb5d3b9f2 | ||
|
|
a7218f9905 | ||
|
|
69705518d6 | ||
|
|
f6f4f94245 |
@ -1056,6 +1056,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
|
|||||||
imageRemoveBackground: Scissors,
|
imageRemoveBackground: Scissors,
|
||||||
imageBoxCutout: Scan,
|
imageBoxCutout: Scan,
|
||||||
imageLocalEdit: Wand2,
|
imageLocalEdit: Wand2,
|
||||||
|
imagePsdTemplateReplace: ImageIcon,
|
||||||
imageRemoveWatermark: Eraser,
|
imageRemoveWatermark: Eraser,
|
||||||
imageResize: Maximize2,
|
imageResize: Maximize2,
|
||||||
imageTextEdit: Type,
|
imageTextEdit: Type,
|
||||||
|
|||||||
@ -576,7 +576,7 @@ export async function POST(request: Request) {
|
|||||||
: "image/jpeg"),
|
: "image/jpeg"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await persistPendingClawdTask({
|
const persistedTask = await persistPendingClawdTask({
|
||||||
chatId: parsed.id,
|
chatId: parsed.id,
|
||||||
userMessageId,
|
userMessageId,
|
||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
@ -587,6 +587,22 @@ export async function POST(request: Request) {
|
|||||||
skillTagName,
|
skillTagName,
|
||||||
processingHint,
|
processingHint,
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
persistedTask &&
|
||||||
|
persistedTask.assistantMessageId !== assistantMessageId
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
buildTaskResponse({
|
||||||
|
chatId: parsed.id,
|
||||||
|
userMessageId,
|
||||||
|
assistantMessageId: persistedTask.assistantMessageId,
|
||||||
|
status: persistedTask.status,
|
||||||
|
processingHint:
|
||||||
|
persistedTask.processingHint || DEFAULT_PROCESSING_HINT,
|
||||||
|
}),
|
||||||
|
{ status: 202, headers: { "Retry-After": "2" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const userProfile = await getUserById({ id: session.user.id }).catch(
|
const userProfile = await getUserById({ id: session.user.id }).catch(
|
||||||
() => null
|
() => null
|
||||||
|
|||||||
@ -504,6 +504,21 @@ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
|
|||||||
]);
|
]);
|
||||||
const CLAWD_TABLE_MAX_FILES = 3;
|
const CLAWD_TABLE_MAX_FILES = 3;
|
||||||
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
|
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isClawdSpreadsheetAttachment(
|
||||||
|
url: string,
|
||||||
|
contentType?: string | null,
|
||||||
|
name?: string | null
|
||||||
|
) {
|
||||||
|
const normalizedType = String(contentType || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedType.includes("spreadsheet") ||
|
||||||
|
normalizedType === "text/csv" ||
|
||||||
|
normalizedType === "text/tab-separated-values" ||
|
||||||
|
/\.(xlsx|csv|tsv)(?:$|[?#])/i.test(String(url || "")) ||
|
||||||
|
/\.(xlsx|csv|tsv)$/i.test(String(name || "").trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
const CLAWD_PROCESSING_HINT =
|
const CLAWD_PROCESSING_HINT =
|
||||||
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
|
||||||
const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000;
|
const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000;
|
||||||
@ -923,19 +938,23 @@ function parseClawdSpreadsheetAttachmentsFromText(
|
|||||||
.filter((part: any) => part?.type === "text" && part?.text)
|
.filter((part: any) => part?.type === "text" && part?.text)
|
||||||
.map((part: any) => String(part.text))
|
.map((part: any) => String(part.text))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const summaryMatch = text.match(
|
|
||||||
/(?:^|\n)已上传表格:\n((?:- [^\n]+(?:\n|$))+)/m
|
|
||||||
);
|
|
||||||
if (!summaryMatch?.[1]) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachments: ClawdSpreadsheetAttachment[] = [];
|
const attachments: ClawdSpreadsheetAttachment[] = [];
|
||||||
for (const line of summaryMatch[1].trim().split("\n")) {
|
const sectionPattern =
|
||||||
|
/(?:^|\n)(已上传表格|已上传文件):\n((?:- [^\n]+(?:\n|$))+)/gm;
|
||||||
|
for (const sectionMatch of text.matchAll(sectionPattern)) {
|
||||||
|
const sectionType = String(sectionMatch[1] || "");
|
||||||
|
const sectionLines = String(sectionMatch[2] || "");
|
||||||
|
for (const line of sectionLines.trim().split("\n")) {
|
||||||
const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/);
|
const lineMatch = line.match(/^- (.+?)(?:((.+)))?$/);
|
||||||
const name = String(lineMatch?.[1] || "").trim();
|
const name = String(lineMatch?.[1] || "").trim();
|
||||||
const details = String(lineMatch?.[2] || "");
|
const details = String(lineMatch?.[2] || "");
|
||||||
if (!name) continue;
|
if (
|
||||||
|
!name ||
|
||||||
|
(sectionType === "已上传文件" &&
|
||||||
|
!isClawdSpreadsheetAttachment("", "", name))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i);
|
const sizeMatch = details.match(/([\d.]+)\s*(KB|MB)/i);
|
||||||
const sheetCountMatch = details.match(/(\d+)\s*个工作表/);
|
const sheetCountMatch = details.match(/(\d+)\s*个工作表/);
|
||||||
@ -956,6 +975,7 @@ function parseClawdSpreadsheetAttachmentsFromText(
|
|||||||
truncated: details.includes("已截断"),
|
truncated: details.includes("已截断"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return attachments;
|
return attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -977,11 +997,7 @@ function getClawdSpreadsheetAttachments(
|
|||||||
: Array.isArray(videoMetadata.spreadsheetAttachments)
|
: Array.isArray(videoMetadata.spreadsheetAttachments)
|
||||||
? videoMetadata.spreadsheetAttachments
|
? videoMetadata.spreadsheetAttachments
|
||||||
: [];
|
: [];
|
||||||
if (rawAttachments.length === 0) {
|
const normalizedAttachments = rawAttachments
|
||||||
return parseClawdSpreadsheetAttachmentsFromText(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawAttachments
|
|
||||||
.map((attachment: any) => {
|
.map((attachment: any) => {
|
||||||
const name = String(
|
const name = String(
|
||||||
attachment?.name || attachment?.fileName || ""
|
attachment?.name || attachment?.fileName || ""
|
||||||
@ -1015,14 +1031,72 @@ function getClawdSpreadsheetAttachments(
|
|||||||
attachment: ClawdSpreadsheetAttachment | null
|
attachment: ClawdSpreadsheetAttachment | null
|
||||||
): attachment is ClawdSpreadsheetAttachment => Boolean(attachment)
|
): attachment is ClawdSpreadsheetAttachment => Boolean(attachment)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const knownNames = new Set(
|
||||||
|
normalizedAttachments.map((attachment: ClawdSpreadsheetAttachment) =>
|
||||||
|
attachment.name.toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const inferredAttachments = [
|
||||||
|
...(((message as any).attachments || []) as any[]),
|
||||||
|
...((message.parts || []).filter((part: any) => part?.type === "file") as any[]),
|
||||||
|
];
|
||||||
|
for (const attachment of inferredAttachments) {
|
||||||
|
const name = String(attachment?.name || attachment?.fileName || "").trim();
|
||||||
|
const url = String(attachment?.url || attachment?.data || "").trim();
|
||||||
|
const contentType = String(
|
||||||
|
attachment?.contentType ||
|
||||||
|
attachment?.content_type ||
|
||||||
|
attachment?.mediaType ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
if (
|
||||||
|
!name ||
|
||||||
|
knownNames.has(name.toLowerCase()) ||
|
||||||
|
!isClawdSpreadsheetAttachment(url, contentType, name)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
normalizedAttachments.push({ name, contentType: contentType || undefined });
|
||||||
|
knownNames.add(name.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripClawdSpreadsheetSummary(text: string) {
|
for (const attachment of parseClawdSpreadsheetAttachmentsFromText(message)) {
|
||||||
|
if (knownNames.has(attachment.name.toLowerCase())) continue;
|
||||||
|
normalizedAttachments.push(attachment);
|
||||||
|
knownNames.add(attachment.name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedAttachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripClawdSpreadsheetSummary(
|
||||||
|
text: string,
|
||||||
|
attachments: ClawdSpreadsheetAttachment[]
|
||||||
|
) {
|
||||||
|
const spreadsheetNames = new Set(
|
||||||
|
attachments.map((attachment) => attachment.name.toLowerCase())
|
||||||
|
);
|
||||||
return text
|
return text
|
||||||
.replace(
|
.replace(
|
||||||
/(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g,
|
/(?:^|\n{2})已上传表格:\n(?:- [^\n]*(?:\n|$))+/g,
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
|
.replace(
|
||||||
|
/(?:^|\n{2})已上传文件:\n((?:- [^\n]*(?:\n|$))+)/g,
|
||||||
|
(_section, lines: string) => {
|
||||||
|
const remainingLines = String(lines)
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => {
|
||||||
|
const name = line.replace(/^- /, "").trim().toLowerCase();
|
||||||
|
return !spreadsheetNames.has(name);
|
||||||
|
});
|
||||||
|
return remainingLines.length > 0
|
||||||
|
? `\n\n已上传文件:\n${remainingLines.join("\n")}`
|
||||||
|
: "\n";
|
||||||
|
}
|
||||||
|
)
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
@ -12233,6 +12307,7 @@ export function CanvasChatPanel({
|
|||||||
id: userMessageId,
|
id: userMessageId,
|
||||||
role: "user",
|
role: "user",
|
||||||
parts: [{ type: "text", text: optimisticMessageText }],
|
parts: [{ type: "text", text: optimisticMessageText }],
|
||||||
|
attachments: sanitizedAttachmentsSnapshot,
|
||||||
createdAt: optimisticCreatedAt,
|
createdAt: optimisticCreatedAt,
|
||||||
metadata: {
|
metadata: {
|
||||||
createdAt: optimisticCreatedAt.toISOString(),
|
createdAt: optimisticCreatedAt.toISOString(),
|
||||||
@ -15913,7 +15988,10 @@ export function CanvasChatPanel({
|
|||||||
.trim();
|
.trim();
|
||||||
const text =
|
const text =
|
||||||
spreadsheetAttachments.length > 0
|
spreadsheetAttachments.length > 0
|
||||||
? stripClawdSpreadsheetSummary(normalizedText)
|
? stripClawdSpreadsheetSummary(
|
||||||
|
normalizedText,
|
||||||
|
spreadsheetAttachments
|
||||||
|
)
|
||||||
: normalizedText;
|
: normalizedText;
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
renderedUserParts.push(
|
renderedUserParts.push(
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
|
FileImage,
|
||||||
Flag,
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Heart,
|
Heart,
|
||||||
@ -715,8 +716,14 @@ export function CanvasWorkspace({
|
|||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLocalEdit"
|
"imageLocalEdit"
|
||||||
);
|
);
|
||||||
|
const selectedImagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"imagePsdTemplateReplace"
|
||||||
|
);
|
||||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||||
const selectedImageLocalEditEnabled = true;
|
const selectedImageLocalEditEnabled = true;
|
||||||
|
// 模版替换属于主无限画布图片的固定入口,兼容没有新 key 的历史配置。
|
||||||
|
const selectedImagePsdTemplateReplaceEnabled = true;
|
||||||
|
|
||||||
// Store actions
|
// Store actions
|
||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
@ -2529,6 +2536,29 @@ export function CanvasWorkspace({
|
|||||||
{selectedImageLocalEditItem.label}
|
{selectedImageLocalEditItem.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selectedImagePsdTemplateReplaceEnabled &&
|
||||||
|
selectedImageNodes.length === 1 ? (
|
||||||
|
<Button
|
||||||
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 border border-border/60 px-3 text-xs shadow-sm",
|
||||||
|
isWorkflowCanvas &&
|
||||||
|
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
requestImageNodeAction(selectedImageNodes[0].id, "psd-template")
|
||||||
|
}
|
||||||
|
title={selectedImagePsdTemplateReplaceItem.label}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fallback={FileImage}
|
||||||
|
item={selectedImagePsdTemplateReplaceItem}
|
||||||
|
/>
|
||||||
|
{selectedImagePsdTemplateReplaceItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||||
|
import { isOpenClawChatFileAttachment } from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
|
||||||
export const SESSION_TITLE_MAX_LEN = 18;
|
export const SESSION_TITLE_MAX_LEN = 18;
|
||||||
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
export const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
||||||
@ -80,6 +81,9 @@ export function normalizeChatMediaType(value?: string | null): string {
|
|||||||
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
if (ALLOWED_CHAT_MEDIA_TYPES.has(normalized)) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
if (isOpenClawChatFileAttachment("", normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
return "image/jpeg";
|
return "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Eraser,
|
Eraser,
|
||||||
Expand,
|
Expand,
|
||||||
|
FileImage,
|
||||||
FlipHorizontal2,
|
FlipHorizontal2,
|
||||||
FlipVertical2,
|
FlipVertical2,
|
||||||
Globe2,
|
Globe2,
|
||||||
@ -130,10 +131,15 @@ import {
|
|||||||
storeCanvasAgentAnnotationEditRequest,
|
storeCanvasAgentAnnotationEditRequest,
|
||||||
type CanvasAgentPendingRequestWindow,
|
type CanvasAgentPendingRequestWindow,
|
||||||
} from "@/lib/canvas/agent-pending-requests";
|
} from "@/lib/canvas/agent-pending-requests";
|
||||||
|
import {
|
||||||
|
createCanvasPsdTemplateLaunchToken,
|
||||||
|
saveCanvasPsdTemplateLaunchPayload,
|
||||||
|
} from "@/lib/canvas/psd-template-launch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@ -1347,6 +1353,10 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
|
||||||
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
const imageLocalEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLocalEdit");
|
||||||
|
const imagePsdTemplateReplaceItem = getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"imagePsdTemplateReplace"
|
||||||
|
);
|
||||||
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
const imageRemoveWatermarkItem = getCanvasToolbarItem(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageRemoveWatermark"
|
"imageRemoveWatermark"
|
||||||
@ -1419,6 +1429,8 @@ export function ImageNode({
|
|||||||
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
// 局部编辑是无限画布图片的核心入口:即使旧后台工具栏配置里还没有该 key,
|
||||||
// 也必须默认展示,避免历史配置把新功能吞掉。
|
// 也必须默认展示,避免历史配置把新功能吞掉。
|
||||||
const imageLocalEditEnabled = true;
|
const imageLocalEditEnabled = true;
|
||||||
|
// 模版替换的页面和接口已经存在;旧工具栏配置没有这个 key 时也要保留入口。
|
||||||
|
const imagePsdTemplateReplaceEnabled = true;
|
||||||
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
const imageLayerSplitEnabledByToolbar = isCanvasToolbarItemEnabled(
|
||||||
canvasToolbarConfig,
|
canvasToolbarConfig,
|
||||||
"imageLayerSplit"
|
"imageLayerSplit"
|
||||||
@ -1484,6 +1496,7 @@ export function ImageNode({
|
|||||||
{ key: "textEdit", enabled: imageTextEditEnabled },
|
{ key: "textEdit", enabled: imageTextEditEnabled },
|
||||||
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
{ key: "boxCutout", enabled: imageBoxCutoutEnabled },
|
||||||
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
{ key: "localEdit", enabled: imageLocalEditEnabled },
|
||||||
|
{ key: "psdTemplate", enabled: imagePsdTemplateReplaceEnabled },
|
||||||
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
{ key: "removeWatermark", enabled: imageRemoveWatermarkEnabled },
|
||||||
{ key: "resize", enabled: imageResizeEnabled },
|
{ key: "resize", enabled: imageResizeEnabled },
|
||||||
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
{ key: "multiAngle", enabled: imageMultiAngleEnabled },
|
||||||
@ -1519,6 +1532,7 @@ export function ImageNode({
|
|||||||
imageRemoveBackgroundEnabled ||
|
imageRemoveBackgroundEnabled ||
|
||||||
imageRemoveWatermarkEnabled ||
|
imageRemoveWatermarkEnabled ||
|
||||||
imageEnhanceEnabled ||
|
imageEnhanceEnabled ||
|
||||||
|
imagePsdTemplateReplaceEnabled ||
|
||||||
imageNoteEnabled ||
|
imageNoteEnabled ||
|
||||||
hasCanvasAgentAnnotations ||
|
hasCanvasAgentAnnotations ||
|
||||||
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
(isLayerSplitEnabled && imageLayerSplitEnabledByToolbar) ||
|
||||||
@ -1677,6 +1691,37 @@ export function ImageNode({
|
|||||||
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
DEFAULT_CANVAS_WORLD_MODEL || "world-standard"
|
||||||
);
|
);
|
||||||
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
const [regeneratePrompt, setRegeneratePrompt] = useState("");
|
||||||
|
const [psdTemplateDialogOpen, setPsdTemplateDialogOpen] = useState(false);
|
||||||
|
const [psdTemplateBrandName, setPsdTemplateBrandName] = useState("");
|
||||||
|
const { data: psdTemplateSummary, isLoading: isLoadingPsdTemplateSummary } =
|
||||||
|
useSWR<{
|
||||||
|
success?: boolean;
|
||||||
|
templates?: Array<{ brandName?: string | null }>;
|
||||||
|
}>(psdTemplateDialogOpen ? "/api/psd-templates?summary=1" : null, fetcher);
|
||||||
|
const availablePsdTemplateBrands = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
(psdTemplateSummary?.templates || [])
|
||||||
|
.map((template) => String(template?.brandName || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[psdTemplateSummary?.templates]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
psdTemplateDialogOpen &&
|
||||||
|
!psdTemplateBrandName.trim() &&
|
||||||
|
availablePsdTemplateBrands.length === 1
|
||||||
|
) {
|
||||||
|
setPsdTemplateBrandName(availablePsdTemplateBrands[0] || "");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
availablePsdTemplateBrands,
|
||||||
|
psdTemplateBrandName,
|
||||||
|
psdTemplateDialogOpen,
|
||||||
|
]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
@ -6052,6 +6097,53 @@ export function ImageNode({
|
|||||||
await detectTextItems(true);
|
await detectTextItems(true);
|
||||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||||
|
|
||||||
|
const handleOpenPsdTemplateReplace = useCallback(() => {
|
||||||
|
if (!canvasId) {
|
||||||
|
toast.error("当前图片未绑定画布,暂时无法打开模版替换");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.src || !isHttpImageUrl(data.src)) {
|
||||||
|
toast.error("图片上传完成后再进行模版替换");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPsdTemplateDialogOpen(true);
|
||||||
|
}, [canvasId, data.src]);
|
||||||
|
|
||||||
|
const handleLaunchPsdTemplateReplace = useCallback(() => {
|
||||||
|
const normalizedBrandName = psdTemplateBrandName.trim();
|
||||||
|
if (!canvasId || !data.src || !isHttpImageUrl(data.src)) {
|
||||||
|
toast.error("当前图片暂时无法进行模版替换");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!normalizedBrandName) {
|
||||||
|
toast.error("请先选择或输入品牌名");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createCanvasPsdTemplateLaunchToken();
|
||||||
|
const returnTo =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.pathname}${window.location.search}`
|
||||||
|
: `/canvas/${canvasId}`;
|
||||||
|
saveCanvasPsdTemplateLaunchPayload(token, {
|
||||||
|
brandName: normalizedBrandName,
|
||||||
|
pendingImages: [
|
||||||
|
{
|
||||||
|
id: node.id,
|
||||||
|
imageUrl: data.src,
|
||||||
|
title: displayTitle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeImageId: node.id,
|
||||||
|
returnTo,
|
||||||
|
templateMode: "main",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setPsdTemplateDialogOpen(false);
|
||||||
|
router.push(`/canvas/${canvasId}/psd-template?launch=${encodeURIComponent(token)}`);
|
||||||
|
}, [canvasId, data.src, displayTitle, node.id, psdTemplateBrandName, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
||||||
return;
|
return;
|
||||||
@ -6071,11 +6163,17 @@ export function ImageNode({
|
|||||||
|
|
||||||
if (imageNodeActionRequest.action === "local-edit") {
|
if (imageNodeActionRequest.action === "local-edit") {
|
||||||
handleEnterLocalEditMode();
|
handleEnterLocalEditMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageNodeActionRequest.action === "psd-template") {
|
||||||
|
handleOpenPsdTemplateReplace();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
clearImageNodeActionRequest,
|
clearImageNodeActionRequest,
|
||||||
handleEnterBoxCutoutMode,
|
handleEnterBoxCutoutMode,
|
||||||
handleEnterLocalEditMode,
|
handleEnterLocalEditMode,
|
||||||
|
handleOpenPsdTemplateReplace,
|
||||||
handleOpenTextEdit,
|
handleOpenTextEdit,
|
||||||
imageNodeActionRequest,
|
imageNodeActionRequest,
|
||||||
node.id,
|
node.id,
|
||||||
@ -6804,6 +6902,10 @@ export function ImageNode({
|
|||||||
handleEnterLocalEditMode();
|
handleEnterLocalEditMode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "psd-template") {
|
||||||
|
handleOpenPsdTemplateReplace();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action === "scale-adjust") {
|
if (action === "scale-adjust") {
|
||||||
handleEnterLocalEditMode(undefined, "scale_adjust");
|
handleEnterLocalEditMode(undefined, "scale_adjust");
|
||||||
return;
|
return;
|
||||||
@ -9649,6 +9751,16 @@ export function ImageNode({
|
|||||||
{imageTextEditItem.label}
|
{imageTextEditItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{imagePsdTemplateReplaceEnabled ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={FileImage}
|
||||||
|
item={imagePsdTemplateReplaceItem}
|
||||||
|
/>
|
||||||
|
<span>{imagePsdTemplateReplaceItem.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{imageExtendEnabled ? (
|
{imageExtendEnabled ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
@ -9800,6 +9912,90 @@ export function ImageNode({
|
|||||||
prompt={regeneratePrompt}
|
prompt={regeneratePrompt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog onOpenChange={setPsdTemplateDialogOpen} open={psdTemplateDialogOpen}>
|
||||||
|
<DialogContent className="w-[calc(100vw-1.5rem)] max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileImage className="h-5 w-5" />
|
||||||
|
模版替换
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
当前图片会作为替换素材带入 PSD 模版工作台。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3 rounded-lg border bg-muted/20 p-3">
|
||||||
|
<img
|
||||||
|
alt={displayTitle}
|
||||||
|
className="aspect-square h-[88px] w-[88px] rounded-md border bg-background object-cover"
|
||||||
|
src={buildImageProxyUrl(data.src, { width: 240 })}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 self-center">
|
||||||
|
<div className="truncate font-medium text-sm">{displayTitle}</div>
|
||||||
|
<div className="mt-1 text-muted-foreground text-xs">
|
||||||
|
已选择 1 张画布图片
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingPsdTemplateSummary ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
正在读取模版品牌...
|
||||||
|
</div>
|
||||||
|
) : availablePsdTemplateBrands.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>快捷选择</Label>
|
||||||
|
<div className="flex max-h-32 flex-wrap gap-2 overflow-y-auto">
|
||||||
|
{availablePsdTemplateBrands.map((brand) => {
|
||||||
|
const active = psdTemplateBrandName.trim() === brand;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1.5 text-sm transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background hover:bg-muted"
|
||||||
|
)}
|
||||||
|
key={brand}
|
||||||
|
onClick={() => setPsdTemplateBrandName(brand)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{brand}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`psd-template-brand-${node.id}`}>品牌名</Label>
|
||||||
|
<Input
|
||||||
|
id={`psd-template-brand-${node.id}`}
|
||||||
|
onChange={(event) => setPsdTemplateBrandName(event.target.value)}
|
||||||
|
placeholder="选择或输入已配置 PSD 模版的品牌名"
|
||||||
|
value={psdTemplateBrandName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setPsdTemplateDialogOpen(false)} variant="outline">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!psdTemplateBrandName.trim()}
|
||||||
|
onClick={handleLaunchPsdTemplateReplace}
|
||||||
|
>
|
||||||
|
<FileImage className="mr-2 h-4 w-4" />
|
||||||
|
打开模版替换
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ThreeDGenerationDialog
|
<ThreeDGenerationDialog
|
||||||
costLabel={
|
costLabel={
|
||||||
threedGenerationDialogMode === "world"
|
threedGenerationDialogMode === "world"
|
||||||
@ -10377,6 +10573,23 @@ export function ImageNode({
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isSelectedImageActionInline("psdTemplate") ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
|
disabled={isProcessing}
|
||||||
|
onClick={() => handleAction("psd-template")}
|
||||||
|
title={imagePsdTemplateReplaceItem.label}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={FileImage}
|
||||||
|
item={imagePsdTemplateReplaceItem}
|
||||||
|
/>
|
||||||
|
{imagePsdTemplateReplaceItem.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isSelectedImageActionInline("removeWatermark") ? (
|
{isSelectedImageActionInline("removeWatermark") ? (
|
||||||
<Button
|
<Button
|
||||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||||
@ -10650,6 +10863,16 @@ export function ImageNode({
|
|||||||
<span>{imageLocalEditItem.label}</span>
|
<span>{imageLocalEditItem.label}</span>
|
||||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{isSelectedImageActionInMore("psdTemplate") ? (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("psd-template")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={FileImage}
|
||||||
|
item={imagePsdTemplateReplaceItem}
|
||||||
|
/>
|
||||||
|
<span>{imagePsdTemplateReplaceItem.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{isSelectedImageActionInMore("removeWatermark") ? (
|
{isSelectedImageActionInMore("removeWatermark") ? (
|
||||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||||
|
|||||||
@ -220,6 +220,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
|
|||||||
label: "局部编辑",
|
label: "局部编辑",
|
||||||
description: "框选多个彩色区域并按区域提示词局部改图",
|
description: "框选多个彩色区域并按区域提示词局部改图",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "imagePsdTemplateReplace",
|
||||||
|
group: "节点工具栏",
|
||||||
|
label: "模版替换",
|
||||||
|
description: "把当前图片带入已配置的 PSD 模版进行素材替换",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "imageRemoveWatermark",
|
key: "imageRemoveWatermark",
|
||||||
group: "节点工具栏",
|
group: "节点工具栏",
|
||||||
|
|||||||
@ -207,6 +207,21 @@ export const CANVAS_CAPABILITY_REGISTRY = [
|
|||||||
inputSchema: promptArgsSchema,
|
inputSchema: promptArgsSchema,
|
||||||
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
agent: { toolName: "localEditImage", aliases: ["局部编辑", "局部重绘"] },
|
||||||
}),
|
}),
|
||||||
|
defineCapability({
|
||||||
|
id: "image.psd_template_replace",
|
||||||
|
title: "模版替换",
|
||||||
|
description: "把选中图片带入已配置的 PSD 模版工作台进行素材替换。",
|
||||||
|
clientAction: "psd-template",
|
||||||
|
toolbarActionKey: "psdTemplate",
|
||||||
|
toolbarSettingKey: "imagePsdTemplateReplace",
|
||||||
|
executionMode: "interactive",
|
||||||
|
risk: "write",
|
||||||
|
modelPolicy: "none",
|
||||||
|
confirmation: "interactive",
|
||||||
|
inputSchema: emptyArgsSchema,
|
||||||
|
agent: { exposed: false },
|
||||||
|
mcp: { exposed: false },
|
||||||
|
}),
|
||||||
defineCapability({
|
defineCapability({
|
||||||
id: "image.scale_adjust",
|
id: "image.scale_adjust",
|
||||||
title: "比例调整",
|
title: "比例调整",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export const CANVAS_CAPABILITY_IDS = [
|
|||||||
"image.text_edit",
|
"image.text_edit",
|
||||||
"image.box_cutout",
|
"image.box_cutout",
|
||||||
"image.local_edit",
|
"image.local_edit",
|
||||||
|
"image.psd_template_replace",
|
||||||
"image.scale_adjust",
|
"image.scale_adjust",
|
||||||
"image.remove_watermark",
|
"image.remove_watermark",
|
||||||
"image.resize",
|
"image.resize",
|
||||||
@ -38,6 +39,7 @@ export type CanvasCapabilityClientAction =
|
|||||||
| "text-edit"
|
| "text-edit"
|
||||||
| "box-cutout"
|
| "box-cutout"
|
||||||
| "local-edit"
|
| "local-edit"
|
||||||
|
| "psd-template"
|
||||||
| "scale-adjust"
|
| "scale-adjust"
|
||||||
| "watermark"
|
| "watermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
@ -63,6 +65,7 @@ export type SelectedImageToolbarActionKey =
|
|||||||
| "textEdit"
|
| "textEdit"
|
||||||
| "boxCutout"
|
| "boxCutout"
|
||||||
| "localEdit"
|
| "localEdit"
|
||||||
|
| "psdTemplate"
|
||||||
| "removeWatermark"
|
| "removeWatermark"
|
||||||
| "resize"
|
| "resize"
|
||||||
| "multiAngle"
|
| "multiAngle"
|
||||||
|
|||||||
@ -27,6 +27,27 @@ const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
|||||||
|
|
||||||
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
|
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
|
||||||
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
|
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
|
||||||
|
const FILE_EXTENSION_PATTERN =
|
||||||
|
/\.(zip|rar|7z|tar|gz|tgz|pdf|txt|md|docx?|pptx?|xlsx?|csv|tsv|json|xml|mp3|wav|m4a|aac|flac|ogg|psd|ai|dxf|dwg|step|stp)(?:$|[?#])/i;
|
||||||
|
const FILE_CONTENT_TYPES = new Set([
|
||||||
|
"application/gzip",
|
||||||
|
"application/msword",
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.rar",
|
||||||
|
"application/x-7z-compressed",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-tar",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/zip",
|
||||||
|
"text/csv",
|
||||||
|
"text/plain",
|
||||||
|
"text/tab-separated-values",
|
||||||
|
]);
|
||||||
|
|
||||||
export type OpenClawChatMediaAttachment = {
|
export type OpenClawChatMediaAttachment = {
|
||||||
type: "image" | "video" | "file";
|
type: "image" | "video" | "file";
|
||||||
@ -105,6 +126,18 @@ export function isOpenClawChatArchiveAttachment(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOpenClawChatFileAttachment(
|
||||||
|
url: string,
|
||||||
|
contentType?: string | null
|
||||||
|
) {
|
||||||
|
const normalizedContentType = normalizeContentType(contentType);
|
||||||
|
return (
|
||||||
|
FILE_CONTENT_TYPES.has(normalizedContentType) ||
|
||||||
|
normalizedContentType.startsWith("audio/") ||
|
||||||
|
FILE_EXTENSION_PATTERN.test(String(url || ""))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeOpenClawChatMediaAttachments(
|
export function normalizeOpenClawChatMediaAttachments(
|
||||||
value: unknown
|
value: unknown
|
||||||
): OpenClawChatMediaAttachment[] {
|
): OpenClawChatMediaAttachment[] {
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import {
|
|||||||
updateMessageById,
|
updateMessageById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
import { encryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
|
import {
|
||||||
|
decryptOpenClawTaskSecret,
|
||||||
|
encryptOpenClawTaskSecret,
|
||||||
|
} from "@/lib/openclaw-chat/task-secret";
|
||||||
import {
|
import {
|
||||||
appendClawdTaskProgress,
|
appendClawdTaskProgress,
|
||||||
CLAWD_TASK_KIND,
|
CLAWD_TASK_KIND,
|
||||||
@ -37,6 +40,32 @@ export type ClawdUpstreamTaskState = {
|
|||||||
lastEventId?: number | null;
|
lastEventId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function findPersistedClawdUpstreamTaskState(params: {
|
||||||
|
chatId: string;
|
||||||
|
taskId: string;
|
||||||
|
}): Promise<ClawdUpstreamTaskState | null> {
|
||||||
|
const messages = await getMessagesByChatId({ id: params.chatId }).catch(
|
||||||
|
() => []
|
||||||
|
);
|
||||||
|
for (const message of messages.toReversed()) {
|
||||||
|
const metadata = normalizeClawdTaskMetadata(message.videoMetadata);
|
||||||
|
if (
|
||||||
|
!metadata ||
|
||||||
|
metadata.upstreamTaskId !== params.taskId ||
|
||||||
|
!metadata.upstreamTaskKey
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
taskId: metadata.upstreamTaskId,
|
||||||
|
taskKey: decryptOpenClawTaskSecret(metadata.upstreamTaskKey),
|
||||||
|
eventsEndpoint: metadata.upstreamEventsEndpoint,
|
||||||
|
lastEventId: metadata.upstreamLastEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTaskMetadata(params: {
|
function buildTaskMetadata(params: {
|
||||||
userMessageId: string;
|
userMessageId: string;
|
||||||
assistantMessageId: string;
|
assistantMessageId: string;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import "server-only";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendPersistedClawdTaskProgress,
|
appendPersistedClawdTaskProgress,
|
||||||
|
findPersistedClawdUpstreamTaskState,
|
||||||
type ClawdUpstreamTaskState,
|
type ClawdUpstreamTaskState,
|
||||||
persistClawdUpstreamTaskState,
|
persistClawdUpstreamTaskState,
|
||||||
} from "@/lib/openclaw-chat/task-store";
|
} from "@/lib/openclaw-chat/task-store";
|
||||||
@ -301,19 +302,37 @@ export async function submitOpenClawDurableTask(params: {
|
|||||||
const task = readRecord(root.task);
|
const task = readRecord(root.task);
|
||||||
const taskId = readText(root.task_id) || readText(task.task_id);
|
const taskId = readText(root.task_id) || readText(task.task_id);
|
||||||
if (!taskId) throw new Error("OpenClaw 未返回 task_id");
|
if (!taskId) throw new Error("OpenClaw 未返回 task_id");
|
||||||
|
const responseTaskKey = readText(root.task_key);
|
||||||
|
const persistedTask = responseTaskKey
|
||||||
|
? null
|
||||||
|
: await findPersistedClawdUpstreamTaskState({
|
||||||
|
chatId: params.target.chatId,
|
||||||
|
taskId,
|
||||||
|
});
|
||||||
|
const taskKey = responseTaskKey || persistedTask?.taskKey || null;
|
||||||
|
if (!taskKey) {
|
||||||
|
throw new OpenClawTaskCredentialUnavailableError(taskId);
|
||||||
|
}
|
||||||
const submittedTask = mergeUpstreamTaskState(
|
const submittedTask = mergeUpstreamTaskState(
|
||||||
{
|
{
|
||||||
taskId,
|
taskId,
|
||||||
taskKey: readText(root.task_key) || null,
|
taskKey,
|
||||||
eventsEndpoint: getTaskEventsEndpoint(payload, taskId),
|
eventsEndpoint:
|
||||||
lastEventId: 0,
|
getTaskEventsEndpoint(payload, taskId) ||
|
||||||
|
persistedTask?.eventsEndpoint ||
|
||||||
|
null,
|
||||||
|
lastEventId: persistedTask?.lastEventId || 0,
|
||||||
},
|
},
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
// A task-creation response can include the latest upstream cursor even though
|
// A task-creation response can include the latest upstream cursor even though
|
||||||
// none of its events have been persisted locally yet. Always consume the
|
// none of its events have been persisted locally yet. Always consume the
|
||||||
// event stream from the beginning after accepting a new durable task.
|
// event stream from the beginning after accepting a new durable task.
|
||||||
const upstreamTask = { ...submittedTask, lastEventId: 0 };
|
const upstreamTask = {
|
||||||
|
...submittedTask,
|
||||||
|
taskKey,
|
||||||
|
lastEventId: persistedTask?.lastEventId || 0,
|
||||||
|
};
|
||||||
await persistClawdUpstreamTaskState({
|
await persistClawdUpstreamTaskState({
|
||||||
...params.target,
|
...params.target,
|
||||||
upstreamTask,
|
upstreamTask,
|
||||||
@ -342,6 +361,11 @@ export async function refreshOpenClawDurableTask(params: {
|
|||||||
target: ClawdTaskPersistenceTarget;
|
target: ClawdTaskPersistenceTarget;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<OpenClawTaskOutcome> {
|
}): Promise<OpenClawTaskOutcome> {
|
||||||
|
if (!params.upstreamTask.taskKey) {
|
||||||
|
throw new OpenClawTaskCredentialUnavailableError(
|
||||||
|
params.upstreamTask.taskId
|
||||||
|
);
|
||||||
|
}
|
||||||
const eventsUrl = buildUpstreamUrl(
|
const eventsUrl = buildUpstreamUrl(
|
||||||
params.config.baseUrl,
|
params.config.baseUrl,
|
||||||
params.upstreamTask.eventsEndpoint ||
|
params.upstreamTask.eventsEndpoint ||
|
||||||
@ -459,6 +483,14 @@ export class OpenClawTaskDetachedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OpenClawTaskCredentialUnavailableError extends OpenClawTaskDetachedError {
|
||||||
|
constructor(readonly taskId: string) {
|
||||||
|
super();
|
||||||
|
this.name = "OpenClawTaskCredentialUnavailableError";
|
||||||
|
this.message = "OpenClaw task key is unavailable; keep the task detached";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForOpenClawDurableTask(params: {
|
export async function waitForOpenClawDurableTask(params: {
|
||||||
config: OpenClawUpstreamConfig;
|
config: OpenClawUpstreamConfig;
|
||||||
upstreamTask: ClawdUpstreamTaskState;
|
upstreamTask: ClawdUpstreamTaskState;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
/routes\/settings-visual-consistency\.test\.ts/,
|
/routes\/settings-visual-consistency\.test\.ts/,
|
||||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||||
|
/routes\/canvas-psd-template-entry\.test\.ts/,
|
||||||
/routes\/model-classification\.test\.ts/,
|
/routes\/model-classification\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const SELECTED_IMAGE_TOOLBAR_ACTIONS = [
|
|||||||
"textEdit",
|
"textEdit",
|
||||||
"boxCutout",
|
"boxCutout",
|
||||||
"localEdit",
|
"localEdit",
|
||||||
|
"psdTemplate",
|
||||||
"removeWatermark",
|
"removeWatermark",
|
||||||
"resize",
|
"resize",
|
||||||
"multiAngle",
|
"multiAngle",
|
||||||
|
|||||||
@ -235,7 +235,11 @@ import {
|
|||||||
normalizeUploadedAssetId,
|
normalizeUploadedAssetId,
|
||||||
validateCanvasImageUploadFile,
|
validateCanvasImageUploadFile,
|
||||||
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
|
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
|
||||||
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
import {
|
||||||
|
normalizeChatMediaType,
|
||||||
|
sanitizeChatAttachment,
|
||||||
|
type PickedObjectAttachment,
|
||||||
|
} from "@/components/canvas/chat-panel/canvas-chat-session";
|
||||||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
@ -253,6 +257,22 @@ function dbTextMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("canvas chat pure helpers", () => {
|
test.describe("canvas chat pure helpers", () => {
|
||||||
|
test("preserves spreadsheet attachment content types", () => {
|
||||||
|
const spreadsheetContentType =
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
|
||||||
|
expect(normalizeChatMediaType(spreadsheetContentType)).toBe(
|
||||||
|
spreadsheetContentType
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
sanitizeChatAttachment({
|
||||||
|
name: "测试用.xlsx",
|
||||||
|
url: "https://cdn.example.com/uploads/test.xlsx",
|
||||||
|
contentType: spreadsheetContentType,
|
||||||
|
})?.contentType
|
||||||
|
).toBe(spreadsheetContentType);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps optimistic messages without timestamps after persisted history", () => {
|
test("keeps optimistic messages without timestamps after persisted history", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
@ -4855,6 +4875,18 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps the PSD template replacement action enabled by default", () => {
|
||||||
|
expect(
|
||||||
|
getCanvasToolbarItem(
|
||||||
|
defaultCanvasToolbarConfig,
|
||||||
|
"imagePsdTemplateReplace"
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
label: "模版替换",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("syncs image notes into structured canvas annotations", () => {
|
test("syncs image notes into structured canvas annotations", () => {
|
||||||
const first = syncCanvasNodeNoteAnnotationMetadata({
|
const first = syncCanvasNodeNoteAnnotationMetadata({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
26
tests/routes/canvas-psd-template-entry.test.ts
Normal file
26
tests/routes/canvas-psd-template-entry.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
function readSource(path: string) {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("canvas PSD template replacement entry", () => {
|
||||||
|
test("keeps the selected-image toolbar action and launch flow wired", () => {
|
||||||
|
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
|
||||||
|
const workspace = readSource("components/canvas/CanvasWorkspace.tsx");
|
||||||
|
|
||||||
|
expect(imageNode).toContain(
|
||||||
|
'getCanvasToolbarItem(\n canvasToolbarConfig,\n "imagePsdTemplateReplace"'
|
||||||
|
);
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInline("psdTemplate")');
|
||||||
|
expect(imageNode).toContain('isSelectedImageActionInMore("psdTemplate")');
|
||||||
|
expect(imageNode).toContain('handleAction("psd-template")');
|
||||||
|
expect(imageNode).toContain("handleOpenPsdTemplateReplace");
|
||||||
|
expect(imageNode).toContain("/psd-template?launch=");
|
||||||
|
expect(workspace).toContain(
|
||||||
|
'requestImageNodeAction(selectedImageNodes[0].id, "psd-template")'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user