diff --git a/.project-docs/30-worklog/tasks/20261013-canvas-workspace-b6d9e2a4.md b/.project-docs/30-worklog/tasks/20261013-canvas-workspace-b6d9e2a4.md new file mode 100644 index 00000000..4649c8ef --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-canvas-workspace-b6d9e2a4.md @@ -0,0 +1,41 @@ +# Task: Align Canvas workspace with Agents and integrate coordinated trial update + +## Identity + +- Task ID: 20261013-canvas-workspace-b6d9e2a4 +- Mode: Feature +- Branch: codex/20261013-canvas-workspace-b6d9e2a4-canvas-workspace +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-canvas-workspace-b6d9e2a4 +- Base commit: 70166d22b3c4bebd76e3f565d32b122c2f1e2dee +- Owner: codex-root +- Status: In Progress + +## Scope + +- Align Canvas navigation, headers, message rendering/composer and production sections with the existing Agents light system; add a resizable conversation/current-work/history workspace with compact panel switching. Preserve cloud V2 generation and all existing edit/quote/confirm/recovery semantics. +- Wait for user-selected Agent--渠道 and Code-navigation chats, integrate their completed commits, verify combined source, update the current temporary application, merge and push main, then retire this task's clean managed worktree. Preserve unrelated work and the live application profile. + +## Intent And Constraints + +- User explicitly authorized implementation of the previous six-part proposal, coordinated trial update, merge/push and cleanup. Use committed origin/main 70166d2 as baseline; do not revive the rejected broad Code visual variant. +- Project Context Loaded: task 20261013-canvas-workspace-b6d9e2a4, feature mode, owned branch/worktree/base shown in Identity. Bundled check/start/status succeeded and owner/reservation match. Read entry/memory index/own task, positioning/integrated snapshot, decisions/system overview, domain/success and evidence/reflection/commitment indexes plus community boundaries. Main owns media/auth/commands; Workspace owns Living Form and Session; use one light system and actual existing component tokens. +- All peer task Scope/Intent/Promotion sections were read only. Channel adapter 9a7c32e1 and Code navigation 35fad017 are the required dependencies for final delivery, with separate file scopes. Knowledge/draft/teacher tasks do not overlap Canvas. Placeholder child-language scope is unknown but has no concrete dependency. Current primary checkout belongs to another completed integration and has a pre-existing dirty task record; it remains untouched during feature development. Trial pointer is being changed by Code-navigation work: re-read/freeze predecessor only after that chat completes. Gate result: Passed. +- Implementation plan: independently improve Canvas conversation/Markdown and its isolated sidebar; add a Canvas-specific keyboard-accessible split with remembered size and current/previous output selection, reusing existing Main-owned asset previews; relocate existing editable production card to the works pane without duplicating reducers/paid actions. Update focused unit/Electron regression coverage and README; run type/lint/affected tests/build and independent review. Final native trial update follows verified fresh-pointer preparation, activation and finalization; preserve user input and running tasks. + +## Outcome + +- Canvas implementation complete: Agents-style navigation/composer/Markdown, 64px identity header, resizable 42/58 workspace, current output preview and historical selection, one persistent editable production card, <=899px compact panel switching. Hidden videos pause; modal Escape does not change split focus; compact creation no longer opens two dialogs. +- Coordinated integration, normal main push, live temporary-application update and worktree retirement remain pending. Code-navigation chat completed source d287d5ac; its live verification is blocked by Mac lock. Channel-adapter chat still running. Required unlock question is pending; no app mutation has occurred in this task. + +## Verification + +- Pinned corepack pnpm 10.33.4; typecheck and scoped ESLint passed. Nine Canvas/store/contract test files: 111 tests passed; separate layout regressions: 9 passed; sidebar interaction regressions: 6 passed. Existing Electron image-workspace-v2 suite: 2 passed, covering actual geometry, keyboard/pointer/focus, 375/768/899/900 layouts, drafts, quotes, result preview and video duration. Full Renderer/Main/Preload/utility build succeeded. +- Independent review findings were fixed and verified. Screenshot QA accepted three fixture images; copied outside the worktree into the calling chat visualization folder. These are controlled test data, not a real paid-generation acceptance. + +## Follow-ups + +- Finish both user-selected chat dependencies before staging combined runtime. Inspect fresh pointer, actual compatibility symlink and Main source; never reuse the rejected broad Code visual variant. Preserve unrelated primary checkout task record and stable profile/runtime/dependency links during cleanup. + +## Promotion Candidates + +- Target: Canvas presentation summary in current-state / ADR-007. Proposal: retain the sole cloud-owned Living Form and generation contract while presenting its only editable card in the works pane, with remembered split/compact tabs and large output preview. Evidence: source changes, 126 focused units, two fixture Electron flows and screenshot QA. Future impact: keep drafts mounted across panel switching and avoid reintroducing fixed 256px result rails. No semantic conflict with authority rules; user approved the presentation change. Canonical updates require serialized integration and are not written by this feature task. diff --git a/README.md b/README.md index 04a64db9..958142e7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 -- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index b15c78be..ad7bd91b 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from 'react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { FolderKanban, Loader2, + MoreHorizontal, Pencil, Plus, RefreshCw, @@ -71,6 +73,8 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba useEffect(() => { const openCreateDialog = () => { + // The compact project Sheet owns this event while the desktop sidebar is hidden. + if (typeof window.matchMedia === 'function' && !window.matchMedia('(min-width: 1024px)').matches) return; setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -144,138 +148,159 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba navigate('/image-canvas'); }; - if (sidebarCollapsed) { - return ( -
+ return ( +
+ {sidebarCollapsed ? ( -
- ); - } - - return ( -
-
- -
- -
- 设计项目 - {(status === 'error' || status === 'unavailable') && ( - - )} -
- -
- {status === 'loading' && ( -
- {[0, 1, 2].map((item) => ( -
- ))} -
- )} - - {(status === 'error' || status === 'unavailable') && ( -
- {workspaceError ?? 'AI 设计暂时不可用'} -
- )} - - {status === 'ready' && sortedProjects.length === 0 && ( - - )} - - {sortedProjects.map((project) => { - const active = project.workspaceId === activeWorkspaceId; - const pending = project.workspaceId === activeWorkspaceId && !workspace; - return ( -
+
+ +
+ +
+ 设计项目 + {(status === 'error' || status === 'unavailable') && ( + )} +
+ +
+ {status === 'loading' && ( +
+ {[0, 1, 2].map((item) => ( +
+ ))} +
+ )} + + {(status === 'error' || status === 'unavailable') && ( +
+ {workspaceError ?? 'AI 设计暂时不可用'} +
+ )} + + {status === 'ready' && sortedProjects.length === 0 && ( + -
- - -
-
- ); - })} -
+ +
+ + + + + + + openRenameDialog(project)} + > + + { + setDeleteTarget(project); + setDeleteConfirmation(''); + setDeleteError(null); + }} + > + + + + +
+
+ ); + })} +
+ + )} !open && !saving && setDialog(null)}> diff --git a/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx b/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx new file mode 100644 index 00000000..51e6d00c --- /dev/null +++ b/src/pages/ImageCanvas/CanvasWorkspaceSplit.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Columns2, Maximize2, MessageSquare, Images } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import './canvas-workspace.css'; + +const preferenceKey = 'makelore.canvas.conversation-ratio'; +const defaultRatio = 42; + +export function CanvasWorkspaceSplit({ conversation, works }: { + conversation: ReactNode; + works: ReactNode; +}) { + const root = useRef(null); + const [ratio, setRatio] = useState(() => { + try { + const saved = Number(localStorage.getItem(preferenceKey)); + return saved >= 30 && saved <= 65 ? saved : defaultRatio; + } catch { return defaultRatio; } + }); + const [focus, setFocus] = useState<'conversation' | 'works' | null>(null); + const [compactPanel, setCompactPanel] = useState<'conversation' | 'works'>('conversation'); + useEffect(() => { + const node = root.current; + if (!node) return; + const pauseHiddenVideos = () => node.querySelectorAll('video').forEach((video) => { + if (!video.paused && video.offsetParent === null) video.pause(); + }); + pauseHiddenVideos(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(pauseHiddenVideos); + observer.observe(node); + return () => observer.disconnect(); + }, [focus, compactPanel]); + const resize = (next: number) => { + const width = (root.current?.clientWidth ?? 0) - 8; + if (width < 892) return; + const clamped = Math.max(30, 300 / width * 100, Math.min(65, 100 - 320 / width * 100, next)); + setRatio(clamped); + try { localStorage.setItem(preferenceKey, String(clamped)); } catch { /* Keep resizing usable without storage. */ } + }; + const toggle = (panel: 'conversation' | 'works') => ( + + ); + + return ( +
{ + if (event.defaultPrevented || !(event.target instanceof Node) || !root.current?.contains(event.target)) return; + if (event.key === 'Escape') setFocus(null); + if (event.key !== 'F6' || !root.current) return; + const panels = [...root.current.querySelectorAll('.canvas-workspace-pane')] + .filter((panel) => panel.offsetParent !== null); + const current = panels.findIndex((panel) => panel.contains(document.activeElement)); + const next = panels[(current + 1) % panels.length]; + const target = [...(next?.querySelectorAll('textarea:not(:disabled), input:not(:disabled), button:not(:disabled)') ?? [])] + .find((element) => element.offsetParent !== null); + if (target) { event.preventDefault(); target.focus(); } + }}> +
+ + +
+
+
+
创作对话{toggle('conversation')}
+ {conversation} +
+
resize(defaultRatio)} + onPointerDown={(event) => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); }} + onPointerMove={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId) && root.current) { + resize((event.clientX - root.current.getBoundingClientRect().left) / (root.current.clientWidth - 8) * 100); + } + }} + onPointerUp={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + }} + onKeyDown={(event) => { + if (!['ArrowLeft', 'ArrowRight', 'Home'].includes(event.key)) return; + event.preventDefault(); + resize(event.key === 'Home' ? defaultRatio : ratio + (event.key === 'ArrowLeft' ? -2 : 2)); + }} /> +
+
作品工作区{toggle('works')}
+ {works} +
+
+
+ ); +} diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 32223f0a..c7d5af41 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { + ArrowUp, + ArrowUpRight, Check, ChevronDown, Clock3, Loader2, - Send, Sparkles, } from 'lucide-react'; import { toast } from 'sonner'; @@ -24,6 +25,7 @@ import type { } from '../../../shared/image-workspace'; import { DesignPendingOperationsPanel } from './DesignProductionPanel'; import { DesignPlanHistory } from './DesignPlanHistory'; +import { DesignMessageMarkdown } from './DesignMessageMarkdown'; import { YouthCreationCard } from './YouthCreationCard'; const STARTER_MESSAGES = [ @@ -32,6 +34,39 @@ const STARTER_MESSAGES = [ '把我的涂鸦变成一段会动的小故事', ]; +const USER_MESSAGE_CLASS = 'canvas-user-message chat-user-message-surface ml-auto w-fit max-w-[85%] whitespace-pre-wrap break-words rounded-[22px] rounded-br-[7px] border !border-[#e3d9f0] !bg-[#f0e9f9] px-4 py-3 text-[15px] leading-7 text-[#273246] !shadow-[0_2px_0_#e9e1f0]'; + +function DesignAssistantMessage({ + text, + operationId, +}: { + text: string; + operationId?: string; +}) { + const streaming = Boolean(operationId); + return ( +
+ +
+

麦洛

+ + {streaming && ( +
+
+ ); +} + function DesignAssistantActivityPanel({ activity, operationId, @@ -141,6 +176,7 @@ export function DesignConversationPane({ generationAvailable, generationOptions, showPlanHistory = true, + showCreationCard = true, }: { workspace: DesignWorkspace; quoteBlockers: DesignCompilationIssue[]; @@ -150,6 +186,7 @@ export function DesignConversationPane({ video: DesignGenerationOptions; }; showPlanHistory?: boolean; + showCreationCard?: boolean; }) { const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); @@ -224,7 +261,14 @@ export function DesignConversationPane({ ]); const submit = () => { - if (!composerDraft.trim() || submittingDesignInput) return; + const workspaceId = workspace.workspace.workspaceId; + // Commands project pending state synchronously, before React has rendered again. + const liveSubmitting = Object.values(useImageWorkspaceStore.getState().pendingOperations).some( + (operation) => operation.status === 'submitting' + && operation.command.kind === 'apply_input' + && operation.command.workspaceId === workspaceId, + ); + if (!composerDraft.trim() || submittingDesignInput || liveSubmitting) return; void sendChat().catch((error) => { toast.error(chatFailureMessage(error)); }); @@ -233,36 +277,43 @@ export function DesignConversationPane({ return (
-
+
{workspace.turns.length === 0 && pendingChatMessages.length === 0 && ( -
- 麦洛 M 标识 -

+
+ + 麦洛 M 标识 + +

你想让麦洛和你一起创作什么?

-

- 不用懂设计。说说想画什么、发生在哪里,或者希望别人看到后有什么感觉;没有想全也没关系。 +

+ 从下面选一句,或说说你想画什么。没有想全也没关系。

-
- {STARTER_MESSAGES.map((message) => ( +
+ {STARTER_MESSAGES.map((message, index) => ( ))}
+

点选只会填入输入框,由你决定何时发送。

)} @@ -272,7 +323,7 @@ export function DesignConversationPane({ return (
{turn.userMessage && ( -
+
{turn.userMessage}
)} @@ -284,9 +335,7 @@ export function DesignConversationPane({ /> )} {turn.assistantMessage && ( -
- {turn.assistantMessage} -
+ )}
); @@ -304,14 +353,14 @@ export function DesignConversationPane({ data-testid={`pending-design-chat-${message.operationId}`} aria-live="polite" aria-atomic="true" - className="ml-auto max-w-[82%]" + className="ml-auto max-w-[85%]" > -
+
{message.message}
{message.status === 'submitting' - ?
@@ -324,19 +373,7 @@ export function DesignConversationPane({ /> )} {streamingReply && ( -
- {streamingReply} -
+ )}
); @@ -344,7 +381,7 @@ export function DesignConversationPane({ {showPlanHistory ? : null} - {pendingChatMessages.length === 0 && ( + {showCreationCard && pendingChatMessages.length === 0 && (