merge: integrate remote main

This commit is contained in:
inman
2026-09-04 14:14:02 +08:00
11 changed files with 258 additions and 46 deletions

View File

@@ -26,7 +26,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
| Official Hosted Plugins | Acquired code-owned bundled Game Resource package → effective parent snapshot → code-owned Main adapter → fixed Works Square hosted route | 无设备下载、更新、Beta 或签名步骤Renderer/Package/Pi 不持有 Provider key、model 或 URL。每次计费操作要求显式确认child 不继承 hosted tool。历史 Hosted Web Search 不再进入当前客户端。 |
| Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 拥有桌面用户权限且生命周期脚本禁用。每个 generation 包含所有显式安装且当前启用的 Skill/extension新/idle parent 自动刷新active parent 在 turn settled 后刷新child 始终为空。 |
| AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | 自然对话是主创作面Living Form 仅以“AI 已理解”的紧凑辅助摘要与可选手动调整投影服务端 Current SpecificationRenderer 只持有草稿和已接受投影 |
| AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer稳定 command/operation ID 支持 unknown-result 重放revision conflict 刷新权威状态 |
| AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer稳定 command/operation ID 支持 unknown-result 重放revision conflict 刷新权威状态;待提交 chat 从同一 pending operation 临时投影assistant delta 只能在匹配该 operation 的一个未完成助手气泡中临时绘制且不生成独立整理进度栏 |
| AI Design Gateway Routing | Main-owned Works Square V2 adapter 与 Direction event stream | Main 持有 Works Token、stream ticket、WebSocket、重试分类和错误脱敏事件顺序与 Task progress 不构成 Specification 真值 |
| AI Design Quote & Task Controls | 精确 Specification revision 编译出的不可变 Quote 与 Workspace Tasks | 客户端只展示 public output summary、warnings、expiry 与 Token Points并以 Quote ID 确认Provider Prompt/model/route/storage/billing atoms 不下发 |
| AI Design Assets | Workspace 已完成作品或本地上传的真实 Asset | Asset 通过 typed binding 写入 Specification生成结果经 Main-owned asset download 保存 |
@@ -87,6 +87,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
- Main 通过 `design.input.apply``design.quote.request``design.generation.confirm` 访问 V2。unknown transport result 必须复用原 command/operation identity结构化业务错误不得重放未知上游文本不得穿透安全投影。
- Main-owned Canvas Workspace JSON 请求和 shared Works token refresh 的完整生命周期最多 30 秒,超时 abort transport 并以固定 `504 DESIGN_WORKSPACE_REQUEST_TIMEOUT` 结束 Renderer 等待。Electron `net.fetch` 失败后的 Node fetch 透明回退只允许 `GET``HEAD``OPTIONS`PATCH/POST 等 mutation 只允许由持有显式幂等身份的上层协议决定重试,不能由底层 transport 隐式重放。该边界不改变上面的 WebSocket→REST 幂等 fallback。
- Renderer 的异步与流式结果必须核对 Workspace、Direction、revision 和 operation identityDirection snapshot 是 Specification 真值Task/Asset 事件只更新 Workspace resource projection。
- 待提交 chat 可以从现有 pending operation 立即投影为明确标注“发送中/正在确认”的用户气泡,但只有服务端 canonical turn 能进入对话历史;确定失败必须恢复原草稿,不能另建一套消息状态或新业务意图。
- `design.assistant.delta` 是未完成的传输状态,不是 canonical assistant turn。Renderer 只能把它临时绘制为与同一 pending chat identity 绑定的单个未完成助手气泡,并在该 operation 确定收敛后由 canonical turn 替换不得持久化为第二条消息、伪装为已完成回复或创建独立整理进度栏。unknown outcome 保留原 identity 与已有片段,其他 operation 的更新不得全局清除它;右侧 Current Specification 继续呈现 AI 当前整理出的设计理解。重叠连接与重放 chunk 必须按连接 generation 和 `chunkIndex` 收敛。
- 确认生成只提交不可变 Quote ID。Task 事务已提交但事件迟到时可刷新 Workspace projectionTask progress 不得改写 Living Form 或授权新的生成操作。
- Updater feed 选择、原始错误日志、下载和安装生命周期只属于 Electron Main。Renderer 不得把缺失稳定 manifest 投影为“已是最新版”也不得显示原始堆栈、URL、路径或错误码并发检查共享同一错误事件时只发送一次错误状态后续独立重试仍可重新报告。
- 图片与视频复用同一个单图来源选择器。图片 Brief 可从当前 Workspace 的已完成作品或本地上传中选择一张参考图继续生成;视频 Brief 使用同一入口绑定首帧。两条路径都必须通过现有 Workspace Asset 上传/选择契约提交一个真实 `attachmentAssetIds`,不得用本地路径或自然语言描述代替资产身份。

