feat(design): integrate conversation-first creation flow
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-04 07:39:58 +08:00
parent 14efa99f0c
commit 945b40de11
14 changed files with 374 additions and 234 deletions

View File

@@ -25,7 +25,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
| Pi Extension, Subagents & Lifecycle | 必需的生成式 Makelore extension、Main 显式选定的已安装 extensions、UI interaction、ephemeral child、write lease 与 background run lease | Makelore bridge 固定为首个 extension其余选定 extension 全部经 Pi 的 explicit additional paths 加载且 ambient discovery 关闭child 并发 4、单次最多 8、禁止递归active/uncertain run 不因页面隐藏或 confirmation timeout 被停止replacement/stop 必须可解释并清理所有 ownership |
| 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 服务端 Current Specification 的投影;Renderer 只持有草稿和已接受投影 |
| 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 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 不下发 |
@@ -84,6 +84,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
- 落盘文件名 `works-cloud-deploy.json` 仅为已安装客户端的数据兼容;领域模型和代码接口是 submission binding不表示仍存在 cloud deployment coordinator。
- AI Design 的一个 Workspace 公开一个当前 Direction、一个持久 Agent Session 和一个 Current Specificationconversation timeline 是交互历史,不是独立创建或选择的语义权威。
- Living Form 与 chat、direct edit、decision、proposal、lock、Asset binding、restore 共用同一服务端 reducer。Renderer 草稿在 accepted 前不进入 canonical state。
- AI Design Renderer 必须把自由描述与 Agent 的自然追问放在主交互面。Specification 投影只复述当前理解并提供默认收起的细节和可选手动调整;普通 Decision Prompt 不得自动渲染成要求青少年逐项完成的表单。
- 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。
@@ -100,4 +101,4 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展
## Last Updated
2026-09-03
2026-09-04

View File

@@ -4,6 +4,24 @@ This file is the integrated default-branch snapshot. Feature tasks record progre
## Integrated Through
- Conversation-first AI Design client source
`fe50e4ba198649a07c5c9443f15edf7dfa2a47e6` from task
`20260903-design-guided-conversation-client-8b4e1c72` is integrated onto local
`main` by task `20260904-integrate-guided-design-6a3f9c82`. ImageCanvas now makes
free-form conversation the dominant creation surface: first-use guidance invites
creators aged 816 to describe a subject, scene, or feeling in their own words,
the Agent-facing examples only prefill the composer, and ordinary persisted
Decision Prompts no longer appear as required option-card forms. The secondary
surface is now a compact “AI 听懂的想法” projection whose constraints,
references, and video details are collapsed by default; manual field editing is
explicitly optional. ADR-007 remains intact: Current Specification is still the
server semantic authority, Quote confirmation still submits only immutable Quote
identity, and Main transport/operation certainty is unchanged. Focused/adjacent
45-test, typecheck, scoped lint, production build, pressure 1/1, and Electron E2E
2/2 passed. The ordinary full suite passed 1,866 tests with 2 conditional skips
and one unrelated Pi real-process 2-second timing assertion at 2,232 ms; that
exact file passed 5/5 in isolation. No server contract, paid generation, package,
deployment, publication, or remote push is claimed.
- AI Design command-outcome source
`83f3f134a6779251aaa18f3290308cd9221b038f` from task
`20260903-design-message-send-client-8d3f2a71` is integrated on `main` as
@@ -670,6 +688,7 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
- 本地构建必须使用安装版 Electron Node、固定 npm 11.6.2 和项目 `package-lock.json` 锁定的 Vite不得回退到全局 PATH、已有 `node_modules` 或未验证的 npm 闭包;依赖安装需要网络。
- 项目 Vite config/plugins 以桌面用户权限执行,不能称为 sandbox此风险边界必须在发布说明中保留。
- AI Design 的 Current Specification 是唯一语义权威。Direction events 必须按 Workspace/Direction/revision/operation identity 收敛assistant delta、event cursor、Task progress 和 Asset updates 不得改写 Living Form。
- AI Design 的用户交互以自然对话为主先理解并复述用户已经说清楚的内容再一次只推进一个关键问题。Current Specification 的客户端投影只作为“AI 已理解”的辅助摘要和可选手动调整面板;新增字段不得自动变成必填问题或选项卡表单。
- Design unknown-result replay 必须复用原 stable command 与 semantic operation ID业务错误不得重复提交。Immutable Quote confirmation 只提交 Quote IDTask recovery 不授权新的生成 intent。
- AI 绘画 Main-owned Workspace JSON 请求和共享 Works token refresh 必须在 30 秒内结束并释放共同等待者;只允许 `GET`/`HEAD`/`OPTIONS` 在 Electron transport 失败后透明改走 Node fetchPATCH/POST 等 mutation 必须由具有显式幂等身份的上层协议决定是否重试。临时 `disable-http2` 只用于安装包诊断,不能替代该有限生命周期与非重放边界。
- 服务端 current Direction Session 与 semantic history 不由客户端在注销或退出时删除Main 只关闭本地流、清除 drafts/pending state 和本机凭据。
@@ -683,4 +702,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选
## Last Updated
2026-09-03
2026-09-04

