fix(canvas): soften design conversation

This commit is contained in:
inman
2026-09-07 17:24:15 +08:00
parent 6a05992e18
commit bb8a19eecf
6 changed files with 90 additions and 65 deletions

View File

@@ -5,7 +5,7 @@
Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页:
- `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - `Makelore CodeAI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;用户在中央对话时间线中让 AI 帮助描绘想法,再直接编辑同一时间线内的制作方案;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 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 授权。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main再由 Main 调用 Works Square成功后回到入口选择页。已登录时Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
@@ -26,7 +26,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
- 客户端更新Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 客户端更新Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。
- 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP保存时自动居中裁剪并以圆形缩略图展示未设置或加载失败时回退为姓名首字母。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP保存时自动居中裁剪并以圆形缩略图展示未设置或加载失败时回退为姓名首字母。
- 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。
- AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。对话与当前制作方案位于同一中央时间线,不是两套状态:聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。 - AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。中央使用与 AI 编程一致的低干扰对话布局:助手回复不套独立卡片,底部输入器是唯一强调层级;当前制作方案以对话内轻量编辑稿呈现,而不是独立硬模块。聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。
- AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入可展开的历史作品记录,桌面端位于右侧全高栏,紧凑窗口保留在时间线中。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。 - AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入可展开的历史作品记录,桌面端位于右侧全高栏,紧凑窗口保留在时间线中。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。
- 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、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。 - AI 绘画左侧项目栏只展示 Workspace桌面设计模式下固定为 256px 且不提供收纳入口,不再在项目下创建独立设计会话;右侧全高历史作品栏同为 256px展示当前 Workspace 的任务与生成资产。紧凑窗口则把项目列表放进左侧抽屉并在中央时间线保留历史记录。删除时必须完整输入项目名称删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目删除最后一个项目后进入空状态。

View File

