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:
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]">
|
||||
|
||||
Reference in new issue
Block a user