Artifacts-backed persistence for Agent “Self” tools (Notes / Todo) + streaming artifact_id support (#2267)

* (feat:memory) use fs/storage for files

* (feat:todo) artifact_id via sse

* (feat:notes) artifact id return

* (feat:artifact) add get endpoint, store todos with conv id

* (feat: artifacts) fe integration

* feat(artifacts): ui enhancements, notes as mkdwn

* chore(artifacts) updated artifact tests

* (feat:todo_tool) return all todo items

* (feat:tools) use specific tool names in bubble

* feat: add conversationId prop to artifact components in Conversation

* Revert "(feat:memory) use fs/storage for files"

This reverts commit d1ce3bea31.

* (fix:fe) build fail
This commit is contained in:
Manish Madan
2026-02-15 05:38:37 +05:30
committed by GitHub
parent 5fb063914e
commit 876b04c058
18 changed files with 1329 additions and 196 deletions

View File

@@ -135,6 +135,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2623,6 +2624,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@@ -3408,6 +3410,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4071,6 +4074,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4421,6 +4425,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -4601,6 +4606,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -4613,6 +4619,7 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -4858,6 +4865,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -5267,6 +5275,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -5887,6 +5896,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5963,6 +5973,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7360,6 +7371,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -10313,6 +10325,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10497,6 +10510,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10516,6 +10530,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -10615,6 +10630,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -10789,7 +10805,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -11927,6 +11944,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12157,6 +12175,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12459,6 +12478,7 @@
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -12567,6 +12587,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -68,6 +68,7 @@ const endpoints = {
AGENT_FOLDERS: '/api/agents/folders/',
AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`,
MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent',
GET_ARTIFACT: (artifactId: string) => `/api/artifact/${artifactId}`,
WORKFLOWS: '/api/workflows',
WORKFLOW: (id: string) => `/api/workflows/${id}`,
},

View File

@@ -160,6 +160,8 @@ const userService = {
token: string | null,
): Promise<any> =>
apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token),
getArtifact: (artifactId: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.GET_ARTIFACT(artifactId), token),
getWorkflow: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.WORKFLOW(id), token),
createWorkflow: (data: any, token: string | null): Promise<any> =>

View File

@@ -16,6 +16,7 @@ interface ActionButtonsProps {
className?: string;
showNewChat?: boolean;
showShare?: boolean;
isArtifactOpen?: boolean;
}
import { useNavigate } from 'react-router-dom';
@@ -24,6 +25,7 @@ export default function ActionButtons({
className = '',
showNewChat = true,
showShare = true,
isArtifactOpen = false,
}: ActionButtonsProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
@@ -41,7 +43,11 @@ export default function ActionButtons({
navigate('/');
};
return (
<div className="fixed top-0 right-4 z-10 flex h-16 flex-col justify-center">
<div
className={`fixed top-0 z-10 flex h-16 flex-col justify-center transition-all duration-300 ${
isArtifactOpen ? 'right-[calc(50%+1rem)]' : 'right-4'
}`}
>
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
{showNewChat && (
<button

View File

@@ -0,0 +1,498 @@
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
vscDarkPlus,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import Exit from '../assets/exit.svg';
import { selectToken } from '../preferences/preferenceSlice';
import userService from '../api/services/userService';
import Spinner from './Spinner';
import CopyButton from './CopyButton';
import { useDarkTheme } from '../hooks';
type TodoItem = {
todo_id: number;
title: string;
status: 'open' | 'completed';
created_at: string | null;
updated_at: string | null;
};
type TodoArtifactData = {
items: TodoItem[];
total_count: number;
open_count: number;
completed_count: number;
};
type NoteArtifactData = {
content: string;
line_count: number;
updated_at: string | null;
};
type ArtifactData =
| { artifact_type: 'todo_list'; data: TodoArtifactData }
| { artifact_type: 'note'; data: NoteArtifactData }
| { artifact_type: 'memory'; data: Record<string, unknown> };
type ArtifactSidebarProps = {
isOpen: boolean;
onClose: () => void;
artifactId: string | null;
toolName?: string;
conversationId: string | null;
/**
* overlay: current fixed slide-in sidebar
* split: renders as a normal panel (to be placed in a split layout)
*/
variant?: 'overlay' | 'split';
};
const ARTIFACT_TITLE_BY_TYPE: Record<ArtifactData['artifact_type'], string> = {
todo_list: 'Todo List',
note: 'Note',
memory: 'Memory',
};
function getArtifactTitle(artifact: ArtifactData | null, toolName?: string) {
if (artifact) return ARTIFACT_TITLE_BY_TYPE[artifact.artifact_type] ?? 'Artifact';
const formattedToolName = (toolName ?? '')
.replace(/_/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
return formattedToolName || 'Artifact';
}
function TodoListView({ data }: { data: TodoArtifactData }) {
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-end">
<div className="flex gap-2 text-xs">
<span className="rounded-full bg-green-100 px-2 py-1 text-green-700 dark:bg-green-900/30 dark:text-green-400">
{data.completed_count} done
</span>
<span className="rounded-full bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{data.open_count} open
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{data.items.length === 0 ? (
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
No todos yet
</p>
) : (
<ul className="space-y-2">
{data.items.map((item, index) => (
<li
key={`${item.todo_id}-${index}`}
className={`flex items-start gap-3 rounded-lg border p-3 ${
item.status === 'completed'
? 'border-green-300 dark:border-green-800'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<span
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
item.status === 'completed'
? 'border-green-500 bg-green-500 text-white'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{item.status === 'completed' && (
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</span>
<div className="flex-1">
<p
className={`text-sm ${
item.status === 'completed'
? 'text-gray-500 line-through dark:text-gray-400'
: 'text-gray-900 dark:text-white'
}`}
>
{item.title}
</p>
<p className="mt-1 text-xs text-gray-400">#{item.todo_id}</p>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
function NoteView({ data }: { data: NoteArtifactData }) {
const [isDarkTheme] = useDarkTheme();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="mb-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{data.line_count} lines
</span>
<CopyButton textToCopy={data.content || ''} />
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{data.content ? (
<ReactMarkdown
className="flex flex-col gap-3 text-sm leading-normal break-words whitespace-pre-wrap text-gray-800 dark:text-gray-200"
remarkPlugins={[remarkGfm]}
components={{
code(props) {
const {
children,
className,
node: _node,
ref: _ref,
...rest
} = props;
void _node;
void _ref;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="group border-light-silver dark:border-raisin-black relative my-2 overflow-hidden rounded-[14px] border">
<div className="bg-platinum dark:bg-eerie-black-2 flex items-center justify-between px-2 py-1">
<span className="text-just-black dark:text-chinese-white text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
style={isDarkTheme ? vscDarkPlus : oneLight}
customStyle={{
margin: 0,
borderRadius: 0,
scrollbarWidth: 'thin',
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code
className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal"
{...rest}
>
{children}
</code>
);
},
ul({ children }) {
return (
<ul className="list-inside list-disc pl-4 whitespace-normal">
{children}
</ul>
);
},
ol({ children }) {
return (
<ol className="list-inside list-decimal pl-4 whitespace-normal">
{children}
</ol>
);
},
a({ children, href }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline dark:text-blue-400"
>
{children}
</a>
);
},
p({ children }) {
return <p className="whitespace-pre-wrap">{children}</p>;
},
h1({ children }) {
return <h1 className="text-xl font-bold">{children}</h1>;
},
h2({ children }) {
return <h2 className="text-lg font-bold">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-base font-bold">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote className="border-l-4 border-gray-300 pl-4 italic dark:border-gray-600">
{children}
</blockquote>
);
},
}}
>
{data.content}
</ReactMarkdown>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">Empty note</p>
)}
</div>
</div>
);
}
export default function ArtifactSidebar({
isOpen,
onClose,
artifactId,
toolName,
conversationId,
variant = 'overlay',
}: ArtifactSidebarProps) {
const sidebarRef = React.useRef<HTMLDivElement>(null);
const lastSuccessfulTodoArtifactIdRef = React.useRef<string | null>(null);
const currentFetchIdRef = React.useRef<string | null>(null);
const token = useSelector(selectToken);
const [artifact, setArtifact] = useState<ArtifactData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [effectiveArtifactId, setEffectiveArtifactId] = useState<string | null>(
artifactId,
);
const title = getArtifactTitle(artifact, toolName);
// Reset last successful todo artifact ID when conversation changes
useEffect(() => {
lastSuccessfulTodoArtifactIdRef.current = null;
}, [conversationId]);
// Reset effectiveArtifactId when artifactId changes
useEffect(() => {
if (!isOpen) {
setEffectiveArtifactId(null);
return;
}
setEffectiveArtifactId(artifactId);
}, [isOpen, artifactId]);
// Fetch artifact when effectiveArtifactId changes
useEffect(() => {
if (!isOpen || !effectiveArtifactId) {
setArtifact(null);
setError(null);
setLoading(false);
currentFetchIdRef.current = null;
return;
}
// Generate a unique ID for this fetch
const fetchId = `${effectiveArtifactId}-${Date.now()}`;
currentFetchIdRef.current = fetchId;
setLoading(true);
setError(null);
// Note: For todo artifacts, the endpoint always returns all todos for the tool; will be coversation scoped later
userService
.getArtifact(effectiveArtifactId, token)
.then(async (res: any) => {
// Ignore if this is not the current fetch
if (currentFetchIdRef.current !== fetchId) return;
const isResponseLike = res && typeof res.json === 'function';
const status = isResponseLike ? res.status : undefined;
const ok = isResponseLike ? Boolean(res.ok) : true;
let data: any = res;
if (isResponseLike) {
try {
data = await res.json();
} catch {
data = null;
}
}
// Check again after async operation
if (currentFetchIdRef.current !== fetchId) return;
if (ok && data?.success && data?.artifact) {
setArtifact(data.artifact);
// Remember the last successful todo artifact id so we can fallback if a newer id 404s.
if (data.artifact?.artifact_type === 'todo_list') {
lastSuccessfulTodoArtifactIdRef.current = effectiveArtifactId;
}
setLoading(false);
return;
}
const isTodoTool = (toolName ?? '').toLowerCase().includes('todo');
// If the latest todo artifact id is missing (404), fall back to the last known good one
// so the backend can still resolve `tool_id` for the todo list.
if (
status === 404 &&
isTodoTool &&
lastSuccessfulTodoArtifactIdRef.current &&
lastSuccessfulTodoArtifactIdRef.current !== effectiveArtifactId
) {
// Update effectiveArtifactId to trigger a new fetch with the fallback id
setEffectiveArtifactId(lastSuccessfulTodoArtifactIdRef.current);
setLoading(false);
return;
}
// Ensure we show a visible error state instead of rendering nothing.
const message =
data?.message ||
(status === 404 ? 'Artifact not found' : null) ||
'Failed to load artifact';
setError(message);
setLoading(false);
})
.catch((err) => {
// Ignore if this is not the current fetch
if (currentFetchIdRef.current !== fetchId) return;
setError('Failed to fetch artifact');
setLoading(false);
});
}, [isOpen, effectiveArtifactId, token, toolName, conversationId]);
const handleClickOutside = (event: MouseEvent) => {
if (
sidebarRef.current &&
!sidebarRef.current.contains(event.target as Node)
) {
onClose();
}
};
useEffect(() => {
if (variant === 'overlay' && isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, variant]);
const renderContent = () => {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-red-500">{error}</p>
</div>
);
}
// Avoid rendering an empty panel if the artifact couldn't be loaded for any reason.
if (!artifact) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Artifact not found
</p>
</div>
);
}
switch (artifact.artifact_type) {
case 'todo_list':
return <TodoListView data={artifact.data} />;
case 'note':
return <NoteView data={artifact.data} />;
default:
return (
<pre className="text-xs text-gray-600 dark:text-gray-400">
{JSON.stringify(artifact, null, 2)}
</pre>
);
}
};
if (variant === 'split') {
if (!isOpen) return null;
return (
<div className="flex h-full w-full flex-col p-3">
{/* Space for top bar / actions */}
<div className="h-14 shrink-0" />
{/* Artifact panel */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-transparent dark:border-gray-700">
<div className="flex w-full items-center justify-between px-4 py-2">
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
{title}
</span>
<button
className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={onClose}
>
<img
className="h-3 w-3 filter dark:invert"
src={Exit}
alt="Close"
/>
</button>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
</div>
);
}
return (
<div ref={sidebarRef} className="h-vh relative">
<div
className={`dark:bg-chinese-black fixed top-0 right-0 z-50 flex h-full w-80 transform flex-col bg-white shadow-xl transition-all duration-300 sm:w-96 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} border-l border-[#9ca3af]/10`}
>
<div className="flex w-full items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<span className="text-sm font-medium text-gray-600 dark:text-gray-300">
{title}
</span>
<button
className="hover:bg-gray-1000 dark:hover:bg-gun-metal rounded-full p-2"
onClick={onClose}
>
<img
className="h-4 w-4 filter dark:invert"
src={Exit}
alt="Close"
/>
</button>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import SharedAgentCard from '../agents/SharedAgentCard';
import ArtifactSidebar from '../components/ArtifactSidebar';
import MessageInput from '../components/MessageInput';
import { useMediaQuery } from '../hooks';
import {
@@ -14,20 +15,16 @@ import { AppDispatch } from '../store';
import { handleSendFeedback } from './conversationHandlers';
import ConversationMessages from './ConversationMessages';
import { FEEDBACK, Query } from './conversationModels';
import { ToolCallsType } from './types';
import {
addQuery,
fetchAnswer,
resendQuery,
selectQueries,
selectStatus,
setConversation,
updateConversationId,
updateQuery,
} from './conversationSlice';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
import { selectCompletedAttachments } from '../upload/uploadSlice';
export default function Conversation() {
const { t } = useTranslation();
@@ -43,13 +40,33 @@ export default function Conversation() {
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
useState<boolean>(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const fetchStream = useRef<any>(null);
const lastAutoOpenedArtifactId = useRef<string | null>(null);
const didInitArtifactAutoOpen = useRef(false);
const prevConversationId = useRef<string | null>(conversationId);
const [openArtifact, setOpenArtifact] = useState<{
id: string;
toolName: string;
} | null>(null);
useEffect(() => {
const prevId = prevConversationId.current;
// Don't reset when the backend assigns the conversation id mid-stream (null -> id)
const isServerAssignedId =
prevId === null && conversationId !== null && status === 'loading';
if (!isServerAssignedId && prevId !== conversationId) {
setOpenArtifact(null);
lastAutoOpenedArtifactId.current = null;
}
prevConversationId.current = conversationId;
}, [conversationId, status]);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
dispatch(fetchAnswer({ question, indx: index }));
},
[dispatch, selectedAgent],
);
@@ -143,61 +160,138 @@ export default function Conversation() {
}
};
const resetConversation = () => {
dispatch(setConversation([]));
dispatch(
updateConversationId({
query: { conversationId: null },
}),
);
dispatch(clearAttachments());
};
useEffect(() => {
if (queries.length) {
const last = queries[queries.length - 1];
if (last.error) setLastQueryReturnedErr(true);
if (last.response) setLastQueryReturnedErr(false);
}
}, [queries]);
useEffect(() => {
if (queries.length === 0) {
setLastQueryReturnedErr(false);
// Avoid auto-opening an artifact from existing conversation history on first mount.
if (!didInitArtifactAutoOpen.current) {
didInitArtifactAutoOpen.current = true;
return;
}
const lastQuery = queries[queries.length - 1];
setLastQueryReturnedErr(!!lastQuery.error && !lastQuery.response);
const isNotesOrTodoTool = (toolName?: string) => {
const t = (toolName ?? '').toLowerCase();
return t === 'notes' || t === 'todo_list' || t === 'todo';
};
const findLatestCompletedArtifactCall = (
items: Query[],
): ToolCallsType | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const calls = items[i].tool_calls ?? [];
for (let j = calls.length - 1; j >= 0; j -= 1) {
const call = calls[j];
if (call.artifact_id && call.status === 'completed') return call;
}
}
return null;
};
const latest = findLatestCompletedArtifactCall(queries);
if (!latest?.artifact_id) return;
if (!isNotesOrTodoTool(latest.tool_name)) return;
if (latest.artifact_id === lastAutoOpenedArtifactId.current) return;
lastAutoOpenedArtifactId.current = latest.artifact_id;
setOpenArtifact({
id: latest.artifact_id,
toolName: latest.tool_name,
});
}, [queries]);
return (
<div className="flex h-full flex-col justify-end gap-1">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
handleFeedback={handleFeedback}
queries={queries}
status={status}
showHeroOnEmpty={selectedAgent ? false : true}
headerContent={
selectedAgent ? (
<div className="flex w-full items-center justify-center py-4">
<SharedAgentCard agent={selectedAgent} />
</div>
) : undefined
}
/>
const handleOpenArtifact = useCallback(
(artifact: { id: string; toolName: string }) => {
lastAutoOpenedArtifactId.current = artifact.id;
setOpenArtifact(artifact);
},
[],
);
<div className="bg-opacity-0 z-3 flex h-auto w-full max-w-[1300px] flex-col items-end self-center rounded-2xl py-1 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="flex w-full items-center rounded-[40px] px-2">
<MessageInput
key={conversationId || 'new'}
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
const handleCloseArtifact = useCallback(() => setOpenArtifact(null), []);
const isSplitArtifactOpen = !isMobile && openArtifact !== null;
return (
<div className="flex h-full">
<div
className={`flex h-full min-h-0 flex-col transition-all ${
isSplitArtifactOpen ? 'w-[60%] px-6' : 'w-full'
}`}
>
<div className="min-h-0 flex-1">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
handleFeedback={handleFeedback}
queries={queries}
status={status}
showHeroOnEmpty={selectedAgent ? false : true}
onOpenArtifact={handleOpenArtifact}
isSplitView={isSplitArtifactOpen}
headerContent={
selectedAgent ? (
<div className="flex w-full items-center justify-center py-4">
<SharedAgentCard agent={selectedAgent} />
</div>
) : undefined
}
/>
</div>
<p className="text-gray-4000 dark:text-sonic-silver hidden w-screen self-center bg-transparent py-2 text-center text-xs md:inline md:w-full">
{t('tagline')}
</p>
<div
className={`bg-opacity-0 z-3 flex h-auto w-full flex-col items-end self-center rounded-2xl py-1 ${
isSplitArtifactOpen
? 'max-w-[1300px]'
: 'max-w-[1300px] md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'
}`}
>
<div className="flex w-full items-center rounded-[40px] px-2">
<MessageInput
key={conversationId || 'new'}
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
/>
</div>
<p className="text-gray-4000 dark:text-sonic-silver hidden w-full self-center bg-transparent py-2 text-center text-xs md:inline">
{t('tagline')}
</p>
</div>
</div>
{isSplitArtifactOpen && (
<div className="h-full min-h-0 w-[40%]">
<ArtifactSidebar
variant="split"
isOpen={true}
onClose={handleCloseArtifact}
artifactId={openArtifact?.id ?? null}
toolName={openArtifact?.toolName}
conversationId={conversationId}
/>
</div>
)}
{isMobile && (
<ArtifactSidebar
variant="overlay"
isOpen={openArtifact !== null}
onClose={handleCloseArtifact}
artifactId={openArtifact?.id ?? null}
toolName={openArtifact?.toolName}
conversationId={conversationId}
/>
)}
</div>
);
}

View File

@@ -62,6 +62,7 @@ const ConversationBubble = forwardRef<
index?: number,
) => void;
filesAttached?: { id: string; fileName: string }[];
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
}
>(function ConversationBubble(
{
@@ -78,6 +79,7 @@ const ConversationBubble = forwardRef<
isStreaming,
handleUpdatedQuestionSubmission,
filesAttached,
onOpenArtifact,
},
ref,
) {
@@ -96,6 +98,21 @@ const ConversationBubble = forwardRef<
const editableQueryRef = useRef<HTMLDivElement>(null);
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
const completedArtifactCalls = (toolCalls ?? []).filter(
(toolCall) => toolCall.artifact_id && toolCall.status === 'completed',
);
const primaryArtifactCall =
completedArtifactCalls[completedArtifactCalls.length - 1] ?? null;
const artifactCount = completedArtifactCalls.length;
const formatToolName = (toolName: string | undefined): string => {
if (!toolName) return '';
return toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
useOutsideAlerter(editableQueryRef, () => setIsEditClicked(false), [], true);
useEffect(() => {
@@ -379,6 +396,45 @@ const ConversationBubble = forwardRef<
{toolCalls && toolCalls.length > 0 && (
<ToolCalls toolCalls={toolCalls} />
)}
{!message && primaryArtifactCall?.artifact_id && onOpenArtifact && (
<div className="my-2 ml-2 flex justify-start">
<button
type="button"
onClick={() =>
onOpenArtifact({
id: primaryArtifactCall.artifact_id!,
toolName: primaryArtifactCall.tool_name,
})
}
className="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{primaryArtifactCall.tool_name
? formatToolName(primaryArtifactCall.tool_name)
: artifactCount > 1
? `View artifacts (${artifactCount})`
: 'View artifact'}
</button>
</div>
)}
{thought && (
<Thought thought={thought} preprocessLaTeX={preprocessLaTeX} />
)}
@@ -548,6 +604,46 @@ const ConversationBubble = forwardRef<
</div>
) : (
<>
{primaryArtifactCall?.artifact_id && onOpenArtifact && (
<div className="relative mr-2 flex items-center justify-center">
<button
type="button"
onClick={() =>
onOpenArtifact({
id: primaryArtifactCall.artifact_id!,
toolName: primaryArtifactCall.tool_name,
})
}
className="flex items-center gap-2 rounded-full bg-purple-100 px-3 py-2 text-sm font-medium text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
aria-label="View artifacts"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{primaryArtifactCall.tool_name
? formatToolName(primaryArtifactCall.tool_name)
: artifactCount > 1
? `Artifacts (${artifactCount})`
: 'Artifact'}
</button>
</div>
)}
{!isStreaming && (
<>
<div className="relative mr-2 block items-center justify-center">
@@ -692,106 +788,107 @@ export default ConversationBubble;
function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
const [isToolCallsOpen, setIsToolCallsOpen] = useState(false);
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
className="h-[26px] w-[30px] text-xl"
avatar={
<img
src={Sources}
alt={'ToolCalls'}
className="h-full w-full object-fill"
/>
}
/>
<button
className="flex flex-row items-center gap-2"
onClick={() => setIsToolCallsOpen(!isToolCallsOpen)}
>
<p className="text-base font-semibold">Tool Calls</p>
<img
src={ChevronDown}
alt="ChevronDown"
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isToolCallsOpen ? 'rotate-180' : ''}`}
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
className="h-[26px] w-[30px] text-xl"
avatar={
<img
src={Sources}
alt={'ToolCalls'}
className="h-full w-full object-fill"
/>
}
/>
</button>
</div>
{isToolCallsOpen && (
<div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
<div className="grid grid-cols-1 gap-2">
{toolCalls.map((toolCall, index) => (
<Accordion
key={`tool-call-${index}`}
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
className="bg-gray-1000 dark:bg-gun-metal w-full rounded-4xl hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
titleClassName="px-6 py-2 text-sm font-semibold"
>
<div className="flex flex-col gap-1">
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Arguments
</span>{' '}
<CopyButton
textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
/>
</p>
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{JSON.stringify(toolCall.arguments, null, 2)}
</span>
</p>
</div>
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Response
</span>{' '}
<CopyButton
textToCopy={
toolCall.status === 'error'
? toolCall.error || 'Unknown error'
: JSON.stringify(toolCall.result, null, 2)
}
/>
</p>
{toolCall.status === 'pending' && (
<span className="dark:bg-raisin-black flex w-full items-center justify-center rounded-b-2xl p-2">
<Spinner size="small" />
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<button
className="flex flex-row items-center gap-2"
onClick={() => setIsToolCallsOpen(!isToolCallsOpen)}
>
<p className="text-base font-semibold">Tool Calls</p>
<img
src={ChevronDown}
alt="ChevronDown"
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isToolCallsOpen ? 'rotate-180' : ''}`}
/>
</button>
</div>
{isToolCallsOpen && (
<div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
<div className="grid grid-cols-1 gap-2">
{toolCalls.map((toolCall, index) => (
<Accordion
key={`tool-call-${index}`}
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
className="bg-gray-1000 dark:bg-gun-metal w-full rounded-4xl hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
titleClassName="px-6 py-2 text-sm font-semibold"
>
<div className="flex flex-col gap-1">
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Arguments
</span>{' '}
<CopyButton
textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
/>
</p>
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{JSON.stringify(toolCall.result, null, 2)}
{JSON.stringify(toolCall.arguments, null, 2)}
</span>
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-red-500 dark:text-red-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{toolCall.error}
</span>
</div>
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold wrap-break-word">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Response
</span>{' '}
<CopyButton
textToCopy={
toolCall.status === 'error'
? toolCall.error || 'Unknown error'
: JSON.stringify(toolCall.result, null, 2)
}
/>
</p>
)}
{toolCall.status === 'pending' && (
<span className="dark:bg-raisin-black flex w-full items-center justify-center rounded-b-2xl p-2">
<Spinner size="small" />
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{JSON.stringify(toolCall.result, null, 2)}
</span>
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span
className="leading-[23px] text-red-500 dark:text-red-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{toolCall.error}
</span>
</p>
)}
</div>
</div>
</div>
</Accordion>
))}
</Accordion>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -36,6 +36,8 @@ type ConversationMessagesProps = {
status: Status;
showHeroOnEmpty?: boolean;
headerContent?: ReactNode;
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
isSplitView?: boolean;
};
export default function ConversationMessages({
@@ -46,6 +48,8 @@ export default function ConversationMessages({
handleFeedback,
showHeroOnEmpty = true,
headerContent,
onOpenArtifact,
isSplitView = false,
}: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
@@ -147,6 +151,7 @@ export default function ConversationMessages({
thought={query.thought}
sources={query.sources}
toolCalls={query.tool_calls}
onOpenArtifact={onOpenArtifact}
feedback={query.feedback}
isStreaming={isCurrentlyStreaming}
handleFeedback={
@@ -213,7 +218,13 @@ export default function ConversationMessages({
</button>
)}
<div className="w-full max-w-[1300px] px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div
className={
isSplitView
? 'w-full max-w-[1300px] px-2'
: 'w-full max-w-[1300px] px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'
}
>
{headerContent}
{queries.length > 0 ? (

View File

@@ -6,4 +6,5 @@ export type ToolCallsType = {
result?: Record<string, any>;
error?: string;
status?: 'pending' | 'completed' | 'error';
artifact_id?: string;
};