圆桌草稿持久化:localStorage 迁至后端 MySQL
新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 })}`;
|
||||
}
|
||||
Reference in new issue
Block a user