mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-28 07:11:21 +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:
@@ -256,6 +256,8 @@ class BaseAgent(ABC):
|
|||||||
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
|
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
|
||||||
|
|
||||||
tool_config["tool_id"] = str(tool_data.get("_id", 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 = tm.load_tool(
|
||||||
tool_data["name"],
|
tool_data["name"],
|
||||||
tool_config=tool_config,
|
tool_config=tool_config,
|
||||||
@@ -269,6 +271,27 @@ class BaseAgent(ABC):
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Executing tool: {action_name} with args: {call_args}")
|
logger.debug(f"Executing tool: {action_name} with args: {call_args}")
|
||||||
result = tool.execute_action(action_name, **parameters)
|
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"] = (
|
tool_call_data["result"] = (
|
||||||
f"{str(result)[:50]}..." if len(str(result)) > 50 else result
|
f"{str(result)[:50]}..." if len(str(result)) > 50 else result
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class NotesTool(Tool):
|
|||||||
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
|
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
|
||||||
self.collection = db["notes"]
|
self.collection = db["notes"]
|
||||||
|
|
||||||
|
self._last_artifact_id: Optional[str] = None
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Action implementations
|
# Action implementations
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -54,6 +56,8 @@ class NotesTool(Tool):
|
|||||||
if not self.user_id:
|
if not self.user_id:
|
||||||
return "Error: NotesTool requires a valid user_id."
|
return "Error: NotesTool requires a valid user_id."
|
||||||
|
|
||||||
|
self._last_artifact_id = None
|
||||||
|
|
||||||
if action_name == "view":
|
if action_name == "view":
|
||||||
return self._get_note()
|
return self._get_note()
|
||||||
|
|
||||||
@@ -125,6 +129,9 @@ class NotesTool(Tool):
|
|||||||
"""Return configuration requirements (none for now)."""
|
"""Return configuration requirements (none for now)."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
|
||||||
|
return self._last_artifact_id
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Internal helpers (single-note)
|
# 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})
|
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
|
||||||
if not doc or not doc.get("note"):
|
if not doc or not doc.get("note"):
|
||||||
return "No note found."
|
return "No note found."
|
||||||
|
if doc.get("_id") is not None:
|
||||||
|
self._last_artifact_id = str(doc.get("_id"))
|
||||||
return str(doc["note"])
|
return str(doc["note"])
|
||||||
|
|
||||||
def _overwrite_note(self, content: str) -> str:
|
def _overwrite_note(self, content: str) -> str:
|
||||||
content = (content or "").strip()
|
content = (content or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
return "Note content required."
|
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},
|
{"user_id": self.user_id, "tool_id": self.tool_id},
|
||||||
{"$set": {"note": content, "updated_at": datetime.utcnow()}},
|
{"$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."
|
return "Note saved."
|
||||||
|
|
||||||
def _str_replace(self, old_str: str, new_str: str) -> str:
|
def _str_replace(self, old_str: str, new_str: str) -> str:
|
||||||
@@ -163,10 +175,13 @@ class NotesTool(Tool):
|
|||||||
import re
|
import re
|
||||||
updated_note = re.sub(re.escape(old_str), new_str, current_note, flags=re.IGNORECASE)
|
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},
|
{"user_id": self.user_id, "tool_id": self.tool_id},
|
||||||
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
|
{"$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."
|
return "Note updated."
|
||||||
|
|
||||||
def _insert(self, line_number: int, text: str) -> str:
|
def _insert(self, line_number: int, text: str) -> str:
|
||||||
@@ -188,12 +203,21 @@ class NotesTool(Tool):
|
|||||||
lines.insert(index, text)
|
lines.insert(index, text)
|
||||||
updated_note = "\n".join(lines)
|
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},
|
{"user_id": self.user_id, "tool_id": self.tool_id},
|
||||||
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
|
{"$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."
|
return "Text inserted."
|
||||||
|
|
||||||
def _delete_note(self) -> str:
|
def _delete_note(self) -> str:
|
||||||
res = self.collection.delete_one({"user_id": self.user_id, "tool_id": self.tool_id})
|
doc = self.collection.find_one_and_delete(
|
||||||
return "Note deleted." if res.deleted_count else "No note found to 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."
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ class TodoListTool(Tool):
|
|||||||
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
|
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
|
||||||
self.collection = db["todos"]
|
self.collection = db["todos"]
|
||||||
|
|
||||||
|
self._last_artifact_id: Optional[str] = None
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Action implementations
|
# Action implementations
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -54,6 +56,8 @@ class TodoListTool(Tool):
|
|||||||
if not self.user_id:
|
if not self.user_id:
|
||||||
return "Error: TodoListTool requires a valid user_id."
|
return "Error: TodoListTool requires a valid user_id."
|
||||||
|
|
||||||
|
self._last_artifact_id = None
|
||||||
|
|
||||||
if action_name == "list":
|
if action_name == "list":
|
||||||
return self._list()
|
return self._list()
|
||||||
|
|
||||||
@@ -165,6 +169,9 @@ class TodoListTool(Tool):
|
|||||||
"""Return configuration requirements."""
|
"""Return configuration requirements."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
def get_artifact_id(self, action_name: str, **kwargs: Any) -> Optional[str]:
|
||||||
|
return self._last_artifact_id
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Internal helpers
|
# Internal helpers
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
@@ -190,11 +197,8 @@ class TodoListTool(Tool):
|
|||||||
Returns a simple integer (1, 2, 3, ...) scoped to this user/tool.
|
Returns a simple integer (1, 2, 3, ...) scoped to this user/tool.
|
||||||
With 5-10 todos max, scanning is negligible.
|
With 5-10 todos max, scanning is negligible.
|
||||||
"""
|
"""
|
||||||
# Find all todos for this user/tool and get their IDs
|
query = {"user_id": self.user_id, "tool_id": self.tool_id}
|
||||||
todos = list(self.collection.find(
|
todos = list(self.collection.find(query, {"todo_id": 1}))
|
||||||
{"user_id": self.user_id, "tool_id": self.tool_id},
|
|
||||||
{"todo_id": 1}
|
|
||||||
))
|
|
||||||
|
|
||||||
# Find the maximum todo_id
|
# Find the maximum todo_id
|
||||||
max_id = 0
|
max_id = 0
|
||||||
@@ -207,8 +211,8 @@ class TodoListTool(Tool):
|
|||||||
|
|
||||||
def _list(self) -> str:
|
def _list(self) -> str:
|
||||||
"""List all todos for the user."""
|
"""List all todos for the user."""
|
||||||
cursor = self.collection.find({"user_id": self.user_id, "tool_id": self.tool_id})
|
query = {"user_id": self.user_id, "tool_id": self.tool_id}
|
||||||
todos = list(cursor)
|
todos = list(self.collection.find(query))
|
||||||
|
|
||||||
if not todos:
|
if not todos:
|
||||||
return "No todos found."
|
return "No todos found."
|
||||||
@@ -242,7 +246,10 @@ class TodoListTool(Tool):
|
|||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_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}"
|
return f"Todo created with ID {todo_id}: {title}"
|
||||||
|
|
||||||
def _get(self, todo_id: Optional[Any]) -> str:
|
def _get(self, todo_id: Optional[Any]) -> str:
|
||||||
@@ -251,15 +258,15 @@ class TodoListTool(Tool):
|
|||||||
if parsed_todo_id is None:
|
if parsed_todo_id is None:
|
||||||
return "Error: todo_id must be a positive integer."
|
return "Error: todo_id must be a positive integer."
|
||||||
|
|
||||||
doc = self.collection.find_one({
|
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
|
||||||
"user_id": self.user_id,
|
doc = self.collection.find_one(query)
|
||||||
"tool_id": self.tool_id,
|
|
||||||
"todo_id": parsed_todo_id
|
|
||||||
})
|
|
||||||
|
|
||||||
if not doc:
|
if not doc:
|
||||||
return f"Error: Todo with ID {parsed_todo_id} not found."
|
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")
|
title = doc.get("title", "Untitled")
|
||||||
status = doc.get("status", "open")
|
status = doc.get("status", "open")
|
||||||
|
|
||||||
@@ -277,14 +284,17 @@ class TodoListTool(Tool):
|
|||||||
if not title:
|
if not title:
|
||||||
return "Error: Title is required."
|
return "Error: Title is required."
|
||||||
|
|
||||||
result = self.collection.update_one(
|
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
|
||||||
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
|
doc = self.collection.find_one_and_update(
|
||||||
{"$set": {"title": title, "updated_at": datetime.now()}}
|
query,
|
||||||
|
{"$set": {"title": title, "updated_at": datetime.now()}},
|
||||||
)
|
)
|
||||||
|
if not doc:
|
||||||
if result.matched_count == 0:
|
|
||||||
return f"Error: Todo with ID {parsed_todo_id} not found."
|
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}"
|
return f"Todo {parsed_todo_id} updated to: {title}"
|
||||||
|
|
||||||
def _complete(self, todo_id: Optional[Any]) -> str:
|
def _complete(self, todo_id: Optional[Any]) -> str:
|
||||||
@@ -293,14 +303,17 @@ class TodoListTool(Tool):
|
|||||||
if parsed_todo_id is None:
|
if parsed_todo_id is None:
|
||||||
return "Error: todo_id must be a positive integer."
|
return "Error: todo_id must be a positive integer."
|
||||||
|
|
||||||
result = self.collection.update_one(
|
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
|
||||||
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
|
doc = self.collection.find_one_and_update(
|
||||||
{"$set": {"status": "completed", "updated_at": datetime.now()}}
|
query,
|
||||||
|
{"$set": {"status": "completed", "updated_at": datetime.now()}},
|
||||||
)
|
)
|
||||||
|
if not doc:
|
||||||
if result.matched_count == 0:
|
|
||||||
return f"Error: Todo with ID {parsed_todo_id} not found."
|
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."
|
return f"Todo {parsed_todo_id} marked as completed."
|
||||||
|
|
||||||
def _delete(self, todo_id: Optional[Any]) -> str:
|
def _delete(self, todo_id: Optional[Any]) -> str:
|
||||||
@@ -309,13 +322,12 @@ class TodoListTool(Tool):
|
|||||||
if parsed_todo_id is None:
|
if parsed_todo_id is None:
|
||||||
return "Error: todo_id must be a positive integer."
|
return "Error: todo_id must be a positive integer."
|
||||||
|
|
||||||
result = self.collection.delete_one({
|
query = {"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id}
|
||||||
"user_id": self.user_id,
|
doc = self.collection.find_one_and_delete(query)
|
||||||
"tool_id": self.tool_id,
|
if not doc:
|
||||||
"todo_id": parsed_todo_id
|
|
||||||
})
|
|
||||||
|
|
||||||
if result.deleted_count == 0:
|
|
||||||
return f"Error: Todo with ID {parsed_todo_id} not found."
|
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."
|
return f"Todo {parsed_todo_id} deleted."
|
||||||
|
|||||||
@@ -467,3 +467,84 @@ class ParseSpec(Resource):
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
current_app.logger.error(f"Error parsing spec: {err}", exc_info=True)
|
current_app.logger.error(f"Error parsing spec: {err}", exc_info=True)
|
||||||
return make_response(jsonify({"success": False, "error": "Failed to parse specification"}), 500)
|
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
|
||||||
|
)
|
||||||
|
|||||||
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==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -2623,6 +2624,7 @@
|
|||||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.21.3",
|
"@babel/core": "^7.21.3",
|
||||||
"@svgr/babel-preset": "8.1.0",
|
"@svgr/babel-preset": "8.1.0",
|
||||||
@@ -3408,6 +3410,7 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -4071,6 +4074,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4421,6 +4425,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
@@ -4601,6 +4606,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -4613,6 +4619,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||||
"@chevrotain/gast": "11.0.3",
|
"@chevrotain/gast": "11.0.3",
|
||||||
@@ -4858,6 +4865,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
@@ -5267,6 +5275,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -5887,6 +5896,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5963,6 +5973,7 @@
|
|||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -7360,6 +7371,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -10313,6 +10325,7 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -10497,6 +10510,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -10516,6 +10530,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -10615,6 +10630,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
@@ -10789,7 +10805,8 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -11927,6 +11944,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -12157,6 +12175,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -12459,6 +12478,7 @@
|
|||||||
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
|
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -12567,6 +12587,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const endpoints = {
|
|||||||
AGENT_FOLDERS: '/api/agents/folders/',
|
AGENT_FOLDERS: '/api/agents/folders/',
|
||||||
AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`,
|
AGENT_FOLDER: (id: string) => `/api/agents/folders/${id}`,
|
||||||
MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent',
|
MOVE_AGENT_TO_FOLDER: '/api/agents/folders/move_agent',
|
||||||
|
GET_ARTIFACT: (artifactId: string) => `/api/artifact/${artifactId}`,
|
||||||
WORKFLOWS: '/api/workflows',
|
WORKFLOWS: '/api/workflows',
|
||||||
WORKFLOW: (id: string) => `/api/workflows/${id}`,
|
WORKFLOW: (id: string) => `/api/workflows/${id}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ const userService = {
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): Promise<any> =>
|
): Promise<any> =>
|
||||||
apiClient.post(endpoints.USER.MOVE_AGENT_TO_FOLDER, data, token),
|
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> =>
|
getWorkflow: (id: string, token: string | null): Promise<any> =>
|
||||||
apiClient.get(endpoints.USER.WORKFLOW(id), token),
|
apiClient.get(endpoints.USER.WORKFLOW(id), token),
|
||||||
createWorkflow: (data: any, token: string | null): Promise<any> =>
|
createWorkflow: (data: any, token: string | null): Promise<any> =>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface ActionButtonsProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
showNewChat?: boolean;
|
showNewChat?: boolean;
|
||||||
showShare?: boolean;
|
showShare?: boolean;
|
||||||
|
isArtifactOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -24,6 +25,7 @@ export default function ActionButtons({
|
|||||||
className = '',
|
className = '',
|
||||||
showNewChat = true,
|
showNewChat = true,
|
||||||
showShare = true,
|
showShare = true,
|
||||||
|
isArtifactOpen = false,
|
||||||
}: ActionButtonsProps) {
|
}: ActionButtonsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@@ -41,7 +43,11 @@ export default function ActionButtons({
|
|||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
return (
|
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}`}>
|
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
|
||||||
{showNewChat && (
|
{showNewChat && (
|
||||||
<button
|
<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 { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import SharedAgentCard from '../agents/SharedAgentCard';
|
import SharedAgentCard from '../agents/SharedAgentCard';
|
||||||
|
import ArtifactSidebar from '../components/ArtifactSidebar';
|
||||||
import MessageInput from '../components/MessageInput';
|
import MessageInput from '../components/MessageInput';
|
||||||
import { useMediaQuery } from '../hooks';
|
import { useMediaQuery } from '../hooks';
|
||||||
import {
|
import {
|
||||||
@@ -14,20 +15,16 @@ import { AppDispatch } from '../store';
|
|||||||
import { handleSendFeedback } from './conversationHandlers';
|
import { handleSendFeedback } from './conversationHandlers';
|
||||||
import ConversationMessages from './ConversationMessages';
|
import ConversationMessages from './ConversationMessages';
|
||||||
import { FEEDBACK, Query } from './conversationModels';
|
import { FEEDBACK, Query } from './conversationModels';
|
||||||
|
import { ToolCallsType } from './types';
|
||||||
import {
|
import {
|
||||||
addQuery,
|
addQuery,
|
||||||
fetchAnswer,
|
fetchAnswer,
|
||||||
resendQuery,
|
resendQuery,
|
||||||
selectQueries,
|
selectQueries,
|
||||||
selectStatus,
|
selectStatus,
|
||||||
setConversation,
|
|
||||||
updateConversationId,
|
|
||||||
updateQuery,
|
updateQuery,
|
||||||
} from './conversationSlice';
|
} from './conversationSlice';
|
||||||
import {
|
import { selectCompletedAttachments } from '../upload/uploadSlice';
|
||||||
selectCompletedAttachments,
|
|
||||||
clearAttachments,
|
|
||||||
} from '../upload/uploadSlice';
|
|
||||||
|
|
||||||
export default function Conversation() {
|
export default function Conversation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -43,13 +40,33 @@ export default function Conversation() {
|
|||||||
|
|
||||||
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
|
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
|
||||||
useState<boolean>(false);
|
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(
|
const handleFetchAnswer = useCallback(
|
||||||
({ question, index }: { question: string; index?: number }) => {
|
({ question, index }: { question: string; index?: number }) => {
|
||||||
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
|
dispatch(fetchAnswer({ question, indx: index }));
|
||||||
},
|
},
|
||||||
[dispatch, selectedAgent],
|
[dispatch, selectedAgent],
|
||||||
);
|
);
|
||||||
@@ -143,61 +160,138 @@ export default function Conversation() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetConversation = () => {
|
useEffect(() => {
|
||||||
dispatch(setConversation([]));
|
if (queries.length) {
|
||||||
dispatch(
|
const last = queries[queries.length - 1];
|
||||||
updateConversationId({
|
if (last.error) setLastQueryReturnedErr(true);
|
||||||
query: { conversationId: null },
|
if (last.response) setLastQueryReturnedErr(false);
|
||||||
}),
|
}
|
||||||
);
|
}, [queries]);
|
||||||
dispatch(clearAttachments());
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queries.length === 0) {
|
// Avoid auto-opening an artifact from existing conversation history on first mount.
|
||||||
setLastQueryReturnedErr(false);
|
if (!didInitArtifactAutoOpen.current) {
|
||||||
|
didInitArtifactAutoOpen.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastQuery = queries[queries.length - 1];
|
const isNotesOrTodoTool = (toolName?: string) => {
|
||||||
setLastQueryReturnedErr(!!lastQuery.error && !lastQuery.response);
|
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]);
|
}, [queries]);
|
||||||
|
|
||||||
return (
|
const handleOpenArtifact = useCallback(
|
||||||
<div className="flex h-full flex-col justify-end gap-1">
|
(artifact: { id: string; toolName: string }) => {
|
||||||
<ConversationMessages
|
lastAutoOpenedArtifactId.current = artifact.id;
|
||||||
handleQuestion={handleQuestion}
|
setOpenArtifact(artifact);
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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">
|
const handleCloseArtifact = useCallback(() => setOpenArtifact(null), []);
|
||||||
<div className="flex w-full items-center rounded-[40px] px-2">
|
|
||||||
<MessageInput
|
const isSplitArtifactOpen = !isMobile && openArtifact !== null;
|
||||||
key={conversationId || 'new'}
|
|
||||||
onSubmit={(text) => {
|
return (
|
||||||
handleQuestionSubmission(text);
|
<div className="flex h-full">
|
||||||
}}
|
<div
|
||||||
loading={status === 'loading'}
|
className={`flex h-full min-h-0 flex-col transition-all ${
|
||||||
showSourceButton={selectedAgent ? false : true}
|
isSplitArtifactOpen ? 'w-[60%] px-6' : 'w-full'
|
||||||
showToolButton={selectedAgent ? false : true}
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
</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">
|
<div
|
||||||
{t('tagline')}
|
className={`bg-opacity-0 z-3 flex h-auto w-full flex-col items-end self-center rounded-2xl py-1 ${
|
||||||
</p>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const ConversationBubble = forwardRef<
|
|||||||
index?: number,
|
index?: number,
|
||||||
) => void;
|
) => void;
|
||||||
filesAttached?: { id: string; fileName: string }[];
|
filesAttached?: { id: string; fileName: string }[];
|
||||||
|
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
|
||||||
}
|
}
|
||||||
>(function ConversationBubble(
|
>(function ConversationBubble(
|
||||||
{
|
{
|
||||||
@@ -78,6 +79,7 @@ const ConversationBubble = forwardRef<
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
handleUpdatedQuestionSubmission,
|
handleUpdatedQuestionSubmission,
|
||||||
filesAttached,
|
filesAttached,
|
||||||
|
onOpenArtifact,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -96,6 +98,21 @@ const ConversationBubble = forwardRef<
|
|||||||
const editableQueryRef = useRef<HTMLDivElement>(null);
|
const editableQueryRef = useRef<HTMLDivElement>(null);
|
||||||
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
|
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);
|
useOutsideAlerter(editableQueryRef, () => setIsEditClicked(false), [], true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -379,6 +396,45 @@ const ConversationBubble = forwardRef<
|
|||||||
{toolCalls && toolCalls.length > 0 && (
|
{toolCalls && toolCalls.length > 0 && (
|
||||||
<ToolCalls toolCalls={toolCalls} />
|
<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={thought} preprocessLaTeX={preprocessLaTeX} />
|
<Thought thought={thought} preprocessLaTeX={preprocessLaTeX} />
|
||||||
)}
|
)}
|
||||||
@@ -548,6 +604,46 @@ const ConversationBubble = forwardRef<
|
|||||||
</div>
|
</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 && (
|
{!isStreaming && (
|
||||||
<>
|
<>
|
||||||
<div className="relative mr-2 block items-center justify-center">
|
<div className="relative mr-2 block items-center justify-center">
|
||||||
@@ -692,106 +788,107 @@ export default ConversationBubble;
|
|||||||
|
|
||||||
function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
|
function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
|
||||||
const [isToolCallsOpen, setIsToolCallsOpen] = useState(false);
|
const [isToolCallsOpen, setIsToolCallsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
<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">
|
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||||
<Avatar
|
<Avatar
|
||||||
className="h-[26px] w-[30px] text-xl"
|
className="h-[26px] w-[30px] text-xl"
|
||||||
avatar={
|
avatar={
|
||||||
<img
|
<img
|
||||||
src={Sources}
|
src={Sources}
|
||||||
alt={'ToolCalls'}
|
alt={'ToolCalls'}
|
||||||
className="h-full w-full object-fill"
|
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' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
<button
|
||||||
</div>
|
className="flex flex-row items-center gap-2"
|
||||||
{isToolCallsOpen && (
|
onClick={() => setIsToolCallsOpen(!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">
|
<p className="text-base font-semibold">Tool Calls</p>
|
||||||
{toolCalls.map((toolCall, index) => (
|
<img
|
||||||
<Accordion
|
src={ChevronDown}
|
||||||
key={`tool-call-${index}`}
|
alt="ChevronDown"
|
||||||
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
|
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isToolCallsOpen ? 'rotate-180' : ''}`}
|
||||||
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"
|
</button>
|
||||||
>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
{isToolCallsOpen && (
|
||||||
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
|
<div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
|
||||||
<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">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
|
{toolCalls.map((toolCall, index) => (
|
||||||
Arguments
|
<Accordion
|
||||||
</span>{' '}
|
key={`tool-call-${index}`}
|
||||||
<CopyButton
|
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
|
||||||
textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
|
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"
|
||||||
</p>
|
>
|
||||||
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
<div className="flex flex-col gap-1">
|
||||||
<span
|
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
|
||||||
className="leading-[23px] text-black dark:text-gray-400"
|
<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">
|
||||||
style={{ fontFamily: 'IBMPlexMono-Medium' }}
|
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
|
||||||
>
|
Arguments
|
||||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
</span>{' '}
|
||||||
</span>
|
<CopyButton
|
||||||
</p>
|
textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
|
||||||
</div>
|
/>
|
||||||
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
|
</p>
|
||||||
<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">
|
<p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm 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
|
<span
|
||||||
className="leading-[23px] text-black dark:text-gray-400"
|
className="leading-[23px] text-black dark:text-gray-400"
|
||||||
style={{ fontFamily: 'IBMPlexMono-Medium' }}
|
style={{ fontFamily: 'IBMPlexMono-Medium' }}
|
||||||
>
|
>
|
||||||
{JSON.stringify(toolCall.result, null, 2)}
|
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
{toolCall.status === 'error' && (
|
<div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
|
||||||
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
|
<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
|
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
|
||||||
className="leading-[23px] text-red-500 dark:text-red-400"
|
Response
|
||||||
style={{ fontFamily: 'IBMPlexMono-Medium' }}
|
</span>{' '}
|
||||||
>
|
<CopyButton
|
||||||
{toolCall.error}
|
textToCopy={
|
||||||
</span>
|
toolCall.status === 'error'
|
||||||
|
? toolCall.error || 'Unknown error'
|
||||||
|
: JSON.stringify(toolCall.result, null, 2)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</Accordion>
|
||||||
</Accordion>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type ConversationMessagesProps = {
|
|||||||
status: Status;
|
status: Status;
|
||||||
showHeroOnEmpty?: boolean;
|
showHeroOnEmpty?: boolean;
|
||||||
headerContent?: ReactNode;
|
headerContent?: ReactNode;
|
||||||
|
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
|
||||||
|
isSplitView?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConversationMessages({
|
export default function ConversationMessages({
|
||||||
@@ -46,6 +48,8 @@ export default function ConversationMessages({
|
|||||||
handleFeedback,
|
handleFeedback,
|
||||||
showHeroOnEmpty = true,
|
showHeroOnEmpty = true,
|
||||||
headerContent,
|
headerContent,
|
||||||
|
onOpenArtifact,
|
||||||
|
isSplitView = false,
|
||||||
}: ConversationMessagesProps) {
|
}: ConversationMessagesProps) {
|
||||||
const [isDarkTheme] = useDarkTheme();
|
const [isDarkTheme] = useDarkTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -147,6 +151,7 @@ export default function ConversationMessages({
|
|||||||
thought={query.thought}
|
thought={query.thought}
|
||||||
sources={query.sources}
|
sources={query.sources}
|
||||||
toolCalls={query.tool_calls}
|
toolCalls={query.tool_calls}
|
||||||
|
onOpenArtifact={onOpenArtifact}
|
||||||
feedback={query.feedback}
|
feedback={query.feedback}
|
||||||
isStreaming={isCurrentlyStreaming}
|
isStreaming={isCurrentlyStreaming}
|
||||||
handleFeedback={
|
handleFeedback={
|
||||||
@@ -213,7 +218,13 @@ export default function ConversationMessages({
|
|||||||
</button>
|
</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}
|
{headerContent}
|
||||||
|
|
||||||
{queries.length > 0 ? (
|
{queries.length > 0 ? (
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export type ToolCallsType = {
|
|||||||
result?: Record<string, any>;
|
result?: Record<string, any>;
|
||||||
error?: string;
|
error?: string;
|
||||||
status?: 'pending' | 'completed' | 'error';
|
status?: 'pending' | 'completed' | 'error';
|
||||||
|
artifact_id?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
194
tests/agents/test_get_artifact.py
Normal file
194
tests/agents/test_get_artifact.py
Normal 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
|
||||||
@@ -191,3 +191,11 @@ def mock_tool_manager(mock_tool, monkeypatch):
|
|||||||
"application.agents.base.ToolManager", Mock(return_value=manager)
|
"application.agents.base.ToolManager", Mock(return_value=manager)
|
||||||
)
|
)
|
||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def flask_app():
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
return app
|
||||||
|
|||||||
@@ -10,20 +10,23 @@ def notes_tool(monkeypatch) -> NotesTool:
|
|||||||
class FakeCollection:
|
class FakeCollection:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.docs = {} # key: user_id:tool_id -> doc
|
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):
|
def update_one(self, q, u, upsert=False):
|
||||||
user_id = q.get("user_id")
|
user_id = q.get("user_id")
|
||||||
tool_id = q.get("tool_id")
|
tool_id = q.get("tool_id")
|
||||||
key = f"{user_id}:{tool_id}"
|
key = f"{user_id}:{tool_id}"
|
||||||
|
|
||||||
# emulate single-note storage with optional upsert
|
|
||||||
|
|
||||||
if key not in self.docs and not upsert:
|
if key not in self.docs and not upsert:
|
||||||
return type("res", (), {"modified_count": 0})
|
return type("res", (), {"modified_count": 0})
|
||||||
if key not in self.docs and upsert:
|
if key not in self.docs and upsert:
|
||||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
|
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||||
if "$set" in u and "note" in u["$set"]:
|
if "$set" in u:
|
||||||
self.docs[key]["note"] = u["$set"]["note"]
|
self.docs[key].update(u["$set"])
|
||||||
return type("res", (), {"modified_count": 1})
|
return type("res", (), {"modified_count": 1})
|
||||||
|
|
||||||
def find_one(self, q):
|
def find_one(self, q):
|
||||||
@@ -32,6 +35,28 @@ def notes_tool(monkeypatch) -> NotesTool:
|
|||||||
key = f"{user_id}:{tool_id}"
|
key = f"{user_id}:{tool_id}"
|
||||||
return self.docs.get(key)
|
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):
|
def delete_one(self, q):
|
||||||
user_id = q.get("user_id")
|
user_id = q.get("user_id")
|
||||||
tool_id = q.get("tool_id")
|
tool_id = q.get("tool_id")
|
||||||
@@ -147,12 +172,9 @@ def test_insert_line(notes_tool: NotesTool) -> None:
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_delete_nonexistent_note(monkeypatch):
|
def test_delete_nonexistent_note(monkeypatch):
|
||||||
class FakeResult:
|
|
||||||
deleted_count = 0
|
|
||||||
|
|
||||||
class FakeCollection:
|
class FakeCollection:
|
||||||
def delete_one(self, *args, **kwargs):
|
def find_one_and_delete(self, q):
|
||||||
return FakeResult()
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"application.core.mongo_db.MongoDB.get_client",
|
"application.core.mongo_db.MongoDB.get_client",
|
||||||
@@ -171,6 +193,11 @@ def test_notes_tool_isolation(monkeypatch) -> None:
|
|||||||
class FakeCollection:
|
class FakeCollection:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.docs = {}
|
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):
|
def update_one(self, q, u, upsert=False):
|
||||||
user_id = q.get("user_id")
|
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:
|
if key not in self.docs and not upsert:
|
||||||
return type("res", (), {"modified_count": 0})
|
return type("res", (), {"modified_count": 0})
|
||||||
if key not in self.docs and upsert:
|
if key not in self.docs and upsert:
|
||||||
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
|
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": "", "_id": self._generate_id()}
|
||||||
if "$set" in u and "note" in u["$set"]:
|
if "$set" in u:
|
||||||
self.docs[key]["note"] = u["$set"]["note"]
|
self.docs[key].update(u["$set"])
|
||||||
return type("res", (), {"modified_count": 1})
|
return type("res", (), {"modified_count": 1})
|
||||||
|
|
||||||
def find_one(self, q):
|
def find_one(self, q):
|
||||||
@@ -191,6 +218,19 @@ def test_notes_tool_isolation(monkeypatch) -> None:
|
|||||||
key = f"{user_id}:{tool_id}"
|
key = f"{user_id}:{tool_id}"
|
||||||
return self.docs.get(key)
|
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_collection = FakeCollection()
|
||||||
fake_db = {"notes": fake_collection}
|
fake_db = {"notes": fake_collection}
|
||||||
fake_client = {settings.MONGO_DB_NAME: fake_db}
|
fake_client = {settings.MONGO_DB_NAME: fake_db}
|
||||||
|
|||||||
@@ -24,14 +24,21 @@ class FakeCursor(list):
|
|||||||
class FakeCollection:
|
class FakeCollection:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.docs = {}
|
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):
|
def create_index(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def insert_one(self, doc):
|
def insert_one(self, doc):
|
||||||
key = (doc["user_id"], doc["tool_id"], doc["todo_id"])
|
key = (doc["user_id"], doc["tool_id"], doc["todo_id"])
|
||||||
|
if "_id" not in doc:
|
||||||
|
doc["_id"] = self._generate_id()
|
||||||
self.docs[key] = doc
|
self.docs[key] = doc
|
||||||
return type("res", (), {"inserted_id": key})
|
return type("res", (), {"inserted_id": doc["_id"]})
|
||||||
|
|
||||||
def find_one(self, query):
|
def find_one(self, query):
|
||||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
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", {}))
|
self.docs[key].update(update.get("$set", {}))
|
||||||
return type("res", (), {"matched_count": 1})
|
return type("res", (), {"matched_count": 1})
|
||||||
elif upsert:
|
elif upsert:
|
||||||
new_doc = {**query, **update.get("$set", {})}
|
new_doc = {**query, **update.get("$set", {}), "_id": self._generate_id()}
|
||||||
self.docs[key] = new_doc
|
self.docs[key] = new_doc
|
||||||
return type("res", (), {"matched_count": 1})
|
return type("res", (), {"matched_count": 1})
|
||||||
else:
|
else:
|
||||||
return type("res", (), {"matched_count": 0})
|
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):
|
def delete_one(self, query):
|
||||||
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
key = (query.get("user_id"), query.get("tool_id"), query.get("todo_id"))
|
||||||
if key in self.docs:
|
if key in self.docs:
|
||||||
|
|||||||
Reference in New Issue
Block a user