mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-22 12:21:39 +00:00
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:
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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
|
||||
|
||||
498
frontend/src/components/ArtifactSidebar.tsx
Normal file
498
frontend/src/components/ArtifactSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -6,4 +6,5 @@ export type ToolCallsType = {
|
||||
result?: Record<string, any>;
|
||||
error?: string;
|
||||
status?: 'pending' | 'completed' | 'error';
|
||||
artifact_id?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user