diff --git a/.project-docs/30-worklog/tasks/20261014-all-thread-trial-6d72e4f1.md b/.project-docs/30-worklog/tasks/20261014-all-thread-trial-6d72e4f1.md index 670b3ae9..8eafeaae 100644 --- a/.project-docs/30-worklog/tasks/20261014-all-thread-trial-6d72e4f1.md +++ b/.project-docs/30-worklog/tasks/20261014-all-thread-trial-6d72e4f1.md @@ -21,15 +21,18 @@ ## Outcome -- Not completed. +- All six named source conversations have supplied final handoffs. The candidate combines navigation548c5e9 (including existing WeChatf1dab), logger5da90e4, model onboardingd49ab20/bec4262, release source0d6ac41, Code textb5f31c1, Canvas recovery0d10e72, homepagef35079b and Canvas works drawerb1eed1b. Older diagnosis-only records, canceled a65/9e/gallery prototype, rejected refined UI, already-integrated knowledge controls and undeployed backend changes are excluded from product replay. +- Import only reviewed product/test/README deltas, preserving original source task/canonical records unchanged in their source commits. Record exact source parents in the manual Git merge commit so every accepted final source remains reachable; the Feature checkout writes only this owned task record. Primary foreign teacher record and all source worktrees remain untouched. +- Semantic conflict resolutions preserve global Home icons plus contextual arrows, Code readable14px text and account placement, the latest single homepage inline Gallery (no Code/Canvas/footer/welcome Gallery), Canvas yellow rail/stable create identity/read-failure retry plus account-wide works drawer. README keeps both independent model and release-directory descriptions. E2E retains model manual-send/draft protection, Canvas504→retry recovery and new home placement; outdated Gallery assertions were corrected while preserving real interactions and side-effect checks. Store tests combine stable auth identity with subscriptions, retaining both create-intent and late collection/logout guards. ## Verification -- Not run. +- Preliminary combined Main12 suites/182tests passed. Initial UI11 suites had303passed/3 obsolete Gallery expectations; only those three expectations changed to the latest authorized product, with focused2 suites/49passing and scopedlint. New Canvas store/route/drawer/generated collection4 suites/113passing. Three E2E conflict files scopedlint and35test collection passed; combined final source validation and default build follow before runtime preparation. +- Source conversations confirmed only client local/source QA; WeChat Worker/backend remains undeployed per human clarification. No real model call, cloud publication, binding operation, profile contents or actual user files mutated. ## Follow-ups -- Wait for final home-gallery font/color source, Canvas works drawer/error-retry and any resumed Agent source before activating. Current pointer has now finalized navigation Renderer d3b0d411 (includesWeChatf1dab), Main436/PID35142; actual stable alias independently resolves navigation-icon runtime and native channel view shows name-edit controls. Earlier stale436-only metadata is not the full source authority. +- Completed sources are now frozen; runtime preparation still requires final combined QA/build and a fresh current/actual-alias snapshot plus protected native save/quit. Current Main436/PID35142 and finalized Renderer d3b0d411 (includesWeChatf1dab) remain unchanged; native channel view confirms name-edit controls. Earlier436-only metadata must not replace this separate actual predecessor pair. ## Project Context Loaded diff --git a/README.md b/README.md index 02b1e42a..c97e01d9 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。每次从首页进入 Code、Agents 或 Canvas 时,默认展开左侧工作栏;进入后沿用各模块现有的手动收起与紧凑布局规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与左侧标题及灵感引导区的中心对齐,三个入口等高、间距均匀;问候使用真实个人资料姓名,标题下显示“还是去「作品广场」找找灵感?”,仅“作品广场”四字可点击,与欢迎语和标题使用同一字体及清晰的深色,仅以细下划线及比整句大约12%的字号强调;点击沿用原系统浏览器公共广场入口,不请求或内嵌作品列表;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。每次从首页进入 Code、Agents 或 Canvas 时,默认展开左侧工作栏;进入后沿用各模块现有的手动收起与紧凑布局规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - 会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏的「作品广场」固定在底部、紧邻账户按钮上方,保持与上方入口相同的横向对齐;只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 +会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区保留麦洛标识、星装饰、原有主标题与说明,标识为普通装饰;作品广场入口统一移到模块首页,输入框上方不再显示广场入口或邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。Code及Canvas左栏不再显示作品广场入口,账户按钮保持原位;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 +- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页房子图标与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。 @@ -24,13 +24,20 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。绑定有效且服务正常时始终开启回复,不提供暂停或恢复开关;更换智能体保留登录与历史,完成重新登录后也开启回复。旧的关闭记录在页面可见时,重新核对登录、在线服务、可用目标、同步状态及新旧渠道操作恢复记录后串行开启;服务离线、退出待确认、目标不可用和结果未知时不自动写入。常用操作是“更换智能体”;“更多操作”内可以解除微信绑定,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。确认解绑后的卡片可选择“删除微信卡片”,只从这台设备的当前账号列表移除,刷新和重开后仍保留删除结果;其他设备及云端账号和聊天历史不受影响,同一微信重新绑定后再次显示。本机偏好按账号与租户隔离,删除前再次读取云端状态并检查原操作,保存失败时保留卡片并显示原因。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成。微信服务离线时,卡片仅保留右上角“微信服务离线”状态,不显示重复的离线长段说明;绑定或重新连接失败的窗口仍说明原因,不把扫码成功当作回复可用。未知或未同步状态显示“状态待确认”。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。微信退出仍在处理中时,禁止新的连接和绑定变更,保留刷新与原操作恢复。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。 -- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部广场/账户样式,仅工作栏底色采用淡紫色(`#f5f2fa`);顶部返回箭头与 Code 对齐,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 +- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧工作栏与 Code 共用间距、图标徽章、项目行、操作菜单和底部账户样式,仅工作栏底色采用淡黄色(`#fff9e8`);顶部返回箭头与 Code 对齐,Canvas 工作栏不显示“作品广场”,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace;创建请求失败后保留名称与本次创建标识,原样重试避免重复创建项目。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 + + Canvas 顶部的“我的作品”打开右侧抽屉,按项目分批读取当前账号已完成的生成结果,提供“全部 / 图片 / 视频”筛选、明确的媒体标签、视频播放标记和时长;点开后在同一抽屉预览、保存到电脑或回到对应项目的制作记录。打开和关闭保留后台工作区的分栏、当前方案和草稿;有未发送或未保存内容时,先处理当前创作再切换项目。集合只纳入成功任务明确关联的生成资产,参考图和失败过程文件不会成为作品;账号变化清除抽屉结果。现有云端单项目详情只提供近期最多 100 个资产及 50 个任务,触及边界或读取不全时会提示较早作品暂时无法全部读取;完整历史仍需要配套云端分页能力,不能把近期集合表述为完整存档。 + - `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 授权。 作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 +编程新对话尚未选定模型时,会先显示“选一个模型,开始聊聊”,点击“选择模型”直接列出当前账号的模型;加载中、读取失败和暂无模型分别说明。输入和图片草稿保持可编辑,选择保存及刷新完成前不能发送,也不会自动发送。有效默认模型可直接使用;尚未创建对话的模型选择只保留为当前账号、项目和编程配置的临时选择,首次手动发送时才创建对话并保存。缺少模型时仍可读取已有历史,不因此启动 Pi 或显示重试报错;实际发送与模型权限仍由 Main 校验。 + +提交作品优先使用打开作品时已记住并通过项目身份校验的工程目录;没有有效记录时,保留根目录工程的发布兼容性,或在项目内明确找到唯一的已有工程。游戏位于项目子目录时,只打包该工程中的源码,项目配置、对话身份和提交记录仍属于原登记项目。目录不明确时说明原因,不自动选择其他作品;确认目录后仍执行完整发布文件、依赖、构建与预检校验。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 @@ -41,7 +48,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 -- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;从项目菜单进入的配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部房子图标与侧栏收起图标同排,与右侧项目/会话名称对齐;局部返回上一级保留箭头。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“新建项目”沿用 Agent 的彩色图标徽章+文字样式。对话名称和新对话操作使用14px、500字重及清晰深色,选中与禁用状态保持可辨。Code 工作栏底部不显示“作品广场”;从项目菜单进入的配置页及插件资源页复用相同工作栏、宽度、收起状态、项目和会话列表及账户位置,配置正文和自身返回操作保留。尚未选择本地项目时,中央以麦洛 M 装饰和引导语建立焦点,并提供“新增项目”操作及已有项目卡片;已有项目的新 Conversation 沿用同一引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 @@ -56,7 +63,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。中央使用与 AI 编程一致的低干扰对话布局:助手回复不套独立卡片,底部输入器是唯一强调层级;当前制作方案以对话内轻量编辑稿呈现,而不是独立硬模块。聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer,并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。 - AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价,确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入可展开的历史作品记录,桌面端位于右侧全高栏,紧凑窗口保留在时间线中。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。 - 视频制作:类型、画幅、时长和数量依据服务端真实能力显示。尚未保存时长时明确显示“请选择时长”,不把默认数字当作已选值;参数保存失败后恢复服务端已接受的值。用户可以上传图片或从历史作品选择视频开始画面,绑定状态不依赖提示词中是否出现 `@图片N`。制作前先由 Agent 整理动作与可执行方案,再按最新方案核价;缺少内部分镜时引导点击“准备制作方案”,不要求用户填写分镜字段。整理失败不继续报价,也不创建任务。只有用户确认 Quote 后才开始制作。历史视频提供播放、放大预览和下载;切回图片会清理隐藏的视频参数,不能使用的参考用途须由用户显式移除,图片素材不被删除。 -- AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 +- AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。切换项目时读取失败会显示错误与“重新连接”,重试读取当前项目;已载入的作品、记录与草稿在后续读取失败时保留。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画左侧项目栏只展示 Workspace,桌面设计模式下固定为 256px 且不提供收纳入口,不再在项目下创建独立设计会话;右侧全高历史作品栏同为 256px,展示当前 Workspace 的任务与生成资产。紧凑窗口则把项目列表放进左侧抽屉并在中央时间线保留历史记录。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - 项目资源设置:项目配置页顶部以一横排三张资源卡提供模型、知识与插件入口,不再提供独立的本地 Skill 入口。模型列表完整显示具体模型名称,以及服务端或已验证模型档案声明的可选思考强度;未知能力不做猜测。插件抽屉把插件与 Skill 统一为“可选 / 已安装”两个页签:可选页展示云端目录及仍待安装的账号能力,已安装页同时展示官方 Release、随应用提供的插件和用户自行安装的 loose `SKILL.md` / Pi 包。每项提供“查看详情”和当前真实生命周期对应的添加、安装、添加到项目、启用或状态按钮。详情只展示介绍、主要能力、使用方式和必要设置,不暴露版本、包、权限标识、运行方式或计费实现字段。深链仍使用 `/project-config/plugins`;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 会确定性落入对应页签。来源身份保持 `official:`、`local:`、`retained:` 分离,账号获取、官方设备交付和项目启用仍分别写入既有 authority,不会自动推进下一步;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包。 - 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;普通 `skill_only` 下载包不依赖运行时 Policy,也不得执行分发包中的任意代码。Project Scaffold 的 `.mjs` 是代码所有的 bundled 例外,只能从签名客户端固定资源根通过应用自带 Node 执行,不能扩展为 Marketplace 下载 artifact 的脚本权限。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 @@ -68,6 +75,10 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 +微信卡片名称可点击名称旁的铅笔修改,支持保存、取消、Enter 保存与 Escape 取消。备注名按当前账号与微信账号 ID 保存在本机,刷新或重新绑定后保留;不修改真实微信昵称或云端名称,不跨设备同步。保存失败保留原名称与编辑内容,可重试。 + +首页入口使用房子图标,悬停提示“返回首页”;智能体聊天/调整及 Code 配置、设置、插件页面的上级返回保留箭头,悬停提示“返回上一级”。既有目的地、键盘操作和未保存修改的离开保护保持。 + ## 安装与开发 项目使用 `package.json` 中固定版本的 pnpm。 @@ -161,7 +172,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 项目设置仅提供默认编程配置的模型选择,不展示项目提示词或项目 Skills 编辑入口;知识文件与插件仍在各自入口管理。默认模型只影响新对话,现有对话独立切换模型与思考选项,已有消息记录不会被改写;保存模型配置保留原有 Agent 元数据。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 -- 会话列表直接挂在左侧项目下,不展示中间智能体列;同一项目内不同历史智能体的会话统一列出,支持删除、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 +- 会话列表直接挂在左侧项目下,不展示中间智能体列;“新对话”和对话名称使用 14px 中等字重的深蓝灰,分组标题与对话图标使用较深的辅助灰,选中与悬停沿用浅色背景;同一项目内不同历史智能体的会话统一列出,支持删除、改名、未读与运行状态。快速切换项目和会话时,迟到的请求与回复保持原归属。 - Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 交付 Snapshot、会话元数据变更通知,以及按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 - 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。折叠的 thinking、助手过程正文和工具输出固定展示第一个可见块的首个非空行,横向偏移始终为零;尾部流式更新不会替换未变化的首行预览,展开后仍可查看完整内容。两类流式文字均在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。 - Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。发送后立即展示图片附件;Pi 实时消息与历史会话恢复都将图片映射到同一附件存储,重复加载复用已有文件。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..d933af2c --- /dev/null +++ b/design-qa.md @@ -0,0 +1,50 @@ +# Canvas My Works design QA + +- Task: 20261014-canvas-works-drawer-7b4e92c1 +- Source visual truth: `/Users/chillishark/.codex/generated_images/01a12e8d-643e-7453-99d8-b0187649dafc/exec-ea04cbe4-075f-4b51-864c-ca517d086861.png`. Approved by the human; preserve the existing right work pane and add clear image/video distinctions. +- Source pixels: 1586 × 992, normalized to 1600 × 1000 solely for comparison. No product image assets generated from this mock. +- Implementation: local isolated QA browser at `http://127.0.0.1:4179/`, actual Canvas/store/drawer components, repository image fixtures and non-playing video DOM stubs. No cloud, upload, mutation, paid generation or user account used. +- Viewport: 1600 × 1000 CSS pixels, deviceScaleFactor 1; responsive checks 1440 × 960 and 390 × 844. Light theme, drawer open, all filter, six sample completed works. + +## Findings + +- [P2, resolved] Initial desktop card density clips the third row captions at 1600 × 1000, where the selected mock shows all six captions above its footer. + - Evidence: combined comparison `/tmp/makelore-canvas-works-drawer-7b4e92c1-comparison.png`; initial screenshot `test-results/canvas-my-works-layout-Can-45a35-rns-to-its-source-at-1600px/canvas-my-works-1600-list.png`. + - Fixed: thumbnail ratio is now 16:9; desktop header padding is 18 px / 28 px / 12 px and filters bottom padding is 18 px. All six complete sample cards are above the footer at the reference viewport; 44 px targets and card font sizes remain intact. +- Previously found and fixed: 390 px Sheet utility specificity constrained the drawer to 75% width and overflowed its filters. Scoped drawer/title rules now supply a full-width narrow layout. Updated 390 screenshot verified and E2E measurements pass. + +## Fidelity Surfaces + +- Typography: project sans/fallback retained; 27 px drawer title, 14 px filters, 13 px card titles and 11 px origin captions establish the same hierarchy. Titles truncate in the grid and wrap in detail. No imported font or changes to application typography. +- Spacing/layout: right overlay with rounded desktop left corners and two-column cards; original editing surface remains mounted and dimmed. Desktop density P2 is resolved; narrow layout does not overflow. Six sample cards and all captions fit above the footer at all three tested sizes. +- Colors/tokens: foreground, background, borders, semantic errors and primary action use existing app tokens; subdued lavender collection accent follows the selected mock. +- Images: actual output assets are rendered with contain; image/video labels, play mark and real duration are explicit. Mock dog/fish/space art and named outputs represent user content, not static product assets; QA repository art is intentionally different. Icons use the existing Lucide library. +- Copy: collection/filter/preview/save/return controls follow the accepted behavior. Cloud read limits are stated; no claim of a complete count. Background product chrome stays with the existing app rather than replacing it with the mock. + +## Comparison History + +1. CUA inspected actual browser list, video filter, single-drawer preview, keyboard focus and background work pane. Exact-dimension E2E captures then compared with the source in one 3200 × 1000 montage. Header/filter/first-row focused comparison is `/tmp/makelore-canvas-works-drawer-7b4e92c1-focused-comparison.png`. Initial desktop density P2 found. +2. Revised source and implementation placed together in `/tmp/makelore-canvas-works-drawer-7b4e92c1-final-comparison.png` (3200 × 1000) and `/tmp/makelore-canvas-works-drawer-7b4e92c1-final-focused-comparison.png` (1402 × 500). Both compared visually after the spacing/ratio fix; the updated 1600 capture shows all six complete cards and captions. Narrow 390 capture also inspected. No actionable P0/P1/P2 differences remain. The underlying app frame uses the existing work pane and public local fixtures, rather than the speculative background/user artwork in the mock. + +## Implementation Checklist + +- [x] Real read-only collection, type filtering and same-drawer previews. +- [x] Save through the existing Main asset action; return to validated source/task. +- [x] Partial retry preserves prior successes; failures preserve original workspace. +- [x] Drawer closure/account changes ignore stale collection responses. +- [x] Keyboard Escape/focus return and editing draft/split retention. +- [x] Desktop density fix, three-size capture and repeat combined visual comparison. + +## Verification + +- E2E 3/3 passed at 1600 × 1000, 1440 × 960 and 390 × 844 using real Canvas/store components with explicit local fixtures. Measurements check two columns, no horizontal overflow and six complete cards above the footer. All page errors and unexpected external requests were empty. +- Interaction coverage: image/video filters; media labels and duration; one drawer for list/image/video; Escape and trigger focus; background split/focus/panel/draft retention; drafts block cross-project navigation; safe source switch exposes and opens the matching task record. +- Screenshots: `test-results/canvas-my-works-layout-Can-45a35-rns-to-its-source-at-1600px/canvas-my-works-1600-{list,video-preview,source}.png`; equivalent 1440 and 390 screenshots in their corresponding test result folders. +- Drawer component tests 14/14 passed, including crossover partial retry, authoritative empty, close/unmount stale result protection, same-offset retry and media lifecycle. Independent source review resolved both P2 findings and reports PASS. + +## Follow-up Polish / Limits + +- Actual cloud video playback/download cannot be demonstrated by the isolated media stub. Component coverage checks controls/no-autoplay/pause and existing asset integration; real account verification belongs to the coordinated trial. +- Current cloud V2 reads cap each project at recent 100 assets / 50 tasks; complete older history needs a cloud pagination capability. + +final result: passed diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index 63de15f9..246664a2 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -476,6 +476,18 @@ export async function handleImageWorkspaceRoutes( return true; } + if (segments.length === 1 && segments[0] === 'generated-works' && req.method === 'GET') { + if (!ctx.imageWorkspace.listGeneratedWorks) { + throw new DesignWorkspaceModuleError(501, 'DESIGN_GENERATED_WORKS_UNAVAILABLE', '作品列表暂不可用'); + } + const rawOffset = url.searchParams.get('offset') ?? '0'; + if (!/^\d+$/.test(rawOffset) || !Number.isSafeInteger(Number(rawOffset))) { + throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_OFFSET', '作品分页位置无效'); + } + sendData(res, await ctx.imageWorkspace.listGeneratedWorks(Number(rawOffset))); + return true; + } + if (segments.length === 1 && segments[0] === 'workspaces' && req.method === 'POST') { const body = await parseJsonBody>(req); const input: DesignCreateWorkspaceInput = { diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 508180fb..99527ece 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -114,6 +114,11 @@ function runtimeError(error: unknown): never { throw new CodingConversationServiceError(503, 'CODING_RUNTIME_UNAVAILABLE', 'The local coding runtime is unavailable'); } +function isModelSelectionError(error: unknown): boolean { + return error instanceof CodingConversationServiceError + && (error.code === 'CODING_MIGRATION_MODEL_REQUIRED' || error.code === 'CODING_MODEL_UNAVAILABLE'); +} + async function persist(operation: () => Promise): Promise { try { return await operation(); @@ -415,13 +420,32 @@ export class CodingConversationService { } async getSnapshot(conversationId: string): Promise { + this.assertNotDeleting(conversationId); const { project, conversation } = await this.projects.findActiveConversation(conversationId); const { config } = await this.projects.getConfig(project.id); const agent = config.agents.find((item) => item.id === conversation.agentId); if (this.options.readHistory && (!agent?.enabled || agent.archivedAt)) { return await this.options.readHistory(project.id, conversation); } - await this.ensurePrepared(conversationId); + if (this.options.readHistory && (!conversation.model || conversation.modelResolution !== 'resolved')) { + if (this.hasWorker(conversationId)) { + try { return await this.runtime.getSnapshot(conversationId); } catch (error) { runtimeError(error); } + } + if (!this.prepareFlights.has(conversationId)) { + return await this.readModelSelectionHistory(project.id, conversation); + } + } + try { + await this.ensurePrepared(conversationId); + } catch (error) { + if (this.options.readHistory && isModelSelectionError(error)) { + if (this.hasWorker(conversationId)) { + try { return await this.runtime.getSnapshot(conversationId); } catch (runtimeFailure) { runtimeError(runtimeFailure); } + } + return await this.readModelSelectionHistory(project.id, conversation); + } + throw error; + } try { return await this.runtime.getSnapshot(conversationId); } catch (error) { @@ -429,6 +453,47 @@ export class CodingConversationService { } } + private hasWorker(conversationId: string): boolean { + return this.runtime.getDiagnostics().workers.some((worker) => worker.conversationId === conversationId); + } + + private async readModelSelectionHistory( + projectId: string, + conversation: CodingConversationV2, + ): Promise { + const snapshot = await this.options.readHistory!(projectId, conversation); + // A model selection or prompt may have started while durable history was read. + // Its real runtime state, including uncertain work, must remain authoritative. + const preparing = this.prepareFlights.get(conversation.id); + if (preparing) { + try { await preparing; } catch (error) { + if (!isModelSelectionError(error)) throw error; + } + } + this.assertNotDeleting(conversation.id); + if (this.hasWorker(conversation.id)) { + try { return await this.runtime.getSnapshot(conversation.id); } catch (error) { runtimeError(error); } + } + const { conversation: current } = await this.projects.findActiveConversation(conversation.id); + this.assertNotDeleting(conversation.id); + if (current.modelResolution !== conversation.modelResolution + || JSON.stringify(current.model) !== JSON.stringify(conversation.model)) { + // A concurrent choice may have persisted and failed preparation before this + // read completed. Recheck that model instead of masking its real failure. + return await this.getSnapshot(conversation.id); + } + if (this.hasWorker(conversation.id)) { + try { return await this.runtime.getSnapshot(conversation.id); } catch (error) { runtimeError(error); } + } + return { + ...snapshot, + conversation: { + ...snapshot.conversation, + model: { model: null, modelResolution: 'required' }, + }, + }; + } + /** Read existing facts for work restoration without opening a model session. */ async getStoredSnapshot(conversationId: string): Promise { const { project, conversation } = await this.projects.findActiveConversation(conversationId); diff --git a/electron/coding-runtime/pi/initialization-diagnostic.ts b/electron/coding-runtime/pi/initialization-diagnostic.ts new file mode 100644 index 00000000..2a8c5264 --- /dev/null +++ b/electron/coding-runtime/pi/initialization-diagnostic.ts @@ -0,0 +1,267 @@ +import { createHash } from 'node:crypto'; +import { sanitizePiDiagnostic } from './worker-process'; + +const STAGES = new Set([ + 'registry', 'provider-input', 'provider-catalog', 'provider-credentials', + 'worker-resources', 'device-resources', 'resource-materialization', + 'extension-registration', 'worker-start', 'session-bind', 'initial-hydration', + 'snapshot', 'prepare', +]); +const EXCEPTIONS = new Set([ + 'Error', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', + 'URIError', 'AggregateError', 'AbortError', 'PiProcessError', + 'CodingRuntimeContractError', 'PiProviderConfigError', 'PiSessionProjectionError', + 'CodingConversationServiceError', 'CodingProjectServiceError', +]); +const CODES = new Set([ + 'ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE', + 'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'ETIMEDOUT', 'EPIPE', 'ECONNRESET', + 'ECONNREFUSED', 'MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM', + 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_INVALID_ARG_TYPE', 'ERR_INVALID_URL', + 'ERR_IMPORT_ATTRIBUTE_MISSING', 'ERR_UNKNOWN_FILE_EXTENSION', + 'ERR_DLOPEN_FAILED', 'ERR_WORKER_INIT_FAILED', 'ABORT_ERR', + 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_SOCKET', + 'PI_RPC_PROTOCOL_ERROR', 'PI_RPC_TIMEOUT', 'PI_RPC_ABORTED', + 'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'PI_RPC_EXITED', + 'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED', + 'CODING_RUNTIME_START_FAILED', 'CODING_RUNTIME_READY_TIMEOUT', + 'CODING_RUNTIME_PROTOCOL_ERROR', 'CODING_RUNTIME_UNAVAILABLE', + 'CODING_CONVERSATION_NOT_FOUND', 'CODING_SESSION_UNREADABLE', + 'CODING_STORAGE_WRITE_FAILED', 'CODING_MIGRATION_MODEL_REQUIRED', + 'CODING_MODEL_UNAVAILABLE', 'CODING_PROVIDER_AUTH_REQUIRED', + 'CODING_AGENT_NOT_FOUND', 'CODING_REQUEST_UNCERTAIN', + 'PROVIDER_INVALID', 'PROVIDER_AUTH_REQUIRED', 'MODEL_UNAVAILABLE', +]); +const MAX_BYTES = 4_096; +const MAX_TEXT_BYTES = 32_768; +const MAX_FRAME_BYTES = 160; +const MAX_FRAMES = 6; +const MAX_CAUSE_DEPTH = 2; +const REDACTED_FRAME = '[REDACTED_FRAME]'; +const MAX_RPC_TAIL_BYTES = 2_048; +// Server control commands plus PiRpcClient's read-only initialization commands. +const RPC_OPERATIONS = [ + 'server_initialize', 'thread_open', 'thread_close', 'server_shutdown', + 'get_available_models', 'get_available_thinking_levels', 'get_commands', + 'get_entries', 'get_fork_messages', 'get_last_assistant_text', 'get_messages', + 'get_session_stats', 'get_state', 'get_tree', +] as const; + +export interface PiInitializationDiagnostic { + version: 1; + stage: string; + exception: string; + code?: string; + category: string; + message: string; + fingerprint?: string; + rpcOperation?: (typeof RPC_OPERATIONS)[number]; + rpcFailureSignature?: 'undefined-property' | 'null-property' | 'not-callable' | 'module-not-found' | 'missing-local-resource'; + frames?: string[]; + causes?: PiInitializationDiagnostic[]; + truncated?: true; +} + +export interface PiInitializationDiagnosticOptions { + stage: string; + sensitiveValues?: readonly string[]; + /** Bundled application/package roots only; never user project roots. */ + trustedRoots?: readonly string[]; +} + +function own(value: unknown, key: string): PropertyDescriptor | undefined { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined; + try { return Object.getOwnPropertyDescriptor(value, key); } catch { return undefined; } +} +function data(value: unknown, key: string): unknown { + const descriptor = own(value, key); + return descriptor && 'value' in descriptor ? descriptor.value : undefined; +} +function strings(value: unknown): string[] { + try { + if (!Array.isArray(value)) return []; + const length = data(value, 'length'); + if (typeof length !== 'number' || !Number.isSafeInteger(length)) return []; + const result: string[] = []; + for (let index = 0; index < Math.min(length, 128); index++) { + const item = data(value, String(index)); + if (typeof item === 'string' && item && item.length <= MAX_TEXT_BYTES && Buffer.byteLength(item) <= MAX_TEXT_BYTES) result.push(item); + } + return result; + } catch { return []; } +} +function exceptionName(error: unknown): string { + let value = error; + for (let depth = 0; depth < 3; depth++) { + const name = data(value, 'name'); + if (typeof name === 'string') return EXCEPTIONS.has(name) ? name : 'UnknownError'; + if (own(value, 'name')) return 'UnknownError'; + try { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') break; + value = Object.getPrototypeOf(value); + } catch { break; } + } + return 'UnknownError'; +} +function knownCode(error: unknown): string | undefined { + const direct = data(error, 'code'); + if (typeof direct === 'string' && CODES.has(direct)) return direct; + const publicCode = data(data(error, 'publicError'), 'code'); + return typeof publicCode === 'string' && CODES.has(publicCode) ? publicCode : undefined; +} +function rpcProjection(error: unknown): Pick { + if (data(error, 'code') !== 'PI_RPC_RESPONSE_ERROR') return {}; + const message = data(error, 'message'); + if (typeof message !== 'string') return {}; + // Inspect a bounded prefix first; never scan/copy an unbounded response tail. + const wrapper = message.slice(0, 128).match(/^Pi RPC ([a-z_]{1,48}) failed: /); + const operation = wrapper ? RPC_OPERATIONS.find(candidate => candidate === wrapper[1]) : undefined; + if (!wrapper || !operation) return {}; + const result: Pick = { rpcOperation: operation }; + if (message.length - wrapper[0].length > MAX_RPC_TAIL_BYTES) return result; + const tail = message.slice(wrapper[0].length); + if (Buffer.byteLength(tail) > MAX_RPC_TAIL_BYTES) return result; + // Match known runtime shapes, but never retain their private property names, + // function expressions, package names, paths, URLs or other response suffixes. + const property = tail.match(/^(?:TypeError: )?Cannot read properties of (undefined|null) \(reading (?:'[^'\r\n]{1,256}'|"[^"\r\n]{1,256}")\)$/); + if (property) result.rpcFailureSignature = property[1] === 'undefined' ? 'undefined-property' : 'null-property'; + else if (/^(?:TypeError: )?[^\r\n]{1,256} is not a function$/.test(tail)) result.rpcFailureSignature = 'not-callable'; + else if (/^(?:Error: )?Cannot find module '[^'\r\n]{1,512}'(?:\nRequire stack:(?:\n- [^\r\n]{1,512}){1,8})?$/.test(tail) + || /^(?:Error: )?Cannot find package '[^'\r\n]{1,256}' imported from [^\r\n]{1,1024}$/.test(tail)) result.rpcFailureSignature = 'module-not-found'; + else if (/^(?:Error: )?ENOENT: no such file or directory(?:, (?:open|access|stat|lstat|scandir|readdir|realpath|readlink) '[^'\r\n]{1,1024}')?$/.test(tail)) result.rpcFailureSignature = 'missing-local-resource'; + return result; +} +function classify(exception: string, code: string | undefined): Pick { + if (code && ['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_REQUIRE_ESM', 'ERR_PACKAGE_PATH_NOT_EXPORTED', 'ERR_UNKNOWN_FILE_EXTENSION', 'ERR_DLOPEN_FAILED'].includes(code)) + return { category: 'module-loading', message: 'A required runtime module could not be loaded.' }; + if (code && ['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EINVAL', 'EEXIST', 'EMFILE', 'ENFILE', 'ENOSPC', 'EROFS', 'EIO', 'ENAMETOOLONG', 'CODING_STORAGE_WRITE_FAILED'].includes(code)) + return { category: 'local-storage', message: 'A local runtime resource could not be read or written.' }; + if (code && ['PROVIDER_AUTH_REQUIRED', 'CODING_PROVIDER_AUTH_REQUIRED'].includes(code)) + return { category: 'provider-authentication', message: 'Provider authentication preparation failed.' }; + if (code && ['PROVIDER_INVALID', 'MODEL_UNAVAILABLE', 'CODING_MODEL_UNAVAILABLE', 'CODING_MIGRATION_MODEL_REQUIRED'].includes(code)) + return { category: 'provider-configuration', message: 'The runtime model configuration could not be prepared.' }; + if (code && ['PI_RPC_TIMEOUT', 'CODING_RUNTIME_READY_TIMEOUT', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT'].includes(code)) + return { category: 'initialization-timeout', message: 'Runtime preparation or communication timed out.' }; + if (code && ['PI_RPC_PROTOCOL_ERROR', 'PI_RPC_RESPONSE_ERROR', 'PI_RPC_WRITE_FAILED', 'CODING_RUNTIME_PROTOCOL_ERROR'].includes(code)) + return { category: 'worker-protocol', message: 'The runtime worker communication failed.' }; + if (code && ['PI_RPC_EXITED', 'PI_WORKER_START_FAILED', 'PI_WORKER_STOPPED', 'PI_WORKER_STOP_FAILED', 'CODING_RUNTIME_START_FAILED', 'ERR_WORKER_INIT_FAILED'].includes(code)) + return { category: 'worker-lifecycle', message: 'The runtime worker could not start or remain available.' }; + if (code === 'CODING_SESSION_UNREADABLE') + return { category: 'session-projection', message: 'Stored runtime session data could not be projected.' }; + if (exception === 'TypeError' || exception === 'ReferenceError' || code === 'ERR_INVALID_ARG_TYPE') + return { category: 'runtime-contract', message: 'Runtime initialization encountered a type or reference mismatch.' }; + if (exception === 'SyntaxError') + return { category: 'invalid-runtime-data', message: 'Runtime initialization encountered invalid syntax or data.' }; + return { category: 'unclassified-initialization', message: 'Runtime initialization failed without a recognized error classification.' }; +} +function secretAliases(values: string[]): string[] { + const result = new Set(); + for (const value of values) { + result.add(value); + try { + result.add(encodeURIComponent(value)); + result.add(encodeURI(value)); + result.add(encodeURIComponent(value).replace(/%20/g, '+')); + } catch { /* Invalid UTF-16 is still covered by its literal value. */ } + result.add(JSON.stringify(value).slice(1, -1)); + result.add(Buffer.from(value).toString('base64')); + result.add(Buffer.from(value).toString('base64url')); + } + return [...result]; +} +function decodedLocation(value: string): string | null { + if (Buffer.byteLength(value) > 2_048) return null; + let result = value; + try { + for (let depth = 0; depth < 2; depth++) { + const next = decodeURIComponent(result); + if (next === result) break; + result = next; + } + } catch { return null; } + if (result.startsWith('file:///')) result = result.slice(7); + else if (result.startsWith('file://localhost/')) result = result.slice(16); + else if (result.includes('://')) return null; + if (/^\/[A-Za-z]:\//.test(result)) result = result.slice(1); + result = result.replace(/\\/g, '/'); + if (result.split('/').some(segment => segment === '..' || segment === '.')) return null; + return result; +} +function frameLocation(line: string, roots: string[], sensitive: string[]): string { + const match = line.match(/^\s*at\s+(?:.*?\()?([^()\r\n]+):(\d{1,9}):(\d{1,9})\)?\s*$/); + if (!match) return REDACTED_FRAME; + const location = decodedLocation(match[1]); + if (!location) return REDACTED_FRAME; + let relative: string | undefined; + if (/^node:internal\/[A-Za-z0-9_./-]+$/.test(location)) relative = location; + else { + for (let index = 0; index < roots.length; index++) { + const root = decodedLocation(roots[index])?.replace(/\/+$/, ''); + if (!root || !/^(?:\/|[A-Za-z]:\/)/.test(root)) continue; + const windows = /^[A-Za-z]:\//.test(root); + const candidate = windows ? location.toLowerCase() : location; + const prefix = windows ? root.toLowerCase() : root; + if (candidate.startsWith(prefix + '/')) { + const suffix = location.slice(root.length + 1); + // Drop function names and absolute roots; retain only code locations. + if (/^[A-Za-z0-9_@+.,$-]+(?:\/[A-Za-z0-9_@+.,$-]+)*\.(?:[cm]?[jt]sx?|mjs|cjs)$/.test(suffix)) relative = `root${index}/${suffix}`; + break; + } + } + } + if (!relative || /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(relative)) return REDACTED_FRAME; + if (sanitizePiDiagnostic(relative, sensitive) !== relative) return REDACTED_FRAME; + const frame = `${relative}:${match[2]}:${match[3]}`; + return Buffer.byteLength(frame) <= MAX_FRAME_BYTES ? frame : REDACTED_FRAME; +} + +/** Produce bounded technical evidence only; arbitrary exception text is never logged. */ +export function buildPiInitializationDiagnostic( + error: unknown, + options: PiInitializationDiagnosticOptions, +): PiInitializationDiagnostic { + const requestedStage = data(options, 'stage'); + const stage = typeof requestedStage === 'string' && STAGES.has(requestedStage) ? requestedStage : 'unknown'; + const roots = strings(data(options, 'trustedRoots')); + const sensitive = secretAliases(strings(data(options, 'sensitiveValues'))); + const seen = new Set(); + const build = (value: unknown, depth: number): PiInitializationDiagnostic => { + const exception = exceptionName(value); + const code = knownCode(value); + const result: PiInitializationDiagnostic = { + version: 1, stage, exception, ...(code ? { code } : {}), ...classify(exception, code), ...rpcProjection(value), + }; + const message = typeof value === 'string' ? value : data(value, 'message'); + if (typeof message === 'string' && message) { + const bytes = Buffer.from(message.slice(0, MAX_TEXT_BYTES)); + result.fingerprint = createHash('sha256').update(bytes.subarray(0, MAX_TEXT_BYTES)).digest('hex').slice(0, 16); + if (message.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true; + } + // Even native lazy stack accessors can call global formatting hooks. Only + // inspect an already available string data property; never invoke a getter. + const stack = data(value, 'stack'); + if (typeof stack === 'string') { + const bytes = Buffer.from(stack.slice(0, MAX_TEXT_BYTES)); + const bounded = bytes.subarray(0, MAX_TEXT_BYTES).toString('utf8'); + const lines = bounded.split(/\r?\n/).filter(line => /^\s*at\s/.test(line)); + result.frames = lines.slice(0, MAX_FRAMES).map(line => frameLocation(line, roots, sensitive)); + if (lines.length > MAX_FRAMES || stack.length > MAX_TEXT_BYTES || bytes.length > MAX_TEXT_BYTES) result.truncated = true; + } + seen.add(value); + const cause = data(value, 'cause'); + if (cause !== undefined) { + if (depth < MAX_CAUSE_DEPTH && !seen.has(cause)) result.causes = [build(cause, depth + 1)]; + else result.truncated = true; + } + return result; + }; + const diagnostic = build(error, 0); + const nodes = [diagnostic, ...(diagnostic.causes ?? []), ...(diagnostic.causes?.[0]?.causes ?? [])]; + while (Buffer.byteLength(JSON.stringify(diagnostic)) > MAX_BYTES) { + diagnostic.truncated = true; + const removable = [...nodes].reverse().find(node => node.frames?.length); + if (removable?.frames) removable.frames.pop(); + else { delete diagnostic.causes; break; } + } + return diagnostic; +} diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 2ed7eed2..c363c203 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -1,6 +1,8 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { realpathSync } from 'node:fs'; import path from 'node:path'; import { logger } from '../../utils/logger'; +import { buildPiInitializationDiagnostic } from './initialization-diagnostic'; import type { ModelSummary, ProviderAccount } from '../../shared/providers/types'; import { validateSessionKey } from '../../coding-projects/conversation-store'; import { @@ -233,9 +235,18 @@ export function createPiManagedWorkerOpener( const createProcess = options.createProcess ?? ((processOptions) => new PiWorkerProcess(processOptions)); const createSessionKey = options.createSessionKey ?? randomUUID; const now = options.now ?? Date.now; - return async (input) => { + const openWorker = async ( + input: PiWorkerOpenInput, + observation: { + stage(value: string): void; + sensitive(...values: Array): void; + failed(error: unknown): void; + }, + ): Promise => { const resourcesStartedAt = now(); const registered = await options.registry.prepare(input.conversation); + observation.sensitive(registered.projectPath, registered.agent.prompt, registered.agent.name, + registered.agent.roleName, registered.conversation.title); const existingSession = input.existingSession ?? registered.session ?? undefined; const model = registered.conversation.model; if (!model || registered.conversation.modelResolution !== 'resolved') { @@ -245,7 +256,9 @@ export function createPiManagedWorkerOpener( true, ); } + observation.stage('provider-input'); const providerInput = await options.loadProviderInput(); + observation.stage('provider-catalog'); const catalog = buildPiProviderCatalog(providerInput); const selection = selectPiProviderModel(catalog, model); const account = providerInput.accounts.find((candidate) => candidate.id === selection.accountId); @@ -253,6 +266,7 @@ export function createPiManagedWorkerOpener( if (!account || !descriptor) throw new Error('Selected Provider account is unavailable'); const managedPaths = await ensurePiManagedPaths(options.userDataDir); await writePiProviderCatalog(managedPaths.modelsFile, catalog, model); + observation.stage('worker-resources'); const workerResources = options.capabilityRegistry ? await options.capabilityRegistry.resolveWorkerResources({ projectPath: registered.projectPath, @@ -261,6 +275,7 @@ export function createPiManagedWorkerOpener( role: 'parent', }) : fallbackWorkerResources(registered.agent.skillIds); + observation.stage('device-resources'); const deviceResources = options.devicePackageManager ? await options.devicePackageManager.resolveEnabledResources() : undefined; @@ -268,6 +283,7 @@ export function createPiManagedWorkerOpener( ...workerResources.skillEntries, ...(deviceResources?.skillEntries ?? []), ]; + observation.stage('resource-materialization'); const resources = await materializePiAgentResources({ userDataDir: options.userDataDir, projectId: input.conversation.projectId, @@ -290,6 +306,8 @@ export function createPiManagedWorkerOpener( : {}), revision: input.revision, }); + observation.sensitive(resources.paths.configDir, resources.projectSessionsDir, managedPaths.rootDir); + observation.stage('provider-credentials'); const credential = await buildPiWorkerCredentialProjection({ account, descriptor, @@ -298,6 +316,8 @@ export function createPiManagedWorkerOpener( ? { localProxyCredential: await options.getLocalProxyCredential() } : {}), }); + observation.sensitive(...credential.sensitiveValues); + observation.stage('extension-registration'); const modelTools = options.modelToolRegistry?.registerWorker({ conversationId: input.conversation.conversationId, generation: input.generation, @@ -335,6 +355,7 @@ export function createPiManagedWorkerOpener( extensionsDir: managedPaths.extensionsDir, }); } catch (error) { + observation.failed(error); modelTools?.dispose(); throw error; } @@ -346,12 +367,15 @@ export function createPiManagedWorkerOpener( now(), !existingSession, ); + observation.sensitive(extension.extensionPath, ...extension.sensitiveValues); + observation.stage('session-bind'); const sessionKey = validateSessionKey( existingSession?.sessionKey ?? createSessionKey(), ); if (existingSession && input.fork) { throw new Error('Pi worker cannot reopen and fork a session at the same time'); } + observation.stage('worker-start'); const process = createProcess({ executablePath: options.executablePath, cliPath: options.cliPath, @@ -417,6 +441,7 @@ export function createPiManagedWorkerOpener( if (input.fork?.sourceEntryId) { await process.request({ type: 'fork', entryId: input.fork.sourceEntryId }); } + observation.stage('initial-hydration'); const readyStartedAt = now(); const response = await process.request( { type: 'get_state' }, @@ -451,6 +476,7 @@ export function createPiManagedWorkerOpener( ); } } + observation.stage('session-bind'); const sessionStartedAt = now(); const bound = await options.registry.ensureBinding(input.conversation, async () => ({ piSessionId, @@ -485,12 +511,57 @@ export function createPiManagedWorkerOpener( session: clone(bound.session), }; } catch (error) { + observation.failed(error); unsubscribeExtensionInvalidation(); await process.stop('open_failure').catch(() => undefined); await disposeManagedResources(); throw error; } }; + return async (input) => { + let stage = 'registry'; + let failureObserved = false; + const sensitiveValues = [input.conversation.title, options.userDataDir, + options.executablePath, options.cliPath, options.bundledSkillsDir]; + const observation = { + stage: (value: string) => { stage = value; }, + sensitive: (...values: Array) => { + sensitiveValues.push(...values.filter((value): value is string => typeof value === 'string')); + }, + failed: (error: unknown) => { + if (failureObserved) return; + failureObserved = true; + try { + const piPackageRoot = path.resolve(path.dirname(options.cliPath), '..'); + const trustedRoots = [piPackageRoot, path.resolve(__dirname, '..', '..'), options.bundledSkillsDir]; + try { + // pnpm package stacks use the real path behind the bundled dependency link. + const resolvedPiRoot = realpathSync.native(piPackageRoot); + if (!trustedRoots.includes(resolvedPiRoot)) trustedRoots.push(resolvedPiRoot); + } catch { + // Missing resources must still produce a diagnostic using known roots. + } + logger.warn('[PiWorkerInitializationFailed]', { + conversationRef: createHash('sha256').update(input.conversation.conversationId).digest('hex').slice(0, 16), + workerGeneration: Number.isSafeInteger(input.generation) ? input.generation : 0, + diagnostic: buildPiInitializationDiagnostic(error, { + stage, + sensitiveValues, + trustedRoots, + }), + }); + } catch { + // Diagnostic construction/output must never change initialization or cleanup. + } + }, + }; + try { + return await openWorker(input, observation); + } catch (error) { + observation.failed(error); + throw error; + } + }; } function recordManagedMilestone( diff --git a/electron/image-workspace/module.ts b/electron/image-workspace/module.ts index 015aab12..c5d199fc 100644 --- a/electron/image-workspace/module.ts +++ b/electron/image-workspace/module.ts @@ -6,6 +6,7 @@ import type { DesignCommandResult, DesignCreateWorkspaceInput, DesignDeleteWorkspaceResult, + DesignGeneratedWorksPage, DesignRenameWorkspaceInput, DesignWorkspace, DesignWorkspaceBootstrap, @@ -44,6 +45,7 @@ export interface DesignWorkspaceModule { deleteWorkspace(workspaceId: string): Promise; renameWorkspace(input: DesignRenameWorkspaceInput): Promise; getWorkspace(workspaceId: string): Promise; + listGeneratedWorks?(offset?: number): Promise; submitCommand(input: DesignCommandInput): Promise; uploadAsset(input: DesignAssetUploadInput): Promise; openWorkspaceEvents( diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 79ada84e..e88e2ab7 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -20,6 +20,8 @@ import { type DesignGenerationQuote, type DesignGenerationOptions, type DesignGenerationTask, + type DesignGeneratedWork, + type DesignGeneratedWorksPage, type DesignInteractionOutcome, type DesignMedium, type DesignQuoteBlockedEvent, @@ -38,7 +40,11 @@ import { type DesignWorkspaceUpdatedEvent, } from '../../shared/image-workspace'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { getValidWorksSquareAccessToken } from '../services/works-square-session'; +import { + getValidWorksSquareAccessToken, + getWorksSquareAccountBinding, + isCurrentWorksSquareAccountBinding, +} from '../services/works-square-session'; import { fetchWithDeadline, proxyAwareFetch, @@ -97,6 +103,10 @@ const RUN_MAX_POLL_MS = 4_000; const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; const WEBSOCKET_PING_INTERVAL_MS = 20_000; const WEBSOCKET_OPEN = 1; +const GENERATED_WORKS_WORKSPACE_PAGE_SIZE = 12; +const GENERATED_WORKS_READ_CONCURRENCY = 3; +const WORKSPACE_ASSET_HISTORY_LIMIT = 100; +const WORKSPACE_TASK_HISTORY_LIMIT = 50; const PUBLIC_ASSISTANT_PROGRESS_MESSAGES: Record = { understanding: '正在听懂你刚补充的内容…', reviewing_context: '正在把它和前面的设计想法放在一起看…', @@ -886,6 +896,119 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { )); } + async listGeneratedWorks(offset = 0): Promise { + if (!Number.isSafeInteger(offset) || offset < 0 + || offset > Number.MAX_SAFE_INTEGER - GENERATED_WORKS_WORKSPACE_PAGE_SIZE) { + throw new DesignWorkspaceModuleError(400, 'IMAGE_WORKSPACE_INVALID_OFFSET', '作品分页位置无效'); + } + try { + return await runWithDeadline( + (signal) => this.scanGeneratedWorks(offset, signal), + this.requestTimeoutMs, + ); + } catch (error) { + if (error instanceof RequestDeadlineExceededError) throw designRequestTimeoutError(); + throw error; + } + } + + private async scanGeneratedWorks( + offset: number, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const account = getWorksSquareAccountBinding(); + if (!account) { + throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再查看作品'); + } + const requireCurrentAccount = () => { + signal.throwIfAborted(); + if (!isCurrentWorksSquareAccountBinding(account)) { + throw new DesignWorkspaceModuleError(409, 'DESIGN_ACCOUNT_CHANGED', '登录账号已改变,请重新打开作品'); + } + }; + const token = await getValidWorksSquareAccessToken({ + fetchImpl: this.fetchImpl, + requestTimeoutMs: this.requestTimeoutMs, + }); + requireCurrentAccount(); + if (!token) { + throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再查看作品'); + } + // Pin one account's credential for this scan; never cache or combine account pages in Main. + const summaries = arrayValue(await this.requestJson( + `/api/design/workspaces?limit=${GENERATED_WORKS_WORKSPACE_PAGE_SIZE + 1}&offset=${offset}`, + { signal }, + token, + ), 'Workspaces').slice(0, GENERATED_WORKS_WORKSPACE_PAGE_SIZE + 1).map(mapWorkspaceSummary); + requireCurrentAccount(); + const page = summaries.slice(0, GENERATED_WORKS_WORKSPACE_PAGE_SIZE); + const works: DesignGeneratedWork[] = []; + const unavailableWorkspaceIds = new Set(); + let historyTruncated = false; + let nextIndex = 0; + const readNext = async () => { + while (nextIndex < page.length) { + requireCurrentAccount(); + const summary = page[nextIndex++]; + try { + const workspace = mapWorkspace(await this.requestJson( + `/api/design/workspaces/${encodeURIComponent(summary.workspaceId)}`, + { signal }, + token, + )); + requireCurrentAccount(); + if (workspace.workspace.workspaceId !== summary.workspaceId) { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', '作品项目不匹配'); + } + historyTruncated ||= workspace.assets.length >= WORKSPACE_ASSET_HISTORY_LIMIT + || workspace.tasks.length >= WORKSPACE_TASK_HISTORY_LIMIT; + const assets = new Map(workspace.assets.map((asset) => [asset.assetId, asset])); + const includedAssetIds = new Set(); + for (const task of workspace.tasks) { + if (task.status !== 'succeeded') continue; + for (const assetId of task.resultAssetIds) { + const asset = assets.get(assetId); + if (!asset) historyTruncated = true; + if (!asset || asset.role !== 'generated' || asset.generationTaskId !== task.taskId + || includedAssetIds.has(assetId)) continue; + includedAssetIds.add(assetId); + works.push({ + asset, + workspaceTitle: workspace.workspace.title, + taskId: task.taskId, + title: workspace.workspace.title, + }); + } + } + } catch (error) { + requireCurrentAccount(); + if (error instanceof DesignWorkspaceModuleError + && (error.status === 401 || error.status === 403)) throw error; + unavailableWorkspaceIds.add(summary.workspaceId); + } + } + }; + await Promise.all(Array.from( + { length: Math.min(GENERATED_WORKS_READ_CONCURRENCY, page.length) }, + readNext, + )); + requireCurrentAccount(); + works.sort((left, right) => (Date.parse(right.asset.createdAt) || 0) + - (Date.parse(left.asset.createdAt) || 0) + || left.asset.workspaceId.localeCompare(right.asset.workspaceId) + || left.asset.assetId.localeCompare(right.asset.assetId)); + return { + works, + nextOffset: summaries.length > GENERATED_WORKS_WORKSPACE_PAGE_SIZE + ? offset + GENERATED_WORKS_WORKSPACE_PAGE_SIZE : null, + unavailableWorkspaceIds: page + .filter((summary) => unavailableWorkspaceIds.has(summary.workspaceId)) + .map((summary) => summary.workspaceId), + historyTruncated, + }; + } + async submitCommand(input: DesignCommandInput): Promise { let command: ServerAgentCommand; try { @@ -1069,10 +1192,14 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { closers.forEach((close) => close()); } - private async requestJson(path: string, init: RequestInit = {}): Promise { + private async requestJson( + path: string, + init: RequestInit = {}, + pinnedAccessToken?: string, + ): Promise { try { return await runWithDeadline(async (signal) => { - const response = await this.authorizedFetch(path, { + const requestInit: RequestInit = { ...init, signal, headers: { @@ -1082,7 +1209,10 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { : {}), ...(init.headers ?? {}), }, - }); + }; + const response = pinnedAccessToken + ? await this.fetchWithToken(path, pinnedAccessToken, requestInit) + : await this.authorizedFetch(path, requestInit); const payload = await readPayload(response); if (!response.ok) { const detail = asErrorDetail(payload); diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts index 8043ce90..b9622d0a 100644 --- a/electron/services/project-packager.ts +++ b/electron/services/project-packager.ts @@ -10,6 +10,8 @@ import { import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import type { ProjectType } from '../../shared/project-config'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; +import { loadWorkEntry } from '../coding-runtime/work-entry-store'; +import { discoverWorkLaunch } from '../coding-runtime/work-launcher'; import { writeStoredZip } from './streaming-zip'; type PublishableProjectType = Exclude; @@ -181,10 +183,21 @@ export async function createStaticProjectPackage(input: { limits?: ProjectPackageLimits; }): Promise { const limits = input.limits ?? DEFAULT_LIMITS; - const projectPath = await resolveProjectDirectory(input.projectPath); - const projectType = await readPublishableProjectType(projectPath); + const projectRoot = await resolveProjectDirectory(input.projectPath); + const rootIdentity = await readSafeDirectoryIdentity(projectRoot, projectRoot); + const { projectType, projectId } = await readPublishableProjectConfig(projectRoot); + const sourceDirectory = await resolveSourceDirectory(projectRoot, projectId); + await assertDirectoryIdentityChain(projectRoot, [rootIdentity]); + const sourceChain = [rootIdentity]; + let projectPath = projectRoot; + for (const part of sourceDirectory.split('/').filter((part) => part !== '.')) { + projectPath = join(projectPath, part); + sourceChain.push(await readSafeDirectoryIdentity(projectRoot, projectPath)); + } + await assertDirectoryIdentityChain(projectRoot, sourceChain); await validateViteProject(projectPath, limits); - const scan = await scanProject(projectPath, limits); + await assertDirectoryIdentityChain(projectRoot, sourceChain); + const scan = await scanProject(projectPath, limits, projectRoot, sourceChain); const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8'); const sourceBytes = scan.sourceBytes + manifestBytes.length; if (sourceBytes > limits.maxSourceBytes) { @@ -217,7 +230,7 @@ export async function createStaticProjectPackage(input: { return { path: archivePath, source: { filePath: file.absolutePath }, - beforeRead: async () => assertStablePackageFile(projectPath, file), + beforeRead: async () => assertStablePackageFile(projectRoot, file), afterRead: async ({ size }) => { if (size !== file.size) { throw new ProjectPackageError( @@ -225,7 +238,7 @@ export async function createStaticProjectPackage(input: { `打包过程中项目文件发生变化:${file.archivePath}`, ); } - await assertStablePackageFile(projectPath, file); + await assertStablePackageFile(projectRoot, file); }, }; }), @@ -268,7 +281,10 @@ export async function createStaticProjectPackage(input: { }; } -async function readPublishableProjectType(projectPath: string): Promise { +async function readPublishableProjectConfig(projectPath: string): Promise<{ + projectType: PublishableProjectType; + projectId?: string; +}> { const result = await readCodingProjectConfigV2(projectPath); if (result.status === 'invalid') { throw new ProjectPackageError( @@ -282,7 +298,31 @@ async function readPublishableProjectType(projectPath: string): Promise { + const saved = projectId ? await loadWorkEntry({ id: projectId, path: projectRoot }) : null; + let directory = saved?.launch?.directory; + if (directory === undefined) { + // Existing root projects may build without a replayable dev script. Their + // release validation remains authoritative; opening need not be supported. + const rootPackage = await lstat(join(projectRoot, 'package.json')).catch(() => null); + if (rootPackage) return '.'; + try { + directory = (await discoverWorkLaunch(projectRoot))?.directory ?? '.'; + } catch { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '无法唯一确认当前作品的工程目录'); + } + } + if (directory !== '.') { + validateArchivePath(directory); + if (directory.split('/').some((part) => part.startsWith('.') || EXCLUDED_DIRECTORY_NAMES.has(part.toLowerCase()))) { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '作品工程目录不能是本地配置或生成目录'); + } + } + return directory; } async function resolveProjectDirectory(projectPath: string): Promise { @@ -386,6 +426,8 @@ async function readRequiredFile( async function scanProject( projectPath: string, limits: ProjectPackageLimits, + projectRoot: string, + sourceChain: DirectoryIdentity[], ): Promise { const files: PackageFile[] = []; const excludedPaths: string[] = []; @@ -396,14 +438,14 @@ async function scanProject( directory: string, parentChain: DirectoryIdentity[], ): Promise { - await assertDirectoryIdentityChain(projectPath, parentChain); - const before = await readSafeDirectoryIdentity(projectPath, directory); + await assertDirectoryIdentityChain(projectRoot, parentChain); + const before = await readSafeDirectoryIdentity(projectRoot, directory); const directoryChain = [...parentChain, before]; const entries = await readdir(directory, { withFileTypes: true }); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); for (const entry of entries) { - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); const absolutePath = join(directory, entry.name); const archivePath = toArchivePath(relative(projectPath, absolutePath)); const isDirectory = entry.isDirectory(); @@ -412,7 +454,7 @@ async function scanProject( if (excludedPaths.length < 100) { excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (entry.isSymbolicLink()) { @@ -424,7 +466,7 @@ async function scanProject( validateArchivePath(archivePath); if (isDirectory) { await walk(absolutePath, directoryChain); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (!entry.isFile()) { @@ -463,12 +505,12 @@ async function scanProject( `项目文件数超过 ${limits.maxFiles} 个限制`, ); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await walk(projectPath, []); + await walk(projectPath, sourceChain); return { files, sourceBytes, diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index da06ac12..0e046708 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -276,6 +276,22 @@ export type DesignAsset = { contentPath: string; }; +export type DesignGeneratedWork = { + asset: DesignAsset; + workspaceTitle: string; + taskId: string; + title: string; +}; + +/** One page scans Workspace summaries; nextOffset is a Workspace offset. */ +export type DesignGeneratedWorksPage = { + works: DesignGeneratedWork[]; + nextOffset: number | null; + unavailableWorkspaceIds: string[]; + /** The upstream detail API limits each Workspace's assets and task history. */ + historyTruncated: boolean; +}; + export type DesignTaskProgress = { stage: | 'queued' diff --git a/src/components/community/CommunityLink.tsx b/src/components/community/CommunityLink.tsx index aa33f76e..751954c7 100644 --- a/src/components/community/CommunityLink.tsx +++ b/src/components/community/CommunityLink.tsx @@ -5,20 +5,21 @@ import { openCommunity } from '@/lib/community'; import './community.css'; export function CommunityLink({ variant = 'inline', collapsed = false }: { - variant?: 'inline' | 'sidebar' | 'welcome'; collapsed?: boolean; + variant?: 'inline' | 'sidebar' | 'welcome' | 'home'; collapsed?: boolean; }) { const [opening, setOpening] = useState(false); const [error, setError] = useState(false); - return
+ const Container = variant === 'home' ? 'span' : 'div'; + return {error && 没能打开作品广场,请再点一次试试。} -
; + ; } diff --git a/src/components/community/community.css b/src/components/community/community.css index 1f0065d7..ba8e2572 100644 --- a/src/components/community/community.css +++ b/src/components/community/community.css @@ -10,6 +10,7 @@ .coding-start-mark img { width: 35px; height: 35px; } .coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; } .coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; } +.coding-start-heading > .coding-start-mark { margin-bottom: 24px; } .coding-start-eyebrow { display: inline-flex; align-items: center; gap: 4px; margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; } .coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; } .coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); } @@ -35,6 +36,10 @@ .community-link[data-variant="welcome"]:hover:not(:disabled) .coding-start-eyebrow, .community-link[data-variant="welcome"]:focus-visible .coding-start-eyebrow { color: #526b46; text-decoration: underline; text-underline-offset: 4px; } .community-link[data-variant="welcome"] .community-link-arrow { width: 12px; height: 12px; } .community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; } +.community-link-container[data-variant="home"] { display: inline; } +.community-link[data-variant="home"] { display: inline-block; vertical-align: baseline; min-height: 0; margin: 0; padding: 0; border: 0; border-radius: 2px; background: transparent; box-shadow: none; color: inherit; font-family: inherit; font-size: 1.12em; font-weight: inherit; line-height: inherit; letter-spacing: inherit; text-decoration: underline; text-decoration-color: currentColor; text-decoration-thickness: 1.5px; text-underline-offset: 6px; transition: transform 160ms ease; } +.community-link[data-variant="home"]:is(:hover, :focus-visible):not(:disabled) { background: transparent; text-decoration-thickness: 2px; transform: translateY(-1px); } +.community-link[data-variant="home"]:active:not(:disabled) { transform: translateY(1px); } .community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; } @keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } } @media (max-width: 600px) { @@ -43,3 +48,4 @@ .community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; } } @media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } .community-link[data-variant="welcome"] .coding-start-mark, .community-link[data-variant="welcome"] .coding-start-eyebrow { transition: none; } } +@media (prefers-reduced-motion: reduce) { .community-link[data-variant="home"] { transition: none; }.community-link[data-variant="home"]:is(:hover, :focus-visible, :active):not(:disabled) { transform: none; } } diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 9a9c5a5a..59289779 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -20,7 +20,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; +import { createImageWorkspaceProjectId, IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import { useAuthStore } from '@/stores/auth'; import type { DesignWorkspaceSummary } from '../../../shared/image-workspace'; @@ -48,6 +48,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const renameProject = useImageWorkspaceStore((state) => state.renameProject); const selectProject = useImageWorkspaceStore((state) => state.selectProject); const [dialog, setDialog] = useState(null); + const [creationIntentId, setCreationIntentId] = useState(createImageWorkspaceProjectId); const [projectName, setProjectName] = useState(''); const [dialogError, setDialogError] = useState(null); const [saving, setSaving] = useState(false); @@ -63,6 +64,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba 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; + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -79,6 +81,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba ); const openCreateDialog = () => { + setCreationIntentId(createImageWorkspaceProjectId()); setProjectName(''); setDialogError(null); setDialog({ mode: 'create', project: null }); @@ -103,7 +106,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba await renameProject(dialog.project.workspaceId, title); toast.success('项目名称已更新'); } else { - await createProject(title); + await createProject(title, creationIntentId); navigate('/image-canvas'); } setDialog(null); @@ -282,12 +285,9 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba )} !open && !saving && setDialog(null)}> - + {dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'} - - 创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。 -
-