View File

@@ -0,0 +1,133 @@
# Task: Make AI Design conversation primary
## Identity
- Task ID: 20260903-design-guided-conversation-client-8b4e1c72
- Mode: Feature
- Branch: codex/20260903-design-guided-conversation-client-8b4e1c72-design-guided-conversation-client
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260903-design-guided-conversation-client-8b4e1c72
- Base commit: 14efa99f0ca7a02d6ae4e08311f125c56b9399a6
- Owner: codex-root
- Status: Ready for Integration
## Scope
- Make the AI Design conversation the primary creation surface and remove the
impression that a young creator must complete a design form before the Agent can
help.
- Reframe the canonical Specification projection as a compact, secondary “current
understanding” summary with optional detail editing; keep Quote, Task, Asset, and
typed reducer behavior unchanged.
- Add clear first-use guidance that invites a free description and gives a few
example starting points without turning them into required choices.
- Own only the ImageCanvas Renderer presentation and focused unit/Electron tests.
Do not change Electron Main transport, server DTOs, Plugin navigation, packaging,
deployment, publication, or paid generation.
## Intent And Constraints
- Preserve ADR-007: the server Current Specification remains the single semantic
authority, Renderer drafts stay local until accepted, and generation still uses
an immutable Quote ID.
- Conversation must remain visually and behaviorally usable when the Specification
is sparse. The interface should invite the user to describe subject, scene, and
feeling in their own words, not enumerate professional fields or force option
cards.
- The compact summary may show what the Agent currently understands and expose an
optional detail drawer, but it must not compete with the composer for attention or
claim readiness before server validation.
- Keep Chinese-only copy, the existing light visual language, keyboard behavior,
reduced-motion support, 4044 px interactive targets, and current responsive
single-mount rules.
- Preserve the prior definitive-failure versus transport-unknown behavior and all
stable operation identities.
## Concurrent Task Gate
- Task ID: `20260903-design-guided-conversation-client-8b4e1c72`.
- Mode: Feature.
- Branch/worktree:
`codex/20260903-design-guided-conversation-client-8b4e1c72-design-guided-conversation-client` /
`D:\Datas\OthersProjects\.codex-worktrees\makelore\20260903-design-guided-conversation-client-8b4e1c72`.
- Base commit: `14efa99f0ca7a02d6ae4e08311f125c56b9399a6`.
- Ownership result: isolated and exclusively claimed by `codex-root`; the root
`main` worktree and its three foreign untracked task records remain untouched.
- Peer assessment: the only planning Design peers are an old test-only Enter change
on an August baseline and an undefined historical assessment task. Neither owns
the current ImageCanvas production surface. Plugin navigation and other active
tasks use separate worktrees and have no path overlap.
- Gate result: Passed.
## Project Context Loaded
- Read the startup memory set, active task, integrated state, decision index,
ADR-007, system overview, module/data flow, Design business rules and success
criteria, evidence/reflection/commitment/stale indexes, the integrated youth Design
source, the command-outcome source, and relevant peer scopes.
- Confirmed that the existing “我的创作” card is a projection, not semantic
authority. Making it secondary is compatible with ADR-007 and does not require a
DTO or reducer change.
- `project-positioning.md` and the top-level project success placeholder remain
incomplete; concrete product constraints come from `AGENTS.md`, current state,
ADR-007, architecture, domain rules, and the previous integrated Design tasks.
- Planning Gate result: Passed.
## Outcome
- The conversation is now the dominant creation surface on desktop and mobile. Its
first-use copy teaches creators to describe a subject, scene, or feeling in their
own words, and example sentences only prefill the composer.
- Ordinary server Decision Prompts are no longer rendered as option-card forms or
allowed to hold the youth-facing projection in an artificial “choice” phase.
- The secondary panel is now “AI 听懂的想法”: it states that it updates from the
conversation, keeps the concise summary and production readiness visible, and
places constraints, references, and video details behind an optional disclosure.
- Manual field editing remains available as an explicitly optional action. Quote,
Task, Asset, typed reducer, stable operation identity, and Main transport behavior
are unchanged.
- The desktop layout gives materially more width to conversation while preserving a
single mounted summary/production surface at each responsive breakpoint.
## Verification
- Red baseline: the two initial component files produced `8 failed / 7 passed`,
confirming the old headings, mobile entry, and Decision Prompt cards still drove
the experience.
- Focused conversation/card/projection tests: `22 passed`.
- Adjacent ImageCanvas, Fine Tune, issue-copy, and store regression: `45 passed`.
- TypeScript `tsc --noEmit`: passed.
- Changed-file ESLint: passed.
- Production Vite/Electron build: passed; only existing browserslist, dynamic-import,
and chunk-size warnings were emitted.
- Electron/Playwright Design workspace flow: `2 passed`, covering desktop/mobile
responsive mounts, optional manual adjustment, conversation, Quote, and generation
confirmation.
- Full unit run reached `1866 passed / 2 skipped / 1 failed`; the sole failure was an
unrelated Pi real-process timing assertion (2.232 seconds against a 2-second
threshold) under parallel load. Its isolated rerun passed `5/5`.
- Coding chat pressure test, which the package script runs separately: `1 passed`.
- `git diff --check`: passed.
## Follow-ups
- None recorded.
## Promotion Candidates
- Target: `.project-docs/30-worklog/current-state.md` and the canonical AI Design UX
description.
- Proposal: record the MakeLore AI Design surface as conversation-primary. The
persistent Specification projection is a compact “AI understood” summary and
optional manual-edit surface, never a prerequisite form for beginning or
continuing creation.
- Evidence: explicit user direction, red tests demonstrating the former form-led
behavior, 45 focused/adjacent passing tests, production build, and 2/2 Electron
end-to-end flow.
- Future impact: new Specification fields must not automatically become visible
questions or controls; Agent guidance should remain natural language and request
only one consequential clarification at a time.
- Semantic conflicts: this supersedes the earlier “persistent Creation Card as the
primary co-authored form” presentation while preserving ADR-007 authority,
reducer, Quote, and production boundaries.
- Human confirmation: already supplied by the user in this task; no additional
product-direction confirmation is required during integration.

