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

@@ -256,6 +256,8 @@ class BaseAgent(ABC):
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
tool_config["tool_id"] = str(tool_data.get("_id", tool_id))
if hasattr(self, "conversation_id") and self.conversation_id:
tool_config["conversation_id"] = self.conversation_id
tool = tm.load_tool(
tool_data["name"],
tool_config=tool_config,
@@ -269,6 +271,27 @@ class BaseAgent(ABC):
else:
logger.debug(f"Executing tool: {action_name} with args: {call_args}")
result = tool.execute_action(action_name, **parameters)
get_artifact_id = (
getattr(tool, "get_artifact_id", None)
if tool_data["name"] != "api_tool"
else None
)
artifact_id = None
if callable(get_artifact_id):
try:
artifact_id = get_artifact_id(action_name, **parameters)
except Exception:
logger.exception(
"Failed to extract artifact_id from tool %s for action %s",
tool_data["name"],
action_name,
)
artifact_id = str(artifact_id).strip() if artifact_id is not None else ""
if artifact_id:
tool_call_data["artifact_id"] = artifact_id
tool_call_data["result"] = (
f"{str(result)[:50]}..." if len(str(result)) > 50 else result
)

View File

@@ -38,6 +38,8 @@ class NotesTool(Tool):
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["notes"]
self._last_artifact_id: Optional[str] = None
# -----------------------------
# Action implementations
# -----------------------------
@@ -54,6 +56,8 @@ class NotesTool(Tool):
if not self.user_id:
return "Error: NotesTool requires a valid user_id."
self._last_artifact_id = None
if action_name == "view":
return self._get_note()
@@ -125,6 +129,9 @@ class NotesTool(Tool):
"""Return configuration requirements (none for now)."""
return {}
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
return self._last_artifact_id
# -----------------------------
# Internal helpers (single-note)
# -----------------------------
@@ -132,17 +139,22 @@ class NotesTool(Tool):
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
return str(doc["note"])
def _overwrite_note(self, content: str) -> str:
content = (content or "").strip()
if not content:
return "Note content required."
self.collection.update_one(
result = self.collection.find_one_and_update(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": content, "updated_at": datetime.utcnow()}},
upsert=True, # ✅ create if missing
upsert=True,
return_document=True,
)
if result and result.get("_id") is not None:
self._last_artifact_id = str(result.get("_id"))
return "Note saved."
def _str_replace(self, old_str: str, new_str: str) -> str:
@@ -163,10 +175,13 @@ class NotesTool(Tool):
import re
updated_note = re.sub(re.escape(old_str), new_str, current_note, flags=re.IGNORECASE)
self.collection.update_one(
result = self.collection.find_one_and_update(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
return_document=True,
)
if result and result.get("_id") is not None:
self._last_artifact_id = str(result.get("_id"))
return "Note updated."
def _insert(self, line_number: int, text: str) -> str:
@@ -188,12 +203,21 @@ class NotesTool(Tool):
lines.insert(index, text)
updated_note = "\n".join(lines)
self.collection.update_one(
result = self.collection.find_one_and_update(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
return_document=True,
)
if result and result.get("_id") is not None:
self._last_artifact_id = str(result.get("_id"))
return "Text inserted."
def _delete_note(self) -> str:
res = self.collection.delete_one({"user_id": self.user_id, "tool_id": self.tool_id})
return "Note deleted." if res.deleted_count else "No note found to delete."
doc = self.collection.find_one_and_delete(
{"user_id": self.user_id, "tool_id": self.tool_id}
)
if not doc:
return "No note found to delete."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
return "Note deleted."

View File

@@ -38,6 +38,8 @@ class TodoListTool(Tool):
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["todos"]
self._last_artifact_id: Optional[str] = None
# -----------------------------
# Action implementations
# -----------------------------
@@ -54,6 +56,8 @@ class TodoListTool(Tool):
if not self.user_id:
return "Error: TodoListTool requires a valid user_id."
self._last_artifact_id = None
if action_name == "list":
return self._list()
@@ -165,6 +169,9 @@ class TodoListTool(Tool):
"""Return configuration requirements."""
return {}
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
return self._last_artifact_id
# -----------------------------
# Internal helpers
# -----------------------------
@@ -190,11 +197,8 @@ class TodoListTool(Tool):
Returns a simple integer (1, 2, 3, ...) scoped to this user/tool.
With 5-10 todos max, scanning is negligible.
"""
# Find all todos for this user/tool and get their IDs
todos = list(self.collection.find(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"todo_id": 1}
))
query = {"user_id": self.user_id, "tool_id": self.tool_id}
todos = list(self.collection.find(query, {"todo_id": 1}))
# Find the maximum todo_id
max_id = 0
@@ -207,8 +211,8 @@ class TodoListTool(Tool):
def _list(self) -> str:
"""List all todos for the user."""
cursor = self.collection.find({"user_id": self.user_id, "tool_id": self.tool_id})
todos = list(cursor)
query = {"user_id": self.user_id, "tool_id": self.tool_id}
todos = list(self.collection.find(query))
if not todos:
return "No todos found."
@@ -242,7 +246,10 @@ class TodoListTool(Tool):
"created_at": now,
"updated_at": now,
}
self.collection.insert_one(doc)
insert_result = self.collection.insert_one(doc)
inserted_id = getattr(insert_result, "inserted_id", None) or doc.get("_id")
if inserted_id is not None:
self._last_artifact_id = str(inserted_id)
return f"Todo created with ID {todo_id}: {title}"
def _get(self, todo_id: Optional[Any]) -> str:
@@ -251,15 +258,15 @@ class TodoListTool(Tool):
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
doc = self.collection.find_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
doc = self.collection.find_one(query)
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
title = doc.get("title", "Untitled")
status = doc.get("status", "open")
@@ -277,14 +284,17 @@ class TodoListTool(Tool):
if not title:
return "Error: Title is required."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"title": title, "updated_at": datetime.now()}}
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
doc = self.collection.find_one_and_update(
query,
{"$set": {"title": title, "updated_at": datetime.now()}},
)
if result.matched_count == 0:
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
return f"Todo {parsed_todo_id} updated to: {title}"
def _complete(self, todo_id: Optional[Any]) -> str:
@@ -293,14 +303,17 @@ class TodoListTool(Tool):
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"status": "completed", "updated_at": datetime.now()}}
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
doc = self.collection.find_one_and_update(
query,
{"$set": {"status": "completed", "updated_at": datetime.now()}},
)
if result.matched_count == 0:
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
return f"Todo {parsed_todo_id} marked as completed."
def _delete(self, todo_id: Optional[Any]) -> str:
@@ -309,13 +322,12 @@ class TodoListTool(Tool):
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.delete_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
if result.deleted_count == 0:
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
doc = self.collection.find_one_and_delete(query)
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
if doc.get("_id") is not None:
self._last_artifact_id = str(doc.get("_id"))
return f"Todo {parsed_todo_id} deleted."

View File

@@ -467,3 +467,84 @@ class ParseSpec(Resource):
except Exception as err:
current_app.logger.error(f"Error parsing spec: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": "Failed to parse specification"}), 500)
@tools_ns.route("/artifact/<artifact_id>")
class GetArtifact(Resource):
@api.doc(description="Get artifact data by artifact ID. Returns all todos for the tool when fetching a todo artifact.")
def get(self, artifact_id: str):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
obj_id = ObjectId(artifact_id)
except Exception:
return make_response(
jsonify({"success": False, "message": "Invalid artifact ID"}), 400
)
from application.core.mongo_db import MongoDB
from application.core.settings import settings
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
note_doc = db["notes"].find_one({"_id": obj_id, "user_id": user_id})
if note_doc:
content = note_doc.get("note", "")
line_count = len(content.split("\n")) if content else 0
artifact = {
"artifact_type": "note",
"data": {
"content": content,
"line_count": line_count,
"updated_at": (
note_doc["updated_at"].isoformat()
if note_doc.get("updated_at")
else None
),
},
}
return make_response(jsonify({"success": True, "artifact": artifact}), 200)
todo_doc = db["todos"].find_one({"_id": obj_id, "user_id": user_id})
if todo_doc:
tool_id = todo_doc.get("tool_id")
# Return all todos for the tool
query = {"user_id": user_id, "tool_id": tool_id}
all_todos = list(db["todos"].find(query))
items = []
open_count = 0
completed_count = 0
for t in all_todos:
status = t.get("status", "open")
if status == "open":
open_count += 1
elif status == "completed":
completed_count += 1
items.append({
"todo_id": t.get("todo_id"),
"title": t.get("title", ""),
"status": status,
"created_at": (
t["created_at"].isoformat() if t.get("created_at") else None
),
"updated_at": (
t["updated_at"].isoformat() if t.get("updated_at") else None
),
})
artifact = {
"artifact_type": "todo_list",
"data": {
"items": items,
"total_count": len(items),
"open_count": open_count,
"completed_count": completed_count,
},
}
return make_response(jsonify({"success": True, "artifact": artifact}), 200)
return make_response(
jsonify({"success": False, "message": "Artifact not found"}), 404
)

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;
};

View File

@@ -0,0 +1,194 @@
from datetime import datetime
import pytest
from bson.objectid import ObjectId
from flask import request
@pytest.mark.unit
class TestGetArtifact:
def test_note_artifact_success(self, mock_mongo_db, flask_app, decoded_token):
from application.core.settings import settings
from application.api.user.tools.routes import GetArtifact
db = mock_mongo_db[settings.MONGO_DB_NAME]
note_id = ObjectId()
db["notes"].insert_one(
{
"_id": note_id,
"user_id": decoded_token["sub"],
"tool_id": "tool1",
"note": "a\nb",
"updated_at": datetime(2025, 1, 1),
}
)
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(note_id))
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "note"
assert resp.json["artifact"]["data"]["content"] == "a\nb"
assert resp.json["artifact"]["data"]["line_count"] == 2
def test_todo_artifact_success(self, mock_mongo_db, flask_app, decoded_token):
from application.core.settings import settings
from application.api.user.tools.routes import GetArtifact
db = mock_mongo_db[settings.MONGO_DB_NAME]
todo_id_1 = ObjectId()
todo_id_2 = ObjectId()
db["todos"].insert_many([
{
"_id": todo_id_1,
"user_id": decoded_token["sub"],
"tool_id": "tool1",
"todo_id": 1,
"title": "First task",
"status": "open",
"created_at": datetime(2025, 1, 1),
"updated_at": datetime(2025, 1, 1),
},
{
"_id": todo_id_2,
"user_id": decoded_token["sub"],
"tool_id": "tool1",
"todo_id": 2,
"title": "Second task",
"status": "completed",
"created_at": datetime(2025, 1, 1),
"updated_at": datetime(2025, 1, 2),
},
])
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(todo_id_1))
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "todo_list"
data = resp.json["artifact"]["data"]
assert data["total_count"] == 2
assert data["open_count"] == 1
assert data["completed_count"] == 1
assert len(data["items"]) == 2
# Verify both todos are returned
todo_ids = [item["todo_id"] for item in data["items"]]
assert 1 in todo_ids
assert 2 in todo_ids
def test_todo_artifact_all_param(self, mock_mongo_db, flask_app, decoded_token):
"""Test that all todos are returned regardless of the 'all' query parameter."""
from application.core.settings import settings
from application.api.user.tools.routes import GetArtifact
db = mock_mongo_db[settings.MONGO_DB_NAME]
todo_id_1 = ObjectId()
todo_id_2 = ObjectId()
db["todos"].insert_many([
{
"_id": todo_id_1,
"user_id": decoded_token["sub"],
"tool_id": "tool1",
"todo_id": 1,
"title": "First task",
"status": "open",
"created_at": datetime(2025, 1, 1),
"updated_at": datetime(2025, 1, 1),
},
{
"_id": todo_id_2,
"user_id": decoded_token["sub"],
"tool_id": "tool1",
"todo_id": 2,
"title": "Second task",
"status": "completed",
"created_at": datetime(2025, 1, 1),
"updated_at": datetime(2025, 1, 2),
},
])
# Test without query parameter - should return all todos
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(todo_id_1))
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "todo_list"
data = resp.json["artifact"]["data"]
assert data["total_count"] == 2
assert data["open_count"] == 1
assert data["completed_count"] == 1
assert len(data["items"]) == 2
# Test with query parameter (should still return all todos, parameter is ignored)
with flask_app.app_context():
with flask_app.test_request_context(query_string={"all": "true"}):
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(todo_id_1))
assert resp.status_code == 200
assert resp.json["artifact"]["artifact_type"] == "todo_list"
data = resp.json["artifact"]["data"]
assert data["total_count"] == 2
assert data["open_count"] == 1
assert data["completed_count"] == 1
assert len(data["items"]) == 2
def test_invalid_artifact_id_returns_400(self, mock_mongo_db, flask_app, decoded_token):
from application.api.user.tools.routes import GetArtifact
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get("not_an_object_id")
assert resp.status_code == 400
assert resp.json["message"] == "Invalid artifact ID"
def test_artifact_not_found_returns_404(self, mock_mongo_db, flask_app, decoded_token):
from application.api.user.tools.routes import GetArtifact
non_existent_id = ObjectId()
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(non_existent_id))
assert resp.status_code == 404
assert resp.json["message"] == "Artifact not found"
def test_other_user_artifact_returns_404(self, mock_mongo_db, flask_app, decoded_token):
from application.core.settings import settings
from application.api.user.tools.routes import GetArtifact
db = mock_mongo_db[settings.MONGO_DB_NAME]
note_id = ObjectId()
db["notes"].insert_one(
{
"_id": note_id,
"user_id": "other_user",
"tool_id": "tool1",
"note": "secret",
"updated_at": datetime(2025, 1, 1),
}
)
with flask_app.app_context():
with flask_app.test_request_context():
request.decoded_token = decoded_token
resource = GetArtifact()
resp = resource.get(str(note_id))
assert resp.status_code == 404

View File

@@ -191,3 +191,11 @@ def mock_tool_manager(mock_tool, monkeypatch):
"application.agents.base.ToolManager", Mock(return_value=manager)
)
return manager
@pytest.fixture
def flask_app():
from flask import Flask
app = Flask(__name__)
return app

View File

@@ -759,4 +759,4 @@ def test_view_file_line_numbers(memory_tool: MemoryTool) -> None:
assert "3: Line 2" not in result # Wrong line number
assert "4: Line 3" not in result # Wrong line number
assert "5: Line 4" not in result # Wrong line number
assert "5: Line 4" not in result # Wrong line number

View File

@@ -10,20 +10,23 @@ def notes_tool(monkeypatch) -> NotesTool:
class FakeCollection:
def __init__(self) -> None:
self.docs = {} # key: user_id:tool_id -> doc
self._id_counter = 0
def _generate_id(self):
self._id_counter += 1
return f"fake_id_{self._id_counter}"
def update_one(self, q, u, upsert=False):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
# emulate single-note storage with optional upsert
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
if "$set" in u and "note" in u["$set"]:
self.docs[key]["note"] = u["$set"]["note"]
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
if "$set" in u:
self.docs[key].update(u["$set"])
return type("res", (), {"modified_count": 1})
def find_one(self, q):
@@ -32,6 +35,28 @@ def notes_tool(monkeypatch) -> NotesTool:
key = f"{user_id}:{tool_id}"
return self.docs.get(key)
def find_one_and_update(self, q, u, upsert=False, return_document=None):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
if key not in self.docs and not upsert:
return None
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
if "$set" in u:
self.docs[key].update(u["$set"])
return self.docs[key]
def find_one_and_delete(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
if key in self.docs:
doc = self.docs.pop(key)
return doc
return None
def delete_one(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
@@ -147,12 +172,9 @@ def test_insert_line(notes_tool: NotesTool) -> None:
@pytest.mark.unit
def test_delete_nonexistent_note(monkeypatch):
class FakeResult:
deleted_count = 0
class FakeCollection:
def delete_one(self, *args, **kwargs):
return FakeResult()
def find_one_and_delete(self, q):
return None
monkeypatch.setattr(
"application.core.mongo_db.MongoDB.get_client",
@@ -171,6 +193,11 @@ def test_notes_tool_isolation(monkeypatch) -> None:
class FakeCollection:
def __init__(self) -> None:
self.docs = {}
self._id_counter = 0
def _generate_id(self):
self._id_counter += 1
return f"fake_id_{self._id_counter}"
def update_one(self, q, u, upsert=False):
user_id = q.get("user_id")
@@ -180,9 +207,9 @@ def test_notes_tool_isolation(monkeypatch) -> None:
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
if "$set" in u and "note" in u["$set"]:
self.docs[key]["note"] = u["$set"]["note"]
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
if "$set" in u:
self.docs[key].update(u["$set"])
return type("res", (), {"modified_count": 1})
def find_one(self, q):
@@ -191,6 +218,19 @@ def test_notes_tool_isolation(monkeypatch) -> None:
key = f"{user_id}:{tool_id}"
return self.docs.get(key)
def find_one_and_update(self, q, u, upsert=False, return_document=None):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
if key not in self.docs and not upsert:
return None
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
if "$set" in u:
self.docs[key].update(u["$set"])
return self.docs[key]
fake_collection = FakeCollection()
fake_db = {"notes": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}

View File

@@ -24,14 +24,21 @@ class FakeCursor(list):
class FakeCollection:
def __init__(self):
self.docs = {}
self._id_counter = 0
def _generate_id(self):
self._id_counter += 1
return f"fake_id_{self._id_counter}"
def create_index(self, *args, **kwargs):
pass
def insert_one(self, doc):
key = (doc["user_id"], doc["tool_id"], doc["todo_id"])
if "_id" not in doc:
doc["_id"] = self._generate_id()
self.docs[key] = doc
return type("res", (), {"inserted_id": key})
return type("res", (), {"inserted_id": doc["_id"]})
def find_one(self, query):
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
@@ -52,12 +59,25 @@ class FakeCollection:
self.docs[key].update(update.get("$set", {}))
return type("res", (), {"matched_count": 1})
elif upsert:
new_doc = {**query, **update.get("$set", {})}
new_doc = {**query, **update.get("$set", {}), "_id": self._generate_id()}
self.docs[key] = new_doc
return type("res", (), {"matched_count": 1})
else:
return type("res", (), {"matched_count": 0})
def find_one_and_update(self, query, update):
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
if key in self.docs:
self.docs[key].update(update.get("$set", {}))
return self.docs[key]
return None
def find_one_and_delete(self, query):
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
if key in self.docs:
return self.docs.pop(key)
return None
def delete_one(self, query):
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
if key in self.docs: