代码修改
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user