Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useFilesStore } from '@/stores/files';
import { getWorkspacePath } from '@/components/shell/sidebar/utils';
import { driveNavigationFor } from '@/lib/drive-links';

/**
* Safety net for drive object-path links that still reach /chat/ (e.g. a shared
* or bookmarked `/chat/naas_abi/platform-drive/bob/…/LOreal.pptx` URL). These
* are not conversation ids — treating one as a conversation collapsed the URL to
* /chat/naas_abi. Send the user to the Files page for the matching drive and open
* the file's preview, reusing the same store navigation the sidebar uses.
*
* In-chat clicks are handled upstream by the markdown link renderer
* (see isDriveObjectPath in chat-interface.tsx), so they never hit /chat/.
*/
export function DriveFileRedirect({
workspaceId,
objectPath,
}: {
workspaceId: string;
objectPath: string;
}) {
const router = useRouter();
const setStarredNavigation = useFilesStore((s) => s.setStarredNavigation);

useEffect(() => {
setStarredNavigation(driveNavigationFor(objectPath));
router.replace(getWorkspacePath(workspaceId, '/files'));
}, [objectPath, workspaceId, router, setStarredNavigation]);

return null;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChatInterface } from '@/components/chat/chat-interface';
import { Header } from '@/components/shell/header';
import { DriveFileRedirect } from './drive-file-redirect';