View File

@@ -20,6 +20,46 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
tests, typecheck, full unit 1,844 with 3 skips plus pressure 1/1, production build,
targeted Electron 8/8, and full Electron 33/33 with 1 platform skip. Lint reported
0 errors and only existing warnings in current source and a historical worktree.
- AI Design streamed-reply diagnosis
`229b1b1ce39b7f1541a93ea3c980ab82b6d6266c` and client fix
`23f96a523eb37d8397bb3766ce95a59d56e59225` from tasks
`20260904-diagnose-design-stream-6a4e9c21` and
`20260904-fix-design-stream-4f7b91c2` are integrated onto local `main` and
canonically reconciled by task `20260904-integrate-design-stream-5e7c2a91`.
The existing post-validation `design.assistant.delta` transport is visible again as
exactly one provisional assistant-shaped bubble tied to its pending chat identity;
canonical `workspace.turns` still owns history and replaces the unfinished bubble,
while unknown outcomes keep the same identity and partial reply. The removed generic
“AI 正在整理你的想法” banner is not restored. A ready conversation now offers an
inline Quote step; successful Quote requests reveal the confirmation on desktop or
open it on mobile, but only explicit immutable Quote-ID confirmation creates a paid
Task. Unknown Quote requests disable both entry points and retry the original
operation. Focused tests passed 35/35, the Electron Quote-confirm-Task flow passed
1/1, typecheck, scoped lint, Vite build, document gates, and independent review
passed. The ordinary full suite had one unrelated Pi real-process 2-second timing
miss among 1,873 passing tests and 2 skips; that exact test passed immediately in
isolation, and the pressure test passed 1/1. Provider-native first-token streaming
remains a separate server follow-up; no server, database, billing, Plugin,
packaging, deployment, publication, or remote push changed.
- AI Design conversation-feedback sources
`403236115bf81e7617856cd8219b1be23f6abbb8` from task
`20260904-design-summary-separation-9c4e7a21` and
`3bda17aa3c1d034ddb3feaa08e516398e91cba0a` from task
`20260904-remove-design-progress-4e8a1c73` are integrated onto local `main` and
canonically reconciled by task `20260904-finalize-design-feedback-7c4e2a91`. A
submitted chat appears immediately as a provisional user bubble from its existing
pending operation, shows `发送中` or `正在确认`, restores the retained draft after
definitive failure, and is replaced by the canonical Workspace turn without a
second message store. Raw `design.assistant.delta` text remains internal transport
state: it is neither a finished reply nor a visible progress banner. The right-side
“AI 听懂的想法” remains the single organized-understanding projection, while
connection generation fencing and `chunkIndex` deduplication still converge repeated
streams. Current Specification, immutable Quote confirmation, server/Main contracts,
plugins, packaging, deployment, publication, and paid Provider behavior are
unchanged. Source verification passed focused/adjacent tests, typecheck, scoped
lint, production build, and both existing AI Design Electron checks individually;
the banner-removal follow-up passed its focused 10-test file, typecheck, and scoped
lint.
- Conversation-first AI Design client source
`fe50e4ba198649a07c5c9443f15edf7dfa2a47e6` from task
`20260903-design-guided-conversation-client-8b4e1c72` is integrated onto local
@@ -569,6 +609,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
AI Design Canvas 现在默认以对话和一张持续可见的“我的创作”卡服务 8-16 岁创作者;专业字段矩阵收进按需打开的“精细调整”,移动端保持对话优先并只挂载一个卡片/底部面板。Creation Card、精细调整、Quote、Task 与结果提示都只投影权威状态,已知问题按 code 转成通俗中文,未知服务端或 Provider 文本不会直接显示。一个 Workspace 仍只公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specificationconversation timeline 只记录交互历史。Chat、direct edits、decision responses、proposal acceptance、locks、Asset binding 与 restore 都通过 `design.input.apply` 进入同一服务端 reducerRenderer drafts 在 accepted 前保持本地。Main 持有 Works Token、stream ticket、WebSocket、request deadline、stable command/operation IDs 与错误脱敏unknown result 只能复用原 identity结构化业务错误不得重放。Generation 由服务端对 exact Specification revision 编译 immutable Quote客户端只展示 public output plan、warnings、expiry 与 Token Points并以 Quote ID 调用 `design.generation.confirm`Provider Prompt、model、route、storage 和 billing atoms 不进入 Renderer。Task/Asset events 独立收敛 Workspace resources不改写 Living Form。Development 与 packaged builds 均使用 Works Square V2V1 DTO、local semantic adapter、mutable Quote PATCH 与 editable provider Prompt 已移除。
新提交的 Design chat 会立即从现有 pending operation 投影为带“发送中”或“正在确认”的临时用户气泡,服务端确认的 canonical turn 到达后再替换它;确定失败时原草稿重新出现在输入框。`design.assistant.delta` 只作为内部传输与收敛状态,不显示原始片段,也不在对话区生成独立整理进度栏;右侧 Current Specification 摘要继续承担“AI 当前听懂的内容”。重复连接和事件按 connection generation 与 `chunkIndex` 收敛。
Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URLcredential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。
密码登录提供可选“记住密码”。该记录与七天登录会话分离,只在正式安装包且系统安全存储可用时由 Electron Main 加密落盘Renderer 仅在登录页内存中接收回填,不写 Zustand/localStorageWorks Square 也不持久化桌面密码。退出登录和短信登录不删除记录,成功的未勾选密码登录会清除旧记录。未打包开发版禁用该选项,避免未签名 Electron 调试进程触发 macOS 钥匙串。

