feat(web): asset upload, task run observer, and session lineage on tasks

Upload binary attachments as project assets, subscribe by run_id after refresh,
and pass session_id/project_id through long-task requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:22 +00:00
committed by weichen
co-authored by Cursor
parent e139b2b267
commit bbd1218922
6 changed files with 310 additions and 86 deletions
+176 -84
View File
@@ -17,6 +17,7 @@ import {
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
import { DocumentLineage } from './types/projectWorkflow';
import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner';
@@ -39,6 +40,7 @@ import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher';
import { authClient } from './api/auth';
import { streamChat, mapErrorInfo } from './api/chatStream';
import { streamTask, buildTaskRequest, buildResumeRequest } from './api/taskStream';
import type { StreamEvent } from './api/chatStream';
import { shouldAutoRetryChat, shouldAutoRetryTask, autoRetryDelayMs, fallbackErrorClass } from './api/chatRetry';
import { applyPhaseEvent } from './engine/phaseEventMapper';
import { LoginResult, SystemAuthConfig } from './types/auth';
@@ -121,6 +123,7 @@ import { fetchChatTurnStatus } from './api/chatTurn';
import { createClientTurnId } from './lib/createClientTurnId';
import { mergeSessionUser } from './lib/mergeSessionUser';
import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter';
import { mapServerSessionToProjectSession, mergeSessionLists } from './lib/sessionAdapter';
import { SendMessageResult } from './types/sendMessage';
import { CategoryFilterBar } from './components/history/CategoryFilterBar';
import { HistoryCalendar } from './components/history/HistoryCalendar';
@@ -133,40 +136,50 @@ import { CategoryDock } from './components/chat/CategoryDock';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
import { saveFile, restoreFileRawToDeliverables } from './data/fileStore';
import { uploadProjectAsset } from './api/projectAssets';
import { subscribeTaskRunEvents, createTaskRun, cancelTaskRun } from './api/taskRuns';
/**
* 将用户上传附件列表转换为任务输入材料(TaskRunMaterial[]),
* 文本类格式(.txt .md .csv .json .xml)读取全文后 Base64 编码;
* 其余格式仅传文件名作来源注记,content 为空字符串。
* 将附件转为任务材料:二进制先上传 CAS 登记 asset_id;小文本可 inline Base64。
*/
async function readAttachmentsAsMaterials(
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob }[]
attachments: { name: string; type: string; docId?: string; fileRaw?: File | Blob; assetId?: number }[],
projectId?: number,
userId?: number,
): Promise<TaskRunMaterial[]> {
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
const results: TaskRunMaterial[] = [];
for (const att of attachments) {
if (att.assetId && att.assetId > 0) {
results.push({ filename: att.name, asset_id: att.assetId });
continue;
}
if (!att.fileRaw) continue;
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
let content = '';
if (TEXT_EXTS.has(ext)) {
try {
content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
// btoa 仅支持 Latin1,需先 encodeURIComponent 转义 Unicode
const b64 = btoa(unescape(encodeURIComponent(text)));
resolve(b64);
};
reader.onerror = () => reject(reader.error);
reader.readAsText(att.fileRaw as Blob, 'utf-8');
});
} catch {
content = '';
if (!TEXT_EXTS.has(ext)) {
if (!projectId || projectId <= 0) {
throw new Error(`附件 ${att.name} 需要先登记为项目资产(缺少 project_id)`);
}
const uploaded = await uploadProjectAsset(projectId, att.fileRaw as Blob, att.name, userId);
results.push({ filename: uploaded.filename || att.name, asset_id: uploaded.asset_id });
continue;
}
try {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const b64 = btoa(unescape(encodeURIComponent(text)));
resolve(b64);
};
reader.onerror = () => reject(reader.error);
reader.readAsText(att.fileRaw as Blob, 'utf-8');
});
results.push({ filename: att.name, content });
} catch {
throw new Error(`读取文本附件 ${att.name} 失败`);
}
results.push({ filename: att.name, content });
}
return results;
}
@@ -563,6 +576,7 @@ export default function App() {
const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false);
const [createProjectError, setCreateProjectError] = useState<string | null>(null);
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
const isAdmin = currentUser.roleId === 'role-admin';
@@ -676,6 +690,42 @@ export default function App() {
};
}, [session, allUsers]);
useEffect(() => {
if (!session || !currentUser.id) {
setSessionSyncNotice(null);
return;
}
let cancelled = false;
void (async () => {
try {
const serverSessions = await fetchChatSessions(currentUser.id);
if (cancelled) return;
setSessions((prev) => {
const mapped = serverSessions.map((item) =>
mapServerSessionToProjectSession(
item,
projects,
prev.find((sessionItem) => sessionItem.sessionId === item.session_id)
)
);
return mergeSessionLists(prev, mapped);
});
setSessionSyncNotice(null);
} catch (err) {
if (cancelled) return;
console.warn('[sessions] 服务端会话列表同步失败,继续使用本地缓存:', err);
setSessionSyncNotice(
'会话列表暂用本地缓存:无法连接服务端,新建会话可能无法在其他设备同步。'
);
}
})();
return () => {
cancelled = true;
};
}, [session, currentUser.id, projects]);
const handleLogout = () => {
authClient.logout();
setSession(null);
@@ -747,7 +797,7 @@ export default function App() {
const workflowBanner = (
<>
{projectSyncNotice && (
{(projectSyncNotice || sessionSyncNotice) && (
<div
style={{
padding: '8px 12px',
@@ -759,7 +809,7 @@ export default function App() {
fontSize: '12px',
}}
>
{projectSyncNotice}
{[projectSyncNotice, sessionSyncNotice].filter(Boolean).join(' ')}
</div>
)}
<WorkflowPrerequisiteBanner
@@ -1970,6 +2020,7 @@ export default function App() {
};
const taskStreamAbortRef = useRef<AbortController | null>(null);
const activeTaskRunIdRef = useRef<string | null>(null);
// P2:长任务尝试模式。'run' 新任务 | 'resume' 续跑失败工作区 | 'restart' 按原意图新工作区
const runTaskAttempt = (
@@ -1980,6 +2031,7 @@ export default function App() {
opts?: { phaseEvents?: PhaseEvent[]; workDir?: string }
) => {
taskStreamAbortRef.current?.abort();
activeTaskRunIdRef.current = null;
const controller = new AbortController();
taskStreamAbortRef.current = controller;
@@ -2028,73 +2080,87 @@ export default function App() {
});
setTaskThinking(true);
const body =
mode === 'resume' && opts?.workDir
? buildResumeRequest(task, opts.workDir)
: buildTaskRequest(task);
streamTask(
body,
{
onEvent: (ev) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'phase': {
// live PhaseEventStrip:由真实 phase 事件驱动,禁止假进度
patchAsst((m) => ({
status: 'streaming_status',
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
}));
break;
}
case 'status': {
if (ev.status?.message) {
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
}
break;
}
case 'delta': {
accumulated += ev.delta || '';
patchAsst({ status: 'streaming_status', content: accumulated });
break;
}
case 'deliverable': {
const title = ev.deliverable?.title;
if (title) {
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
patchAsst({ status: 'streaming_status', content: accumulated });
}
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
});
setTaskThinking(false);
break;
}
default:
break;
const onEvent = (ev: StreamEvent) => {
if (controller.signal.aborted || settled) return;
switch (ev.type) {
case 'phase': {
patchAsst((m) => ({
status: 'streaming_status',
livePhaseLabel: ev.phase?.message || m.livePhaseLabel,
phaseEvents: applyPhaseEvent(opts?.phaseEvents || m.phaseEvents || [], ev.phase),
}));
break;
}
case 'status': {
if (ev.status?.message) {
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
}
},
},
controller.signal
).catch((_err) => {
break;
}
case 'delta': {
accumulated += ev.delta || '';
patchAsst({ status: 'streaming_status', content: accumulated });
break;
}
case 'deliverable': {
const title = ev.deliverable?.title;
if (title) {
accumulated += (accumulated ? '\n\n' : '') + `📄 已生成文档:**${title}**`;
patchAsst({ status: 'streaming_status', content: accumulated });
}
break;
}
case 'error': {
failOrRetry(mapErrorInfo(ev));
break;
}
case 'done': {
settled = true;
activeTaskRunIdRef.current = null;
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: ev.done?.workspace_id,
});
setTaskThinking(false);
break;
}
default:
break;
}
};
const startObserver = async () => {
const body =
mode === 'resume' && opts?.workDir
? buildResumeRequest(task, opts.workDir)
: buildTaskRequest(task);
if (task.sessionId && task.projectId && task.projectId > 0 && mode === 'run') {
const created = await createTaskRun({
...body,
session_id: task.sessionId,
project_id: task.projectId,
user_id: task.userId,
client_turn_id: task.clientTurnId,
user_message: task.userMessage,
});
activeTaskRunIdRef.current = created.run_id;
patchAsst({ taskRunId: created.run_id });
await subscribeTaskRunEvents(created.run_id, { onEvent }, controller.signal);
return;
}
await streamTask(body, { onEvent }, controller.signal);
};
void startObserver().catch((_err) => {
if (settled) return;
if (controller.signal.aborted) {
patchAsst({ status: 'cancelled', livePhaseLabel: undefined });
setTaskThinking(false);
return;
}
// fetch/网络层抛出:合成瞬态错误,走与后端 error 事件一致的自动重试路径
failOrRetry(buildNetworkErrorInfo());
});
};
@@ -2139,6 +2205,11 @@ export default function App() {
const handleStopStreaming = () => {
turnRuntime.abortActiveSessionTurns();
taskStreamAbortRef.current?.abort();
const runId = activeTaskRunIdRef.current;
if (runId) {
void cancelTaskRun(runId).catch(() => undefined);
activeTaskRunIdRef.current = null;
}
};
// 发送消息与对话驱动双轨逻辑
@@ -2325,13 +2396,17 @@ export default function App() {
const asstId = 'ast-' + Date.now();
const currentProj = sessions.find((s) => s.sessionId === activeSessionId);
// 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续
const clientTurnId = `turn_${Date.now()}`;
const baseTask: TaskRunContext = {
taskKey: matchedTemplate.taskKey,
projectName: currentProj?.title || '医疗器械研发演示项目',
customerName: '北京水木东方',
deviceType: currentProj?.deviceType || '医疗器械',
sessionId: activeSessionId,
userId: currentUser.id,
projectId: currentProj?.projectId || 1,
clientTurnId,
userMessage: text,
};
const placeholder: ChatMessage = {
id: asstId,
@@ -2347,7 +2422,11 @@ export default function App() {
setMessages((prev) => [...prev, placeholder]);
// 异步读取附件内容后再触发任务,避免文档材料丢失
void (async () => {
const materials = await readAttachmentsAsMaterials(processedAttachments || []);
const materials = await readAttachmentsAsMaterials(
processedAttachments || [],
baseTask.projectId,
baseTask.userId,
);
const task: TaskRunContext = materials.length > 0 ? { ...baseTask, materials } : baseTask;
runTaskAttempt(task, asstId, 'run', 0);
})();
@@ -2878,6 +2957,19 @@ export default function App() {
setProjects((prev) => [newProject, ...prev]);
setSessions((prev) => [...newSessionsToAppend, ...prev]);
setMessages((prev) => [...prev, ...newMessagesToAppend]);
void Promise.all(
newSessionsToAppend.map((sessionItem) =>
upsertChatSession({
session_id: sessionItem.sessionId,
title: sessionItem.title,
user_id: sessionItem.userId ?? currentUser.id,
project_id: sessionItem.projectId,
})
)
).catch((err) => {
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
});
setDeliverables((prev) => [...prev, ...clonedDocs]);
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
+10 -1
View File
@@ -79,7 +79,16 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage {
interactiveCard: meta.interactive_card,
recommendedCases: meta.recommended_cases,
quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined),
status: r.role === 'assistant' ? 'completed' : undefined,
status:
r.role === 'assistant'
? meta.task_status === 'running'
? 'streaming_status'
: meta.task_status === 'failed'
? 'failed'
: 'completed'
: undefined,
taskRunId: typeof meta.run_id === 'string' ? meta.run_id : undefined,
taskWorkspaceId: typeof meta.workspace_id === 'string' ? meta.workspace_id : undefined,
contentKind: r.content_kind,
exportCapability: r.export_capability,
};
+39
View File
@@ -0,0 +1,39 @@
export type ProjectAsset = {
asset_id: number;
filename: string;
sha256: string;
mime_type?: string;
size_bytes?: number;
project_id: number;
};
export async function uploadProjectAsset(
projectId: number,
file: File | Blob,
filename: string,
userId?: number,
): Promise<ProjectAsset> {
const form = new FormData();
form.append('file', file, filename);
const headers: Record<string, string> = {};
if (userId && userId > 0) {
headers['X-User-ID'] = String(userId);
}
const res = await fetch(`/api/v1/projects/${projectId}/assets`, {
method: 'POST',
headers,
body: form,
});
if (!res.ok) {
throw new Error(`upload asset failed: HTTP ${res.status}`);
}
return res.json();
}
export async function listProjectAssets(projectId: number): Promise<ProjectAsset[]> {
const res = await fetch(`/api/v1/projects/${projectId}/assets`);
if (!res.ok) {
throw new Error(`list assets failed: HTTP ${res.status}`);
}
return res.json();
}
+76
View File
@@ -0,0 +1,76 @@
import { parseSSEChunk, type StreamEvent } from './chatStream';
export type CreateTaskRunBody = Record<string, unknown> & {
session_id: string;
project_id: number;
user_id?: number;
client_turn_id?: string;
user_message?: string;
};
export type CreateTaskRunResponse = {
run_id: string;
status: string;
workspace_id?: string;
};
export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTaskRunResponse> {
const res = await fetch('/api/v1/tasks/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`create task run failed: HTTP ${res.status}`);
}
return res.json();
}
export async function subscribeTaskRunEvents(
runId: string,
handlers: { onEvent: (ev: StreamEvent) => void; afterSeq?: number },
signal?: AbortSignal,
): Promise<void> {
const after = handlers.afterSeq ?? 0;
const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${after}`;
const resp = await fetch(url, {
method: 'GET',
headers: { Accept: 'text/event-stream' },
signal,
});
if (!resp.ok || !resp.body) {
throw new Error(`subscribe task run failed: HTTP ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parsed = parseSSEChunk(buf);
buf = parsed.rest;
for (const ev of parsed.events) handlers.onEvent(ev);
}
if (buf) {
const flushed = parseSSEChunk(buf, { flush: true });
for (const ev of flushed.events) handlers.onEvent(ev);
}
}
export async function cancelTaskRun(runId: string): Promise<void> {
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
});
if (!res.ok) {
throw new Error(`cancel task run failed: HTTP ${res.status}`);
}
}
export async function getTaskRun(runId: string) {
const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`);
if (!res.ok) {
throw new Error(`get task run failed: HTTP ${res.status}`);
}
return res.json();
}
+2
View File
@@ -56,8 +56,10 @@ export function buildTaskRequest(task: TaskRunContext): Record<string, unknown>
};
if (task.intendedUse) body.intended_use = task.intendedUse;
if (task.targetMarket) body.target_market = task.targetMarket;
if (task.sessionId) body.session_id = task.sessionId;
if (task.userId) body.user_id = task.userId;
if (task.projectId) body.project_id = task.projectId;
if (task.clientTurnId) body.client_turn_id = task.clientTurnId;
if (task.materials && task.materials.length > 0) {
body.materials = task.materials;
}
+7 -1
View File
@@ -128,7 +128,8 @@ export interface ChatErrorInfo {
*/
export type TaskRunMaterial = {
filename: string;
content: string; // Base64 编码的文件内容
content?: string; // 小文本可选 Base64;二进制应使用 asset_id
asset_id?: number;
};
/** 长任务运行上下文:触发 / 重试 / 重新开始时重发请求所需的最小参数集 */
@@ -139,8 +140,11 @@ export type TaskRunContext = {
deviceType: string;
intendedUse?: string;
targetMarket?: string;
sessionId?: string;
userId?: number;
projectId?: number;
clientTurnId?: string;
userMessage?: string;
/** 用户上传的参考文档材料,注入任务工作区 materials_text/ 目录 */
materials?: TaskRunMaterial[];
};
@@ -172,6 +176,8 @@ export interface ChatMessage {
sourceUserMessageId?: string;
/** P2: 长任务上下文(存在即表示该气泡由 task stream 驱动) */
taskRequest?: TaskRunContext;
/** 观察者模型 run_id,刷新后可重订阅 */
taskRunId?: string;
/** P2: 最近一次任务成功/收尾时的工作区目录(done.workspace_id) */
taskWorkspaceId?: string;