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[],
|
clarifications: ClarificationPayload[],
|
||||||
overrideByKey?: Record<string, IntentClarificationSelection>,
|
overrideByKey?: Record<string, IntentClarificationSelection>,
|
||||||
) => void;
|
) => void;
|
||||||
|
/**
|
||||||
|
* 用户在「当前意图摘要」卡里手动编辑后保存:直接覆盖 `intentReady`。下游(Step 2 seed
|
||||||
|
* `buildInitialIntent`、左右侧栏摘要卡、step2Locked 判定)都读 `intentReady`,改这里即全局生效。
|
||||||
|
* 持久化由调用方(页面)在更新后触发草稿保存。
|
||||||
|
*/
|
||||||
|
updateIntentReady: (next: IntentSummary) => void;
|
||||||
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
/** Abort 当前 intent 流。气泡尾部追加「(已暂停)」并 toast。 */
|
||||||
stopIntentStream: () => void;
|
stopIntentStream: () => void;
|
||||||
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
/** 从草稿加载:批量 setState + 标记 init 已完成,跳过自动 runStep1Init。 */
|
||||||
@@ -568,6 +574,12 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
setInteractiveMessages((prev) => [...prev, msg]);
|
setInteractiveMessages((prev) => [...prev, msg]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updateIntentReady = useCallback((next: IntentSummary) => {
|
||||||
|
// 用户手动改完意图 → 覆盖为权威值,并清掉「待澄清」态(已确认意图,无需再答问)。
|
||||||
|
setIntentReady(next);
|
||||||
|
setPendingIntentClarificationMsgId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
intentThreadId,
|
intentThreadId,
|
||||||
intentReady,
|
intentReady,
|
||||||
@@ -582,6 +594,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
runStep1Init,
|
runStep1Init,
|
||||||
sendIntentMessage,
|
sendIntentMessage,
|
||||||
submitIntentClarifications,
|
submitIntentClarifications,
|
||||||
|
updateIntentReady,
|
||||||
stopIntentStream,
|
stopIntentStream,
|
||||||
hydrateFromDraft,
|
hydrateFromDraft,
|
||||||
appendLocalMessage,
|
appendLocalMessage,
|
||||||
|
|||||||
@@ -41,12 +41,14 @@ import {
|
|||||||
ListOrdered,
|
ListOrdered,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
@@ -86,6 +88,7 @@ import {
|
|||||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
|
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
|
||||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
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";
|
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||||
|
|
||||||
export default function RoundtablePlanningPage() {
|
export default function RoundtablePlanningPage() {
|
||||||
@@ -469,6 +472,26 @@ export default function RoundtablePlanningPage() {
|
|||||||
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
|
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 再跳转,挂载时消费;
|
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
|
||||||
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
|
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
|
||||||
@@ -1006,6 +1029,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
intentReady={step1.intentReady}
|
intentReady={step1.intentReady}
|
||||||
timelineEntries={step1.timelineEntries}
|
timelineEntries={step1.timelineEntries}
|
||||||
triggerToast={triggerToast}
|
triggerToast={triggerToast}
|
||||||
|
onSaveIntent={handleSaveIntent}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 {
|
interface IntentSummaryCardProps {
|
||||||
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -1673,6 +1839,11 @@ interface IntentSummaryCardProps {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 提供则显示「编辑」按钮,允许用户手动修改识别出的意图(目标 / 约束 / 假设)并保存。
|
||||||
|
* 保存后回调上抛新意图,由页面覆盖 `intentReady` 并持久化草稿。不传 = 只读。
|
||||||
|
*/
|
||||||
|
onSave?: (next: IntentSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IntentSummaryCard({
|
function IntentSummaryCard({
|
||||||
@@ -1680,9 +1851,25 @@ function IntentSummaryCard({
|
|||||||
title = "当前意图摘要",
|
title = "当前意图摘要",
|
||||||
collapsible = false,
|
collapsible = false,
|
||||||
sidebarToggle,
|
sidebarToggle,
|
||||||
|
onSave,
|
||||||
}: IntentSummaryCardProps) {
|
}: IntentSummaryCardProps) {
|
||||||
const [expanded, setExpanded] = useState(!collapsible);
|
const [expanded, setExpanded] = useState(!collapsible);
|
||||||
const showFullDetail = !collapsible || expanded;
|
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 (
|
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)]">
|
<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" />
|
<IntentSectionIcon className="w-4 h-4" />
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
{sidebarToggle && (
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
{onSave && intentReady && (
|
||||||
type="button"
|
<button
|
||||||
onClick={sidebarToggle.onToggle}
|
type="button"
|
||||||
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
onClick={() => setEditing(true)}
|
||||||
aria-expanded={sidebarToggle.expanded}
|
title="编辑意图"
|
||||||
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"
|
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 ? (
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
<PanelLeftClose className="h-4 w-4" />
|
</button>
|
||||||
) : (
|
)}
|
||||||
<PanelLeftOpen className="h-4 w-4" />
|
{sidebarToggle && (
|
||||||
)}
|
<button
|
||||||
</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>
|
||||||
|
|
||||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
<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"];
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
|
timelineEntries: ReturnType<typeof useStep1Intent>["timelineEntries"];
|
||||||
triggerToast: (msg: string) => void;
|
triggerToast: (msg: string) => void;
|
||||||
|
/** 用户编辑并保存意图的回调(透传给 IntentSummaryCard)。 */
|
||||||
|
onSaveIntent: (next: IntentSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RightSidebar({
|
function RightSidebar({
|
||||||
@@ -1963,6 +2164,7 @@ function RightSidebar({
|
|||||||
intentReady,
|
intentReady,
|
||||||
timelineEntries,
|
timelineEntries,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
|
onSaveIntent,
|
||||||
}: RightSidebarProps) {
|
}: RightSidebarProps) {
|
||||||
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||||
if (currentStep !== 1) return null;
|
if (currentStep !== 1) return null;
|
||||||
@@ -1978,7 +2180,7 @@ function RightSidebar({
|
|||||||
modelsLoading={modelsLoading}
|
modelsLoading={modelsLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IntentSummaryCard intentReady={intentReady} />
|
<IntentSummaryCard intentReady={intentReady} onSave={onSaveIntent} />
|
||||||
|
|
||||||
{/* 澄清记录 Timeline */}
|
{/* 澄清记录 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]">
|
<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