feat(agent): 创作助手 - 完成第一期交互与执行基线
This commit is contained in:
66
web/src/components/creation/creative-agent-cards.css
Normal file
66
web/src/components/creation/creative-agent-cards.css
Normal file
@@ -0,0 +1,66 @@
|
||||
.creative-agent-card { container-type: inline-size; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--background); color: var(--foreground); display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; overflow-wrap: anywhere; }
|
||||
.creative-agent-card h3, .creative-agent-card p { margin: 0; }
|
||||
.creative-agent-card-content { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
|
||||
.creative-agent-receipt { padding: var(--space-2) var(--space-3); color: var(--muted-foreground); font-size: var(--fs-caption); line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.creative-agent-receipt summary { cursor: pointer; }
|
||||
.creative-agent-receipt p { margin-block: var(--space-2); }
|
||||
.creative-agent-next { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2) var(--space-1); color: var(--muted-foreground); line-height: 1.7; }
|
||||
.creative-agent-next p { margin: 0; }
|
||||
.creative-agent-next .creative-agent-actions:empty { display: none; }
|
||||
.creative-agent-receipt summary:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
.creative-agent-archive-row { display: flex; align-items: center; gap: var(--space-1); min-width: 0; }
|
||||
.creative-agent-archive-row > button { flex-shrink: 0; }
|
||||
.creative-agent-archive-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.creative-agent-quote-details summary { cursor: pointer; color: var(--muted-foreground); font-size: var(--fs-caption); }
|
||||
.creative-agent-quote-details[open] > .creative-agent-card-content { margin-top: var(--space-3); }
|
||||
.creative-agent-card h3 { font-size: var(--fs-heading); font-weight: 600; line-height: 1.5; }
|
||||
.creative-agent-card-row { display: flex; gap: var(--space-2); align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.creative-agent-muted, .creative-agent-option small { color: var(--muted-foreground); }
|
||||
.creative-agent-overline { font-size: var(--fs-caption); line-height: 1.5; color: var(--muted-foreground); }
|
||||
.creative-agent-step-dots { display: flex; gap: var(--space-1); flex-wrap: wrap; max-width: 45%; }
|
||||
.creative-agent-step-dots i { width: var(--space-4); height: var(--space-1); border-radius: var(--control-radius); background: var(--border); }
|
||||
.creative-agent-step-dots i[data-current="true"] { background: var(--foreground); }
|
||||
.creative-agent-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); padding-top: var(--space-1); }
|
||||
.creative-agent-actions .ant-btn { max-width: 100%; height: auto; min-height: var(--space-8); white-space: normal; }
|
||||
.creative-agent-options { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.creative-agent-option { display: flex; align-items: flex-start; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--control-radius); cursor: pointer; }
|
||||
.creative-agent-option[data-selected="true"] { background: var(--control-selected-bg); border-color: var(--control-selected-border); color: var(--control-selected-fg); }
|
||||
.creative-agent-option:not([data-disabled="true"]):hover { border-color: var(--control-selected-border); }
|
||||
.creative-agent-option[data-disabled="true"] { cursor: default; }
|
||||
.creative-agent-option > span:last-child { min-width: 0; line-height: 1.5; }
|
||||
.creative-agent-option strong { font-weight: 500; }
|
||||
.creative-agent-option small { display: block; margin-top: var(--space-1); }
|
||||
.creative-agent-custom-answer { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.creative-agent-custom-answer > span { color: var(--muted-foreground); font-size: var(--fs-caption); }
|
||||
.creative-agent-proposal-summary { line-height: 1.8; }
|
||||
.creative-agent-deliverables { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); margin: 0; padding: 0; list-style: none; }
|
||||
.creative-agent-deliverables li { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
|
||||
.creative-agent-deliverables span { color: var(--muted-foreground); font-size: var(--fs-caption); }
|
||||
.creative-agent-deliverables strong { font-weight: 500; line-height: 1.6; }
|
||||
.creative-agent-detail-toggle { align-self: flex-start; }
|
||||
.creative-agent-scope-note { padding: var(--space-3); background: var(--surface-card); border-radius: var(--control-radius); color: var(--muted-foreground); line-height: 1.7; font-size: var(--fs-caption); }
|
||||
.creative-agent-detail { max-height: 50vh; overflow: auto; overscroll-behavior: contain; }
|
||||
.creative-agent-plan { width: min(360px, calc(100vw - 48px)); max-height: min(360px, 45vh); overflow: auto; overscroll-behavior: contain; color: var(--foreground); }
|
||||
.creative-agent-plan ol { margin: var(--space-2) 0 0; padding-inline-start: var(--space-6); }
|
||||
.creative-agent-plan li { padding-block: var(--space-2); }
|
||||
.creative-agent-plan li + li { border-top: 1px solid var(--border); }
|
||||
.creative-agent-plan li p { color: var(--muted-foreground); margin: var(--space-1) 0 0; line-height: 1.6; }
|
||||
.creative-agent-plan li[data-status="completed"]::marker { color: var(--muted-foreground); }
|
||||
.creative-agent-plan-trigger { display: flex; width: 100%; gap: var(--space-2); white-space: normal; height: auto; min-height: var(--space-8); text-align: start; padding: var(--space-2) var(--space-3); background: var(--surface-card); border-color: var(--border); }
|
||||
.creative-agent-plan-count { flex-shrink: 0; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
||||
.creative-agent-plan-current { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.creative-agent-plan-chevron { color: var(--muted-foreground); flex-shrink: 0; }
|
||||
.creative-agent-quote-items { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.creative-agent-quote-items li { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); }
|
||||
.creative-agent-quote-items li div { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
|
||||
.creative-agent-quote-items li strong, .creative-agent-quote-items li b { font-weight: 500; }
|
||||
.creative-agent-quote-items li b { flex-shrink: 0; }
|
||||
.creative-agent-quote-items li span { color: var(--muted-foreground); font-size: var(--fs-caption); line-height: 1.6; }
|
||||
.creative-agent-quote-total { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); border-top: 1px solid var(--border); padding-top: var(--space-3); }
|
||||
.creative-agent-quote-total > span { color: var(--muted-foreground); flex-shrink: 0; }
|
||||
.creative-agent-quote-total strong { text-align: end; font-size: var(--fs-heading-lg); font-variant-numeric: tabular-nums; }
|
||||
.creative-agent-card :focus-visible, .creative-agent-plan :focus-visible, .creative-agent-plan-trigger:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
|
||||
@container (max-width: 280px) {
|
||||
.creative-agent-deliverables { grid-template-columns: minmax(0, 1fr); }
|
||||
.creative-agent-quote-total { flex-wrap: wrap; }
|
||||
}
|
||||
113
web/src/components/creation/creative-agent-cards.tsx
Normal file
113
web/src/components/creation/creative-agent-cards.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { Alert, Button, Checkbox, Dropdown, Input, Popover, Radio, Tag } from "antd";
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { validateCreativeAnswers, type CreativeAnswers, type CreativeAssetOption, type CreativePlan, type CreativeProposal, type CreativeQuestionRequest, type CreativeQuote } from "@/lib/creation/creative-agent-contract";
|
||||
import "./creative-agent-cards.css";
|
||||
|
||||
export type CreativeQuestionCardProps = {
|
||||
request: CreativeQuestionRequest; answers: CreativeAnswers; onAnswersChange: (answers: CreativeAnswers) => void;
|
||||
onSubmit: (answers: CreativeAnswers) => void | Promise<void>; onModify?: () => void; assets: CreativeAssetOption[]; busy?: boolean; disabled?: boolean; error?: string;
|
||||
};
|
||||
export function CreativeQuestionCard({ request, answers, onAnswersChange, onSubmit, onModify, assets, busy, disabled, error }: CreativeQuestionCardProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailId = useId();
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
const currentPage = Math.min(page, Math.max(request.questions.length - 1, 0));
|
||||
const question = request.questions[currentPage];
|
||||
if (!question) return null;
|
||||
const answer = answers[question.id] ?? { selected: [], custom: "" };
|
||||
const locked = request.status !== "pending" || busy || disabled || submitting;
|
||||
const options = question.type === "asset" ? assets : question.options;
|
||||
const archived = request.status !== "pending";
|
||||
const summary = request.questions.map((item) => {
|
||||
const value = answers[item.id];
|
||||
const choices = item.type === "asset" ? assets : item.options;
|
||||
return value ? [...value.selected.map((id) => choices.find((option) => option.id === id)?.label ?? "素材已不可用"), value.custom].filter(Boolean).join("、") : "";
|
||||
}).filter(Boolean).join(" · ") || `${request.questions.length} 项创作信息`;
|
||||
const change = (patch: Partial<typeof answer>) => { setLocalError(""); onAnswersChange({ ...answers, [question.id]: { ...answer, ...patch } }); };
|
||||
const move = (index: number) => { setPage(index); requestAnimationFrame(() => headingRef.current?.focus()); };
|
||||
const submit = async () => {
|
||||
const invalid = validateCreativeAnswers(request, answers, assets);
|
||||
if (invalid) { setLocalError(invalid.message); move(request.questions.findIndex((item) => item.id === invalid.questionId)); return; }
|
||||
setSubmitting(true); setLocalError("");
|
||||
try { await onSubmit(answers); } catch (cause) { setLocalError(cause instanceof Error ? cause.message : "提交失败,请重试"); } finally { setSubmitting(false); }
|
||||
};
|
||||
return <section className="creative-agent-card creative-agent-question-card" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="需求问答">
|
||||
{archived && <div className="creative-agent-archive-row"><span className="creative-agent-archive-summary" title={summary}>{request.status === "submitted" ? "已回答" : "已更新"} · {summary}</span><Button type="text" size="small" aria-expanded={expanded} aria-controls={detailId} onClick={() => setExpanded(!expanded)}>{expanded ? "收起" : "查看"}</Button>{onModify && <Button type="text" size="small" disabled={busy} onClick={onModify}>修改</Button>}</div>}
|
||||
{(!archived || expanded) && <div id={detailId} className="creative-agent-card-content">
|
||||
<div className="creative-agent-card-row creative-agent-overline"><span>补充创作信息 · {currentPage + 1}/{request.questions.length}</span><span className="creative-agent-step-dots" aria-hidden="true">{request.questions.map((item, index) => <i key={item.id} data-current={index === currentPage} />)}</span></div>
|
||||
{request.status === "submitted" ? <Tag>已提交</Tag> : request.status === "superseded" ? <Tag>已更新,请使用新问题</Tag> : null}
|
||||
<h3 ref={headingRef} tabIndex={-1}>{question.title}</h3>
|
||||
{question.description && <p className="creative-agent-muted">{question.description}</p>}
|
||||
<div className="creative-agent-options">
|
||||
{options.map((option) => <label className="creative-agent-option" key={option.id} data-selected={answer.selected.includes(option.id)} data-disabled={Boolean(locked)}>
|
||||
{question.type === "single" ? <Radio checked={answer.selected.includes(option.id)} disabled={locked} onChange={() => change({ selected: [option.id], custom: "" })} /> : <Checkbox checked={answer.selected.includes(option.id)} disabled={locked} onChange={(event) => change({ selected: event.target.checked ? [...answer.selected, option.id] : answer.selected.filter((id) => id !== option.id) })} />}
|
||||
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
|
||||
</label>)}
|
||||
</div>
|
||||
{question.type === "asset" && !assets.length && <p className="creative-agent-muted">暂无可用素材,请先添加素材或补充说明。</p>}
|
||||
{question.allowCustom && <label className="creative-agent-custom-answer"><span>{question.type === "text" ? "你的想法" : "也可以自定义或补充"}</span><Input.TextArea aria-label={question.type === "text" ? question.title : "自定义回答"} value={answer.custom} disabled={locked} autoSize={{ minRows: 2, maxRows: 6 }} onChange={(event) => change({ custom: event.target.value, ...(question.type === "single" ? { selected: [] } : {}) })} placeholder="补充你的想法" /></label>}
|
||||
{(error || localError) && <Alert type="error" title={error || localError} showIcon />}
|
||||
<div className="creative-agent-actions">
|
||||
<Button disabled={currentPage === 0} onClick={() => move(currentPage - 1)}>上一题</Button>
|
||||
{currentPage < request.questions.length - 1 ? <Button type={archived ? "default" : "primary"} onClick={() => move(currentPage + 1)}>下一题</Button> : !archived && <Button type="primary" disabled={locked} loading={busy || submitting} onClick={() => void submit()}>提交回答</Button>}
|
||||
{!question.required && !locked && <Button type="text" onClick={() => { change({ selected: [], custom: "" }); if (currentPage < request.questions.length - 1) move(currentPage + 1); }}>跳过</Button>}
|
||||
</div>
|
||||
{request.status === "submitted" && <p className="creative-agent-muted">{[...answer.selected.map((id) => options.find((option) => option.id === id)?.label ?? "素材已不可用"), answer.custom].filter(Boolean).join("、") || "已跳过"}</p>}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export type CreativeProposalCardProps = { proposal: CreativeProposal; onApprove: () => void; onModify: () => void; onRedirect: () => void; busy?: boolean; disabled?: boolean; archived?: boolean; modifiable?: boolean };
|
||||
export function CreativeProposalCard({ proposal, onApprove, onModify, onRedirect, busy, disabled, archived, modifiable }: CreativeProposalCardProps) {
|
||||
const [detailExpanded, setDetailExpanded] = useState(false);
|
||||
const [archivedExpanded, setArchivedExpanded] = useState(false);
|
||||
const expanded = archived ? archivedExpanded : detailExpanded;
|
||||
const setExpanded = archived ? setArchivedExpanded : setDetailExpanded;
|
||||
const detailId = useId();
|
||||
return <section className="creative-agent-card creative-agent-proposal-card" data-canvas-no-zoom data-canvas-wheel-scroll aria-label="创意方案">
|
||||
{archived ? <div className="creative-agent-archive-row"><strong className="creative-agent-archive-summary" title={proposal.title}>{proposal.title} · v{proposal.version}</strong><Button type="text" size="small" aria-expanded={expanded} aria-controls={detailId} onClick={() => setExpanded(!expanded)}>{expanded ? "收起方案" : "查看方案"}</Button>{modifiable && <Button type="text" size="small" disabled={disabled || busy} onClick={onModify}>修改</Button>}</div> : <div className="creative-agent-card-row creative-agent-overline"><span>创意方案</span><span>版本 {proposal.version}</span></div>}
|
||||
{(!archived || expanded) && <>
|
||||
<h3>{proposal.title}</h3>
|
||||
<p className="creative-agent-proposal-summary">{proposal.summary}</p>
|
||||
{proposal.deliverables.length > 0 && <ul className="creative-agent-deliverables">{proposal.deliverables.map((item, index) => <li key={index}><span>产物 {index + 1}</span><strong>{item}</strong></li>)}</ul>}
|
||||
{!archived && <Button className="creative-agent-detail-toggle" aria-expanded={expanded} aria-controls={detailId} onClick={() => setExpanded(!expanded)}>{expanded ? "收起完整方案" : "查看完整方案"}</Button>}
|
||||
{expanded && <div id={detailId} className="creative-agent-detail"><AIMessageMarkdown>{proposal.markdown}</AIMessageMarkdown></div>}
|
||||
{!archived && <><div className="creative-agent-scope-note">确认后创建本阶段节点并准备参数;图片、视频等生成费用另行确认。</div>
|
||||
<div className="creative-agent-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认方案</Button><Button disabled={disabled || busy} onClick={onModify}>修改</Button><Dropdown menu={{ items: [{ key: "redirect", label: "换个方向" }], onClick: onRedirect }} trigger={["click"]} disabled={disabled || busy}><Button disabled={disabled || busy}>更多</Button></Dropdown></div></>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
const stepLabels = { pending: "待开始", running: "处理中", waiting: "等待你确认", completed: "已完成", failed: "失败", cancelled: "已取消" };
|
||||
export function CreativePlanBar({ plan, onLocateNode }: { plan?: CreativePlan | null; onLocateNode?: (nodeId: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const panelId = useId();
|
||||
if (!plan?.steps.length) return null;
|
||||
const completed = plan.steps.filter((step) => step.status === "completed").length;
|
||||
const running = plan.steps.filter((step) => step.status === "running").length;
|
||||
const current = plan.steps.find((step) => step.status === "running" || step.status === "waiting" || step.status === "failed");
|
||||
const close = () => { setOpen(false); requestAnimationFrame(() => trigger.current?.focus()); };
|
||||
return <Popover open={open} onOpenChange={(next) => { setOpen(next); if (!next) trigger.current?.focus(); }} afterOpenChange={(next) => { if (next) panel.current?.focus(); }} placement="top" trigger="click" content={<div id={panelId} ref={panel} tabIndex={-1} className="creative-agent-plan" data-canvas-no-zoom data-canvas-wheel-scroll onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } }}>
|
||||
<div className="creative-agent-card-row"><strong>制作计划</strong><Button size="small" onClick={close}>收起</Button></div>
|
||||
<ol>{plan.steps.map((step) => <li key={step.id} data-status={step.status}><div className="creative-agent-card-row"><strong>{step.title}</strong><Tag>{stepLabels[step.status]}</Tag></div>{step.detail && <p>{step.detail}</p>}{onLocateNode && step.nodeIds?.map((id, index) => <Button key={id} type="link" onClick={() => onLocateNode(id)}>定位节点 {index + 1}</Button>)}</li>)}</ol>
|
||||
</div>}><Button ref={trigger} className="creative-agent-plan-trigger" aria-expanded={open} aria-controls={panelId} onKeyDown={(event) => { if (event.key === "Escape") close(); }}><span className="creative-agent-plan-count">{completed}/{plan.steps.length}</span><span className="creative-agent-plan-current">{running > 1 ? `运行中 ${running} 项` : current ? current.title : completed === plan.steps.length ? "计划已完成" : "查看制作计划"}</span><span className="creative-agent-plan-chevron" aria-hidden="true">{open ? "⌄" : "⌃"}</span></Button></Popover>;
|
||||
}
|
||||
|
||||
export function CreativeQuoteCard({ quote, onApprove, onRefresh, busy, disabled }: { quote: CreativeQuote; onApprove: () => void; onRefresh?: () => void; busy?: boolean; disabled?: boolean }) {
|
||||
const quantity = quote.items.reduce((total, item) => total + item.quantity, 0);
|
||||
return <section className="creative-agent-card creative-agent-quote-card" aria-label="费用确认" data-canvas-no-zoom data-canvas-wheel-scroll>
|
||||
<div className="creative-agent-overline">本批生成 · 费用确认</div>
|
||||
<h3>{quote.title}</h3><div className="creative-agent-quote-total"><span>本批 {quantity} 项生成</span><strong>{quote.amountLabel}</strong></div>
|
||||
<details className="creative-agent-quote-details"><summary>查看费用明细</summary><div className="creative-agent-card-content"><ul className="creative-agent-quote-items">{quote.items.map((item) => <li key={item.id}><div><strong>{item.label}</strong><span>{item.model} · {item.specification}</span></div><b>× {item.quantity}</b></li>)}</ul><p className="creative-agent-muted">{quote.basis}</p>
|
||||
{quote.expiresAt && <p>有效期至 {quote.expiresAt}</p>}{quote.approvedQuantity !== undefined && <p>已批准 {quote.approvedQuantity} 项</p>}
|
||||
{onRefresh && <Button className="creative-agent-detail-toggle" type="text" disabled={disabled || busy} onClick={onRefresh}>更新报价</Button>}</div></details>
|
||||
{quote.externalBilling && <p>由外部渠道计费,平台无法封顶第三方账单。</p>}
|
||||
<div className="creative-agent-actions"><Button type="primary" loading={busy} disabled={disabled || busy} onClick={onApprove}>确认生成 · {quote.amountLabel}</Button></div>
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user