feat: 支持岗位会商任务和意图编辑

This commit is contained in:
fengxiangdebaozi committed 2026-07-22 17:44:34 +08:00
1 parent 790267ac43
commit 7bd032ec77
2 files changed
+408 -12

No files matched your search

@@ -128,7 +128,7 @@ function toNode(node: ServerNode): PositionRoundtableNode {
}
function toSession(session: ServerSession): PositionRoundtableSession {
return {
const result: PositionRoundtableSession = {
id: session.id,
externalTaskId: session.external_task_id,
taskSnapshot: session.task_snapshot ?? {},
@@ -143,8 +143,11 @@ function toSession(session: ServerSession): PositionRoundtableSession {
status: session.status,
createdAt: session.created_at,
updatedAt: session.updated_at,
nodes: session.nodes?.map(toNode),
};
if (Array.isArray(session.nodes)) {
result.nodes = session.nodes.map(toNode);
}
return result;
}
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
@@ -182,6 +185,21 @@ export async function getPositionSession(sessionId: string): Promise<PositionRou
return toSession(result);
}
export async function updatePositionTaskSnapshot(
sessionId: string,
taskSnapshot: Record<string, unknown>,
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }),
}),
"update position task",
);
return toSession(result);
}
export async function savePositionIntent(
sessionId: string,
input: { intentThreadId?: string | null; intentSnapshot: object | null },
@@ -19,6 +19,7 @@ import {
Layers3,
Loader2,
MessageSquareText,
PencilLine,
Plus,
RotateCcw,
Search,
@@ -86,6 +87,7 @@ import {
preparePositionSummary,
rejectPositionNode,
savePositionIntent,
updatePositionTaskSnapshot,
type PositionRoundtableNode,
type PositionRoundtableSession,
} from "@/position-roundtable/api/sessions";
@@ -116,6 +118,11 @@ type PositionArtifactPreview = {
mimeType: string | null;
auto?: boolean;
};
type IntentEditDraft = {
objective: string;
constraints: string;
assumptions: string;
};
function sandboxThreadSignature(thread: BaseStream<AgentThreadState>): string {
const messages = thread.messages ?? [];
@@ -347,6 +354,14 @@ function artifactName(artifact: Record<string, unknown> | null | undefined): str
return path.split("/").filter(Boolean).at(-1) ?? "";
}
function mergePositionSession(
current: PositionRoundtableSession | null,
next: PositionRoundtableSession,
): PositionRoundtableSession {
if (!current || current.id !== next.id || Array.isArray(next.nodes)) return next;
return { ...current, ...next, nodes: current.nodes };
}
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
return null;
@@ -430,6 +445,17 @@ export default function PositionRoundtablePage({
signature: string;
} | null>(null);
const [flowDetailItem, setFlowDetailItem] = useState<ResultItem | null>(null);
const [taskEditorOpen, setTaskEditorOpen] = useState(false);
const [taskDraft, setTaskDraft] = useState<PositionTask>(fallbackTask);
const [taskSaving, setTaskSaving] = useState(false);
const [intentEditorOpen, setIntentEditorOpen] = useState(false);
const [intentDraft, setIntentDraft] = useState<IntentEditDraft>({
objective: "",
constraints: "",
assumptions: "",
});
const [intentSaving, setIntentSaving] = useState(false);
const [intentCompletedAt, setIntentCompletedAt] = useState("");
const activePosition = POSITIONS.find((item) => item.id === positionId)!;
useEffect(() => {
artifactsCtxRef.current = artifactsCtx;
@@ -482,6 +508,140 @@ export default function PositionRoundtablePage({
});
const hydratedIntentSessionRef = useRef<string | null>(null);
const persistedIntentRef = useRef<string | null>(null);
const isArchivedSession = positionSession?.status === "archived";
const syncSessionUrl = useCallback(
(sessionId: string, nextTaskId: string) => {
const next = new URLSearchParams(searchParamsString);
next.set("taskId", nextTaskId);
next.set("sessionId", sessionId);
next.delete("new");
setSearchParams(next, { replace: true });
},
[searchParamsString, setSearchParams],
);
const ensureEditableSession = useCallback(
async (taskSnapshot: PositionTask): Promise<PositionRoundtableSession> => {
if (positionSession) return positionSession;
const created = await createPositionSession({
externalTaskId: taskSnapshot.id || undefined,
taskSnapshot: { ...taskSnapshot },
});
setPositionSession(created);
syncSessionUrl(created.id, taskSnapshot.id);
return created;
},
[positionSession, syncSessionUrl],
);
const openTaskEditor = useCallback(() => {
if (isArchivedSession) return;
setTaskDraft({ ...task });
setTaskEditorOpen(true);
}, [isArchivedSession, task]);
const handleSaveTaskEdit = useCallback(async () => {
if (isArchivedSession) {
toast.warning("已归档的会话不能编辑任务信息");
return;
}
const title = taskDraft.title.trim();
if (!title) {
toast.warning("请填写任务名称");
return;
}
const nextTask: PositionTask = {
...task,
title,
description: taskDraft.description.trim(),
direction: taskDraft.direction.trim() || task.direction,
};
setTaskSaving(true);
try {
const saved = positionSession
? await updatePositionTaskSnapshot(positionSession.id, { ...nextTask })
: await createPositionSession({
externalTaskId: nextTask.id || undefined,
taskSnapshot: { ...nextTask },
});
setPositionSession((current) => mergePositionSession(current, saved));
syncSessionUrl(saved.id, nextTask.id);
setTaskEditorOpen(false);
toast.success("任务信息已更新");
} catch (error) {
toast.error(`任务信息保存失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setTaskSaving(false);
}
}, [isArchivedSession, positionSession, syncSessionUrl, task, taskDraft]);
const openIntentEditor = useCallback(() => {
if (isArchivedSession) return;
const currentIntent = frozenIntent ?? intent.intentReady;
setIntentDraft({
objective: currentIntent?.objective ?? "",
constraints: (currentIntent?.constraints ?? []).join("\n"),
assumptions: (currentIntent?.assumptions ?? []).join("\n"),
});
setIntentEditorOpen(true);
}, [frozenIntent, intent.intentReady, isArchivedSession]);
const handleSaveIntentEdit = useCallback(async () => {
if (isArchivedSession) {
toast.warning("已归档的会话不能编辑意图理解");
return;
}
const objective = intentDraft.objective.trim();
if (!objective) {
toast.warning("请填写任务意图");
return;
}
const splitLines = (value: string) =>
value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const nextIntent: IntentSummary = {
objective,
constraints: splitLines(intentDraft.constraints),
assumptions: splitLines(intentDraft.assumptions),
};
setIntentSaving(true);
try {
const baseSession = await ensureEditableSession(task);
const intentThreadId = baseSession.intentThreadId ?? intent.intentThreadId;
const saved = await savePositionIntent(baseSession.id, {
intentThreadId,
intentSnapshot: nextIntent,
});
persistedIntentRef.current = `${saved.id}:${intentThreadId ?? ""}:${JSON.stringify(nextIntent)}`;
setPositionSession((current) => mergePositionSession(current, saved));
intent.hydrateFromDraft({
intentThreadId: intentThreadId ?? intent.intentThreadId,
intentReady: nextIntent,
interactiveMessages: intent.interactiveMessages,
timelineEntries: [],
});
if (!intentCompletedAt) setIntentCompletedAt(timeLabel());
setIntentEditorOpen(false);
toast.success("意图理解已更新");
} catch (error) {
toast.error(`意图理解保存失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setIntentSaving(false);
}
}, [
ensureEditableSession,
intent,
intentCompletedAt,
intentDraft.assumptions,
intentDraft.constraints,
intentDraft.objective,
isArchivedSession,
task,
]);
const { data: chains = [], isLoading: chainsLoading } = useQuery({
queryKey: ["position-roundtable", "chains"],
@@ -550,6 +710,25 @@ export default function PositionRoundtablePage({
const nextNodeKey = activeNode?.nodeKey ?? null;
if (activeNodeKey !== nextNodeKey) setActiveNodeKey(nextNodeKey);
}, [activeNode?.nodeKey, activeNodeKey]);
useEffect(() => {
if (
!sessionIdFromUrl ||
!positionSession ||
Array.isArray(positionSession.nodes) ||
positionSession.status === "intent_pending"
) {
return;
}
let cancelled = false;
void getPositionSession(sessionIdFromUrl)
.then((session) => {
if (!cancelled) setPositionSession(session);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [positionSession, sessionIdFromUrl]);
const handleNodeUpdate = useCallback((updatedNode: PositionRoundtableNode) => {
setPositionSession((current) => {
if (!current?.nodes) return current;
@@ -597,7 +776,7 @@ export default function PositionRoundtablePage({
});
if (cancelled) return;
persistedIntentRef.current = `${saved.id}:${intentThreadId}:${snapshotKey}`;
setPositionSession((current) => (current?.id === saved.id ? { ...current, ...saved } : saved));
setPositionSession((current) => mergePositionSession(current, saved));
if (!sessionIdFromUrl) {
const next = new URLSearchParams(searchParamsString);
next.set("taskId", task.id);
@@ -756,7 +935,6 @@ export default function PositionRoundtablePage({
}, [sessionIdFromUrl]);
const { agent: selectedAgent } = useAgent(selectedAgentId);
const [intentCompletedAt, setIntentCompletedAt] = useState("");
useEffect(() => {
if (intent.intentReady && !intentCompletedAt) setIntentCompletedAt(timeLabel());
if (!intent.intentReady && intentCompletedAt) setIntentCompletedAt("");
@@ -857,7 +1035,7 @@ export default function PositionRoundtablePage({
if (!window.confirm("归档后该会话将只读,是否继续?")) return;
void archivePositionSession(positionSession.id)
.then((session) => {
setPositionSession((current) => (current ? { ...current, ...session } : session));
setPositionSession((current) => mergePositionSession(current, session));
toast.success("会话已归档,可新建会话继续研讨");
})
.catch((error) => {
@@ -1163,11 +1341,19 @@ export default function PositionRoundtablePage({
<div className="flex min-h-0 flex-1 gap-3 overflow-hidden p-3">
<aside className="custom-scrollbar flex w-[360px] shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:text-[#c5cfe0] dark:shadow-none dark:backdrop-blur-md">
<TaskInformationCard task={task} />
<TaskInformationCard
task={task}
editable={!isArchivedSession}
onEdit={openTaskEditor}
/>
{activePosition.isIntentPosition ? (
isIntelligenceDeliveryOverview ? (
<IntentUnderstandingCard intent={frozenIntent ?? intent.intentReady} />
<IntentUnderstandingCard
intent={frozenIntent ?? intent.intentReady}
editable={!isArchivedSession}
onEdit={openIntentEditor}
/>
) : (
<BusinessChainSelector
chains={chains}
@@ -1179,7 +1365,11 @@ export default function PositionRoundtablePage({
)
) : (
<div className="contents">
<IntentUnderstandingCard intent={intent.intentReady} />
<IntentUnderstandingCard
intent={frozenIntent ?? intent.intentReady}
editable={!isArchivedSession}
onEdit={openIntentEditor}
/>
<BusinessChainFlow
chain={activeChain}
nodes={positionSession?.nodes ?? []}
@@ -1368,6 +1558,22 @@ export default function PositionRoundtablePage({
showReject={false}
showBuiltinAsk={false}
/>
<TaskInformationEditDialog
open={taskEditorOpen}
task={taskDraft}
saving={taskSaving}
onOpenChange={setTaskEditorOpen}
onChange={setTaskDraft}
onSubmit={handleSaveTaskEdit}
/>
<IntentUnderstandingEditDialog
open={intentEditorOpen}
draft={intentDraft}
saving={intentSaving}
onOpenChange={setIntentEditorOpen}
onChange={setIntentDraft}
onSubmit={handleSaveIntentEdit}
/>
</div>
</div>
);
@@ -1732,10 +1938,39 @@ function DeliveryOverviewCard({ item }: { item: DeliveryOverviewItem }) {
);
}
/** 仅展示岗位会商任务,不在会商页编辑任务。 */
function TaskInformationCard({ task }: { task: PositionTask }) {
/** 岗位会商内的任务快照展示,可按当前 Session 独立编辑。 */
function HoverEditButton({
title,
onClick,
}: {
title: string;
onClick: () => void;
}) {
return (
<section className="rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="absolute right-3 top-3 z-10 flex size-7 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 opacity-0 shadow-sm transition-all hover:border-primary/40 hover:bg-primary/5 hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 group-hover:opacity-100 dark:border-[#2b8ffe]/35 dark:bg-[#063363]/95 dark:text-[#9ebfe2] dark:hover:border-[#2b8ffe]/80 dark:hover:bg-[#0b4fae]/45 dark:hover:text-[#83c1ff]"
>
<PencilLine className="size-3.5" strokeWidth={2.4} />
</button>
);
}
function TaskInformationCard({
task,
editable,
onEdit,
}: {
task: PositionTask;
editable?: boolean;
onEdit?: () => void;
}) {
return (
<section className="group relative rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
{editable && onEdit && <HoverEditButton title="编辑任务信息" onClick={onEdit} />}
<div className="mb-3 flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Target className="size-3" strokeWidth={2.5} />
@@ -1774,6 +2009,72 @@ function TaskInformationCard({ task }: { task: PositionTask }) {
);
}
function TaskInformationEditDialog({
open,
task,
saving,
onOpenChange,
onChange,
onSubmit,
}: {
open: boolean;
task: PositionTask;
saving: boolean;
onOpenChange: (open: boolean) => void;
onChange: (task: PositionTask) => void;
onSubmit: () => void;
}) {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!saving) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-[560px] dark:border-[#2b8ffe]/35 dark:bg-[#042b59]">
<DialogHeader>
<DialogTitle>编辑任务信息</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务名称</span>
<TInput
value={task.title}
onChange={(value) => onChange({ ...task, title: String(value) })}
placeholder="请输入任务名称"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务描述</span>
<Textarea
value={task.description}
onChange={(event) => onChange({ ...task, description: event.target.value })}
className="min-h-28 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="请输入任务描述"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务方向</span>
<TInput
value={task.direction}
onChange={(value) => onChange({ ...task, direction: String(value) })}
placeholder="请输入任务方向"
/>
</label>
</div>
<DialogFooter>
<TButton variant="outline" disabled={saving} onClick={() => onOpenChange(false)}>
取消
</TButton>
<TButton theme="primary" loading={saving} onClick={onSubmit}>
保存
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function BusinessChainSelector({
chains,
loading,
@@ -1976,11 +2277,16 @@ function BusinessChainSelector({
function IntentUnderstandingCard({
intent,
editable,
onEdit,
}: {
intent: ReturnType<typeof useStep1Intent>["intentReady"];
editable?: boolean;
onEdit?: () => void;
}) {
return (
<section className="rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
<section className="group relative rounded-2xl border border-[#ECE7E4] bg-white p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none">
{editable && onEdit && <HoverEditButton title="编辑意图理解" onClick={onEdit} />}
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold tracking-wide text-slate-900 dark:text-[#e8edf5]">
<span className="flex size-5 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Sparkles className="size-3" strokeWidth={2.5} />
@@ -2011,6 +2317,78 @@ function IntentUnderstandingCard({
);
}
function IntentUnderstandingEditDialog({
open,
draft,
saving,
onOpenChange,
onChange,
onSubmit,
}: {
open: boolean;
draft: IntentEditDraft;
saving: boolean;
onOpenChange: (open: boolean) => void;
onChange: (draft: IntentEditDraft) => void;
onSubmit: () => void;
}) {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!saving) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-[600px] dark:border-[#2b8ffe]/35 dark:bg-[#042b59]">
<DialogHeader>
<DialogTitle>编辑意图理解</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务意图</span>
<Textarea
value={draft.objective}
onChange={(event) => onChange({ ...draft, objective: event.target.value })}
className="min-h-24 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="请输入识别后的任务意图"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
关键约束
</span>
<Textarea
value={draft.constraints}
onChange={(event) => onChange({ ...draft, constraints: event.target.value })}
className="min-h-28 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="每行一条关键约束"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
补充假设 / 说明
</span>
<Textarea
value={draft.assumptions}
onChange={(event) => onChange({ ...draft, assumptions: event.target.value })}
className="min-h-20 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="可选,每行一条"
/>
</label>
</div>
<DialogFooter>
<TButton variant="outline" disabled={saving} onClick={() => onOpenChange(false)}>
取消
</TButton>
<TButton theme="primary" loading={saving} onClick={onSubmit}>
保存
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function chainStages(chain: BusinessChain) {
const seatById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
if (chain.stages?.length) {