圆桌草稿持久化:localStorage 迁至后端 MySQL

新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-05-31 00:23:25 +08:00
1 parent 24e92c437d
commit 838cae0d2d
16 files changed
+1410 -480

No files matched your search

@@ -0,0 +1,211 @@
/**
* Backend-persisted draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted server-side (MySQL) bound to the logged-in user, so it
* follows the account across devices/sessions — replacing the old browser-only
* `localStorage` storage (see git history of `utils/drafts.ts`).
*
* The list endpoint returns lightweight metadata only; the full step1/step2
* snapshots are fetched on demand via {@link getDraft}. This keeps the history
* dropdown cheap and guarantees a load always reads the freshest server copy
* (the old localStorage path loaded from a possibly-stale in-memory array).
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
* because their inline types in the hooks are wide; the hook casts on hydrate.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
seatStubMessages?: Record<string, unknown[]>;
}
/** Lightweight metadata row returned by the list endpoint. */
export interface DraftMeta {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
}
/** Full record including the step snapshots. */
export interface DraftRecord extends DraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
}
/** Fields accepted when creating a draft. */
export interface DraftCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
title: string;
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
/** Partial update — send only what changed. Omitting `title` preserves a rename. */
export interface DraftUpdatePayload {
title?: string;
furthestStep?: 1 | 2 | 3;
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (furthest_step / created_at / updated_at); the rest
// of the frontend reads camelCase. Map at this boundary so callers stay clean.
interface ServerDraftMeta {
id: string;
title: string;
furthest_step: number;
created_at: string | null;
updated_at: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
step1: DraftStep1Snapshot | null;
step2: DraftStep2Snapshot | null;
}
function toMeta(s: ServerDraftMeta): DraftMeta {
const step = Math.min(3, Math.max(1, s.furthest_step || 1)) as 1 | 2 | 3;
return {
id: s.id,
title: s.title ?? "",
furthestStep: step,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
};
}
function toRecord(s: ServerDraftFull): DraftRecord {
return { ...toMeta(s), step1: s.step1 ?? null, step2: s.step2 ?? null };
}
function fromCreate(p: DraftCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
title: p.title,
furthest_step: p.furthestStep,
step1: p.step1,
step2: p.step2,
};
}
function fromUpdate(p: DraftUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.furthestStep !== undefined) out.furthest_step = p.furthestStep;
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's drafts, newest first (lightweight metadata only). */
export async function listDrafts(): Promise<DraftMeta[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts");
return (data.drafts ?? []).map(toMeta);
}
/** Fetch the full draft (incl. step1/step2). Returns null on 404. */
export async function getDraft(id: string): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "get draft");
return toRecord(data);
}
export async function createDraft(payload: DraftCreatePayload): Promise<DraftRecord> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerDraftFull>(res, "create draft");
return toRecord(data);
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerDraftFull>(res, "update draft");
return toRecord(data);
}
export async function deleteDraft(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete draft failed (${res.status}): ${text}`);
}
}
/**
* Derive a short task-summary name from whatever the user supplied so far.
* Preference: intent objective → first user message → timestamp. Trimmed to
* ~40 chars so dropdown rows stay scannable. (Ported from the old utils/drafts.)
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
@@ -198,6 +198,104 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
return { text, id };
}
/**
* One persisted recommendation run, bound to a draft/session. Returned by
* {@link listRecommendHistory}; lets the dialog show "last recommendation for
* this session" before deciding whether to re-analyze.
*/
export interface RecommendHistoryItem {
id: string;
draftId: string;
objective: string;
/** How the run ended on the backend: "done" | "fallback" | "asking" | "error". */
status: string;
model: string | null;
rationale: string | null;
picks: RecommendPick[];
/** Candidate-pool snapshot captured at recommend time. */
candidates: RecommendCandidate[];
createdAt: string;
}
/** Payload persisted after a recommendation run completes. */
export interface SaveRecommendHistoryPayload {
objective: string;
status: string;
model?: string;
rationale?: string;
picks: RecommendPick[];
candidates: RecommendCandidate[];
}
interface ServerHistoryItem {
id: string;
draft_id: string;
objective: string;
status: string;
model: string | null;
rationale: string | null;
picks: RecommendPick[];
candidates: RecommendCandidate[];
created_at: string | null;
}
function toHistoryItem(s: ServerHistoryItem): RecommendHistoryItem {
return {
id: s.id,
draftId: s.draft_id,
objective: s.objective ?? "",
status: s.status ?? "done",
model: s.model ?? null,
rationale: s.rationale ?? null,
picks: Array.isArray(s.picks) ? s.picks : [],
candidates: Array.isArray(s.candidates) ? s.candidates : [],
createdAt: s.created_at ?? "",
};
}
/** List a draft's recommendation history, newest first. */
export async function listRecommendHistory(draftId: string): Promise<RecommendHistoryItem[]> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
{ method: "GET" },
);
if (res.status === 404) return [];
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`list recommend history failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { history: ServerHistoryItem[] };
return (data.history ?? []).map(toHistoryItem);
}
/** Append one recommendation run to a draft's history. */
export async function saveRecommendHistory(
draftId: string,
payload: SaveRecommendHistoryPayload,
): Promise<RecommendHistoryItem | null> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/roundtable-drafts/${encodeURIComponent(draftId)}/recommendations`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objective: payload.objective,
status: payload.status,
model: payload.model,
rationale: payload.rationale,
picks: payload.picks,
candidates: payload.candidates,
}),
},
);
if (res.status === 404) return null; // draft gone — skip silently
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`save recommend history failed (${res.status}): ${text}`);
}
return toHistoryItem((await res.json()) as ServerHistoryItem);
}
/**
* Open an SSE connection to `/api/recommend/stream` and resolve once the
* upstream stream finishes. Each call is one recommendation round — the
@@ -31,9 +31,12 @@ import { MessageStepsCard } from "./MessageStepsCard";
import type { IntentSummary } from "../api/intent";
import {
filterRecommendCandidates,
listRecommendHistory,
parseRecommendStreamContent,
saveRecommendHistory,
streamRecommend,
type RecommendCandidate,
type RecommendHistoryItem,
type RecommendStreamPhase,
} from "../api/recommend";
import { useAutoScroll } from "../hooks/useAutoScroll";
@@ -56,6 +59,13 @@ export interface RecommendAgentsDialogProps {
intent: IntentSummary | null;
/** Falls back to the recommender's own default if omitted. */
model?: string;
/**
* Draft/session id this recommendation belongs to. When present, the dialog
* shows the session's last recommendation on open (with a「重新分析」button)
* and persists each fresh run to history. Null → behaves statelessly (always
* runs a fresh recommendation, no history).
*/
draftId?: string | null;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
@@ -81,6 +91,8 @@ const FALLBACK_AGENT_IDS = [
type DialogPhase =
| "idle"
| "loading_agents"
| "loading_history"
| "history"
| "recommending"
| "ready"
| "fallback"
@@ -111,6 +123,8 @@ interface InternalState {
warning: string | null;
/** Latest error message; only shown when `phase === "error"`. */
errorMessage: string | null;
/** ISO time of the historical record being shown (phase === "history"). */
historyCreatedAt: string | null;
}
const INITIAL_STATE: InternalState = {
@@ -123,6 +137,7 @@ const INITIAL_STATE: InternalState = {
recommendedIds: [],
warning: null,
errorMessage: null,
historyCreatedAt: null,
};
/** 推荐流程的 phase → 中文标签;与 RoundtablePlanningPage::phaseLabel 风格保持一致。 */
@@ -164,6 +179,7 @@ export function RecommendAgentsDialog({
open,
intent,
model,
draftId,
onCancel,
onConfirm,
}: RecommendAgentsDialogProps) {
@@ -185,172 +201,240 @@ export function RecommendAgentsDialog({
}, []);
/**
* The full pipeline: fetch agents → run recommendation → seed selection.
* Wrapped so the user can re-trigger it via the "重新推荐" button.
* Render a stored history record as the current view (no streaming). Used on
* open when the session already has a prior recommendation. The user can
* confirm these picks directly or click「重新分析」to run a fresh one.
*/
const runRecommendation = useCallback(async () => {
if (!intent || !intent.objective) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "缺少任务目标,无法启动推荐",
});
return;
const applyHistory = useCallback((item: RecommendHistoryItem, candidates: RecommendCandidate[]) => {
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of item.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
cancelInflight();
const myGen = ++generationRef.current;
const controller = new AbortController();
abortRef.current = controller;
setState({ ...INITIAL_STATE, phase: "loading_agents" });
setSelectedIds(new Set());
let agents: Agent[];
try {
agents = await listAgents();
} catch (err) {
if (myGen !== generationRef.current) return;
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
});
return;
}
if (myGen !== generationRef.current) return;
const candidates = filterRecommendCandidates(agents);
if (candidates.length === 0) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
});
return;
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(item.rationale || "");
setState((prev) => ({
...prev,
phase: "recommending",
phase: "history",
candidates,
rationale: "",
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
rationale: rationale || item.rationale || "",
recommendedIds: seed,
reasons,
streamPhase: null,
steps: [],
warning: null,
historyCreatedAt: item.createdAt || null,
}));
}, []);
try {
const result = await streamRecommend({
intent,
/**
* Stream a fresh recommendation over the given candidate pool, seed the
* selection, and (when bound to a draft) persist the run to history.
*/
const analyze = useCallback(
async (candidates: RecommendCandidate[], myGen: number, signal: AbortSignal) => {
if (!intent) return;
setState((prev) => ({
...prev,
phase: "recommending",
candidates,
model,
signal: controller.signal,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
rationale: "",
reasons: {},
recommendedIds: [],
warning: null,
historyCreatedAt: null,
streamPhase: "connecting",
// 推荐开始,清空上一轮的步骤时间线并打第一条点。
steps: [
{ id: "0", key: "connecting", label: recommendPhaseLabel("connecting"), ts: nowTimeStr() },
],
}));
setSelectedIds(new Set());
try {
const result = await streamRecommend({
intent,
candidates,
model,
signal,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({ ...prev, recommendedIds, reasons }));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
if (result.picks) {
for (const p of result.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({
...prev,
recommendedIds,
reasons,
}));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => {
const key = phase;
const last = prev.steps[prev.steps.length - 1];
// 连续重复阶段不重复 push,只在阶段切换时打点。
const steps =
last && last.key === key
? prev.steps
: [
...prev.steps,
{
id: String(prev.steps.length),
key,
label: recommendPhaseLabel(phase),
ts: nowTimeStr(),
},
];
return { ...prev, streamPhase: phase, steps };
});
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
if (result.picks) {
for (const p of result.picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
}
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
let finalPhase: DialogPhase;
let finalIds: string[];
let warning: string | null;
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
finalPhase = "ready";
finalIds = intersectInOrder(recommendedIds, candidates);
warning = null;
} else {
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
finalPhase = "fallback";
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
finalIds =
fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
warning = "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。";
}
setSelectedIds(new Set(finalIds));
setState((prev) => ({
...prev,
phase: "ready",
phase: finalPhase,
rationale: rationale || prev.rationale,
recommendedIds,
recommendedIds: finalIds,
reasons,
streamPhase: null,
warning: null,
warning,
}));
// Persist this run to the session's history (best-effort).
if (draftId) {
const picks = finalIds.map((id) => ({ agent_id: id, reason: reasons[id] ?? "" }));
void saveRecommendHistory(draftId, {
objective: intent.objective,
status: finalPhase === "ready" ? "done" : "fallback",
model,
rationale: rationale || result.content || "",
picks,
candidates,
}).catch((err) => console.warn("[recommend] 保存推荐历史失败:", err));
}
} catch (err) {
if (myGen !== generationRef.current) return;
// AbortError fires when the user closes the dialog mid-stream — no
// state update needed because the dialog is going away.
if (err instanceof DOMException && err.name === "AbortError") return;
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
}));
}
},
[intent, model, draftId],
);
/**
* Open pipeline: load candidates → (unless forceFresh) show the session's
* last recommendation from history → otherwise run a fresh analysis.
* `forceFresh=true` is the「重新分析」/「重新推荐」path.
*/
const start = useCallback(
async (forceFresh: boolean) => {
if (!intent || !intent.objective) {
setState({ ...INITIAL_STATE, phase: "error", errorMessage: "缺少任务目标,无法启动推荐" });
return;
}
// Fallback: status === "asking" or picks too few. Use the 6 built-in
// roundtable seats, intersected against the live pool.
const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates);
const seed = fallback.length >= MIN_SELECTED
? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "fallback",
rationale: rationale || prev.rationale,
recommendedIds: seed,
reasons,
streamPhase: null,
warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。",
}));
} catch (err) {
cancelInflight();
const myGen = ++generationRef.current;
const controller = new AbortController();
abortRef.current = controller;
setState({ ...INITIAL_STATE, phase: "loading_agents" });
setSelectedIds(new Set());
let agents: Agent[];
try {
agents = await listAgents();
} catch (err) {
if (myGen !== generationRef.current) return;
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`,
});
return;
}
if (myGen !== generationRef.current) return;
// AbortError fires when the user closes the dialog mid-stream — no
// state update needed because the dialog is going away.
if (err instanceof DOMException && err.name === "AbortError") return;
setState((prev) => ({
...prev,
phase: "error",
streamPhase: null,
errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`,
}));
}
}, [intent, model, cancelInflight]);
const candidates = filterRecommendCandidates(agents);
if (candidates.length === 0) {
setState({
...INITIAL_STATE,
phase: "error",
errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体",
});
return;
}
// 若该会话已有推荐历史,默认展示最近一次(可重新分析),不自动重跑模型。
if (!forceFresh && draftId) {
setState((prev) => ({ ...prev, phase: "loading_history", candidates }));
try {
const history = await listRecommendHistory(draftId);
if (myGen !== generationRef.current) return;
if (history.length > 0) {
applyHistory(history[0], candidates);
return;
}
} catch (err) {
if (myGen !== generationRef.current) return;
// 历史拉取失败不阻断:退化为直接跑一次新推荐。
console.warn("[recommend] 拉取推荐历史失败,回退到新推荐:", err);
}
}
await analyze(candidates, myGen, controller.signal);
},
[intent, draftId, cancelInflight, analyze, applyHistory],
);
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
// in-flight stream so the next open starts clean.
@@ -359,11 +443,11 @@ export function RecommendAgentsDialog({
cancelInflight();
return;
}
void runRecommendation();
void start(false);
return () => {
cancelInflight();
};
}, [open, runRecommendation, cancelInflight]);
}, [open, start, cancelInflight]);
const handleToggle = useCallback((agentId: string) => {
setSelectedIds((prev) => {
@@ -391,7 +475,7 @@ export function RecommendAgentsDialog({
const selectedCount = selectedIds.size;
const canConfirm =
(state.phase === "ready" || state.phase === "fallback") &&
(state.phase === "ready" || state.phase === "fallback" || state.phase === "history") &&
selectedCount >= MIN_SELECTED &&
selectedCount <= MAX_SELECTED;
@@ -452,7 +536,9 @@ export function RecommendAgentsDialog({
============================================================ */}
<CollapsibleModelOutput
isLoading={
state.phase === "loading_agents" || state.phase === "recommending"
state.phase === "loading_agents" ||
state.phase === "loading_history" ||
state.phase === "recommending"
}
phase={state.phase}
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
@@ -497,8 +583,20 @@ export function RecommendAgentsDialog({
</div>
)}
</>
) : (state.phase === "ready" || state.phase === "fallback") ? (
) : (state.phase === "ready" || state.phase === "fallback" || state.phase === "history") ? (
<>
{state.phase === "history" && (
<div className="text-xs text-sky-700 dark:text-sky-300 p-2 border border-sky-300/50 dark:border-sky-700/50 rounded-md bg-sky-50 dark:bg-sky-950/30 flex items-center justify-between gap-2">
<span>
📌 这是本会话上次的推荐结果
{state.historyCreatedAt
? `(${new Date(state.historyCreatedAt).toLocaleString("zh-CN", { hour12: false })})`
: ""}
,可直接进入研讨,或点「重新分析」让模型重新推荐。
</span>
</div>
)}
{state.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
{state.warning}
@@ -551,15 +649,18 @@ export function RecommendAgentsDialog({
>
{selectionHint}
</span>
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
{(state.phase === "ready" ||
state.phase === "fallback" ||
state.phase === "history" ||
state.phase === "error") && (
<Button
variant="ghost"
variant={state.phase === "history" ? "secondary" : "ghost"}
size="sm"
onClick={() => void runRecommendation()}
onClick={() => void start(true)}
className="h-7 px-2 text-xs"
>
<RefreshCwIcon className="size-3 mr-1" />
重新推荐
{state.phase === "history" ? "重新分析" : "重新推荐"}
</Button>
)}
</div>
@@ -624,7 +725,8 @@ function CollapsibleModelOutput({
hasAutoClosedRef.current = false;
return;
}
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
const isCompleted =
phase === "ready" || phase === "fallback" || phase === "error" || phase === "history";
if (isCompleted && !hasAutoClosedRef.current && open) {
const timer = setTimeout(() => {
setOpen(false);
@@ -639,6 +741,10 @@ function CollapsibleModelOutput({
switch (phase) {
case "loading_agents":
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
case "loading_history":
return { label: "读取历史", className: "bg-slate-100 text-slate-600" };
case "history":
return { label: "历史记录", className: "bg-sky-50 text-sky-700 border border-sky-200" };
case "recommending":
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
case "ready":
@@ -713,11 +819,11 @@ function CollapsibleModelOutput({
* - 流式中正文末尾跟一个闪烁竖线
*/
function RecommenderOutput({ state }: { state: InternalState }) {
if (state.phase === "loading_agents") {
if (state.phase === "loading_agents" || state.phase === "loading_history") {
return (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
{state.phase === "loading_history" ? "正在读取历史推荐..." : "正在加载候选智能体..."}
</div>
);
}
@@ -733,7 +839,8 @@ function RecommenderOutput({ state }: { state: InternalState }) {
const showRationaleStage =
state.phase === "recommending" ||
state.phase === "ready" ||
state.phase === "fallback";
state.phase === "fallback" ||
state.phase === "history";
if (!showRationaleStage) return null;
const isStreaming = state.phase === "recommending";
@@ -1,26 +1,42 @@
/**
* 圆桌规划草稿持久化 hook。
* 圆桌规划草稿持久化 hook(后端版)。
*
* 维护 localStorage 草稿列表与 currentDraftId、自动保存(前进切换 1→2、2→3)、
* 手动保存、加载 / 重命名 / 删除。Step 1 / Step 2 的状态由各自的 hook 提供
* 快照函数与 hydrateFromDraft;本 hook 只负责编排「什么时机存 / 加载到哪里」。
* 2026-06 改造:草稿从浏览器 localStorage 迁移到后端 MySQL(`/api/roundtable-drafts`),
* 按登录用户隔离、跨设备/会话保留。本 hook 仍只负责编排「什么时机存 / 加载到哪里」,
* Step 1 / Step 2 的状态由各自的 hook 提供快照函数与 hydrate。
*
* 对外返回的接口(`drafts` 列表项含 `name` 等字段、各 handler 签名)与旧版保持一致,
* 主页面无需改动。
*
* 同时修复了旧 localStorage 版的几个存储 bug:
* 1. **自动保存会覆盖手动重命名** —— 现在 create 时写一次派生标题,之后所有 update
* 一律不带 title;标题只由显式「重命名」改写。
* 2. **快速连续前进(1→2→3)产生重复草稿** —— 所有保存经一条串行队列,create 完成
* 即写入 `currentDraftIdRef`,后续保存读到 id 走 update 而非再 create。
* 3. **加载读到 state 里的旧列表快照** —— load 走 `getDraft(id)` 实时拉服务端最新全量。
* 4. localStorage 配额导致的静默写失败 / 数据丢失 —— 改后端后不复存在。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type {
Step1Snapshot,
} from "@/roundtable-planning/hooks/useStep1Intent";
import type {
Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
createDraft,
deleteDraft,
deriveDraftName,
getDraft,
listDrafts,
renameDraft,
upsertDraft,
type DraftRecord,
} from "@/roundtable-planning/utils/drafts";
updateDraft,
} from "@/roundtable-planning/api/drafts";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
id: string;
name: string;
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
}
export interface UseDraftPersistenceOptions {
currentStep: number;
@@ -44,16 +60,12 @@ export interface UseDraftPersistenceOptions {
}
export interface UseDraftPersistenceReturn {
drafts: DraftRecord[];
drafts: DraftListItem[];
currentDraftId: string | null;
isHistoryOpen: boolean;
renamingDraftId: string | null;
renameDraftValue: string;
historyDropdownRef: React.MutableRefObject<HTMLDivElement | null>;
/**
* Shared latch: 加载草稿后置 true,影响 LeftSidebar 在 Step 2 时角色面板的
* 初始 status(在线 vs 正在上线)。由主页面在加载完后 read,使用完可手动复位。
*/
loadedFromDraftRef: React.MutableRefObject<boolean>;
setIsHistoryOpen: React.Dispatch<React.SetStateAction<boolean>>;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
@@ -61,9 +73,14 @@ export interface UseDraftPersistenceReturn {
handleManualSave: () => void;
handleLoadDraft: (id: string) => void;
handleDeleteDraft: (id: string) => void;
handleStartRename: (draft: DraftRecord) => void;
handleStartRename: (draft: DraftListItem) => void;
handleCommitRename: (id: string) => void;
handleCancelRename: () => void;
/**
* 幂等地保证后端存在一条对应当前会话的草稿,返回其 id(无可保存内容时返回 null)。
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
}
export function useDraftPersistence(
@@ -81,8 +98,8 @@ export function useDraftPersistence(
setStep2InitGate,
} = options;
const [drafts, setDrafts] = useState<DraftRecord[]>(() => listDrafts());
const [currentDraftId, setCurrentDraftId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
const [currentDraftId, setCurrentDraftIdState] = useState<string | null>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState<boolean>(false);
const [renamingDraftId, setRenamingDraftId] = useState<string | null>(null);
const [renameDraftValue, setRenameDraftValue] = useState<string>("");
@@ -91,95 +108,164 @@ export function useDraftPersistence(
const prevStepRef = useRef<number>(currentStep);
const historyDropdownRef = useRef<HTMLDivElement | null>(null);
const buildDraftPayload = useCallback(
(
furthestStep: 1 | 2 | 3,
idOverride?: string | null,
): Parameters<typeof upsertDraft>[0] => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const resolvedId = idOverride ?? currentDraftId;
return {
...(resolvedId ? { id: resolvedId } : {}),
name: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
};
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
const currentDraftIdRef = useRef<string | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
(action) => {
setCurrentDraftIdState((prev) => {
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
currentDraftIdRef.current = next;
return next;
});
},
[currentDraftId, getStep1Snapshot, getStep2Snapshot],
[],
);
const saveDraft = useCallback(
(furthestStep: 1 | 2 | 3): DraftRecord | null => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
const hasUserMessage = step1.interactiveMessages.some(
(m) => m.sender === "user",
const refreshList = useCallback(async () => {
try {
const metas = await listDrafts();
setDrafts(
metas.map((m) => ({
id: m.id,
name: m.title,
furthestStep: m.furthestStep,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
})),
);
if (!hasUserMessage && step2.step2RoundtableDialogues.length === 0) {
return null;
}
const saved = upsertDraft(buildDraftPayload(furthestStep));
setCurrentDraftId(saved.id);
setDrafts(listDrafts());
return saved;
} catch (err) {
console.warn("[drafts] 刷新草稿列表失败:", err);
}
}, []);
// 首次挂载拉一次草稿列表。
useEffect(() => {
void refreshList();
}, [refreshList]);
/** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
}, []);
/**
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
* 返回 draft id(无可保存内容返回 null)。
*/
const enqueueSave = useCallback(
(furthestStep: 1 | 2 | 3): Promise<string | null> => {
const run = async (): Promise<string | null> => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
if (!hasSavableContent(step1, step2)) return null;
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step1, step2 });
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
}
const created = await createDraft({
title: deriveDraftName({
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
step1,
step2,
});
currentDraftIdRef.current = created.id;
setCurrentDraftId(created.id);
return created.id;
};
const next = saveQueueRef.current.then(run, run);
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[buildDraftPayload, getStep1Snapshot, getStep2Snapshot],
[getStep1Snapshot, getStep2Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
async (furthestStep: 1 | 2 | 3 = (currentStep as 1 | 2 | 3) || 1): Promise<string | null> => {
if (currentDraftIdRef.current) return currentDraftIdRef.current;
const id = await enqueueSave(furthestStep);
if (id) await refreshList();
return id;
},
[currentStep, enqueueSave, refreshList],
);
const handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) ?? 1;
const saved = saveDraft(furthest);
if (saved) {
triggerToast(`💾 已保存为草稿:${saved.name}`);
} else {
triggerToast("当前会话还没有可保存的内容");
}
}, [currentStep, saveDraft, triggerToast]);
const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => {
const id = await enqueueSave(furthest);
if (id) {
await refreshList();
triggerToast("💾 已保存为草稿");
} else {
triggerToast("当前会话还没有可保存的内容");
}
})();
}, [currentStep, enqueueSave, refreshList, triggerToast]);
// 前进切换时自动保存;后退不保存以避免覆盖 furthestStep。
useEffect(() => {
const prev = prevStepRef.current;
prevStepRef.current = currentStep;
if (currentStep > prev) {
saveDraft(currentStep as 1 | 2 | 3);
void (async () => {
const id = await enqueueSave(currentStep as 1 | 2 | 3);
if (id) await refreshList();
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
const handleLoadDraft = useCallback(
(id: string) => {
const draft = drafts.find((d) => d.id === id) ?? null;
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
setDrafts(listDrafts());
return;
}
void (async () => {
let draft;
try {
draft = await getDraft(id);
} catch (err) {
console.warn("[drafts] 加载草稿失败:", err);
triggerToast("⚠️ 加载草稿失败,请重试");
return;
}
if (!draft) {
triggerToast("⚠️ 该草稿已不存在");
await refreshList();
return;
}
cancelAllStreams();
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
cancelAllStreams();
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
hydrateStep1(draft.step1 as Step1Snapshot);
hydrateStep2(draft.step2 as Step2Snapshot);
setStep2InitGate(draft.furthestStep >= 2);
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
triggerToast(`📂 已加载草稿:${draft.name}`);
setCurrentDraftId(draft.id);
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
triggerToast(`📂 已加载草稿:${draft.title}`);
})();
},
[
cancelAllStreams,
drafts,
hydrateStep1,
hydrateStep2,
refreshList,
setCurrentDraftId,
setCurrentStep,
setStep2InitGate,
triggerToast,
@@ -191,31 +277,54 @@ export function useDraftPersistence(
if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) {
return;
}
deleteDraft(id);
setDrafts(listDrafts());
if (currentDraftId === id) setCurrentDraftId(null);
if (renamingDraftId === id) setRenamingDraftId(null);
triggerToast("🗑 已删除历史记录");
void (async () => {
try {
await deleteDraft(id);
} catch (err) {
console.warn("[drafts] 删除草稿失败:", err);
triggerToast("⚠️ 删除失败,请重试");
return;
}
await refreshList();
if (currentDraftIdRef.current === id) setCurrentDraftId(null);
if (renamingDraftId === id) setRenamingDraftId(null);
triggerToast("🗑 已删除历史记录");
})();
},
[currentDraftId, renamingDraftId, triggerToast],
[refreshList, renamingDraftId, setCurrentDraftId, triggerToast],
);
const handleStartRename = useCallback((draft: DraftRecord) => {
const handleStartRename = useCallback((draft: DraftListItem) => {
setRenamingDraftId(draft.id);
setRenameDraftValue(draft.name);
}, []);
const handleCommitRename = useCallback(
(id: string) => {
const updated = renameDraft(id, renameDraftValue);
if (updated) {
setDrafts(listDrafts());
triggerToast("✏️ 已重命名");
const trimmed = renameDraftValue.trim();
if (!trimmed) {
setRenamingDraftId(null);
setRenameDraftValue("");
return;
}
setRenamingDraftId(null);
setRenameDraftValue("");
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
const updated = await updateDraft(id, { title: trimmed });
if (updated) {
await refreshList();
triggerToast("✏️ 已重命名");
}
} catch (err) {
console.warn("[drafts] 重命名失败:", err);
triggerToast("⚠️ 重命名失败,请重试");
} finally {
setRenamingDraftId(null);
setRenameDraftValue("");
}
})();
},
[renameDraftValue, triggerToast],
[refreshList, renameDraftValue, triggerToast],
);
const handleCancelRename = useCallback(() => {
@@ -253,5 +362,6 @@ export function useDraftPersistence(
handleStartRename,
handleCommitRename,
handleCancelRename,
ensureDraft,
};
}
@@ -320,6 +320,7 @@ export default function RoundtablePlanningPage() {
open={isRecommendDialogOpen}
intent={step1.intentReady}
model={selectedModel || undefined}
draftId={drafts.currentDraftId}
onCancel={() => setIsRecommendDialogOpen(false)}
onConfirm={handleConfirmAgents}
/>
@@ -467,7 +468,9 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
setIsRecommendDialogOpen(true);
// 先把草稿落地拿到 draft_id,推荐历史才有归属;失败也照常打开弹窗
// (弹窗在无 draftId 时退化为「直接推荐、不存历史」)。
void drafts.ensureDraft(1).finally(() => setIsRecommendDialogOpen(true));
}}
onManualSave={drafts.handleManualSave}
onTriggerToast={triggerToast}
@@ -1,200 +0,0 @@
/**
* Local-only draft storage for /page/roundtable/planning.
*
* One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent
* roundtable). Persisted in `localStorage` so refresh / browser-restart keeps
* it; cross-device sync is out of scope (would need a backend). The Step 2
* back-end LangGraph threads are reused on load, so list/preview/download of
* artifacts in Step 3 continue to work as long as the user hasn't deleted the
* threads server-side.
*/
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
const STORAGE_KEY = "roundtable-planning:drafts:v1";
/**
* Snapshot of every piece of UI state needed to resume a session. We capture
* shapes loosely (`unknown[]`) for the chat / role lists because their inline
* types in the page component are wide and would force a refactor we don't
* want as part of the persistence feature.
*/
export interface DraftStep1Snapshot {
intentThreadId: string | null;
intentReady: IntentSummary | null;
interactiveMessages: unknown[];
timelineEntries: unknown[];
}
export interface DraftStep2Snapshot {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/**
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
* loader. Older drafts won't have this — callers should default to `{}`.
*/
seatStubMessages?: Record<string, unknown[]>;
}
export interface DraftRecord {
id: string;
/** Auto-derived task summary (objective → first user message → timestamp). */
name: string;
createdAt: string;
updatedAt: string;
/** Furthest step the user had reached when this snapshot was taken. */
furthestStep: 1 | 2 | 3;
step1: DraftStep1Snapshot;
step2: DraftStep2Snapshot;
}
function isBrowser(): boolean {
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
}
function readAll(): DraftRecord[] {
if (!isBrowser()) return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed as DraftRecord[];
} catch (err) {
console.warn("[drafts] failed to parse localStorage payload:", err);
return [];
}
}
function writeAll(records: DraftRecord[]): void {
if (!isBrowser()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records));
} catch (err) {
// Quota errors are the realistic failure mode — surface but don't throw,
// since the user can keep working with their in-memory state.
console.warn("[drafts] failed to write localStorage payload:", err);
}
}
function newId(): string {
if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** List drafts newest first. */
export function listDrafts(): DraftRecord[] {
return readAll()
.slice()
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
}
export function getDraft(id: string): DraftRecord | null {
return readAll().find((d) => d.id === id) ?? null;
}
/**
* Create-or-update a draft. If `id` is given and exists, that record is
* overwritten (updatedAt refreshed). Otherwise a new record is inserted.
* Returns the (re-)saved record so callers can read back the new id.
*/
export function upsertDraft(
payload: Omit<DraftRecord, "createdAt" | "updatedAt" | "id"> & { id?: string },
): DraftRecord {
const records = readAll();
const now = new Date().toISOString();
if (payload.id) {
const idx = records.findIndex((d) => d.id === payload.id);
if (idx >= 0) {
const existing = records[idx];
const updated: DraftRecord = {
...existing,
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
updatedAt: now,
};
records[idx] = updated;
writeAll(records);
return updated;
}
}
const created: DraftRecord = {
id: payload.id ?? newId(),
name: payload.name,
furthestStep: payload.furthestStep,
step1: payload.step1,
step2: payload.step2,
createdAt: now,
updatedAt: now,
};
records.push(created);
writeAll(records);
return created;
}
export function deleteDraft(id: string): void {
const records = readAll();
const next = records.filter((d) => d.id !== id);
if (next.length !== records.length) writeAll(next);
}
/**
* Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the
* updated record so callers can refresh local UI state.
*/
export function renameDraft(id: string, name: string): DraftRecord | null {
const trimmed = name.trim();
if (!trimmed) return null;
const records = readAll();
const idx = records.findIndex((d) => d.id === id);
if (idx < 0) return null;
const updated: DraftRecord = {
...records[idx],
name: trimmed,
updatedAt: new Date().toISOString(),
};
records[idx] = updated;
writeAll(records);
return updated;
}
/**
* Derive a short task-summary name from whatever the user has supplied so far.
* Preference order: intent agent's structured objective → first user message
* in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so
* the dropdown rows stay scannable.
*/
export function deriveDraftName(args: {
intentReady?: IntentSummary | null;
interactiveMessages?: unknown[];
}): string {
const MAX = 40;
const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s);
const objective = args.intentReady?.objective?.trim();
if (objective) return truncate(objective);
if (Array.isArray(args.interactiveMessages)) {
for (const m of args.interactiveMessages) {
if (!m || typeof m !== "object") continue;
const obj = m as Record<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}