View File

@@ -99,10 +99,12 @@
- AI Design 必须以自然对话帮助用户描述清楚需求:先提取和复述已明确内容,每次最多推进一个真正影响作品的问题。客户端 Current Specification 投影是辅助摘要和可选手动调整,不得把内部字段、缺项或普通 Decision Prompt 变成用户必须逐项填写的表单。
- 每个 mutation 使用稳定 command 与 semantic operation identity。transport-unknown 只能重放原命令business rejection、timeout 或用户再次点击不能自动生成新的业务意图。
- Direction projection 是 Specification 真值。event cursor、assistant delta、Task progress 与 Asset event 只用于传输/资源收敛,不得推进或覆盖 canonical specification revision。
- 用户提交 chat 后,客户端可以立即把同一个 pending operation 投影为临时用户气泡,并明确显示“发送中”或“正在确认”;只有服务端返回的 canonical turn 才进入 conversation timeline。确定失败时必须让原草稿重新可编辑不得把临时投影持久化为第二条消息。
- `design.assistant.delta` 只用于未完成回复的传输和结果收敛,不得进入 canonical conversation timeline。客户端可以把它临时显示为与同一 pending chat identity 绑定的单个未完成助手气泡;确定收敛后必须由 canonical turn 替换unknown outcome 则保留原 identity 与已有片段。不得把片段伪装为完成回复、持久化为第二条消息、因其他 operation 更新而全局清除或另加独立的整理进度栏。AI 当前整理出的设计理解仍由右侧 Current Specification 投影承载;连接重叠或事件重放按 connection generation 与 `chunkIndex` 去重收敛。
- Main 只向 Renderer 投影已知错误码的固定中文提示未知上游错误文本必须脱敏为通用提示Works Token、stream ticket、provider internals 留在 Main/服务端。
- AI 绘画 Workspace JSON 请求与 shared Works token refresh 必须覆盖取凭据、发请求和读取响应 body 的完整 30 秒 deadline即使底层 transport 忽略 abort调用方也必须确定性结束为 `504 DESIGN_WORKSPACE_REQUEST_TIMEOUT` 并释放共同等待者。Electron-to-Node 透明 fallback 仅允许 `GET`/`HEAD`/`OPTIONS`mutation 不得因 transport failure 被隐式重放,任何重试必须由上层显式幂等合同授权。
- `design.quote.request` 必须绑定 exact current Specification revision 并返回 immutable Quote任何 production-meaning edit 都需要新 revision 与新 Quote。
- `design.generation.confirm` 只提交 Quote identity。客户端不编辑 provider Prompt/model/route/storage不计算 Token Points也不把 Task recovery 当作再次确认授权。
- `design.quote.request` 必须绑定 exact current Specification revision 并返回 immutable Quote任何 production-meaning edit 都需要新 revision 与新 Quote。对话或 Current Specification 就绪态可以引导用户请求并查看 Quote桌面端可定位到报价区、移动端可打开报价面板但该动作不得创建 Task。
- `design.generation.confirm` 只提交用户明确确认的 Quote identity。客户端不编辑 provider Prompt/model/route/storage不计算 Token Points也不把聊天就绪文案、Quote 展示、重复点击或 Task recovery 当作生成授权。
- 图片/视频 source 必须是当前 Workspace 的 canonical Asset并通过 typed binding 进入 Specificationmedium/role 决定用途,不得从 quick-reply 文案、V1 Brief 或本地路径推断。
- Task/Asset reconciliation 只能更新 Workspace resources已经落库但事件迟到的 Task 可通过 refresh 恢复,不能覆盖 Living Form、local drafts 或 pending Design input。
- 删除 Canvas 项目必须要求用户完整输入项目名并通过 Main-owned Workspace DELETE。成功后被删 Workspace 的会话、任务和资产不得继续留在 Renderer 可访问状态;服务端决定软删除、任务取消、预留积分释放和运行中任务结算。