View File

@@ -0,0 +1,93 @@
# Task: Integrate conversation-first AI Design client
## Identity
- Task ID: 20260904-integrate-guided-design-6a3f9c82
- Mode: Integration
- Branch: main
- Worktree: D:\Datas\OthersProjects\makelore
- Base commit: 14efa99f0ca7a02d6ae4e08311f125c56b9399a6
- Owner: codex-root
- Status: Ready for Integration
## Scope
- Integrate completed client source `fe50e4b` from task
`20260903-design-guided-conversation-client-8b4e1c72` onto exact `main`
baseline `14efa99f0ca7a02d6ae4e08311f125c56b9399a6`.
- Promote the accepted conversation-first AI Design presentation into canonical
current state, system overview, and business rules.
- Preserve the three explicitly adopted foreign untracked task records byte-for-byte;
do not add, move, edit, stash, reset, or clean them.
- Do not change server, Electron Main, DTO/API, generation, billing, plugins,
package, deployment, publication, or remote history.
## Intent And Constraints
- The user explicitly replaced the former form-led presentation with a natural
conversation that helps young creators describe a clear need. This is the
human-confirmed product direction for the integration decision.
- ADR-007 remains accepted: Current Specification is still server authority,
Renderer drafts remain local until accepted, and generation confirmation still
submits only an immutable Quote identity.
- Integrate the source task record unchanged. Canonical documents may summarize its
accepted promotion candidate but must not overstate local automation as production
deployment or real paid generation evidence.
- The completed source task passed focused, adjacent, typecheck, lint, production
build, and Electron end-to-end verification. One unrelated Pi real-process timing
assertion failed only under full parallel load and passed 5/5 in isolation.
## Project Context Loaded
- Concurrent Task Gate: passed in Integration mode on root `main`; the worktree is
exclusively owned by this task and its three known foreign task records were
adopted by exact fingerprint under prior user authorization.
- Planning Gate: passed after reading the active and source tasks, integrated current
state, ADR-007, system overview, business rules, success criteria, and the evidence,
reflection, commitment, and stale indexes.
- Other active tasks are isolated in separate worktrees. The completed Design source
owns the exact ImageCanvas changes being integrated; plugin navigation is already
present in the base; no active peer owns or semantically conflicts with this scope.
- `project-positioning.md` remains a project-wide placeholder. Concrete constraints
come from accepted ADR-007 and the populated architecture/domain/current-state
records.
## Outcome
- Applied source `fe50e4ba198649a07c5c9443f15edf7dfa2a47e6` without
conflicts onto its exact parent baseline. The source task record remained
byte-identical to the committed source blob.
- `main` now contains the conversation-first ImageCanvas: natural description and
one-at-a-time Agent questions are primary, persisted option cards are not rendered
as a form, the “AI 听懂的想法” projection is secondary, detailed constraints are
collapsed by default, and manual field adjustment is explicitly optional.
- Promoted the accepted behavior to `current-state.md`, `system-overview.md`, and
`business-rules.md` without changing ADR-007's authority or Quote boundary.
- The three adopted foreign untracked task records remain unmodified and excluded
from the integration commit.
## Verification
- Source product paths match source commit `fe50e4b` exactly.
- Root-main focused/adjacent unit regression: `45 passed`.
- Root-main TypeScript `tsc --noEmit`: passed.
- Root-main changed-file ESLint: passed.
- Root-main production Vite/Electron build: passed with only existing browserslist,
dynamic-import, and chunk-size warnings.
- Root-main Electron/Playwright AI Design flow: `2 passed` across desktop/mobile,
optional manual adjustment, conversation, Quote, and generation confirmation.
- Source full-unit evidence: `1866 passed / 2 skipped / 1 unrelated timing failure`;
the Pi real-process timing file passed `5/5` in isolation, and pressure passed
`1/1`.
- Source task record blob equality, `git diff --check`, project-doc completeness,
and task-aware integration drift: passed.
## Follow-ups
- None recorded.
## Promotion Candidates
- Applied the source task's conversation-first presentation candidate to canonical
current state, system architecture, and business rules. No unresolved promotion
candidate remains.

