feat: 支持会商 Markdown 产物提交到任务过程,并补充 Angular 对接文档
8BF/会商工作区可将 Markdown 产物一键提交到任务过程报告,后端记录提交状态以便跨用户与刷新后保持已提交;同时为 Angular 智能体对话对接文档补上前后端参考代码索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
33b0af333c
commit
dcfaa199bf
19 files changed
+1133
-43
No files matched your search
@@ -154,6 +154,7 @@ export function ArtifactFileDetail({
|
||||
hideFileName = false,
|
||||
onDownloadClick,
|
||||
toolbarActions,
|
||||
headerLeadingAction,
|
||||
researchSessionId,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -189,6 +190,8 @@ export function ArtifactFileDetail({
|
||||
* 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。
|
||||
*/
|
||||
toolbarActions?: ReactNode;
|
||||
/** Replaces the left file-type badge for an embedded workspace action. */
|
||||
headerLeadingAction?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
@@ -1203,9 +1206,11 @@ export function ArtifactFileDetail({
|
||||
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
|
||||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||||
{fileTypeLabel}
|
||||
</span>
|
||||
{headerLeadingAction ?? (
|
||||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||||
{fileTypeLabel}
|
||||
</span>
|
||||
)}
|
||||
{!hideFileName && (
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
||||
|
||||
@@ -73,6 +73,8 @@ export function createEightBfFallbackTask(taskId: string): EightBfTask {
|
||||
}
|
||||
|
||||
const TASK_DETAIL_PATH = "/task/{taskId}";
|
||||
const PROCESS_REPORT_ADD_PATH = "/task/process-report/add";
|
||||
const PROCESS_REPORT_PATH = "/task/process-report/{id}";
|
||||
const DICTIONARY_TYPES = {
|
||||
priority: "task_priority",
|
||||
direction: "task_direction",
|
||||
@@ -94,6 +96,21 @@ function externalUrl(path: string): string {
|
||||
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export interface EightBfTaskProcessReportInput {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
content: string;
|
||||
/** 与岗位会商一致:测试分析岗的过程产物位置为 0。 */
|
||||
position: number;
|
||||
reportName: string;
|
||||
submitStatus: 0 | 1;
|
||||
}
|
||||
|
||||
export interface EightBfTaskProcessReportReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||
@@ -122,6 +139,66 @@ async function fetchExternalTaskSystem(
|
||||
});
|
||||
}
|
||||
|
||||
async function externalTaskError(response: Response, fallback: string): Promise<Error> {
|
||||
const text = await response.text().catch(() => "");
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: unknown; msg?: unknown; detail?: unknown };
|
||||
const detail = firstText(payload.message, payload.msg, payload.detail);
|
||||
if (detail) return new Error(`${fallback}(${response.status}):${detail}`);
|
||||
} catch {
|
||||
// Keep the HTTP status when this deployment returns a non-JSON error body.
|
||||
}
|
||||
return new Error(`${fallback}(${response.status})`);
|
||||
}
|
||||
|
||||
function findProcessReportId(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
const source = payload as Record<string, unknown>;
|
||||
const direct = firstText(
|
||||
source.id,
|
||||
source.processReportId,
|
||||
source.process_report_id,
|
||||
source.reportId,
|
||||
);
|
||||
if (direct) return direct;
|
||||
return findProcessReportId(source.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the external task system's process report in its unsubmitted state.
|
||||
* The local gateway stores the returned id so a refresh can later submit the
|
||||
* same report instead of creating a duplicate one.
|
||||
*/
|
||||
export async function createEightBfTaskProcessReport(
|
||||
input: EightBfTaskProcessReportInput,
|
||||
): Promise<EightBfTaskProcessReportReceipt> {
|
||||
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
|
||||
const id = findProcessReportId(await response.json().catch(() => null));
|
||||
if (!id) throw new Error("过程产物登记成功,但接口未返回报告 ID");
|
||||
return { id };
|
||||
}
|
||||
|
||||
/** Submit an already-registered process report to the external task system. */
|
||||
export async function updateEightBfTaskProcessReport(
|
||||
reportId: string,
|
||||
input: EightBfTaskProcessReportInput,
|
||||
): Promise<void> {
|
||||
const response = await fetchExternalTaskSystem(
|
||||
PROCESS_REPORT_PATH.replace("{id}", encodeURIComponent(reportId)),
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
|
||||
}
|
||||
|
||||
function firstText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
@@ -15,9 +15,12 @@ import {
|
||||
fetchEightBfTask,
|
||||
fetchEightBfTaskDictionaries,
|
||||
normalizeEightBfTask,
|
||||
createEightBfTaskProcessReport,
|
||||
updateEightBfTaskProcessReport,
|
||||
type EightBfTask,
|
||||
type EightBfTaskDictionaries,
|
||||
} from "@/eightbf-roundtable/api/task-context";
|
||||
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
|
||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||
import { listChains } from "@/roundtable-planning/api/chains";
|
||||
@@ -79,6 +82,15 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||
[task, taskDictionaries],
|
||||
);
|
||||
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
|
||||
() => taskId ? {
|
||||
taskId,
|
||||
position: 0,
|
||||
createProcessReport: createEightBfTaskProcessReport,
|
||||
updateProcessReport: updateEightBfTaskProcessReport,
|
||||
} : undefined,
|
||||
[taskId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
@@ -305,6 +317,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
/>
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface RoundtableArtifactReference {
|
||||
sessionId?: string | null;
|
||||
threadId: string;
|
||||
nodeKey?: string | null;
|
||||
sourceKey: string;
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType?: string | null;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface RoundtableArtifactSubmissionRecord {
|
||||
id: string;
|
||||
taskId: string;
|
||||
version: number;
|
||||
submitted: boolean;
|
||||
submittedAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
taskDeliveryId?: string | null;
|
||||
artifact: RoundtableArtifactReference;
|
||||
}
|
||||
|
||||
function endpoint(taskId: string): string {
|
||||
return `${getBackendBaseURL()}/api/roundtable/tasks/${encodeURIComponent(taskId)}/artifacts`;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string): Promise<Error> {
|
||||
const body = await response.text().catch(() => "");
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { detail?: unknown; message?: unknown };
|
||||
const detail = typeof parsed.detail === "string"
|
||||
? parsed.detail
|
||||
: typeof parsed.message === "string"
|
||||
? parsed.message
|
||||
: "";
|
||||
if (detail) return new Error(detail);
|
||||
} catch {
|
||||
// The status fallback below is more useful than an opaque HTML response.
|
||||
}
|
||||
return new Error(`${fallback}(${response.status})`);
|
||||
}
|
||||
|
||||
export async function listRoundtableArtifactSubmissions(
|
||||
taskId: string,
|
||||
submitted: boolean,
|
||||
): Promise<RoundtableArtifactSubmissionRecord[]> {
|
||||
const response = await apiFetch(`${endpoint(taskId)}?submitted=${submitted}`, { method: "GET" });
|
||||
if (!response.ok) throw await readError(response, "读取产物提交状态失败");
|
||||
const payload = await response.json() as { items?: RoundtableArtifactSubmissionRecord[] };
|
||||
return Array.isArray(payload.items) ? payload.items : [];
|
||||
}
|
||||
|
||||
export async function saveRoundtableArtifactSubmission(
|
||||
taskId: string,
|
||||
body: {
|
||||
submitted: boolean;
|
||||
registerOnly?: boolean;
|
||||
submissionId?: string;
|
||||
taskDeliveryId?: string;
|
||||
expectedVersion?: number;
|
||||
artifact: RoundtableArtifactReference;
|
||||
},
|
||||
): Promise<RoundtableArtifactSubmissionRecord> {
|
||||
const response = await apiFetch(`${endpoint(taskId)}/submission`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ taskId, ...body }),
|
||||
});
|
||||
if (!response.ok) throw await readError(response, "保存产物提交状态失败");
|
||||
return response.json() as Promise<RoundtableArtifactSubmissionRecord>;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ import type {
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
/** Display name of the seat (e.g. "情报收集"). */
|
||||
@@ -93,6 +95,7 @@ interface HighFidelityReportProps {
|
||||
onReset: () => void;
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
seatStubMessages: Record<string, unknown[]>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
interface SeatArtifacts {
|
||||
@@ -139,6 +142,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
taskArtifactSubmission,
|
||||
}) => {
|
||||
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
||||
const consensusDocTitle = useMemo(() => {
|
||||
@@ -445,6 +449,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
savedSummary={savedSummary}
|
||||
summary={summaryFlow}
|
||||
onViewStatic={() => setSummaryMode(false)}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -467,6 +472,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
report={reportFlow}
|
||||
dashboard={dashboardFlow}
|
||||
onBack={() => setDrawMode(false)}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -917,6 +923,27 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5 text-slate-300 transition group-hover:text-primary dark:text-muted-foreground" />
|
||||
{seat.threadId && (
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.key,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.key,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -1011,6 +1038,23 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
threadId={fileSandboxTarget.threadId}
|
||||
onClose={handleCloseFileSandbox}
|
||||
allowVirtualMarkdownEdit
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}) ?? {
|
||||
threadId: fileSandboxTarget.threadId,
|
||||
path: fileSandboxTarget.file.path,
|
||||
name: fileSandboxTarget.file.name,
|
||||
mimeType: fileSandboxTarget.file.mimeType,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
FileIcon,
|
||||
@@ -50,6 +50,7 @@ import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpe
|
||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import {
|
||||
MarkdownContent,
|
||||
@@ -87,6 +88,7 @@ import { desensitize } from "@/lib/desensitize";
|
||||
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
@@ -160,6 +162,8 @@ export interface Step2PanelProps {
|
||||
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
|
||||
isViewingHistory?: boolean;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
/** 任务深链页面注入的外部过程产物提交控制器。 */
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2Panel(props: Step2PanelProps) {
|
||||
@@ -207,6 +211,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
onBackgroundSuspend,
|
||||
isViewingHistory,
|
||||
onTriggerToast,
|
||||
taskArtifactSubmission,
|
||||
} = props;
|
||||
|
||||
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -592,6 +597,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
@@ -648,6 +654,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -808,6 +815,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
activeThreadId={activeArtifactThreadId}
|
||||
isStreaming={isSandboxStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
|
||||
</Step2SandboxLayout>
|
||||
@@ -950,6 +958,7 @@ export interface Step2MessageProps {
|
||||
) => void;
|
||||
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||
onFileCardClick?: (path: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2Message({
|
||||
@@ -960,6 +969,7 @@ export function Step2Message({
|
||||
resolveAgentName,
|
||||
onArtifactStepClick,
|
||||
onFileCardClick,
|
||||
taskArtifactSubmission,
|
||||
}: Step2MessageProps) {
|
||||
const isInjected = msg.sender === "用户 (插话干预)";
|
||||
const isStreaming = msg.streaming === true;
|
||||
@@ -1038,6 +1048,8 @@ export function Step2Message({
|
||||
files={presentedFiles}
|
||||
onClick={onFileCardClick}
|
||||
disabled={!msg.agentThreadId}
|
||||
threadId={msg.agentThreadId}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1103,10 +1115,14 @@ function PresentFilesRow({
|
||||
files,
|
||||
onClick,
|
||||
disabled,
|
||||
threadId,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
files: string[];
|
||||
onClick?: (path: string) => void;
|
||||
disabled: boolean;
|
||||
threadId?: string;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
@@ -1116,6 +1132,12 @@ function PresentFilesRow({
|
||||
path={path}
|
||||
disabled={disabled || !onClick}
|
||||
onClick={() => onClick?.(path)}
|
||||
submitButton={threadId ? (
|
||||
<TaskArtifactSubmitButton
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({ threadId, path }) ?? { threadId, path }}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1149,37 +1171,42 @@ function PresentFileCard({
|
||||
path,
|
||||
disabled,
|
||||
onClick,
|
||||
submitButton,
|
||||
}: {
|
||||
path: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
submitButton?: ReactNode;
|
||||
}) {
|
||||
const filename = path.split("/").pop() ?? path;
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="flex min-w-0 flex-col gap-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
|
||||
</div>
|
||||
</button>
|
||||
{submitButton && <div className="mt-1 flex justify-end">{submitButton}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
@@ -48,6 +50,8 @@ interface Step2SandboxLayoutProps {
|
||||
* 左侧消息列表保持可见;置空即恢复沙箱(文件预览/列表)。
|
||||
*/
|
||||
sidePanel?: React.ReactNode;
|
||||
/** 任务深链页面的 Markdown 过程产物提交能力。 */
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step2SandboxLayout({
|
||||
@@ -58,6 +62,7 @@ export function Step2SandboxLayout({
|
||||
artifactsFill = false,
|
||||
onArtifactsClose,
|
||||
sidePanel,
|
||||
taskArtifactSubmission,
|
||||
}: Step2SandboxLayoutProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const {
|
||||
@@ -118,6 +123,16 @@ export function Step2SandboxLayout({
|
||||
allowVirtualMarkdownEdit
|
||||
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
|
||||
disableDesensitize
|
||||
headerLeadingAction={(
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: previewThreadId,
|
||||
path: selectedArtifact,
|
||||
}) ?? { threadId: previewThreadId, path: selectedArtifact }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import { stripReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import { useMemo } from "react";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
export interface Step3PanelProps {
|
||||
threadIds: ThreadIdMap | null;
|
||||
@@ -54,6 +55,7 @@ export interface Step3PanelProps {
|
||||
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
seatStubMessages: Record<string, unknown[]>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
export function Step3Panel(props: Step3PanelProps) {
|
||||
@@ -80,6 +82,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
taskArtifactSubmission,
|
||||
} = props;
|
||||
|
||||
const seatDeliveries = useMemo(
|
||||
@@ -127,6 +130,7 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
onReset={onReset}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
seatStubMessages={seatStubMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
@@ -57,6 +58,7 @@ import type {
|
||||
ReportIntent,
|
||||
UseStep3ReportReturn,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
@@ -88,6 +90,7 @@ interface Step3ReportModeProps {
|
||||
/** 页面层常驻的大屏多页流(模式二)。 */
|
||||
dashboard: UseStep3DashboardReturn;
|
||||
onBack: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
@@ -108,6 +111,7 @@ export function Step3ReportMode({
|
||||
report,
|
||||
dashboard,
|
||||
onBack,
|
||||
taskArtifactSubmission,
|
||||
}: Step3ReportModeProps) {
|
||||
const reportSeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
@@ -376,6 +380,7 @@ export function Step3ReportMode({
|
||||
activeThreadId={activeArtifactThreadId ?? reportThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
@@ -390,6 +395,7 @@ export function Step3ReportMode({
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
@@ -428,6 +434,7 @@ export function Step3ReportMode({
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -513,6 +520,7 @@ function AgentProgressPanel({
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
phase: UseStep3ReportReturn["phase"];
|
||||
dialogues: UseStep3ReportReturn["dialogues"];
|
||||
@@ -526,6 +534,7 @@ function AgentProgressPanel({
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
|
||||
@@ -610,6 +619,7 @@ function AgentProgressPanel({
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -822,11 +832,13 @@ function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
taskArtifactSubmission,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
@@ -932,6 +944,25 @@ function SeatFilesModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
@@ -61,6 +62,7 @@ import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
@@ -88,6 +90,7 @@ interface Step3SummaryModeProps {
|
||||
summary: UseStep3SummaryReturn;
|
||||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||||
onViewStatic: () => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
@@ -108,6 +111,7 @@ export function Step3SummaryMode({
|
||||
savedSummary,
|
||||
summary,
|
||||
onViewStatic,
|
||||
taskArtifactSubmission,
|
||||
}: Step3SummaryModeProps) {
|
||||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||||
|
||||
@@ -389,6 +393,7 @@ export function Step3SummaryMode({
|
||||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
sidePanel={
|
||||
flowGraph && flowView ? (
|
||||
<FlowGraphPanel
|
||||
@@ -435,6 +440,7 @@ export function Step3SummaryMode({
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
|
||||
@@ -491,6 +497,7 @@ export function Step3SummaryMode({
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -589,6 +596,7 @@ function AgentProgressPanel({
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
taskArtifactSubmission,
|
||||
}: {
|
||||
phase: UseStep3SummaryReturn["phase"];
|
||||
dialogues: UseStep3SummaryReturn["dialogues"];
|
||||
@@ -622,6 +630,7 @@ function AgentProgressPanel({
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
@@ -726,6 +735,7 @@ function AgentProgressPanel({
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1106,11 +1116,13 @@ function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
taskArtifactSubmission,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
taskArtifactSubmission?: TaskArtifactSubmissionController;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
@@ -1216,6 +1228,25 @@ function SeatFilesModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<TaskArtifactSubmitButton
|
||||
compact
|
||||
controller={taskArtifactSubmission}
|
||||
target={taskArtifactSubmission?.targetFor({
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}) ?? {
|
||||
threadId: seat.threadId,
|
||||
path: f.path,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
agentId: seat.agentName,
|
||||
agentName: seat.displayName,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Check, Loader2, UploadCloud } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskArtifactSubmissionController, TaskArtifactTarget } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
export function TaskArtifactSubmitButton({
|
||||
controller,
|
||||
target,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
controller?: TaskArtifactSubmissionController;
|
||||
target: TaskArtifactTarget;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!controller?.enabled || !controller.isMarkdown(target)) return null;
|
||||
const state = controller.stateFor(target);
|
||||
const error = controller.errorFor(target);
|
||||
const isSubmitted = state === "submitted";
|
||||
const isSubmitting = state === "submitting";
|
||||
const label = isSubmitted ? "已提交" : isSubmitting ? "提交中…" : "提交";
|
||||
const title = error || (isSubmitted ? "该 Markdown 产物已提交到任务过程" : "提交 Markdown 过程产物");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSubmitting || isSubmitted}
|
||||
aria-label={`${target.name || target.path}:${label}`}
|
||||
title={title}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void controller.submit(target).catch(() => undefined);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center gap-1 rounded-md border px-2 py-1 text-[10px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 disabled:cursor-not-allowed",
|
||||
isSubmitted
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: error
|
||||
? "border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
: "border-primary/35 bg-primary/10 text-primary hover:bg-primary/15",
|
||||
compact && "h-7 px-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : isSubmitted ? <Check className="size-3" /> : <UploadCloud className="size-3" />}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import {
|
||||
listRoundtableArtifactSubmissions,
|
||||
saveRoundtableArtifactSubmission,
|
||||
type RoundtableArtifactReference,
|
||||
type RoundtableArtifactSubmissionRecord,
|
||||
} from "@/roundtable-planning/api/artifact-submissions";
|
||||
import { buildArtifactDownloadUrl, type ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import type {
|
||||
TaskArtifactSubmissionConfig,
|
||||
TaskArtifactSubmissionController,
|
||||
TaskArtifactSubmissionState,
|
||||
TaskArtifactTarget,
|
||||
TaskProcessReportPayload,
|
||||
} from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
type StatusEntry = {
|
||||
state: TaskArtifactSubmissionState;
|
||||
error: string | null;
|
||||
record?: RoundtableArtifactSubmissionRecord;
|
||||
};
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed.startsWith("write-file:")) return trimmed;
|
||||
try {
|
||||
return decodeURIComponent(new URL(trimmed).pathname).trim();
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
const normalized = normalizedPath(path);
|
||||
return normalized.split("/").filter(Boolean).pop() || normalized || "产物.md";
|
||||
}
|
||||
|
||||
function isMarkdownTarget(target: TaskArtifactTarget): boolean {
|
||||
return /\.(md|markdown)$/i.test(normalizedPath(target.path))
|
||||
|| target.mimeType?.toLowerCase().includes("markdown") === true;
|
||||
}
|
||||
|
||||
function recordKey(target: TaskArtifactTarget): string {
|
||||
const source = target.agentId?.trim() || `thread:${target.threadId}`;
|
||||
return `${source}\u001f${normalizedPath(target.path)}\u001f0`;
|
||||
}
|
||||
|
||||
function payloadFrom(
|
||||
config: TaskArtifactSubmissionConfig,
|
||||
target: TaskArtifactTarget,
|
||||
content: string,
|
||||
submitStatus: 0 | 1,
|
||||
): TaskProcessReportPayload {
|
||||
const name = target.name?.trim() || fileName(target.path);
|
||||
const agentId = target.agentId?.trim() || target.threadId;
|
||||
return {
|
||||
taskId: config.taskId,
|
||||
agentId,
|
||||
agentName: target.agentName?.trim() || agentId,
|
||||
content,
|
||||
position: config.position,
|
||||
reportName: name,
|
||||
submitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the task system's process-report API and DeerFlow's task-scoped
|
||||
* submission ledger. The ledger is read on mount, so "已提交" survives reloads
|
||||
* and is shared by all users opening the same task.
|
||||
*/
|
||||
export function useTaskArtifactSubmission(
|
||||
config: TaskArtifactSubmissionConfig | undefined,
|
||||
threadIds: ThreadIdMap | null,
|
||||
agentNameToRole: Record<string, { displayName: string }> | undefined,
|
||||
): TaskArtifactSubmissionController | undefined {
|
||||
const [entries, setEntries] = useState<Record<string, StatusEntry>>({});
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const pendingRef = useRef(new Map<string, Promise<RoundtableArtifactSubmissionRecord | null>>());
|
||||
|
||||
const resolveTarget = useCallback((target: TaskArtifactTarget): TaskArtifactTarget => {
|
||||
const normalized = normalizedPath(target.path);
|
||||
const matchedAgentId = target.agentId || Object.entries(threadIds ?? {})
|
||||
.find(([, threadId]) => threadId === target.threadId)?.[0];
|
||||
const role = matchedAgentId ? agentNameToRole?.[matchedAgentId.toLowerCase()] : undefined;
|
||||
return {
|
||||
...target,
|
||||
path: normalized,
|
||||
name: target.name || fileName(normalized),
|
||||
mimeType: target.mimeType || (isMarkdownTarget({ ...target, path: normalized }) ? "text/markdown" : null),
|
||||
agentId: matchedAgentId || target.threadId,
|
||||
agentName: target.agentName || role?.displayName || matchedAgentId || target.threadId,
|
||||
};
|
||||
}, [agentNameToRole, threadIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.taskId) {
|
||||
setEntries({});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
listRoundtableArtifactSubmissions(config.taskId, true),
|
||||
listRoundtableArtifactSubmissions(config.taskId, false),
|
||||
]).then(([submitted, registered]) => {
|
||||
if (cancelled) return;
|
||||
const next: Record<string, StatusEntry> = {};
|
||||
for (const record of [...submitted, ...registered]) {
|
||||
const key = `${record.artifact.sourceKey}\u001f${normalizedPath(record.artifact.path)}\u001f${record.artifact.revision}`;
|
||||
next[key] = {
|
||||
state: record.submitted ? "submitted" : "idle",
|
||||
error: null,
|
||||
record,
|
||||
};
|
||||
}
|
||||
setEntries(next);
|
||||
}).catch((error) => {
|
||||
// State recovery is additive. A transient read failure must not prevent a
|
||||
// user from submitting a Markdown artifact from the current session.
|
||||
console.warn("[roundtable] 读取任务产物提交状态失败", error);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [config?.taskId]);
|
||||
|
||||
const submit = useCallback(async (rawTarget: TaskArtifactTarget) => {
|
||||
if (!config || !isMarkdownTarget(rawTarget)) return null;
|
||||
const target = resolveTarget(rawTarget);
|
||||
const key = recordKey(target);
|
||||
const existingPending = pendingRef.current.get(key);
|
||||
if (existingPending) return existingPending;
|
||||
const existing = entriesRef.current[key];
|
||||
if (existing?.state === "submitted" && existing.record) return existing.record;
|
||||
|
||||
const operation = (async () => {
|
||||
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "submitting", error: null } }));
|
||||
try {
|
||||
let content = target.content;
|
||||
if (content == null) {
|
||||
const response = await apiFetch(buildArtifactDownloadUrl(target.threadId, target.path), { method: "GET" });
|
||||
if (!response.ok) throw new Error(`读取 Markdown 产物失败(${response.status})`);
|
||||
content = await response.text();
|
||||
}
|
||||
|
||||
let record = entriesRef.current[key]?.record;
|
||||
let reportId = record?.taskDeliveryId || undefined;
|
||||
if (!reportId) {
|
||||
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
|
||||
reportId = receipt.id;
|
||||
const artifact: RoundtableArtifactReference = {
|
||||
threadId: target.threadId,
|
||||
sourceKey: target.agentId || `thread:${target.threadId}`,
|
||||
path: target.path,
|
||||
name: target.name || fileName(target.path),
|
||||
mimeType: target.mimeType || "text/markdown",
|
||||
revision: 0,
|
||||
};
|
||||
record = await saveRoundtableArtifactSubmission(config.taskId, {
|
||||
submitted: false,
|
||||
registerOnly: true,
|
||||
taskDeliveryId: reportId,
|
||||
artifact,
|
||||
});
|
||||
}
|
||||
|
||||
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
|
||||
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
|
||||
submitted: true,
|
||||
taskDeliveryId: reportId,
|
||||
expectedVersion: record?.version,
|
||||
artifact: {
|
||||
threadId: target.threadId,
|
||||
sourceKey: target.agentId || `thread:${target.threadId}`,
|
||||
path: target.path,
|
||||
name: target.name || fileName(target.path),
|
||||
mimeType: target.mimeType || "text/markdown",
|
||||
revision: 0,
|
||||
},
|
||||
});
|
||||
setEntries((current) => ({ ...current, [key]: { state: "submitted", error: null, record: submitted } }));
|
||||
return submitted;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "提交产物失败,请重试";
|
||||
setEntries((current) => ({ ...current, [key]: { ...current[key], state: "error", error: message } }));
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRef.current.delete(key);
|
||||
}
|
||||
})();
|
||||
pendingRef.current.set(key, operation);
|
||||
return operation;
|
||||
}, [config, resolveTarget]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
return {
|
||||
enabled: true,
|
||||
isMarkdown: isMarkdownTarget,
|
||||
targetFor: resolveTarget,
|
||||
stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle",
|
||||
errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null,
|
||||
submit,
|
||||
} satisfies TaskArtifactSubmissionController;
|
||||
}, [config, entries, resolveTarget, submit]);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions";
|
||||
|
||||
export interface TaskProcessReportPayload {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
content: string;
|
||||
position: number;
|
||||
reportName: string;
|
||||
submitStatus: 0 | 1;
|
||||
}
|
||||
|
||||
export interface TaskArtifactSubmissionConfig {
|
||||
/** External task-system id, not the local roundtable draft storage key. */
|
||||
taskId: string;
|
||||
position: number;
|
||||
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
|
||||
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TaskArtifactTarget {
|
||||
threadId: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
mimeType?: string | null;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export type TaskArtifactSubmissionState = "idle" | "submitting" | "submitted" | "error";
|
||||
|
||||
export interface TaskArtifactSubmissionController {
|
||||
enabled: boolean;
|
||||
isMarkdown: (target: TaskArtifactTarget) => boolean;
|
||||
targetFor: (target: TaskArtifactTarget) => TaskArtifactTarget;
|
||||
stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState;
|
||||
errorFor: (target: TaskArtifactTarget) => string | null;
|
||||
submit: (target: TaskArtifactTarget) => Promise<RoundtableArtifactSubmissionRecord | null>;
|
||||
}
|
||||
@@ -152,6 +152,8 @@ import { listAgents } from "@/core/agents";
|
||||
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
|
||||
import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core/embed/wujie";
|
||||
import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables";
|
||||
import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission";
|
||||
import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission";
|
||||
|
||||
/**
|
||||
* 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏
|
||||
@@ -324,6 +326,8 @@ export default function RoundtablePlanningPage({
|
||||
newTaskLabel,
|
||||
/** 独立业务页可隐藏通用的设置入口。 */
|
||||
hideHeaderSettings = false,
|
||||
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
|
||||
taskArtifactSubmissionConfig,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
lockedChainId?: string | null;
|
||||
@@ -351,6 +355,7 @@ export default function RoundtablePlanningPage({
|
||||
hideHeaderSaveDraft?: boolean;
|
||||
newTaskLabel?: string;
|
||||
hideHeaderSettings?: boolean;
|
||||
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
@@ -718,6 +723,12 @@ export default function RoundtablePlanningPage({
|
||||
taskContext,
|
||||
});
|
||||
|
||||
const taskArtifactSubmission = useTaskArtifactSubmission(
|
||||
taskArtifactSubmissionConfig,
|
||||
step2.threadIds,
|
||||
step2.agentNameToRole,
|
||||
);
|
||||
|
||||
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
|
||||
useEffect(() => {
|
||||
step1StateRef.current = {
|
||||
@@ -2460,6 +2471,7 @@ export default function RoundtablePlanningPage({
|
||||
onBackgroundSuspend={handleBackgroundSuspend}
|
||||
isViewingHistory={drafts.loadedFromDraftRef.current}
|
||||
onTriggerToast={triggerToast}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2489,6 +2501,7 @@ export default function RoundtablePlanningPage({
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
taskArtifactSubmission={taskArtifactSubmission}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user