diff --git a/agent-service/src/agent/prompts.ts b/agent-service/src/agent/prompts.ts index 064eed2e3e5..727520210ae 100644 --- a/agent-service/src/agent/prompts.ts +++ b/agent-service/src/agent/prompts.ts @@ -239,6 +239,7 @@ Result: - **Call tools only through the native protocol**: Invoke tools using the tool-call mechanism. Never emit \`\`, \`\`, \`\`, or any other tag-like structures in your response — those shapes appear in your input to describe past turns and existing state, never in your output. - **One operation per operator**: Each operator does one task (join, filter, aggregate, etc.). Use links to connect them. +- **Always wire inputs at creation**: When adding any non-source operator (anything that consumes data), you MUST include \`inputOperatorIds\` in the same \`addOperator\` call — e.g., \`"inputOperatorIds": {"0": ["op1"]}\`. Never add an operator first and try to link it separately with \`modifyOperator\`. - **Build incrementally**: Link new operators to existing ones. Never recreate data already in the workflow. - **Read documentation first**: When the task mentions abstract concepts, load documentation to understand exact definitions. - **Refine or fix operator in place by modifying operators**: When an operator errors or produces an unexpected result, modify that operator directly — don't add a downstream operator to patch the output or recreate the pipeline. For execution errors, read the error message and the input operator's result, then rewrite the failing operator's code. For semantically wrong results, trace back to the operator whose logic is off (often upstream of where you first noticed the problem) and fix it in place. @@ -247,7 +248,24 @@ Result: - **Normalize before grouping or joining**: String keys may contain naming variants such as special character delimiters, encoding differences, or duplicate entries across files. Inspect sample values and stats of grouping/join columns, normalize where needed, and verify matched counts are plausible after joins. - **Load all data before subsetting**: When the question requires comparing across groups, load all relevant files first, then determine the correct subset. - **Handle messy data files**: Load data files directly in a single operator. Real-world data files are often malformed — they may have wrong delimiters, missing or misplaced headers, metadata/comment rows, or multiple tables in one file. After loading, inspect the result. If column names look auto-generated (e.g., \`Unnamed: 0\`) or a data value appears as a header, adjust the loading parameters (e.g., \`header=\`, \`skiprows=\`, \`sep=\`) by modifying the data loading operator. +- **Narrate each step**: Before every tool call, write a brief (≤ 12 words) plain-English note — no operator IDs, no technical jargon. Good: "Loading the uploaded CSV file." Bad: "Adding op1 CSVFileScan operator." - **Avoid monolithic code blocks**: Do NOT write one large operator that does everything — you cannot tell which step failed, inspect intermediate results, or debug without re-running everything. Instead, decompose into separate operators each doing ONE thing (e.g., filter → join → aggregate → filter → join → final filter). Each can be executed and verified independently. +- **Do only what was asked — nothing more**: Build exactly the operators the user requested. Do NOT proactively add analysis, aggregation, or visualization operators unless the user explicitly asked for them. If the user says "load the file", only load the file. +- **Always execute before reporting**: After adding or modifying an operator, call \`executeOperator\` on it to confirm results. Only report real numbers actually returned by execution — never describe what an operator *would* produce. +- **Clean response formatting**: Use markdown. Round numbers to 3 significant figures (0.947 not 0.9469804911510485). Never expose internal IDs (op1, op2, did=, wid=) to users — use human names like "the CSV loader" or "the analysis step". Use **bold** for file names and key metrics. +- **Guide the user after completing the request**: Call \`navigateToWorkflow\` with a structured summary in exactly this format: + - Line 1: One sentence describing what was done (e.g. "Loaded **Grammar_Correction.csv** — 2,018 rows, 4 columns.") + - Blank line + - Results section (if applicable): key numbers as a short table or 2-3 bullet points, rounded to 3 sig figs + - Blank line + - "**What you can do next:**" followed by 3 numbered suggestions (specific, actionable, plain English) +- **Content discovery**: + - "Show me my files / what did I upload / list my datasets" → call \`listDatasets\`, then present the results as a numbered markdown list with **bold** file names and upload dates, then call \`navigate("datasets")\` to take the user to their dataset page. Do NOT call \`navigateToWorkflow\` for these requests. + - "Load / analyze / use [file name]" → call \`listDatasets\` to find the file path, then \`addOperator(CSVFileScan)\` with that path. Do NOT navigate to the datasets page. + - "Show me my workflows / open workflow X" → call \`listWorkflows\` to find it, present the results, then call \`navigate("workflows")\`. +- **Present list results immediately**: When \`listWorkflows\` or \`listDatasets\` returns a list, format and show it to the user in your response right away — numbered list, **bold** file/workflow names, no raw IDs. Do NOT call the tool again — one call is enough. +- **Navigation shortcuts**: Use \`navigate\` for requests like "go to my datasets", "show me my workflows", "take me to the dashboard", or "open workflow X". These are terminal actions — call them directly, do not build operators first. +- **Creating a computing unit**: When the user asks to add or create a computing unit, use \`createComputingUnit\`. If they did not provide a name, ask: "What would you like to name this computing unit?" before calling the tool. After creation the tool automatically navigates to the Compute page. ## Available Operators diff --git a/agent-service/src/agent/texera-agent.ts b/agent-service/src/agent/texera-agent.ts index 37eb12d8688..0496d3093b8 100644 --- a/agent-service/src/agent/texera-agent.ts +++ b/agent-service/src/agent/texera-agent.ts @@ -17,14 +17,15 @@ * under the License. */ -import { generateText, type ModelMessage, type LanguageModel, stepCountIs } from "ai"; +import { generateText, tool, type ModelMessage, type LanguageModel, stepCountIs } from "ai"; +import { z } from "zod"; import { Subscription } from "rxjs"; import { debounceTime } from "rxjs/operators"; import { WorkflowState } from "./workflow-state"; import { WorkflowSystemMetadata } from "./util/workflow-system-metadata"; import { WorkflowResultState } from "./workflow-result-state"; import { formatOperatorResult } from "./tools/result-formatting"; -import type { AgentSettings, ReActStep, TokenUsage, UserInfo } from "../types/agent"; +import type { AgentSettings, ReActStep, TokenUsage, UserInfo, FileContext } from "../types/agent"; import { AgentState as AgentStateEnum, DEFAULT_AGENT_SETTINGS, @@ -49,11 +50,29 @@ import { } from "./tools/workflow-execution-tools"; import { assembleContext } from "./util/context-utils"; import { compileWorkflowAsync, type WorkflowCompilationResponse } from "../api/compile-api"; +import { env } from "../config/env"; import { createLogger } from "../logger"; import type { Logger } from "pino"; const PERSIST_DEBOUNCE_MS = 500; +/** + * Derive a concise, human-readable workflow name from the navigateToWorkflow summary. + * Looks for a bolded filename or the first meaningful sentence. + */ +function extractWorkflowName(summary: string): string | null { + // Try to extract a bold filename: **filename.csv** or **Name Analysis** + const boldMatch = summary.match(/\*\*([^*]{3,60})\*\*/); + if (boldMatch) { + const candidate = boldMatch[1].replace(/\.(csv|json|txt|xlsx?|parquet)$/i, "").trim(); + if (candidate.length >= 3) return candidate.substring(0, 60); + } + // Fall back to first sentence (≤ 50 chars) + const firstLine = summary.split("\n")[0].replace(/[*`]/g, "").trim(); + if (firstLine.length >= 5 && firstLine.length <= 60) return firstLine; + return null; +} + export interface TexeraAgentConfig { model: LanguageModel; modelType: string; @@ -108,12 +127,21 @@ export class TexeraAgent { private delegateConfig?: { userToken: string; userInfo?: UserInfo; - workflowId: number; + workflowId?: number; workflowName?: string; computingUnitId?: number; }; private stepCallback: ReActStepCallback | null = null; + private navigateCallback: ((url: string) => void) | null = null; + private shouldStopGeneration = false; + private navigationFiredThisTurn = false; + private currentFileContext: FileContext | undefined = undefined; + // Persists the last uploaded file across turns so the agent can use it in follow-up messages. + private lastSeenFileContext: FileContext | undefined = undefined; + private operatorTypeAddCount: Map = new Map(); + private operatorModifyCount: Map = new Map(); + private listCallCount: Map = new Map(); private messageCounter = 0; @@ -183,7 +211,7 @@ export class TexeraAgent { if (!this.delegateConfig) return undefined; return { userToken: this.delegateConfig.userToken, - workflowId: this.delegateConfig.workflowId, + workflowId: this.delegateConfig.workflowId ?? 0, computingUnitId: this.delegateConfig.computingUnitId, maxOperatorResultCharLimit: this.settings.maxOperatorResultCharLimit, maxOperatorResultCellCharLimit: this.settings.maxOperatorResultCellCharLimit, @@ -191,6 +219,26 @@ export class TexeraAgent { }; } + /** Lazily creates a workflow the first time the agent needs to build operators. */ + private async ensureWorkflow(): Promise { + if (this.delegateConfig?.workflowId || !this.delegateConfig?.userToken) return; + try { + const { createWorkflow } = await import("../api/workflow-api"); + // Name the workflow after the file being analyzed, or fall back to a generic name + const fc = this.lastSeenFileContext ?? this.currentFileContext; + const workflowName = fc?.fileName + ? fc.fileName.replace(/\.[^.]+$/, "").replace(/_/g, " ") // strip extension, underscores → spaces + : "Agent Workflow"; + const wid = await createWorkflow(this.delegateConfig.userToken, workflowName); + // Store workflowName so auto-persist uses it instead of "Agent Workflow" + this.delegateConfig = { ...this.delegateConfig, workflowId: wid, workflowName }; + this.setupWorkflowChangeHandlers(); + this.log.info({ wid }, "lazily created workflow for agent"); + } catch (e: any) { + this.log.warn({ err: e?.message }, "failed to lazily create workflow"); + } + } + private createTools(): Record { const operatorSchemas = new Map(); for (const type of Object.keys(this.metadataStore.getAllOperatorTypes())) { @@ -201,6 +249,8 @@ export class TexeraAgent { } } + // Expose executeOperator whenever a delegate config (user token + workflow) is present. + // If no computingUnitId is set, the tool auto-discovers a running unit at call time. const getExecutionConfig = this.delegateConfig ? () => this.buildExecutionConfig()! : undefined; const context: ToolContext = { @@ -210,6 +260,24 @@ export class TexeraAgent { toolTimeoutMs: this.settings.toolTimeoutMs, executionTimeoutMs: this.settings.executionTimeoutMs, }, + // Fall back to the last uploaded file if no file was attached to the current message + getFileContext: () => this.currentFileContext ?? this.lastSeenFileContext, + operatorTypeAddCount: this.operatorTypeAddCount, + operatorModifyCount: this.operatorModifyCount, + ensureWorkflow: () => this.ensureWorkflow(), + resetWorkflow: () => { + // Unlink the current workflow so ensureWorkflow() will create a fresh one. + if (this.delegateConfig) { + this.delegateConfig = { ...this.delegateConfig, workflowId: undefined, workflowName: undefined }; + } + // Clear all operators and links from the in-memory state. + this.workflowState.setWorkflowContent({ operators: [], links: [], operatorPositions: {}, commentBoxes: [], settings: { dataTransferBatchSize: 400 } }); + this.log.info("reset workflow for new file analysis"); + }, + abort: () => { + this.shouldStopGeneration = true; + this.abortController?.abort(); + }, }; const tools: Record = { @@ -228,6 +296,367 @@ export class TexeraAgent { ); } + // Content-discovery and navigation tools — available whenever a user token exists. + if (this.delegateConfig?.userToken) { + const userToken = this.delegateConfig.userToken; + const dashboardEndpoint = env.TEXERA_DASHBOARD_SERVICE_ENDPOINT; + const fileEndpoint = env.FILE_SERVICE_ENDPOINT; + const navCb = () => this.navigateCallback; + + tools["listWorkflows"] = tool({ + description: + "List the user's existing workflows. Use this when the user mentions a previous workflow " + + "or wants to open, continue, or reference one they built before. " + + "Returns wid (workflow ID), name, and last-modified date.", + inputSchema: z.object({ + query: z.string().optional().describe("Optional name filter (case-insensitive substring match)"), + }), + execute: async ({ query }: { query?: string }) => { + const key = `listWorkflows:${query ?? ""}`; + const prev = this.listCallCount.get(key) ?? 0; + if (prev >= 2) { + this.shouldStopGeneration = true; + this.abortController?.abort(); + return query + ? `No workflow named "${query}" was found after searching twice. Navigate to "workflows" to let the user browse and open manually.` + : `Workflow list already retrieved twice. Stop repeating — present the results to the user or navigate to "workflows".`; + } + this.listCallCount.set(key, prev + 1); + try { + const res = await fetch(`${dashboardEndpoint}/api/workflow/list`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) return `Failed to list workflows: ${res.status}`; + const items = (await res.json()) as Array<{ workflow: { wid: number; name: string; lastModifiedTime: number } }>; + const filtered = query + ? items.filter(i => i.workflow.name.toLowerCase().includes(query.toLowerCase())) + : items; + if (!filtered.length) + return query + ? `No workflows matching "${query}". Try a different search or use navigate("workflows") to browse all.` + : "No workflows found."; + + // Sort by most recently modified + const sorted = [...filtered].sort((a, b) => (b.workflow.lastModifiedTime ?? 0) - (a.workflow.lastModifiedTime ?? 0)); + const lines = sorted.slice(0, 20).map((i, idx) => { + const wf = i.workflow; + const age = wf.lastModifiedTime + ? new Date(wf.lastModifiedTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "unknown"; + return `${idx + 1}. **${wf.name}** — modified ${age} (wid: ${wf.wid})`; + }); + const shownNote = sorted.length > 20 ? `, showing 20 most recent` : ""; + return `📋 **Your workflows** (${filtered.length} total${shownNote}):\n\n${lines.join("\n")}\n\nTo open one, say "open wid [number]".`; + } catch (e: any) { + return `Error listing workflows: ${e.message}`; + } + }, + }); + + tools["listDatasets"] = tool({ + description: + "List the user's uploaded datasets/files. Use this when the user wants to use a file " + + "they uploaded previously, or to discover what data is available. " + + "Returns dataset name, ID, and creation date.", + inputSchema: z.object({ + query: z.string().optional().describe("Optional name filter (case-insensitive substring match)"), + }), + execute: async ({ query }: { query?: string }) => { + const key = `listDatasets:${query ?? ""}`; + const prev = this.listCallCount.get(key) ?? 0; + if (prev >= 2) { + this.shouldStopGeneration = true; + this.abortController?.abort(); + return query + ? `No dataset named "${query}" was found after searching twice. Navigate to "datasets" to let the user browse manually.` + : `Dataset list already retrieved twice. Stop repeating — present the results to the user or navigate to "datasets".`; + } + this.listCallCount.set(key, prev + 1); + try { + const res = await fetch(`${fileEndpoint}/api/dataset/list`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (!res.ok) return `Failed to list datasets: ${res.status}`; + const items = (await res.json()) as Array<{ + dataset: { did: number; name: string; description: string; creationTime: number }; + ownerEmail: string; + }>; + + // Resolve display name and file path for each dataset + const resolveEntry = async ( + ds: { did: number; name: string; description: string; creationTime: number }, + ownerEmail: string + ): Promise<{ displayName: string; filePath: string; date: string }> => { + const date = ds.creationTime + ? new Date(ds.creationTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "unknown date"; + const desc = ds.description ?? ""; + + // New format: stored at upload time + if (desc.startsWith("agent-upload:")) { + const safeFileName = desc.slice("agent-upload:".length); + return { displayName: safeFileName, filePath: `/${ownerEmail}/${ds.name}/v1/${safeFileName}`, date }; + } + + // Fetch file list from version/latest + try { + const vRes = await fetch(`${fileEndpoint}/api/dataset/${ds.did}/version/latest`, { + headers: { Authorization: `Bearer ${userToken}` }, + }); + if (vRes.ok) { + const vData = (await vRes.json()) as { + fileNodes?: Array<{ name: string; parentDir: string; type: string }>; + }; + const fileNode = (vData.fileNodes ?? []).find(n => n.type === "file"); + if (fileNode) { + return { displayName: fileNode.name, filePath: `${fileNode.parentDir}/${fileNode.name}`, date }; + } + } + } catch { /* ignore */ } + + // Last resort: strip timestamp prefix from dataset name + const bare = ds.name.replace(/^agent_upload_\d+_/, "").replace(/_/g, " ").replace(/\s+csv$/i, ".csv"); + return { displayName: bare, filePath: "", date }; + }; + + // Resolve all entries + const resolved = await Promise.all( + items.map(i => resolveEntry(i.dataset, i.ownerEmail ?? "unknown").then(e => ({ ...e, did: i.dataset.did }))) + ); + + // Apply query filter on display name + const filteredResolved = query + ? resolved.filter(e => e.displayName.toLowerCase().includes(query.toLowerCase())) + : resolved; + + if (!filteredResolved.length) + return query + ? `No files matching "${query}". Try a different search term.` + : "No uploaded files found."; + + // Deduplicate by display name — keep the most recent copy of each file + const byName = new Map(); + const countByName = new Map(); + for (const e of filteredResolved.sort((a, b) => b.did - a.did)) { + countByName.set(e.displayName, (countByName.get(e.displayName) ?? 0) + 1); + if (!byName.has(e.displayName)) byName.set(e.displayName, e); + } + + const unique = Array.from(byName.values()).slice(0, 20); + const totalMsg = filteredResolved.length !== byName.size + ? ` (${filteredResolved.length} total versions)` + : ""; + + const lines = unique.map((e, idx) => { + const copies = countByName.get(e.displayName) ?? 1; + const copiesNote = copies > 1 ? ` (${copies} versions)` : ""; + const pathNote = e.filePath ? ` — filePath: \`${e.filePath}\`` : ""; + return `${idx + 1}. **${e.displayName}**${copiesNote} — uploaded ${e.date}${pathNote}`; + }); + + return `📁 **Your uploaded files** (${byName.size} unique${totalMsg}):\n\n${lines.join("\n")}\n\nTo load a file, say "load [filename]".`; + } catch (e: any) { + return `Error listing datasets: ${e.message}`; + } + }, + }); + + tools["navigate"] = tool({ + description: + "Navigate the user's browser to any page in the Texera app. " + + "Choose the destination that best matches what the user asked for. " + + "This is a terminal action — call it last, after all data retrieval is done.", + inputSchema: z.object({ + destination: z + .enum([ + "dashboard", // home / landing page + "workflows", // my workflows list + "datasets", // my datasets list (overview) + "dataset", // open a SPECIFIC dataset detail page — requires datasetId + "compute", // computing units — use this for: "computing unit", "compute", "start a worker", "my compute resources" + "quota", // storage/usage quota — use this for: "usage", "quota", "storage limit", "how much space" + "projects", // my projects + "discussion", // discussion / forum + "hub", // public hub (browse shared workflows) + "workflow", // open THIS agent's current workflow (never pass a workflowId here — use navigateToWorkflow instead) + ]) + .describe("Page to navigate to. Use 'workflow' to open the current agent workflow. Use 'dataset' with datasetId to open a specific file. Use 'compute' for computing units."), + datasetId: z.number().optional().describe("Dataset ID — required when destination='dataset'"), + datasetVersionId: z.number().optional().describe("Dataset version ID (dvid) — use when navigating to 'dataset' to pre-select the version"), + message: z.string().describe("One sentence telling the user where they are going"), + }), + execute: async ({ + destination, + datasetId: navDid, + datasetVersionId: navDvid, + message, + }: { + destination: string; + datasetId?: number; + datasetVersionId?: number; + message: string; + }) => { + if (this.navigationFiredThisTurn) { + return "Already navigated this turn. Do not call navigate again."; + } + this.navigationFiredThisTurn = true; + this.shouldStopGeneration = true; + + // For "workflow" destination always use the agent's own workflow — never a hallucinated ID + const ownWorkflowId = this.delegateConfig?.workflowId; + + const urls: Record = { + dashboard: "/dashboard/home", + workflows: "/dashboard/user/workflow", + datasets: "/dashboard/user/dataset", + dataset: (navDid && navDid > 0) ? `/dashboard/user/dataset/${navDid}${navDvid ? `?dvid=${navDvid}` : ""}` : "/dashboard/user/dataset", + compute: "/dashboard/user/compute", + quota: "/dashboard/user/quota", + projects: "/dashboard/user/project", + discussion: "/dashboard/user/discussion", + hub: "/dashboard/hub/workflow/result", + workflow: ownWorkflowId ? `/dashboard/user/workflow/${ownWorkflowId}` : "/dashboard/user/workflow", + }; + const url = urls[destination] ?? "/dashboard/home"; + const cb = navCb(); + if (cb) cb(url); + this.abortController?.abort(); + return `Navigating to ${destination}. ${message}`; + }, + }); + } + + // createComputingUnit — provisions a new local computing unit for the user. + if (this.delegateConfig?.userToken) { + const cuToken = this.delegateConfig.userToken; + const cuEndpoint = env.COMPUTING_UNIT_MANAGING_SERVICE_ENDPOINT; + const cuHeaders = { Authorization: `Bearer ${cuToken}`, "Content-Type": "application/json" }; + const navCbForCU = () => this.navigateCallback; + + tools["createComputingUnit"] = tool({ + description: + "Create a new computing unit for the user. " + + "If the user did not provide a name, ask for one before calling this tool. " + + "After creating, navigate to the compute page so the user can see it.", + inputSchema: z.object({ + name: z.string().describe("A human-friendly name for the computing unit (ask the user if not provided)"), + }), + execute: async ({ name }: { name: string }) => { + try { + // Create the unit + const createRes = await fetch(`${cuEndpoint}/api/computing-unit/create`, { + method: "POST", + headers: cuHeaders, + body: JSON.stringify({ + name, + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + uri: env.COMPUTING_UNIT_WSAPI_URI, + unitType: "local", + }), + }); + + if (!createRes.ok) { + const text = await createRes.text(); + return `[ERROR] Failed to create computing unit: ${createRes.status} — ${text}`; + } + + const created = (await createRes.json()) as { computingUnit?: { cuid: number; name: string } }; + const cuid = created.computingUnit?.cuid; + if (!cuid) return `[ERROR] Computing unit created but no ID returned.`; + + // Poll up to 15s for Running status + for (let i = 0; i < 15; i++) { + await new Promise(r => setTimeout(r, 1000)); + try { + const pollRes = await fetch(`${cuEndpoint}/api/computing-unit/${cuid}`, { headers: cuHeaders }); + if (pollRes.ok) { + const u = (await pollRes.json()) as { status?: string }; + if (u.status === "Running") break; + } + } catch { /* ignore poll errors */ } + } + + // Navigate to compute page and stop generation + this.shouldStopGeneration = true; + this.navigationFiredThisTurn = true; + const cb = navCbForCU(); + if (cb) cb("/dashboard/user/compute"); + this.abortController?.abort(); + + return `✅ Computing unit **"${name}"** created (ID: ${cuid}) and is now running. Taking you to the Compute page.`; + } catch (e: any) { + return `[ERROR] ${e.message}`; + } + }, + }); + } + + // navigateToWorkflow — always available when a user is logged in. + // workflowId is read lazily at execution time so that ensureWorkflow() called + // mid-turn (by addOperator) can create the workflow first and still be navigated to. + if (this.delegateConfig?.userToken) { + tools["navigateToWorkflow"] = tool({ + description: + "Navigate the user's browser to the workspace. " + + "Only call this AFTER executing operators and seeing real results. " + + "The summary MUST include: (1) what operators were built, (2) actual numbers " + + "from execution (row counts, metric values, column names), and (3) 2-3 specific " + + "follow-up questions the user can ask. Format with line breaks between sections.", + inputSchema: z.object({ + summary: z + .string() + .describe( + "Structured summary with three sections separated by newlines: " + + "Section 1 — what was built (operator names and types). " + + "Section 2 — actual results from execution (real numbers, not descriptions). " + + "Section 3 — 2-3 specific next steps the user can ask for." + ), + }), + execute: async ({ summary }: { summary: string }) => { + // Read workflowId lazily — ensureWorkflow() may have set it mid-turn. + const workflowId = this.delegateConfig?.workflowId; + if (!workflowId) { + return "No workflow is linked to this agent yet. Build a workflow first (add and execute operators), then call navigateToWorkflow."; + } + + // Guard against repeated calls in the same turn. + if (this.navigationFiredThisTurn) { + return "Navigation already sent this turn. Stop calling navigateToWorkflow — the user is being taken to the workflow now."; + } + this.navigationFiredThisTurn = true; + this.shouldStopGeneration = true; + + // Auto-rename the workflow based on the summary before navigating. + const workflowName = extractWorkflowName(summary); + if (workflowName && this.delegateConfig?.userToken) { + try { + await fetch(`${env.TEXERA_DASHBOARD_SERVICE_ENDPOINT}/api/workflow/update/name`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.delegateConfig.userToken}`, + }, + body: JSON.stringify({ wid: workflowId, name: workflowName }), + }); + } catch { + /* rename is best-effort — don't block navigation */ + } + } + + const cb = this.navigateCallback; + if (cb) { + cb(`/dashboard/user/workflow/${workflowId}`); + } + this.abortController?.abort(); + return `Navigating to workflow #${workflowId}. ${summary}`; + }, + }); + } + return tools; } @@ -312,6 +741,10 @@ export class TexeraAgent { this.stepCallback = callback; } + setNavigateCallback(callback: ((url: string) => void) | null): void { + this.navigateCallback = callback; + } + private generateStepId(): string { return `step-${this.agentId}-${++this.stepCounter}-${Date.now()}`; } @@ -432,19 +865,18 @@ export class TexeraAgent { setDelegateConfig(config: { userToken: string; userInfo?: UserInfo; - workflowId: number; + workflowId?: number; workflowName?: string; computingUnitId?: number; }): void { this.delegateConfig = config; - + // Rebuild tools with the new delegate config (unlocks navigate, listDatasets, etc.) this.tools = this.createTools(); - this.setupWorkflowChangeHandlers(); } getDelegateConfig(): - | { userToken: string; userInfo?: UserInfo; workflowId: number; workflowName?: string; computingUnitId?: number } + | { userToken: string; userInfo?: UserInfo; workflowId?: number; workflowName?: string; computingUnitId?: number } | undefined { return this.delegateConfig; } @@ -485,7 +917,11 @@ export class TexeraAgent { this.workflowState.addSubscription(subscription); } - async sendMessage(userMessage: string, messageSource?: "chat" | "feedback"): Promise { + async sendMessage( + userMessage: string, + messageSource?: "chat" | "feedback", + fileContext?: FileContext + ): Promise { const messageId = `msg-${this.agentId}-${++this.messageCounter}-${Date.now()}`; let stepIndex = 0; @@ -496,6 +932,14 @@ export class TexeraAgent { this.state = AgentStateEnum.GENERATING; this.currentMessageId = messageId; + this.shouldStopGeneration = false; + this.navigationFiredThisTurn = false; + this.currentFileContext = fileContext; + // Remember the file context across turns for follow-up messages like "load it" + if (fileContext) this.lastSeenFileContext = fileContext; + this.operatorTypeAddCount = new Map(); + this.operatorModifyCount = new Map(); + this.listCallCount = new Map(); try { let beforeStepContent = this.workflowState.getWorkflowContent(); @@ -513,6 +957,7 @@ export class TexeraAgent { isBegin: true, isEnd: true, messageSource, + fileContext, beforeWorkflowContent: beforeStepContent, afterWorkflowContent: beforeStepContent, usage: { @@ -536,7 +981,7 @@ export class TexeraAgent { messages: currentUserMessage, tools: this.tools, temperature: 0.2, - stopWhen: stepCountIs(this.settings.maxSteps), + stopWhen: (ctx: any) => stepCountIs(this.settings.maxSteps)(ctx) || this.shouldStopGeneration, prepareStep: async ({ stepNumber, messages: currentMessages }) => { let compilationResult: WorkflowCompilationResponse | null = null; if (this.workflowState.getAllOperators().length > 0) { @@ -557,6 +1002,34 @@ export class TexeraAgent { compilationResult ); lastPreparedMessages = processed; + + // After navigation fires: force text-only response, no more tool calls. + if (this.navigationFiredThisTurn) { + const stopMessages: ModelMessage[] = [ + ...processed, + { + role: "user", + content: + "STOP CALLING TOOLS. Navigation is complete — the user is already being taken to the workflow. " + + "Do NOT call navigateToWorkflow or any other tool again. " + + "Respond with a single short text message summarising what was done.", + }, + ]; + return { messages: stopMessages, toolChoice: "none" as const }; + } + + // Every 5 steps, inject a progress-check prompt so the LLM narrates + // what it has done and what it is about to do next. + if (stepNumber > 0 && stepNumber % 5 === 0) { + const checkIn: ModelMessage = { + role: "user", + content: + "Progress check: in 1–2 sentences describe what you have built so far " + + "and what your very next action will be. Then immediately continue working.", + }; + return { messages: [...processed, checkIn] }; + } + return { messages: processed }; }, abortSignal: this.abortController?.signal, @@ -576,6 +1049,18 @@ export class TexeraAgent { input: tc.input, })); + // Log tool calls so issues are visible in the agent log. + if (formattedToolCalls?.length) { + for (const tc of formattedToolCalls) { + const result = toolResults?.find(r => r.toolCallId === tc.toolCallId); + const isError = !!(result?.output as any)?.error; + this.log.info( + { toolName: tc.toolName, operatorType: (tc.input as any)?.operatorType, isError }, + isError ? "tool call failed" : "tool call succeeded" + ); + } + } + const formattedToolResults = toolResults?.map(tr => ({ toolCallId: tr.toolCallId, output: tr.output, @@ -592,7 +1077,35 @@ export class TexeraAgent { stepId: stepIndex, timestamp: Date.now(), role: "agent", - content: text || "", + // Surface results from tools that the LLM should present but often loops on instead. + content: (() => { + // Navigation tools — append their summary/message + const navCall = toolCalls?.find( + tc => tc.toolName === "navigateToWorkflow" || tc.toolName === "navigate" + ); + if (navCall) { + const extra: string = + (navCall.input as any)?.summary ?? (navCall.input as any)?.message ?? ""; + if (extra) return text ? `${text}\n\n${extra}` : extra; + } + + // List tools — append the actual list so the LLM doesn't need to call again + const listCall = toolCalls?.find( + tc => tc.toolName === "listWorkflows" || tc.toolName === "listDatasets" + ); + if (listCall && toolResults) { + const idx = (toolCalls ?? []).indexOf(listCall); + const listOut = String(toolResults[idx]?.output ?? ""); + const isUseful = + listOut.length > 0 && + !listOut.startsWith("[ERROR]") && + !listOut.includes("Stop repeating") && + !listOut.includes("already retrieved"); + if (isUseful) return text ? `${text}\n\n${listOut}` : listOut; + } + + return text || ""; + })(), isBegin: isFirstStep, isEnd: false, toolCalls: formattedToolCalls, @@ -642,6 +1155,22 @@ export class TexeraAgent { beforeStepContent = afterStepContent; isFirstStep = false; + + // Abort if navigateToWorkflow fired (terminal action). + const navigated = toolCalls?.some(tc => tc.toolName === "navigateToWorkflow"); + if (navigated) { + this.abortController?.abort(); + } + + // Abort if any loop detector fired this step (addOperator or modifyOperator repetition). + if (!navigated && toolResults) { + const loopDetected = toolResults.some( + tr => typeof tr.output === "string" && tr.output.includes("Generation is stopping") + ); + if (loopDetected) { + this.abortController?.abort(); + } + } }, }); @@ -650,6 +1179,32 @@ export class TexeraAgent { const lastStep = msgSteps[msgSteps.length - 1]; if (lastStep.role === "agent") { lastStep.isEnd = true; + + // If the last step has no text content, the agent was cut off mid-task + // (hit maxSteps or a loop guard). Append an explanation. + if (!lastStep.content && lastStep.toolCalls?.length) { + // Check if the last tool result has a meaningful message to surface + const lastResult = lastStep.toolResults?.[lastStep.toolResults.length - 1]; + const lastOut = typeof lastResult?.output === "string" ? lastResult.output : ""; + + if (lastOut.includes("Stop repeating") || lastOut.includes("Already searched") || lastOut.includes("Already navigated")) { + // Loop detector fired — extract the helpful part of the message + lastStep.content = lastOut.replace(/Generation is stopping\.\s*/g, "").replace(/\[ERROR\]\s*/g, "").trim(); + } else { + const ops = this.workflowState.getAllOperators(); + if (ops.length > 0) { + const opSummary = ops.map(o => `${o.operatorID} (${o.operatorType})`).join(", "); + lastStep.content = + `⚠️ I reached the step limit before finishing. ` + + `Here's what I built so far: **${opSummary}**. ` + + `You can ask me to continue, simplify the request, or describe what to fix.`; + } else { + lastStep.content = + `⚠️ I ran out of steps before completing your request. ` + + `Please rephrase or break it into smaller steps.`; + } + } + } } } diff --git a/agent-service/src/agent/tools/workflow-crud-tools.ts b/agent-service/src/agent/tools/workflow-crud-tools.ts index 54a0b29db99..7c743ae1f06 100644 --- a/agent-service/src/agent/tools/workflow-crud-tools.ts +++ b/agent-service/src/agent/tools/workflow-crud-tools.ts @@ -43,6 +43,22 @@ export interface ToolContext { toolTimeoutMs?: number; executionTimeoutMs?: number; }; + /** Returns the file context for the current message turn, if any. */ + getFileContext?: () => { fileName: string; filePath: string } | undefined; + /** Tracks how many times each operatorType has been added this turn (for loop detection). */ + operatorTypeAddCount?: Map; + /** Tracks how many times each operatorId has been modified this turn (for loop detection). */ + operatorModifyCount?: Map; + /** Aborts generation immediately (used by loop detector). */ + abort?: () => void; + /** Lazily creates a workflow when the agent first needs to build operators. */ + ensureWorkflow?: () => Promise; + /** + * Clears the current workflow (operators + workflowId) so the next ensureWorkflow() + * creates a fresh one. Used when the user loads a different file into a workflow that + * already contains operators from a previous file. + */ + resetWorkflow?: () => void; } export const TOOL_NAME_ADD_OPERATOR = "addOperator"; @@ -80,7 +96,7 @@ Examples: "Name of Operator. Use the format 'op' followed by an incrementing number starting from 1 (e.g., op1, op2, op3)." ), operatorType: z.string().describe("The operator type (e.g., 'DataProcessing', 'Aggregate')"), - properties: z.record(z.any()).describe("Properties to set on the operator"), + properties: z.record(z.any()).optional().describe("Properties to set on the operator"), inputOperatorIds: z .record(z.array(z.string())) .optional() @@ -99,13 +115,61 @@ Examples: summary: string; }) => { try { + // Resolve the incoming fileName early (may come from args.properties or fileContext + // auto-injection) so the reset check sees it regardless of which path sets it. + const isScanOp = args.operatorType.toLowerCase().includes("scan"); + const incomingFileName: string | undefined = + args.properties?.fileName || + (isScanOp && context?.getFileContext ? context.getFileContext()?.filePath : undefined); + + // If the user is loading a different file into a workflow that already has operators, + // reset to a fresh workflow so we don't mix operators from different analyses. + if (isScanOp && incomingFileName && context?.resetWorkflow) { + const existingScans = workflowState.getAllOperators().filter(op => + op.operatorType.toLowerCase().includes("scan") + ); + if (existingScans.length > 0) { + const existingFile = (existingScans[0] as any).operatorProperties?.fileName; + if (existingFile && existingFile !== incomingFileName) { + context.resetWorkflow(); + } + } + } + + // Lazily create a workflow the first time the agent needs to build operators. + if (context?.ensureWorkflow) await context.ensureWorkflow(); + const inputInfo = formatInputArgs(args); const schemaEntry = operatorSchemas.get(args.operatorType); if (!schemaEntry) { - return createErrorResult( - `Unknown operator type: "${args.operatorType}". Available types: ${[...operatorSchemas.keys()].join(", ")}. ${inputInfo}` - ); + // Fuzzy-match: find types whose name contains the requested name (case-insensitive) + // or whose requested name contains the type name. + const req = args.operatorType.toLowerCase(); + const allTypes = [...operatorSchemas.keys()]; + const suggestions = allTypes.filter(t => { + const tl = t.toLowerCase(); + return tl.includes(req) || req.includes(tl) || req.split(/(?=[A-Z])/).some((w: string) => tl.includes(w.toLowerCase())); + }); + const hint = suggestions.length > 0 + ? `Did you mean: ${suggestions.join(", ")}?` + : `Search the available operator list in the system prompt for a visualization operator.`; + return createErrorResult(`Unknown operator type: "${args.operatorType}". ${hint} ${inputInfo}`); + } + + // Loop detection: if the same operatorType has been added 3+ times this turn, abort. + if (context?.operatorTypeAddCount) { + const prev = context.operatorTypeAddCount.get(args.operatorType) ?? 0; + if (prev >= 3) { + // Abort generation — the LLM ignores plain errors but abort stops the loop. + context.abort?.(); + return createErrorResult( + `You have already added "${args.operatorType}" ${prev} times this turn without success. ` + + `Generation is stopping. Tell the user what was built so far and that ` + + `this specific step requires a different approach or manual configuration.` + ); + } + context.operatorTypeAddCount.set(args.operatorType, prev + 1); } if (context?.metadataStore && args.properties) { @@ -136,11 +200,47 @@ Examples: ); } - let operator = workflowUtil.getNewOperatorPredicate(args.operatorType, args.summary); + // Auto-inject fileName from file context when the operator needs one but none was provided. + let resolvedProperties = args.properties || {}; + if (!resolvedProperties.fileName && context?.getFileContext) { + const fc = context.getFileContext(); + const schema = context.metadataStore?.getSchema(args.operatorType); + const needsFileName = + schema?.properties?.fileName !== undefined || + schema?.required?.includes("fileName"); + if (fc && needsFileName) { + resolvedProperties = { ...resolvedProperties, fileName: fc.filePath }; + } + } + + const operatorTemplate = workflowUtil.getNewOperatorPredicate(args.operatorType, args.summary); + + // If this operator needs inputs but none were specified, block and explain. + if (operatorTemplate.inputPorts.length > 0 && !args.inputOperatorIds) { + const existingOps = workflowState.getAllOperators(); + if (existingOps.length > 0) { + const opList = existingOps + .map(o => `${o.operatorID} (${o.operatorType})`) + .join(", "); + // Don't count this blocked attempt toward the repetition limit. + if (context?.operatorTypeAddCount) { + const cur = context.operatorTypeAddCount.get(args.operatorType) ?? 0; + context.operatorTypeAddCount.set(args.operatorType, Math.max(0, cur - 1)); + } + return createErrorResult( + `Operator "${args.operatorId}" (${args.operatorType}) requires ${operatorTemplate.inputPorts.length} input(s) but no inputOperatorIds was provided. ` + + `You MUST specify which operator to connect it to using inputOperatorIds in this same addOperator call. ` + + `Available operators to connect to: ${opList}. ` + + `Example: {"0": ["${existingOps[0].operatorID}"]}` + ); + } + } + + let operator = operatorTemplate; operator = { ...operator, operatorID: args.operatorId, - operatorProperties: { ...operator.operatorProperties, ...args.properties }, + operatorProperties: { ...operator.operatorProperties, ...resolvedProperties }, }; workflowState.addOperator(operator); @@ -242,6 +342,19 @@ Examples: const operator = workflowState.getOperator(args.operatorId); if (!operator) return createErrorResult(`Operator ${args.operatorId} not found. ${inputInfo}`); + // Loop detection: abort if the same operator has been modified 5+ times this turn. + if (context?.operatorModifyCount) { + const prev = context.operatorModifyCount.get(args.operatorId) ?? 0; + if (prev >= 5) { + context.abort?.(); + return createErrorResult( + `You have already modified "${args.operatorId}" ${prev} times this turn without success. ` + + `Generation is stopping. Tell the user what was built and what step is blocking progress.` + ); + } + context.operatorModifyCount.set(args.operatorId, prev + 1); + } + if (args.properties && context?.metadataStore) { const mergedProperties = { ...operator.operatorProperties, ...args.properties }; const validation = context.metadataStore.validateOperatorProperties(operator.operatorType, mergedProperties); diff --git a/agent-service/src/agent/tools/workflow-execution-tools.ts b/agent-service/src/agent/tools/workflow-execution-tools.ts index 15fa81ff977..5ab6166463b 100644 --- a/agent-service/src/agent/tools/workflow-execution-tools.ts +++ b/agent-service/src/agent/tools/workflow-execution-tools.ts @@ -31,6 +31,78 @@ import { createLogger } from "../../logger"; const log = createLogger("ExecutionTools"); +/** Calls the computing-unit-managing-service to find or create a running unit for this user. */ +async function discoverRunningComputingUnit(userToken: string): Promise { + const baseUrl = `${env.COMPUTING_UNIT_MANAGING_SERVICE_ENDPOINT}/api/computing-unit`; + const headers = { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" }; + + try { + // 1. Try to find an already-running unit + const listRes = await fetch(baseUrl, { headers }); + if (!listRes.ok) return undefined; + + const units = (await listRes.json()) as Array<{ + computingUnit: { cuid: number; uri: string }; + status: string; + }>; + + const running = units.find(u => u.status === "Running"); + if (running) { + log.info({ cuid: running.computingUnit.cuid }, "auto-discovered running computing unit"); + return running.computingUnit.cuid; + } + + // 2. No running unit — create a new local one automatically + log.info("no running computing unit found — provisioning one automatically"); + const wsUri = env.COMPUTING_UNIT_WSAPI_URI; + const createRes = await fetch(`${baseUrl}/create`, { + method: "POST", + headers, + body: JSON.stringify({ + name: "Agent Computing Unit", + cpuLimit: "NaN", + memoryLimit: "NaN", + gpuLimit: "NaN", + jvmMemorySize: "NaN", + shmSize: "NaN", + uri: wsUri, + unitType: "local", + }), + }); + + if (!createRes.ok) { + log.warn({ status: createRes.status }, "failed to auto-provision computing unit"); + return undefined; + } + + const created = (await createRes.json()) as { + computingUnit?: { cuid: number }; + cuid?: number; + }; + const newCuid = created.computingUnit?.cuid ?? (created as any).cuid; + if (!newCuid) return undefined; + + // 3. Wait up to 15s for it to become Running + for (let i = 0; i < 15; i++) { + await new Promise(r => setTimeout(r, 1000)); + const pollRes = await fetch(`${baseUrl}/${newCuid}`, { headers }); + if (pollRes.ok) { + const unit = (await pollRes.json()) as { status: string }; + if (unit.status === "Running") { + log.info({ cuid: newCuid }, "auto-provisioned computing unit is now Running"); + return newCuid; + } + } + } + + log.warn({ cuid: newCuid }, "auto-provisioned unit did not start in time"); + return newCuid; // Return it anyway and let execution try + } catch (e: any) { + log.warn({ err: e?.message }, "error discovering/provisioning computing unit"); + return undefined; + } +} + export const TOOL_NAME_EXECUTE_OPERATOR = "executeOperator"; export interface ExecutionConfig { @@ -207,8 +279,8 @@ function buildLogicalPlan(workflowState: WorkflowState, opsToViewResult?: string ...op.operatorProperties, operatorID: op.operatorID, operatorType: op.operatorType, - inputPorts: op.inputPorts, - outputPorts: op.outputPorts, + // inputPorts/outputPorts are omitted: the frontend uses string portIDs ("output-0") + // but Scala expects PortIdentity objects. Scala derives ports from the operator class. })); linksList = subDAG.links.map(link => ({ @@ -222,8 +294,6 @@ function buildLogicalPlan(workflowState: WorkflowState, opsToViewResult?: string ...op.operatorProperties, operatorID: op.operatorID, operatorType: op.operatorType, - inputPorts: op.inputPorts, - outputPorts: op.outputPorts, })); linksList = workflowState.getAllLinks().map(link => ({ @@ -433,7 +503,7 @@ function jsonToTableFormat(jsonResult: Record[]): string { export async function executeOperatorAndFormat( workflowState: WorkflowState, - config: ExecutionConfig, + config: ExecutionConfig, // may be mutated locally to inject discovered computingUnitId operatorId: string, options: { abortSignal?: AbortSignal; @@ -445,6 +515,18 @@ export async function executeOperatorAndFormat( const release = await getWorkflowMutex(config.workflowId).acquire(); try { + // If no computing unit configured, auto-discover or provision one. + if (config.computingUnitId === undefined) { + const discovered = await discoverRunningComputingUnit(config.userToken); + if (discovered === undefined) { + return createErrorResult( + "Could not find or create a computing unit. " + + "Please go to the Compute page and start one, or ask the agent to create one for you." + ); + } + config = { ...config, computingUnitId: discovered }; + } + const logicalPlan = buildLogicalPlan(workflowState, [operatorId]); if (logicalPlan.operators.length === 0) { diff --git a/agent-service/src/agent/util/context-utils.ts b/agent-service/src/agent/util/context-utils.ts index 195692cbf50..2ef9951888e 100644 --- a/agent-service/src/agent/util/context-utils.ts +++ b/agent-service/src/agent/util/context-utils.ts @@ -112,6 +112,27 @@ function serializeTask(steps: ReActStep[], status: "completed" | "ongoing"): str if (userStep) { lines.push("### User request"); lines.push(""); + if (userStep.fileContext) { + const fc = userStep.fileContext; + const datasetInfo = fc.datasetId ? ` (dataset ID: ${fc.datasetId})` : ""; + lines.push(`[File saved to datasets: **${fc.fileName}**${datasetInfo} → \`${fc.filePath}\`]`); + const navInstruction = fc.datasetId + ? `call navigate("dataset", datasetId=${fc.datasetId}${fc.datasetVersionId ? `, datasetVersionId=${fc.datasetVersionId}` : ""}) to open this exact dataset` + : `call navigate("datasets") to take the user to their datasets list`; + lines.push( + `The file is now in the user's datasets. Read the user's message and decide:\n` + + `• User wants to **analyze** the data → call addOperator(CSVFileScan, fileName="${fc.filePath}"), execute it to get schema and sample rows, then build 1-2 analysis operators (Aggregate or PythonUDFV2) to actually compute something meaningful (e.g. row counts by category, basic stats). Then call navigate("workflow"). ` + + `The navigate message must be well-formatted markdown with: a bold file summary line (rows × columns), a blank line, then a "**What you can do next:**" section as a numbered list with 3 specific suggestions based on the actual column names.\n` + + `• User wants to **just load / view** the data → call addOperator(CSVFileScan, fileName="${fc.filePath}"), execute it, then call navigate("workflow"). ` + + `The navigate message must be well-formatted markdown: bold file name + row/column count on line 1, blank line, then numbered list of 3 specific next-step suggestions.\n` + + `• User wants to **upload / store / save** it → ${navInstruction}. ` + + `The navigate message must be well-formatted markdown: line 1 "✅ **${fc.fileName}** saved to your datasets.", blank line, numbered list of 2-3 next steps like "1. Load it into a workflow for analysis", "2. Filter or search the data". ` + + `Do NOT guess a datasetId — only use the one provided above.\n` + + `• User's intent is unclear → confirm the upload and ask: "Would you like me to analyze it, load it into a workflow, or just view it in your datasets?"\n` + + `IMPORTANT for ALL navigate messages: use markdown with **bold**, line breaks between sections, and numbered lists. Never write next steps as inline text like "(1)... (2)... (3)...".` + ); + lines.push(""); + } lines.push(userStep.content); lines.push(""); } diff --git a/agent-service/src/api/workflow-api.ts b/agent-service/src/api/workflow-api.ts index 7a96f979a1c..674694b5bce 100644 --- a/agent-service/src/api/workflow-api.ts +++ b/agent-service/src/api/workflow-api.ts @@ -75,6 +75,35 @@ export async function persistWorkflow( return data; } +export async function createWorkflow(token: string, name: string): Promise { + const config = getBackendConfig(); + const url = `${config.apiEndpoint}/api/${WORKFLOW_BASE_URL}/create`; + + const emptyContent: WorkflowContent = { + operators: [], + commentBoxes: [], + links: [], + operatorPositions: {}, + settings: { dataTransferBatchSize: 400, executionMode: "pipelined" } as any, + }; + + const response = await fetch(url, { + method: "POST", + headers: createAuthHeaders(token), + body: JSON.stringify({ name, content: JSON.stringify(emptyContent) }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to create workflow: ${response.status} - ${text}`); + } + + const data = (await response.json()) as { workflow?: { wid: number }; wid?: number }; + const wid = data.workflow?.wid ?? (data as any).wid; + if (!wid) throw new Error("Created workflow has no wid"); + return wid; +} + export async function retrieveWorkflow(token: string, wid: number): Promise { const config = getBackendConfig(); const url = `${config.apiEndpoint}/api/${WORKFLOW_BASE_URL}/${wid}`; diff --git a/agent-service/src/config/env.ts b/agent-service/src/config/env.ts index 16a25b9be77..85dc8cd4556 100644 --- a/agent-service/src/config/env.ts +++ b/agent-service/src/config/env.ts @@ -30,6 +30,9 @@ const EnvSchema = z.object({ LOG_PRETTY: z.coerce.boolean().default(false), TEXERA_DASHBOARD_SERVICE_ENDPOINT: z.string().url().default("http://localhost:8080"), + COMPUTING_UNIT_MANAGING_SERVICE_ENDPOINT: z.string().url().default("http://localhost:8888"), + COMPUTING_UNIT_WSAPI_URI: z.string().default("http://localhost:4200/wsapi"), + FILE_SERVICE_ENDPOINT: z.string().url().default("http://localhost:9092"), LLM_ENDPOINT: z.string().url().default("http://localhost:9096"), WORKFLOW_COMPILING_SERVICE_ENDPOINT: z.string().url().default("http://localhost:9090"), WORKFLOW_EXECUTION_SERVICE_ENDPOINT: z.string().url().default("http://localhost:8085"), diff --git a/agent-service/src/server.ts b/agent-service/src/server.ts index a31f9ede115..2f3d959408a 100644 --- a/agent-service/src/server.ts +++ b/agent-service/src/server.ts @@ -52,7 +52,7 @@ async function createAgentInstance( const config = getBackendConfig(); const openai = createOpenAI({ - baseURL: `${config.modelsEndpoint}/api`, + baseURL: config.modelsEndpoint, apiKey: env.LLM_API_KEY, }); @@ -67,26 +67,30 @@ async function createAgentInstance( await agent.initialize(); - if (delegateConfig?.workflowId && delegateConfig.userToken) { - try { - const workflow = await retrieveWorkflow(delegateConfig.userToken, delegateConfig.workflowId); - delegateConfig.workflowName = workflow.name; - - const workflowState = agent.getWorkflowState(); - workflowState.setWorkflowContent(workflow.content); - - agent.setDelegateConfig({ - userToken: delegateConfig.userToken, - userInfo: delegateConfig.userInfo, - workflowId: delegateConfig.workflowId, - workflowName: delegateConfig.workflowName, - computingUnitId: delegateConfig.computingUnitId, - }); - - log.info({ agentId, workflowId: delegateConfig.workflowId }, "loaded workflow for agent"); - } catch (error) { - log.warn({ agentId, workflowId: delegateConfig.workflowId, err: error }, "failed to load workflow"); + // Always set delegate config when we have a user token — even without a workflowId. + // This ensures navigation and discovery tools (navigate, listDatasets, etc.) are available + // from any page, not just when a workflow is open. + if (delegateConfig?.userToken) { + // Load the existing workflow content if we have a workflowId + if (delegateConfig.workflowId) { + try { + const workflow = await retrieveWorkflow(delegateConfig.userToken, delegateConfig.workflowId); + delegateConfig.workflowName = workflow.name; + const workflowState = agent.getWorkflowState(); + workflowState.setWorkflowContent(workflow.content); + log.info({ agentId, workflowId: delegateConfig.workflowId }, "loaded workflow for agent"); + } catch (error) { + log.warn({ agentId, workflowId: delegateConfig.workflowId, err: error }, "failed to load workflow"); + } } + + agent.setDelegateConfig({ + userToken: delegateConfig.userToken, + userInfo: delegateConfig.userInfo, + workflowId: delegateConfig.workflowId, + workflowName: delegateConfig.workflowName, + computingUnitId: delegateConfig.computingUnitId, + }); } agentStore.set(agentId, agent); @@ -400,12 +404,79 @@ const agentsRouter = new Elysia({ prefix: "/agents" }) allowedOperatorTypes: t.Optional(t.Array(t.String())), }), } + ) + + .post( + "/:id/upload", + async ({ params, body, set }) => { + const agent = getAgent(params.id); + const delegateConfig = agent.getDelegateConfig(); + if (!delegateConfig?.userToken) { + set.status = 401; + return { error: "Agent has no user token" }; + } + + const { file } = body as { file: File }; + const fileName = file.name; + const ownerEmail = delegateConfig.userInfo?.email ?? "unknown"; + const datasetName = `agent_upload_${Date.now()}_${fileName.replace(/[^a-zA-Z0-9._-]/g, "_")}`; + + const createRes = await fetch(`${env.FILE_SERVICE_ENDPOINT}/api/dataset/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${delegateConfig.userToken}`, + }, + body: JSON.stringify({ + datasetName, + datasetDescription: "Uploaded via agent chat", + isDatasetPublic: false, + isDatasetDownloadable: false, + }), + }); + + if (!createRes.ok) { + const text = await createRes.text(); + throw new Error(`Failed to create dataset: ${createRes.status} ${text}`); + } + + const dataset = (await createRes.json()) as { did: number }; + const did = dataset.did; + + const fileBytes = await file.arrayBuffer(); + const uploadRes = await fetch( + `${env.FILE_SERVICE_ENDPOINT}/api/dataset/${did}/upload?filePath=${encodeURIComponent(fileName)}`, + { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": String(fileBytes.byteLength), + Authorization: `Bearer ${delegateConfig.userToken}`, + }, + body: fileBytes, + } + ); + + if (!uploadRes.ok) { + const text = await uploadRes.text(); + throw new Error(`Failed to upload file: ${uploadRes.status} ${text}`); + } + + log.info({ agentId: params.id, datasetName, fileName }, "file uploaded via chat"); + return { datasetName, fileName, ownerEmail }; + }, + { + body: t.Object({ + file: t.File(), + }), + } ); interface WsMessage { type: "message" | "stop"; content?: string; messageSource?: "chat" | "feedback"; + fileContext?: { fileName: string; filePath: string; datasetId?: number; datasetVersionId?: number }; } interface OperatorResultSummaryWs { @@ -423,7 +494,7 @@ interface OperatorResultSummaryWs { } interface WsOutgoingMessage { - type: "step" | "state" | "error" | "complete" | "init" | "headChange"; + type: "step" | "state" | "error" | "complete" | "init" | "headChange" | "navigate"; step?: ReActStep; state?: string; error?: string; @@ -431,6 +502,8 @@ interface WsOutgoingMessage { headId?: string; operatorResults?: Record; workflowContent?: any; + // navigate message + url?: string; } function getOperatorResultSummaries(agent: TexeraAgent): Record { @@ -540,7 +613,9 @@ export function buildApp() { wsLog.info({ agentId, preview: msg.content.substring(0, 50) }, "received message"); + const broadcastedStepIds = new Set(); agent.setStepCallback((step: ReActStep) => { + broadcastedStepIds.add(step.id); const hasToolCalls = step.toolCalls && step.toolCalls.length > 0; broadcastToAgent(agentId, { type: "step", @@ -549,16 +624,24 @@ export function buildApp() { }); }); + agent.setNavigateCallback((url: string) => { + broadcastToAgent(agentId, { type: "navigate", url }); + }); + broadcastToAgent(agentId, { type: "state", state: "GENERATING" }); try { - const result = await agent.sendMessage(msg.content, msg.messageSource); + const result = await agent.sendMessage(msg.content, msg.messageSource, msg.fileContext); agent.setStepCallback(null); + agent.setNavigateCallback(null); const allSteps = agent.getReActSteps(); const lastStep = allSteps[allSteps.length - 1]; - if (lastStep && lastStep.isEnd) { + // Only re-broadcast the last step if it was NOT already sent via the callback + // (prevents duplicate messages — the callback sends isEnd:false, then we'd + // re-send the same step with isEnd:true after mutation). + if (lastStep && lastStep.isEnd && !broadcastedStepIds.has(lastStep.id)) { broadcastToAgent(agentId, { type: "step", step: lastStep }); } diff --git a/agent-service/src/types/agent.ts b/agent-service/src/types/agent.ts index 765f5a7cb46..12f6f02901d 100644 --- a/agent-service/src/types/agent.ts +++ b/agent-service/src/types/agent.ts @@ -35,6 +35,13 @@ export interface TokenUsage { export const INITIAL_STEP_ID = "step-initial"; +export interface FileContext { + fileName: string; + filePath: string; // full Texera path: /ownerEmail/datasetName/v1/fileName + datasetId?: number; // dataset ID for direct navigation to the dataset detail page + datasetVersionId?: number; // dvid for pre-selecting the version on the dataset page +} + export interface ReActStep { id: string; parentId?: string; @@ -58,6 +65,7 @@ export interface ReActStep { usage?: TokenUsage; inputMessages?: any[]; messageSource?: "chat" | "feedback"; + fileContext?: FileContext; beforeWorkflowContent?: WorkflowContent; afterWorkflowContent?: WorkflowContent; } @@ -85,7 +93,7 @@ export const DEFAULT_AGENT_SETTINGS: Omit = { operatorResultSerializationMode: OperatorResultSerializationMode.TSV, toolTimeoutMs: 240000, executionTimeoutMs: 240000, - maxSteps: 100, + maxSteps: 30, allowedOperatorTypes: [ "CSVFileScan", "Filter", diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 513b05b6985..7071c261f41 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -17,9 +17,13 @@ * under the License. */ -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { filter, switchMap, take } from "rxjs"; import { GuiConfigService } from "./common/service/gui-config.service"; -import { UntilDestroy } from "@ngneat/until-destroy"; +import { AgentService } from "./workspace/service/agent/agent.service"; +import { UserService } from "./common/service/user/user.service"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; @UntilDestroy() @Component({ @@ -34,16 +38,20 @@ import { UntilDestroy } from "@ngneat/until-destroy"; + `, standalone: false, }) -export class AppComponent { +export class AppComponent implements OnInit { configLoaded = false; - constructor(private config: GuiConfigService) { - // determine whether configuration was successfully loaded by APP_INITIALIZER + constructor( + private config: GuiConfigService, + private agentService: AgentService, + private userService: UserService, + private router: Router + ) { try { - // accessing env will throw if not loaded void this.config.env; this.configLoaded = true; } catch { @@ -51,6 +59,37 @@ export class AppComponent { } } + ngOnInit(): void { + // Listen for agent-driven navigation requests and execute them in the browser. + this.agentService.navigate$.pipe(untilDestroyed(this)).subscribe(url => { + this.router.navigateByUrl(url); + }); + + if (!this.configLoaded || !this.copilotEnabled) return; + + // Auto-open the agent panel when the user logs in and has no agents yet. + this.userService + .userChanged() + .pipe( + untilDestroyed(this), + filter(user => user !== undefined), // only when logged in + switchMap(() => + // Re-fetch agent list fresh after login (bypasses cached empty state) + this.agentService.getAllAgents().pipe(take(1)) + ) + ) + .subscribe(agents => { + if (agents.length === 0) { + // Delay so router finishes navigating to the dashboard before opening + setTimeout(() => this.agentService.requestOpenPanel(), 700); + } + }); + } + + get copilotEnabled(): boolean { + return this.config.env.copilotEnabled; + } + retry(): void { window.location.reload(); } diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index b04eafb3107..182d71d59be 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -20,6 +20,7 @@ diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 57e6e8e284e..5f3e35bdf91 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -27,6 +27,7 @@ import { HubComponent } from "../../hub/component/hub.component"; import { SocialAuthService, GoogleSigninButtonModule } from "@abacritt/angularx-social-login"; import { AdminSettingsService } from "../service/admin/settings/admin-settings.service"; import { GuiConfigService } from "../../common/service/gui-config.service"; +import { AgentService } from "../../workspace/service/agent/agent.service"; import { DASHBOARD_ABOUT, @@ -124,12 +125,20 @@ export class DashboardComponent implements OnInit { private socialAuthService: SocialAuthService, private route: ActivatedRoute, private adminSettingsService: AdminSettingsService, - protected config: GuiConfigService + protected config: GuiConfigService, + private agentService: AgentService ) {} ngOnInit(): void { this.isCollapsed = false; + // Collapse the sidebar automatically when the agent panel opens + this.agentService.agentPanelOpen$ + .pipe(untilDestroyed(this)) + .subscribe(panelOpen => { + this.isCollapsed = panelOpen; + }); + this.router.events.pipe(untilDestroyed(this)).subscribe(() => { this.checkRoute(); }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 1c347784269..cdfc32c370c 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -359,15 +359,20 @@ export class DatasetDetailComponent implements OnInit { retrieveDatasetVersionList() { if (this.did) { + // Read optional dvid query param to pre-select a specific version (e.g. after agent upload) + const preSelectDvid = this.route.snapshot.queryParamMap.get("dvid"); + this.datasetService .retrieveDatasetVersionList(this.did, this.isLogin) .pipe(untilDestroyed(this)) .subscribe(versionNames => { this.versions = versionNames; - // by default, the selected version is the 1st element in the retrieved list - // which is guaranteed(by the backend) to be the latest created version. if (this.versions.length > 0) { - this.selectedVersion = this.versions[0]; + // If a dvid was provided in the URL, pre-select that version + const target = preSelectDvid + ? this.versions.find(v => String(v.dvid) === preSelectDvid) ?? this.versions[0] + : this.versions[0]; + this.selectedVersion = target; this.onVersionSelected(this.selectedVersion); } }); diff --git a/frontend/src/app/hub/component/about/local-login/local-login.component.ts b/frontend/src/app/hub/component/about/local-login/local-login.component.ts index 215b17e3425..5ea97371bed 100644 --- a/frontend/src/app/hub/component/about/local-login/local-login.component.ts +++ b/frontend/src/app/hub/component/about/local-login/local-login.component.ts @@ -25,7 +25,7 @@ import { UserService } from "../../../../common/service/user/user.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { catchError } from "rxjs/operators"; import { throwError } from "rxjs"; -import { DASHBOARD_USER_WORKFLOW } from "../../../../app-routing.constant"; +import { DASHBOARD_HOME } from "../../../../app-routing.constant"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; import { NgIf } from "@angular/common"; @@ -132,7 +132,7 @@ export class LocalLoginComponent implements OnInit { untilDestroyed(this) ) .subscribe(() => - this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || DASHBOARD_USER_WORKFLOW) + this.router.navigateByUrl(this.route.snapshot.queryParams["returnUrl"] || DASHBOARD_HOME) ); } diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html index d650a0a146b..47e8e25d64f 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-chat/agent-chat.component.html @@ -113,6 +113,17 @@ (mouseenter)="setHoveredMessage(i)" (mouseleave)="setHoveredMessage(null)" style="position: relative"> + +
+ + {{ response.fileContext.fileName }} +
@@ -181,8 +192,92 @@
+ +
+
+ + + Found an existing copy of {{ duplicateFile.fileName }} + (uploaded {{ duplicateFile.uploadedDate }}). Use existing or upload a fresh copy? + +
+
+ + + +
+
+
+ + + + +
+ + {{ selectedFile.name }} + +
+ + +
+ + + Uploading... + +
+ + + + + + nzTooltipTitle="Open AI Agent"> + + AI Agent +
+
- -
    -
  • - -
  • -
+ (nzResizeEnd)="onResizeEnd()"> -
-

- AI Agents -

+ + - - - -
- {{ agents.length }} agent(s) -
-
+ +
+
+ + AI Agent +
+
+ {{ agents.length }} agent(s) + +
+
- - - - - - + + - - - -
- {{ agent.name }} - - -
-
- - - -
-
+ + + + + + - -
+ + + +
+ {{ agent.name }} + + +
+
+ + + +
+ +
diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.scss b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.scss index 776ec393f0c..d3b983b4073 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.scss +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.scss @@ -17,76 +17,180 @@ * under the License. */ +// Host: full-viewport overlay, pointer-events off so underlying UI isn't blocked :host { display: block; - width: 100%; - height: 100%; position: fixed; - z-index: 3; + top: 0; + right: 0; + height: 100vh; + z-index: 200; + pointer-events: none; } -#agent-container { +// ─── Collapsed tab ──────────────────────────────────────────────────────────── +// A thin vertical strip on the right edge, like Cursor's "AI" tab +.sidebar-tab { + pointer-events: all; position: absolute; - top: calc(-100% + 80px); + top: 50%; right: 0; - z-index: 3; - background: white; -} + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 28px; + padding: 16px 0; + background: #fff; + border: 1px solid #e8e8e8; + border-right: none; + border-radius: 6px 0 0 6px; + cursor: pointer; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.08); + transition: background 0.15s, box-shadow 0.15s; -#title { - padding: 5px 9px; - border-bottom: 1px solid #e0e0e0; - position: absolute; - top: 0; - background: white; - width: 100%; - z-index: 2; -} + &:hover { + background: #f0f7ff; + box-shadow: -3px 0 12px rgba(24, 144, 255, 0.15); -#agent-docked-button { - position: fixed; - bottom: 40px; // Position above the mini-map button - right: 10px; - z-index: 4; - box-shadow: - 0 3px 1px -2px #0003, - 0 2px 2px #00000024, - 0 1px 5px #0000001f; + i, .sidebar-tab-label { + color: #1890ff; + } + } + + i { + font-size: 16px; + color: #595959; + } + + .sidebar-tab-label { + font-size: 11px; + color: #595959; + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + letter-spacing: 0.5px; + user-select: none; + } } -#return-button { +// ─── Expanded sidebar panel ──────────────────────────────────────────────────── +#agent-container { + pointer-events: all; position: absolute; top: 0; right: 0; - z-index: 3; + height: 100vh; display: flex; -} + flex-direction: column; + background: #fff; + border-left: 1px solid #e8e8e8; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08); + overflow: hidden; -#content { - width: 100%; - height: 100%; - padding-top: 32px; - display: inline-block; - overflow-y: auto; -} + // Left resize handle — wide hit area with visible grip dots + ::ng-deep .ant-resizable-handle-left { + width: 8px; + left: 0; + background: transparent; + cursor: col-resize; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 10; + + // Grip dots + &::after { + content: ""; + display: block; + width: 2px; + height: 32px; + background: repeating-linear-gradient( + to bottom, + #d0d0d0 0px, + #d0d0d0 3px, + transparent 3px, + transparent 6px + ); + border-radius: 2px; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; + } + + &:hover { + background: rgba(24, 144, 255, 0.06); + &::after { + opacity: 1; + background: repeating-linear-gradient( + to bottom, + #1890ff 0px, + #1890ff 3px, + transparent 3px, + transparent 6px + ); + } + } -.shadow { - border-radius: 5px; - box-shadow: - 0 3px 1px -2px #0003, - 0 2px 2px #00000024, - 0 1px 5px #0000001f; + &:active { + background: rgba(24, 144, 255, 0.1); + } + } } -.ant-menu-item { - margin: 0 !important; - height: 32px; - line-height: 32px; - padding: 0 9px; +// ─── Panel header ────────────────────────────────────────────────────────────── +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + height: 44px; + min-height: 44px; + border-bottom: 1px solid #f0f0f0; + background: #fafafa; + flex-shrink: 0; + + .panel-header-left { + display: flex; + align-items: center; + } + + .panel-title { + font-weight: 600; + font-size: 14px; + color: #262626; + } + + .panel-header-right { + display: flex; + align-items: center; + gap: 8px; + } + + .agent-count { + font-size: 11px; + color: #8c8c8c; + background: #f0f0f0; + padding: 2px 8px; + border-radius: 10px; + } + + .close-btn { + color: #8c8c8c; + + &:hover { + color: #262626; + background: #f0f0f0 !important; + } + } } +// ─── Tabs ────────────────────────────────────────────────────────────────────── .agent-tabs { - height: calc(100% - 32px); // Account for the title bar + flex: 1; + min-height: 0; display: flex; flex-direction: column; overflow: hidden; @@ -100,6 +204,8 @@ .ant-tabs-nav { margin-bottom: 0; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; } .ant-tabs-content-holder { @@ -122,58 +228,46 @@ .agent-tab-title { display: flex; align-items: center; - gap: 8px; + gap: 6px; .agent-tab-name { flex: 1; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - // Visual feedback for agents on different workflows &.workflow-mismatch { opacity: 0.6; - - .agent-tab-name { - color: #8c8c8c; - } + .agent-tab-name { color: #8c8c8c; } } .workflow-lock-icon { color: #faad14; - font-size: 12px; - margin-left: -4px; + font-size: 11px; } } .agent-tab-close { padding: 0 !important; - width: 20px !important; - height: 20px !important; - min-width: 20px !important; + width: 18px !important; + height: 18px !important; + min-width: 18px !important; display: flex; align-items: center; justify-content: center; - opacity: 0.6; - margin-left: 4px; + opacity: 0; + transition: opacity 0.15s; - &:hover { - opacity: 1; - color: #ff4d4f !important; - background: rgba(255, 77, 79, 0.1) !important; + .agent-tab-title:hover & { + opacity: 0.6; } - i { - font-size: 12px; + &:hover { + opacity: 1 !important; + color: #ff4d4f !important; } -} - -.tab-bar-extra { - padding-right: 8px; -} -.agent-count { - font-size: 12px; - color: #8c8c8c; - padding: 4px 8px; - background: #f0f0f0; - border-radius: 4px; + i { font-size: 11px; } } diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.ts b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.ts index cf47b8b3ab7..fa129696d78 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.ts +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-panel.component.ts @@ -23,20 +23,15 @@ import { NzResizeEvent, NzResizableDirective, NzResizeHandlesComponent } from "n import { AgentService, AgentInfo } from "../../../service/agent/agent.service"; import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; import { NotificationService } from "../../../../common/service/notification/notification.service"; -import { calculateTotalTranslate3d } from "../../../../common/util/panel-dock"; -import { NgIf, NgClass, NgFor } from "@angular/common"; -import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; +import { NgIf, NgFor } from "@angular/common"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzIconDirective } from "ng-zorro-antd/icon"; -import { CdkDrag, CdkDragHandle } from "@angular/cdk/drag-drop"; -import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; import { NzTabsComponent, NzTabBarExtraContentDirective, NzTabComponent, NzTabDirective } from "ng-zorro-antd/tabs"; import { AgentRegistrationComponent } from "./agent-registration/agent-registration.component"; import { AgentChatComponent } from "./agent-chat/agent-chat.component"; -import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/repeat-dnd.component"; @UntilDestroy() @Component({ @@ -45,33 +40,24 @@ import { FormlyRepeatDndComponent } from "../../../../common/formly/repeat-dnd/r styleUrls: ["agent-panel.component.scss"], imports: [ NgIf, - NzSpaceCompactItemDirective, + NgFor, NzButtonComponent, NzWaveDirective, ɵNzTransitionPatchDirective, NzTooltipDirective, NzIconDirective, - CdkDrag, NzResizableDirective, - NzMenuDirective, - NgClass, - NzMenuItemComponent, - CdkDragHandle, NzTabsComponent, NzTabBarExtraContentDirective, NzTabComponent, NzTabDirective, AgentRegistrationComponent, - NgFor, AgentChatComponent, NzResizeHandlesComponent, - FormlyRepeatDndComponent, ], }) export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { - protected readonly window = window; - private static readonly MIN_PANEL_WIDTH = 400; - private static readonly MIN_PANEL_HEIGHT = 450; + static readonly MIN_PANEL_WIDTH = 380; /** * Optional agent ID to activate when the panel loads. @@ -80,13 +66,29 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { */ @Input() agentIdToActivate?: string; - // Panel dimensions and position - width: number = 0; // Start with 0 to show docked button - height = Math.max(AgentPanelComponent.MIN_PANEL_HEIGHT, window.innerHeight * 0.7); - id = -1; - dragPosition = { x: 0, y: 0 }; - returnPosition = { x: 0, y: 0 }; - isDocked = true; + // Width of the collapsed tab strip in px + private static readonly TAB_WIDTH = 28; + + // Separate open state from width so *ngIf doesn't destroy the panel during resize + isPanelOpen = false; + + // Sidebar width in px (only meaningful when isPanelOpen is true) + _width: number = AgentPanelComponent.MIN_PANEL_WIDTH; + get width(): number { return this._width; } + set width(v: number) { + const clamped = Math.max(AgentPanelComponent.MIN_PANEL_WIDTH, v); + this._width = clamped; + } + + private applyWidth(open: boolean): void { + const panelWidth = open ? this._width : 0; + document.body.style.paddingRight = open + ? `${this._width}px` + : `${AgentPanelComponent.TAB_WIDTH}px`; + this.agentService.setAgentPanelOpen(open); + } + + private resizeId = -1; // Tab management selectedTabIndex: number = 0; // 0 = registration tab, 1+ = agent tabs @@ -125,6 +127,14 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { // Try to activate the agent if agentIdToActivate is set this.tryActivateAgentFromInput(); }); + + // Open the panel when requested (e.g. on first login) + this.agentService.openPanel$.pipe(untilDestroyed(this)).subscribe(() => { + if (!this.isPanelOpen) { + this.isPanelOpen = true; + this.applyWidth(true); + } + }); } ngOnChanges(changes: SimpleChanges): void { @@ -148,8 +158,9 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { } // Open the panel if it's closed - if (this.width === 0) { - this.width = AgentPanelComponent.MIN_PANEL_WIDTH; + if (!this.isPanelOpen) { + this.isPanelOpen = true; + this.applyWidth(true); } // Switch to the agent's tab and activate it @@ -171,23 +182,17 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { @HostListener("window:beforeunload") ngOnDestroy(): void { - // Deactivate any active agent before destroying this.deactivateCurrentAgent(); this.savePanelSettings(); + document.body.style.paddingRight = ""; } - /** - * Open the panel from docked state - */ + /** Used by the tryActivateAgentFromInput and openPanel$ to check open state */ + get isOpen(): boolean { return this.isPanelOpen; } + public openPanel(): void { - if (this.width === 0) { - // Open panel - this.width = AgentPanelComponent.MIN_PANEL_WIDTH; - } else { - // Close panel (dock it) - this.width = 0; - this.isDocked = true; - } + this.isPanelOpen = !this.isPanelOpen; + this.applyWidth(this.isPanelOpen); } /** @@ -237,19 +242,8 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { const agentWorkflowId = agent.delegate?.workflowId; const currentWorkflowId = this.workflowActionService.getWorkflowMetadata().wid; - // If agent has a workflow ID, check if it matches the current workflow - if (agentWorkflowId !== undefined && agentWorkflowId !== 0) { - if (currentWorkflowId !== agentWorkflowId) { - // Block switching - workflow mismatch - this.notificationService.warning( - `Cannot switch to agent "${agent.name}": It's working on a different workflow. ` + - `Open workflow #${agentWorkflowId} to interact with this agent.` - ); - return; - } - } - - // Workflow matches or agent has no workflow - allow switch + // Switch to the agent regardless of which page the user is on. + // The agent works from any page via the chat panel — no navigation needed. this.switchToAgent(agent.id, index); } @@ -330,71 +324,35 @@ export class AgentPanelComponent implements OnInit, OnDestroy, OnChanges { } } - /** - * Handle panel resize - */ - onResize({ width, height }: NzResizeEvent): void { - cancelAnimationFrame(this.id); - this.id = requestAnimationFrame(() => { - this.width = width!; - this.height = height!; + onResize({ width }: NzResizeEvent): void { + if (!width || width < AgentPanelComponent.MIN_PANEL_WIDTH) return; + cancelAnimationFrame(this.resizeId); + this.resizeId = requestAnimationFrame(() => { + this._width = width; + // Update body padding in real-time during drag + document.body.style.paddingRight = `${width}px`; }); } - /** - * Handle drag start - */ - handleDragStart(): void { - this.isDocked = false; + onResizeEnd(): void { + this.savePanelSettings(); } - /** - * Load panel settings from localStorage - */ private loadPanelSettings(): void { const savedWidth = localStorage.getItem("agent-panel-width"); - const savedHeight = localStorage.getItem("agent-panel-height"); - const savedStyle = localStorage.getItem("agent-panel-style"); - const savedDocked = localStorage.getItem("agent-panel-docked"); - - // Only restore width if the panel was not docked - if (savedDocked === "false" && savedWidth) { - const parsedWidth = Number(savedWidth); - if (!isNaN(parsedWidth) && parsedWidth >= AgentPanelComponent.MIN_PANEL_WIDTH) { - this.width = parsedWidth; - } - } - - if (savedHeight) { - const parsedHeight = Number(savedHeight); - if (!isNaN(parsedHeight) && parsedHeight >= AgentPanelComponent.MIN_PANEL_HEIGHT) { - this.height = parsedHeight; - } - } - - if (savedStyle) { - const container = document.getElementById("agent-container"); - if (container) { - container.style.cssText = savedStyle; - const translates = container.style.transform; - const [xOffset, yOffset] = calculateTotalTranslate3d(translates); - this.returnPosition = { x: -xOffset, y: -yOffset }; - this.isDocked = this.dragPosition.x === this.returnPosition.x && this.dragPosition.y === this.returnPosition.y; + const savedOpen = localStorage.getItem("agent-panel-open"); + if (savedWidth) { + const w = Number(savedWidth); + if (!isNaN(w) && w >= AgentPanelComponent.MIN_PANEL_WIDTH) { + this._width = w; } } + this.isPanelOpen = savedOpen === "true"; + this.applyWidth(this.isPanelOpen); } - /** - * Save panel settings to localStorage - */ private savePanelSettings(): void { - localStorage.setItem("agent-panel-width", String(this.width)); - localStorage.setItem("agent-panel-height", String(this.height)); - localStorage.setItem("agent-panel-docked", String(this.width === 0)); - - const container = document.getElementById("agent-container"); - if (container) { - localStorage.setItem("agent-panel-style", container.style.cssText); - } + localStorage.setItem("agent-panel-width", String(this._width)); + localStorage.setItem("agent-panel-open", String(this.isPanelOpen)); } } diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.html b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.html index 61d3bc65fee..c801a5e4531 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.html +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.html @@ -18,9 +18,65 @@ -->
+ + +
+
+ +

Meet your Texera AI Agent

+

Your natural-language assistant for data analysis — no clicking required.

+
+
+
+ +
+ Upload & explore files + Drop a CSV or dataset and ask "what columns do I have?" +
+
+
+ +
+ Run analysis in plain English + "Compute correlation between Human and GPT3 ratings by dataset" +
+
+
+ +
+ Create visualizations + "Plot a scatterplot of Human vs GPT3, colored by dataset" +
+
+
+ +
+ Navigate anywhere + "Go to my datasets", "Open my previous workflow", "Show my compute units" +
+
+
+ +
+ + +
-

Welcome to Texera Agent!

-

Select a model type and create an AI agent to assist with your data science tasks

+

Create an AI Agent

+

Select a model and start analyzing your data with natural language.

@@ -83,21 +139,13 @@

Agent Name (Optional)

- - +
+
diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.scss b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.scss index 639dcb78eb0..c69ef47a878 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.scss +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.scss @@ -145,3 +145,67 @@ min-width: 200px; } } + +.welcome-screen { + padding: 16px 8px; + display: flex; + flex-direction: column; + gap: 20px; + + .welcome-hero { + text-align: center; + padding: 8px 0; + + .welcome-icon { + font-size: 48px; + color: #1890ff; + display: block; + margin-bottom: 12px; + } + + h2 { + margin: 0 0 8px; + font-size: 18px; + font-weight: 600; + } + + .welcome-subtitle { + color: #666; + font-size: 13px; + margin: 0; + } + } + + .welcome-capabilities { + display: flex; + flex-direction: column; + gap: 12px; + + .capability-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 12px; + background: #fafafa; + border-radius: 8px; + border: 1px solid #f0f0f0; + + i { font-size: 18px; color: #1890ff; flex-shrink: 0; margin-top: 2px; } + + div { + display: flex; + flex-direction: column; + gap: 2px; + + strong { font-size: 13px; font-weight: 600; } + span { font-size: 12px; color: #888; } + } + } + } + + .get-started-btn { + width: 100%; + height: 44px; + font-size: 15px; + } +} diff --git a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.ts b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.ts index f8bcfff4628..515509277ae 100644 --- a/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.ts +++ b/frontend/src/app/workspace/component/agent/agent-panel/agent-registration/agent-registration.component.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from "@angular/core"; import { AgentService, ModelType } from "../../../../service/agent/agent.service"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { WorkflowActionService } from "../../../../service/workflow-graph/model/workflow-action.service"; @@ -55,9 +55,11 @@ import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; NzTooltipDirective, ], }) -export class AgentRegistrationComponent implements OnInit, OnDestroy { +export class AgentRegistrationComponent implements OnInit, OnChanges, OnDestroy { @Output() agentCreated = new EventEmitter(); + @Input() isFirstTime: boolean = false; + public showWelcome: boolean = false; public modelTypes: ModelType[] = []; public selectedModelType: string | null = null; public customAgentName: string = "Texera Agent"; @@ -75,7 +77,18 @@ export class AgentRegistrationComponent implements OnInit, OnDestroy { private computingUnitStatusService: ComputingUnitStatusService ) {} + ngOnChanges(changes: SimpleChanges): void { + if (changes["isFirstTime"]) { + this.showWelcome = this.isFirstTime; + } + } + + public getStarted(): void { + this.showWelcome = false; + } + ngOnInit(): void { + this.showWelcome = this.isFirstTime; this.isLoadingModels = true; this.hasLoadingError = false; @@ -120,12 +133,14 @@ export class AgentRegistrationComponent implements OnInit, OnDestroy { if (!this.selectedModelType || this.isCreating) { return; } - this.isCreating = true; + // Use the open workflow ID if in the workspace; otherwise create the agent + // without a workflow — one will be created lazily when analysis is needed. + const workflowId = this.workflowActionService.getWorkflowMetadata()?.wid; + this.doCreateAgent(workflowId); + } - const workflowMetadata = this.workflowActionService.getWorkflowMetadata(); - const workflowId = workflowMetadata?.wid; - + private doCreateAgent(workflowId?: number): void { this.agentService .createAgent(this.selectedModelType!, this.customAgentName || undefined, workflowId) .pipe(takeUntil(this.destroy$)) @@ -148,6 +163,6 @@ export class AgentRegistrationComponent implements OnInit, OnDestroy { } public canCreate(): boolean { - return this.selectedModelType !== null && !this.isCreating && this.computingUnitConnected; + return this.selectedModelType !== null && !this.isCreating; } } diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index c54446fb318..2e662831a33 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -33,8 +33,5 @@ - diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts index 0eaaf7f5fb4..fcda50b6c96 100644 --- a/frontend/src/app/workspace/component/workspace.component.spec.ts +++ b/frontend/src/app/workspace/component/workspace.component.spec.ts @@ -352,11 +352,5 @@ describe("WorkspaceComponent", () => { }); }); - describe("copilotEnabled", () => { - it("passes through to GuiConfigService.env.copilotEnabled", async () => { - await createFixture(); - // MockGuiConfigService defaults `copilotEnabled` to false. - expect(component.copilotEnabled).toBe(false); - }); - }); + // copilotEnabled was moved to AppComponent; WorkspaceComponent no longer has it. }); diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts index 9968c26f647..8ff7df5e807 100644 --- a/frontend/src/app/workspace/component/workspace.component.ts +++ b/frontend/src/app/workspace/component/workspace.component.ts @@ -50,7 +50,6 @@ import { EntityType, HubService } from "../../hub/service/hub.service"; import { THROTTLE_TIME_MS } from "../../hub/component/workflow/detail/hub-workflow-detail.component"; import { WorkflowCompilingService } from "../service/compile-workflow/workflow-compiling.service"; import { DASHBOARD_USER_WORKSPACE } from "../../app-routing.constant"; -import { GuiConfigService } from "../../common/service/gui-config.service"; import { checkIfWorkflowBroken } from "../../common/util/workflow-check"; import { NzSpinComponent } from "ng-zorro-antd/spin"; import { ResultPanelComponent } from "./result-panel/result-panel.component"; @@ -58,7 +57,6 @@ import { WorkflowEditorComponent } from "./workflow-editor/workflow-editor.compo import { MenuComponent } from "./menu/menu.component"; import { MiniMapComponent } from "./workflow-editor/mini-map/mini-map.component"; import { LeftPanelComponent } from "./left-panel/left-panel.component"; -import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component"; import { PropertyEditorComponent } from "./property-editor/property-editor.component"; import { FormlyRepeatDndComponent } from "../../common/formly/repeat-dnd/repeat-dnd.component"; @@ -81,7 +79,6 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; MiniMapComponent, LeftPanelComponent, NgIf, - AgentPanelComponent, PropertyEditorComponent, FormlyRepeatDndComponent, ], @@ -92,13 +89,6 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { public isLoading: boolean = false; @ViewChild("codeEditor", { read: ViewContainerRef }) codeEditorViewRef!: ViewContainerRef; - /** - * Optional agent ID to activate when the workspace loads. - * When provided (from agent dashboard), the agent panel will open - * and connect to this agent automatically. - */ - @Input() agentIdToActivate?: string; - /** * Flag to ensure auto persist is registered only once. This prevents multiple * subscriptions and avoids accidental persistence of an empty workflow @@ -125,7 +115,6 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { private notificationService: NotificationService, private hubService: HubService, private codeEditorService: CodeEditorService, - private config: GuiConfigService, private changeDetectorRef: ChangeDetectorRef ) {} @@ -325,7 +314,4 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { this.changeDetectorRef.detectChanges(); } - public get copilotEnabled(): boolean { - return this.config.env.copilotEnabled; - } } diff --git a/frontend/src/app/workspace/service/agent/agent-types.ts b/frontend/src/app/workspace/service/agent/agent-types.ts index c687de472a2..1505aadb725 100644 --- a/frontend/src/app/workspace/service/agent/agent-types.ts +++ b/frontend/src/app/workspace/service/agent/agent-types.ts @@ -74,6 +74,8 @@ export interface ReActStep { parentId?: string; /** Source of the user message: "chat" or "feedback" */ messageSource?: string; + /** File uploaded with this message (user steps only) */ + fileContext?: { fileName: string; filePath: string; datasetId?: number; datasetVersionId?: number }; /** Workflow state before this step executed */ beforeWorkflowContent?: any; /** Workflow state after this step executed */ diff --git a/frontend/src/app/workspace/service/agent/agent.service.ts b/frontend/src/app/workspace/service/agent/agent.service.ts index 2009734030b..b4e4cd60f01 100644 --- a/frontend/src/app/workspace/service/agent/agent.service.ts +++ b/frontend/src/app/workspace/service/agent/agent.service.ts @@ -25,6 +25,7 @@ import { BehaviorSubject, catchError, filter, + forkJoin, map, of, shareReplay, @@ -32,6 +33,7 @@ import { throwError, interval, switchMap, + take, takeUntil, } from "rxjs"; import { NotificationService } from "../../../common/service/notification/notification.service"; @@ -41,6 +43,8 @@ import { AuthService } from "../../../common/service/user/auth.service"; import { AgentState, ReActStep, ModelMessage } from "./agent-types"; import { Workflow, WorkflowContent } from "../../../common/type/workflow"; import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service"; +import { Dataset } from "../../../common/type/dataset"; /** * Agent settings for API (serializable format). @@ -222,12 +226,33 @@ export class AgentService { private scrollToStepSubject = new Subject<{ agentId: string; messageId: string; stepId: number }>(); public scrollToStep$ = this.scrollToStepSubject.asObservable(); + /** Subject emitting navigation URLs requested by the agent */ + private navigateSubject = new Subject(); + public navigate$ = this.navigateSubject.asObservable(); + + /** Subject requesting the agent panel to open */ + private openPanelSubject = new Subject(); + public openPanel$ = this.openPanelSubject.asObservable(); + + public requestOpenPanel(): void { + this.openPanelSubject.next(); + } + + /** Emits true when the agent panel is open (sidebar should collapse) */ + private agentPanelOpenSubject = new BehaviorSubject(false); + public agentPanelOpen$ = this.agentPanelOpenSubject.asObservable(); + + public setAgentPanelOpen(open: boolean): void { + this.agentPanelOpenSubject.next(open); + } + constructor( private http: HttpClient, private notificationService: NotificationService, private workflowPersistService: WorkflowPersistService, private ngZone: NgZone, - private computingUnitStatusService: ComputingUnitStatusService + private computingUnitStatusService: ComputingUnitStatusService, + private datasetService: DatasetService ) { // Sync local cache with backend on service initialization // This handles cases where the backend was restarted @@ -575,6 +600,12 @@ export class AgentService { } break; + case "navigate": + if (message.url) { + this.navigateSubject.next(message.url); + } + break; + default: console.warn("Unknown agent WebSocket message type:", message.type); } @@ -901,7 +932,156 @@ export class AgentService { * Send a message to an agent via WebSocket. * The message is sent through the WebSocket connection for real-time streaming. */ - public sendMessage(agentId: string, message: string, messageSource: "chat" | "feedback" = "chat"): void { + /** + * Check if a file with the same name already exists in the user's datasets. + * Returns the existing file info if found, null otherwise. + */ + public checkExistingFile( + file: File + ): Observable<{ fileName: string; filePath: string; uploadedDate: string; datasetId: number; datasetVersionId?: number } | null> { + const safeFileName = file.name.replace(/\s+/g, "_"); + const expectedDesc = `agent-upload:${safeFileName}`; + + /** Fetch the latest dvid for a dataset so the version auto-selects on navigation. */ + const getLatestDvid = (did: number): Observable => + this.datasetService.createDatasetVersion(did, "").pipe( + // We don't want to CREATE a version — just read the latest one. + // Use getDataset + version list instead. + switchMap(() => of(undefined)), + catchError(() => of(undefined)) + ); + + const fetchDvid = (did: number): Observable => + this.http + .get<{ datasetVersion?: { dvid?: number } }>( + `${AppSettings.getApiEndpoint()}/dataset/${did}/version/latest` + ) + .pipe( + map(r => r?.datasetVersion?.dvid ?? undefined), + catchError(() => of(undefined)) + ); + + return this.datasetService.retrieveAccessibleDatasets().pipe( + switchMap(datasets => { + // Fast path: check new-format description + const newMatch = datasets.find(d => d.dataset.description === expectedDesc); + if (newMatch) { + const ownerEmail = newMatch.ownerEmail; + const did = newMatch.dataset.did!; + const filePath = `/${ownerEmail}/${newMatch.dataset.name}/v1/${safeFileName}`; + const uploadedDate = newMatch.dataset.creationTime + ? new Date(newMatch.dataset.creationTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "previously"; + return fetchDvid(did).pipe( + map(dvid => ({ fileName: safeFileName, filePath, uploadedDate, datasetId: did, datasetVersionId: dvid })) + ); + } + + // Slow path: check old-format datasets by exact suffix match on the dataset name. + // Pattern: agent_upload_{timestamp}_{safeDatasetName} + const safeDatasetSuffix = file.name + .toLowerCase() + .replace(/\s+/g, "_") + .replace(/\./g, "_") + .replace(/[^a-z0-9_-]/g, "_"); + + // Only check datasets whose name ends with exactly _ + const candidates = datasets + .filter(d => d.dataset.name.endsWith(`_${safeDatasetSuffix}`)) + .slice(0, 5); + + if (candidates.length === 0) return of(null); + + const checks$ = candidates.map(candidate => of(candidate).pipe( + map(c => { + const ownerEmail = c.ownerEmail; + const dsName = c.dataset.name; + // Verify it's a properly formatted agent_upload dataset + if (!dsName.startsWith("agent_upload_")) return null; + + const filePath = `/${ownerEmail}/${dsName}/v1/${safeFileName}`; + const uploadedDate = c.dataset.creationTime + ? new Date(c.dataset.creationTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : "previously"; + return { fileName: safeFileName, filePath, uploadedDate, datasetId: c.dataset.did!, datasetVersionId: undefined }; + }), + catchError(() => of(null)) + )); + + return forkJoin(checks$).pipe( + map(results => results.find(r => r !== null) ?? null) + ); + }), + catchError(() => of(null)) + ); + } + + public uploadFile(_agentId: string, file: File): Observable<{ fileName: string; filePath: string }> { + // Dataset name: lowercase, no spaces, only safe chars + const safeDatasetName = file.name + .toLowerCase() + .replace(/\s+/g, "_") + .replace(/\./g, "_") + .replace(/[^a-z0-9_-]/g, "_"); + const datasetName = `agent_upload_${Date.now()}_${safeDatasetName}`; + + // File path inside the dataset: keep original extension, replace spaces with underscores + const safeFileName = file.name.replace(/\s+/g, "_"); + + const PART_SIZE = 5 * 1024 * 1024; // 5 MB — S3 minimum for non-final parts + + const dataset: Dataset = { + did: undefined, + ownerUid: undefined, + name: datasetName, + // Store the safe filename so the agent service can reconstruct the full Texera path. + // Format: "agent-upload:{safeFileName}" — parsed by listDatasets in the agent service. + description: `agent-upload:${safeFileName}`, + isPublic: false, + isDownloadable: false, + storagePath: undefined, + creationTime: undefined, + coverImage: undefined, + }; + + return this.datasetService.createDataset(dataset).pipe( + switchMap(dashboardDataset => { + const { did } = dashboardDataset.dataset; + const ownerEmail = dashboardDataset.ownerEmail; + + return this.datasetService + .multipartUpload(ownerEmail, datasetName, safeFileName, file, PART_SIZE, 3, false) + .pipe( + filter(p => p.status === "finished" || p.status === "failed"), + take(1), + switchMap(progress => { + if (progress.status === "failed") { + throw new Error(`File upload failed for ${file.name}`); + } + // Create a dataset version so FileResolver can find the file. + // Pass empty string so the server generates "v1" (not "v1 - v1") as the version name. + // Use the actual version name from the response to build the file path, + // since FileResolver looks up DATASET_VERSION.NAME by exact match. + return this.datasetService.createDatasetVersion(did!, "").pipe( + map(version => ({ + fileName: safeFileName, + filePath: `/${ownerEmail}/${datasetName}/${version.name}/${safeFileName}`, + datasetId: did!, + datasetVersionId: version.dvid, // dvid for direct version selection in UI + })) + ); + }) + ); + }) + ); + } + + public sendMessage( + agentId: string, + message: string, + messageSource: "chat" | "feedback" = "chat", + fileContext?: { fileName: string; filePath: string; datasetId?: number; datasetVersionId?: number } + ): void { const agent = this.agents.get(agentId); if (!agent) { this.notificationService.error(`Agent with ID ${agentId} not found`); @@ -914,11 +1094,14 @@ export class AgentService { return; } - const wsMessage = { + const wsMessage: Record = { type: "message", content: message, messageSource, }; + if (fileContext) { + wsMessage["fileContext"] = fileContext; + } try { tracking.websocket.send(JSON.stringify(wsMessage));