feat(roundtable): 第一步识别出的意图支持手动编辑并保存

「当前意图摘要」卡新增编辑态:核心目标(必填)、约束、关键假设(可增删列表),
保存后由 useStep1Intent.updateIntentReady 覆盖权威 intentReady,下游(Step 2
种子消息 buildInitialIntent、侧栏摘要卡、step2Locked)随之生效,并落草稿持久化。

持久化用「计数 + effect」触发 handleManualSave,避免 enqueueSave 闭包在 setState
flush 前读到旧意图的竞态(与 step2/step3 计数触发器同构)。聊天气泡内的绿色意图卡
保持为历史记录,右侧「当前意图摘要」为可编辑的当前值。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-09 15:58:24 +08:00
1 parent c230f7a6c6
commit cc5ada1f3d
2 files changed
+231 -16

No files matched your search

@@ -123,6 +123,12 @@ export interface UseStep1IntentReturn {
clarifications: ClarificationPayload[],
overrideByKey?: Record<string, IntentClarificationSelection>,
) => void;
/**
* 用户在「当前意图摘要」卡里手动编辑后保存:直接覆盖 `intentReady`。下游(Step 2 seed
* `buildInitialIntent`、左右侧栏摘要卡、step2Locked 判定)都读 `intentReady`,改这里即全局生效。
* 持久化由调用方(页面)在更新后触发草稿保存。
*/
updateIntentReady: (next: IntentSummary) => void;
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
stopIntentStream: () => void;
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
@@ -568,6 +574,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setInteractiveMessages((prev) => [...prev, msg]);
}, []);
const updateIntentReady = useCallback((next: IntentSummary) => {
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
setIntentReady(next);
setPendingIntentClarificationMsgId(null);
}, []);
return {
intentThreadId,
intentReady,
@@ -582,6 +594,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
runStep1Init,
sendIntentMessage,
submitIntentClarifications,
updateIntentReady,
stopIntentStream,
hydrateFromDraft,
appendLocalMessage,
@@ -41,12 +41,14 @@ import {
ListOrdered,
Loader2,
Pencil,
Plus,
Save,
Settings,
Sparkles,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
@@ -86,6 +88,7 @@ import {
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
export default function RoundtablePlanningPage() {
@@ -469,6 +472,26 @@ export default function RoundtablePlanningPage() {
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
});
// ─── 用户手动编辑「当前意图摘要」并保存 ───────────────────────
// 先 updateIntentReady 覆盖意图,再 bump 计数触发一次草稿保存。**必须用计数 + effect**
// 而非直接调 handleManualSave:enqueueSave 闭包捕获的 getStep1Snapshot 读的是本次渲染的
// step1.intentReady,同步调用会在 setState flush 前存进旧意图(踩坑);放到 effect 里,等
// 重渲染后再保存,getStep1Snapshot 才读到新值(与 step2/step3 的计数触发器同构)。
const [intentEditTick, setIntentEditTick] = useState(0);
const handleSaveIntent = useCallback(
(next: IntentSummary) => {
step1.updateIntentReady(next);
setIntentEditTick((t) => t + 1);
},
[step1],
);
useEffect(() => {
if (intentEditTick === 0) return;
drafts.handleManualSave();
// 仅在 tick 递增时保存;handleManualSave 取最新闭包(effect 跑在重渲染之后)。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intentEditTick]);
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
@@ -1006,6 +1029,7 @@ export default function RoundtablePlanningPage() {
intentReady={step1.intentReady}
timelineEntries={step1.timelineEntries}
triggerToast={triggerToast}
onSaveIntent={handleSaveIntent}
/>
</main>
</div>
@@ -1663,6 +1687,148 @@ function ModelSelectorCard({
);
}
/**
* 「当前意图摘要」编辑态:目标(单行/多行文本)、约束、关键假设(均为可增删的列表)。
* 受控本地草稿,保存时过滤空行、要求目标非空,再上抛 {@link IntentSummary}。
*/
function IntentSummaryEditor({
initial,
title,
onSave,
onCancel,
}: {
initial: IntentSummary;
title: string;
onSave: (next: IntentSummary) => void;
onCancel: () => void;
}) {
const [objective, setObjective] = useState(initial.objective ?? "");
const [constraints, setConstraints] = useState<string[]>(
initial.constraints?.length ? [...initial.constraints] : [""],
);
const [assumptions, setAssumptions] = useState<string[]>(
initial.assumptions?.length ? [...initial.assumptions] : [],
);
const objectiveInvalid = objective.trim().length === 0;
const updateRow = (
setter: React.Dispatch<React.SetStateAction<string[]>>,
idx: number,
value: string,
) => setter((prev) => prev.map((v, i) => (i === idx ? value : v)));
const removeRow = (setter: React.Dispatch<React.SetStateAction<string[]>>, idx: number) =>
setter((prev) => prev.filter((_, i) => i !== idx));
const addRow = (setter: React.Dispatch<React.SetStateAction<string[]>>) =>
setter((prev) => [...prev, ""]);
const handleSave = () => {
if (objectiveInvalid) return;
onSave({
objective: objective.trim(),
constraints: constraints.map((c) => c.trim()).filter(Boolean),
assumptions: assumptions.map((a) => a.trim()).filter(Boolean),
});
};
const rowList = (
label: string,
rows: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
placeholder: string,
) => (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
<div className="space-y-1.5">
{rows.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<input
value={row}
onChange={(e) => updateRow(setter, i, e.target.value)}
placeholder={placeholder}
maxLength={200}
className="min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[11.5px] text-slate-700 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
/>
<button
type="button"
onClick={() => removeRow(setter, i)}
title="删除该项"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-rose-50 hover:text-rose-500 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => addRow(setter)}
className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-500 hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
添加{label}
</button>
</div>
</div>
);
return (
<div className="bg-white border border-primary/30 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-3">
<div className="flex items-center gap-2 min-w-0">
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">
编辑{title}
</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={handleSave}
disabled={objectiveInvalid}
title="保存"
className="flex h-7 items-center gap-1 rounded-lg bg-primary px-2.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed transition-colors"
>
<Check className="h-3.5 w-3.5" />
保存
</button>
<button
type="button"
onClick={onCancel}
title="取消"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-700 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<textarea
value={objective}
onChange={(e) => setObjective(e.target.value)}
rows={3}
maxLength={500}
placeholder="一句话说明本次要达成什么"
className={cn(
"w-full resize-y rounded-lg border bg-white px-2.5 py-1.5 text-[11.5px] leading-relaxed text-slate-700 focus:outline-none focus:ring-1",
objectiveInvalid
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/50"
: "border-slate-200 focus:border-primary focus:ring-[var(--brand-200)]/50",
)}
/>
{objectiveInvalid && (
<p className="text-[10px] text-rose-500">核心目标不能为空</p>
)}
</div>
{rowList("约束", constraints, setConstraints, "如:预算 / 时间 / 合规 / 范围")}
{rowList("假设", assumptions, setAssumptions, "智能体为你做的假设(可选)")}
</div>
</div>
);
}
interface IntentSummaryCardProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
title?: string;
@@ -1673,6 +1839,11 @@ interface IntentSummaryCardProps {
expanded: boolean;
onToggle: () => void;
};
/**
* 提供则显示「编辑」按钮,允许用户手动修改识别出的意图(目标 / 约束 / 假设)并保存。
* 保存后回调上抛新意图,由页面覆盖 `intentReady` 并持久化草稿。不传 = 只读。
*/
onSave?: (next: IntentSummary) => void;
}
function IntentSummaryCard({
@@ -1680,9 +1851,25 @@ function IntentSummaryCard({
title = "当前意图摘要",
collapsible = false,
sidebarToggle,
onSave,
}: IntentSummaryCardProps) {
const [expanded, setExpanded] = useState(!collapsible);
const showFullDetail = !collapsible || expanded;
const [editing, setEditing] = useState(false);
if (editing && intentReady) {
return (
<IntentSummaryEditor
initial={intentReady}
title={title}
onCancel={() => setEditing(false)}
onSave={(next) => {
onSave?.(next);
setEditing(false);
}}
/>
);
}
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
@@ -1691,21 +1878,33 @@ function IntentSummaryCard({
<IntentSectionIcon className="w-4 h-4" />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
</div>
{sidebarToggle && (
<button
type="button"
onClick={sidebarToggle.onToggle}
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
aria-expanded={sidebarToggle.expanded}
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
{sidebarToggle.expanded ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</button>
)}
<div className="flex items-center gap-1 shrink-0">
{onSave && intentReady && (
<button
type="button"
onClick={() => setEditing(true)}
title="编辑意图"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{sidebarToggle && (
<button
type="button"
onClick={sidebarToggle.onToggle}
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
aria-expanded={sidebarToggle.expanded}
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
>
{sidebarToggle.expanded ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
@@ -1952,6 +2151,8 @@ interface RightSidebarProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
triggerToast: (msg: string) => void;
/** 用户编辑并保存意图的回调(透传给 IntentSummaryCard)。 */
onSaveIntent: (next: IntentSummary) => void;
}
function RightSidebar({
@@ -1963,6 +2164,7 @@ function RightSidebar({
intentReady,
timelineEntries,
triggerToast,
onSaveIntent,
}: RightSidebarProps) {
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
if (currentStep !== 1) return null;
@@ -1978,7 +2180,7 @@ function RightSidebar({
modelsLoading={modelsLoading}
/>
<IntentSummaryCard intentReady={intentReady} />
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
{/* 澄清记录 Timeline */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">