interface ChatWorkspacePageProps {
params: {
Expand All @@ -9,7 +10,17 @@ interface ChatWorkspacePageProps {
}

export default function ChatWorkspacePage({ params }: ChatWorkspacePageProps) {
const conversationId = params.slug?.[0] ?? null;
const slug = params.slug ?? [];

// Drive object paths (naas_abi/<drive>/…) are sometimes emitted as relative
// file links and get resolved against the current /chat/ URL, landing here.
// They are not conversation ids — treating slug[0] as one collapsed the URL
// to /chat/naas_abi. Redirect to the Files page and open the file instead.
if (slug[0] === 'naas_abi') {
return <DriveFileRedirect workspaceId={params.workspaceId} objectPath={slug.join('/')} />;
}

const conversationId = slug[0] ?? null;
return (
<div className="flex h-full flex-col">
<Header title="Chat" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { useIntegrationsStore } from '@/stores/integrations';
import { useAgentsStore } from '@/stores/agents';
import { useSecretsStore } from '@/stores/secrets';
import { useAuthStore, authFetch } from '@/stores/auth';
import { useFilesStore } from '@/stores/files';
import { getWorkspacePath } from '@/components/shell/sidebar/utils';
import { isDriveObjectPath, driveNavigationFor, parseDriveObjectPath } from '@/lib/drive-links';
import { useWebSocket } from '@/contexts/websocket-context';
import { useTenant } from '@/contexts/tenant-context';
import { AgentSelector, ModelSelector } from './agent-selector';
Expand Down Expand Up @@ -2993,7 +2996,8 @@ const MessageBubble = React.memo(function MessageBubble({
const [urlExists, setUrlExists] = useState<Record<string, boolean>>({});
const wasProcessingRef = useRef(false);
const processingStartRef = useRef<number | null>(null);

const router = useRouter();

// Get user name and agent info for display
const user = useAuthStore(state => state.user);
const agents = useAgentsStore(state => state.agents);
Expand Down Expand Up @@ -3280,13 +3284,74 @@ const MessageBubble = React.memo(function MessageBubble({
}
}, []);

// Download a drive object-path file directly to the user's machine, mirroring
// the Files page's download (same /api/files/raw endpoint, scope and params).
const downloadDriveFile = useCallback(async (objectPath: string) => {
const { path, name, scope } = parseDriveObjectPath(objectPath);
const workspaceId = useWorkspaceStore.getState().currentWorkspaceId;
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
const params = `workspace_id=${encodeURIComponent(workspaceId ?? '')}&scope=${scope}`;
const response = await authFetch(`/api/files/raw/${encodedPath}?${params}`);
if (!response.ok) {
throw new Error('Failed to download file');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}, []);

const markdownComponents = useMemo(
() => ({
a: ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children?: React.ReactNode }) => {
// Drive object paths (naas_abi/<drive>/…) are emitted by the agent as
// relative links. Left alone the browser resolves them against the
// current /chat/ URL and lands on the chat catch-all route. Intercept
// them so no /chat/ URL is ever produced: a file downloads directly, a
// folder opens the Files page (drive interface) at that folder.
if (isDriveObjectPath(href)) {
const objectPath = href;
const { isFile } = parseDriveObjectPath(objectPath);
const openInDrive = () => {
useFilesStore.getState().setStarredNavigation(driveNavigationFor(objectPath));
router.push(
getWorkspacePath(useWorkspaceStore.getState().currentWorkspaceId, '/files'),
);
};
return (
<a
href={
isFile
? '#'
: getWorkspacePath(
useWorkspaceStore.getState().currentWorkspaceId,
'/files',
)
}
onClick={(e) => {
e.preventDefault();
if (isFile) {
// Fall back to opening the file in the drive if the download fails.
void downloadDriveFile(objectPath).catch(openInDrive);
} else {
openInDrive();
}
}}
className="text-workspace-accent hover:text-workspace-accent/90 underline underline-offset-2 break-all cursor-pointer"
>
{children ?? href}
</a>
);
}
if (!href || !/^https?:\/\//i.test(href)) {
return <a href={href} {...props}>{children}</a>;
}
Expand Down Expand Up @@ -3351,7 +3416,7 @@ const MessageBubble = React.memo(function MessageBubble({
);
},
}),
[copiedCodeKey, handleCopyCode, onPreviewUrl]
[copiedCodeKey, handleCopyCode, onPreviewUrl, router, downloadDriveFile]
);

return (
Expand Down
87 changes: 87 additions & 0 deletions libs/naas-abi/naas_abi/apps/nexus/apps/web/src/lib/drive-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { StarredNavigation } from '@/stores/files';

// A drive object path always starts with `naas_abi/<drive-segment>/…`. Map the
// drive segment back to the Files-page source id so we can open the file there.
// (See driveRoot in files/page.tsx.)
export const DRIVE_SEGMENT_TO_SOURCE: Record<string, string> = {
'platform-drive': 'platform-drive',
'my-drive': 'my-drive',
'workspace-drive': 'workspace',
};

// API `scope` param used by the /api/files endpoints, keyed by source id.
// Mirrors filesScope in files/page.tsx.
export type FilesScope = 'workspace' | 'my_drive' | 'platform_drive' | 'system_drive';

export function driveScopeForSource(source: string): FilesScope {
switch (source) {
case 'my-drive':
return 'my_drive';
case 'platform-drive':
return 'platform_drive';
case 'system-drive':
return 'system_drive';
default:
return 'workspace';
}
}

/**
* True when `href` is a relative drive object path (e.g.
* `naas_abi/platform-drive/bob/…/LOreal.pptx`) rather than a real URL.
*
* The agent emits these as relative markdown links; the browser would otherwise
* resolve them against the current `/chat/` URL and land on the chat catch-all
* route instead of opening the file. Callers use this to intercept them.
*/
export function isDriveObjectPath(href?: string | null): href is string {
if (!href) return false;
// Reject anything with a URL scheme (http:, https:, mailto:, …) or protocol-relative.
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) return false;
return href.replace(/^\/+/, '').startsWith('naas_abi/');
}

export interface DriveObjectRef {
source: string; // Files-page source id (platform-drive, my-drive, workspace, …)
scope: FilesScope; // API scope param
path: string; // full normalized object path (naas_abi/<drive>/…)
parentPath: string; // parent folder's object path
name: string; // last path segment
isFile: boolean; // heuristic: last segment contains a "."
}

/** Parse a drive object path into the pieces the Files API / navigation need. */
export function parseDriveObjectPath(objectPath: string): DriveObjectRef {
const normalized = objectPath.replace(/^\/+|\/+$/g, '');
const segments = normalized.split('/');
const driveSegment = segments[1] ?? '';
const source = DRIVE_SEGMENT_TO_SOURCE[driveSegment] ?? 'system-drive';

const name = segments[segments.length - 1] ?? '';
const isFile = name.includes('.');
const parentPath = normalized.includes('/')
? normalized.slice(0, normalized.lastIndexOf('/'))
: '';

return {
source,
scope: driveScopeForSource(source),
path: normalized,
parentPath,
name,
isFile,
};
}

/**
* Build the Files-page navigation payload for a drive object path, reusing the
* same store navigation the sidebar's starred-file click uses. For a file we
* navigate to its parent folder and open the file's preview; for a folder we
* navigate into it.
*/
export function driveNavigationFor(objectPath: string): StarredNavigation {
const { source, path, parentPath, isFile } = parseDriveObjectPath(objectPath);
return isFile
? { source, path: parentPath, previewPath: path }
: { source, path };
}
Loading