mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
12 Commits
fix/gmail-
...
lluisagust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c047a1fc91 | ||
|
|
a5c12ae9e0 | ||
|
|
ab5e451f32 | ||
|
|
87e4dfb2da | ||
|
|
b86e8c3fe9 | ||
|
|
c41ea161ce | ||
|
|
d3f95071af | ||
|
|
fc7744db71 | ||
|
|
224663b56c | ||
|
|
299fd9ea5c | ||
|
|
a1ad8e18bd | ||
|
|
96a3bb9aa2 |
@@ -131,6 +131,54 @@ class StorageUsageResponse(BaseModel):
|
||||
file_count: int
|
||||
|
||||
|
||||
class DownloadUrlResponse(BaseModel):
|
||||
url: str
|
||||
direct: bool # True = browser can fetch URL directly (signed GCS URL)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/download-url",
|
||||
summary="Get download URL for a file",
|
||||
)
|
||||
async def get_file_download_url(
|
||||
user_id: Annotated[str, fastapi.Security(get_user_id)],
|
||||
file_id: str,
|
||||
) -> DownloadUrlResponse:
|
||||
"""
|
||||
Return a download URL for a workspace file.
|
||||
|
||||
For GCS storage: returns a time-limited signed URL the browser can fetch directly.
|
||||
For local storage: returns the API download path (must still be proxied).
|
||||
"""
|
||||
workspace = await get_workspace(user_id)
|
||||
if workspace is None:
|
||||
raise fastapi.HTTPException(status_code=404, detail="Workspace not found")
|
||||
|
||||
file = await get_workspace_file(file_id, workspace.id)
|
||||
if file is None:
|
||||
raise fastapi.HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
storage = await get_workspace_storage()
|
||||
|
||||
if file.storage_path.startswith("local://"):
|
||||
return DownloadUrlResponse(
|
||||
url=f"/api/workspace/files/{file_id}/download",
|
||||
direct=False,
|
||||
)
|
||||
|
||||
# GCS — try to generate signed URL
|
||||
try:
|
||||
url = await storage.get_download_url(file.storage_path, expires_in=300)
|
||||
if url.startswith("/api/"):
|
||||
return DownloadUrlResponse(url=url, direct=False)
|
||||
return DownloadUrlResponse(url=url, direct=True)
|
||||
except Exception:
|
||||
return DownloadUrlResponse(
|
||||
url=f"/api/workspace/files/{file_id}/download",
|
||||
direct=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/files/{file_id}/download",
|
||||
summary="Download file by ID",
|
||||
|
||||
@@ -5,15 +5,19 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Message, MessageContent } from "@/components/ai-elements/message";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { FileUIPart, ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
|
||||
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
|
||||
import { parseSpecialMarkers } from "./helpers";
|
||||
import { AssistantMessageActions } from "./components/AssistantMessageActions";
|
||||
import { CollapsedToolGroup } from "./components/CollapsedToolGroup";
|
||||
import { MessageAttachments } from "./components/MessageAttachments";
|
||||
import { MessagePartRenderer } from "./components/MessagePartRenderer";
|
||||
import { ReasoningCollapse } from "./components/ReasoningCollapse";
|
||||
import { ThinkingIndicator } from "./components/ThinkingIndicator";
|
||||
|
||||
type MessagePart = UIMessage<unknown, UIDataTypes, UITools>["parts"][number];
|
||||
|
||||
interface Props {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
status: string;
|
||||
@@ -23,6 +27,132 @@ interface Props {
|
||||
sessionID?: string | null;
|
||||
}
|
||||
|
||||
function isCompletedToolPart(part: MessagePart): part is ToolUIPart {
|
||||
return (
|
||||
part.type.startsWith("tool-") &&
|
||||
"state" in part &&
|
||||
(part.state === "output-available" || part.state === "output-error")
|
||||
);
|
||||
}
|
||||
|
||||
type RenderSegment =
|
||||
| { kind: "part"; part: MessagePart; index: number }
|
||||
| { kind: "collapsed-group"; parts: ToolUIPart[] };
|
||||
|
||||
// Tool types that have custom renderers and should NOT be collapsed
|
||||
const CUSTOM_TOOL_TYPES = new Set([
|
||||
"tool-find_block",
|
||||
"tool-find_agent",
|
||||
"tool-find_library_agent",
|
||||
"tool-search_docs",
|
||||
"tool-get_doc_page",
|
||||
"tool-run_block",
|
||||
"tool-run_mcp_tool",
|
||||
"tool-run_agent",
|
||||
"tool-schedule_agent",
|
||||
"tool-create_agent",
|
||||
"tool-edit_agent",
|
||||
"tool-view_agent_output",
|
||||
"tool-search_feature_requests",
|
||||
"tool-create_feature_request",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Groups consecutive completed generic tool parts into collapsed segments.
|
||||
* Non-generic tools (those with custom renderers) and active/streaming tools
|
||||
* are left as individual parts.
|
||||
*/
|
||||
function buildRenderSegments(
|
||||
parts: MessagePart[],
|
||||
baseIndex = 0,
|
||||
): RenderSegment[] {
|
||||
const segments: RenderSegment[] = [];
|
||||
let pendingGroup: Array<{ part: ToolUIPart; index: number }> | null = null;
|
||||
|
||||
function flushGroup() {
|
||||
if (!pendingGroup) return;
|
||||
if (pendingGroup.length >= 2) {
|
||||
segments.push({
|
||||
kind: "collapsed-group",
|
||||
parts: pendingGroup.map((p) => p.part),
|
||||
});
|
||||
} else {
|
||||
for (const p of pendingGroup) {
|
||||
segments.push({ kind: "part", part: p.part, index: p.index });
|
||||
}
|
||||
}
|
||||
pendingGroup = null;
|
||||
}
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
const absoluteIndex = baseIndex + i;
|
||||
const isGenericCompletedTool =
|
||||
isCompletedToolPart(part) && !CUSTOM_TOOL_TYPES.has(part.type);
|
||||
|
||||
if (isGenericCompletedTool) {
|
||||
if (!pendingGroup) pendingGroup = [];
|
||||
pendingGroup.push({ part: part as ToolUIPart, index: absoluteIndex });
|
||||
} else {
|
||||
flushGroup();
|
||||
segments.push({ kind: "part", part, index: absoluteIndex });
|
||||
}
|
||||
});
|
||||
|
||||
flushGroup();
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* For finalized assistant messages, split parts into "reasoning" (intermediate
|
||||
* text + tools before the final response) and "response" (final text after the
|
||||
* last tool). If there are no tools, everything is response.
|
||||
*/
|
||||
function splitReasoningAndResponse(parts: MessagePart[]): {
|
||||
reasoning: MessagePart[];
|
||||
response: MessagePart[];
|
||||
} {
|
||||
const lastToolIndex = parts.findLastIndex((p) => p.type.startsWith("tool-"));
|
||||
|
||||
// No tools → everything is response
|
||||
if (lastToolIndex === -1) {
|
||||
return { reasoning: [], response: parts };
|
||||
}
|
||||
|
||||
// Check if there's any text after the last tool
|
||||
const hasResponseAfterTools = parts
|
||||
.slice(lastToolIndex + 1)
|
||||
.some((p) => p.type === "text");
|
||||
|
||||
if (!hasResponseAfterTools) {
|
||||
// No final text response → don't collapse anything
|
||||
return { reasoning: [], response: parts };
|
||||
}
|
||||
|
||||
return {
|
||||
reasoning: parts.slice(0, lastToolIndex + 1),
|
||||
response: parts.slice(lastToolIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function renderSegments(
|
||||
segments: RenderSegment[],
|
||||
messageID: string,
|
||||
): React.ReactNode[] {
|
||||
return segments.map((seg, segIdx) => {
|
||||
if (seg.kind === "collapsed-group") {
|
||||
return <CollapsedToolGroup key={`group-${segIdx}`} parts={seg.parts} />;
|
||||
}
|
||||
return (
|
||||
<MessagePartRenderer
|
||||
key={`${messageID}-${seg.index}`}
|
||||
part={seg.part}
|
||||
messageID={messageID}
|
||||
partIndex={seg.index}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Collect all messages belonging to a turn: the user message + every
|
||||
* assistant message up to (but not including) the next user message. */
|
||||
function getTurnMessages(
|
||||
@@ -119,6 +249,24 @@ export function ChatMessagesContainer({
|
||||
(p): p is FileUIPart => p.type === "file",
|
||||
);
|
||||
|
||||
// For finalized assistant messages, split into reasoning + response.
|
||||
// During streaming, show everything normally with tool collapsing.
|
||||
const isFinalized =
|
||||
message.role === "assistant" && !isCurrentlyStreaming;
|
||||
const { reasoning, response } = isFinalized
|
||||
? splitReasoningAndResponse(message.parts)
|
||||
: { reasoning: [] as MessagePart[], response: message.parts };
|
||||
const hasReasoning = reasoning.length > 0;
|
||||
|
||||
const responseStartIndex = message.parts.length - response.length;
|
||||
const responseSegments =
|
||||
message.role === "assistant"
|
||||
? buildRenderSegments(response, responseStartIndex)
|
||||
: null;
|
||||
const reasoningSegments = hasReasoning
|
||||
? buildRenderSegments(reasoning, 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent
|
||||
@@ -128,14 +276,21 @@ export function ChatMessagesContainer({
|
||||
"group-[.is-assistant]:bg-transparent group-[.is-assistant]:text-slate-900"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, i) => (
|
||||
<MessagePartRenderer
|
||||
key={`${message.id}-${i}`}
|
||||
part={part}
|
||||
messageID={message.id}
|
||||
partIndex={i}
|
||||
/>
|
||||
))}
|
||||
{hasReasoning && reasoningSegments && (
|
||||
<ReasoningCollapse>
|
||||
{renderSegments(reasoningSegments, message.id)}
|
||||
</ReasoningCollapse>
|
||||
)}
|
||||
{responseSegments
|
||||
? renderSegments(responseSegments, message.id)
|
||||
: message.parts.map((part, i) => (
|
||||
<MessagePartRenderer
|
||||
key={`${message.id}-${i}`}
|
||||
part={part}
|
||||
messageID={message.id}
|
||||
partIndex={i}
|
||||
/>
|
||||
))}
|
||||
{isLastInTurn && !isCurrentlyStreaming && (
|
||||
<TurnStatsBar
|
||||
turnMessages={getTurnMessages(messages, messageIndex)}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import {
|
||||
ArrowsClockwiseIcon,
|
||||
CaretRightIcon,
|
||||
CheckCircleIcon,
|
||||
FileIcon,
|
||||
FilesIcon,
|
||||
GearIcon,
|
||||
GlobeIcon,
|
||||
ListChecksIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MonitorIcon,
|
||||
PencilSimpleIcon,
|
||||
TerminalIcon,
|
||||
TrashIcon,
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
type ToolCategory,
|
||||
extractToolName,
|
||||
getAnimationText,
|
||||
getToolCategory,
|
||||
} from "../../../tools/GenericTool/helpers";
|
||||
|
||||
interface Props {
|
||||
parts: ToolUIPart[];
|
||||
}
|
||||
|
||||
/** Category icon matching GenericTool's ToolIcon for completed states. */
|
||||
function EntryIcon({
|
||||
category,
|
||||
isError,
|
||||
}: {
|
||||
category: ToolCategory;
|
||||
isError: boolean;
|
||||
}) {
|
||||
if (isError) {
|
||||
return (
|
||||
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
|
||||
);
|
||||
}
|
||||
|
||||
const iconClass = "text-green-500";
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return <TerminalIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "web":
|
||||
return <GlobeIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "browser":
|
||||
return <MonitorIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "file-read":
|
||||
case "file-write":
|
||||
return <FileIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "file-delete":
|
||||
return <TrashIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "file-list":
|
||||
return <FilesIcon size={14} weight="regular" className={iconClass} />;
|
||||
case "search":
|
||||
return (
|
||||
<MagnifyingGlassIcon size={14} weight="regular" className={iconClass} />
|
||||
);
|
||||
case "edit":
|
||||
return (
|
||||
<PencilSimpleIcon size={14} weight="regular" className={iconClass} />
|
||||
);
|
||||
case "todo":
|
||||
return (
|
||||
<ListChecksIcon size={14} weight="regular" className={iconClass} />
|
||||
);
|
||||
case "compaction":
|
||||
return (
|
||||
<ArrowsClockwiseIcon size={14} weight="regular" className={iconClass} />
|
||||
);
|
||||
default:
|
||||
return <GearIcon size={14} weight="regular" className={iconClass} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function CollapsedToolGroup({ parts }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
|
||||
const errorCount = parts.filter((p) => p.state === "output-error").length;
|
||||
const label =
|
||||
errorCount > 0
|
||||
? `${parts.length} tool calls (${errorCount} failed)`
|
||||
: `${parts.length} tool calls completed`;
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<CaretRightIcon
|
||||
size={12}
|
||||
weight="bold"
|
||||
className={
|
||||
"transition-transform duration-150 " + (expanded ? "rotate-90" : "")
|
||||
}
|
||||
/>
|
||||
{errorCount > 0 ? (
|
||||
<WarningDiamondIcon
|
||||
size={14}
|
||||
weight="regular"
|
||||
className="text-red-500"
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon
|
||||
size={14}
|
||||
weight="regular"
|
||||
className="text-green-500"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
id={panelId}
|
||||
className="ml-5 mt-1 space-y-0.5 border-l border-neutral-200 pl-3"
|
||||
>
|
||||
{parts.map((part) => {
|
||||
const toolName = extractToolName(part);
|
||||
const category = getToolCategory(toolName);
|
||||
const text = getAnimationText(part, category);
|
||||
const isError = part.state === "output-error";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.toolCallId}
|
||||
className={
|
||||
"flex items-center gap-1.5 text-xs " +
|
||||
(isError ? "text-red-500" : "text-muted-foreground")
|
||||
}
|
||||
>
|
||||
<EntryIcon category={category} isError={isError} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) {
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
function handleSubmit() {
|
||||
if (!comment.trim()) return;
|
||||
onSubmit(comment);
|
||||
setComment("");
|
||||
}
|
||||
@@ -36,7 +37,7 @@ export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) {
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="mx-auto w-[95%] space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your feedback helps us improve. Share details below.
|
||||
</p>
|
||||
<Textarea
|
||||
@@ -48,12 +49,18 @@ export function FeedbackModal({ isOpen, onSubmit, onCancel }: Props) {
|
||||
className="resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400">{comment.length}/2000</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{comment.length}/2000
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!comment.trim()}
|
||||
>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { LightbulbIcon } from "@phosphor-icons/react";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ReasoningCollapse({ children }: Props) {
|
||||
return (
|
||||
<Dialog title="Reasoning">
|
||||
<Dialog.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 transition-colors hover:text-zinc-700"
|
||||
>
|
||||
<LightbulbIcon size={12} weight="bold" />
|
||||
<span>Show reasoning</span>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -181,6 +181,14 @@ export function convertChatSessionMessagesToUiMessages(
|
||||
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// Merge consecutive assistant messages into a single UIMessage
|
||||
// to avoid split bubbles on page reload.
|
||||
const prevUI = uiMessages[uiMessages.length - 1];
|
||||
if (msg.role === "assistant" && prevUI && prevUI.role === "assistant") {
|
||||
prevUI.parts.push(...parts);
|
||||
return;
|
||||
}
|
||||
|
||||
uiMessages.push({
|
||||
id: `${sessionId}-${index}`,
|
||||
role: msg.role,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
export type CreateAgentToolOutput =
|
||||
| AgentPreviewResponse
|
||||
@@ -134,7 +134,7 @@ export function ToolIcon({
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return <PlusIcon size={14} weight="regular" className="text-neutral-400" />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
export type EditAgentToolOutput =
|
||||
| AgentPreviewResponse
|
||||
@@ -121,7 +121,7 @@ export function ToolIcon({
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return (
|
||||
<PencilLineIcon size={14} weight="regular" className="text-neutral-400" />
|
||||
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
type ToolCategory,
|
||||
extractToolName,
|
||||
getAnimationText,
|
||||
getToolCategory,
|
||||
truncate,
|
||||
} from "./helpers";
|
||||
|
||||
interface Props {
|
||||
part: ToolUIPart;
|
||||
@@ -48,77 +55,6 @@ function RenderMedia({
|
||||
return <OutputItem value={value} metadata={metadata} renderer={renderer} />;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tool name helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function extractToolName(part: ToolUIPart): string {
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tool categorization */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ToolCategory =
|
||||
| "bash"
|
||||
| "web"
|
||||
| "browser"
|
||||
| "file-read"
|
||||
| "file-write"
|
||||
| "file-delete"
|
||||
| "file-list"
|
||||
| "search"
|
||||
| "edit"
|
||||
| "todo"
|
||||
| "compaction"
|
||||
| "other";
|
||||
|
||||
function getToolCategory(toolName: string): ToolCategory {
|
||||
switch (toolName) {
|
||||
case "bash_exec":
|
||||
return "bash";
|
||||
case "web_fetch":
|
||||
case "WebSearch":
|
||||
case "WebFetch":
|
||||
return "web";
|
||||
case "browser_navigate":
|
||||
case "browser_act":
|
||||
case "browser_screenshot":
|
||||
return "browser";
|
||||
case "read_workspace_file":
|
||||
case "read_file":
|
||||
case "Read":
|
||||
return "file-read";
|
||||
case "write_workspace_file":
|
||||
case "write_file":
|
||||
case "Write":
|
||||
return "file-write";
|
||||
case "delete_workspace_file":
|
||||
return "file-delete";
|
||||
case "list_workspace_files":
|
||||
case "glob":
|
||||
case "Glob":
|
||||
return "file-list";
|
||||
case "grep":
|
||||
case "Grep":
|
||||
return "search";
|
||||
case "edit_file":
|
||||
case "Edit":
|
||||
return "edit";
|
||||
case "TodoWrite":
|
||||
return "todo";
|
||||
case "context_compaction":
|
||||
return "compaction";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tool icon */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -141,7 +77,7 @@ function ToolIcon({
|
||||
return <OrbitLoader size={14} />;
|
||||
}
|
||||
|
||||
const iconClass = "text-neutral-400";
|
||||
const iconClass = "text-green-500";
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return <TerminalIcon size={14} weight="regular" className={iconClass} />;
|
||||
@@ -210,191 +146,6 @@ function AccordionIcon({ category }: { category: ToolCategory }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Input extraction */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getInputSummary(toolName: string, input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const inp = input as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case "bash_exec":
|
||||
return typeof inp.command === "string" ? inp.command : null;
|
||||
case "web_fetch":
|
||||
case "WebFetch":
|
||||
return typeof inp.url === "string" ? inp.url : null;
|
||||
case "WebSearch":
|
||||
return typeof inp.query === "string" ? inp.query : null;
|
||||
case "browser_navigate":
|
||||
return typeof inp.url === "string" ? inp.url : null;
|
||||
case "browser_act":
|
||||
return typeof inp.action === "string"
|
||||
? inp.target
|
||||
? `${inp.action} ${inp.target}`
|
||||
: (inp.action as string)
|
||||
: null;
|
||||
case "browser_screenshot":
|
||||
return null;
|
||||
case "read_workspace_file":
|
||||
case "read_file":
|
||||
case "Read":
|
||||
return (
|
||||
(typeof inp.file_path === "string" ? inp.file_path : null) ??
|
||||
(typeof inp.path === "string" ? inp.path : null)
|
||||
);
|
||||
case "write_workspace_file":
|
||||
case "write_file":
|
||||
case "Write":
|
||||
return (
|
||||
(typeof inp.file_path === "string" ? inp.file_path : null) ??
|
||||
(typeof inp.path === "string" ? inp.path : null)
|
||||
);
|
||||
case "delete_workspace_file":
|
||||
return typeof inp.file_path === "string" ? inp.file_path : null;
|
||||
case "glob":
|
||||
case "Glob":
|
||||
return typeof inp.pattern === "string" ? inp.pattern : null;
|
||||
case "grep":
|
||||
case "Grep":
|
||||
return typeof inp.pattern === "string" ? inp.pattern : null;
|
||||
case "edit_file":
|
||||
case "Edit":
|
||||
return typeof inp.file_path === "string" ? inp.file_path : null;
|
||||
case "TodoWrite": {
|
||||
// Extract the in-progress task name for the status line
|
||||
const todos = Array.isArray(inp.todos) ? inp.todos : [];
|
||||
const active = todos.find(
|
||||
(t: Record<string, unknown>) => t.status === "in_progress",
|
||||
);
|
||||
if (active && typeof active.activeForm === "string")
|
||||
return active.activeForm;
|
||||
if (active && typeof active.content === "string") return active.content;
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + "…";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Animation text */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getAnimationText(part: ToolUIPart, category: ToolCategory): string {
|
||||
const toolName = extractToolName(part);
|
||||
const summary = getInputSummary(toolName, part.input);
|
||||
const shortSummary = summary ? truncate(summary, 60) : null;
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
case "input-available": {
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return shortSummary ? `Running: ${shortSummary}` : "Running command…";
|
||||
case "web":
|
||||
if (toolName === "WebSearch") {
|
||||
return shortSummary
|
||||
? `Searching "${shortSummary}"`
|
||||
: "Searching the web…";
|
||||
}
|
||||
return shortSummary
|
||||
? `Fetching ${shortSummary}`
|
||||
: "Fetching web content…";
|
||||
case "browser":
|
||||
if (toolName === "browser_screenshot") return "Taking screenshot…";
|
||||
return shortSummary
|
||||
? `Browsing ${shortSummary}`
|
||||
: "Interacting with browser…";
|
||||
case "file-read":
|
||||
return shortSummary ? `Reading ${shortSummary}` : "Reading file…";
|
||||
case "file-write":
|
||||
return shortSummary ? `Writing ${shortSummary}` : "Writing file…";
|
||||
case "file-delete":
|
||||
return shortSummary ? `Deleting ${shortSummary}` : "Deleting file…";
|
||||
case "file-list":
|
||||
return shortSummary ? `Listing ${shortSummary}` : "Listing files…";
|
||||
case "search":
|
||||
return shortSummary
|
||||
? `Searching for "${shortSummary}"`
|
||||
: "Searching…";
|
||||
case "edit":
|
||||
return shortSummary ? `Editing ${shortSummary}` : "Editing file…";
|
||||
case "todo":
|
||||
return shortSummary ? `${shortSummary}` : "Updating task list…";
|
||||
case "compaction":
|
||||
return "Summarizing earlier messages…";
|
||||
default:
|
||||
return `Running ${formatToolName(toolName)}…`;
|
||||
}
|
||||
}
|
||||
case "output-available": {
|
||||
switch (category) {
|
||||
case "bash": {
|
||||
const exitCode = getExitCode(part.output);
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
return `Command exited with code ${exitCode}`;
|
||||
}
|
||||
return shortSummary ? `Ran: ${shortSummary}` : "Command completed";
|
||||
}
|
||||
case "web":
|
||||
if (toolName === "WebSearch") {
|
||||
return shortSummary
|
||||
? `Searched "${shortSummary}"`
|
||||
: "Web search completed";
|
||||
}
|
||||
return shortSummary
|
||||
? `Fetched ${shortSummary}`
|
||||
: "Fetched web content";
|
||||
case "browser":
|
||||
if (toolName === "browser_screenshot") return "Screenshot captured";
|
||||
return shortSummary
|
||||
? `Browsed ${shortSummary}`
|
||||
: "Browser action completed";
|
||||
case "file-read":
|
||||
return shortSummary ? `Read ${shortSummary}` : "File read completed";
|
||||
case "file-write":
|
||||
return shortSummary ? `Wrote ${shortSummary}` : "File written";
|
||||
case "file-delete":
|
||||
return shortSummary ? `Deleted ${shortSummary}` : "File deleted";
|
||||
case "file-list":
|
||||
return "Listed files";
|
||||
case "search":
|
||||
return shortSummary
|
||||
? `Searched for "${shortSummary}"`
|
||||
: "Search completed";
|
||||
case "edit":
|
||||
return shortSummary ? `Edited ${shortSummary}` : "Edit completed";
|
||||
case "todo":
|
||||
return "Updated task list";
|
||||
case "compaction":
|
||||
return "Earlier messages were summarized";
|
||||
default:
|
||||
return `${formatToolName(toolName)} completed`;
|
||||
}
|
||||
}
|
||||
case "output-error": {
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return "Command failed";
|
||||
case "web":
|
||||
return toolName === "WebSearch" ? "Search failed" : "Fetch failed";
|
||||
case "browser":
|
||||
return "Browser action failed";
|
||||
default:
|
||||
return `${formatToolName(toolName)} failed`;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return `Running ${formatToolName(toolName)}…`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Output parsing helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -435,13 +186,6 @@ function extractMcpText(output: Record<string, unknown>): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getExitCode(output: unknown): number | null {
|
||||
const parsed = parseOutput(output);
|
||||
if (!parsed) return null;
|
||||
if (typeof parsed.exit_code === "number") return parsed.exit_code;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStringField(
|
||||
obj: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { ToolUIPart } from "ai";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tool name helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function extractToolName(part: ToolUIPart): string {
|
||||
return part.type.replace(/^tool-/, "");
|
||||
}
|
||||
|
||||
export function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tool categorization */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ToolCategory =
|
||||
| "bash"
|
||||
| "web"
|
||||
| "browser"
|
||||
| "file-read"
|
||||
| "file-write"
|
||||
| "file-delete"
|
||||
| "file-list"
|
||||
| "search"
|
||||
| "edit"
|
||||
| "todo"
|
||||
| "compaction"
|
||||
| "other";
|
||||
|
||||
export function getToolCategory(toolName: string): ToolCategory {
|
||||
switch (toolName) {
|
||||
case "bash_exec":
|
||||
return "bash";
|
||||
case "web_fetch":
|
||||
case "WebSearch":
|
||||
case "WebFetch":
|
||||
return "web";
|
||||
case "browser_navigate":
|
||||
case "browser_act":
|
||||
case "browser_screenshot":
|
||||
return "browser";
|
||||
case "read_workspace_file":
|
||||
case "read_file":
|
||||
case "Read":
|
||||
return "file-read";
|
||||
case "write_workspace_file":
|
||||
case "write_file":
|
||||
case "Write":
|
||||
return "file-write";
|
||||
case "delete_workspace_file":
|
||||
return "file-delete";
|
||||
case "list_workspace_files":
|
||||
case "glob":
|
||||
case "Glob":
|
||||
return "file-list";
|
||||
case "grep":
|
||||
case "Grep":
|
||||
return "search";
|
||||
case "edit_file":
|
||||
case "Edit":
|
||||
return "edit";
|
||||
case "TodoWrite":
|
||||
return "todo";
|
||||
case "context_compaction":
|
||||
return "compaction";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Input summary */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getInputSummary(toolName: string, input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const inp = input as Record<string, unknown>;
|
||||
|
||||
switch (toolName) {
|
||||
case "bash_exec":
|
||||
return typeof inp.command === "string" ? inp.command : null;
|
||||
case "web_fetch":
|
||||
case "WebFetch":
|
||||
return typeof inp.url === "string" ? inp.url : null;
|
||||
case "WebSearch":
|
||||
return typeof inp.query === "string" ? inp.query : null;
|
||||
case "browser_navigate":
|
||||
return typeof inp.url === "string" ? inp.url : null;
|
||||
case "browser_act":
|
||||
if (typeof inp.action !== "string") return null;
|
||||
return typeof inp.target === "string"
|
||||
? `${inp.action} ${inp.target}`
|
||||
: inp.action;
|
||||
case "browser_screenshot":
|
||||
return null;
|
||||
case "read_workspace_file":
|
||||
case "read_file":
|
||||
case "Read":
|
||||
return (
|
||||
(typeof inp.file_path === "string" ? inp.file_path : null) ??
|
||||
(typeof inp.path === "string" ? inp.path : null)
|
||||
);
|
||||
case "write_workspace_file":
|
||||
case "write_file":
|
||||
case "Write":
|
||||
return (
|
||||
(typeof inp.file_path === "string" ? inp.file_path : null) ??
|
||||
(typeof inp.path === "string" ? inp.path : null)
|
||||
);
|
||||
case "delete_workspace_file":
|
||||
return typeof inp.file_path === "string" ? inp.file_path : null;
|
||||
case "glob":
|
||||
case "Glob":
|
||||
return typeof inp.pattern === "string" ? inp.pattern : null;
|
||||
case "grep":
|
||||
case "Grep":
|
||||
return typeof inp.pattern === "string" ? inp.pattern : null;
|
||||
case "edit_file":
|
||||
case "Edit":
|
||||
return typeof inp.file_path === "string" ? inp.file_path : null;
|
||||
case "TodoWrite": {
|
||||
const todos = Array.isArray(inp.todos) ? inp.todos : [];
|
||||
const active = todos.find(
|
||||
(t: unknown) =>
|
||||
t !== null &&
|
||||
typeof t === "object" &&
|
||||
(t as Record<string, unknown>).status === "in_progress",
|
||||
) as Record<string, unknown> | undefined;
|
||||
if (active && typeof active.activeForm === "string")
|
||||
return active.activeForm;
|
||||
if (active && typeof active.content === "string") return active.content;
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen).trimEnd() + "\u2026";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exit code helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getExitCode(output: unknown): number | null {
|
||||
if (!output || typeof output !== "object") return null;
|
||||
const parsed = output as Record<string, unknown>;
|
||||
if (typeof parsed.exit_code === "number") return parsed.exit_code;
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Animation text */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function getAnimationText(
|
||||
part: ToolUIPart,
|
||||
category: ToolCategory,
|
||||
): string {
|
||||
const toolName = extractToolName(part);
|
||||
const summary = getInputSummary(toolName, part.input);
|
||||
const shortSummary = summary ? truncate(summary, 60) : null;
|
||||
|
||||
switch (part.state) {
|
||||
case "input-streaming":
|
||||
case "input-available": {
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return shortSummary
|
||||
? `Running: ${shortSummary}`
|
||||
: "Running command\u2026";
|
||||
case "web":
|
||||
if (toolName === "WebSearch") {
|
||||
return shortSummary
|
||||
? `Searching "${shortSummary}"`
|
||||
: "Searching the web\u2026";
|
||||
}
|
||||
return shortSummary
|
||||
? `Fetching ${shortSummary}`
|
||||
: "Fetching web content\u2026";
|
||||
case "browser":
|
||||
if (toolName === "browser_screenshot")
|
||||
return "Taking screenshot\u2026";
|
||||
return shortSummary
|
||||
? `Browsing ${shortSummary}`
|
||||
: "Interacting with browser\u2026";
|
||||
case "file-read":
|
||||
return shortSummary
|
||||
? `Reading ${shortSummary}`
|
||||
: "Reading file\u2026";
|
||||
case "file-write":
|
||||
return shortSummary
|
||||
? `Writing ${shortSummary}`
|
||||
: "Writing file\u2026";
|
||||
case "file-delete":
|
||||
return shortSummary
|
||||
? `Deleting ${shortSummary}`
|
||||
: "Deleting file\u2026";
|
||||
case "file-list":
|
||||
return shortSummary
|
||||
? `Listing ${shortSummary}`
|
||||
: "Listing files\u2026";
|
||||
case "search":
|
||||
return shortSummary
|
||||
? `Searching for "${shortSummary}"`
|
||||
: "Searching\u2026";
|
||||
case "edit":
|
||||
return shortSummary
|
||||
? `Editing ${shortSummary}`
|
||||
: "Editing file\u2026";
|
||||
case "todo":
|
||||
return shortSummary ? `${shortSummary}` : "Updating task list\u2026";
|
||||
case "compaction":
|
||||
return "Summarizing earlier messages\u2026";
|
||||
default:
|
||||
return `Running ${formatToolName(toolName)}\u2026`;
|
||||
}
|
||||
}
|
||||
case "output-available": {
|
||||
switch (category) {
|
||||
case "bash": {
|
||||
const exitCode = getExitCode(part.output);
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
return `Command exited with code ${exitCode}`;
|
||||
}
|
||||
return shortSummary ? `Ran: ${shortSummary}` : "Command completed";
|
||||
}
|
||||
case "web":
|
||||
if (toolName === "WebSearch") {
|
||||
return shortSummary
|
||||
? `Searched "${shortSummary}"`
|
||||
: "Web search completed";
|
||||
}
|
||||
return shortSummary
|
||||
? `Fetched ${shortSummary}`
|
||||
: "Fetched web content";
|
||||
case "browser":
|
||||
if (toolName === "browser_screenshot") return "Screenshot captured";
|
||||
return shortSummary
|
||||
? `Browsed ${shortSummary}`
|
||||
: "Browser action completed";
|
||||
case "file-read":
|
||||
return shortSummary ? `Read ${shortSummary}` : "File read completed";
|
||||
case "file-write":
|
||||
return shortSummary ? `Wrote ${shortSummary}` : "File written";
|
||||
case "file-delete":
|
||||
return shortSummary ? `Deleted ${shortSummary}` : "File deleted";
|
||||
case "file-list":
|
||||
return "Listed files";
|
||||
case "search":
|
||||
return shortSummary
|
||||
? `Searched for "${shortSummary}"`
|
||||
: "Search completed";
|
||||
case "edit":
|
||||
return shortSummary ? `Edited ${shortSummary}` : "Edit completed";
|
||||
case "todo":
|
||||
return "Updated task list";
|
||||
case "compaction":
|
||||
return "Earlier messages were summarized";
|
||||
default:
|
||||
return `${formatToolName(toolName)} completed`;
|
||||
}
|
||||
}
|
||||
case "output-error": {
|
||||
switch (category) {
|
||||
case "bash":
|
||||
return "Command failed";
|
||||
case "web":
|
||||
return toolName === "WebSearch" ? "Search failed" : "Fetch failed";
|
||||
case "browser":
|
||||
return "Browser action failed";
|
||||
default:
|
||||
return `${formatToolName(toolName)} failed`;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return `Running ${formatToolName(toolName)}\u2026`;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
|
||||
import {
|
||||
ContentGrid,
|
||||
@@ -86,7 +86,7 @@ export function RunAgentTool({ part }: Props) {
|
||||
|
||||
{isStreaming && !output && (
|
||||
<ToolAccordion
|
||||
icon={<OrbitLoader size={32} />}
|
||||
icon={<ScaleLoader size={14} />}
|
||||
title="Running agent, this may take a few minutes. Play while you wait."
|
||||
expanded={true}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
export interface RunAgentInput {
|
||||
username_agent_slug?: string;
|
||||
@@ -171,7 +171,7 @@ export function ToolIcon({
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
WarningDiamondIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
/** Block details returned on first run_block attempt (before input_data provided). */
|
||||
export interface BlockDetailsResponse {
|
||||
@@ -157,7 +157,7 @@ export function ToolIcon({
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return <PlayIcon size={14} weight="regular" className="text-neutral-400" />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ResponseType } from "@/app/api/__generated__/models/responseType";
|
||||
import type { SetupRequirementsResponse } from "@/app/api/__generated__/models/setupRequirementsResponse";
|
||||
import { WarningDiamondIcon, PlugsConnectedIcon } from "@phosphor-icons/react";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { OrbitLoader } from "../../components/OrbitLoader/OrbitLoader";
|
||||
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Re-export generated types for use by RunMCPTool components
|
||||
@@ -212,7 +212,7 @@ export function ToolIcon({
|
||||
);
|
||||
}
|
||||
if (isStreaming) {
|
||||
return <OrbitLoader size={24} />;
|
||||
return <ScaleLoader size={14} />;
|
||||
}
|
||||
return (
|
||||
<PlugsConnectedIcon
|
||||
|
||||
@@ -92,12 +92,18 @@ export function useCopilotStream({
|
||||
// Set when the user explicitly clicks stop — prevents onError from
|
||||
// triggering a reconnect cycle for the resulting AbortError.
|
||||
const isUserStoppingRef = useRef(false);
|
||||
// Set when all reconnect attempts are exhausted — prevents hasActiveStream
|
||||
// from keeping the UI blocked forever when the backend is slow to clear it.
|
||||
// Must be state (not ref) so that setting it triggers a re-render and
|
||||
// recomputes `isReconnecting`.
|
||||
const [reconnectExhausted, setReconnectExhausted] = useState(false);
|
||||
|
||||
function handleReconnect(sid: string) {
|
||||
if (isReconnectScheduledRef.current || !sid) return;
|
||||
|
||||
const nextAttempt = reconnectAttemptsRef.current + 1;
|
||||
if (nextAttempt > RECONNECT_MAX_ATTEMPTS) {
|
||||
setReconnectExhausted(true);
|
||||
toast({
|
||||
title: "Connection lost",
|
||||
description: "Unable to reconnect. Please refresh the page.",
|
||||
@@ -146,7 +152,11 @@ export function useCopilotStream({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if backend executor is still running after clean close
|
||||
// Check if backend executor is still running after clean close.
|
||||
// Brief delay to let the backend clear active_stream — without this,
|
||||
// the refetch often races and sees stale active_stream=true, triggering
|
||||
// unnecessary reconnect cycles.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const result = await refetchSession();
|
||||
const d = result.data;
|
||||
const backendActive =
|
||||
@@ -276,6 +286,7 @@ export function useCopilotStream({
|
||||
setIsReconnectScheduled(false);
|
||||
hasShownDisconnectToast.current = false;
|
||||
isUserStoppingRef.current = false;
|
||||
setReconnectExhausted(false);
|
||||
hasResumedRef.current.clear();
|
||||
return () => {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
@@ -299,6 +310,7 @@ export function useCopilotStream({
|
||||
if (status === "ready") {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
hasShownDisconnectToast.current = false;
|
||||
setReconnectExhausted(false);
|
||||
}
|
||||
}
|
||||
}, [status, sessionId, queryClient, isReconnectScheduled]);
|
||||
@@ -358,10 +370,12 @@ export function useCopilotStream({
|
||||
}, [hasActiveStream]);
|
||||
|
||||
// True while reconnecting or backend has active stream but we haven't connected yet.
|
||||
// Suppressed when the user explicitly stopped — the backend may take a moment
|
||||
// to clear active_stream but the UI should be responsive immediately.
|
||||
// Suppressed when the user explicitly stopped or when all reconnect attempts
|
||||
// are exhausted — the backend may be slow to clear active_stream but the UI
|
||||
// should remain responsive.
|
||||
const isReconnecting =
|
||||
!isUserStoppingRef.current &&
|
||||
!reconnectExhausted &&
|
||||
(isReconnectScheduled ||
|
||||
(hasActiveStream && status !== "streaming" && status !== "submitted"));
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@ function isWorkspaceDownloadRequest(path: string[]): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle workspace file download requests with proper binary response streaming.
|
||||
* Handle workspace file download requests using signed URL redirect or full buffering.
|
||||
*/
|
||||
async function handleWorkspaceDownload(
|
||||
req: NextRequest,
|
||||
backendUrl: string,
|
||||
path: string[],
|
||||
): Promise<NextResponse> {
|
||||
const token = await getServerAuthToken();
|
||||
|
||||
@@ -44,40 +44,64 @@ async function handleWorkspaceDownload(
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
// Build the download-url endpoint path (replace last segment)
|
||||
const urlPath = [...path];
|
||||
urlPath[urlPath.length - 1] = "download-url";
|
||||
const downloadUrlEndpoint = buildBackendUrl(urlPath, "");
|
||||
|
||||
// Ask backend for signed URL
|
||||
const urlResponse = await fetch(downloadUrlEndpoint, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow", // Follow redirects to signed URLs
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (!urlResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to download file: ${response.statusText}` },
|
||||
{ status: response.status },
|
||||
{ error: `Failed to get download URL: ${urlResponse.statusText}` },
|
||||
{ status: urlResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
// Get the content type from the backend response
|
||||
const contentType =
|
||||
response.headers.get("Content-Type") || "application/octet-stream";
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
const { url, direct } = (await urlResponse.json()) as {
|
||||
url: string;
|
||||
direct: boolean;
|
||||
};
|
||||
|
||||
// Direct URL (GCS signed) — redirect browser to fetch directly from GCS
|
||||
if (direct) {
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
|
||||
// Non-direct (local storage) — proxy with full buffering to avoid truncation
|
||||
const backendUrl = buildBackendUrl(path, new URL(req.url).search);
|
||||
const fileResponse = await fetch(backendUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!fileResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to download file: ${fileResponse.statusText}` },
|
||||
{ status: fileResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await fileResponse.arrayBuffer();
|
||||
const contentType =
|
||||
fileResponse.headers.get("Content-Type") || "application/octet-stream";
|
||||
const contentDisposition = fileResponse.headers.get("Content-Disposition");
|
||||
|
||||
// Stream the response body
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(buffer.byteLength),
|
||||
};
|
||||
|
||||
if (contentDisposition) {
|
||||
responseHeaders["Content-Disposition"] = contentDisposition;
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (contentLength) {
|
||||
responseHeaders["Content-Length"] = contentLength;
|
||||
}
|
||||
|
||||
// Stream the response body directly instead of buffering in memory
|
||||
return new NextResponse(response.body, {
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
@@ -250,7 +274,7 @@ async function handler(
|
||||
try {
|
||||
// Handle workspace file downloads separately (binary response)
|
||||
if (method === "GET" && isWorkspaceDownloadRequest(path)) {
|
||||
return await handleWorkspaceDownload(req, backendUrl);
|
||||
return await handleWorkspaceDownload(req, path);
|
||||
}
|
||||
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
|
||||
Reference in New Issue
Block a user