mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 13:04:38 +08:00
fix(agent): ensure image attachments retain thumbnails after history sync and prevent internal context from displaying in user messages
This commit is contained in:
+2
-1
@@ -17,7 +17,8 @@
|
||||
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||
+ [调整] 进入画布并连接 Agent 时默认开启空白新对话,不再自动加载上一次会话,历史对话改为由用户主动选择恢复。
|
||||
+ [优化] Agent 在画布内接收任务时优先直接操作当前画布,不再无故查询画布列表或重复进入画布。
|
||||
+ [修复] 修复 Agent 用户图片附件位置偏移,以及任务完成同步历史后图片消失并回显内部附件说明的问题。
|
||||
+ [修复] 修复 Agent 用户图片附件位置偏移,历史同步或重新打开对话后仍保留本地缩略图,并且不再回显内部附件说明。
|
||||
+ [修复] 修复画布全局复制快捷键拦截 Agent 对话和节点详情文本复制的问题。
|
||||
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
|
||||
+ [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ docker compose up -d
|
||||
|
||||
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
||||
|
||||
你可以在遵守 AGPL-3.0 的前提下自由使用、复制、修改和分发本项目。如果你分发修改后的版本,或将其作为网站、SaaS 等网络服务提供给他人使用,需要向对应用户公开基于本项目形成的完整源代码,继续使用 AGPL-3.0 协议,并保留原项目的作者、版权、许可证和来源说明。
|
||||
|
||||
本项目禁止未经授权的闭源商用。如果你希望将无限画布用于商业项目,请尊重开源,遵循 AGPL-3.0 协议,继续开源基于本项目修改或开发的对应代码,回馈开源社区;如果无法公开对应代码,请联系作者购买商业授权后闭源使用。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
|
||||
|
||||
@@ -15,7 +15,8 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
||||
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧按原比例展示;任务运行中和完成同步历史后图片位置及内容应保持不变,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中。
|
||||
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧按原比例展示;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
||||
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
||||
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
|
||||
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
|
||||
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
|
||||
|
||||
@@ -54,7 +54,7 @@ export function AgentChatTimeline({
|
||||
}, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]);
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div ref={listRef} className="thin-scrollbar h-full space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
||||
<div ref={listRef} className="thin-scrollbar h-full select-text space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
|
||||
{messages.map((item) => (
|
||||
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
|
||||
))}
|
||||
@@ -128,4 +128,3 @@ function UsageNumber({ label, value, color }: { label: string; value: number; co
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -475,14 +475,15 @@ export function normalizeHistoryMessages(messages: AgentChatItem[]) {
|
||||
}
|
||||
|
||||
export function mergeHistoryAttachments(messages: AgentChatItem[], currentMessages: AgentChatItem[]) {
|
||||
const currentUsers = currentMessages.filter((item) => item.role === "user").reverse();
|
||||
let userIndex = 0;
|
||||
const currentUsers = currentMessages.filter((item) => item.role === "user" && item.attachments?.length).reverse();
|
||||
return [...messages]
|
||||
.reverse()
|
||||
.map((item) => {
|
||||
if (item.role !== "user") return item;
|
||||
const current = currentUsers[userIndex++];
|
||||
return current?.attachments?.length ? { ...item, id: current.id, text: current.text, attachments: current.attachments } : item;
|
||||
const index = currentUsers.findIndex((current) => current.text === item.text || current.historyText === item.text);
|
||||
if (index < 0) return item;
|
||||
const current = currentUsers.splice(index, 1)[0];
|
||||
return { ...item, id: current.id, text: current.text, historyText: current.historyText, attachments: current.attachments };
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function AgentPanel() {
|
||||
>
|
||||
<motion.aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l"
|
||||
data-canvas-shortcuts-ignore
|
||||
initial={{ x: 48 }}
|
||||
animate={{ x: panelClosing ? 28 : 0 }}
|
||||
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { randomId } from "@/lib/utils";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { deleteAgentThreadMessages, readAgentUserMessages, saveAgentUserMessage } from "@/services/agent-chat-storage";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAgentStore, type AgentCanvasContext, type AgentChatItem, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
|
||||
@@ -123,12 +124,14 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
try {
|
||||
const currentThreadId = useAgentStore.getState().activeThreadId;
|
||||
const currentThreadRequest = currentThreadId && !skipHistory ? fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`).catch(() => null) : null;
|
||||
const storedMessagesRequest = currentThreadId && !skipHistory ? readAgentUserMessages(currentThreadId) : null;
|
||||
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
|
||||
let nextMessages: AgentChatItem[] = [];
|
||||
if (currentThreadId && !skipHistory) {
|
||||
let thread = await currentThreadRequest;
|
||||
thread ||= await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(currentThreadId)}`);
|
||||
nextMessages = mergeHistoryAttachments(normalizeHistoryMessages(thread.messages || []), useAgentStore.getState().messages);
|
||||
const storedMessages = (await storedMessagesRequest) || [];
|
||||
nextMessages = mergeHistoryAttachments(normalizeHistoryMessages(thread.messages || []), [...storedMessages, ...useAgentStore.getState().messages]);
|
||||
}
|
||||
if (sequence !== loadThreadsSequenceRef.current) return;
|
||||
setAgentState({ threads: data.data || [], workspacePath: data.workspace?.workspacePath || "", ...(skipHistory ? {} : { messages: nextMessages }) });
|
||||
@@ -282,6 +285,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
}
|
||||
setAgentState({ activity: "发送中", sending: true });
|
||||
const messageId = createId();
|
||||
const userText = text || `发送了 ${files.length} 张图片`;
|
||||
let threadId = useAgentStore.getState().activeThreadId;
|
||||
try {
|
||||
if (!threadId) {
|
||||
@@ -290,14 +294,15 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (!threadId) throw new Error("新建对话失败");
|
||||
setAgentState({ activeThreadId: threadId, messages: [], tokenUsage: null });
|
||||
}
|
||||
addMessage({ id: messageId, role: "user", text: text || "发送了图片", attachments: files });
|
||||
addMessage({ id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files });
|
||||
if (files.length) void saveAgentUserMessage(threadId, { id: messageId, role: "user", text: userText, historyText: requestPrompt, attachments: files }).catch(() => undefined);
|
||||
addEventLog("发送任务", `${compactText(text) || "仅附件"}${files.length ? ` · 附件 ${files.length}` : ""}`);
|
||||
const data = await fetchAgentJson<{ threadId?: string }>(endpoint, token, "/agent/codex/turn", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: requestPrompt,
|
||||
messageText: text || `发送了 ${files.length} 张图片`,
|
||||
messageText: userText,
|
||||
messageId,
|
||||
clientId: clientIdRef.current,
|
||||
threadId,
|
||||
@@ -541,8 +546,13 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
if (!connected || !threadId || sending || waiting) return;
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
|
||||
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), tokenUsage: null, activeTab: "chat", activity: "已恢复会话" });
|
||||
const current = useAgentStore.getState();
|
||||
const [data, storedMessages] = await Promise.all([
|
||||
fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }),
|
||||
readAgentUserMessages(threadId),
|
||||
]);
|
||||
const localMessages = current.activeThreadId === threadId ? current.messages : [];
|
||||
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: mergeHistoryAttachments(normalizeHistoryMessages(data.messages || []), [...storedMessages, ...localMessages]), tokenUsage: null, activeTab: "chat", activity: "已恢复会话" });
|
||||
} catch (error) {
|
||||
addEventLog("恢复对话失败", error);
|
||||
message.error(error instanceof Error ? error.message : "恢复对话失败");
|
||||
@@ -556,6 +566,7 @@ export function LocalAgentPanel({ embedded, headless, autoConnect }: { embedded?
|
||||
setAgentState({ loadingThreads: true });
|
||||
try {
|
||||
await Promise.all(threadIds.map((threadId) => fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) })));
|
||||
void deleteAgentThreadMessages(threadIds).catch(() => undefined);
|
||||
const current = useAgentStore.getState();
|
||||
const deleted = new Set(threadIds);
|
||||
setAgentState({
|
||||
|
||||
@@ -252,7 +252,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
return (
|
||||
<Modal className="canvas-node-info-modal" title={title} open={open && Boolean(node)} centered footer={null} onCancel={onClose}>
|
||||
{node ? (
|
||||
<div className="h-[56vh] min-h-[360px] text-sm">
|
||||
<div className="h-[56vh] min-h-[360px] select-text text-sm" data-canvas-shortcuts-ignore>
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
|
||||
<InfoRow label="ID" value={node.id} />
|
||||
|
||||
@@ -1382,11 +1382,13 @@ function InfiniteCanvasPage() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom],[data-canvas-shortcuts-ignore]")) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
||||
|
||||
if (isModifierShortcut && key === "c" && window.getSelection()?.toString()) return;
|
||||
|
||||
if (isModifierShortcut && !event.altKey && key === "z") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redoCanvas();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import { upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import type { AgentAttachment, AgentChatItem } from "@/stores/use-agent-store";
|
||||
|
||||
export type StoredAgentUserMessage = Pick<AgentChatItem, "id" | "text" | "attachments"> & { role: "user"; historyText: string };
|
||||
|
||||
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "agent_chat_messages" });
|
||||
const indexKey = (threadId: string) => `thread:${threadId}`;
|
||||
const messageKey = (threadId: string, messageId: string) => `message:${threadId}:${messageId}`;
|
||||
|
||||
export async function saveAgentUserMessage(threadId: string, message: StoredAgentUserMessage) {
|
||||
if (!message.attachments?.length) return;
|
||||
const attachments = await Promise.all((message.attachments || []).map(createThumbnail));
|
||||
await store.setItem(messageKey(threadId, message.id), { ...message, attachments });
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
if (!ids.includes(message.id)) await store.setItem(indexKey(threadId), [...ids, message.id]);
|
||||
}
|
||||
|
||||
export async function readAgentUserMessages(threadId: string) {
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
return (await Promise.all(ids.map((id) => store.getItem<StoredAgentUserMessage>(messageKey(threadId, id))))).filter((item): item is StoredAgentUserMessage => Boolean(item));
|
||||
}
|
||||
|
||||
export async function deleteAgentThreadMessages(threadIds: string[]) {
|
||||
await Promise.all(
|
||||
threadIds.map(async (threadId) => {
|
||||
const ids = (await store.getItem<string[]>(indexKey(threadId))) || [];
|
||||
await Promise.all(ids.map((id) => store.removeItem(messageKey(threadId, id))));
|
||||
await store.removeItem(indexKey(threadId));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createThumbnail(attachment: AgentAttachment): Promise<AgentAttachment> {
|
||||
const dataUrl = Math.max(attachment.width, attachment.height) > 512 ? await upscaleDataUrl(attachment.dataUrl, { targetLongEdge: 512, algorithm: "high" }) : attachment.dataUrl;
|
||||
return { ...attachment, size: dataUrl.length, url: dataUrl, dataUrl };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { CanvasAgentOp, CanvasAgentSnapshot } from "@/lib/canvas/canvas-age
|
||||
|
||||
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
|
||||
export type AgentAttachment = { id: string; name: string; type: string; size: number; width: number; height: number; url: string; dataUrl: string };
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; historyText?: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
|
||||
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
|
||||
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[]; path?: string } & Record<string, unknown> };
|
||||
export type AgentCanvasContext = { snapshot: CanvasAgentSnapshot; applyOps: (ops?: CanvasAgentOp[]) => CanvasAgentSnapshot; undoOps: () => CanvasAgentSnapshot | null; canUndo: boolean };
|
||||
|
||||
Reference in New Issue
Block a user