View File

@@ -1,5 +1,12 @@
import { useEffect, useRef } from 'react';
import { Clock3, Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
import {
Clock3,
Loader2,
MessageSquareText,
Send,
Sparkles,
WandSparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
@@ -40,10 +47,19 @@ function chatFailureMessage(error: unknown): string {
return '暂时没能确认这条消息的处理结果,内容还在输入框里';
}
export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) {
export function DesignConversationPane({
workspace,
canRequestQuote,
onQuoteOffered,
}: {
workspace: DesignWorkspace;
canRequestQuote: boolean;
onQuoteOffered: () => void;
}) {
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
@@ -60,28 +76,32 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
status: operation.status,
}];
});
const streamingOperationIds = Object.entries(assistantStreams)
.filter(([, text]) => text.trim())
.map(([operationId]) => operationId);
const hasAssistantProgress = streamingOperationIds.length > 0;
const hasUnknownAssistantProgress = streamingOperationIds.some(
(operationId) => pendingOperations[operationId]?.status === 'unknown',
);
const submittingDesignInput = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
);
const quoteRequestPending = Object.values(pendingOperations).some(
(operation) => operation.command.kind === 'request_quote'
&& operation.command.workspaceId === workspace.workspace.workspaceId,
);
const streamingReplies = pendingChatMessages.flatMap((message) => {
const text = assistantStreams[message.operationId]?.trim();
return text ? [{ ...message, text }] : [];
});
const projectedDraft = chatDraft.trim() !== ''
&& pendingChatMessages.some((message) => message.message === chatDraft.trim());
const composerDraft = projectedDraft ? '' : chatDraft;
const pendingChatKey = pendingChatMessages
.map((message) => `${message.operationId}:${message.status}`)
.join('|');
const streamingReplyKey = streamingReplies
.map((reply) => `${reply.operationId}:${reply.text}`)
.join('|');
useEffect(() => {
const scrollAnchor = scrollAnchorRef.current;
if (typeof scrollAnchor?.scrollIntoView === 'function') {
scrollAnchor.scrollIntoView({ block: 'end' });
}
}, [hasAssistantProgress, pendingChatKey, workspace.turns.length]);
}, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]);
const submit = () => {
if (!composerDraft.trim() || submittingDesignInput) return;
@@ -90,6 +110,15 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
});
};
const prepareQuote = () => {
if (!canRequestQuote || quoteRequestPending || pendingChatMessages.length > 0) return;
void requestQuote()
.then(onQuoteOffered)
.catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
};
return (
<section
data-testid="image-workspace-conversation"
@@ -107,8 +136,7 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
{workspace.turns.length === 0
&& pendingChatMessages.length === 0
&& !hasAssistantProgress && (
&& pendingChatMessages.length === 0 && (
<div className="mx-auto flex h-full max-w-sm flex-col justify-center py-8">
<div className="mb-5 flex h-11 w-11 items-center justify-center rounded-2xl bg-brand/[0.09] text-brand">
<MessageSquareText className="h-5 w-5" />
@@ -169,26 +197,45 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
</div>
))}
{hasAssistantProgress && (
{streamingReplies.map((reply) => (
<div
key={reply.operationId}
data-testid={`streaming-design-assistant-${reply.operationId}`}
role="status"
aria-live="polite"
data-testid="design-assistant-progress"
className="mr-auto flex max-w-[92%] items-start gap-3 rounded-xl bg-brand/[0.06] px-3.5 py-3 text-muted-foreground shadow-sm"
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"
>
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-background text-brand shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-xs font-semibold text-foreground">
{hasUnknownAssistantProgress ? '正在确认刚才的整理结果' : 'AI 正在整理你的想法'}
</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{hasUnknownAssistantProgress
? '结果回来后会显示在对话里,不需要重复发送。'
: '这不是新的回复,完成后会显示下一步问题或建议。'}
{reply.text}
<span
aria-hidden="true"
className="ml-1 inline-block h-3.5 w-1 animate-pulse rounded-full bg-brand align-middle"
/>
</div>
))}
{canRequestQuote && pendingChatMessages.length === 0 && (
<div
data-testid="design-conversation-quote-action"
className="flex flex-col gap-3 rounded-2xl border border-brand/20 bg-brand/[0.045] p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-semibold text-foreground"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
className="min-h-11 shrink-0 rounded-xl"
disabled={quoteRequestPending}
onClick={prepareQuote}
>
{quoteRequestPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
: <WandSparkles className="mr-2 h-4 w-4" aria-hidden="true" />}
{quoteRequestPending ? '正在准备方案' : '下一步:看看制作方案'}
</Button>
</div>
)}

View File

@@ -177,7 +177,11 @@ export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace })
}
return (
<section className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm">
<section
id="design-offered-quote"
data-testid="design-offered-quote"
className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">

View File

@@ -33,6 +33,7 @@ export type YouthCreationCardProps = {
generationAvailable: boolean;
onOpenFineTune: () => void;
onFocusComposer: () => void;
onQuoteOffered?: () => void;
};
function formatDuration(seconds: number): string {
@@ -335,19 +336,23 @@ export function YouthCreationCard({
generationAvailable,
onOpenFineTune,
onFocusComposer,
onQuoteOffered,
}: YouthCreationCardProps) {
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const model = projectYouthCreationCard(workspace, { quoteBlockers });
const requesting = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
(operation) => operation.command.kind === 'request_quote'
&& operation.command.workspaceId === workspace.workspace.workspaceId,
);
const request = () => {
if (!generationAvailable || requesting || !model.canRequestQuote) return;
void requestQuote().catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
void requestQuote()
.then(() => onQuoteOffered?.())
.catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
};
const hasMoreDetails = model.mustInclude.length > 0
|| model.mustPreserve.length > 0

View File

@@ -185,6 +185,15 @@ export function ImageCanvas() {
setCreationCardOpen(false);
setFineTuneOpen(true);
};
const revealOfferedQuote = () => {
if (!desktopLayout) setCreationCardOpen(true);
globalThis.setTimeout(() => {
const quote = document.getElementById('design-offered-quote');
if (typeof quote?.scrollIntoView === 'function') {
quote.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 0);
};
const productionContent = (
<div className="space-y-5">
<DesignPendingOperationsPanel />
@@ -270,7 +279,13 @@ export function ImageCanvas() {
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
<div className="flex min-h-0 lg:border-r lg:border-border/70">
<DesignConversationPane workspace={workspace} />
<DesignConversationPane
workspace={workspace}
canRequestQuote={generationAvailable
&& cardModel.phase === 'ready'
&& cardModel.canRequestQuote}
onQuoteOffered={revealOfferedQuote}
/>
</div>
{desktopLayout && (
@@ -286,6 +301,7 @@ export function ImageCanvas() {
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>
@@ -309,6 +325,7 @@ export function ImageCanvas() {
generationAvailable={generationAvailable}
onOpenFineTune={openFineTune}
onFocusComposer={focusComposer}
onQuoteOffered={revealOfferedQuote}
/>
{productionContent}
</div>

View File

@@ -462,8 +462,9 @@ test.describe('AI Design V2 workspace', () => {
await expect(page.getByTestId('image-workspace-conversation'))
.toContainText('记住了:主体更简洁,也会为标题留出空间。');
await page.getByRole('button', { name: '看看制作方案' }).click();
await page.getByRole('button', { name: '下一步:看看制作方案', exact: true }).click();
await expect(page.getByRole('heading', { name: '制作方案准备好了' })).toBeVisible();
await expect(page.getByTestId('design-offered-quote')).toBeInViewport();
await expect(page.getByText('开始后会按现在的想法制作;如果再改想法,需要重新准备方案。')).toBeVisible();
await expect(page.getByLabel(/Prompt|提示词/i)).toHaveCount(0);
await expect(page.getByText(/规格版本|编译器|生成方式|不可变/)).toHaveCount(0);

View File

@@ -93,11 +93,12 @@ describe('youth AI Design Canvas page', () => {
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('presents unfinished AI output as progress instead of a chat reply', () => {
prepareWorkspace();
const unfinishedDraft = '收到,我们要收到,我们要制作一张社团活动海报。';
it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => {
const { workspace } = prepareWorkspace();
const firstChunk = '收到,我们要制作';
const unfinishedDraft = `${firstChunk}一张社团活动海报。`;
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-1': unfinishedDraft },
assistantStreams: { 'operation-chat-1': firstChunk },
pendingOperations: {
'operation-chat-1': {
id: 'operation-chat-1',
@@ -121,9 +122,69 @@ describe('youth AI Design Canvas page', () => {
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
expect(within(conversation).getByRole('status')).toHaveTextContent('AI 正在整理你的想法');
const pendingMessage = within(conversation).getByTestId('pending-design-chat-operation-chat-1');
expect(within(pendingMessage).getByText('做一张社团活动海报')).toBeInTheDocument();
expect(within(pendingMessage).getByText('发送中')).toBeInTheDocument();
expect(within(conversation).queryByRole('button', { name: '下一步:看看制作方案' }))
.not.toBeInTheDocument();
expect(within(conversation).queryByTestId('design-assistant-progress')).not.toBeInTheDocument();
expect(within(conversation).queryByText('AI 正在整理你的想法')).not.toBeInTheDocument();
expect(within(conversation).getByText('我已经整理了用途、受众和初步概念,请确认右侧建议。')).toBeInTheDocument();
expect(within(conversation).queryByText(unfinishedDraft)).not.toBeInTheDocument();
const streamingReply = within(conversation)
.getByTestId('streaming-design-assistant-operation-chat-1');
expect(streamingReply).toHaveTextContent(firstChunk);
act(() => {
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-1': unfinishedDraft },
});
});
expect(streamingReply).toHaveTextContent(unfinishedDraft);
act(() => {
useImageWorkspaceStore.setState({
workspace: {
...workspace,
turns: [...workspace.turns, {
turnId: 'turn-2',
rawTurnSequence: 2,
userMessage: '做一张社团活动海报',
assistantMessage: unfinishedDraft,
}],
},
pendingOperations: {},
});
});
await waitFor(() => {
expect(within(conversation).queryByTestId('streaming-design-assistant-operation-chat-1'))
.not.toBeInTheDocument();
});
expect(within(conversation).getAllByText(unfinishedDraft)).toHaveLength(1);
expect(within(conversation).getByRole('button', { name: '下一步:看看制作方案' }))
.toBeInTheDocument();
});
it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => {
const { actions } = prepareWorkspace();
const quotedWorkspace = designWorkspaceFixture({
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
});
actions.requestQuote.mockImplementationOnce(async () => {
useImageWorkspaceStore.setState({ workspace: quotedWorkspace });
return quotedWorkspace;
});
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
fireEvent.click(within(conversation).getByRole('button', { name: '下一步:看看制作方案' }));
expect(actions.requestQuote).toHaveBeenCalledOnce();
expect(actions.confirmGeneration).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByRole('heading', { name: '制作方案准备好了' })).toBeInTheDocument();
});
expect(screen.getByTestId('design-offered-quote')).toBeInTheDocument();
});
it('shows a submitted message immediately and replaces it with the canonical turn', async () => {
@@ -334,9 +395,13 @@ describe('youth AI Design Canvas page', () => {
});
render(<ImageCanvas />);
expect(within(screen.getByTestId('image-workspace-conversation'))
.getByRole('button', { name: '正在准备方案' })).toBeDisabled();
openMobileCreationCard();
expect(screen.getByText('还在确认刚才的操作')).toBeInTheDocument();
expect(screen.getByText(/不会重复制作或重复扣费/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '看看制作方案', exact: true })).toBeDisabled();
expect(actions.requestQuote).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看原来的结果' }));
expect(actions.retryOperation).toHaveBeenCalledWith('operation-1');
});

View File

@@ -523,6 +523,25 @@ describe('V2 Living Form store', () => {
const source = await loadedStore();
useImageWorkspaceStore.getState().setFieldDraft('intent.purpose', '本地草稿');
useImageWorkspaceStore.setState({
assistantStreams: { 'operation-chat-unknown': '这段回复还没确认' },
pendingOperations: {
'operation-chat-unknown': {
id: 'operation-chat-unknown',
label: '发送创作想法',
command: {
kind: 'apply_input',
workspaceId: 'workspace-1',
sessionId: 'session-1',
expectedDirectionRevision: 4,
clientOperationId: 'operation-chat-unknown',
input: { kind: 'chat', message: '保留这条未知结果' },
},
status: 'unknown',
error: '结果尚未确认',
clearDraftPaths: [],
clearChatDraft: true,
},
},
quoteBlockers: [{
code: 'missing_choice',
message: '还需要选择作品形状',
@@ -573,9 +592,12 @@ describe('V2 Living Form store', () => {
}),
});
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
'operation-chat-unknown': '这段回复还没确认',
'operation-chat-1': '正在整理',
});
expect(useImageWorkspaceStore.getState()).toMatchObject({
lastEventId: 'session-1:8',
assistantStreams: { 'operation-chat-1': '正在整理' },
fieldDrafts: { 'intent.purpose': '本地草稿' },
quoteBlockers: [],
workspace: { form: { directionRevision: 5, specificationRevision: 4 } },

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
import { DesignTaskPanel } from '@/pages/ImageCanvas/DesignProductionPanel';
@@ -21,12 +21,14 @@ function renderCard(
quoteBlockers?: DesignCompilationIssue[];
onOpenFineTune?: () => void;
onFocusComposer?: () => void;
onQuoteOffered?: () => void;
} = {},
) {
const requestQuote = vi.fn().mockResolvedValue(workspace);
const resolveDecisionPrompt = vi.fn().mockResolvedValue(workspace);
const onOpenFineTune = options.onOpenFineTune ?? vi.fn();
const onFocusComposer = options.onFocusComposer ?? vi.fn();
const onQuoteOffered = options.onQuoteOffered ?? vi.fn();
useImageWorkspaceStore.setState({
workspace,
quoteBlockers: [],
@@ -41,6 +43,7 @@ function renderCard(
generationAvailable={options.generationAvailable ?? true}
onOpenFineTune={onOpenFineTune}
onFocusComposer={onFocusComposer}
onQuoteOffered={onQuoteOffered}
/>,
);
return {
@@ -49,6 +52,7 @@ function renderCard(
resolveDecisionPrompt,
onOpenFineTune,
onFocusComposer,
onQuoteOffered,
};
}
@@ -97,8 +101,8 @@ describe('YouthCreationCard', () => {
vi.clearAllMocks();
});
it('shows a plain creation summary and offers a youth-facing ready action', () => {
const { requestQuote } = renderCard();
it('shows a plain creation summary and reveals the offered quote after requesting it', async () => {
const { onQuoteOffered, requestQuote } = renderCard();
expect(screen.getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
expect(screen.getByText('会跟着聊天自动更新,不需要逐项填写。')).toBeInTheDocument();
@@ -112,6 +116,7 @@ describe('YouthCreationCard', () => {
fireEvent.click(screen.getByRole('button', { name: '看看制作方案' }));
expect(requestQuote).toHaveBeenCalledOnce();
await waitFor(() => expect(onQuoteOffered).toHaveBeenCalledOnce());
});
it('does not surface legacy decision prompts as a form', () => {