View File

@@ -96,6 +96,7 @@
- 客户端更新检查由 Electron Main 持有。缺少当前平台正式稳定 manifest 必须保持错误并提示稍后重试或从官网下载不得宣称“已是最新版”设置页只显示一条中文用户提示原始堆栈、URL、路径和错误码只保留在 Main 日志。
- 一个 AI Design Workspace 公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specificationconversation timeline 不是独立创建或选择的权威对象。
- Living Form 只能投影服务端 Current Specification。Chat、direct edit、decision、proposal、lock、Asset binding 与 restore 必须通过同一 V2 reducerRenderer drafts 在 accepted 前保持本地。
- 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。
- Main 只向 Renderer 投影已知错误码的固定中文提示未知上游错误文本必须脱敏为通用提示Works Token、stream ticket、provider internals 留在 Main/服务端。
@@ -142,4 +143,4 @@
## Last Reviewed
2026-09-03
2026-09-04

View File

@@ -1,96 +0,0 @@
import { Check, Sparkles, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { YouthActiveChoice } from './youth-form-projection';
export type CreativeChoiceGridProps = {
choice: YouthActiveChoice;
disabled?: boolean;
onSelect: (optionId: string) => void;
onReject?: () => void;
};
/**
* A small, keyboard-friendly surface for the one decision the creator needs
* to make next. The server-provided option label stays visible verbatim (so
* options such as “你帮我选” remain useful), while the first option gets a
* gentle recommendation marker.
*/
export function CreativeChoiceGrid({
choice,
disabled = false,
onSelect,
onReject,
}: CreativeChoiceGridProps) {
if (choice.options.length === 0) return null;
return (
<section
data-testid="youth-creation-choice"
className="rounded-2xl border border-brand/20 bg-brand/[0.035] p-4"
>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-brand/[0.1] text-brand">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground">{choice.title}</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{choice.body}</p>
</div>
</div>
<fieldset className="mt-3 space-y-2">
<legend className="sr-only"></legend>
{choice.options.map((option, index) => {
const descriptionId = `${choice.id}-${option.id}-description`;
return (
<button
key={option.id}
type="button"
disabled={disabled}
aria-describedby={option.description ? descriptionId : undefined}
className={cn(
'flex min-h-11 w-full items-center gap-3 rounded-xl border border-border/70 bg-background px-3 text-left transition-colors duration-150',
'hover:border-brand/35 hover:bg-brand/[0.04]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-55',
)}
onClick={() => onSelect(option.id)}
>
<span className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 py-2">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium text-foreground">
<span>{option.label}</span>
{index === 0 && (
<span className="rounded-full bg-brand/[0.1] px-2 py-0.5 text-[11px] font-medium text-brand">
</span>
)}
</span>
{option.description && (
<span
id={descriptionId}
className="text-xs leading-5 text-muted-foreground"
>
{option.description}
</span>
)}
</span>
<Check className="h-4 w-4 shrink-0 text-brand/75" aria-hidden="true" />
</button>
);
})}
</fieldset>
{onReject && (
<button
type="button"
disabled={disabled}
className="mt-2 inline-flex min-h-11 items-center gap-1.5 rounded-xl px-2.5 text-xs text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-55"
onClick={onReject}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
</section>
);
}

View File

@@ -7,12 +7,11 @@ import { ImageWorkspaceApiError } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignWorkspace } from '../../../shared/image-workspace';
import { CreativeChoiceGrid } from './CreativeChoiceGrid';
const STARTER_MESSAGES = [
'画一只会做饭的机器人',
'把我的涂鸦变成一段动画',
'做一张社团活动海报',
'我想做一张热闹的社团招新海报',
'我脑中有一只在月球踢球的熊猫',
'把我的涂鸦变成一段会动的小故事',
];
function chatFailureMessage(error: unknown): string {
@@ -23,7 +22,7 @@ function chatFailureMessage(error: unknown): string {
: '暂时没能确认这条消息的处理结果,内容还在输入框里';
}
const messages: Record<string, string> = {
design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
design_reasoner_invalid: 'AI 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送',
design_reasoner_unavailable: 'AI 现在有点忙,暂时没能整理这次想法,请稍后再试',
design_agent_run_failed: 'AI 这次没有整理好,内容还在输入框里,请再试一次',
design_agent_run_cancelled: '这次整理已停止,内容还在输入框里',
@@ -45,7 +44,6 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
const sendChat = useImageWorkspaceStore((state) => state.sendChat);
const resolveDecisionPrompt = useImageWorkspaceStore((state) => state.resolveDecisionPrompt);
const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
@@ -56,17 +54,12 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
const submittingChat = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
);
const activeDecisionPrompt = workspace.form.decisionPrompts
.find((prompt) => prompt.options.length > 0)
?? workspace.form.decisionPrompts[0]
?? null;
useEffect(() => {
const scrollAnchor = scrollAnchorRef.current;
if (typeof scrollAnchor?.scrollIntoView === 'function') {
scrollAnchor.scrollIntoView({ block: 'end' });
}
}, [streams, workspace.form.decisionPrompts.length, workspace.turns.length]);
}, [streams, workspace.turns.length]);
const submit = () => {
if (!chatDraft.trim() || submittingChat) return;
@@ -83,10 +76,10 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
<header className="border-b border-border/70 bg-background px-5 py-4">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-brand" />
<h2 className="text-sm font-semibold text-foreground"></h2>
<h2 className="text-sm font-semibold text-foreground"> AI </h2>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
AI
</p>
</header>
@@ -96,10 +89,11 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
<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" />
</div>
<h3 className="text-lg font-semibold text-foreground"></h3>
<h3 className="text-lg font-semibold text-foreground"></h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
</p>
<p className="mt-5 text-xs font-medium text-foreground"></p>
<div className="mt-5 space-y-2">
{STARTER_MESSAGES.map((message) => (
<button
@@ -141,22 +135,6 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
</div>
))}
{activeDecisionPrompt && (
<CreativeChoiceGrid
choice={activeDecisionPrompt}
disabled={submittingChat}
onSelect={(optionId) => {
void resolveDecisionPrompt(activeDecisionPrompt.id, 'select', optionId).catch(() => {
toast.error('这个选择没有保存,请再试一次');
});
}}
onReject={() => {
void resolveDecisionPrompt(activeDecisionPrompt.id, 'reject').catch(() => {
toast.error('这个选择没有保存,请再试一次');
});
}}
/>
)}
</div>
<div ref={scrollAnchorRef} />
</div>
@@ -172,7 +150,7 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
value={chatDraft}
rows={3}
disabled={submittingChat}
placeholder="比如:画一只在月球踢球的熊猫……"
placeholder="比如:我想做一张社团招新海报,要热闹、有活力……"
className="min-h-[72px] resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(event) => setChatDraft(event.currentTarget.value)}
onKeyDown={(event) => {

View File

@@ -1,6 +1,7 @@
import {
Ban,
Check,
ChevronDown,
Film,
ImageIcon,
Lightbulb,
@@ -25,7 +26,6 @@ import {
projectYouthCreationCard,
type YouthCreationCardModel,
} from './youth-form-projection';
import { CreativeChoiceGrid } from './CreativeChoiceGrid';
export type YouthCreationCardProps = {
workspace: DesignWorkspace;
@@ -245,20 +245,20 @@ function ActionFooter({
case 'empty':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground"></p>
<p className="text-xs leading-5 text-muted-foreground"></p>
<Button type="button" className="min-h-11 rounded-xl" onClick={onFocusComposer}>
<MessageCircle className="mr-2 h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
case 'exploring':
return (
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs leading-5 text-muted-foreground"> AI </p>
<p className="text-xs leading-5 text-muted-foreground">AI </p>
<Button type="button" className="min-h-11 rounded-xl" onClick={onFocusComposer}>
<MessageCircle className="mr-2 h-4 w-4" aria-hidden="true" />
</Button>
</div>
);
@@ -336,37 +336,24 @@ export function YouthCreationCard({
onOpenFineTune,
onFocusComposer,
}: YouthCreationCardProps) {
const resolveDecisionPrompt = useImageWorkspaceStore((state) => state.resolveDecisionPrompt);
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const model = projectYouthCreationCard(workspace, { quoteBlockers });
const submittingInput = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input',
);
const requesting = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
);
const choose = (optionId: string) => {
if (!model.activeChoice || submittingInput) return;
void resolveDecisionPrompt(model.activeChoice.id, 'select', optionId).catch(() => {
toast.error('这个选择没有保存,请再试一次');
});
};
const rejectChoice = () => {
if (!model.activeChoice || submittingInput) return;
void resolveDecisionPrompt(model.activeChoice.id, 'reject').catch(() => {
toast.error('这个选择没有保存,请再试一次');
});
};
const request = () => {
if (!generationAvailable || requesting || !model.canRequestQuote) return;
void requestQuote().catch(() => {
toast.error('制作方案还没准备好,请稍后再试');
});
};
const hasMoreDetails = model.mustInclude.length > 0
|| model.mustPreserve.length > 0
|| model.mustAvoid.length > 0
|| model.references.length > 0
|| model.video !== null;
return (
<section
@@ -379,18 +366,22 @@ export function YouthCreationCard({
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
<h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground">
AI
</h2>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
variant="ghost"
aria-label="手动调整(可选)"
className="min-h-11 shrink-0 rounded-xl px-2.5 text-xs"
onClick={onOpenFineTune}
>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
</header>
@@ -401,7 +392,7 @@ export function YouthCreationCard({
<section>
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Lightbulb className="h-3.5 w-3.5 text-brand" aria-hidden="true" />
<h3></h3>
<h3></h3>
</div>
<p className="mt-2 rounded-xl bg-surface-subtle/75 px-3 py-2.5 text-sm leading-6 text-foreground">
{model.summary}
@@ -433,35 +424,36 @@ export function YouthCreationCard({
</div>
)}
<div className="space-y-4">
<TagSection
title="一定要有"
items={model.mustInclude}
icon={<ListChecks className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="想保留"
items={model.mustPreserve}
icon={<Check className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="不要出现"
items={model.mustAvoid}
icon={<Ban className="h-3.5 w-3.5" aria-hidden="true" />}
tone="avoid"
/>
</div>
<ReferenceSection model={model} />
<VideoSection model={model} />
{model.activeChoice && (
<CreativeChoiceGrid
choice={model.activeChoice}
disabled={submittingInput}
onSelect={choose}
onReject={rejectChoice}
/>
{hasMoreDetails && (
<details
data-testid="youth-creation-details"
className="group rounded-xl border border-border/65 bg-surface-subtle/35"
>
<summary className="flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-3 text-xs font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 [&::-webkit-details-marker]:hidden">
<span> <span className="font-normal text-muted-foreground"></span></span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="space-y-4 border-t border-border/60 px-3 py-4">
<TagSection
title="一定要有"
items={model.mustInclude}
icon={<ListChecks className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="想保留"
items={model.mustPreserve}
icon={<Check className="h-3.5 w-3.5" aria-hidden="true" />}
/>
<TagSection
title="不要出现"
items={model.mustAvoid}
icon={<Ban className="h-3.5 w-3.5" aria-hidden="true" />}
tone="avoid"
/>
<ReferenceSection model={model} />
<VideoSection model={model} />
</div>
</details>
)}
<ActionFooter

View File

@@ -53,7 +53,7 @@ function LoadingWorkspace() {
return (
<div className="flex h-full min-h-0 flex-col bg-background" aria-label="正在加载 AI 设计">
<div className="h-16 animate-pulse border-b border-border/60 bg-surface-subtle/60" />
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(420px,1.35fr)_minmax(360px,0.9fr)]">
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1.8fr)_minmax(320px,0.72fr)]">
<div className="space-y-4 border-r border-border/60 bg-surface-subtle/40 p-5">
<div className="h-16 animate-pulse rounded-2xl bg-surface-subtle" />
<div className="ml-auto h-20 w-4/5 animate-pulse rounded-2xl bg-brand/[0.07]" />
@@ -261,14 +261,14 @@ export function ImageCanvas() {
>
<span className="flex min-w-0 items-center gap-2">
<Sparkles className="h-4 w-4 shrink-0 text-brand" />
<span className="truncate text-sm font-semibold text-foreground"></span>
<span className="truncate text-sm font-semibold text-foreground"></span>
</span>
<span className="truncate text-xs text-muted-foreground">{cardModel.readinessMessage}</span>
</button>
</div>
)}
<main className="grid min-h-0 flex-1 lg:grid-cols-[minmax(420px,1.35fr)_minmax(360px,0.9fr)]">
<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} />
</div>
@@ -298,8 +298,8 @@ export function ImageCanvas() {
<Sheet open={creationCardOpen} onOpenChange={setCreationCardOpen}>
<SheetContent side="bottom" className="flex max-h-[88vh] flex-col overflow-hidden rounded-t-3xl p-0">
<SheetHeader className="sr-only">
<SheetTitle></SheetTitle>
<SheetDescription></SheetDescription>
<SheetTitle></SheetTitle>
<SheetDescription> AI </SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto bg-surface-subtle/35 px-4 py-4">
<div className="space-y-5 pb-[max(1rem,env(safe-area-inset-bottom))]">

View File

@@ -1,7 +1,6 @@
import type {
DesignAsset,
DesignCompilationIssue,
DesignDecisionPrompt,
DesignMedium,
DesignSpecificationValues,
DesignWorkspace,
@@ -141,11 +140,6 @@ export type YouthVideoSummary = {
storyBeats: string[];
};
export type YouthActiveChoice = Pick<
DesignDecisionPrompt,
'id' | 'kind' | 'title' | 'body' | 'options' | 'targetPaths'
>;
export type YouthCreationCardModel = {
phase: YouthCreationPhase;
summary: string | null;
@@ -157,7 +151,6 @@ export type YouthCreationCardModel = {
mustAvoid: string[];
references: YouthReferenceSummary[];
video: YouthVideoSummary | null;
activeChoice: YouthActiveChoice | null;
blockers: DesignCompilationIssue[];
blockerCount: number;
canRequestQuote: boolean;
@@ -344,8 +337,6 @@ export function projectYouthCreationCard(
): YouthCreationCardModel {
const values = workspace.form.specification.values;
const summary = projectSummary(values);
const activeChoice = workspace.form.decisionPrompts
.find((prompt) => prompt.options.length > 0) ?? null;
const blockers = (options.quoteBlockers ?? [])
.filter((issue) => issue.severity === 'blocker')
.map(projectYouthCompilationIssue);
@@ -361,11 +352,9 @@ export function projectYouthCreationCard(
} else if (offeredQuote) {
phase = 'confirming';
readinessMessage = '制作方案准备好了';
} else if (blockers.length > 0 || activeChoice) {
} else if (blockers.length > 0) {
phase = 'exploring';
readinessMessage = blockers.length > 0
? `还差 ${blockers.length} 个关键选择`
: '还有一个关键选择';
readinessMessage = `还差 ${blockers.length} 个关键选择`;
} else if (values.intent.media && hasCoreIdea(values)) {
phase = 'ready';
readinessMessage = '想法已经清楚,可以检查制作方案';
@@ -388,7 +377,6 @@ export function projectYouthCreationCard(
mustAvoid: values.constraints.must_avoid.filter(Boolean),
references: projectReferences(workspace),
video: projectVideo(values),
activeChoice,
blockers,
blockerCount: blockers.length,
canRequestQuote: !offeredQuote && (phase === 'ready' || phase === 'result'),

View File

@@ -407,7 +407,8 @@ test.describe('AI Design V2 workspace', () => {
))).toBe(true);
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByTestId('image-workspace-creation-pane')).toBeVisible();
await expect(page.getByRole('heading', { name: '我的创作' })).toBeVisible();
await expect(page.getByRole('heading', { name: '和 AI 聊聊你的想法' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'AI 听懂的想法' })).toBeVisible();
await expect(page.getByText('把便携咖啡机呈现为城市通勤中的精密工具')).toBeVisible();
await expect(page.getByText('Living Form')).toHaveCount(0);
await expect(page.getByText('目标与用途')).toHaveCount(0);
@@ -438,7 +439,7 @@ test.describe('AI Design V2 workspace', () => {
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('image-workspace-conversation')).toBeVisible();
await expect(page.getByTestId('image-workspace-creation-pane')).toBeHidden();
await page.getByRole('button', { name: /^我的创作/ }).click();
await page.getByRole('button', { name: /^当前想法与作品/ }).click();
await expect(page.getByTestId('youth-creation-card')).toBeVisible();
await page.keyboard.press('Escape');
@@ -449,7 +450,7 @@ test.describe('AI Design V2 workspace', () => {
await page.setViewportSize(desktopViewport ?? { width: 1280, height: 800 });
await expect(page.getByTestId('image-workspace-creation-pane')).toBeVisible();
await page.getByRole('button', { name: '精细调整' }).click();
await page.getByRole('button', { name: '手动调整(可选)' }).click();
await page.getByRole('button', { name: '电脑横屏' }).click();
await page.getByRole('button', { name: '完成调整' }).click();
await expect(page.getByTestId('youth-creation-card').getByText('电脑横屏')).toBeVisible();

View File

@@ -61,7 +61,7 @@ function prepareWorkspace(overrides = {}) {
}
function openMobileCreationCard() {
fireEvent.click(screen.getByRole('button', { name: /^我的创作/ }));
fireEvent.click(screen.getByRole('button', { name: /^当前想法与作品/ }));
}
describe('youth AI Design Canvas page', () => {
@@ -76,28 +76,62 @@ describe('youth AI Design Canvas page', () => {
useImageWorkspaceStore.getState().reset();
});
it('starts with conversation and a concise Creation Card instead of a professional field matrix', () => {
it('starts with a guided conversation and keeps the structured summary secondary', () => {
prepareWorkspace();
render(<ImageCanvas />);
expect(screen.getByTestId('image-workspace-conversation')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '一起做作品' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '和 AI 聊聊你的想法' })).toBeInTheDocument();
expect(screen.getByText('像和同学讲故事一样说就好。AI 会先听懂,再一次问一个最重要的问题。')).toBeInTheDocument();
openMobileCreationCard();
const card = screen.getByTestId('youth-creation-card');
expect(card).toBeInTheDocument();
expect(within(card).getByRole('heading', { name: '我的创作' })).toBeInTheDocument();
expect(within(card).getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
expect(screen.getByText('做一张便携咖啡机的小红书主视觉')).toBeInTheDocument();
expect(screen.queryByText('Living Form')).not.toBeInTheDocument();
expect(screen.queryByText('目标与用途')).not.toBeInTheDocument();
expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument();
});
it('invites a free description and does not turn a legacy decision prompt into a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({
decisionPrompts: [{
id: 'choice-1',
kind: 'question',
basedOnSpecificationRevisionId: 'specification-revision-3',
targetPaths: ['visual.art_direction'],
title: '画面想要什么感觉?',
body: '请从下面选一个。',
options: [
{ id: 'cartoon', label: '有趣的卡通', description: null },
{ id: 'cinema', label: '像电影一样', description: null },
],
}],
}),
turns: [],
});
prepareWorkspace(workspace);
render(<ImageCanvas />);
const conversation = screen.getByTestId('image-workspace-conversation');
expect(within(conversation).getByRole('heading', { name: '先把脑中的画面说出来' })).toBeInTheDocument();
expect(within(conversation).getByText(/想画什么、发生在哪里/)).toBeInTheDocument();
expect(within(conversation).queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
expect(within(conversation).queryByRole('button', { name: '有趣的卡通' })).not.toBeInTheDocument();
fireEvent.click(within(conversation).getByRole('button', { name: '我想做一张热闹的社团招新海报' }));
expect(within(conversation).getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toHaveValue(
'我想做一张热闹的社团招新海报',
);
});
it('sends high-impact fine adjustments through the existing typed field operations', () => {
const { actions } = prepareWorkspace();
render(<ImageCanvas />);
openMobileCreationCard();
fireEvent.click(screen.getByRole('button', { name: '精细调整' }));
fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' }));
fireEvent.click(screen.getByRole('button', { name: '电脑横屏' }));
expect(actions.applyFieldOperations).toHaveBeenCalledWith(
@@ -160,7 +194,9 @@ describe('youth AI Design Canvas page', () => {
fireEvent.click(screen.getByRole('button', { name: '发送消息' }));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith('AI 没有整理好这次想法,请再试一次');
expect(toastErrorMock).toHaveBeenCalledWith(
'AI 这次没听懂。你的内容还在输入框里,可以补充一个画面细节后再发送',
);
});
expect(toastErrorMock).not.toHaveBeenCalledWith('消息没有发出去,请再试一次');
});

View File

@@ -100,7 +100,8 @@ describe('YouthCreationCard', () => {
it('shows a plain creation summary and offers a youth-facing ready action', () => {
const { requestQuote } = renderCard();
expect(screen.getByRole('heading', { name: '我的创作' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
expect(screen.getByText('会跟着聊天自动更新,不需要逐项填写。')).toBeInTheDocument();
expect(screen.getByText('把便携咖啡机呈现为城市通勤中的精密工具')).toBeInTheDocument();
expect(screen.getByText('图片')).toBeInTheDocument();
expect(screen.getByText('竖版海报')).toBeInTheDocument();
@@ -113,7 +114,7 @@ describe('YouthCreationCard', () => {
expect(requestQuote).toHaveBeenCalledOnce();
});
it('shows only the first choice, keeps the server label, and resolves it through the store', () => {
it('does not surface legacy decision prompts as a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({
decisionPrompts: [{
@@ -141,18 +142,12 @@ describe('YouthCreationCard', () => {
});
const { resolveDecisionPrompt } = renderCard(workspace);
expect(screen.getByTestId('youth-creation-choice')).toBeInTheDocument();
expect(screen.getByText('画面想要什么感觉?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /有趣的卡通/ })).toHaveClass('min-h-11');
expect(screen.getByText('推荐')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /你帮我选/ })).toBeInTheDocument();
expect(screen.queryByTestId('youth-creation-choice')).not.toBeInTheDocument();
expect(screen.queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /有趣的卡通/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /你帮我选/ })).not.toBeInTheDocument();
expect(screen.queryByText('这个问题不该显示')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /你帮我选/ }));
expect(resolveDecisionPrompt).toHaveBeenCalledWith('choice-1', 'select', 'delegate');
fireEvent.click(screen.getByRole('button', { name: '先跳过这题' }));
expect(resolveDecisionPrompt).toHaveBeenCalledWith('choice-1', 'reject');
expect(resolveDecisionPrompt).not.toHaveBeenCalled();
});
it('shows known must-have, must-avoid, reference, video, and plain blocker details only', () => {
@@ -213,6 +208,7 @@ describe('YouthCreationCard', () => {
renderCard(workspace, { quoteBlockers: [blocker] });
fireEvent.click(screen.getByText('查看画面细节'));
expect(screen.getByText('一定要有')).toBeInTheDocument();
expect(screen.getByText('远处的地球')).toBeInTheDocument();
expect(screen.getByText('想保留')).toBeInTheDocument();
@@ -258,10 +254,10 @@ describe('YouthCreationCard', () => {
);
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('等你的第一个想法')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '先说一个想法' }));
fireEvent.click(screen.getByRole('button', { name: '开始描述' }));
expect(onFocusComposer).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '精细调整' }));
fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' }));
expect(onOpenFineTune).toHaveBeenCalledOnce();
});

View File

@@ -139,7 +139,7 @@ describe('youth Design projection', () => {
expect(model.readinessMessage).not.toContain('可以开始制作');
});
it('shows only the first consequential choice and concrete quote blockers', () => {
it('projects concrete quote blockers without exposing server decision prompts', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({
decisionPrompts: [{
@@ -174,8 +174,6 @@ describe('youth Design projection', () => {
const model = projectYouthCreationCard(workspace, { quoteBlockers: blockers });
expect(model.phase).toBe('exploring');
expect(model.activeChoice?.id).toBe('question-1');
expect(model.activeChoice?.options).toHaveLength(3);
expect(model.blockers).toEqual([{
...blockers[0],
message: '先选择作品形状。',