@@ -2,10 +2,10 @@ import { useEffect, useRef } from 'react';
import { import {
Clock3, Clock3,
Loader2, Loader2,
MessageSquareText,
Send, Send,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import makeloreMark from '@/assets/logo.svg';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { ImageWorkspaceApiError } from '@/lib/image-workspace';
@@ -118,26 +118,30 @@ export function DesignConversationPane({
return ( return (
<section <section
data-testid="image-workspace-conversation" data-testid="image-workspace-conversation"
className="flex min-h-0 flex-1 flex-col bg-surface-subtle/45" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background"
> >
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6"> <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-4 sm:px-6">
{workspace.turns.length === 0 {workspace.turns.length === 0
&& pendingChatMessages.length === 0 && ( && pendingChatMessages.length === 0 && (
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8"> <div className="mx-auto flex h-full max-w-[50rem] flex-col items-center justify-center py-10 text-center">
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand"> <img
<MessageSquareText className="h-5 w-5" /> src={makeloreMark}
</div> alt="麦洛 M 标识"
<h3 className="text-lg font-semibold text-foreground"></h3> className="h-auto w-12 select-none sm:w-[3.25rem]"
<p className="mt-2 text-sm leading-6 text-muted-foreground"> draggable={false}
/>
<h3 className="mt-6 max-w-2xl text-balance text-[clamp(1.65rem,2.45vw,2.1rem)] font-medium leading-[1.28] tracking-[-0.04em] text-foreground">
</h3>
<p className="mt-3 max-w-md text-pretty text-sm leading-6 text-muted-foreground">
</p> </p>
<p className="mt-5 text-xs font-medium text-foreground"></p> <div className="mt-7 flex max-w-2xl flex-wrap justify-center gap-2">
<div className="mt-5 space-y-2">
{STARTER_MESSAGES.map((message) => ( {STARTER_MESSAGES.map((message) => (
<button <button
key={message} key={message}
type="button" type="button"
className="min-h-11 w-full rounded-xl border border-border/70 bg-background px-3 py-2.5 text-left text-xs leading-5 text-foreground transition-colors hover:border-brand/30 hover:bg-brand/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30" className="motion-press rounded-full bg-surface-subtle px-3.5 py-2 text-xs leading-5 text-foreground transition-[background-color,color] duration-150 hover:bg-surface-tertiary hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => setChatDraft(message)} onClick={() => setChatDraft(message)}
> >
{message} {message}
@@ -147,16 +151,16 @@ export function DesignConversationPane({
</div> </div>
)} )}
<div className="mx-auto max-w-5xl space-y-4"> <div className="mx-auto max-w-[50rem] space-y-6">
{workspace.turns.map((turn) => ( {workspace.turns.map((turn) => (
<div key={turn.turnId} className="space-y-3"> <div key={turn.turnId} className="space-y-5">
{turn.userMessage && ( {turn.userMessage && (
<div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground"> <div className="chat-user-message-surface ml-auto max-w-[82%] whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-foreground">
{turn.userMessage} {turn.userMessage}
</div> </div>
)} )}
{turn.assistantMessage && ( {turn.assistantMessage && (
<div className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-border/65 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm"> <div className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground">
{turn.assistantMessage} {turn.assistantMessage}
</div> </div>
)} )}
@@ -169,9 +173,9 @@ export function DesignConversationPane({
data-testid={`pending-design-chat-${message.operationId}`} data-testid={`pending-design-chat-${message.operationId}`}
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
className="ml-auto max-w-[88%]" className="ml-auto max-w-[82%]"
> >
<div className="rounded-2xl rounded-br-md border border-brand/20 bg-brand/[0.055] px-3.5 py-2.5 text-sm leading-6 text-foreground"> <div className="chat-user-message-surface whitespace-pre-wrap break-words rounded-[18px] rounded-tr-md px-3.5 py-2.5 text-pretty text-[15px] leading-6 text-foreground">
{message.message} {message.message}
</div> </div>
<div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground"> <div className="mt-1 flex items-center justify-end gap-1.5 pr-1 text-[11px] text-muted-foreground">
@@ -190,7 +194,7 @@ export function DesignConversationPane({
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
className="mr-auto max-w-[92%] rounded-2xl rounded-bl-md border border-brand/20 bg-background px-3.5 py-3 text-sm leading-6 text-foreground shadow-sm" className="chat-assistant-message-surface mr-auto w-full whitespace-pre-wrap break-words py-0.5 text-pretty text-[15px] leading-6 text-foreground"
> >
{reply.text} {reply.text}
<span <span
@@ -214,36 +218,43 @@ export function DesignConversationPane({
<div ref={scrollAnchorRef} /> <div ref={scrollAnchorRef} />
</div> </div>
<footer className="border-t border-border/70 bg-background p-3.5 sm:px-6"> <form
<div className={cn( data-testid="design-message-composer"
'rounded-2xl border border-border/80 bg-surface-input p-2 transition-colors focus-within:border-brand/35 focus-within:ring-2 focus-within:ring-brand/10', className="relative z-20 mx-auto w-[calc(100%_-_2rem)] max-w-[50rem] shrink-0 pb-3 pt-1"
submittingDesignInput && 'opacity-80', onSubmit={(event) => {
)}> event.preventDefault();
submit();
}}
>
<div
data-testid="design-message-composer-surface"
className={cn(
'chat-composer-surface border bg-background p-3 transition-[border-color,box-shadow] duration-150 focus-within:border-foreground/25 focus-within:ring-1 focus-within:ring-foreground/10',
submittingDesignInput && 'opacity-80',
)}
>
<Textarea <Textarea
id="design-chat-composer" id="design-chat-composer"
aria-label="告诉 AI 你想创作什么" aria-label="告诉 AI 你想创作什么"
value={composerDraft} value={composerDraft}
rows={2} rows={1}
disabled={submittingDesignInput} disabled={submittingDesignInput}
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……" placeholder="描述画面,或告诉麦洛想怎么调整"
className="min-h-[52px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" className="max-h-48 !min-h-[42px] resize-none border-0 bg-transparent px-0 py-1 text-[15px] leading-6 shadow-none placeholder:text-muted-foreground/55 focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => setChatDraft(event.currentTarget.value)} onChange={(event) => setChatDraft(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return;
event.preventDefault(); event.preventDefault();
submit(); submit();
}
}} }}
/> />
<div className="flex items-center justify-between px-1 pt-1"> <div className="flex min-h-8 items-center gap-2">
<span className="text-[11px] text-muted-foreground">Enter Shift + Enter </span> <span className="text-[11px] text-muted-foreground">Enter · Shift + Enter </span>
<Button <Button
type="button" type="submit"
size="icon" className="ml-auto h-8 w-8 shrink-0 rounded-full bg-foreground p-0 text-background shadow-soft hover:bg-foreground/90"
className="h-11 w-11 rounded-xl"
disabled={!composerDraft.trim() || submittingDesignInput} disabled={!composerDraft.trim() || submittingDesignInput}
aria-label="发送消息" aria-label="发送"
onClick={submit}
> >
{submittingDesignInput {submittingDesignInput
? <Loader2 className="h-4 w-4 animate-spin" /> ? <Loader2 className="h-4 w-4 animate-spin" />
@@ -251,7 +262,7 @@ export function DesignConversationPane({
</Button> </Button>
</div> </div>
</div> </div>
</footer> </form>
</section> </section>
); );
} }

View File

@@ -346,26 +346,26 @@ export function YouthCreationCard({
<section <section
data-testid="youth-creation-card" data-testid="youth-creation-card"
aria-labelledby="youth-creation-card-title" aria-labelledby="youth-creation-card-title"
className="overflow-hidden rounded-2xl border border-border/75 bg-background shadow-sm" className="py-2"
> >
<header className="flex items-start justify-between gap-3 border-b border-border/60 px-4 py-3.5 sm:px-5"> <header className="mb-3 flex items-start justify-between gap-3 px-1">
<div> <div>
<h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground"></h2> <h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground"></h2>
<p className="mt-0.5 text-xs text-muted-foreground"></p> <p className="mt-0.5 text-xs text-muted-foreground"></p>
</div> </div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand/[0.055] px-2.5 py-1 text-[11px] font-medium text-brand"> <span className="inline-flex items-center gap-1.5 pt-1 text-[11px] font-medium text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
AI
</span> </span>
</header> </header>
<div className="space-y-3 px-4 py-4 sm:px-5"> <div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-surface-subtle/25 p-3"> <section>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="creator-final-prompt" className="text-xs font-semibold text-foreground"></label> <label htmlFor="creator-final-prompt" className="text-xs font-semibold text-foreground"></label>
<button <button
type="button" type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background px-2.5 text-xs font-medium text-foreground hover:border-brand/25 hover:text-brand" className="motion-press inline-flex h-8 items-center gap-1.5 rounded-lg bg-surface-subtle px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-surface-tertiary hover:text-brand"
disabled={busy} disabled={busy}
onMouseDown={(event) => event.preventDefault()} onMouseDown={(event) => event.preventDefault()}
onClick={() => { onClick={() => {
@@ -387,7 +387,7 @@ export function YouthCreationCard({
rows={4} rows={4}
disabled={busy} disabled={busy}
placeholder="AI 会根据前面的对话整理成最终创作提示词,你也可以直接修改。" placeholder="AI 会根据前面的对话整理成最终创作提示词,你也可以直接修改。"
className="min-h-[104px] resize-y bg-background pr-12 text-sm leading-6" className="min-h-[104px] resize-y rounded-xl border-border/70 bg-surface-subtle/45 pr-12 text-sm leading-6 shadow-none transition-[border-color,background-color,box-shadow] focus-visible:border-foreground/25 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-foreground/10"
onChange={(event) => { onChange={(event) => {
const value = event.currentTarget.value; const value = event.currentTarget.value;
const cursor = event.currentTarget.selectionStart ?? value.length; const cursor = event.currentTarget.selectionStart ?? value.length;
@@ -457,6 +457,7 @@ export function YouthCreationCard({
</div> </div>
</section> </section>
{(references.length > 0 || unboundReferenceNumbers.length > 0) && (
<section aria-label="参考图" className="border-t border-border/60 pt-3"> <section aria-label="参考图" className="border-t border-border/60 pt-3">
<h3 className="text-xs font-semibold text-foreground"> {references.length > 0 ? references.length : ''}</h3> <h3 className="text-xs font-semibold text-foreground"> {references.length > 0 ? references.length : ''}</h3>
<div className="mt-2 grid gap-2 lg:grid-cols-2"> <div className="mt-2 grid gap-2 lg:grid-cols-2">
@@ -546,18 +547,20 @@ export function YouthCreationCard({
)} )}
</div> </div>
)} )}
<input
ref={uploadInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择参考图片"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadReference(file);
}}
/>
</section> </section>
)}
<input
ref={uploadInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择参考图片"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadReference(file);
}}
/>
<section aria-label="制作参数" className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border/60 pt-3"> <section aria-label="制作参数" className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-xs font-medium text-foreground"> <label className="flex items-center gap-2 text-xs font-medium text-foreground">
@@ -646,7 +649,7 @@ export function YouthCreationCard({
))} ))}
</div> </div>
<footer className="flex flex-col gap-3 border-t border-border/70 bg-surface-subtle/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5"> <footer className="mt-4 flex flex-col gap-3 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className={cn( <span className={cn(
'text-emerald-600', 'text-emerald-600',

View File

@@ -356,6 +356,9 @@ test.describe('AI Design V2 workspace', () => {
await page.getByTestId('ai-module-option-painting').click(); await page.getByTestId('ai-module-option-painting').click();
await expect(page.getByTestId('image-workspace')).toBeVisible(); await expect(page.getByTestId('image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible(); await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByTestId('image-workspace-conversation')).toHaveClass(/bg-background/);
await expect(page.getByTestId('design-message-composer')).toHaveClass(/max-w-\[50rem\]/);
await expect(page.getByTestId('design-message-composer-surface')).toHaveClass(/chat-composer-surface/);
await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible(); await expect(page.getByTestId('sidebar-image-workspace')).toBeVisible();
await expect(page.getByTestId('image-workspace-history-rail')).toBeVisible(); await expect(page.getByTestId('image-workspace-history-rail')).toBeVisible();
await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0); await expect(page.getByTestId('image-workspace-works-rail')).toHaveCount(0);
@@ -365,6 +368,7 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByText('实时同步')).toHaveCount(0); await expect(page.getByText('实时同步')).toHaveCount(0);
await expect(page.getByRole('button', { name: '刷新作品' })).toHaveCount(0); await expect(page.getByRole('button', { name: '刷新作品' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: '制作方案' })).toBeVisible(); await expect(page.getByRole('heading', { name: '制作方案' })).toBeVisible();
await expect(page.getByTestId('youth-creation-card')).not.toHaveClass(/border|shadow-sm/);
await expect(page.getByRole('textbox', { name: '创作提示词' })).toHaveValue( await expect(page.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'把便携咖啡机呈现为城市通勤中的精密工具', '把便携咖啡机呈现为城市通勤中的精密工具',
); );

View File

@@ -115,6 +115,10 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />); render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation'); const conversation = screen.getByTestId('image-workspace-conversation');
expect(conversation).toHaveClass('bg-background');
expect(conversation).not.toHaveClass('bg-surface-subtle/45');
expect(within(conversation).getByTestId('design-message-composer')).toHaveClass('max-w-[50rem]');
expect(within(conversation).getByTestId('design-message-composer-surface')).toHaveClass('chat-composer-surface');
expect(within(conversation).queryByRole('heading', { name: '和 AI 一起完善创作' })).not.toBeInTheDocument(); expect(within(conversation).queryByRole('heading', { name: '和 AI 一起完善创作' })).not.toBeInTheDocument();
expect(within(conversation).getByRole('heading', { name: '制作方案' })).toBeInTheDocument(); expect(within(conversation).getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
expect(within(conversation).getByRole('textbox', { name: '创作提示词' })).toHaveValue( expect(within(conversation).getByRole('textbox', { name: '创作提示词' })).toHaveValue(
@@ -317,7 +321,7 @@ describe('AI Design Canvas page', () => {
render(<ImageCanvas />); render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation'); const conversation = screen.getByTestId('image-workspace-conversation');
expect(within(conversation).getByRole('heading', { name: '先把脑中的画面说出来' })).toBeInTheDocument(); expect(within(conversation).getByRole('heading', { name: '你想让麦洛和你一起创作什么?' })).toBeInTheDocument();
expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument(); expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' })); fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' }));
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue( expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue(
@@ -381,7 +385,7 @@ describe('AI Design Canvas page', () => {
fireEvent.change(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' }), { fireEvent.change(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' }), {
target: { value: '画一只会做饭的机器人' }, target: { value: '画一只会做饭的机器人' },
}); });
fireEvent.click(screen.getByRole('button', { name: '发送消息' })); fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => { await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith( expect(toastErrorMock).toHaveBeenCalledWith(

View File

@@ -120,9 +120,11 @@ describe('YouthCreationCard', () => {
useImageWorkspaceStore.getState().reset(); useImageWorkspaceStore.getState().reset();
}); });
it('puts the editable creator prompt and compact visible parameters in one plan card', () => { it('keeps the editable prompt and visible parameters in a lightweight inline plan', () => {
renderCard(); renderCard();
expect(screen.getByTestId('youth-creation-card')).toHaveClass('py-2');
expect(screen.getByTestId('youth-creation-card')).not.toHaveClass('border', 'shadow-sm');
expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue( expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'把便携咖啡机呈现为城市通勤中的精密工具', '把便携咖啡机呈现为城市通勤中的精密工具',
@@ -131,6 +133,7 @@ describe('YouthCreationCard', () => {
expect(screen.getByRole('combobox', { name: '画幅' })).toHaveValue('3:4'); expect(screen.getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
expect(screen.getByRole('button', { name: '增加数量' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '增加数量' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '准备制作方案' })).toBeInTheDocument();
expect(screen.queryByRole('region', { name: '参考图' })).not.toBeInTheDocument();
expect(screen.queryByText('查看画面细节')).not.toBeInTheDocument(); expect(screen.queryByText('查看画面细节')).not.toBeInTheDocument();
expect(screen.queryByText('手动调整')).not.toBeInTheDocument(); expect(screen.queryByText('手动调整')).not.toBeInTheDocument();
}); });