代码修改

This commit is contained in:
董宏凯 committed 2026-07-23 15:08:00 +08:00
1 parent b60d9ed09e
commit 1d07025f74
9 files changed
+337 -53

No files matched your search

@@ -126,7 +126,7 @@ PositionRoundtablePage
`PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。
完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。方案总结(以及行动规划的 Markdown 部分)若模型只返回了对话正文、遗漏 `write_file`,服务端会将该正文自动写入对应线程的 `outputs/` 目录并登记为 Markdown 产物,因此用户始终同时获得聊天正文与可预览、下载的报告文件;行动规划仍必须另外生成 `action-plan-subtasks.json`。
## 5. 文件预览沙箱
@@ -334,7 +334,7 @@ export async function completePositionNodeTurn(
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { title: string; reason: string },
input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await apiFetch(
@@ -343,8 +343,10 @@ export async function rejectPositionNode(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: input.title,
reason: input.reason,
// ``title`` is retained for older callers. The current position
// workspace deliberately sends only an optional reason.
...(input.title ? { title: input.title } : {}),
reason: input.reason ?? "",
}),
},
),
@@ -76,12 +76,6 @@ interface PositionBuiltinAgentChatProps {
const SUMMARY_AGENT_ID = "roundtable-summary";
const ACTION_PLAN_AGENT_ID = "position-action-planner";
// 方案总结 / 行动规划仍使用普通的 thread 流;首次自动提交期间若组件因
// thread 绑定而短暂重渲染,保留服务端运行并允许恢复 SSE,避免首轮被取消。
const POSITION_BUILTIN_RUN_OPTIONS = {
onDisconnect: "continue",
multitaskStrategy: "rollback",
} as const;
function builtinAgentId(kind: PositionBuiltinAgentKind) {
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
@@ -244,7 +238,12 @@ export function PositionBuiltinAgentChat({
pendingSubmissionRef.current = false;
setPendingSubmission(null);
handledAutoStartRequestRef.current = undefined;
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
// Do not depend on `persistedThreadId` here. Preparing the automatic
// first question can persist a thread id while the page's initial
// session-request is still resolving. Treating that update as a new
// session used to clear `pendingSubmission` before the normal send path
// ran; a manual click only worked because that request had already ended.
}, [fallbackAgentId, kind, session?.id]);
const canWrite =
Boolean(session) &&
@@ -264,7 +263,6 @@ export function PositionBuiltinAgentChat({
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId,
runOptions: POSITION_BUILTIN_RUN_OPTIONS,
context: {
...settings.context,
agent_id: assistantId,
@@ -578,6 +576,12 @@ export function PositionBuiltinAgentChat({
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Use the built-in agent's regular submit path. This keeps a
// clarification answer on the same persisted thread and retains
// the summary/action-plan preparation and completion lifecycle.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
@@ -4,6 +4,13 @@ import { Bot, FileText, LockKeyhole, Settings } from "lucide-react";
import { toast } from "sonner";
import { Button as TButton, Select as TSelect } from "tdesign-react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
@@ -207,8 +214,12 @@ export function PositionNodeChat({
onThreadSnapshot,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false);
const { agent } = useAgent(node.agentId);
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const lastRejection = node.status === "rejected" ? node.lastRejection : null;
const rejectionReason =
lastRejection && typeof lastRejection.reason === "string" ? lastRejection.reason.trim() : "";
const switchOptions = useMemo<PositionNodeSwitchOption[]>(
() =>
positionNodeOptions?.length
@@ -504,6 +515,17 @@ export function PositionNodeChat({
onSelect={onSelectBuiltinAgent}
/>
)}
{lastRejection && (
<TButton
size="small"
theme="warning"
variant="outline"
className="!shrink-0"
onClick={() => setRejectionReasonOpen(true)}
>
查看驳回理由
</TButton>
)}
{onOpenConfiguration && (
<TButton
size="small"
@@ -559,6 +581,13 @@ export function PositionNodeChat({
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-6"
// Clarification cards are rendered by MessageList, but position
// nodes own the actual stream. Route their answer through the
// same submit path as the normal input so handoff context,
// permissions and node lifecycle updates are all preserved.
onClarificationSubmit={(answer) =>
handleSubmit({ text: answer, files: [] })
}
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
onOpenArtifact={handleOpenMessageArtifact}
/>
@@ -593,6 +622,22 @@ export function PositionNodeChat({
onStop={() => void thread.stop()}
/>
</div>
<Dialog open={rejectionReasonOpen} onOpenChange={setRejectionReasonOpen}>
<DialogContent className="w-[480px] !max-w-[calc(100vw-2rem)]">
<DialogHeader>
<DialogTitle>驳回理由</DialogTitle>
</DialogHeader>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-sm leading-6 text-amber-900 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-100">
{rejectionReason || "本次驳回未填写具体理由。"}
</div>
<DialogFooter>
<TButton variant="outline" onClick={() => setRejectionReasonOpen(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ThreadContext.Provider>
);
@@ -808,6 +808,11 @@ export default function PositionRoundtablePage({
const showClosureAgents = positionId === DEFAULT_POSITION_ID;
const [activeBuiltinKind, setActiveBuiltinKind] = useState<PositionBuiltinAgentKind | null>(null);
const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0);
// Stage selection only arms the automatic first question. The key is
// incremented after the page has finished restoring its Session so the
// ordinary chat component never submits against a thread being rebound.
const [pendingBuiltinAutoStartKind, setPendingBuiltinAutoStartKind] =
useState<PositionBuiltinAgentKind | null>(null);
const builtinAgentOptions = useMemo<PositionBuiltinAgentSwitchOption[]>(
() => [
{
@@ -834,7 +839,27 @@ export default function PositionRoundtablePage({
useEffect(() => {
setActiveBuiltinKind(null);
setBuiltinAutoStartRequestKey(0);
setPendingBuiltinAutoStartKind(null);
}, [positionSession?.id]);
useEffect(() => {
if (
!pendingBuiltinAutoStartKind ||
pendingBuiltinAutoStartKind !== activeBuiltinKind ||
!positionSession ||
sessionLoading
) {
return;
}
// Let the ordinary chat mount and finish the current page's session
// restoration first. The callback below only increments a key; the child
// still uses its standard manual-submit path to create the run.
const timer = window.setTimeout(() => {
setBuiltinAutoStartRequestKey((current) => current + 1);
setPendingBuiltinAutoStartKind(null);
}, 0);
return () => window.clearTimeout(timer);
}, [activeBuiltinKind, pendingBuiltinAutoStartKind, positionSession?.id, sessionLoading]);
const assignedAgentIds = useMemo(
() =>
new Set(
@@ -1421,7 +1446,7 @@ export default function PositionRoundtablePage({
setActiveNodeKey(null);
setRightPanelOpen(false);
setActiveBuiltinKind(kind);
setBuiltinAutoStartRequestKey((current) => current + 1);
setPendingBuiltinAutoStartKind(kind);
},
[allPositionDeliveriesDone, closeSandbox, positionId, summaryDone],
);
@@ -4454,8 +4479,12 @@ function AnalysisResults({
const [nodeFilter, setNodeFilter] = useState("all");
const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all");
const [detailItem, setDetailItem] = useState<ResultItem | null>(null);
// Rejecting starts from the artifact detail dialog. Keep the requested node
// separate until that dialog has actually unmounted; otherwise Radix places
// the reason dialog below the existing deliverable dialog and the click
// appears to do nothing.
const [pendingRejectNode, setPendingRejectNode] = useState<PositionRoundtableNode | null>(null);
const [rejectNode, setRejectNode] = useState<PositionRoundtableNode | null>(null);
const [rejectTitle, setRejectTitle] = useState("");
const [rejectReason, setRejectReason] = useState("");
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const seatsByAgentId = useMemo(
@@ -4666,37 +4695,41 @@ function AnalysisResults({
);
const openRejectDialog = useCallback((node: PositionRoundtableNode) => {
setRejectNode(node);
setRejectTitle(nodeRejectionTitle(node));
setRejectReason("");
// Close the high-z-index product dialog first, then open the rejection
// dialog on the next render. This prevents an invisible, unclickable
// dialog from being rendered under the product detail modal.
setDetailItem(null);
setPendingRejectNode(node);
}, []);
useEffect(() => {
if (!pendingRejectNode || detailItem || rejectSubmitting) return;
setRejectNode(pendingRejectNode);
setRejectReason("");
setPendingRejectNode(null);
}, [detailItem, pendingRejectNode, rejectSubmitting]);
const handleRejectSubmit = useCallback(async () => {
if (!rejectNode) return;
const title = rejectTitle.trim();
const reason = rejectReason.trim();
if (!title || !reason) {
toast.warning("请填写驳回标题和理由");
return;
}
setRejectSubmitting(true);
try {
const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, {
title,
reason,
});
// The endpoint returns the complete session, including every downstream
// node invalidated by this rejection. Replacing the parent snapshot here
// updates the flow chart, delivery drawer and the active chat together.
onSessionUpdate(session);
setRejectNode(null);
setRejectTitle("");
setRejectReason("");
setDetailItem(null);
toast.success("已驳回该产物,后续阶段已标记为需重新完成");
toast.success("产物已驳回,相关后续阶段已同步标记为待重做");
} catch (error) {
toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
setRejectSubmitting(false);
}
}, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]);
}, [onSessionUpdate, rejectNode, rejectReason]);
if (resultItems.length === 0) {
return (
@@ -4914,20 +4947,19 @@ function AnalysisResults({
<RejectDeliveryDialog
node={rejectNode}
title={rejectTitle}
reason={rejectReason}
submitting={rejectSubmitting}
onOpenChange={(open) => {
if (!open && !rejectSubmitting) {
setRejectNode(null);
setRejectTitle("");
setPendingRejectNode(null);
setRejectReason("");
}
}}
onTitleChange={setRejectTitle}
onReasonChange={setRejectReason}
onSubmit={() => void handleRejectSubmit()}
/>
{rejectSubmitting && <RejectSyncingOverlay />}
</div>
);
}
@@ -5143,26 +5175,25 @@ function DeliverableDetailDialog({
function RejectDeliveryDialog({
node,
title,
reason,
submitting,
onOpenChange,
onTitleChange,
onReasonChange,
onSubmit,
}: {
node: PositionRoundtableNode | null;
title: string;
reason: string;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onTitleChange: (value: string) => void;
onReasonChange: (value: string) => void;
onSubmit: () => void;
}) {
return (
<Dialog open={Boolean(node)} onOpenChange={onOpenChange}>
<DialogContent className="w-[560px] !max-w-[calc(100vw-2rem)]">
<DialogContent
overlayClassName="!z-[6100]"
className="!z-[6101] w-[560px] !max-w-[calc(100vw-2rem)]"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<RotateCcw className="size-4 text-destructive" />
@@ -5171,22 +5202,14 @@ function RejectDeliveryDialog({
</DialogHeader>
<div className="space-y-3">
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/35 dark:bg-amber-950/25 dark:text-amber-200">
驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。
驳回后,该节点会回到待重做状态;已受其影响的后续交付会同步失效,待上游重新完成后再依次生成。
</div>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回标题</span>
<TInput
value={title}
placeholder="例如:依据不足,需要补充约束条件"
onChange={onTitleChange}
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由</span>
<span className="text-xs font-semibold text-slate-700 dark:text-[#c5cfe0]">驳回理由(选填)</span>
<Textarea
rows={5}
value={reason}
placeholder="请说明需要重做的原因、补充材料或调整方向"
placeholder="可说明需要重做的原因、补充材料或调整方向;不填写也可以直接驳回"
onChange={(event) => onReasonChange(event.target.value)}
/>
</label>
@@ -5203,3 +5226,24 @@ function RejectDeliveryDialog({
</Dialog>
);
}
/** Blocks duplicate clicks while the server cascades the rejection state. */
function RejectSyncingOverlay() {
return (
<div
className="fixed inset-0 z-[7000] flex items-center justify-center bg-slate-950/45 p-4 backdrop-blur-[1px]"
role="status"
aria-live="polite"
>
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white p-5 text-center shadow-2xl dark:border-[#2b8ffe]/35 dark:bg-[#082653]">
<span className="mx-auto flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Loader2 className="size-5 animate-spin" />
</span>
<p className="mt-3 text-sm font-bold text-slate-900 dark:text-[#e8edf5]">正在驳回产物并同步业务链</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground dark:text-[#8aa0c4]">
正在更新受影响节点的状态,请稍候…
</p>
</div>
</div>
);
}