代码修改
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>
|
||||
);
|
||||
}
|
||||
@@ -403,12 +403,23 @@ and direct-agent node indexes in `position_roundtable_nodes` (migration
|
||||
`20260721_01`). Message history remains in the established LangGraph thread
|
||||
store; a node completion unlocks its next chain stage and a later upstream
|
||||
revision marks downstream records `stale` for refresh.
|
||||
`POST /api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` uses the
|
||||
same cascade: its reason is intentionally optional, the selected node becomes
|
||||
`rejected`, and downstream material becomes `stale` (or `locked` when no
|
||||
material exists) with `invalidated_by` set. Clients must replace their complete
|
||||
session snapshot from this response so the flow, delivery list, and active
|
||||
node state stay consistent. A rejected/stale node returns to `done` only after
|
||||
a fresh Markdown delivery is recorded.
|
||||
For direct-agent handoff, the router resolves artifact paths only inside the
|
||||
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
|
||||
text outputs; binary artifacts stay as metadata-only references.
|
||||
`POST /sessions/{id}/archive` turns a session into a read-only historical
|
||||
record: task/intent updates, node binding, hidden-context construction, and
|
||||
turn completion are rejected, while read endpoints remain available.
|
||||
Built-in summary and action-plan completion also preserves a visible report
|
||||
answer when the model misses its final `write_file` call: the router writes
|
||||
that exact answer as the required Markdown artifact in the owning thread's
|
||||
`outputs/` directory, so users receive both chat text and a downloadable file.
|
||||
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
| `POST /api/memory/reload` | Force memory reload |
|
||||
| `GET /api/memory/config` | Memory configuration |
|
||||
|
||||
@@ -28,6 +28,10 @@ _MAX_INLINE_ARTIFACT_CHARS = 1_800
|
||||
_BUILTIN_MATERIAL_BUDGET = 24_000
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
ACTION_PLAN_AGENT_ID = "position-action-planner"
|
||||
_BUILTIN_MARKDOWN_OUTPUTS = {
|
||||
"summary": "mnt/user-data/outputs/岗位会商方案总结报告.md",
|
||||
"action_plan": "mnt/user-data/outputs/岗位会商行动规划.md",
|
||||
}
|
||||
_TEXT_ARTIFACT_SUFFIXES = {
|
||||
".csv",
|
||||
".json",
|
||||
@@ -113,8 +117,11 @@ class CompleteTurnRequest(BaseModel):
|
||||
|
||||
|
||||
class RejectNodeRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=120)
|
||||
reason: str = Field(min_length=1, max_length=5_000)
|
||||
# The UI only asks for an optional rejection reason. Keep ``title`` for
|
||||
# compatible clients and for the audit record, but provide a stable
|
||||
# default so neither field turns a valid rejection into a 422/400.
|
||||
title: str = Field(default="产物驳回", max_length=120)
|
||||
reason: str = Field(default="", max_length=5_000)
|
||||
|
||||
|
||||
class PrepareBuiltinRunRequest(BaseModel):
|
||||
@@ -731,6 +738,45 @@ def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, A
|
||||
return refs
|
||||
|
||||
|
||||
async def _write_builtin_markdown_fallback(
|
||||
*, thread_id: str, kind: str, content: str
|
||||
) -> dict[str, Any]:
|
||||
"""Materialize a built-in agent's visible answer when it skipped ``write_file``.
|
||||
|
||||
Position roundtable runs are direct chat streams. A model can therefore
|
||||
finish with a complete visible report but still omit its required artifact
|
||||
tool call. Keep the chat answer as the source of truth and mirror it into
|
||||
the owning thread's outputs directory so the report remains previewable and
|
||||
downloadable after the stream has ended.
|
||||
"""
|
||||
virtual_path = _BUILTIN_MARKDOWN_OUTPUTS.get(kind)
|
||||
report = content.strip()
|
||||
if virtual_path is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown built-in delivery kind: {kind}")
|
||||
if not report:
|
||||
raise HTTPException(status_code=422, detail="内置智能体未返回可保存为报告的正文")
|
||||
|
||||
try:
|
||||
output_path = await aresolve_thread_virtual_path(thread_id, virtual_path)
|
||||
await asyncio.to_thread(output_path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(
|
||||
output_path.write_text,
|
||||
report if report.endswith("\n") else f"{report}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
stat = await asyncio.to_thread(output_path.stat)
|
||||
except OSError as error:
|
||||
raise HTTPException(status_code=500, detail="无法保存内置智能体 Markdown 报告") from error
|
||||
|
||||
return {
|
||||
"name": output_path.name,
|
||||
"path": virtual_path,
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"size_bytes": stat.st_size,
|
||||
}
|
||||
|
||||
|
||||
async def _effective_delivery_material(
|
||||
session: dict[str, Any], nodes: list[dict[str, Any]]
|
||||
) -> str:
|
||||
@@ -1109,10 +1155,8 @@ async def complete_node_turn(
|
||||
async def reject_node(
|
||||
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
|
||||
) -> SessionDetailResponse:
|
||||
title = body.title.strip()
|
||||
title = body.title.strip() or "产物驳回"
|
||||
reason = body.reason.strip()
|
||||
if not title or not reason:
|
||||
raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
current = await store.get_node(session_id, node_key, user_id)
|
||||
if current is None:
|
||||
@@ -1199,7 +1243,17 @@ async def _complete_builtin_run(
|
||||
artifacts = _artifact_manifest_refs(body.artifact_manifest)
|
||||
markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
|
||||
if not markdown:
|
||||
raise HTTPException(status_code=422, detail="内置智能体必须交付 outputs 目录下的一份 Markdown 报告文档")
|
||||
# The report text is already visible in the chat stream. Do not lose
|
||||
# that successful result just because the model skipped its final
|
||||
# write_file call: mirror the exact response into the thread outputs
|
||||
# directory and persist it as the normal report artifact.
|
||||
fallback_markdown = await _write_builtin_markdown_fallback(
|
||||
thread_id=body.thread_id,
|
||||
kind=kind,
|
||||
content=body.content,
|
||||
)
|
||||
artifacts.append(fallback_markdown)
|
||||
markdown = [fallback_markdown]
|
||||
if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
|
||||
raise HTTPException(status_code=422, detail="行动规划智能体还需交付 action-plan-subtasks.json 子任务清单")
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ async def store(tmp_path):
|
||||
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
|
||||
|
||||
def test_reject_node_request_allows_an_optional_reason():
|
||||
"""A rejection must work even when the reviewer leaves the reason blank."""
|
||||
request = position_roundtable_router.RejectNodeRequest()
|
||||
|
||||
assert request.title == "产物驳回"
|
||||
assert request.reason == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
created = await store.create_session(
|
||||
@@ -186,6 +194,122 @@ def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_markdown_fallback_writes_visible_summary_to_thread_outputs(tmp_path, monkeypatch):
|
||||
target = tmp_path / "outputs" / "岗位会商方案总结报告.md"
|
||||
|
||||
async def resolve(thread_id: str, path: str):
|
||||
assert thread_id == "summary-thread"
|
||||
assert path == "mnt/user-data/outputs/岗位会商方案总结报告.md"
|
||||
return target
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
|
||||
|
||||
artifact = await position_roundtable_router._write_builtin_markdown_fallback(
|
||||
thread_id="summary-thread",
|
||||
kind="summary",
|
||||
content="# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。",
|
||||
)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "# 岗位会商方案总结报告\n\n这是对话中生成的完整报告正文。\n"
|
||||
assert artifact["name"] == "岗位会商方案总结报告.md"
|
||||
assert artifact["path"] == "mnt/user-data/outputs/岗位会商方案总结报告.md"
|
||||
assert artifact["mime_type"] == "text/markdown"
|
||||
assert artifact["size_bytes"] == target.stat().st_size
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_skips_write_file(monkeypatch):
|
||||
node = {
|
||||
"id": "node-1",
|
||||
"session_id": "session-a",
|
||||
"node_key": "intent-node",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "intent-agent",
|
||||
"position_id": "intelligence",
|
||||
"thread_id": "intent-thread",
|
||||
"status": "done",
|
||||
"latest_answer": "已确认任务意图",
|
||||
"artifact_manifest": [],
|
||||
"revision": 1,
|
||||
"upstream_revision": None,
|
||||
"rejection_count": 0,
|
||||
"last_rejection": None,
|
||||
"invalidated_by": None,
|
||||
}
|
||||
session = {
|
||||
"id": "session-a",
|
||||
"status": "active",
|
||||
"task_snapshot": {},
|
||||
"chain_id": "chain-a",
|
||||
"chain_snapshot": {},
|
||||
"intent_thread_id": "intent-thread",
|
||||
"intent_snapshot": {"objective": "形成方案", "constraints": []},
|
||||
"summary_thread_id": None,
|
||||
"summary_snapshot": None,
|
||||
"action_plan_thread_id": None,
|
||||
"action_plan_snapshot": None,
|
||||
}
|
||||
|
||||
class StubStore:
|
||||
updates: dict | None = None
|
||||
|
||||
async def get_session(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return session
|
||||
|
||||
async def list_nodes(self, session_id, user_id):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
return [node]
|
||||
|
||||
async def update_session(self, session_id, user_id, **updates):
|
||||
assert (session_id, user_id) == ("session-a", "user-a")
|
||||
self.updates = updates
|
||||
return {**session, **updates}
|
||||
|
||||
store = StubStore()
|
||||
|
||||
async def write_fallback(**kwargs):
|
||||
assert kwargs["thread_id"] == "summary-thread"
|
||||
assert kwargs["kind"] == "summary"
|
||||
assert kwargs["content"] == "# 方案总结\n\n完整报告正文"
|
||||
return {
|
||||
"name": "岗位会商方案总结报告.md",
|
||||
"path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"modified_at": "2026-07-23T00:00:00+00:00",
|
||||
"size_bytes": 42,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(position_roundtable_router, "_write_builtin_markdown_fallback", write_fallback)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=store)),
|
||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||
)
|
||||
body = position_roundtable_router.CompleteBuiltinRunRequest(
|
||||
thread_id="summary-thread",
|
||||
content="# 方案总结\n\n完整报告正文",
|
||||
artifact_manifest=[],
|
||||
source_revisions={"intent-node": 1},
|
||||
)
|
||||
|
||||
result = await position_roundtable_router._complete_builtin_run(
|
||||
request,
|
||||
"session-a",
|
||||
kind="summary",
|
||||
body=body,
|
||||
)
|
||||
|
||||
assert store.updates is not None
|
||||
snapshot = store.updates["summary_snapshot"]
|
||||
assert snapshot["content"] == "# 方案总结\n\n完整报告正文"
|
||||
assert snapshot["primary_artifact"]["name"] == "岗位会商方案总结报告.md"
|
||||
assert result.summary_snapshot["primary_artifact"]["path"] == (
|
||||
"mnt/user-data/outputs/岗位会商方案总结报告.md"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
|
||||
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
|
||||
|
||||
Reference in new issue
Block a user