mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
223 files changed
+23053
-5733
No files matched your search
@@ -201,6 +201,9 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/agents/<string:agent_id>/schedules", "GET"): _rule(
|
||||
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
("/api/agents/<string:agent_id>/schedules/stats", "GET"): _rule(
|
||||
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
("/api/agents/<string:agent_id>/schedules", "POST"): _rule(
|
||||
"schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
@@ -225,6 +228,7 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/sources/<string:source_id>/graph/node/<string:node_id>", "GET"): _rule(
|
||||
"sources:read", (VIEW, "source_id")
|
||||
),
|
||||
("/api/sources/<string:source_id>/graph/nodes", "GET"): _rule("sources:read", (VIEW, "source_id")),
|
||||
# Ingestion and attachment extraction both report through this poll.
|
||||
("/api/task_status", "GET"): _rule(any_of=("sources:read", "sources:write", "chat:run"), open=True),
|
||||
("/api/upload", "POST"): _rule("sources:write"),
|
||||
|
||||
@@ -317,6 +317,55 @@ class AgentSchedules(Resource):
|
||||
return _ok({"schedule": _format_schedule(created)}, status=201)
|
||||
|
||||
|
||||
@schedules_ns.route("/agents/<string:agent_id>/schedules/stats")
|
||||
class AgentScheduleStats(Resource):
|
||||
@api.doc(
|
||||
description="Run stats for an agent's schedules over a recent window.",
|
||||
params={"days": "Window in days (default 30, clamped to 1..365)"},
|
||||
)
|
||||
@_safe_route
|
||||
def get(self, agent_id: str):
|
||||
"""Return run count, failures, tokens and latest failure for an agent.
|
||||
|
||||
Args:
|
||||
agent_id: Agent id from the URL.
|
||||
|
||||
Returns:
|
||||
A Flask response with ``days``, ``runs``, ``failed``, ``tokens``
|
||||
and ``latest_failure`` (or an error envelope).
|
||||
"""
|
||||
user_id = _user_id()
|
||||
if not user_id:
|
||||
return _err("unauthorized", 401)
|
||||
agent = _agent_owned(agent_id, user_id)
|
||||
if agent is None:
|
||||
return _err("agent not found", 404)
|
||||
try:
|
||||
days = max(1, min(int(request.args.get("days", 30)), 365))
|
||||
except (TypeError, ValueError):
|
||||
days = 30
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
stats = ScheduleRunsRepository(conn).stats_for_agent(
|
||||
str(agent["id"]), user_id, days=days,
|
||||
)
|
||||
except Exception as exc:
|
||||
current_app.logger.error(
|
||||
"schedule stats failed: %s", exc, exc_info=True,
|
||||
)
|
||||
return _err("internal error", 500)
|
||||
latest = stats.get("latest_failure")
|
||||
return _ok(
|
||||
{
|
||||
"days": days,
|
||||
"runs": stats["runs"],
|
||||
"failed": stats["failed"],
|
||||
"tokens": stats["tokens"],
|
||||
"latest_failure": _format_run(latest) if latest else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@schedules_ns.route("/schedules/<string:schedule_id>")
|
||||
class ScheduleResource(Resource):
|
||||
@api.doc(description="Get schedule by id.")
|
||||
|
||||
@@ -7,10 +7,11 @@ from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from docsgpt.api import api
|
||||
from docsgpt.api.user.base import get_vector_store
|
||||
from docsgpt.api.user.team_sharing import effective_write_owner
|
||||
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
from docsgpt.storage.db.session import db_readonly
|
||||
from docsgpt.utils import check_required_fields, num_tokens_from_string
|
||||
from docsgpt.vectorstore.base import InvalidChunkMetadataError
|
||||
|
||||
sources_chunks_ns = Namespace(
|
||||
"sources", description="Source document management operations", path="/api"
|
||||
@@ -18,12 +19,18 @@ sources_chunks_ns = Namespace(
|
||||
|
||||
|
||||
def _resolve_source(doc_id: str, user: str):
|
||||
"""Resolve a source (UUID or legacy ObjectId) for the caller.
|
||||
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
|
||||
|
||||
Returns the row dict (with PG UUID in ``id``) or ``None`` if missing.
|
||||
Read access = owner or any team grant (viewer/editor). Returns the row
|
||||
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
|
||||
"""
|
||||
with db_readonly() as conn:
|
||||
return SourcesRepository(conn).get_any(doc_id, user)
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
if doc is not None:
|
||||
return doc
|
||||
if not can_access(conn, "source", doc_id, user):
|
||||
return None
|
||||
return SourcesRepository(conn).get_by_id(doc_id)
|
||||
|
||||
|
||||
def _resolve_source_for_write(doc_id: str, user: str):
|
||||
@@ -45,6 +52,35 @@ def _resolve_source_for_write(doc_id: str, user: str):
|
||||
return SourcesRepository(conn).get_any(doc_id, owner)
|
||||
|
||||
|
||||
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
"""Move a graphrag source's links from an edited chunk's old id to its new one.
|
||||
|
||||
Only needed when the store's ``update_chunk`` fell back to re-adding the
|
||||
chunk under a new id (stores that update in place keep the id). Without
|
||||
this the graph keeps pointing at the deleted row: the entity loses the
|
||||
chunk and retrieval stops returning it. A failure is logged, not raised,
|
||||
because the edit itself has already been saved.
|
||||
|
||||
Args:
|
||||
doc: The resolved source row.
|
||||
old_chunk_id: The edited chunk's previous id.
|
||||
new_chunk_id: The id the edit was saved under.
|
||||
"""
|
||||
from docsgpt.storage.db.source_config import SourceConfig
|
||||
|
||||
if SourceConfig.parse(doc.get("config")).kind != "graphrag":
|
||||
return
|
||||
try:
|
||||
from docsgpt.graphrag.store import GraphStore
|
||||
|
||||
GraphStore().remap_chunk(str(doc["id"]), old_chunk_id, new_chunk_id)
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
f"Failed to remap graph links from chunk {old_chunk_id} to {new_chunk_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _has_usable_token_count(metadata: dict) -> bool:
|
||||
"""Whether ``metadata`` already carries a count worth showing.
|
||||
|
||||
@@ -98,6 +134,38 @@ def _with_token_counts(chunks: list) -> list:
|
||||
return chunks
|
||||
|
||||
|
||||
def _path_ends_with(value: str, path: str) -> bool:
|
||||
"""Return whether ``value`` is ``path`` or ends with it at a ``/`` boundary.
|
||||
|
||||
A bare ``endswith`` let a root ``setup.md`` also claim the chunks of
|
||||
``guides/setup.md`` (and ``a.md`` those of ``data.md``).
|
||||
"""
|
||||
return bool(value) and (value == path or value.endswith(f"/{path}"))
|
||||
|
||||
|
||||
def _chunk_matches_path(metadata: dict, path: str) -> bool:
|
||||
"""Return whether a chunk belongs to the tree file at ``path``.
|
||||
|
||||
Args:
|
||||
metadata: The chunk's stored metadata.
|
||||
path: The file's key path in the source's ``directory_structure``.
|
||||
|
||||
Returns:
|
||||
True when the chunk's ``source`` or ``file_path`` names that file, or
|
||||
when the worker could only have keyed it by title: a remote ingest
|
||||
(web page, Reddit post) whose chunks carry no ``file_path`` or ``key``
|
||||
(see ``remote_worker``). Sources ingested that way stay browsable
|
||||
without a re-ingest.
|
||||
"""
|
||||
source = metadata.get("source") or ""
|
||||
file_path = metadata.get("file_path") or ""
|
||||
if _path_ends_with(source, path) or _path_ends_with(file_path, path):
|
||||
return True
|
||||
if "://" in source and not file_path and not metadata.get("key"):
|
||||
return metadata.get("title") == path
|
||||
return False
|
||||
|
||||
|
||||
@sources_chunks_ns.route("/get_chunks")
|
||||
class GetChunks(Resource):
|
||||
@api.doc(
|
||||
@@ -141,14 +209,8 @@ class GetChunks(Resource):
|
||||
for chunk in chunks:
|
||||
metadata = chunk.get("metadata", {})
|
||||
|
||||
if path:
|
||||
chunk_source = metadata.get("source", "")
|
||||
chunk_file_path = metadata.get("file_path", "")
|
||||
source_match = chunk_source and chunk_source.endswith(path)
|
||||
file_path_match = chunk_file_path and chunk_file_path.endswith(path)
|
||||
|
||||
if not (source_match or file_path_match):
|
||||
continue
|
||||
if path and not _chunk_matches_path(metadata, path):
|
||||
continue
|
||||
if search_term:
|
||||
text_match = search_term in chunk.get("text", "").lower()
|
||||
title_match = search_term in metadata.get("title", "").lower()
|
||||
@@ -339,13 +401,11 @@ class UpdateChunk(Resource):
|
||||
if text is not None:
|
||||
new_metadata["token_count"] = num_tokens_from_string(new_text)
|
||||
try:
|
||||
new_chunk_id = store.add_chunk(new_text, new_metadata)
|
||||
|
||||
deleted = store.delete_chunk(chunk_id)
|
||||
if not deleted:
|
||||
current_app.logger.warning(
|
||||
f"Failed to delete old chunk {chunk_id}, but new chunk {new_chunk_id} was created"
|
||||
)
|
||||
# In place where the store supports it (same id, same list
|
||||
# position); the base fallback re-adds under a new id.
|
||||
new_chunk_id = store.update_chunk(chunk_id, new_text, new_metadata)
|
||||
if new_chunk_id != chunk_id:
|
||||
_remap_graph_chunk(doc, chunk_id, new_chunk_id)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
@@ -356,8 +416,13 @@ class UpdateChunk(Resource):
|
||||
),
|
||||
200,
|
||||
)
|
||||
except InvalidChunkMetadataError as meta_error:
|
||||
current_app.logger.warning(
|
||||
f"Rejected metadata for chunk {chunk_id}: {meta_error}"
|
||||
)
|
||||
return make_response(jsonify({"error": "Invalid metadata"}), 400)
|
||||
except Exception as add_error:
|
||||
current_app.logger.error(f"Failed to add updated chunk: {add_error}")
|
||||
current_app.logger.error(f"Failed to update chunk {chunk_id}: {add_error}")
|
||||
return make_response(
|
||||
jsonify({"error": "Failed to update chunk - addition failed"}), 500
|
||||
)
|
||||
|
||||
@@ -46,6 +46,9 @@ from docsgpt.vectorstore.vector_creator import VectorCreator
|
||||
|
||||
WIKI_INDEX_PATH = "/index.md"
|
||||
|
||||
# Longest graph-node search term forwarded to the store; longer input is truncated.
|
||||
_GRAPH_SEARCH_MAX_LEN = 200
|
||||
|
||||
|
||||
sources_ns = Namespace(
|
||||
"sources", description="Source document management operations", path="/api"
|
||||
@@ -545,7 +548,7 @@ class DirectoryStructure(Resource):
|
||||
return make_response(jsonify({"error": "Document ID is required"}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = SourcesRepository(conn).get_any(doc_id, user)
|
||||
doc = _resolve_readable_source(conn, doc_id, user)
|
||||
if not doc:
|
||||
return make_response(
|
||||
jsonify({"error": "Document not found or access denied"}), 404
|
||||
@@ -1247,18 +1250,50 @@ class EnableSourceGraphRAG(Resource):
|
||||
)
|
||||
|
||||
|
||||
def _graph_overview_payload(source_id, limit):
|
||||
"""Return a bounded ``{nodes, edges}`` overview for a graphrag source.
|
||||
def _graph_overview_payload(source_id: str, limit: int) -> dict:
|
||||
"""Return a bounded ``{nodes, edges, stats}`` overview for a graphrag source.
|
||||
|
||||
An empty graph (extraction pending/capped/failed) yields empty lists rather
|
||||
than an error, mirroring the ClassicRAG degradation guarantee.
|
||||
than an error, mirroring the ClassicRAG degradation guarantee, and skips
|
||||
the overview query entirely. ``stats`` carries the whole graph's totals,
|
||||
not the bounded overview's.
|
||||
|
||||
Args:
|
||||
source_id: The resolved source id.
|
||||
limit: Requested overview size; the store clamps it.
|
||||
|
||||
Returns:
|
||||
dict: ``{"nodes": [...], "edges": [...], "stats": {"nodes": int,
|
||||
"edges": int}}``.
|
||||
"""
|
||||
from docsgpt.graphrag.store import GraphStore
|
||||
|
||||
store = GraphStore()
|
||||
if store.count_nodes(source_id) == 0:
|
||||
return {"nodes": [], "edges": []}
|
||||
return store.get_graph_overview(source_id, limit)
|
||||
node_count = store.count_nodes(source_id)
|
||||
if node_count == 0:
|
||||
return {"nodes": [], "edges": [], "stats": {"nodes": 0, "edges": 0}}
|
||||
overview = store.get_graph_overview(source_id, limit)
|
||||
return {
|
||||
"nodes": overview["nodes"],
|
||||
"edges": overview["edges"],
|
||||
"stats": {"nodes": node_count, "edges": store.count_edges(source_id)},
|
||||
}
|
||||
|
||||
|
||||
def _int_arg(name: str, default: int) -> int:
|
||||
"""Read an integer query arg, falling back to ``default`` when unparsable.
|
||||
|
||||
Args:
|
||||
name: Query-string parameter name.
|
||||
default: Value used when the arg is missing or not an integer.
|
||||
|
||||
Returns:
|
||||
int: The parsed value or ``default``.
|
||||
"""
|
||||
try:
|
||||
return int(request.args.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@sources_ns.route("/sources/<string:source_id>/graph")
|
||||
@@ -1310,6 +1345,142 @@ class SourceGraph(Resource):
|
||||
"success": True,
|
||||
"nodes": overview["nodes"],
|
||||
"edges": overview["edges"],
|
||||
"stats": overview["stats"],
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def _graph_node_page(
|
||||
source_id: str,
|
||||
query: str | None,
|
||||
type_key: str | None,
|
||||
page: int,
|
||||
per_page: int,
|
||||
) -> tuple[dict, list]:
|
||||
"""Return ``(listing, type_facets)`` for one page of a source's graph nodes.
|
||||
|
||||
A graph store that is not configured, or whose tables do not exist yet
|
||||
(no graph has ever been built on this deployment), yields an empty page,
|
||||
matching :func:`_graph_overview_payload`. Every other failure propagates,
|
||||
so a broken query is never shown as an empty graph.
|
||||
|
||||
Args:
|
||||
source_id: The resolved source id.
|
||||
query: Name substring filter, or ``None``.
|
||||
type_key: Type key filter, or ``None`` for no filter.
|
||||
page: 1-based page, already clamped.
|
||||
per_page: Page size, already clamped.
|
||||
|
||||
Returns:
|
||||
tuple: ``({"nodes": [...], "total": int}, [{"key", "label", "count"}])``.
|
||||
|
||||
Raises:
|
||||
Exception: Any store failure other than a missing store or schema.
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
from docsgpt.graphrag.store import GraphStore
|
||||
|
||||
empty = ({"nodes": [], "total": 0}, [])
|
||||
try:
|
||||
store = GraphStore()
|
||||
except (ValueError, ImportError) as err:
|
||||
current_app.logger.info("Graph store unavailable, listing no nodes: %s", err)
|
||||
return empty
|
||||
try:
|
||||
listing = store.list_nodes(
|
||||
source_id,
|
||||
query=query,
|
||||
type_key=type_key,
|
||||
offset=(page - 1) * per_page,
|
||||
limit=per_page,
|
||||
)
|
||||
types = store.node_type_facets(source_id)
|
||||
except psycopg.errors.UndefinedTable as err:
|
||||
current_app.logger.info("Graph tables missing, listing no nodes: %s", err)
|
||||
return empty
|
||||
return listing, types
|
||||
|
||||
|
||||
@sources_ns.route("/sources/<string:source_id>/graph/nodes")
|
||||
class SourceGraphNodes(Resource):
|
||||
@api.doc(
|
||||
description="Paged, searchable list of a graphrag source's nodes, "
|
||||
"highest degree first, plus type facets over all its nodes (read "
|
||||
"access: owner or shared). Query params: q (name substring), type "
|
||||
"(type key), page (1-based), per_page (1-100, default 25)."
|
||||
)
|
||||
def get(self, source_id: str):
|
||||
"""List one page of a source's graph nodes with type facets.
|
||||
|
||||
Args:
|
||||
source_id: The source id from the URL.
|
||||
|
||||
Returns:
|
||||
Response: ``{success, nodes, total, page, per_page, types}``, or
|
||||
401/404/400 on missing auth, no read access or a failure. ``page``
|
||||
is clamped to ``1..GRAPH_NODE_LIST_MAX_PAGE`` and ``per_page`` to
|
||||
``1..GRAPH_NODE_LIST_MAX_LIMIT``. A graph store that is not
|
||||
configured or has no tables yet reads as an empty page, like the
|
||||
overview; any other store query failure is a 400, never an empty
|
||||
200.
|
||||
"""
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
from docsgpt.graphrag.store import (
|
||||
GRAPH_NODE_LIST_DEFAULT_LIMIT,
|
||||
GRAPH_NODE_LIST_MAX_LIMIT,
|
||||
GRAPH_NODE_LIST_MAX_PAGE,
|
||||
)
|
||||
|
||||
page = max(1, min(_int_arg("page", 1), GRAPH_NODE_LIST_MAX_PAGE))
|
||||
per_page = max(
|
||||
1,
|
||||
min(
|
||||
_int_arg("per_page", GRAPH_NODE_LIST_DEFAULT_LIMIT),
|
||||
GRAPH_NODE_LIST_MAX_LIMIT,
|
||||
),
|
||||
)
|
||||
query = (request.args.get("q") or "").strip()[:_GRAPH_SEARCH_MAX_LEN] or None
|
||||
type_key = request.args.get("type")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
doc = _resolve_readable_source(conn, source_id, user)
|
||||
if doc is None:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not found"}),
|
||||
404,
|
||||
)
|
||||
resolved_source_id = str(doc["id"])
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error resolving source {source_id} for graph nodes: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
try:
|
||||
listing, types = _graph_node_page(
|
||||
resolved_source_id, query, type_key, page, per_page
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error listing graph nodes for {source_id}: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"nodes": listing["nodes"],
|
||||
"total": listing["total"],
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"types": types,
|
||||
}
|
||||
),
|
||||
200,
|
||||
|
||||
@@ -112,17 +112,22 @@ def validate_url(url: str, allow_localhost: bool = False) -> str:
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
raise SSRFError("No URL was provided.")
|
||||
# Ensure URL has a scheme
|
||||
if not urlparse(url).scheme:
|
||||
url = "http://" + url
|
||||
# ``urlparse`` and ``.hostname`` raise ValueError on malformed input such
|
||||
# as an unclosed IPv6 bracket (``http://[bad``); callers only catch SSRFError.
|
||||
try:
|
||||
# Ensure URL has a scheme
|
||||
if not urlparse(url).scheme:
|
||||
url = "http://" + url
|
||||
|
||||
parsed = urlparse(url)
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError as e:
|
||||
raise SSRFError(f"Invalid URL: {e}") from e
|
||||
|
||||
# Check scheme
|
||||
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||
raise SSRFError(f"URL scheme '{parsed.scheme}' is not allowed. Only HTTP(S) is permitted.")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise SSRFError("URL must have a valid hostname.")
|
||||
|
||||
|
||||
+406
-4
@@ -39,8 +39,49 @@ MAX_SUBGRAPH_EDGES = 2000
|
||||
GRAPH_OVERVIEW_DEFAULT_LIMIT = 100
|
||||
GRAPH_OVERVIEW_MAX_LIMIT = 250
|
||||
|
||||
GRAPH_NODE_LIST_DEFAULT_LIMIT = 25
|
||||
GRAPH_NODE_LIST_MAX_LIMIT = 100
|
||||
# Highest 1-based page the node-list route accepts. Keeps ``OFFSET`` (at most
|
||||
# ~1e8 rows at the max page size) far inside Postgres' bigint range, so a huge
|
||||
# ``page`` query arg yields an empty page with the real total, not an error.
|
||||
GRAPH_NODE_LIST_MAX_PAGE = 1_000_000
|
||||
|
||||
MAX_NODE_RELATIONSHIPS = 300
|
||||
|
||||
PGVECTOR_SOURCE_COLUMN = "source_id"
|
||||
|
||||
def graph_type_key(type_value: Optional[str]) -> str:
|
||||
"""Return the grouping key for a node type.
|
||||
|
||||
``"Person"``, ``"PERSON"`` and ``"per son"`` all fold to ``"person"``, so
|
||||
the extractor's inconsistent spellings land in one facet. Computed in
|
||||
Python on purpose, never in SQL: ``lower()`` and ``[:alnum:]`` follow the
|
||||
database's ``LC_CTYPE``, and on a ``C``-locale database (common on managed
|
||||
Postgres) they fold every Cyrillic or CJK type to ``""``. Must agree with
|
||||
the frontend, which filters and colours by this key with
|
||||
``/[^\\p{L}\\p{N}]/gu`` and ``toLowerCase``.
|
||||
|
||||
Args:
|
||||
type_value: The raw node type, possibly ``None`` or empty.
|
||||
|
||||
Returns:
|
||||
str: The lower-cased type with non-alphanumerics removed; ``""`` for a
|
||||
missing type.
|
||||
"""
|
||||
return "".join(ch for ch in (type_value or "").lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape ``value`` so ``LIKE``/``ILIKE`` treats it as a literal.
|
||||
|
||||
Args:
|
||||
value: Raw user text.
|
||||
|
||||
Returns:
|
||||
str: ``value`` with ``\\``, ``%`` and ``_`` backslash-escaped.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _safe_identifier(name: str) -> str:
|
||||
"""Return ``name`` if it is a bare SQL identifier, else raise.
|
||||
@@ -857,6 +898,174 @@ class GraphStore:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def count_edges(self, source_id: str) -> int:
|
||||
"""Number of edges for a source.
|
||||
|
||||
Args:
|
||||
source_id: Source whose edges to count.
|
||||
|
||||
Returns:
|
||||
int: The edge count, or ``0`` when the query fails.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT count(*) FROM graph_edges WHERE source_id = %s;",
|
||||
(source_id,),
|
||||
)
|
||||
return int(cursor.fetchone()[0])
|
||||
except Exception as e:
|
||||
logging.error(f"Error counting edges: {e}")
|
||||
return 0
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def list_nodes(
|
||||
self,
|
||||
source_id: str,
|
||||
query: Optional[str] = None,
|
||||
type_key: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = GRAPH_NODE_LIST_DEFAULT_LIMIT,
|
||||
) -> Dict[str, Any]:
|
||||
"""One page of a source's nodes, highest degree first, with the match count.
|
||||
|
||||
Args:
|
||||
source_id: Source whose nodes to list.
|
||||
query: Case-insensitive substring of the node name; ``%``, ``_``
|
||||
and ``\\`` match literally. Blank means no name filter.
|
||||
type_key: Only nodes whose :func:`graph_type_key` equals this;
|
||||
``""`` selects untyped nodes and ``None`` applies no filter.
|
||||
offset: Rows to skip (floored at 0).
|
||||
limit: Page size, clamped to ``1..GRAPH_NODE_LIST_MAX_LIMIT``.
|
||||
|
||||
Returns:
|
||||
dict: ``{"nodes": [{id, name, type, degree, doc_freq}], "total": int}``
|
||||
where ``total`` counts every match, not just this page. A source
|
||||
with no graph yields ``{"nodes": [], "total": 0}``.
|
||||
|
||||
Raises:
|
||||
Exception: Any query failure, so a caller never mistakes a broken
|
||||
query for an empty graph.
|
||||
"""
|
||||
limit = max(1, min(int(limit), GRAPH_NODE_LIST_MAX_LIMIT))
|
||||
offset = max(0, int(offset))
|
||||
clauses = ["source_id = %s"]
|
||||
params: List[Any] = [source_id]
|
||||
clean = (query or "").strip()
|
||||
if clean:
|
||||
clauses.append("name ILIKE %s ESCAPE '\\'")
|
||||
params.append(f"%{_escape_like(clean)}%")
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
if type_key is not None:
|
||||
# The key is folded in Python (see ``graph_type_key``), so the
|
||||
# filter matches the raw spellings whose key equals it.
|
||||
cursor.execute(
|
||||
"SELECT DISTINCT type FROM graph_nodes WHERE source_id = %s;",
|
||||
(source_id,),
|
||||
)
|
||||
raw_types = sorted(
|
||||
row[0]
|
||||
for row in cursor.fetchall()
|
||||
if row[0] is not None and graph_type_key(row[0]) == type_key
|
||||
)
|
||||
if type_key == "":
|
||||
clauses.append("(type IS NULL OR type = ANY(%s))")
|
||||
elif not raw_types:
|
||||
return {"nodes": [], "total": 0}
|
||||
else:
|
||||
clauses.append("type = ANY(%s)")
|
||||
params.append(raw_types)
|
||||
where = " AND ".join(clauses)
|
||||
cursor.execute(
|
||||
f"SELECT count(*) FROM graph_nodes WHERE {where};", tuple(params)
|
||||
)
|
||||
total = int(cursor.fetchone()[0])
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT id, name, type, degree, doc_freq
|
||||
FROM graph_nodes
|
||||
WHERE {where}
|
||||
ORDER BY degree DESC, id
|
||||
LIMIT %s OFFSET %s;
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
)
|
||||
nodes = [
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"degree": row[3],
|
||||
"doc_freq": row[4],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
return {"nodes": nodes, "total": total}
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def node_type_facets(self, source_id: str) -> List[Dict[str, Any]]:
|
||||
"""Node counts per type key over every node of a source.
|
||||
|
||||
Spellings that share a :func:`graph_type_key` are folded into one
|
||||
facet labelled with the most frequent spelling (ties go to the
|
||||
alphabetically first). Untyped nodes form the ``""`` facet with a
|
||||
``None`` label.
|
||||
|
||||
Args:
|
||||
source_id: Source whose node types to count.
|
||||
|
||||
Returns:
|
||||
list: ``[{"key", "label", "count"}]`` sorted by count descending,
|
||||
then label. Empty for a source with no graph.
|
||||
|
||||
Raises:
|
||||
Exception: Any query failure, so a caller never mistakes a broken
|
||||
query for an empty graph.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
# Group by the raw spelling only; the key is folded in Python so it
|
||||
# does not depend on the database locale (see ``graph_type_key``).
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT type, count(*)
|
||||
FROM graph_nodes
|
||||
WHERE source_id = %s
|
||||
GROUP BY type;
|
||||
""",
|
||||
(source_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
spellings: Dict[str, List[tuple]] = {}
|
||||
for raw_type, n in rows:
|
||||
spellings.setdefault(graph_type_key(raw_type), []).append(
|
||||
(int(n), raw_type)
|
||||
)
|
||||
facets = []
|
||||
for key, variants in spellings.items():
|
||||
label = None
|
||||
if key:
|
||||
# Most frequent spelling; a tie goes to the alphabetically first.
|
||||
label = min(variants, key=lambda v: (-v[0], v[1]))[1]
|
||||
facets.append(
|
||||
{"key": key, "label": label, "count": sum(v[0] for v in variants)}
|
||||
)
|
||||
facets.sort(
|
||||
key=lambda f: (-f["count"], f["label"] is None, f["label"] or "")
|
||||
)
|
||||
return facets
|
||||
|
||||
def count_nodes_many(self, source_ids: List[str]) -> Dict[str, int]:
|
||||
"""Node counts for several sources in one round trip.
|
||||
|
||||
@@ -1390,11 +1599,26 @@ class GraphStore:
|
||||
def get_node_detail(
|
||||
self, source_id: str, node_id: str, max_chunks: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""A node's full record plus a bounded list of its linked chunks.
|
||||
"""A node's full record, its relationships and a bounded list of its chunks.
|
||||
|
||||
Returns ``None`` when the node does not belong to the source. Chunk texts
|
||||
are read from the co-located pgvector table; at most ``max_chunks`` are
|
||||
returned so a hub node never streams an unbounded payload.
|
||||
Chunk texts are read from the co-located pgvector table; at most
|
||||
``max_chunks`` are returned so a hub node never streams an unbounded
|
||||
payload. ``relationships`` holds every edge touching the node in either
|
||||
direction (self-loops skipped), joined to the other endpoint, strongest
|
||||
neighbour first and capped at ``MAX_NODE_RELATIONSHIPS``;
|
||||
``relationships_total`` is how many such edges there are, so a capped
|
||||
list can say what it leaves out.
|
||||
|
||||
Args:
|
||||
source_id: Source the node must belong to.
|
||||
node_id: The node's id.
|
||||
max_chunks: Most linked chunks to return.
|
||||
|
||||
Returns:
|
||||
dict | None: ``{id, name, type, description, degree, doc_freq,
|
||||
relationships: [{id, name, type, degree, edge_type, direction}],
|
||||
relationships_total, chunks: [{chunk_id, text, metadata}]}``; ``None`` when the node does
|
||||
not belong to the source or the read fails.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
@@ -1421,6 +1645,15 @@ class GraphStore:
|
||||
"degree": row[4],
|
||||
"doc_freq": row[5],
|
||||
}
|
||||
relationships = self._node_relationships(conn, source_id, node_id)
|
||||
node["relationships"] = relationships
|
||||
node["relationships_total"] = (
|
||||
self._node_relationships_total(
|
||||
conn, source_id, node_id, len(relationships)
|
||||
)
|
||||
if len(relationships) >= MAX_NODE_RELATIONSHIPS
|
||||
else len(relationships)
|
||||
)
|
||||
|
||||
chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get(
|
||||
str(node_id), []
|
||||
@@ -1443,6 +1676,107 @@ class GraphStore:
|
||||
finally:
|
||||
conn.rollback()
|
||||
|
||||
def _node_relationships(
|
||||
self, conn, source_id: str, node_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Edges touching ``node_id``, joined to the neighbour at the other end.
|
||||
|
||||
Args:
|
||||
conn: Open connection; the caller owns the transaction.
|
||||
source_id: Source the edges and neighbours must belong to.
|
||||
node_id: The node whose relationships to read.
|
||||
|
||||
Returns:
|
||||
list: ``[{id, name, type, degree, edge_type, direction}]`` where
|
||||
``direction`` is ``"out"`` when the node is the edge's source,
|
||||
ordered by neighbour degree descending, neighbour id, edge type.
|
||||
Empty when the read fails (the transaction is rolled back).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT o.id, o.name, o.type, o.degree, e.type,
|
||||
CASE WHEN e.src_node_id = %s THEN 'out' ELSE 'in' END AS direction
|
||||
FROM graph_edges e
|
||||
JOIN graph_nodes o
|
||||
ON o.id = CASE WHEN e.src_node_id = %s
|
||||
THEN e.dst_node_id ELSE e.src_node_id END
|
||||
WHERE e.source_id = %s
|
||||
AND (e.src_node_id = %s OR e.dst_node_id = %s)
|
||||
AND e.src_node_id <> e.dst_node_id
|
||||
AND o.source_id = %s
|
||||
ORDER BY o.degree DESC, o.id, e.type
|
||||
LIMIT %s;
|
||||
""",
|
||||
(
|
||||
node_id, node_id, source_id, node_id, node_id, source_id,
|
||||
MAX_NODE_RELATIONSHIPS,
|
||||
),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"degree": row[3],
|
||||
"edge_type": row[4],
|
||||
"direction": row[5],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
# Degrade to no relationships rather than failing the whole node
|
||||
# detail; roll back so the caller's chunk read can reuse ``conn``.
|
||||
logging.error(f"Error reading node relationships: {e}")
|
||||
conn.rollback()
|
||||
return []
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def _node_relationships_total(
|
||||
self, conn, source_id: str, node_id: str, fallback: int
|
||||
) -> int:
|
||||
"""How many edges ``_node_relationships`` would return without its cap.
|
||||
|
||||
Same filters as the list: either direction, self-loops skipped, the
|
||||
neighbour in the same source.
|
||||
|
||||
Args:
|
||||
conn: Open connection; the caller owns the transaction.
|
||||
source_id: Source the edges and neighbours must belong to.
|
||||
node_id: The node whose relationships to count.
|
||||
fallback: What to return when the count fails (the list's length).
|
||||
|
||||
Returns:
|
||||
int: The edge count, or ``fallback`` when the read fails (the
|
||||
transaction is rolled back).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM graph_edges e
|
||||
JOIN graph_nodes o
|
||||
ON o.id = CASE WHEN e.src_node_id = %s
|
||||
THEN e.dst_node_id ELSE e.src_node_id END
|
||||
WHERE e.source_id = %s
|
||||
AND (e.src_node_id = %s OR e.dst_node_id = %s)
|
||||
AND e.src_node_id <> e.dst_node_id
|
||||
AND o.source_id = %s;
|
||||
""",
|
||||
(node_id, source_id, node_id, node_id, source_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return int(row[0]) if row else fallback
|
||||
except Exception as e:
|
||||
logging.error(f"Error counting node relationships: {e}")
|
||||
conn.rollback()
|
||||
return fallback
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def set_node_degrees(self, source_id: str):
|
||||
"""Recompute every node's degree from its incident edges for a source.
|
||||
|
||||
@@ -1506,6 +1840,74 @@ class GraphStore:
|
||||
|
||||
return self._write_with_reconnect(_write)
|
||||
|
||||
def remap_chunk(self, source_id: str, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
"""Point every graph reference to a chunk at its replacement id.
|
||||
|
||||
A chunk edit re-adds the chunk under a new id and deletes the old row,
|
||||
so without this the node links, edge provenance and extraction
|
||||
checkpoint would all name a chunk that no longer exists. The graph
|
||||
itself is not re-extracted from the edited text.
|
||||
|
||||
Args:
|
||||
source_id: Source the chunk belongs to.
|
||||
old_chunk_id: The id the graph currently references.
|
||||
new_chunk_id: The id of the chunk that replaced it.
|
||||
|
||||
Raises:
|
||||
Exception: The underlying write failure, after a rollback.
|
||||
"""
|
||||
self._ensure_tables_once()
|
||||
old_id, new_id = str(old_chunk_id), str(new_chunk_id)
|
||||
|
||||
def _write(conn):
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
_lock_source(cursor, source_id)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_node_chunks SET chunk_id = %s
|
||||
WHERE source_id = %s AND chunk_id = %s;
|
||||
""",
|
||||
(new_id, source_id, old_id),
|
||||
)
|
||||
# Rewrite the matching element in place, keeping array order.
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_edges
|
||||
SET source_chunk_ids = (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN t.v #>> '{}' = %s THEN to_jsonb(%s::text) ELSE t.v END
|
||||
ORDER BY t.ord
|
||||
)
|
||||
FROM jsonb_array_elements(source_chunk_ids)
|
||||
WITH ORDINALITY AS t(v, ord)
|
||||
)
|
||||
WHERE source_id = %s
|
||||
AND jsonb_typeof(source_chunk_ids) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(source_chunk_ids) AS s(v)
|
||||
WHERE s.v #>> '{}' = %s
|
||||
);
|
||||
""",
|
||||
(old_id, new_id, source_id, old_id),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_ingest_progress SET chunk_id = %s
|
||||
WHERE source_id = %s AND chunk_id = %s;
|
||||
""",
|
||||
(new_id, source_id, old_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
_safe_rollback(conn)
|
||||
logging.error(f"Error remapping graph chunk: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
return self._write_with_reconnect(_write)
|
||||
|
||||
def pending_chunks(self, source_id: str, all_chunk_ids: List[str]) -> List[str]:
|
||||
"""Chunk ids from ``all_chunk_ids`` not yet marked ``done`` for the source."""
|
||||
if not all_chunk_ids:
|
||||
|
||||
@@ -1,10 +1,191 @@
|
||||
"""Base reader class."""
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from abc import abstractmethod
|
||||
from typing import Any, List
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit
|
||||
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.vectorstore.document_class import Document as VectorDocument
|
||||
|
||||
_PAGE_EXTENSIONS = (".html", ".htm", ".php", ".asp", ".aspx", ".jsp")
|
||||
|
||||
# Joins a page's path to its encoded query string in the virtual file name.
|
||||
_QUERY_SEPARATOR = "__"
|
||||
# Longest encoded query kept verbatim; longer ones keep a prefix plus a hash.
|
||||
MAX_QUERY_SEGMENT_LENGTH = 64
|
||||
_QUERY_HASH_LENGTH = 10
|
||||
# Anything but letters, digits, ``_``, ``.`` and ``-`` in a query key or value.
|
||||
_UNSAFE_QUERY_CHARS = re.compile(r"[^\w.-]+")
|
||||
|
||||
|
||||
def _query_segment(query: str) -> str:
|
||||
"""Encode a URL query string as a deterministic, tree-safe name segment.
|
||||
|
||||
Parameters are sorted so the same page maps to the same name across
|
||||
re-syncs whatever order its links list them in. Keys and values are
|
||||
percent-decoded and every run of characters outside ``[\\w.-]`` becomes
|
||||
``-``, so the segment carries no ``/``, ``?``, ``#`` or ``%``. A segment
|
||||
longer than ``MAX_QUERY_SEGMENT_LENGTH`` keeps a prefix and a hash of the
|
||||
full query, which keeps it bounded and still distinct.
|
||||
|
||||
Args:
|
||||
query: The raw query string, without the leading ``?``.
|
||||
|
||||
Returns:
|
||||
The encoded segment, or ``""`` when the query has no parameters.
|
||||
"""
|
||||
pairs = sorted(parse_qsl(query, keep_blank_values=True))
|
||||
if not pairs:
|
||||
return ""
|
||||
parts = []
|
||||
for key, value in pairs:
|
||||
key = _UNSAFE_QUERY_CHARS.sub("-", key)
|
||||
value = _UNSAFE_QUERY_CHARS.sub("-", value)
|
||||
parts.append(f"{key}={value}" if value else key)
|
||||
segment = "&".join(parts)
|
||||
if len(segment) <= MAX_QUERY_SEGMENT_LENGTH:
|
||||
return segment
|
||||
digest = hashlib.sha1(urlencode(pairs).encode("utf-8")).hexdigest()
|
||||
keep = MAX_QUERY_SEGMENT_LENGTH - _QUERY_HASH_LENGTH - 1
|
||||
return f"{segment[:keep]}-{digest[:_QUERY_HASH_LENGTH]}"
|
||||
|
||||
|
||||
def url_to_virtual_path(url: str, include_host: bool = False) -> str:
|
||||
"""Convert a page URL to the virtual ``.md`` path used as its tree key.
|
||||
|
||||
Web ingests store this as the chunk's ``file_path``: the worker keys the
|
||||
source's file tree by it and the chunks view filters by it, so the two
|
||||
agree on which chunks belong to which file.
|
||||
|
||||
The fragment is dropped (it names a spot on the same page), but the query
|
||||
string is kept, encoded into the file name (see ``_query_segment``), so
|
||||
``/p?page=1`` and ``/p?page=2`` stay two files. A URL without a query maps
|
||||
exactly as before. Paths that still collide, such as ``/a`` and
|
||||
``/a.html``, are told apart by ``dedupe_virtual_paths``.
|
||||
|
||||
Args:
|
||||
url: Page URL, e.g. ``"https://docs.docsgpt.cloud/guides/setup"``.
|
||||
include_host: Prefix the host, for ingests spanning several hosts
|
||||
whose paths would otherwise collide (every root is ``index.md``).
|
||||
|
||||
Returns:
|
||||
A relative path such as ``"index.md"``, ``"guides/setup.md"``,
|
||||
``"list__page=2.md"`` or, with ``include_host``,
|
||||
``"docs.docsgpt.cloud/guides/setup.md"``.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
if not path:
|
||||
path = "index.md"
|
||||
else:
|
||||
base, ext = os.path.splitext(path)
|
||||
if ext.lower() in _PAGE_EXTENSIONS:
|
||||
path = base
|
||||
if not path.endswith(".md"):
|
||||
path = f"{path}.md"
|
||||
|
||||
query = _query_segment(parsed.query)
|
||||
if query:
|
||||
path = f"{path[:-len('.md')]}{_QUERY_SEPARATOR}{query}.md"
|
||||
|
||||
if include_host and parsed.netloc:
|
||||
return f"{parsed.netloc}/{path}"
|
||||
return path
|
||||
|
||||
|
||||
def spans_multiple_hosts(urls: List[str]) -> bool:
|
||||
"""Return whether ``urls`` point at more than one host.
|
||||
|
||||
A URL ``urlparse`` rejects (an unclosed IPv6 bracket, say) is left out:
|
||||
it fails validation and is never fetched, so it names no host here.
|
||||
|
||||
Args:
|
||||
urls: Page URLs about to be ingested together.
|
||||
|
||||
Returns:
|
||||
True when paths alone could collide and need a host prefix.
|
||||
"""
|
||||
hosts = set()
|
||||
for url in urls:
|
||||
try:
|
||||
hosts.add(urlparse(url).netloc)
|
||||
except ValueError:
|
||||
continue
|
||||
return len(hosts) > 1
|
||||
|
||||
|
||||
def normalize_page_url(url: str) -> str:
|
||||
"""Return the identity of the page ``url`` names, for deduplication.
|
||||
|
||||
The fragment is dropped (it names a spot on the same page) and the query
|
||||
parameters are sorted, as ``_query_segment`` sorts them, so
|
||||
``/p?a=1&b=2`` and ``/p?b=2&a=1#top`` are one page. The result is a key
|
||||
only; fetch the URL as it was written.
|
||||
|
||||
Args:
|
||||
url: Page URL.
|
||||
|
||||
Returns:
|
||||
The normalized URL.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``urlsplit`` cannot parse ``url``.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)))
|
||||
return urlunsplit(parts._replace(query=query, fragment=""))
|
||||
|
||||
|
||||
def dedupe_virtual_paths(documents: List[Document]) -> List[Document]:
|
||||
"""Give distinct pages that share a virtual ``file_path`` distinct ones.
|
||||
|
||||
``url_to_virtual_path`` folds some different URLs onto one path (``/a`` and
|
||||
``/a.html`` are both ``a.md``), and the worker would then merge those pages
|
||||
into one tree entry. Within a path, the pages are ordered by URL (fragment
|
||||
dropped, query parameters sorted; see ``normalize_page_url``): the first
|
||||
keeps the path and each later one gets ``-2``, ``-3`` and so on before
|
||||
``.md``, skipping any path another page already has.
|
||||
Ordering by URL rather than by fetch order keeps the names stable across
|
||||
re-syncs of the same pages. Documents for the same URL (one page reached
|
||||
via two fragments, or with its query parameters in another order) are one
|
||||
page and keep one path.
|
||||
|
||||
Args:
|
||||
documents: Loaded documents; each ``extra_info`` carries ``source``
|
||||
(the page URL) and ``file_path``. Others are left untouched.
|
||||
|
||||
Returns:
|
||||
``documents``, with colliding ``file_path`` values rewritten in place.
|
||||
"""
|
||||
pages_by_path: Dict[str, Dict[str, List[Document]]] = {}
|
||||
for doc in documents:
|
||||
info = doc.extra_info or {}
|
||||
path = info.get("file_path")
|
||||
if not path:
|
||||
continue
|
||||
page = normalize_page_url(str(info.get("source") or path))
|
||||
pages_by_path.setdefault(path, {}).setdefault(page, []).append(doc)
|
||||
|
||||
taken = set(pages_by_path)
|
||||
for path in sorted(pages_by_path):
|
||||
pages = pages_by_path[path]
|
||||
if len(pages) < 2:
|
||||
continue
|
||||
stem = path[: -len(".md")] if path.endswith(".md") else path
|
||||
suffix = 1
|
||||
for page in sorted(pages)[1:]:
|
||||
suffix += 1
|
||||
while f"{stem}-{suffix}.md" in taken:
|
||||
suffix += 1
|
||||
new_path = f"{stem}-{suffix}.md"
|
||||
taken.add(new_path)
|
||||
for doc in pages[page]:
|
||||
doc.extra_info["file_path"] = new_path
|
||||
return documents
|
||||
|
||||
|
||||
class BaseRemote:
|
||||
"""Utilities for loading data from a directory."""
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import BaseRemote, dedupe_virtual_paths, normalize_page_url, url_to_virtual_path
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.core.url_validation import validate_url, SSRFError
|
||||
from docsgpt.security.safe_url import pinned_request
|
||||
@@ -25,6 +24,8 @@ class CrawlerLoader(BaseRemote):
|
||||
logging.error(f"URL validation failed: {e}")
|
||||
return []
|
||||
|
||||
# Keyed by normalize_page_url, so fragment and query-order variants of
|
||||
# one page are fetched once.
|
||||
visited_urls = set()
|
||||
base_url = urlparse(url).scheme + "://" + urlparse(url).hostname
|
||||
urls_to_visit = [url]
|
||||
@@ -32,7 +33,10 @@ class CrawlerLoader(BaseRemote):
|
||||
|
||||
while urls_to_visit:
|
||||
current_url = urls_to_visit.pop(0)
|
||||
visited_urls.add(current_url)
|
||||
page_key = normalize_page_url(current_url)
|
||||
if page_key in visited_urls:
|
||||
continue
|
||||
visited_urls.add(page_key)
|
||||
|
||||
try:
|
||||
response = pinned_request("GET", current_url, timeout=30)
|
||||
@@ -58,45 +62,29 @@ class CrawlerLoader(BaseRemote):
|
||||
logging.error(f"Error processing URL {current_url}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
# Parse the HTML content to extract all links
|
||||
all_links = [
|
||||
urljoin(current_url, a['href'])
|
||||
for a in soup.find_all('a', href=True)
|
||||
if base_url in urljoin(current_url, a['href'])
|
||||
]
|
||||
# Parse the HTML content to extract all links. A malformed href
|
||||
# (``http://[bad``) makes urljoin or the key parse raise; skip it
|
||||
# rather than abort the crawl.
|
||||
all_links = []
|
||||
for a in soup.find_all('a', href=True):
|
||||
try:
|
||||
link = urljoin(current_url, a['href'])
|
||||
link_key = normalize_page_url(link)
|
||||
except ValueError:
|
||||
continue
|
||||
if base_url in link and link_key not in visited_urls:
|
||||
all_links.append(link)
|
||||
|
||||
# Add new links to the list of URLs to visit if they haven't been visited yet
|
||||
urls_to_visit.extend([link for link in all_links if link not in visited_urls])
|
||||
urls_to_visit.extend(all_links)
|
||||
urls_to_visit = list(set(urls_to_visit))
|
||||
|
||||
# Stop crawling if the limit of pages to scrape is reached
|
||||
if self.limit is not None and len(visited_urls) >= self.limit:
|
||||
break
|
||||
|
||||
return loaded_content
|
||||
return dedupe_virtual_paths(loaded_content)
|
||||
|
||||
def _url_to_virtual_path(self, url):
|
||||
"""
|
||||
Convert a URL to a virtual file path ending with .md.
|
||||
|
||||
Examples:
|
||||
https://docs.docsgpt.cloud/ -> index.md
|
||||
https://docs.docsgpt.cloud/guides/setup -> guides/setup.md
|
||||
https://docs.docsgpt.cloud/guides/setup/ -> guides/setup.md
|
||||
https://example.com/page.html -> page.md
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
if not path:
|
||||
return "index.md"
|
||||
|
||||
# Remove common file extensions and add .md
|
||||
base, ext = os.path.splitext(path)
|
||||
if ext.lower() in [".html", ".htm", ".php", ".asp", ".aspx", ".jsp"]:
|
||||
path = base
|
||||
|
||||
if not path.endswith(".md"):
|
||||
path = f"{path}.md"
|
||||
|
||||
return path
|
||||
"""Convert a URL to a virtual ``.md`` path; see ``url_to_virtual_path``."""
|
||||
return url_to_virtual_path(url)
|
||||
@@ -1,13 +1,12 @@
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from bs4 import BeautifulSoup
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import BaseRemote, dedupe_virtual_paths, normalize_page_url, url_to_virtual_path
|
||||
from docsgpt.core.url_validation import validate_url, SSRFError
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, pinned_request
|
||||
import re
|
||||
from markdownify import markdownify
|
||||
from docsgpt.parser.schema.base import Document
|
||||
import tldextract
|
||||
import os
|
||||
|
||||
# The bundled public-suffix snapshot is enough for domain matching; the
|
||||
# default extractor would fetch the live list on first use and cache it on
|
||||
@@ -38,7 +37,8 @@ class CrawlerLoader(BaseRemote):
|
||||
print(f"URL validation failed: {e}")
|
||||
return []
|
||||
|
||||
# Keep track of visited URLs to avoid revisiting the same page
|
||||
# Keep track of visited pages to avoid revisiting the same page. Keyed
|
||||
# by normalize_page_url, so fragment and query-order variants are one.
|
||||
visited_urls = set()
|
||||
|
||||
# Determine the base domain for link filtering using tldextract
|
||||
@@ -50,9 +50,10 @@ class CrawlerLoader(BaseRemote):
|
||||
current_url = urls_to_visit.pop()
|
||||
|
||||
# Skip if already visited
|
||||
if current_url in visited_urls:
|
||||
page_key = normalize_page_url(current_url)
|
||||
if page_key in visited_urls:
|
||||
continue
|
||||
visited_urls.add(current_url)
|
||||
visited_urls.add(page_key)
|
||||
|
||||
# Fetch the page content
|
||||
html_content = self._fetch_page(current_url)
|
||||
@@ -85,13 +86,15 @@ class CrawlerLoader(BaseRemote):
|
||||
filtered_links = self._filter_links(new_links, base_domain)
|
||||
|
||||
# Add any new, not-yet-visited links to the queue
|
||||
urls_to_visit.update(link for link in filtered_links if link not in visited_urls)
|
||||
urls_to_visit.update(
|
||||
link for link in filtered_links if normalize_page_url(link) not in visited_urls
|
||||
)
|
||||
|
||||
# If we've reached the limit, stop crawling
|
||||
if self.limit is not None and len(visited_urls) >= self.limit:
|
||||
break
|
||||
|
||||
return documents
|
||||
return dedupe_virtual_paths(documents)
|
||||
|
||||
def _fetch_page(self, url):
|
||||
try:
|
||||
@@ -124,7 +127,13 @@ class CrawlerLoader(BaseRemote):
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
links = []
|
||||
for a in soup.find_all('a', href=True):
|
||||
full_url = urljoin(current_url, a['href'])
|
||||
# A malformed href (``http://[bad``) makes urljoin or the later
|
||||
# parse raise; skip that link rather than abort the crawl.
|
||||
try:
|
||||
full_url = urljoin(current_url, a['href'])
|
||||
normalize_page_url(full_url)
|
||||
except ValueError:
|
||||
continue
|
||||
links.append((full_url, a.text.strip()))
|
||||
return links
|
||||
|
||||
@@ -160,28 +169,5 @@ class CrawlerLoader(BaseRemote):
|
||||
return filtered
|
||||
|
||||
def _url_to_virtual_path(self, url):
|
||||
"""
|
||||
Convert a URL to a virtual file path ending with .md.
|
||||
|
||||
Examples:
|
||||
https://docs.docsgpt.cloud/ -> index.md
|
||||
https://docs.docsgpt.cloud/guides/setup -> guides/setup.md
|
||||
https://docs.docsgpt.cloud/guides/setup/ -> guides/setup.md
|
||||
https://example.com/page.html -> page.md
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip("/")
|
||||
|
||||
if not path:
|
||||
return "index.md"
|
||||
|
||||
# Remove common file extensions and add .md
|
||||
base, ext = os.path.splitext(path)
|
||||
if ext.lower() in [".html", ".htm", ".php", ".asp", ".aspx", ".jsp"]:
|
||||
path = base
|
||||
|
||||
# Ensure path ends with .md
|
||||
if not path.endswith(".md"):
|
||||
path = path + ".md"
|
||||
|
||||
return path
|
||||
"""Convert a URL to a virtual ``.md`` path; see ``url_to_virtual_path``."""
|
||||
return url_to_virtual_path(url)
|
||||
@@ -4,7 +4,12 @@ import re
|
||||
import defusedxml.ElementTree as ET
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import (
|
||||
BaseRemote,
|
||||
dedupe_virtual_paths,
|
||||
spans_multiple_hosts,
|
||||
url_to_virtual_path,
|
||||
)
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.core.url_validation import validate_url, SSRFError
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, pinned_request
|
||||
@@ -32,6 +37,7 @@ class SitemapLoader(BaseRemote):
|
||||
return []
|
||||
|
||||
# Load content of extracted URLs
|
||||
include_host = spans_multiple_hosts(urls)
|
||||
documents = []
|
||||
processed_urls = 0 # Counter for processed URLs
|
||||
for url in urls:
|
||||
@@ -50,7 +56,13 @@ class SitemapLoader(BaseRemote):
|
||||
documents.append(
|
||||
Document(
|
||||
soup.get_text(separator="\n", strip=True),
|
||||
extra_info={"source": url},
|
||||
# Without file_path the worker had no tree key (no
|
||||
# title, key or doc_id), so sitemap pages never
|
||||
# appeared in the file tree.
|
||||
extra_info={
|
||||
"source": url,
|
||||
"file_path": url_to_virtual_path(url, include_host),
|
||||
},
|
||||
)
|
||||
)
|
||||
processed_urls += 1 # Increment the counter after processing each URL
|
||||
@@ -58,7 +70,7 @@ class SitemapLoader(BaseRemote):
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
return documents
|
||||
return dedupe_virtual_paths(documents)
|
||||
|
||||
def _extract_urls(self, sitemap_url):
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,12 @@ import logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import (
|
||||
BaseRemote,
|
||||
dedupe_virtual_paths,
|
||||
spans_multiple_hosts,
|
||||
url_to_virtual_path,
|
||||
)
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.security.safe_url import pinned_request
|
||||
|
||||
@@ -24,15 +29,17 @@ class WebLoader(BaseRemote):
|
||||
urls = inputs
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
documents = []
|
||||
valid_urls = []
|
||||
for url in urls:
|
||||
try:
|
||||
url = validate_url(url)
|
||||
valid_urls.append(validate_url(url))
|
||||
except SSRFError as e:
|
||||
logging.warning(
|
||||
f"Skipping URL due to SSRF validation failure: {url} - {e}"
|
||||
)
|
||||
continue
|
||||
include_host = spans_multiple_hosts(valid_urls)
|
||||
documents = []
|
||||
for url in valid_urls:
|
||||
try:
|
||||
response = pinned_request("GET", url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
@@ -45,6 +52,9 @@ class WebLoader(BaseRemote):
|
||||
html_tag = soup.find("html")
|
||||
if html_tag and html_tag.get("lang"):
|
||||
metadata["language"] = html_tag.get("lang")
|
||||
# The worker keys the file tree by file_path; without it the
|
||||
# tree fell back to the title, which the chunks view can't match.
|
||||
metadata["file_path"] = url_to_virtual_path(url, include_host)
|
||||
documents.append(
|
||||
Document(
|
||||
soup.get_text(separator="\n", strip=True),
|
||||
@@ -54,4 +64,4 @@ class WebLoader(BaseRemote):
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
return documents
|
||||
return dedupe_virtual_paths(documents)
|
||||
@@ -167,6 +167,81 @@ class ScheduleRunsRepository:
|
||||
).fetchall()
|
||||
return [row_to_dict(r) for r in rows]
|
||||
|
||||
def stats_for_agent(
|
||||
self, agent_id: str, user_id: str, *, days: int = 30,
|
||||
) -> dict:
|
||||
"""Aggregate run stats for an agent's owned schedules over a window.
|
||||
|
||||
Only runs of schedules owned by ``user_id`` on ``agent_id`` whose
|
||||
``scheduled_for`` falls within the last ``days`` days are counted;
|
||||
runs scheduled in the future are not.
|
||||
|
||||
Args:
|
||||
agent_id: Agent UUID the schedules belong to.
|
||||
user_id: Owner of the schedules (and runs).
|
||||
days: Window length in days, counted back from now.
|
||||
|
||||
Returns:
|
||||
Dict with ``runs``, ``failed`` (``failed``/``timeout``),
|
||||
``tokens`` (prompt + generated) and ``latest_failure`` — the
|
||||
most recent failed run's ``scheduled_for``/``status``/
|
||||
``error_type``, or ``None`` when there is none.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
text(
|
||||
"""
|
||||
WITH window_runs AS (
|
||||
SELECT r.scheduled_for, r.status, r.error_type,
|
||||
r.prompt_tokens, r.generated_tokens
|
||||
FROM schedule_runs r
|
||||
JOIN schedules s ON s.id = r.schedule_id
|
||||
WHERE s.agent_id = CAST(:agent_id AS uuid)
|
||||
AND s.user_id = :user_id
|
||||
AND r.user_id = :user_id
|
||||
AND r.scheduled_for >= now() - make_interval(days => :days)
|
||||
AND r.scheduled_for <= now()
|
||||
),
|
||||
latest_failure AS (
|
||||
SELECT scheduled_for, status, error_type
|
||||
FROM window_runs
|
||||
WHERE status IN ('failed', 'timeout')
|
||||
ORDER BY scheduled_for DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT agg.runs, agg.failed, agg.tokens,
|
||||
lf.scheduled_for AS latest_failure_scheduled_for,
|
||||
lf.status AS latest_failure_status,
|
||||
lf.error_type AS latest_failure_error_type
|
||||
FROM (
|
||||
SELECT count(*) AS runs,
|
||||
count(*) FILTER (
|
||||
WHERE status IN ('failed', 'timeout')
|
||||
) AS failed,
|
||||
COALESCE(
|
||||
SUM(prompt_tokens + generated_tokens), 0
|
||||
) AS tokens
|
||||
FROM window_runs
|
||||
) agg
|
||||
LEFT JOIN latest_failure lf ON true
|
||||
"""
|
||||
),
|
||||
{"agent_id": str(agent_id), "user_id": user_id, "days": int(days)},
|
||||
).fetchone()
|
||||
data = row_to_dict(row)
|
||||
latest_failure = None
|
||||
if data.get("latest_failure_scheduled_for") is not None:
|
||||
latest_failure = {
|
||||
"scheduled_for": data["latest_failure_scheduled_for"],
|
||||
"status": data["latest_failure_status"],
|
||||
"error_type": data["latest_failure_error_type"],
|
||||
}
|
||||
return {
|
||||
"runs": int(data.get("runs") or 0),
|
||||
"failed": int(data.get("failed") or 0),
|
||||
"tokens": int(data.get("tokens") or 0),
|
||||
"latest_failure": latest_failure,
|
||||
}
|
||||
|
||||
def update(self, run_id: str, fields: dict) -> Optional[dict]:
|
||||
"""Apply a whitelisted partial update to a run row."""
|
||||
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
|
||||
|
||||
@@ -362,6 +362,14 @@ def build_local_embeddings(
|
||||
return embedding_instance
|
||||
|
||||
|
||||
class InvalidChunkMetadataError(ValueError):
|
||||
"""Chunk metadata the store cannot write, such as a key it reserves.
|
||||
|
||||
A client-input error, distinct from a store or embedding failure: the
|
||||
chunk routes answer it with a 400 rather than a 500.
|
||||
"""
|
||||
|
||||
|
||||
class BaseVectorStore(ABC):
|
||||
def __init__(self):
|
||||
pass
|
||||
@@ -431,6 +439,46 @@ class BaseVectorStore(ABC):
|
||||
"""Delete a specific chunk from the vectorstore"""
|
||||
pass
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: dict) -> str:
|
||||
"""Replace a chunk's text and metadata, returning the id it is now under.
|
||||
|
||||
Stores that can rewrite a row in place override this and keep both the
|
||||
id and the chunk's position in :meth:`get_chunks`. This default works
|
||||
for any store but re-adds the chunk and deletes the old one, so the
|
||||
returned id differs and the chunk moves; callers holding the old id
|
||||
(graph links, for one) must follow the returned id.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata.
|
||||
|
||||
Returns:
|
||||
The id the updated chunk is stored under.
|
||||
|
||||
Raises:
|
||||
RuntimeError: The old chunk could not be deleted. The new chunk is
|
||||
deleted again (best effort) so no duplicate is left behind.
|
||||
"""
|
||||
new_chunk_id = self.add_chunk(text, metadata)
|
||||
delete_error: Optional[Exception] = None
|
||||
try:
|
||||
deleted = self.delete_chunk(chunk_id)
|
||||
except Exception as err:
|
||||
deleted, delete_error = False, err
|
||||
if deleted:
|
||||
return new_chunk_id
|
||||
try:
|
||||
self.delete_chunk(new_chunk_id)
|
||||
except Exception:
|
||||
logging.error(
|
||||
"Failed to roll back new chunk %s after old chunk %s could not be deleted",
|
||||
new_chunk_id,
|
||||
chunk_id,
|
||||
exc_info=True,
|
||||
)
|
||||
raise RuntimeError(f"Failed to delete old chunk {chunk_id} during update") from delete_error
|
||||
|
||||
def delete_chunks_by_source_path(self, path) -> int:
|
||||
"""Delete every chunk whose ``metadata.source`` equals ``path``.
|
||||
|
||||
|
||||
@@ -306,6 +306,58 @@ class FaissStore(BaseVectorStore):
|
||||
self._save_to_storage()
|
||||
return ids[0]
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Replace a chunk in place, keeping its id and its place in the docstore.
|
||||
|
||||
The new vector is computed before anything changes, so a failed embed
|
||||
leaves the chunk as it was. Its old row is removed from the index and
|
||||
the new vector appended; the row mapping is renumbered to match, and
|
||||
the docstore entry is replaced where it stands so :meth:`get_chunks`
|
||||
keeps its order. Saved to storage once.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If ``chunk_id`` is not in this index.
|
||||
ValueError: If the new vector's width does not match the index.
|
||||
"""
|
||||
if chunk_id not in self.documents:
|
||||
raise KeyError(f"Chunk id not found in index: {chunk_id}")
|
||||
rows_by_id = {doc_id: row for row, doc_id in self.index_to_docstore_id.items()}
|
||||
if chunk_id not in rows_by_id:
|
||||
raise KeyError(f"Chunk id has no row in the FAISS index: {chunk_id}")
|
||||
|
||||
vector = np.array(self.embeddings.embed_documents([text]), dtype=np.float32)
|
||||
if vector.ndim != 2 or vector.shape != (1, self.index.d):
|
||||
raise ValueError(
|
||||
f"Embedding for chunk {chunk_id} has shape {vector.shape}, "
|
||||
f"expected (1, {self.index.d})"
|
||||
)
|
||||
|
||||
row = rows_by_id[chunk_id]
|
||||
self.index.remove_ids(np.array([row], dtype=np.int64))
|
||||
self.index.add(vector)
|
||||
# remove_ids compacts the index and add appends, so the edited chunk's
|
||||
# vector is now the last row; renumber as delete_index does.
|
||||
remaining = [
|
||||
doc_id
|
||||
for current_row, doc_id in sorted(self.index_to_docstore_id.items())
|
||||
if current_row != row
|
||||
]
|
||||
self.index_to_docstore_id = dict(enumerate(remaining + [chunk_id]))
|
||||
self.documents[chunk_id] = {
|
||||
"page_content": text,
|
||||
"metadata": dict(metadata or {}),
|
||||
}
|
||||
self._save_to_storage()
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a chunk and save to storage."""
|
||||
self.delete_index([chunk_id])
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from functools import cached_property
|
||||
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.vectorstore.base import BaseVectorStore
|
||||
from docsgpt.vectorstore.base import BaseVectorStore, InvalidChunkMetadataError
|
||||
from docsgpt.vectorstore.document_class import Document
|
||||
|
||||
|
||||
@@ -224,6 +224,68 @@ class MongoDBVectorStore(BaseVectorStore):
|
||||
result = self._collection.insert_one(chunk_data)
|
||||
return str(result.inserted_id)
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: dict) -> str:
|
||||
"""Rewrite a chunk's document in place, keeping its ``_id``.
|
||||
|
||||
Metadata lives as top-level fields, so the new metadata is ``$set``
|
||||
and any field the record carries but the new metadata lacks is
|
||||
``$unset``; the record then holds exactly the new metadata. ``_id``,
|
||||
the text, the embedding and ``source_id`` are reserved and cannot be
|
||||
overwritten through ``metadata``. The embedding is computed before
|
||||
anything is written.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no chunk with that id.
|
||||
InvalidChunkMetadataError: If a metadata key is empty, contains
|
||||
``.`` or starts with ``$``. Mongo reads those as a path or an
|
||||
operator, so ``$set`` would fail or write a nested field.
|
||||
ValueError: If no embedding could be generated.
|
||||
"""
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
for key in metadata or {}:
|
||||
if not isinstance(key, str) or not key or "." in key or key.startswith("$"):
|
||||
raise InvalidChunkMetadataError(
|
||||
f"Metadata key {key!r} is not allowed: keys must be non-empty, "
|
||||
"contain no '.' and not start with '$'"
|
||||
)
|
||||
|
||||
query = {"_id": ObjectId(chunk_id), "source_id": self._source_id}
|
||||
existing = self._collection.find_one(query)
|
||||
if existing is None:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
|
||||
embeddings = self._embedding.embed_documents([text])
|
||||
if not embeddings:
|
||||
raise ValueError("Could not generate embedding for chunk")
|
||||
|
||||
reserved = {"_id", self._text_key, self._embedding_key, "source_id"}
|
||||
fields = {k: v for k, v in (metadata or {}).items() if k not in reserved}
|
||||
update = {
|
||||
"$set": {
|
||||
self._text_key: text,
|
||||
self._embedding_key: embeddings[0],
|
||||
"source_id": self._source_id,
|
||||
**fields,
|
||||
}
|
||||
}
|
||||
stale = {k: "" for k in existing if k not in reserved and k not in fields}
|
||||
if stale:
|
||||
update["$unset"] = stale
|
||||
|
||||
result = self._collection.update_one(query, update)
|
||||
if result.matched_count == 0:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id):
|
||||
try:
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
@@ -637,7 +637,7 @@ class PGVectorStore(BaseVectorStore):
|
||||
select_query = f"""
|
||||
SELECT id, {self._text_column}, {self._metadata_column}
|
||||
FROM {self._table_name}
|
||||
WHERE source_id = %s;
|
||||
WHERE source_id = %s ORDER BY id;
|
||||
"""
|
||||
cursor.execute(select_query, (self._source_id,))
|
||||
results = cursor.fetchall()
|
||||
@@ -704,6 +704,58 @@ class PGVectorStore(BaseVectorStore):
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Rewrite a chunk's row in place, keeping its id.
|
||||
|
||||
One ``UPDATE`` scoped to this source. The embedding is computed first,
|
||||
so a failed embed writes nothing. ``get_chunks`` orders by id, so the
|
||||
chunk also keeps its place in the list.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata; ``source_id`` is
|
||||
stamped on it as :meth:`add_chunk` does.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no chunk with that id.
|
||||
ValueError: If no embedding could be generated.
|
||||
"""
|
||||
final_metadata = dict(metadata or {})
|
||||
final_metadata["source_id"] = self._source_id
|
||||
|
||||
embeddings = self._embedding.embed_documents([text])
|
||||
if not embeddings:
|
||||
raise ValueError("Could not generate embedding for chunk")
|
||||
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
update_query = f"""
|
||||
UPDATE {self._table_name}
|
||||
SET {self._text_column} = %s, {self._vector_column} = %s, {self._metadata_column} = %s
|
||||
WHERE id = %s AND source_id = %s;
|
||||
"""
|
||||
cursor.execute(
|
||||
update_query,
|
||||
(text, embeddings[0], Jsonb(final_metadata), int(chunk_id), self._source_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
conn.commit()
|
||||
return str(chunk_id)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Error updating chunk: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a specific chunk by its ID"""
|
||||
conn = self._get_connection()
|
||||
|
||||
@@ -206,6 +206,50 @@ class QdrantStore(BaseVectorStore):
|
||||
ids = self.add_texts([text], [metadata or {}])
|
||||
return ids[0]
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Overwrite a chunk's point in place, keeping its id.
|
||||
|
||||
Upserting the same id replaces the vector and payload; ``scroll``
|
||||
orders by point id, so the chunk also keeps its place in
|
||||
:meth:`get_chunks`. The payload has the shape :meth:`add_texts` writes.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the point to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata; ``source_id`` is
|
||||
stamped on it for source scoping.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no point with that id.
|
||||
"""
|
||||
records = self._client.retrieve(
|
||||
collection_name=self._collection,
|
||||
ids=[chunk_id],
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
payload = (records[0].payload or {}) if records else {}
|
||||
if (payload.get("metadata") or {}).get("source_id") != self._source_id:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
|
||||
vector = self._embeddings.embed_documents([text])[0]
|
||||
payload_metadata = dict(metadata or {})
|
||||
payload_metadata["source_id"] = self._source_id
|
||||
self._client.upsert(
|
||||
collection_name=self._collection,
|
||||
points=[
|
||||
self._models.PointStruct(
|
||||
id=chunk_id,
|
||||
vector=vector,
|
||||
payload={"page_content": text, "metadata": payload_metadata},
|
||||
)
|
||||
],
|
||||
)
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a single chunk by id."""
|
||||
try:
|
||||
|
||||
+648
-213
File diff suppressed because it is too large.
Load diff
+59
-61
@@ -7,22 +7,53 @@ import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
import { cardSurfaceSelectors } from './eslint/card-surfaces.js';
|
||||
import { everywhereSelectors, pageSelectors } from './eslint/design-rules.js';
|
||||
|
||||
// Selectors every file gets: viewport heights, focus return, card surfaces.
|
||||
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with the
|
||||
// toolbars hidden, so a vh height overflows the visible screen. w-screen is
|
||||
// vw, which the toolbars don't change, so it stays allowed.
|
||||
const baseSyntaxRules = [
|
||||
...['Literal[value=/\\dvh\\b/]', 'TemplateElement[value.raw=/\\dvh\\b/]'].map(
|
||||
(selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
|
||||
}),
|
||||
),
|
||||
...[
|
||||
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
|
||||
})),
|
||||
// Modal, Sheet and DialogContent return focus to what had it on open
|
||||
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
|
||||
// even after a tap, which lights its ring on iOS.
|
||||
{
|
||||
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
|
||||
message:
|
||||
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
|
||||
},
|
||||
// Nothing on a filled tile repeats its muted fill (DESIGN.md "Card
|
||||
// surfaces").
|
||||
...cardSurfaceSelectors,
|
||||
];
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'prettier.config.cjs',
|
||||
'.eslintrc.cjs',
|
||||
'env.d.ts',
|
||||
'public/',
|
||||
'assets/',
|
||||
'vite-env.d.ts',
|
||||
'.prettierignore',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'postcss.config.cjs',
|
||||
'tailwind.config.cjs',
|
||||
'tsconfig.json',
|
||||
'tsconfig.node.json',
|
||||
'vite.config.ts',
|
||||
@@ -84,35 +115,14 @@ export default [
|
||||
],
|
||||
},
|
||||
],
|
||||
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with
|
||||
// the toolbars hidden, so a vh height overflows the visible screen.
|
||||
// w-screen is vw, which the toolbars don't change, so it stays allowed.
|
||||
// DESIGN.md rules a selector can check: viewport heights, focus return
|
||||
// and card surfaces (baseSyntaxRules above), plus the class and markup
|
||||
// rules in eslint/design-rules.js.
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...[
|
||||
'Literal[value=/\\dvh\\b/]',
|
||||
'TemplateElement[value.raw=/\\dvh\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
|
||||
})),
|
||||
...[
|
||||
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
|
||||
})),
|
||||
// Modal, Sheet and DialogContent return focus to what had it on open
|
||||
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
|
||||
// even after a tap, which lights its ring on iOS.
|
||||
{
|
||||
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
|
||||
message:
|
||||
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
|
||||
},
|
||||
...baseSyntaxRules,
|
||||
...everywhereSelectors,
|
||||
...pageSelectors,
|
||||
],
|
||||
// Design-system rules (@shadcn/lint). Tokens, variants and the
|
||||
// approved exceptions are documented in DESIGN.md.
|
||||
@@ -205,23 +215,7 @@ export default [
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, destructive, success, warning, info) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Checkbox$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Checkbox>: use size from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: '^Dropzone$',
|
||||
allow: ['layout'],
|
||||
message: {
|
||||
default:
|
||||
'"{{className}}" is not allowed on <Dropzone>: use size (default, compact); colours, border and radius follow the drag state in {{file}}.',
|
||||
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, neutral, success, warning, info, destructive) from {{file}}.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -248,14 +242,6 @@ export default [
|
||||
pattern: '^Spinner$',
|
||||
allow: ['layout', 'color'],
|
||||
},
|
||||
{
|
||||
pattern: '^Progress$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(Toast|ToastViewport)$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
|
||||
allow: ['layout', 'spacing'],
|
||||
@@ -267,10 +253,6 @@ export default [
|
||||
pattern: '^MessageScroller(Viewport|Content)$',
|
||||
allow: ['layout', 'spacing'],
|
||||
},
|
||||
{
|
||||
pattern: '^OptionCard$',
|
||||
allow: ['layout'],
|
||||
},
|
||||
{
|
||||
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
|
||||
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
|
||||
@@ -334,6 +316,22 @@ export default [
|
||||
rules: {
|
||||
'shadcn/no-restyle': 'off',
|
||||
'no-restricted-imports': 'off',
|
||||
// ui/ defines the parts the page rules ask for (the table, the
|
||||
// checkbox, the floating label's transition-all), so only the
|
||||
// everywhere rules apply here.
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...baseSyntaxRules,
|
||||
...everywhereSelectors,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Tests hold class strings as fixtures (often asserting their absence),
|
||||
// so the class-token rules don't apply to them.
|
||||
files: ['src/**/*.test.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error', ...baseSyntaxRules],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export declare const cardSurfaceSelectors: {
|
||||
selector: string;
|
||||
message: string;
|
||||
}[];
|
||||
@@ -0,0 +1,39 @@
|
||||
// Card surface rule (DESIGN.md "Card surfaces"): a tile is `filled`, and
|
||||
// nothing on a tile repeats its muted fill, or it disappears into it. The
|
||||
// neutral Badge and the muted Avatar are `bg-muted-foreground/15`, a tint
|
||||
// that shows on muted, so they are fine; a literal `bg-muted` is not. These
|
||||
// are `no-restricted-syntax` entries; they see children written in the same
|
||||
// JSX tree as the `<Card variant="filled">`, not ones a child component
|
||||
// renders.
|
||||
|
||||
// `variant="filled"` or `variant={'filled'}`.
|
||||
const FILLED_VARIANT =
|
||||
'JSXAttribute[name.name="variant"]:matches([value.value="filled"], [value.expression.value="filled"])';
|
||||
|
||||
const FILLED_CARD = `JSXElement:has(> JSXOpeningElement[name.name="Card"]:has(> ${FILLED_VARIANT}))`;
|
||||
|
||||
// `bg-muted` or `bg-muted/NN`, with any variant prefix, but not
|
||||
// `bg-muted-foreground`.
|
||||
const BG_MUTED = '/(^|[\\s:])bg-muted([^-a-z]|$)/';
|
||||
|
||||
/** @type {{ selector: string, message: string }[]} */
|
||||
export const cardSurfaceSelectors = [
|
||||
{
|
||||
selector: `${FILLED_CARD} JSXElement > JSXOpeningElement[name.name="Card"] > ${FILLED_VARIANT}`,
|
||||
message:
|
||||
'A filled Card inside a filled Card is a fill on a fill. A well inside a tile needs a panel around it, or none. See DESIGN.md "Card surfaces".',
|
||||
},
|
||||
...[
|
||||
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] Literal[value=${BG_MUTED}]`,
|
||||
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] TemplateElement[value.raw=${BG_MUTED}]`,
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'bg-muted inside a filled tile is a fill on a fill and disappears. Drop the fill (use a Separator or border-border for structure). See DESIGN.md "Card surfaces".',
|
||||
})),
|
||||
{
|
||||
selector: `${FILLED_CARD} JSXOpeningElement[name.name="Skeleton"]:not(:has(JSXAttribute[name.name="surface"]))`,
|
||||
message:
|
||||
'Skeleton bars on a filled tile need surface="muted", or they vanish into the fill. See DESIGN.md "Skeleton".',
|
||||
},
|
||||
];
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
type Selector = { selector: string; message: string };
|
||||
export declare const everywhereSelectors: Selector[];
|
||||
export declare const pageSelectors: Selector[];
|
||||
@@ -0,0 +1,92 @@
|
||||
// Class and markup rules from DESIGN.md that a `no-restricted-syntax`
|
||||
// selector can check. `everywhereSelectors` apply to ui/ too; `pageSelectors`
|
||||
// are about app code composing ui/ parts, so ui/ (which defines those parts)
|
||||
// is exempt from them.
|
||||
|
||||
// A class token in a string: start of string, a space or a variant colon
|
||||
// before it; a space or the end after it.
|
||||
const cls = (body) => `/(^|[\\s:])${body}(\\s|$)/`;
|
||||
|
||||
const inStrings = (regex, message) =>
|
||||
[`Literal[value=${regex}]`, `TemplateElement[value.raw=${regex}]`].map(
|
||||
(selector) => ({ selector, message }),
|
||||
);
|
||||
|
||||
/** @type {{ selector: string, message: string }[]} */
|
||||
export const everywhereSelectors = [
|
||||
...inStrings(
|
||||
cls('break-all'),
|
||||
'break-all splits ordinary words mid-word. Use wrap-break-word for prose and wrap-anywhere for a long token (URL, id, email). See DESIGN.md "Typography roles".',
|
||||
),
|
||||
...inStrings(
|
||||
cls('rounded'),
|
||||
'Bare `rounded` is a fixed 4px, outside the radius scale. Use rounded-sm (6px, bars), rounded-md (8px) or the role radius in DESIGN.md "Radius by role".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])(min|max)-\\[|\\[@media/',
|
||||
'No custom breakpoints (min-[…], max-[…], [@media…]). Phone / desktop is lg; sm and md only reflow content. See DESIGN.md "Breakpoints".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])-?z-(?!(0|10|20|50|200|auto)(\\s|$))/',
|
||||
'z-index comes from the stacking layers: z-10 sticky headers, z-20 in-page floating chrome, z-50 overlays, z-200 portalled floating lists. See DESIGN.md "Stacking".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:-])(focus|focus-visible|focus-within):ring-2(\\s|$)/',
|
||||
'The focus ring is ring-3 ring-ring/50 on focus-visible (fields: focus-within on a frame the same way). ring-2 is only for selection rings. See DESIGN.md "Focus ring".',
|
||||
),
|
||||
{
|
||||
selector:
|
||||
'ImportDeclaration[source.value="lucide-react"] > ImportSpecifier[imported.name=/^(Loader|Loader2|LoaderCircle)$/]',
|
||||
message:
|
||||
'A loader is <Spinner> (ui/spinner) or <LoadingState>, never a lucide loader icon. See DESIGN.md "Spinner and Skeleton".',
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {{ selector: string, message: string }[]} */
|
||||
export const pageSelectors = [
|
||||
...inStrings(
|
||||
cls('transition-all'),
|
||||
'Transition only the property that changes: transition-colors by default, transition-transform for chevrons, transition-shadow for a ring. See DESIGN.md "Motion".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:-])hover:scale-(?!x-|y-)/',
|
||||
'Hover is a fill, border or text-colour change, never a scale. See DESIGN.md "Motion".',
|
||||
),
|
||||
...inStrings(
|
||||
'/(^|[\\s:])bg-primary\\u002f10(\\s|$)/',
|
||||
'The brand soft fill is bg-secondary text-secondary-foreground, not bg-primary/10. See DESIGN.md "Colour tokens".',
|
||||
),
|
||||
{
|
||||
selector:
|
||||
'JSXOpeningElement[name.name="input"] > JSXAttribute[name.name="type"][value.value="checkbox"]',
|
||||
message:
|
||||
'A checkbox is <Checkbox> (ui/checkbox); native boxes take the OS accent and ignore dark mode. See DESIGN.md "Checkbox".',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'JSXOpeningElement[name.name="Button"] > JSXAttribute[name.name="title"]',
|
||||
message:
|
||||
'An icon-only button is <IconButton label> (its tooltip is the label); a labelled Button needs no title. See DESIGN.md "Tooltip and IconButton".',
|
||||
},
|
||||
...[
|
||||
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] Literal[value=/(^|[\\s:])(max-)?w-/]',
|
||||
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|[\\s:])(max-)?w-/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'A drawer\'s width comes from SheetContent size (default 384px, detail 576px, wide 800px), never a w-* or max-w-* class. See DESIGN.md "Modal, not Dialog".',
|
||||
})),
|
||||
{
|
||||
selector: 'JSXOpeningElement[name.name="table"]',
|
||||
message:
|
||||
'Every table is ui/table (Table, TableHead, TableRow…), never a raw <table>. See DESIGN.md "Table".',
|
||||
},
|
||||
...[
|
||||
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] Literal[value=/(^|[\\s:])(underline|text-primary)(\\s|$)/]',
|
||||
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|[\\s:])(underline|text-primary)(\\s|$)/]',
|
||||
].map((selector) => ({
|
||||
selector,
|
||||
message:
|
||||
'A link is <Button variant="link" size="inline" asChild> around the <a>, never a raw <a> styled as a link. See DESIGN.md "Button".',
|
||||
})),
|
||||
];
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Summarise @shadcn/lint warnings from ESLint JSON output.
|
||||
* Summarise design-lint violations from ESLint JSON output: the @shadcn/lint
|
||||
* rules and the DESIGN.md selectors under no-restricted-syntax
|
||||
* (eslint/design-rules.js, eslint/card-surfaces.js, viewport heights).
|
||||
*
|
||||
* npm run lint:design rules × counts, then the 25 worst files
|
||||
* npm run lint:design -- --rule no-raw-colors files for one rule
|
||||
@@ -26,10 +28,12 @@ const rows = [];
|
||||
for (const file of results) {
|
||||
const rel = file.filePath.replace(`${process.cwd()}/`, '');
|
||||
for (const m of file.messages) {
|
||||
if (!m.ruleId?.startsWith('shadcn/')) continue;
|
||||
const design =
|
||||
m.ruleId?.startsWith('shadcn/') || m.ruleId === 'no-restricted-syntax';
|
||||
if (!design) continue;
|
||||
rows.push({
|
||||
file: rel,
|
||||
rule: m.ruleId.slice(7),
|
||||
rule: m.ruleId.replace(/^shadcn\//, ''),
|
||||
line: m.line,
|
||||
msg: m.message,
|
||||
});
|
||||
@@ -47,7 +51,7 @@ if (onlyFile) {
|
||||
const mine = rows.filter(
|
||||
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
|
||||
);
|
||||
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
|
||||
console.log(`${mine.length} design violations in ${onlyFile}\n`);
|
||||
for (const r of mine.sort((a, b) => a.line - b.line)) {
|
||||
console.log(
|
||||
`${pad(r.line, 5)} ${r.rule}\n ${r.msg.split(' See frontend/DESIGN.md')[0]}\n`,
|
||||
@@ -55,14 +59,14 @@ if (onlyFile) {
|
||||
}
|
||||
} else if (onlyRule) {
|
||||
const mine = rows.filter((r) => r.rule === onlyRule);
|
||||
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
|
||||
console.log(`${mine.length} violations of ${onlyRule}\n`);
|
||||
const byFile = new Map();
|
||||
for (const r of mine) byFile.set(r.file, (byFile.get(r.file) ?? 0) + 1);
|
||||
for (const [f, n] of [...byFile.entries()].sort((a, b) => b[1] - a[1]))
|
||||
console.log(`${pad(n, 5)} ${f}`);
|
||||
} else {
|
||||
console.log(
|
||||
`${rows.length} design warnings in ${new Set(rows.map((r) => r.file)).size} files\n`,
|
||||
`${rows.length} design violations in ${new Set(rows.map((r) => r.file)).size} files\n`,
|
||||
);
|
||||
console.log('By rule:');
|
||||
for (const [rule, n] of count('rule')) console.log(`${pad(n, 5)} ${rule}`);
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import ConversationTile from './conversation/ConversationTile';
|
||||
import { useMediaQuery } from './hooks';
|
||||
import useTokenAuth from './hooks/useTokenAuth';
|
||||
import { cn } from './lib/utils';
|
||||
import { cn, overlayScrim } from './lib/utils';
|
||||
import ConfirmationModal from './modals/ConfirmationModal';
|
||||
import JWTModal from './modals/JWTModal';
|
||||
import SearchConversationsModal from './modals/SearchConversationsModal';
|
||||
@@ -402,7 +402,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
<>
|
||||
{isMobile && navOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black opacity-50 transition-opacity duration-300"
|
||||
className={cn(
|
||||
'animate-in fade-in-0 fixed inset-0 z-20',
|
||||
overlayScrim,
|
||||
)}
|
||||
onClick={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock('react-redux', () => ({ useSelector: () => 'token' }));
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgent: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'a1',
|
||||
name: 'Carrier FAQ',
|
||||
last_used_at: null,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../settings/Analytics', () => ({
|
||||
default: () => <div data-testid="analytics" />,
|
||||
}));
|
||||
vi.mock('../settings/Logs', () => ({
|
||||
default: () => <div data-testid="logs" />,
|
||||
}));
|
||||
vi.mock('./components/GuardrailEvents', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => <h1>Logs</h1>,
|
||||
}));
|
||||
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
|
||||
|
||||
import AgentLogs from './AgentLogs';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentLogs', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
// A3: the same toolbar as Overview and Schedules, the agent in its byline.
|
||||
it('names the agent in the shared agent toolbar', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/manage/logs/a1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/logs/:agentId"
|
||||
element={<AgentLogs />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('Carrier FAQ');
|
||||
expect(toolbar.textContent).toContain('agents.logs.noUsageHistory');
|
||||
expect(toolbar.querySelector('[data-slot="separator"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,8 @@ import userService from '../api/services/userService';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import Analytics from '../settings/Analytics';
|
||||
import Logs from '../settings/Logs';
|
||||
import { formatDateTime } from '../utils/dateTimeUtils';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
|
||||
import GuardrailEvents from './components/GuardrailEvents';
|
||||
import { Agent } from './types';
|
||||
|
||||
@@ -39,26 +39,16 @@ export default function AgentLogs() {
|
||||
}, [agentId, token]);
|
||||
|
||||
return (
|
||||
<SectionShell pills>
|
||||
<div className="flex flex-col gap-3">
|
||||
{agent && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground">{agent.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.last_used_at
|
||||
? t('agents.logs.lastUsedAt') +
|
||||
' ' +
|
||||
formatDateTime(agent.last_used_at)
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionShell>
|
||||
{agent && (
|
||||
<AgentPageToolbar
|
||||
name={agent.name}
|
||||
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
|
||||
/>
|
||||
)}
|
||||
{agentId && (
|
||||
<>
|
||||
<div className="mt-8">
|
||||
<Analytics agentId={agentId} />
|
||||
</div>
|
||||
<Analytics agentId={agentId} />
|
||||
<GuardrailEvents agentId={agentId} />
|
||||
<div className="mt-8">
|
||||
<Logs
|
||||
|
||||
@@ -53,4 +53,52 @@ describe('AgentPageHeader sub-nav', () => {
|
||||
expect(tabs[0].tagName).toBe('A');
|
||||
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
|
||||
const onNameClick = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentId="a1"
|
||||
agentName="Helpdesk Triage"
|
||||
currentPage="overview"
|
||||
onNameClick={onNameClick}
|
||||
status={<span data-testid="status">Published</span>}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const crumb = container.querySelector(
|
||||
'button[aria-haspopup="dialog"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(crumb).not.toBeNull();
|
||||
expect(crumb.getAttribute('data-variant')).toBe('ghost');
|
||||
expect(crumb.getAttribute('data-size')).toBe('sm');
|
||||
expect(crumb.querySelector('img')).not.toBeNull();
|
||||
expect(crumb.querySelector('.lucide-chevron-down')).not.toBeNull();
|
||||
expect(crumb.textContent).toContain('Helpdesk Triage');
|
||||
expect(container.querySelector('[data-testid="status"]')).not.toBeNull();
|
||||
act(() => crumb.click());
|
||||
expect(onNameClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides the tabs for a workflow that has no id yet', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentName="New workflow"
|
||||
currentPage="overview"
|
||||
onNameClick={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
container.querySelector(
|
||||
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -36,13 +38,26 @@ type AgentPageHeaderProps = {
|
||||
* (e.g. the workflow builder's fixed toolbar) to avoid a double rule.
|
||||
*/
|
||||
inline?: boolean;
|
||||
/** The agent's avatar URL; the robot is drawn when it's empty. */
|
||||
agentImage?: string;
|
||||
/**
|
||||
* Makes the current crumb a button (avatar, name, chevron) that opens the
|
||||
* agent's details. Only with `currentPage="overview"`.
|
||||
*/
|
||||
onNameClick?: () => void;
|
||||
/** A status Badge placed after the crumbs. */
|
||||
status?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared chrome for the agent sub-pages (Overview/Edit, Logs, Schedules).
|
||||
*
|
||||
* Top: shadcn Breadcrumb (`Agents > <agent name> > <current page>`).
|
||||
* Bottom: underline-style sub-nav linking between the agent's sub-pages.
|
||||
* The workflow builder's toolbar chrome: a Breadcrumb (`Agents > <agent
|
||||
* name> > <current page>`), an optional status Badge, and underline tab links
|
||||
* to the agent's Overview, Logs and Schedules (hidden until the agent has an
|
||||
* id). The builder is full-screen with no sidebar, so it needs its own way
|
||||
* between them. With `onNameClick` the current crumb is the agent's avatar,
|
||||
* name and a chevron in a `ghost sm` Button that opens the details, like the
|
||||
* phone top bar's chat title. Section pages use
|
||||
* `components/AgentPageToolbar` and the sidebar instead.
|
||||
*/
|
||||
export default function AgentPageHeader({
|
||||
agentId,
|
||||
@@ -51,6 +66,9 @@ export default function AgentPageHeader({
|
||||
currentPage,
|
||||
className,
|
||||
inline = false,
|
||||
agentImage,
|
||||
onNameClick,
|
||||
status,
|
||||
}: AgentPageHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -85,83 +103,116 @@ export default function AgentPageHeader({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6',
|
||||
// The builder only renders from lg, so one row.
|
||||
'flex min-w-0 items-center gap-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Breadcrumb className="shrink-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={AGENTS_MANAGE_ROOT}>
|
||||
{t('agents.pageHeader.crumbs.agents')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{currentPage === 'overview' ? (
|
||||
<BreadcrumbPage title={displayName} className="w-[16ch]">
|
||||
{displayName}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Breadcrumb className="min-w-0">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={editPath} className="max-w-[40ch] truncate">
|
||||
{displayName}
|
||||
<Link to={AGENTS_MANAGE_ROOT}>
|
||||
{t('agents.pageHeader.crumbs.agents')}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{currentPage === 'overview' && onNameClick ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-haspopup="dialog"
|
||||
onClick={onNameClick}
|
||||
// Button is shrink-0; shrink lets a long name truncate.
|
||||
className="min-w-0 shrink"
|
||||
>
|
||||
<Avatar
|
||||
src={agentImage}
|
||||
alt=""
|
||||
shape="circle"
|
||||
className="shrink-0 overflow-hidden"
|
||||
imgClassName="size-5 object-contain"
|
||||
/>
|
||||
{/* The list's muted colour would reach the name; the current crumb
|
||||
is foreground, like BreadcrumbPage. */}
|
||||
<span
|
||||
className="text-foreground max-w-[24ch] truncate"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
<ChevronDown className="text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
) : currentPage === 'overview' ? (
|
||||
<BreadcrumbPage title={displayName} className="w-[16ch]">
|
||||
{displayName}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={editPath} className="max-w-[40ch] truncate">
|
||||
{displayName}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{currentPage !== 'overview' && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{currentPage !== 'overview' && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{status}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label={t('agents.pageHeader.subnavAriaLabel')}
|
||||
className={cn(
|
||||
'flex items-center gap-6',
|
||||
// 1px baseline rule under the whole row; the active tab's 2px
|
||||
// primary underline sits on top of it for the GitHub-style look.
|
||||
!inline && 'border-border border-b',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
{agentId && (
|
||||
<nav
|
||||
aria-label={t('agents.pageHeader.subnavAriaLabel')}
|
||||
className={cn(
|
||||
'flex items-center gap-6',
|
||||
// 1px baseline rule under the whole row; the active tab's 2px
|
||||
// primary underline sits on top of it for the GitHub-style look.
|
||||
!inline && 'border-border border-b',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
asChild
|
||||
variant="tab"
|
||||
size="inline"
|
||||
data-active
|
||||
className="-mb-px"
|
||||
>
|
||||
<span aria-current="page">{tab.label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
asChild
|
||||
variant="tab"
|
||||
size="inline"
|
||||
data-active
|
||||
className="-mb-px"
|
||||
>
|
||||
<span aria-current="page">{tab.label}</span>
|
||||
<Link to={tab.href}>{tab.label}</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
asChild
|
||||
variant="tab"
|
||||
size="inline"
|
||||
className="-mb-px"
|
||||
>
|
||||
<Link to={tab.href}>{tab.label}</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
import MessageInput from '../components/MessageInput';
|
||||
import ConversationMessages from '../conversation/ConversationMessages';
|
||||
import { Query } from '../conversation/conversationModels';
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
fetchPreviewAnswer,
|
||||
handlePreviewAbort,
|
||||
resendQuery,
|
||||
resetPreview,
|
||||
selectPreviewQueries,
|
||||
selectPreviewStatus,
|
||||
} from './agentPreviewSlice';
|
||||
@@ -96,12 +96,13 @@ export default function AgentPreview() {
|
||||
}
|
||||
};
|
||||
|
||||
// Closing the drawer unmounts the preview: stop a stream in flight, but
|
||||
// keep the chat. The page that hosts the drawer decides when it starts
|
||||
// over (New chat, a save, leaving the page).
|
||||
useEffect(() => {
|
||||
dispatch(resetPreview());
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handlePreviewAbort();
|
||||
dispatch(resetPreview());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -115,13 +116,12 @@ export default function AgentPreview() {
|
||||
<div className="relative h-full w-full">
|
||||
<div className="scrollbar-overlay absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
|
||||
{queries.length === 0 ? (
|
||||
<section className="flex h-full flex-col items-center justify-center">
|
||||
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
|
||||
<MessageSquare className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t('agents.preview.emptyTitle')}
|
||||
</p>
|
||||
<section className="flex h-full items-center justify-center">
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t('agents.preview.emptyTitle')}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<ConversationMessages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
|
||||
|
||||
@@ -16,16 +16,26 @@ const mockState = {
|
||||
prompts: [],
|
||||
agentFolders: [],
|
||||
},
|
||||
agentPreview: { queries: [], status: 'idle' },
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const jsonResponse = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
return {
|
||||
jsonResponse,
|
||||
dispatch: vi.fn(),
|
||||
getAgent: vi.fn(() => jsonResponse({})),
|
||||
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => vi.fn(),
|
||||
useDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: () =>
|
||||
@@ -40,8 +50,8 @@ vi.mock('../api/services/userService', () => ({
|
||||
],
|
||||
}),
|
||||
getAgentFolders: () => jsonResponse({ folders: [] }),
|
||||
getAgent: () => jsonResponse({}),
|
||||
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
|
||||
getAgent: mocks.getAgent,
|
||||
createAgent: mocks.createAgent,
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
createPrompt: () => jsonResponse({}),
|
||||
@@ -95,9 +105,22 @@ vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({
|
||||
default: () => <div data-testid="section-pills" />,
|
||||
}));
|
||||
vi.mock('../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => null,
|
||||
CurrentSectionHeader: ({
|
||||
title,
|
||||
titleAction,
|
||||
}: {
|
||||
title?: React.ReactNode;
|
||||
titleAction?: React.ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
{title ? <h1>{title}</h1> : null}
|
||||
{titleAction}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
|
||||
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
|
||||
@@ -132,6 +155,9 @@ describe('NewAgent form', () => {
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.getAgent.mockClear();
|
||||
mocks.createAgent.mockClear();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
@@ -182,33 +208,176 @@ describe('NewAgent form', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('titles each form panel with a SectionHeader spaced by the panel gap', async () => {
|
||||
// A1a: three grouped sections instead of six one-field panels.
|
||||
it('groups the form into Basics, Knowledge and behaviour, and Model', async () => {
|
||||
await render();
|
||||
const titles = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"] > h2'),
|
||||
).map((h) => h.textContent);
|
||||
expect(titles).toEqual(
|
||||
expect(titles).toEqual([
|
||||
'agents.form.sections.basics',
|
||||
'agents.form.sections.knowledge',
|
||||
'agents.form.sections.model',
|
||||
]);
|
||||
const basics = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"]'),
|
||||
).find((h) => h.textContent === 'agents.form.sections.basics')!;
|
||||
expect(basics.parentElement!.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
);
|
||||
});
|
||||
|
||||
// On a phone the avatar sits beside Name and Description spans the row;
|
||||
// from sm the avatar spans both rows beside the fields.
|
||||
it('lays Basics out as avatar beside Name, Description full width on a phone', async () => {
|
||||
await render();
|
||||
const name = container.querySelector(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
const grid = name.closest('.grid')!;
|
||||
expect(grid.className).toContain('grid-cols-[auto_1fr]');
|
||||
const description = container
|
||||
.querySelector(
|
||||
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
|
||||
)!
|
||||
.closest('[data-slot="form-field"]')!;
|
||||
expect(description.className).toContain('col-span-2');
|
||||
expect(description.className).toContain('sm:col-start-2');
|
||||
});
|
||||
|
||||
it('labels every picker with a floating label', async () => {
|
||||
await render();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll('[data-slot="form-field-label"]'),
|
||||
).map((l) => l.textContent);
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
'agents.form.sections.meta',
|
||||
'agents.form.sections.source',
|
||||
'agents.form.labels.name',
|
||||
'agents.form.labels.description',
|
||||
'agents.form.labels.sources',
|
||||
'agents.form.sections.tools',
|
||||
'agents.form.sections.agentType',
|
||||
'agents.form.sections.models',
|
||||
]),
|
||||
);
|
||||
const meta = Array.from(
|
||||
container.querySelectorAll('[data-slot="section-header"]'),
|
||||
).find((h) => h.textContent === 'agents.form.sections.meta')!;
|
||||
expect(meta.parentElement!.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
});
|
||||
|
||||
it('puts Sources beside Tools in a two-up field grid', async () => {
|
||||
await render();
|
||||
const [sources, tools] = Array.from(
|
||||
container.querySelectorAll('[data-testid="picker"] > button'),
|
||||
);
|
||||
expect((meta.nextElementSibling as HTMLElement).className).not.toContain(
|
||||
'mt-5',
|
||||
const grid = sources.closest('.grid')!;
|
||||
expect(grid.className).toContain('sm:grid-cols-2');
|
||||
expect(grid.contains(tools)).toBe(true);
|
||||
});
|
||||
|
||||
it('titles the new-agent page and puts its actions in the agent toolbar', async () => {
|
||||
await render();
|
||||
expect(container.querySelector('h1')?.textContent).toBe('agents.newAgent');
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('agents.form.byline.new');
|
||||
expect(toolbar.contains(buttonByText('agents.form.buttons.publish'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Decision 64: the Advanced header is a section-toggle with a leading
|
||||
// lucide chevron; the panel draws the focus ring.
|
||||
// Before publishing, Preview can only say "Publish to preview", so it is a
|
||||
// ⋯ item beside the title rather than a toolbar button.
|
||||
it('drops the New agent title once the agent is saved', async () => {
|
||||
await render();
|
||||
mocks.createAgent.mockImplementationOnce(() =>
|
||||
jsonResponse({ id: 'agent-1' }),
|
||||
);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.buttons.saveDraft').click(),
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).not.toBe(
|
||||
'agents.newAgent',
|
||||
);
|
||||
});
|
||||
|
||||
// The preview talks to the saved agent; Redux must hold that snapshot, not
|
||||
// the unsaved form, or the preview picks up an unsaved model.
|
||||
it('keeps the preview on the saved agent while editing a published one', async () => {
|
||||
mocks.getAgent.mockImplementationOnce(() =>
|
||||
jsonResponse({
|
||||
id: 'agent-1',
|
||||
name: 'Saved name',
|
||||
description: 'Saved description',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
prompt_id: 'default',
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/edit/:agentId"
|
||||
element={<NewAgent mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.agentName"]',
|
||||
)!;
|
||||
await act(async () => setNativeValue(name, 'Unsaved name'));
|
||||
|
||||
const pushed = mocks.dispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter((a) => a?.type === 'preference/setSelectedAgent' && a.payload);
|
||||
expect(pushed.length).toBeGreaterThan(0);
|
||||
for (const action of pushed) {
|
||||
expect(action.payload.name).toBe('Saved name');
|
||||
}
|
||||
});
|
||||
|
||||
it('offers Preview from the title-row menu until the agent is published', async () => {
|
||||
await render();
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(
|
||||
Array.from(toolbar.querySelectorAll('button')).some((b) =>
|
||||
b.textContent?.includes('agents.form.sections.preview'),
|
||||
),
|
||||
).toBe(false);
|
||||
const menu = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.form.buttons.moreActions"]',
|
||||
)!;
|
||||
expect(menu.getAttribute('data-size')).toBe('icon');
|
||||
// Beside the page title, not in the toolbar row.
|
||||
expect(menu.closest('[data-slot="page-toolbar"]')).toBeNull();
|
||||
expect(menu.parentElement!.querySelector('h1')).not.toBeNull();
|
||||
await act(async () => {
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
const preview = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent === 'agents.form.sections.preview')!;
|
||||
await act(async () => preview.click());
|
||||
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.textContent).toContain('agents.form.preview.publishTitle');
|
||||
});
|
||||
|
||||
it('stretches the main button on a phone', async () => {
|
||||
await render();
|
||||
const publish = buttonByText('agents.form.buttons.publish');
|
||||
expect(publish.className).toContain('flex-1');
|
||||
expect(publish.className).toContain('sm:flex-none');
|
||||
});
|
||||
|
||||
// The sidebar (and the agent card's menu) already switch between an
|
||||
// agent's pages, so the pages carry no pill row.
|
||||
it('has no destination pills', async () => {
|
||||
await render();
|
||||
expect(container.querySelector('[data-testid="section-pills"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the Advanced header as a section-toggle', async () => {
|
||||
await render();
|
||||
const toggle = buttonByText('agents.form.sections.advanced');
|
||||
@@ -221,7 +390,7 @@ describe('NewAgent form', () => {
|
||||
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
|
||||
expect(toggle.querySelector('h2')).toBeNull();
|
||||
expect(toggle.parentElement!.tagName).toBe('H2');
|
||||
const panel = toggle.closest('.bg-card')!;
|
||||
const panel = toggle.closest('[data-slot="card"]')!;
|
||||
expect(panel.className).toContain(
|
||||
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
|
||||
);
|
||||
@@ -265,11 +434,14 @@ describe('NewAgent form', () => {
|
||||
|
||||
it('renders Save draft and Publish as pill button variants', async () => {
|
||||
await render();
|
||||
// A4: one purple button per page; every header button is field pill.
|
||||
const draft = buttonByText('agents.form.buttons.saveDraft');
|
||||
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
|
||||
expect(draft.getAttribute('data-variant')).toBe('outline');
|
||||
expect(draft.getAttribute('data-size')).toBe('field');
|
||||
expect(draft.getAttribute('data-shape')).toBe('pill');
|
||||
const publish = buttonByText('agents.form.buttons.publish');
|
||||
expect(publish.getAttribute('data-variant')).toBe('default');
|
||||
expect(publish.getAttribute('data-size')).toBe('field');
|
||||
expect(publish.getAttribute('data-shape')).toBe('pill');
|
||||
expect(publish.disabled).toBe(true);
|
||||
});
|
||||
@@ -283,6 +455,7 @@ describe('NewAgent form', () => {
|
||||
await act(async () => setNativeValue(name, 'Support bot'));
|
||||
const cancel = buttonByText('agents.form.buttons.cancel');
|
||||
expect(cancel.getAttribute('data-variant')).toBe('ghost');
|
||||
expect(cancel.getAttribute('data-size')).toBe('field');
|
||||
expect(cancel.getAttribute('data-shape')).toBe('pill');
|
||||
});
|
||||
|
||||
@@ -327,12 +500,37 @@ describe('NewAgent form', () => {
|
||||
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the preview placeholder illustration as decorative images', async () => {
|
||||
// Card surfaces: the form is a place, so each section is a subtle panel
|
||||
// straight on the page, with no muted panel around the form.
|
||||
it('draws each form section as a subtle panel on the page', async () => {
|
||||
await render();
|
||||
const images = container.querySelectorAll('img[alt=""]');
|
||||
expect(images).toHaveLength(2);
|
||||
for (const img of images) {
|
||||
expect(img.getAttribute('aria-hidden')).toBe('true');
|
||||
const titles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="section-header"]'),
|
||||
);
|
||||
expect(titles.length).toBeGreaterThan(0);
|
||||
for (const title of titles) {
|
||||
const panel = title.closest<HTMLElement>('[data-slot="card"]')!;
|
||||
expect(panel.dataset.variant).toBe(
|
||||
title.closest('[data-tone="destructive"]')
|
||||
? panel.dataset.variant
|
||||
: 'subtle',
|
||||
);
|
||||
}
|
||||
const advanced = buttonByText('agents.form.sections.advanced');
|
||||
const panel = advanced.closest<HTMLElement>('[data-slot="card"]')!;
|
||||
expect(panel.dataset.variant).toBe('subtle');
|
||||
expect(panel.dataset.padding).toBe('lg');
|
||||
expect(container.querySelector('.bg-muted.rounded-2xl')).toBeNull();
|
||||
});
|
||||
|
||||
it('notches floating labels on the page background', async () => {
|
||||
await render();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="form-field-label"]'),
|
||||
);
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
for (const label of labels) {
|
||||
expect(label.className).toContain('bg-background');
|
||||
}
|
||||
});
|
||||
});
|
||||
+632
-506
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import SharedAgentCard from './SharedAgentCard';
|
||||
|
||||
describe('SharedAgentCard', () => {
|
||||
// Card surfaces: a summary of one agent is a thing, so it is a filled tile.
|
||||
it('is a filled tile', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SharedAgentCard
|
||||
agent={
|
||||
{ id: 'a1', name: 'Carrier FAQ', description: 'Answers' } as never
|
||||
}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="card"');
|
||||
expect(html).toContain('data-variant="filled"');
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export default function SharedAgentCard({
|
||||
Object.keys(agent.shared_metadata).length > 0;
|
||||
return (
|
||||
<Card
|
||||
variant="subtle"
|
||||
variant="filled"
|
||||
padding="lg"
|
||||
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import AgentPageToolbar from './AgentPageToolbar';
|
||||
|
||||
describe('AgentPageToolbar', () => {
|
||||
it('puts the agent byline beside the actions and closes with a rule', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar
|
||||
name="Carrier onboarding FAQ"
|
||||
status={<span data-testid="status">Published</span>}
|
||||
meta="Last used at 21 Sep"
|
||||
actions={<button type="button">Save</button>}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="page-toolbar"');
|
||||
expect(html).toContain('Carrier onboarding FAQ');
|
||||
expect(html).toContain('data-testid="status"');
|
||||
expect(html).toContain('Last used at 21 Sep');
|
||||
expect(html).toContain('<button type="button">Save</button>');
|
||||
expect(html).toContain('data-slot="separator"');
|
||||
});
|
||||
|
||||
// On a phone, a status badge fills the first line: the meta takes a line
|
||||
// of its own there, without a dangling separator.
|
||||
it('moves the meta to its own line on a phone when there is a status', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar
|
||||
name="FAQ"
|
||||
status={<span>Published</span>}
|
||||
meta="Last used"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span aria-hidden="true" class="hidden sm:inline">·</span>',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span class="basis-full sm:basis-auto">Last used</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the meta on the name line when there is no status', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar name="FAQ" meta="Last used" />,
|
||||
);
|
||||
expect(html).toContain(
|
||||
'<span aria-hidden="true">·</span><span>Last used</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the row at field height when a tab has no actions', () => {
|
||||
const html = renderToStaticMarkup(<AgentPageToolbar name="FAQ" />);
|
||||
// A 38px spacer stands in for the actions, so the rule lands in the same
|
||||
// place on every agent tab.
|
||||
expect(html).toContain('h-9.5');
|
||||
});
|
||||
|
||||
it('renders notices between the row and the rule', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AgentPageToolbar name="FAQ">
|
||||
<p>Save failed</p>
|
||||
</AgentPageToolbar>,
|
||||
);
|
||||
expect(html.indexOf('Save failed')).toBeLessThan(
|
||||
html.indexOf('data-slot="separator"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import PageToolbar from '../../components/PageToolbar';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
|
||||
type AgentPageToolbarProps = {
|
||||
/** The agent's name, first in the byline. */
|
||||
name?: string;
|
||||
/** A status Badge beside the name (Overview's Published / Draft). */
|
||||
status?: ReactNode;
|
||||
/** The tab's context after the name, e.g. "Last used at …". */
|
||||
meta?: ReactNode;
|
||||
/** The tab's buttons, on the right of the row. */
|
||||
actions?: ReactNode;
|
||||
/** A line of its own in place of the name byline (the new-agent form). */
|
||||
intro?: ReactNode;
|
||||
/** Notices under the row, above the rule (a failed save). */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The block under an agent tab's title (Overview, Logs, Schedules): a muted
|
||||
* byline naming the agent, the tab's actions on the right, then a rule. The
|
||||
* row keeps the field height when a tab has no actions, so the rule lands in
|
||||
* the same place on every tab and nothing jumps when switching.
|
||||
*
|
||||
* Args:
|
||||
* name: The agent's name.
|
||||
* status: A Badge shown beside the name.
|
||||
* meta: Tab context shown after the name, separated by a middle dot.
|
||||
* actions: The tab's buttons.
|
||||
* intro: Replaces the name byline.
|
||||
* children: Notices between the row and the rule.
|
||||
*/
|
||||
export default function AgentPageToolbar({
|
||||
name,
|
||||
status,
|
||||
meta,
|
||||
actions,
|
||||
intro,
|
||||
children,
|
||||
}: AgentPageToolbarProps) {
|
||||
const byline = intro ?? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{name ? <span className="wrap-break-word">{name}</span> : null}
|
||||
{status}
|
||||
{meta ? (
|
||||
status ? (
|
||||
// With a status badge the first line is full on a phone: the meta
|
||||
// takes its own line there, so no separator is left hanging.
|
||||
<>
|
||||
<span aria-hidden className="hidden sm:inline">
|
||||
·
|
||||
</span>
|
||||
<span className="basis-full sm:basis-auto">{meta}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{meta}</span>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageToolbar
|
||||
intro={byline}
|
||||
action={actions ?? <div aria-hidden className="h-9.5" />}
|
||||
divider
|
||||
>
|
||||
{children}
|
||||
</PageToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent tabs' usage meta: "Last used at …", or "no usage history".
|
||||
*
|
||||
* Args:
|
||||
* lastUsedAt: The agent's `last_used_at` timestamp.
|
||||
*/
|
||||
export function LastUsedMeta({ lastUsedAt }: { lastUsedAt?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{lastUsedAt
|
||||
? `${t('agents.logs.lastUsedAt')} ${formatDateTime(lastUsedAt)}`
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { act } from 'react';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({ useDispatch: () => dispatch }));
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import AgentPreviewSheet from './AgentPreviewSheet';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('AgentPreviewSheet', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentPreviewSheet
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
title="Preview"
|
||||
description="Carrier onboarding FAQ"
|
||||
actions={<span data-testid="action">Running</span>}
|
||||
>
|
||||
<div data-testid="body">Chat</div>
|
||||
</AgentPreviewSheet>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it('is a right Sheet at the workflow preview widths', async () => {
|
||||
await render();
|
||||
const content = document.querySelector<HTMLElement>(
|
||||
'[data-slot="sheet-content"]',
|
||||
)!;
|
||||
expect(content.dataset.side).toBe('right');
|
||||
expect(content.className).toContain('lg:max-w-[800px]');
|
||||
expect(content.className).toContain('p-0');
|
||||
});
|
||||
|
||||
it('titles the drawer with SheetTitle and SheetDescription', async () => {
|
||||
await render();
|
||||
expect(
|
||||
document.querySelector('[data-slot="sheet-title"]')?.textContent,
|
||||
).toBe('Preview');
|
||||
expect(
|
||||
document.querySelector('[data-slot="sheet-description"]')?.textContent,
|
||||
).toBe('Carrier onboarding FAQ');
|
||||
});
|
||||
|
||||
it('keeps header actions clear of the close button', async () => {
|
||||
await render();
|
||||
const action = document.querySelector('[data-testid="action"]')!;
|
||||
const header = action.parentElement!;
|
||||
// The X sits at top-2 right-2 (32px wide); pr-12 keeps 48px free.
|
||||
expect(header.className).toContain('pr-12');
|
||||
expect(
|
||||
header.contains(document.querySelector('[data-slot="sheet-title"]')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// DESIGN maps "running" to info, as schedule runs do (was primary text).
|
||||
it('shows a running run as an info badge in the header', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentPreviewSheet
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
title="Preview"
|
||||
running
|
||||
>
|
||||
<div />
|
||||
</AgentPreviewSheet>,
|
||||
);
|
||||
});
|
||||
const badge = document.querySelector<HTMLElement>('[data-slot="badge"]')!;
|
||||
expect(badge.dataset.variant).toBe('info');
|
||||
expect(badge.textContent).toBe('agents.schedules.status.running');
|
||||
});
|
||||
|
||||
// The toast stack moves bottom-left while any agent preview is open.
|
||||
it('reports whether it is open, and closed when it goes away', async () => {
|
||||
dispatch.mockClear();
|
||||
await render();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'workflowPreview/setPreviewOpen',
|
||||
payload: true,
|
||||
});
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
expect(dispatch).toHaveBeenLastCalledWith({
|
||||
type: 'workflowPreview/setPreviewOpen',
|
||||
payload: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the body under a separator on the sheet surface', async () => {
|
||||
await render();
|
||||
const body = document.querySelector('[data-testid="body"]')!;
|
||||
expect(body.textContent).toBe('Chat');
|
||||
expect(document.querySelector('[data-slot="separator"]')).not.toBeNull();
|
||||
const content = document.querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(content.querySelector('.bg-card')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
import { setPreviewOpen } from '../workflow/workflowPreviewSlice';
|
||||
|
||||
type AgentPreviewSheetProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The drawer's title, e.g. "Preview". */
|
||||
title: string;
|
||||
/** One muted line under the title: the agent's name. */
|
||||
description?: string;
|
||||
/** Shows an info "Running" badge while the preview is answering. */
|
||||
running?: boolean;
|
||||
/** Actions at the header's right end (New chat). */
|
||||
actions?: ReactNode;
|
||||
/** The preview itself; fills the rest of the drawer. */
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The drawer an agent is previewed in, for workflow and classic agents alike:
|
||||
* a right Sheet with a title row, a rule, then the preview filling the rest.
|
||||
*
|
||||
* Args:
|
||||
* open: Whether the drawer is open.
|
||||
* onOpenChange: Called with the next open state (the X, Escape, the scrim).
|
||||
* title: The drawer's title.
|
||||
* description: A muted line under the title.
|
||||
* running: Whether the preview is answering; shows a Running badge.
|
||||
* actions: Nodes at the right end of the header, clear of the close button.
|
||||
* children: The preview body.
|
||||
*/
|
||||
export default function AgentPreviewSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
running = false,
|
||||
actions,
|
||||
children,
|
||||
}: AgentPreviewSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// App moves the toast stack bottom-left while a preview drawer is open, so
|
||||
// it doesn't sit over the chat. The flag lives in the workflow preview
|
||||
// slice for both agent types.
|
||||
useEffect(() => {
|
||||
dispatch(setPreviewOpen(open));
|
||||
return () => {
|
||||
dispatch(setPreviewOpen(false));
|
||||
};
|
||||
}, [dispatch, open]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" size="wide" className="p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description && (
|
||||
<SheetDescription className="truncate" title={description}>
|
||||
{description}
|
||||
</SheetDescription>
|
||||
)}
|
||||
</div>
|
||||
{running && (
|
||||
<Badge variant="info">
|
||||
{t('agents.schedules.status.running')}
|
||||
</Badge>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,14 @@ describe('GuardrailEvents tones', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
// A3: the section lines up with Analytics and the log table on Logs.
|
||||
it('is not indented against the sections around it', () => {
|
||||
const root = document.querySelector<HTMLElement>(
|
||||
'[data-testid="guardrail-events"]',
|
||||
)!;
|
||||
expect(root.className).not.toMatch(/(^|\s)px-/);
|
||||
});
|
||||
|
||||
it('colours outcome badges by meaning', () => {
|
||||
const variants = Array.from(
|
||||
container.querySelectorAll(
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function GuardrailEvents({ agentId }: Props) {
|
||||
const tableHeadingId = React.useId();
|
||||
|
||||
return (
|
||||
<div className="mt-8 px-4" data-testid="guardrail-events">
|
||||
<div className="mt-8" data-testid="guardrail-events">
|
||||
<SectionHeader
|
||||
title={t('agents.guardrailEvents.heading')}
|
||||
description={t('agents.guardrailEvents.description')}
|
||||
|
||||
@@ -179,18 +179,44 @@ describe('GuardrailsSection', () => {
|
||||
expect(active.parentElement).toBe(heading.parentElement);
|
||||
expect(heading.parentElement?.firstElementChild).toBe(heading);
|
||||
|
||||
// A place, not a thing: the section is a subtle panel on the page, and
|
||||
// Card draws the ring for the section-toggle inside it.
|
||||
const panel = q('guardrails-section')!;
|
||||
expect(panel.contains(toggle)).toBe(true);
|
||||
expect(panel.dataset.slot).toBe('card');
|
||||
expect(panel.dataset.variant).toBe('subtle');
|
||||
expect(panel.dataset.padding).toBe('lg');
|
||||
expect(panel.className).toContain(
|
||||
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
|
||||
);
|
||||
expect(panel.className).toContain('bg-card');
|
||||
|
||||
await act(async () => toggle.click());
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
|
||||
});
|
||||
|
||||
// Decision 74: the panel spaces header and body with gap-5 like the other
|
||||
// form sections, and the body stacks its rows with a gap-5 flex column
|
||||
// rather than per-child margins.
|
||||
it('spaces the panel and its body with gap-5, not margins', async () => {
|
||||
await render({ disabled: true });
|
||||
const panel = q('guardrails-section')!;
|
||||
expect(panel.className.split(' ')).toContain('gap-5');
|
||||
expect(panel.className.split(' ')).not.toContain('gap-3');
|
||||
const body = panel.children[1] as HTMLElement;
|
||||
expect(body.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
|
||||
);
|
||||
// Notices, Enable row, mode, checks, block message, fail-open, timeout.
|
||||
expect(body.children.length).toBeGreaterThanOrEqual(9);
|
||||
for (const child of Array.from(body.children)) {
|
||||
expect(child.getAttribute('class') ?? '').not.toMatch(/(^|\s)mt-\d/);
|
||||
}
|
||||
// The checks list keeps its own tighter gap.
|
||||
const checks = q('guardrail-orphan-gone')!.parentElement!;
|
||||
expect(checks.className.split(' ')).toContain('gap-3');
|
||||
});
|
||||
|
||||
it('renders the header pills as badges in their status roles', async () => {
|
||||
await render();
|
||||
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
|
||||
@@ -343,6 +369,7 @@ describe('GuardrailsSection', () => {
|
||||
await render();
|
||||
const card = q('guardrail-check-pii');
|
||||
expect(card?.dataset.slot).toBe('card');
|
||||
expect(card?.dataset.variant).toBe('subtle');
|
||||
expect(card?.dataset.padding).toBe('sm');
|
||||
});
|
||||
|
||||
|
||||
@@ -216,8 +216,10 @@ export default function GuardrailsSection({
|
||||
const instanceDisabled = catalog !== null && catalog.enabled === false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="lg"
|
||||
className="gap-5"
|
||||
data-testid="guardrails-section"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -265,7 +267,7 @@ export default function GuardrailsSection({
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 pb-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
{loadError && (
|
||||
<EmptyState
|
||||
tone="destructive"
|
||||
@@ -286,22 +288,14 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
{disabled && disabledNotice && (
|
||||
<Alert
|
||||
role="status"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-read-only"
|
||||
>
|
||||
<Alert role="status" data-testid="guardrails-read-only">
|
||||
<Lock aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>{disabledNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{instanceDisabled && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
data-testid="guardrails-instance-disabled"
|
||||
>
|
||||
<Alert variant="warning" data-testid="guardrails-instance-disabled">
|
||||
<TriangleAlert aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.instanceDisabled')}
|
||||
@@ -310,7 +304,7 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
{floorControls.size > 0 && (
|
||||
<Alert variant="info" role="status" className="mt-3">
|
||||
<Alert variant="info" role="status">
|
||||
<Info aria-hidden="true" className="size-4" />
|
||||
<AlertDescription>
|
||||
{t('agents.form.guardrails.floorNotice', {
|
||||
@@ -321,7 +315,6 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
className="mt-4"
|
||||
label={t('agents.form.guardrails.enable')}
|
||||
description={t('agents.form.guardrails.enableDescription')}
|
||||
htmlFor={enabledId}
|
||||
@@ -337,45 +330,42 @@ export default function GuardrailsSection({
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<div className="mt-5">
|
||||
<FormField
|
||||
label={t('agents.form.guardrails.mode')}
|
||||
hint={
|
||||
config.mode === 'monitor_only'
|
||||
? t('agents.form.guardrails.monitorHint')
|
||||
: undefined
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.form.guardrails.mode')}
|
||||
hint={
|
||||
config.mode === 'monitor_only'
|
||||
? t('agents.form.guardrails.monitorHint')
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select
|
||||
value={config.mode}
|
||||
onValueChange={(mode) =>
|
||||
patch({ mode: mode as GuardrailsConfig['mode'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select
|
||||
value={config.mode}
|
||||
onValueChange={(mode) =>
|
||||
patch({ mode: mode as GuardrailsConfig['mode'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
size="field"
|
||||
shape="pill"
|
||||
data-testid="guardrails-mode"
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
size="field"
|
||||
shape="pill"
|
||||
data-testid="guardrails-mode"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map(
|
||||
(mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(MODE_KEYS[mode] ?? mode)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map((mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(MODE_KEYS[mode] ?? mode)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-medium">
|
||||
{t('agents.form.guardrails.checks')}
|
||||
</p>
|
||||
@@ -426,7 +416,7 @@ export default function GuardrailsSection({
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
className="mt-6"
|
||||
labelSurface="background"
|
||||
label={t('agents.form.guardrails.blockMessage')}
|
||||
hint={t('agents.form.guardrails.blockMessageDescription')}
|
||||
disabled={disabled}
|
||||
@@ -442,7 +432,6 @@ export default function GuardrailsSection({
|
||||
</FormField>
|
||||
|
||||
<SettingRow
|
||||
className="mt-6"
|
||||
label={t('agents.form.guardrails.failOpen')}
|
||||
description={t('agents.form.guardrails.failOpenDescription')}
|
||||
htmlFor={failOpenId}
|
||||
@@ -457,7 +446,7 @@ export default function GuardrailsSection({
|
||||
</SettingRow>
|
||||
|
||||
<FormField
|
||||
className="mt-5"
|
||||
labelSurface="background"
|
||||
label={t('agents.form.guardrails.timeout')}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -476,7 +465,7 @@ export default function GuardrailsSection({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -597,7 +586,11 @@ function CheckCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding="sm" data-testid={`guardrail-check-${info.name}`}>
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
data-testid={`guardrail-check-${info.name}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SectionHeader as="h4" size="xs" title={info.label} />
|
||||
|
||||
@@ -81,7 +81,13 @@ describe('RunDetailDrawer', () => {
|
||||
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
|
||||
);
|
||||
const pre = content()!.querySelector('pre')!;
|
||||
const card = pre.parentElement!;
|
||||
// The scroll cap sits on an inner scroller inside the Card's padding,
|
||||
// so the scrollbar stays clear of the rounded corners.
|
||||
const scroller = pre.parentElement!;
|
||||
expect(scroller.className.split(' ')).toEqual(
|
||||
expect.arrayContaining(['scrollbar-overlay', 'overflow-y-auto']),
|
||||
);
|
||||
const card = scroller.parentElement!;
|
||||
expect(card.getAttribute('data-slot')).toBe('card');
|
||||
expect(card.getAttribute('data-variant')).toBe('filled');
|
||||
expect(card.className.split(' ')).toContain('bg-muted');
|
||||
|
||||
@@ -29,11 +29,7 @@ export default function RunDetailDrawer({
|
||||
if (!run) return null;
|
||||
return (
|
||||
<Sheet open onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="sm:max-w-xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<SheetContent side="right" size="detail" aria-describedby={undefined}>
|
||||
{/* The sheet owns no padding of its own; the body sets it. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 p-6">
|
||||
<SheetTitle>{t('agents.schedules.runDetails.title')}</SheetTitle>
|
||||
@@ -78,14 +74,12 @@ export default function RunDetailDrawer({
|
||||
: t('agents.schedules.runDetails.error')
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
variant="filled"
|
||||
padding="sm"
|
||||
className="max-h-48 overflow-y-auto"
|
||||
>
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{run.error}
|
||||
</pre>
|
||||
<Card variant="filled" padding="sm">
|
||||
<div className="scrollbar-overlay max-h-48 overflow-y-auto">
|
||||
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
|
||||
{run.error}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const runs = [
|
||||
{
|
||||
id: 'r1',
|
||||
schedule_id: 's1',
|
||||
status: 'success',
|
||||
scheduled_for: '2026-09-21T09:00:00Z',
|
||||
started_at: '2026-09-21T09:00:00Z',
|
||||
finished_at: '2026-09-21T09:00:42Z',
|
||||
trigger_source: 'cron',
|
||||
prompt_tokens: 4000,
|
||||
generated_tokens: 812,
|
||||
},
|
||||
];
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: 't' },
|
||||
schedules: { byAgent: {}, runsBySchedule: { s1: runs } },
|
||||
}),
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import RunLog from './RunLog';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('RunLog', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('opens a run from its whole row, with duration and grouped tokens', async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(<RunLog scheduleId="s1" onSelect={onSelect} />);
|
||||
});
|
||||
const heads = Array.from(container.querySelectorAll('th')).map(
|
||||
(th) => th.textContent,
|
||||
);
|
||||
expect(heads).toContain('agents.schedules.runLog.duration');
|
||||
// The default table head, not the eyebrow.
|
||||
expect(container.querySelector('th')!.className).not.toContain('uppercase');
|
||||
const row = container.querySelector('tbody tr') as HTMLTableRowElement;
|
||||
expect(row.textContent).toContain('42.0 s');
|
||||
expect(row.textContent).toContain('4.8k');
|
||||
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
|
||||
// No separate Details link: the row is the target.
|
||||
expect(row.querySelector('button')).toBeNull();
|
||||
await act(async () => row.click());
|
||||
expect(onSelect).toHaveBeenCalledWith(runs[0]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { EmptyState } from '../../components/ui/empty-state';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,16 +13,16 @@ import {
|
||||
TableRow,
|
||||
} from '../../components/ui/table';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTokens,
|
||||
} from '../../settings/traces/traceUtils';
|
||||
import type { AppDispatch, RootState } from '../../store';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
import type { ScheduleRun } from '../types/schedule';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
|
||||
|
||||
// The column heads are a label row, so they take the eyebrow recipe.
|
||||
const HEADER_CELL =
|
||||
'text-muted-foreground text-xs font-semibold tracking-wider uppercase';
|
||||
|
||||
export type RunLogProps = {
|
||||
scheduleId: string;
|
||||
onSelect?: (run: ScheduleRun) => void;
|
||||
@@ -31,7 +32,18 @@ const formatTimestamp = (value?: string | null): string => {
|
||||
return value ? formatDateTime(value) : '—';
|
||||
};
|
||||
|
||||
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
|
||||
/** How long a run took, or a dash while it hasn't started or finished. */
|
||||
const runDuration = (run: ScheduleRun): string => {
|
||||
if (!run.started_at || !run.finished_at) return '—';
|
||||
return formatDurationMs(
|
||||
Date.parse(run.finished_at) - Date.parse(run.started_at),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A schedule's run log (SSE updates merge via schedulesSlice). Each row opens
|
||||
* the run's details when `onSelect` is given.
|
||||
*/
|
||||
export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
@@ -47,9 +59,11 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-3 text-sm">
|
||||
{t('agents.schedules.runLog.empty')}
|
||||
</p>
|
||||
<EmptyState
|
||||
size="xs"
|
||||
illustration="none"
|
||||
title={t('agents.schedules.runLog.empty')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,24 +71,26 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
<Table minWidth="min-w-0">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runLog.when')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.status')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.tokens')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}>
|
||||
{t('agents.schedules.runDetails.trigger')}
|
||||
</TableHeader>
|
||||
<TableHeader className={HEADER_CELL}></TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runLog.when')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.status')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runLog.duration')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.tokens')}</TableHeader>
|
||||
<TableHeader>{t('agents.schedules.runDetails.trigger')}</TableHeader>
|
||||
{onSelect && (
|
||||
<TableHeader width="40px" align="center">
|
||||
<span className="sr-only">
|
||||
{t('agents.schedules.runLog.details')}
|
||||
</span>
|
||||
</TableHeader>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{runs.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableRow
|
||||
key={run.id}
|
||||
onClick={onSelect ? () => onSelect(run) : undefined}
|
||||
>
|
||||
<TableCell>{formatTimestamp(run.scheduled_for)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -86,25 +102,23 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{run.prompt_tokens + run.generated_tokens}</TableCell>
|
||||
<TableCell className="tabular-nums">{runDuration(run)}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{formatTokens(run.prompt_tokens + run.generated_tokens)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t(`agents.schedules.trigger.${run.trigger_source}`, {
|
||||
defaultValue: run.trigger_source,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{onSelect && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => onSelect(run)}
|
||||
className="-mx-2 -my-1"
|
||||
>
|
||||
{t('agents.schedules.runLog.details')}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
{onSelect && (
|
||||
<TableCell align="center">
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'count' in opts ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
vi.mock('./RunLog', () => ({ default: () => <div data-testid="runlog" /> }));
|
||||
|
||||
import ScheduleRow from './ScheduleRow';
|
||||
import type { Schedule } from '../types/schedule';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const base: Schedule = {
|
||||
id: 's1',
|
||||
user_id: 'u',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Weekly compliance digest',
|
||||
instruction: 'Summarise expiring certificates.',
|
||||
status: 'active',
|
||||
cron: '0 9 * * 1',
|
||||
timezone: 'Europe/Berlin',
|
||||
next_run_at: '2026-09-28T07:00:00Z',
|
||||
last_run_at: '2026-09-21T07:00:00Z',
|
||||
tool_allowlist: [],
|
||||
created_via: 'ui',
|
||||
consecutive_failure_count: 0,
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('ScheduleRow', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const handlers = {
|
||||
onToggleRuns: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onSetPaused: vi.fn(),
|
||||
onRunNow: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSelectRun: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (schedule: Schedule, expanded = false) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ScheduleRow schedule={schedule} expanded={expanded} {...handlers} />,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttons = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('button'));
|
||||
|
||||
it('is a subtle panel with one visible action and a menu', async () => {
|
||||
await render(base);
|
||||
const card = container.querySelector<HTMLElement>('[data-slot="card"]')!;
|
||||
expect(card.dataset.variant).toBe('subtle');
|
||||
const runNow = buttons().find((b) =>
|
||||
b.textContent?.includes('agents.schedules.runNow'),
|
||||
)!;
|
||||
expect(runNow.dataset.variant).toBe('outline');
|
||||
expect(runNow.dataset.size).toBe('sm');
|
||||
expect(runNow.dataset.shape).toBe('pill');
|
||||
// Edit, Pause and Delete live in the menu, not as pills.
|
||||
expect(
|
||||
buttons().some((b) => b.textContent === 'agents.schedules.edit'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('[aria-label="agents.schedules.actions"]'),
|
||||
).not.toBeNull();
|
||||
await act(async () => runNow.click());
|
||||
expect(handlers.onRunNow).toHaveBeenCalledWith(base);
|
||||
});
|
||||
|
||||
it('reads its meta with icons, and shows the instruction under a name', async () => {
|
||||
await render(base);
|
||||
expect(container.textContent).toContain('Summarise expiring certificates.');
|
||||
expect(container.textContent).toContain('Europe/Berlin');
|
||||
expect(container.textContent).toContain('agents.schedules.meta.nextRun');
|
||||
expect(container.textContent).not.toContain('tz:');
|
||||
expect(container.querySelector('svg.lucide-repeat')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers Resume while paused and flags a failure streak', async () => {
|
||||
await render({ ...base, status: 'paused', consecutive_failure_count: 2 });
|
||||
expect(
|
||||
buttons().some((b) => b.textContent?.includes('agents.schedules.resume')),
|
||||
).toBe(true);
|
||||
const streak = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
|
||||
).find((b) => b.textContent?.includes('agents.schedules.failureStreak'))!;
|
||||
expect(streak.dataset.variant).toBe('destructive');
|
||||
expect(streak.textContent).toContain(':2');
|
||||
});
|
||||
|
||||
it('toggles its runs with a chevron and shows them flush in the card', async () => {
|
||||
await render(base, true);
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.schedules.hideRuns"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(container.querySelector('[data-testid="runlog"]')).not.toBeNull();
|
||||
await act(async () => toggle.click());
|
||||
expect(handlers.onToggleRuns).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
|
||||
it('shows a one-time task with its run time and only a menu', async () => {
|
||||
await render({
|
||||
...base,
|
||||
trigger_type: 'once',
|
||||
cron: null,
|
||||
run_at: '2026-10-01T08:00:00Z',
|
||||
});
|
||||
expect(container.textContent).toContain('agents.schedules.meta.runsAt');
|
||||
expect(
|
||||
buttons().some((b) => b.textContent?.includes('agents.schedules.runNow')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="agents.schedules.showRuns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
History,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Repeat,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { ActionMenu, type MenuOption } from '../../components/ui/dropdown-menu';
|
||||
import { IconButton } from '../../components/ui/icon-button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDateTime, formatRelative } from '../../utils/dateTimeUtils';
|
||||
import type { Schedule, ScheduleRun } from '../types/schedule';
|
||||
import RunLog from './RunLog';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { formatCron } from './cronBuilder';
|
||||
|
||||
// Timezones and dates carry slashes: React escapes on render, so i18next
|
||||
// must not escape them first.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
const STATUS_DOT: Record<Schedule['status'], string> = {
|
||||
active: 'bg-success',
|
||||
paused: 'bg-warning',
|
||||
completed: 'bg-muted-foreground',
|
||||
cancelled: 'bg-muted-foreground',
|
||||
};
|
||||
|
||||
export type ScheduleRowProps = {
|
||||
schedule: Schedule;
|
||||
/** Whether the run log is open under the row (recurring schedules). */
|
||||
expanded: boolean;
|
||||
onToggleRuns: (scheduleId: string) => void;
|
||||
onEdit: (schedule: Schedule) => void;
|
||||
onSetPaused: (schedule: Schedule, paused: boolean) => void;
|
||||
onRunNow: (schedule: Schedule) => void;
|
||||
onDelete: (schedule: Schedule) => void;
|
||||
onSelectRun: (run: ScheduleRun) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One schedule on an agent's Schedules page: a subtle panel with a status dot,
|
||||
* the name and status, the instruction, one meta line, the common action as
|
||||
* a button (Run now, or Resume while paused) and the rest in a menu. A
|
||||
* recurring schedule opens its run log flush under the row.
|
||||
*
|
||||
* Args:
|
||||
* schedule: The schedule to show.
|
||||
* expanded: Whether its run log is open.
|
||||
* onToggleRuns: Opens or closes the run log.
|
||||
* onEdit: Opens the schedule form.
|
||||
* onSetPaused: Pauses (true) or resumes (false) a recurring schedule.
|
||||
* onRunNow: Runs the schedule once now.
|
||||
* onDelete: Asks to delete the schedule (or cancel a one-time task).
|
||||
* onSelectRun: Opens a run's details.
|
||||
*/
|
||||
export default function ScheduleRow({
|
||||
schedule,
|
||||
expanded,
|
||||
onToggleRuns,
|
||||
onEdit,
|
||||
onSetPaused,
|
||||
onRunNow,
|
||||
onDelete,
|
||||
onSelectRun,
|
||||
}: ScheduleRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const recurring = schedule.trigger_type === 'recurring';
|
||||
const active = schedule.status === 'active';
|
||||
const paused = schedule.status === 'paused';
|
||||
const title = schedule.name || schedule.instruction.slice(0, 80);
|
||||
|
||||
const edit: MenuOption = {
|
||||
label: t('agents.schedules.edit'),
|
||||
icon: Pencil,
|
||||
onClick: () => onEdit(schedule),
|
||||
};
|
||||
const remove: MenuOption = {
|
||||
label: t('agents.schedules.delete'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => onDelete(schedule),
|
||||
};
|
||||
const menu: MenuOption[] = recurring
|
||||
? active
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.pause'),
|
||||
icon: Pause,
|
||||
onClick: () => onSetPaused(schedule, true),
|
||||
},
|
||||
remove,
|
||||
]
|
||||
: paused
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.runNow'),
|
||||
icon: Play,
|
||||
onClick: () => onRunNow(schedule),
|
||||
},
|
||||
remove,
|
||||
]
|
||||
: [remove]
|
||||
: active
|
||||
? [
|
||||
edit,
|
||||
{
|
||||
label: t('agents.schedules.cancelTask'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => onDelete(schedule),
|
||||
},
|
||||
]
|
||||
: [remove];
|
||||
|
||||
const meta: { icon: typeof Clock; text: string }[] = [];
|
||||
if (recurring) {
|
||||
meta.push({
|
||||
icon: Repeat,
|
||||
text: `${formatCron(schedule.cron, t)}, ${schedule.timezone}`,
|
||||
});
|
||||
if (active && schedule.next_run_at) {
|
||||
meta.push({
|
||||
icon: Clock,
|
||||
text: t('agents.schedules.meta.nextRun', {
|
||||
...NO_ESCAPE,
|
||||
// A past next run (the scheduler is behind) reads as a date, not
|
||||
// "… ago".
|
||||
time:
|
||||
Date.parse(schedule.next_run_at) > Date.now()
|
||||
? formatRelative(schedule.next_run_at, { future: true })
|
||||
: formatDateTime(schedule.next_run_at),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else if (schedule.run_at) {
|
||||
meta.push({
|
||||
icon: Clock,
|
||||
text: t('agents.schedules.meta.runsAt', {
|
||||
...NO_ESCAPE,
|
||||
time: `${formatDateTime(schedule.run_at)}, ${schedule.timezone}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (schedule.last_run_at) {
|
||||
meta.push({
|
||||
icon: History,
|
||||
text: t('agents.schedules.meta.lastRun', {
|
||||
...NO_ESCAPE,
|
||||
time: formatRelative(schedule.last_run_at),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="subtle" padding="none" data-schedule-id={schedule.id}>
|
||||
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-1.5 size-2 shrink-0 rounded-full',
|
||||
STATUS_DOT[schedule.status],
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium wrap-break-word">{title}</span>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
{schedule.consecutive_failure_count > 0 && (
|
||||
<Badge variant="destructive">
|
||||
<TriangleAlert aria-hidden />
|
||||
{t('agents.schedules.failureStreak', {
|
||||
count: schedule.consecutive_failure_count,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{schedule.name && (
|
||||
<p className="text-muted-foreground line-clamp-1 text-sm">
|
||||
{schedule.instruction}
|
||||
</p>
|
||||
)}
|
||||
{meta.length > 0 && (
|
||||
<p className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{meta.map(({ icon: Icon, text }) => (
|
||||
<span key={text} className="inline-flex items-center gap-1">
|
||||
<Icon aria-hidden className="size-3.5 shrink-0" />
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 pl-5 sm:pl-0">
|
||||
{recurring && (active || paused) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
active ? onRunNow(schedule) : onSetPaused(schedule, false)
|
||||
}
|
||||
>
|
||||
<Play />
|
||||
{active
|
||||
? t('agents.schedules.runNow')
|
||||
: t('agents.schedules.resume')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
triggerLabel={t('agents.schedules.actions')}
|
||||
options={menu}
|
||||
/>
|
||||
{recurring && (
|
||||
<IconButton
|
||||
label={
|
||||
expanded
|
||||
? t('agents.schedules.hideRuns')
|
||||
: t('agents.schedules.showRuns')
|
||||
}
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleRuns(schedule.id)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recurring && expanded && (
|
||||
<div className="border-border border-t">
|
||||
<RunLog scheduleId={schedule.id} onSelect={onSelectRun} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'count' in opts ? `${key}:${opts.count}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const schedules = [
|
||||
{
|
||||
id: 's1',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Weekly digest',
|
||||
instruction: 'Summarise.',
|
||||
status: 'active',
|
||||
cron: '0 9 * * 1',
|
||||
timezone: 'Europe/Berlin',
|
||||
next_run_at: '2099-09-28T07:00:00Z',
|
||||
consecutive_failure_count: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
agent_id: 'a1',
|
||||
trigger_type: 'recurring',
|
||||
name: 'Daily queue',
|
||||
instruction: 'Check.',
|
||||
status: 'paused',
|
||||
cron: '30 8 * * 1-5',
|
||||
timezone: 'Europe/Berlin',
|
||||
consecutive_failure_count: 2,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: 't' },
|
||||
schedules: { byAgent: { a1: schedules }, runsBySchedule: {} },
|
||||
}),
|
||||
useDispatch: () => vi.fn(() => ({ unwrap: () => Promise.resolve() })),
|
||||
}));
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgent: () =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ id: 'a1', name: 'Carrier FAQ', tools: [] }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../api/services/schedulesService', () => ({
|
||||
default: {
|
||||
statsForAgent: () =>
|
||||
Promise.resolve({
|
||||
days: 30,
|
||||
runs: 14,
|
||||
failed: 3,
|
||||
tokens: 48230,
|
||||
latest_failure: {
|
||||
scheduled_for: '2026-09-14T09:00:00Z',
|
||||
status: 'timeout',
|
||||
error_type: 'timeout',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('./ScheduleRow', () => ({
|
||||
default: ({ schedule }: { schedule: { id: string } }) => (
|
||||
<div data-testid={`row-${schedule.id}`} />
|
||||
),
|
||||
}));
|
||||
vi.mock('./ScheduleFormModal', () => ({ default: () => null }));
|
||||
vi.mock('./RunDetailDrawer', () => ({ default: () => null }));
|
||||
vi.mock('../../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../../navigation/SectionPageHeader', () => ({
|
||||
CurrentSectionHeader: () => <h1>Schedules</h1>,
|
||||
}));
|
||||
vi.mock('../../navigation/SectionPills', () => ({ default: () => null }));
|
||||
|
||||
import SchedulesView from './SchedulesView';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('SchedulesView', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/manage/schedules/a1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/schedules/:agentId"
|
||||
element={<SchedulesView />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('puts New schedule in the agent toolbar, with no second heading', async () => {
|
||||
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
|
||||
expect(toolbar.textContent).toContain('Carrier FAQ');
|
||||
const create = Array.from(toolbar.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('agents.schedules.newRecurring'),
|
||||
)!;
|
||||
expect(create.dataset.variant).toBe('default');
|
||||
expect(create.dataset.size).toBe('field');
|
||||
expect(create.dataset.shape).toBe('pill');
|
||||
expect(container.querySelectorAll('h1, h2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the stat row as StatCards, failures in the destructive tone', async () => {
|
||||
const tiles = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="card"]'),
|
||||
);
|
||||
expect(tiles).toHaveLength(4);
|
||||
expect(container.textContent).toContain('agents.schedules.stats.active');
|
||||
expect(container.textContent).toContain('14');
|
||||
const failed = tiles.find((c) =>
|
||||
c.textContent?.includes('agents.schedules.stats.failed'),
|
||||
)!;
|
||||
expect(failed.dataset.tone).toBe('destructive');
|
||||
expect(failed.textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('switches Recurring and One-time with underline tabs', async () => {
|
||||
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
|
||||
expect(tabs.map((t) => t.textContent)).toEqual([
|
||||
'agents.schedules.recurring2',
|
||||
'agents.schedules.oneTime0',
|
||||
]);
|
||||
expect(tabs[0].getAttribute('data-variant')).toBe('underline');
|
||||
expect(container.querySelector('[data-testid="row-s1"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="row-s2"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows an empty tab as an EmptyState with New schedule', async () => {
|
||||
const oneTime = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="tab"]'),
|
||||
)[1];
|
||||
await act(async () => {
|
||||
oneTime.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
oneTime.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
oneTime.click();
|
||||
});
|
||||
const empty = container.querySelector('[data-slot="empty-state"]')!;
|
||||
expect(empty.textContent).toContain('agents.schedules.noOneTime');
|
||||
expect(empty.querySelector('button')?.textContent).toContain(
|
||||
'agents.schedules.newRecurring',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,26 +4,38 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import schedulesService from '../../api/services/schedulesService';
|
||||
import StatCard from '../../components/StatCard';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
import type { AppDispatch, RootState } from '../../store';
|
||||
import { formatDateTime } from '../../utils/dateTimeUtils';
|
||||
import { formatTokens } from '../../settings/traces/traceUtils';
|
||||
import {
|
||||
formatDateOnly,
|
||||
formatDateTime,
|
||||
formatRelative,
|
||||
} from '../../utils/dateTimeUtils';
|
||||
import AgentPageToolbar, { LastUsedMeta } from '../components/AgentPageToolbar';
|
||||
import SectionShell from '../../navigation/SectionShell';
|
||||
import type { Agent } from '../types';
|
||||
import type {
|
||||
Schedule,
|
||||
ScheduleCreatePayload,
|
||||
ScheduleRun,
|
||||
ScheduleStats,
|
||||
} from '../types/schedule';
|
||||
import RunDetailDrawer from './RunDetailDrawer';
|
||||
import RunLog from './RunLog';
|
||||
import ScheduleFormModal from './ScheduleFormModal';
|
||||
import ScheduleStatusBadge from './StatusBadge';
|
||||
import { formatCron } from './cronBuilder';
|
||||
import ScheduleRow from './ScheduleRow';
|
||||
import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
@@ -38,9 +50,8 @@ import {
|
||||
// must not escape them first.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
const formatTimestamp = (value?: string | null): string => {
|
||||
return value ? formatDateTime(value) : '—';
|
||||
};
|
||||
// The stat row's window, in days.
|
||||
const STATS_DAYS = 30;
|
||||
|
||||
/** Standalone Schedules page for an agent: list, create, edit, pause, run, delete. */
|
||||
export default function SchedulesView() {
|
||||
@@ -65,6 +76,28 @@ export default function SchedulesView() {
|
||||
const schedules = useSelector((state: RootState) =>
|
||||
selectSchedulesForAgent(state, agentId ?? ''),
|
||||
);
|
||||
// undefined while loading, null when the stats request failed.
|
||||
const [stats, setStats] = useState<ScheduleStats | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Refresh the totals whenever the list changes (a run finished, a
|
||||
// schedule was paused or removed): the SSE feed updates `schedules`.
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
schedulesService
|
||||
.statsForAgent(agentId, token, STATS_DAYS)
|
||||
.then((next) => {
|
||||
if (!cancelled) setStats(next);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStats(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, token, schedules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
@@ -151,218 +184,165 @@ export default function SchedulesView() {
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = schedules.filter((s) => s.status === 'active').length;
|
||||
const pausedCount = schedules.filter((s) => s.status === 'paused').length;
|
||||
const nextRunAt = schedules
|
||||
.filter((s) => s.status === 'active' && s.next_run_at)
|
||||
.map((s) => s.next_run_at as string)
|
||||
.sort()[0];
|
||||
// A next run in the past means the scheduler hasn't picked it up yet.
|
||||
const nextRunOverdue =
|
||||
Boolean(nextRunAt) && Date.parse(nextRunAt as string) < Date.now();
|
||||
const statsLoading = stats === undefined;
|
||||
const failedCount = stats?.failed ?? 0;
|
||||
const latestFailure = stats?.latest_failure;
|
||||
|
||||
const newScheduleButton = (
|
||||
<Button type="button" size="field" shape="pill" onClick={openCreate}>
|
||||
<Plus />
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderList = (list: Schedule[], emptyKey: string) =>
|
||||
list.length === 0 ? (
|
||||
<Card variant="subtle" padding="lg">
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t(emptyKey)}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus />
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{list.map((schedule) => (
|
||||
<li key={schedule.id}>
|
||||
<ScheduleRow
|
||||
schedule={schedule}
|
||||
expanded={expanded === schedule.id}
|
||||
onToggleRuns={(id) => setExpanded(expanded === id ? null : id)}
|
||||
onEdit={openEdit}
|
||||
onSetPaused={(target, paused) =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
id: target.id,
|
||||
action: paused ? 'pause' : 'resume',
|
||||
token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onRunNow={(target) =>
|
||||
dispatch(runScheduleNow({ id: target.id, token }))
|
||||
}
|
||||
onDelete={requestDelete}
|
||||
onSelectRun={(run) => setActiveRun(run)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionShell pills>
|
||||
<div className="flex flex-col gap-3">
|
||||
{agent && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground">{agent.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{agent.last_used_at
|
||||
? t('agents.logs.lastUsedAt') +
|
||||
' ' +
|
||||
formatDateTime(agent.last_used_at)
|
||||
: t('agents.logs.noUsageHistory')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SectionShell>
|
||||
{agent && (
|
||||
<AgentPageToolbar
|
||||
name={agent.name}
|
||||
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
|
||||
actions={newScheduleButton}
|
||||
/>
|
||||
)}
|
||||
{loadingAgent ? (
|
||||
<LoadingState fill="block" />
|
||||
) : (
|
||||
agent && (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<SectionHeader
|
||||
title={t('agents.schedules.heading')}
|
||||
actions={
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
{t('agents.schedules.newRecurring')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={`${t('agents.schedules.recurring')} (${recurring.length})`}
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.active')}
|
||||
value={activeCount}
|
||||
sub={
|
||||
pausedCount > 0
|
||||
? t('agents.schedules.stats.paused', {
|
||||
count: pausedCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{recurring.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.schedules.noRecurring')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{recurring.map((schedule) => (
|
||||
<li
|
||||
key={schedule.id}
|
||||
className="border-border bg-card rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
{schedule.name ||
|
||||
schedule.instruction.slice(0, 80)}
|
||||
</p>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatCron(schedule.cron, t)} ·{' '}
|
||||
{t('agents.schedules.timezoneMeta', {
|
||||
...NO_ESCAPE,
|
||||
timezone: schedule.timezone,
|
||||
})}{' '}
|
||||
·{' '}
|
||||
{t('agents.schedules.nextRunMeta', {
|
||||
...NO_ESCAPE,
|
||||
time: formatTimestamp(schedule.next_run_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setSchedulePaused({
|
||||
id: schedule.id,
|
||||
action:
|
||||
schedule.status === 'active'
|
||||
? 'pause'
|
||||
: 'resume',
|
||||
token,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{schedule.status === 'active'
|
||||
? t('agents.schedules.pause')
|
||||
: t('agents.schedules.resume')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
runScheduleNow({ id: schedule.id, token }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('agents.schedules.runNow')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
>
|
||||
{t('agents.schedules.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
setExpanded(
|
||||
expanded === schedule.id ? null : schedule.id,
|
||||
)
|
||||
}
|
||||
className="mt-0.5 -ml-2"
|
||||
>
|
||||
{expanded === schedule.id
|
||||
? t('agents.schedules.hideRuns')
|
||||
: t('agents.schedules.showRuns')}
|
||||
</Button>
|
||||
{expanded === schedule.id && (
|
||||
<div className="mt-2">
|
||||
<RunLog
|
||||
scheduleId={schedule.id}
|
||||
onSelect={(run) => setActiveRun(run)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="sm"
|
||||
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.nextRun')}
|
||||
value={
|
||||
!nextRunAt
|
||||
? '—'
|
||||
: nextRunOverdue
|
||||
? t('agents.schedules.stats.overdue')
|
||||
: formatRelative(nextRunAt, { future: true })
|
||||
}
|
||||
valueTone={nextRunOverdue ? 'warning' : undefined}
|
||||
sub={nextRunAt ? formatDateTime(nextRunAt) : undefined}
|
||||
/>
|
||||
{oneTime.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.schedules.noOneTime')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{oneTime.map((schedule) => (
|
||||
<li
|
||||
key={schedule.id}
|
||||
className="border-border bg-card rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
{schedule.name ||
|
||||
schedule.instruction.slice(0, 80)}
|
||||
</p>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
runs at {formatTimestamp(schedule.run_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{schedule.status === 'active' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openEdit(schedule)}
|
||||
>
|
||||
{t('agents.schedules.edit')}
|
||||
</Button>
|
||||
)}
|
||||
{schedule.status === 'active' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => requestDelete(schedule)}
|
||||
>
|
||||
{t('agents.schedules.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.runs', { days: STATS_DAYS })}
|
||||
value={stats ? stats.runs : '—'}
|
||||
loading={statsLoading}
|
||||
sub={
|
||||
stats
|
||||
? t('agents.schedules.stats.tokens', {
|
||||
tokens: formatTokens(stats.tokens),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('agents.schedules.stats.failed', {
|
||||
days: STATS_DAYS,
|
||||
})}
|
||||
value={stats ? failedCount : '—'}
|
||||
loading={statsLoading}
|
||||
tone={failedCount > 0 ? 'destructive' : 'default'}
|
||||
valueTone={failedCount > 0 ? 'destructive' : undefined}
|
||||
sub={
|
||||
latestFailure
|
||||
? t('agents.schedules.stats.latestFailure', {
|
||||
...NO_ESCAPE,
|
||||
status: t(
|
||||
`agents.schedules.status.${latestFailure.status}`,
|
||||
),
|
||||
date: formatDateOnly(latestFailure.scheduled_for),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="recurring">
|
||||
<TabsList variant="underline">
|
||||
<TabsTrigger value="recurring" variant="underline">
|
||||
{t('agents.schedules.recurring')}
|
||||
<Badge variant="neutral">{recurring.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="once" variant="underline">
|
||||
{t('agents.schedules.oneTime')}
|
||||
<Badge variant="neutral">{oneTime.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="recurring" className="mt-4">
|
||||
{renderList(recurring, 'agents.schedules.noRecurring')}
|
||||
</TabsContent>
|
||||
<TabsContent value="once" className="mt-4">
|
||||
{renderList(oneTime, 'agents.schedules.noOneTime')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<RunDetailDrawer
|
||||
run={activeRun}
|
||||
onClose={() => setActiveRun(null)}
|
||||
|
||||
@@ -268,3 +268,13 @@ describe('schedulesSlice SSE event handling', () => {
|
||||
expect(stub.trigger_source).toBe('cron');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSchedulesForAgent', () => {
|
||||
it('returns a stable empty list for an agent that has not loaded', () => {
|
||||
// A fresh [] per call re-runs every effect that depends on the list.
|
||||
const state = { schedules: reducer(undefined, { type: '@@init' }) };
|
||||
expect(selectSchedulesForAgent(state, 'missing')).toBe(
|
||||
selectSchedulesForAgent(state, 'missing'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -333,12 +333,17 @@ const schedulesSlice = createSlice({
|
||||
export const { applyEvent, resetSchedules } = schedulesSlice.actions;
|
||||
export default schedulesSlice.reducer;
|
||||
|
||||
// Shared fallbacks: a fresh [] per call would change identity on every render
|
||||
// and re-run any effect that depends on the selected list.
|
||||
const NO_SCHEDULES: Schedule[] = [];
|
||||
const NO_RUNS: ScheduleRun[] = [];
|
||||
|
||||
export const selectSchedulesForAgent = (
|
||||
state: { schedules: SchedulesState },
|
||||
agentId: string,
|
||||
): Schedule[] => state.schedules.byAgent[agentId] ?? [];
|
||||
): Schedule[] => state.schedules.byAgent[agentId] ?? NO_SCHEDULES;
|
||||
|
||||
export const selectRunsForSchedule = (
|
||||
state: { schedules: SchedulesState },
|
||||
scheduleId: string,
|
||||
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? [];
|
||||
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? NO_RUNS;
|
||||
@@ -68,6 +68,19 @@ export type ScheduleRun = {
|
||||
};
|
||||
|
||||
export type ScheduleListResponse = { schedules: Schedule[] };
|
||||
|
||||
/** Run totals for an agent's schedules over the last `days` days. */
|
||||
export type ScheduleStats = {
|
||||
days: number;
|
||||
runs: number;
|
||||
failed: number;
|
||||
tokens: number;
|
||||
latest_failure: {
|
||||
scheduled_for: string;
|
||||
status: ScheduleRunStatus;
|
||||
error_type?: ScheduleRunErrorType | null;
|
||||
} | null;
|
||||
};
|
||||
export type ScheduleResponse = { schedule: Schedule };
|
||||
export type ScheduleRunListResponse = {
|
||||
runs: ScheduleRun[];
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Maximize, Minus, Plus, Redo2, Undo2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Panel, useReactFlow, useStore } from 'reactflow';
|
||||
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface CanvasControlsProps {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
const ZOOM_DURATION_MS = 200;
|
||||
|
||||
/**
|
||||
* The canvas's one control strip, bottom left: undo and redo, then zoom out,
|
||||
* the zoom level, zoom in and fit view. Replaces React Flow's own
|
||||
* `<Controls />`, whose buttons ignore the theme. Render inside `<ReactFlow>`.
|
||||
*/
|
||||
export default function CanvasControls({
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
}: CanvasControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const zoom = useStore((state) => state.transform[2]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
position="bottom-left"
|
||||
className="bg-card border-border flex items-center gap-0.5 rounded-full border p-1"
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
shape="pill"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
label={t('agents.workflow.undo')}
|
||||
hint={t('agents.workflow.undoHint')}
|
||||
icon={Undo2}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
shape="pill"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
label={t('agents.workflow.redo')}
|
||||
hint={t('agents.workflow.redoHint')}
|
||||
icon={Redo2}
|
||||
/>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
shape="pill"
|
||||
onClick={() => zoomOut({ duration: ZOOM_DURATION_MS })}
|
||||
label={t('agents.workflow.zoomOut')}
|
||||
icon={Minus}
|
||||
/>
|
||||
<span className="text-foreground w-11 text-center text-xs tabular-nums">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
shape="pill"
|
||||
onClick={() => zoomIn({ duration: ZOOM_DURATION_MS })}
|
||||
label={t('agents.workflow.zoomIn')}
|
||||
icon={Plus}
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
fitView({ padding: 0.2, maxZoom: 0.8, duration: ZOOM_DURATION_MS })
|
||||
}
|
||||
label={t('agents.workflow.fitView')}
|
||||
icon={Maximize}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import NodePalette from './NodePalette';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('NodePalette', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (onAdd = vi.fn(), onDragStart = vi.fn()) => {
|
||||
act(() => {
|
||||
root.render(<NodePalette onAdd={onAdd} onDragStart={onDragStart} />);
|
||||
});
|
||||
return { onAdd, onDragStart };
|
||||
};
|
||||
|
||||
const pill = (labelKey: string) =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes(labelKey),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
it('renders each node type as a draggable button', () => {
|
||||
render();
|
||||
const buttons = container.querySelectorAll('button');
|
||||
expect(buttons).toHaveLength(6);
|
||||
buttons.forEach((button) => {
|
||||
expect(button.getAttribute('type')).toBe('button');
|
||||
expect(button.getAttribute('draggable')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a node of the pill type on click', () => {
|
||||
const { onAdd } = render();
|
||||
act(() => pill('agents.workflow.nodes.condition').click());
|
||||
expect(onAdd).toHaveBeenCalledWith('condition');
|
||||
act(() => pill('agents.workflow.builder.aiAgent').click());
|
||||
expect(onAdd).toHaveBeenLastCalledWith('agent');
|
||||
});
|
||||
|
||||
it('starts a drag with the pill type', () => {
|
||||
const { onDragStart } = render();
|
||||
const button = pill('agents.workflow.nodes.code');
|
||||
act(() => {
|
||||
button.dispatchEvent(new Event('dragstart', { bubbles: true }));
|
||||
});
|
||||
expect(onDragStart).toHaveBeenCalledWith(expect.anything(), 'code');
|
||||
});
|
||||
|
||||
it('shows the shared tone on the icon square, with no hover swap', () => {
|
||||
render();
|
||||
const square = pill('agents.workflow.nodes.end').querySelector(
|
||||
'span',
|
||||
) as HTMLElement;
|
||||
expect(square.className).toContain('bg-success/10');
|
||||
// Radius by role: a tinted icon square at size-8 is rounded-md.
|
||||
expect(square.className).toContain('rounded-md');
|
||||
expect(container.innerHTML).not.toContain('group-hover:');
|
||||
});
|
||||
|
||||
it('explains click-to-add under the groups', () => {
|
||||
render();
|
||||
expect(container.textContent).toContain(
|
||||
'agents.workflow.builder.paletteHint',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { type DragEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { cn, focusRing } from '@/lib/utils';
|
||||
|
||||
import { NODE_META, nodeToneClass, type WorkflowNodeType } from './nodeTones';
|
||||
|
||||
interface PaletteEntry {
|
||||
type: WorkflowNodeType;
|
||||
group: 'core' | 'logic';
|
||||
hintKey?: string;
|
||||
}
|
||||
|
||||
const PALETTE: PaletteEntry[] = [
|
||||
{ type: 'agent', group: 'core' },
|
||||
{ type: 'end', group: 'core' },
|
||||
{ type: 'note', group: 'core' },
|
||||
{
|
||||
type: 'state',
|
||||
group: 'logic',
|
||||
hintKey: 'agents.workflow.builder.setStateHint',
|
||||
},
|
||||
{
|
||||
type: 'condition',
|
||||
group: 'logic',
|
||||
hintKey: 'agents.workflow.builder.conditionHint',
|
||||
},
|
||||
{ type: 'code', group: 'logic', hintKey: 'agents.workflow.builder.codeHint' },
|
||||
];
|
||||
|
||||
const GROUPS: { group: PaletteEntry['group']; titleKey: string }[] = [
|
||||
{ group: 'core', titleKey: 'agents.workflow.builder.coreNodes' },
|
||||
{ group: 'logic', titleKey: 'agents.workflow.builder.logicNodes' },
|
||||
];
|
||||
|
||||
interface NodePaletteItemProps {
|
||||
entry: PaletteEntry;
|
||||
onAdd: (nodeType: WorkflowNodeType) => void;
|
||||
onDragStart: (e: DragEvent, nodeType: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A palette pill: drag it onto the canvas, or click (or press Enter) to add
|
||||
* the node without dragging.
|
||||
*
|
||||
* Args:
|
||||
* entry: The palette entry to render.
|
||||
* onAdd: Adds a node of the entry's type beside the selection.
|
||||
* onDragStart: Starts dragging a node of the entry's type onto the canvas.
|
||||
*/
|
||||
function NodePaletteItem({ entry, onAdd, onDragStart }: NodePaletteItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { icon: Icon, labelKey } = NODE_META[entry.type];
|
||||
const label = (
|
||||
<span className="text-foreground text-sm font-medium">{t(labelKey)}</span>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, entry.type)}
|
||||
onClick={() => onAdd(entry.type)}
|
||||
className={cn(
|
||||
'border-border bg-card hover:bg-accent flex cursor-grab items-center gap-3 rounded-full border px-4 py-2.5 text-left transition-colors outline-none',
|
||||
focusRing,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
nodeToneClass(entry.type),
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5" aria-hidden="true" />
|
||||
</span>
|
||||
{entry.hintKey ? (
|
||||
<span className="flex flex-col">
|
||||
{label}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(entry.hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface NodePaletteProps {
|
||||
/** Adds a node of the given type without dragging (click or Enter). */
|
||||
onAdd: (nodeType: WorkflowNodeType) => void;
|
||||
/** Starts dragging a node of the given type onto the canvas. */
|
||||
onDragStart: (e: DragEvent, nodeType: string) => void;
|
||||
}
|
||||
|
||||
/** The builder's left rail of node types, grouped Core and Logic. */
|
||||
export default function NodePalette({ onAdd, onDragStart }: NodePaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="border-border bg-background flex w-64 shrink-0 flex-col gap-6 overflow-y-auto border-r p-4">
|
||||
{GROUPS.map(({ group, titleKey }) => (
|
||||
<div key={group} className="flex flex-col gap-3">
|
||||
<SectionHeader as="h3" size="sm" title={t(titleKey)} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{PALETTE.filter((entry) => entry.group === group).map((entry) => (
|
||||
<NodePaletteItem
|
||||
key={entry.type}
|
||||
entry={entry}
|
||||
onAdd={onAdd}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('agents.workflow.builder.paletteHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -103,8 +103,8 @@ describe('WorkflowMiniMap', () => {
|
||||
);
|
||||
expect(tokens(r.busy)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
'hover:bg-secondary',
|
||||
'dark:hover:bg-secondary',
|
||||
'animate-pulse',
|
||||
]),
|
||||
);
|
||||
@@ -125,8 +125,8 @@ describe('WorkflowMiniMap', () => {
|
||||
expect.arrayContaining([
|
||||
'ring-2',
|
||||
'ring-primary',
|
||||
'hover:bg-primary/10',
|
||||
'dark:hover:bg-primary/10',
|
||||
'hover:bg-secondary',
|
||||
'dark:hover:bg-secondary',
|
||||
]),
|
||||
);
|
||||
expect(done).not.toContain('hover:bg-accent');
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Model display names by model id, provided by the builder so the canvas
|
||||
* nodes (rendered by React Flow, out of reach of props) can name a model.
|
||||
*/
|
||||
export const WorkflowModelsContext = createContext<Record<string, string>>({});
|
||||
|
||||
/**
|
||||
* A model's display name.
|
||||
*
|
||||
* Args:
|
||||
* modelId: The model id stored on the node.
|
||||
*
|
||||
* Returns:
|
||||
* The display name, the id itself when the model is unknown, or undefined
|
||||
* when no id is set.
|
||||
*/
|
||||
export function useModelDisplayName(
|
||||
modelId: string | undefined,
|
||||
): string | undefined {
|
||||
const names = useContext(WorkflowModelsContext);
|
||||
if (!modelId) return undefined;
|
||||
return names[modelId] || modelId;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./WorkflowRunArtifacts', () => ({ default: () => null }));
|
||||
|
||||
import { ExecutionDetails, RunArtifactsSection } from './WorkflowPreview';
|
||||
import type { WorkflowNode } from '../types/workflow';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const nodes = [
|
||||
{
|
||||
id: 'n1',
|
||||
type: 'agent',
|
||||
title: 'Summarise',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
},
|
||||
] as unknown as WorkflowNode[];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
nodeId: 'n1',
|
||||
nodeType: 'agent',
|
||||
nodeTitle: 'Summarise',
|
||||
status: 'completed',
|
||||
output: 'Three carriers expire this month.',
|
||||
stateDelta: { region: 'EU', query: 'ignored' },
|
||||
},
|
||||
] as never;
|
||||
|
||||
describe('WorkflowPreview execution sections', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const renderDetails = async (isOpen = true) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ExecutionDetails
|
||||
steps={steps}
|
||||
nodes={nodes}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// The answer's step-row recipe (AnswerFlow): ghost sm at ml-3.5, a muted
|
||||
// icon, muted text, and a chevron that turns while open.
|
||||
it('draws the Execution details toggle as an answer step row', async () => {
|
||||
await renderDetails();
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button')!;
|
||||
expect(toggle.dataset.variant).toBe('ghost');
|
||||
expect(toggle.dataset.size).toBe('sm');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(toggle.className).toContain('ml-3.5');
|
||||
const icons = toggle.querySelectorAll('svg');
|
||||
expect(icons[0].getAttribute('class')).toContain('text-muted-foreground');
|
||||
expect(icons[icons.length - 1].getAttribute('class')).toContain(
|
||||
'rotate-180',
|
||||
);
|
||||
expect(toggle.querySelector('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('draws each step as a subtle panel with its output in a filled well', async () => {
|
||||
await renderDetails();
|
||||
const step = container.querySelector<HTMLElement>(
|
||||
'[data-slot="card"][data-variant="subtle"]',
|
||||
)!;
|
||||
expect(step.dataset.padding).toBe('sm');
|
||||
const well = step.querySelector<HTMLElement>(
|
||||
'[data-slot="card"][data-variant="filled"]',
|
||||
)!;
|
||||
expect(well.dataset.padding).toBe('sm');
|
||||
expect(well.textContent).toContain('Three carriers expire this month.');
|
||||
// No hand-rolled muted boxes: a muted box in a muted box disappears.
|
||||
expect(
|
||||
container.querySelector(
|
||||
'div.bg-muted:not([data-slot="card"]), span.bg-muted',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows state changes as mono neutral badges, without query', async () => {
|
||||
await renderDetails();
|
||||
const badges = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
|
||||
);
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0].dataset.variant).toBe('neutral');
|
||||
expect(badges[0].className).toContain('font-mono');
|
||||
expect(badges[0].textContent).toContain('region');
|
||||
expect(badges[0].textContent).toContain('EU');
|
||||
});
|
||||
|
||||
it('draws the Artifacts toggle as the same step row', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RunArtifactsSection
|
||||
workflowRunId="run-1"
|
||||
isOpen={false}
|
||||
onToggle={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const toggle = container.querySelector<HTMLButtonElement>('button')!;
|
||||
expect(toggle.dataset.variant).toBe('ghost');
|
||||
expect(toggle.dataset.size).toBe('sm');
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(toggle.className).toContain('ml-3.5');
|
||||
expect(toggle.querySelector('p')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Flag,
|
||||
GitBranch,
|
||||
type LucideIcon,
|
||||
MessageSquare,
|
||||
Play,
|
||||
StickyNote,
|
||||
Workflow,
|
||||
@@ -21,9 +20,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { SheetTitle } from '@/components/ui/sheet';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
MessageScroller,
|
||||
@@ -50,7 +51,6 @@ import {
|
||||
resendQuery,
|
||||
resetWorkflowPreview,
|
||||
selectActiveNodeId,
|
||||
setPreviewOpen,
|
||||
selectWorkflowExecutionSteps,
|
||||
selectWorkflowPreviewQueries,
|
||||
selectWorkflowPreviewStatus,
|
||||
@@ -94,7 +94,64 @@ const NODE_COLORS: Record<string, string> = {
|
||||
code: 'text-info',
|
||||
};
|
||||
|
||||
function ExecutionDetails({
|
||||
/**
|
||||
* A step row under an answer, like Reasoning in chat (AnswerFlow): a ghost
|
||||
* button whose muted icon sits on the answer's ml-6 column, then the panel it
|
||||
* opens in place on that column.
|
||||
*/
|
||||
function StepDisclosure({
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
count?: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="my-2 flex w-full flex-col">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 puts the icon on the
|
||||
// answer's ml-6 text column.
|
||||
className="ml-3.5 w-fit max-w-full justify-start"
|
||||
>
|
||||
<Icon className="text-muted-foreground" aria-hidden />
|
||||
<span className="text-muted-foreground min-w-0 truncate">{label}</span>
|
||||
{count && (
|
||||
<span className="text-muted-foreground/70 font-normal">{count}</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'text-muted-foreground shrink-0 transition-transform duration-200',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-5 ml-6 grid transition-[grid-template-rows,opacity] duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionDetails({
|
||||
steps,
|
||||
nodes,
|
||||
isOpen,
|
||||
@@ -122,153 +179,121 @@ function ExecutionDetails({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<Workflow className="text-muted-foreground size-5" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="-ml-2.5"
|
||||
>
|
||||
<p className="text-base font-semibold">
|
||||
{t('agents.workflow.preview.executionDetails')}
|
||||
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
|
||||
{t('agents.workflow.preview.stepCount', {
|
||||
count: completedSteps.length,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex flex-col gap-2 pr-2">
|
||||
{completedSteps.map((step, stepIndex) => {
|
||||
const node = nodes.find((n) => n.id === step.nodeId);
|
||||
const displayName =
|
||||
node?.title || node?.data?.title || step.nodeTitle;
|
||||
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
|
||||
const stateVars = step.stateDelta
|
||||
? Object.entries(step.stateDelta).filter(
|
||||
([key]) => !['query', 'chat_history'].includes(key),
|
||||
)
|
||||
: [];
|
||||
<StepDisclosure
|
||||
icon={Workflow}
|
||||
label={t('agents.workflow.preview.executionDetails')}
|
||||
count={t('agents.workflow.preview.stepCount', {
|
||||
count: completedSteps.length,
|
||||
})}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{completedSteps.map((step, stepIndex) => {
|
||||
const node = nodes.find((n) => n.id === step.nodeId);
|
||||
const displayName =
|
||||
node?.title || node?.data?.title || step.nodeTitle;
|
||||
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
|
||||
const stateVars = step.stateDelta
|
||||
? Object.entries(step.stateDelta).filter(
|
||||
([key]) => !['query', 'chat_history'].includes(key),
|
||||
)
|
||||
: [];
|
||||
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
};
|
||||
const hasOutput =
|
||||
step.output !== undefined &&
|
||||
step.output !== null &&
|
||||
formatValue(step.output) !== '';
|
||||
const formattedOutput = hasOutput ? formatValue(step.output) : '';
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + '...';
|
||||
};
|
||||
const hasOutput =
|
||||
step.output !== undefined &&
|
||||
step.output !== null &&
|
||||
formatValue(step.output) !== '';
|
||||
const formattedOutput = hasOutput ? formatValue(step.output) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.nodeId}
|
||||
ref={(el) => {
|
||||
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
|
||||
}}
|
||||
className="bg-muted rounded-xl p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
|
||||
{stepIndex + 1}.
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
|
||||
)}
|
||||
>
|
||||
<StepIcon className="size-3" />
|
||||
</div>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
<div className="ml-auto shrink-0">
|
||||
{step.status === 'completed' && (
|
||||
<CircleCheck className="text-success size-4" />
|
||||
)}
|
||||
{step.status === 'failed' && (
|
||||
<CircleX className="text-destructive size-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(hasOutput || step.error || stateVars.length > 0) && (
|
||||
<div className="mt-3 flex flex-col gap-2 text-sm">
|
||||
{hasOutput && (
|
||||
<div className="bg-muted rounded-lg p-2">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t('agents.workflow.preview.outputLabel')}{' '}
|
||||
</span>
|
||||
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
|
||||
{truncateText(formattedOutput, 300)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{step.error && (
|
||||
<Alert variant="destructive" role="status">
|
||||
<CircleAlert />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">
|
||||
{t('agents.workflow.preview.errorLabel')}{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap">
|
||||
{step.error}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{stateVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stateVars.map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
|
||||
{key}:
|
||||
</span>
|
||||
<span
|
||||
className="text-foreground ml-1 max-w-[200px] truncate"
|
||||
title={formatValue(value)}
|
||||
>
|
||||
{truncateText(formatValue(value), 50)}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
// A step is a place you read inside: a subtle panel on the
|
||||
// drawer's background, with its output in a filled well.
|
||||
return (
|
||||
<Card
|
||||
key={step.nodeId}
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
|
||||
}}
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
|
||||
{stepIndex + 1}.
|
||||
</span>
|
||||
<StepIcon
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3 shrink-0',
|
||||
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
|
||||
)}
|
||||
/>
|
||||
<span className="text-foreground min-w-0 truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
<div className="ml-auto shrink-0">
|
||||
{step.status === 'completed' && (
|
||||
<CircleCheck className="text-success size-4" />
|
||||
)}
|
||||
{step.status === 'failed' && (
|
||||
<CircleX className="text-destructive size-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasOutput && (
|
||||
<Card variant="filled" padding="sm">
|
||||
<p className="wrap-break-word whitespace-pre-wrap">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{t('agents.workflow.preview.outputLabel')}{' '}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{truncateText(formattedOutput, 300)}
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{step.error && (
|
||||
<Alert variant="destructive" role="status">
|
||||
<CircleAlert />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">
|
||||
{t('agents.workflow.preview.errorLabel')}{' '}
|
||||
</span>
|
||||
<span className="wrap-break-word whitespace-pre-wrap">
|
||||
{step.error}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{stateVars.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stateVars.map(([key, value]) => (
|
||||
// eslint-disable-next-line shadcn/no-restyle -- state keys and values are serialised by the app, so the chip is set in mono
|
||||
<Badge key={key} variant="neutral" className="font-mono">
|
||||
<span className="max-w-[100px] truncate">{key}:</span>
|
||||
<span
|
||||
className="text-foreground max-w-[200px] truncate"
|
||||
title={formatValue(value)}
|
||||
>
|
||||
{truncateText(formatValue(value), 50)}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</StepDisclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function RunArtifactsSection({
|
||||
export function RunArtifactsSection({
|
||||
workflowRunId,
|
||||
isOpen,
|
||||
onToggle,
|
||||
@@ -281,47 +306,21 @@ function RunArtifactsSection({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
|
||||
<div className="my-2 flex flex-row items-center justify-center gap-3">
|
||||
<div className="flex h-[26px] w-[30px] items-center justify-center">
|
||||
<FileBox className="text-muted-foreground size-5" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className="-ml-2.5"
|
||||
>
|
||||
<p className="text-base font-semibold">
|
||||
{t('agents.workflow.preview.artifacts')}
|
||||
</p>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
<StepDisclosure
|
||||
icon={FileBox}
|
||||
label={t('agents.workflow.preview.artifacts')}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="max-h-[480px] overflow-y-auto pt-1">
|
||||
{isOpen && (
|
||||
<WorkflowRunArtifacts
|
||||
workflowRunId={workflowRunId}
|
||||
inProgress={runInProgress}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="max-h-[480px] overflow-y-auto pr-2">
|
||||
{isOpen && (
|
||||
<WorkflowRunArtifacts
|
||||
workflowRunId={workflowRunId}
|
||||
inProgress={runInProgress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StepDisclosure>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,11 +408,11 @@ export function WorkflowMiniMap({
|
||||
className={cn(
|
||||
'h-12 w-full justify-start disabled:opacity-100',
|
||||
isActive
|
||||
? 'bg-primary/10 ring-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10 ring-2'
|
||||
? 'bg-secondary ring-primary hover:bg-secondary dark:bg-secondary dark:hover:bg-secondary ring-2'
|
||||
: status === 'completed'
|
||||
? 'border-success/50 bg-success/10 hover:bg-success/10 dark:border-success/50 dark:bg-success/10 dark:hover:bg-success/10'
|
||||
: status === 'running'
|
||||
? 'border-primary/50 bg-primary/10 hover:bg-primary/10 dark:border-primary/50 dark:bg-primary/10 dark:hover:bg-primary/10 animate-pulse'
|
||||
? 'border-primary/50 bg-secondary hover:bg-secondary dark:border-primary/50 dark:bg-secondary dark:hover:bg-secondary animate-pulse'
|
||||
: status === 'failed'
|
||||
? 'border-destructive/50 bg-destructive/10 hover:bg-destructive/10 dark:border-destructive/50 dark:bg-destructive/10 dark:hover:bg-destructive/10'
|
||||
: 'border-border bg-muted hover:bg-muted dark:border-border dark:bg-muted dark:hover:bg-muted',
|
||||
@@ -596,12 +595,10 @@ export default function WorkflowPreview({
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(true));
|
||||
return () => {
|
||||
if (fetchStream.current) fetchStream.current.abort();
|
||||
handleWorkflowPreviewAbort();
|
||||
dispatch(resetWorkflowPreview());
|
||||
dispatch(setPreviewOpen(false));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -616,28 +613,7 @@ export default function WorkflowPreview({
|
||||
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
|
||||
|
||||
return (
|
||||
<div className="bg-card flex h-full flex-col">
|
||||
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
|
||||
<Play className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
|
||||
<p className="text-muted-foreground max-w-md truncate text-xs">
|
||||
{workflowData.name}
|
||||
{workflowData.description && ` - ${workflowData.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status === 'loading' && (
|
||||
<span className="text-primary flex items-center gap-1 text-xs">
|
||||
<Spinner size="xs" />
|
||||
{t('agents.schedules.status.running')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="border-border flex w-64 shrink-0 flex-col border-r">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
@@ -662,13 +638,12 @@ export default function WorkflowPreview({
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{queries.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4">
|
||||
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
|
||||
<MessageSquare className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-foreground text-xl font-semibold">
|
||||
{t('agents.workflow.preview.emptyTitle')}
|
||||
</p>
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t('agents.workflow.preview.emptyTitle')}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MessageScrollerProvider autoScroll>
|
||||
@@ -788,11 +763,12 @@ export default function WorkflowPreview({
|
||||
</MessageScrollerProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
|
||||
<div className="flex w-full flex-col gap-2 px-4 pt-2 pb-4">
|
||||
{sendBlockedMessage && (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{t(sendBlockedMessage)}
|
||||
</p>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertDescription>{t(sendBlockedMessage)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<MessageInput
|
||||
onSubmit={(text) => handleQuestionSubmission(text)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function NodeDocumentsControl({
|
||||
return (
|
||||
<FormField label={label} hint={helpText} id={fieldId} float={false}>
|
||||
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
|
||||
<div className="border-border bg-card rounded-xl border p-1">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<ToggleGroup
|
||||
id={fieldId}
|
||||
type="single"
|
||||
@@ -121,7 +121,7 @@ export default function NodeDocumentsControl({
|
||||
onClick={addRef}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<Plus />
|
||||
{t('agents.form.buttons.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -337,6 +337,8 @@ interface PromptTextAreaProps {
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
/** Surface behind the field, for the floating label's notch. */
|
||||
labelSurface?: 'card' | 'background' | 'muted';
|
||||
}
|
||||
|
||||
export default function PromptTextArea({
|
||||
@@ -348,6 +350,7 @@ export default function PromptTextArea({
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
labelSurface = 'card',
|
||||
}: PromptTextAreaProps) {
|
||||
const { t } = useTranslation();
|
||||
const textareaId = useId();
|
||||
@@ -442,7 +445,7 @@ export default function PromptTextArea({
|
||||
// so typing keeps filtering it.
|
||||
const field = (
|
||||
<PopoverAnchor asChild>
|
||||
<div className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-shadow focus-within:ring-2">
|
||||
<div className="border-border focus-within:border-ring focus-within:ring-ring/50 relative rounded-xl border transition-shadow focus-within:ring-3">
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
@@ -525,7 +528,7 @@ export default function PromptTextArea({
|
||||
}}
|
||||
>
|
||||
{label ? (
|
||||
<FormField label={label} id={textareaId}>
|
||||
<FormField label={label} id={textareaId} labelSurface={labelSurface}>
|
||||
{field}
|
||||
</FormField>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import WorkflowDetailsSheet from './WorkflowDetailsSheet';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const details = {
|
||||
name: 'Helpdesk Triage',
|
||||
description: 'Routes internal requests',
|
||||
allowPromptOverride: false,
|
||||
};
|
||||
|
||||
describe('WorkflowDetailsSheet', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = (props: { errors?: string[]; saving?: boolean } = {}) => {
|
||||
const handlers = { onOpenChange: vi.fn(), onSave: vi.fn() };
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkflowDetailsSheet
|
||||
open
|
||||
details={details}
|
||||
currentImage=""
|
||||
saving={props.saving ?? false}
|
||||
errors={props.errors ?? []}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return handlers;
|
||||
};
|
||||
|
||||
const button = (name: string) =>
|
||||
Array.from(document.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === name,
|
||||
) as HTMLButtonElement;
|
||||
|
||||
const nameInput = () =>
|
||||
document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
|
||||
const type = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
act(() => {
|
||||
setter?.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
it('opens with the workflow details filled in', () => {
|
||||
render();
|
||||
expect(document.body.textContent).toContain(
|
||||
'agents.workflow.builder.detailsTitle',
|
||||
);
|
||||
expect(nameInput().value).toBe('Helpdesk Triage');
|
||||
expect(document.querySelector('textarea')?.value).toBe(
|
||||
'Routes internal requests',
|
||||
);
|
||||
expect(document.querySelector('[role="switch"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps Save disabled until something changes, then saves the edits', () => {
|
||||
const { onSave } = render();
|
||||
const save = button('agents.form.buttons.save');
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
type(nameInput(), 'Helpdesk Router');
|
||||
expect(save.disabled).toBe(false);
|
||||
|
||||
act(() => save.click());
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
name: 'Helpdesk Router',
|
||||
description: 'Routes internal requests',
|
||||
allowPromptOverride: false,
|
||||
imageFile: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('closes on Cancel without saving', () => {
|
||||
const { onOpenChange, onSave } = render();
|
||||
type(nameInput(), 'Something else');
|
||||
act(() => button('agents.form.buttons.cancel').click());
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists save errors in an alert inside the sheet', () => {
|
||||
render({ errors: ['Workflow must have an end node'] });
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('agents.workflow.builder.unableSave');
|
||||
expect(alert?.textContent).toContain('Workflow must have an end node');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CircleX } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SettingRow } from '@/components/ui/setting-row';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { FileUpload } from '../../../components/FileUpload';
|
||||
|
||||
export interface WorkflowDetailsValues {
|
||||
name: string;
|
||||
description: string;
|
||||
allowPromptOverride: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowDetailsSave extends WorkflowDetailsValues {
|
||||
/** A newly picked avatar, or null to keep the current one. */
|
||||
imageFile: File | null;
|
||||
}
|
||||
|
||||
interface WorkflowDetailsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** The builder's current values; the sheet edits a copy of them. */
|
||||
details: WorkflowDetailsValues;
|
||||
/** The saved avatar's URL, shown in the tile until a new one is picked. */
|
||||
currentImage: string;
|
||||
saving: boolean;
|
||||
/** Errors from the last save started here, shown at the top of the body. */
|
||||
errors: string[];
|
||||
onSave: (values: WorkflowDetailsSave) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workflow's name, description, avatar and prompt-override setting, in a
|
||||
* right drawer laid out like the classic agent's Basics on a phone.
|
||||
*
|
||||
* The sheet edits a copy: Cancel, the X, Escape and the scrim drop the edits,
|
||||
* and Save hands them to the builder, which saves the workflow.
|
||||
*
|
||||
* Args:
|
||||
* open: Whether the sheet is open.
|
||||
* onOpenChange: Opens or closes the sheet.
|
||||
* details: The values to start from each time it opens.
|
||||
* currentImage: The saved avatar's URL.
|
||||
* saving: Whether a save started here is running.
|
||||
* errors: Errors from that save.
|
||||
* onSave: Receives the edited values.
|
||||
*/
|
||||
export default function WorkflowDetailsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
details,
|
||||
currentImage,
|
||||
saving,
|
||||
errors,
|
||||
onSave,
|
||||
}: WorkflowDetailsSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<WorkflowDetailsValues>(details);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
|
||||
// Start from the builder's values every time the sheet opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(details);
|
||||
setImageFile(null);
|
||||
// Only on open: later changes to `details` come from this sheet's own save.
|
||||
}, [open]);
|
||||
|
||||
const dirty =
|
||||
imageFile !== null ||
|
||||
draft.name !== details.name ||
|
||||
draft.description !== details.description ||
|
||||
draft.allowPromptOverride !== details.allowPromptOverride;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
size="default"
|
||||
className="p-0"
|
||||
closeLabel={t('agents.close')}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
|
||||
<div className="flex flex-col gap-1 px-6 pt-6 pr-12 pb-4">
|
||||
<SheetTitle>{t('agents.workflow.builder.detailsTitle')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('agents.workflow.builder.detailsDescription')}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-6">
|
||||
{errors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<CircleX />
|
||||
<AlertTitle>
|
||||
{t('agents.workflow.builder.unableSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="flex list-inside list-disc flex-col gap-1 wrap-break-word">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Card variant="subtle" padding="lg" className="gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.form.sections.basics')}
|
||||
/>
|
||||
{/* The phone layout of classic Basics: the avatar beside Name,
|
||||
Description across the row. */}
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-5">
|
||||
<FileUpload
|
||||
showPreview
|
||||
size="tile"
|
||||
tileSize="fixed"
|
||||
currentImage={currentImage || undefined}
|
||||
onUpload={(files) => setImageFile(files[0] ?? null)}
|
||||
onRemove={() => setImageFile(null)}
|
||||
uploadText={t('agents.form.labels.avatar')}
|
||||
/>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.form.labels.name')}
|
||||
>
|
||||
<Input
|
||||
shape="pill"
|
||||
type="text"
|
||||
value={draft.name}
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.workflowNamePlaceholder',
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.form.labels.description')}
|
||||
className="col-span-2"
|
||||
>
|
||||
<Textarea
|
||||
size="lg"
|
||||
className="h-32"
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.workflowDescriptionPlaceholder',
|
||||
)}
|
||||
value={draft.description}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="subtle" padding="lg" className="gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.form.sections.advanced')}
|
||||
/>
|
||||
<SettingRow
|
||||
label={t('agents.form.advanced.systemPromptOverride')}
|
||||
description={t(
|
||||
'agents.form.advanced.systemPromptOverrideDescription',
|
||||
)}
|
||||
htmlFor="workflow-system-prompt-override"
|
||||
>
|
||||
<Switch
|
||||
id="workflow-system-prompt-override"
|
||||
checked={draft.allowPromptOverride}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft({ ...draft, allowPromptOverride: checked })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Card>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
disabled={!dirty}
|
||||
loading={saving}
|
||||
onClick={() => onSave({ ...draft, imageFile })}
|
||||
>
|
||||
{t('agents.form.buttons.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { NODE_TONES, nodeToneClass, TONE_CLASSES } from './nodeTones';
|
||||
|
||||
describe('nodeTones', () => {
|
||||
it('maps every node type to one tone', () => {
|
||||
expect(NODE_TONES).toEqual({
|
||||
start: 'success',
|
||||
agent: 'primary',
|
||||
end: 'success',
|
||||
note: 'warning',
|
||||
state: 'info',
|
||||
condition: 'warning',
|
||||
code: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('never paints End red', () => {
|
||||
expect(nodeToneClass('end')).not.toMatch(/destructive/);
|
||||
expect(nodeToneClass('end')).toBe('bg-success/10 text-success');
|
||||
});
|
||||
|
||||
it('uses the brand soft fill for primary, not bg-primary/10', () => {
|
||||
expect(TONE_CLASSES.primary).toBe('bg-secondary text-secondary-foreground');
|
||||
expect(nodeToneClass('agent')).toBe(TONE_CLASSES.primary);
|
||||
});
|
||||
|
||||
it('gives status tones their /10 fill and text', () => {
|
||||
expect(TONE_CLASSES.info).toBe('bg-info/10 text-info');
|
||||
expect(TONE_CLASSES.warning).toBe('bg-warning/10 text-warning');
|
||||
});
|
||||
|
||||
it('falls back to a neutral tint for an unknown type', () => {
|
||||
expect(nodeToneClass('mystery')).toBe(
|
||||
'bg-muted-foreground/15 text-muted-foreground',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Bot,
|
||||
CodeXml,
|
||||
Database,
|
||||
Flag,
|
||||
GitBranch,
|
||||
Play,
|
||||
StickyNote,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** The node types the builder draws. */
|
||||
export type WorkflowNodeType =
|
||||
'start' | 'agent' | 'end' | 'note' | 'state' | 'condition' | 'code';
|
||||
|
||||
/** A node's colour role: the brand tint or one status tint. */
|
||||
export type NodeTone = 'primary' | 'success' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* One colour per node type, read by the palette, every canvas node and the
|
||||
* settings panel's header. Start and End share `success` as the two ends of
|
||||
* the flow; `destructive` is kept for errors, so End is never red.
|
||||
*/
|
||||
export const NODE_TONES: Record<WorkflowNodeType, NodeTone> = {
|
||||
start: 'success',
|
||||
agent: 'primary',
|
||||
end: 'success',
|
||||
note: 'warning',
|
||||
state: 'info',
|
||||
condition: 'warning',
|
||||
code: 'info',
|
||||
};
|
||||
|
||||
// Whole class strings per tone so Tailwind sees them. Brand is the soft
|
||||
// `secondary` fill (never bg-primary/10); status tones are the /10 fill.
|
||||
export const TONE_CLASSES: Record<NodeTone, string> = {
|
||||
primary: 'bg-secondary text-secondary-foreground',
|
||||
success: 'bg-success/10 text-success',
|
||||
warning: 'bg-warning/10 text-warning',
|
||||
info: 'bg-info/10 text-info',
|
||||
};
|
||||
|
||||
const NEUTRAL_TONE_CLASS = 'bg-muted-foreground/15 text-muted-foreground';
|
||||
|
||||
/**
|
||||
* The icon-square classes for a node type.
|
||||
*
|
||||
* Args:
|
||||
* type: The node's type.
|
||||
*
|
||||
* Returns:
|
||||
* The tone's fill and text classes, or a neutral tint for an unknown type.
|
||||
*/
|
||||
export function nodeToneClass(type: string | undefined): string {
|
||||
const tone = NODE_TONES[type as WorkflowNodeType];
|
||||
return tone ? TONE_CLASSES[tone] : NEUTRAL_TONE_CLASS;
|
||||
}
|
||||
|
||||
/** Each node type's icon and its name's locale key. */
|
||||
export const NODE_META: Record<
|
||||
WorkflowNodeType,
|
||||
{ icon: LucideIcon; labelKey: string }
|
||||
> = {
|
||||
start: { icon: Play, labelKey: 'agents.workflow.nodes.start' },
|
||||
agent: { icon: Bot, labelKey: 'agents.workflow.builder.aiAgent' },
|
||||
end: { icon: Flag, labelKey: 'agents.workflow.nodes.end' },
|
||||
note: { icon: StickyNote, labelKey: 'agents.workflow.nodes.note' },
|
||||
state: { icon: Database, labelKey: 'agents.workflow.nodes.setState' },
|
||||
condition: { icon: GitBranch, labelKey: 'agents.workflow.nodes.condition' },
|
||||
code: { icon: CodeXml, labelKey: 'agents.workflow.nodes.code' },
|
||||
};
|
||||
@@ -3,11 +3,14 @@ import { Handle, Position } from 'reactflow';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { nodeToneClass, type WorkflowNodeType } from '../nodeTones';
|
||||
|
||||
interface BaseNodeProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
selected?: boolean;
|
||||
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
|
||||
/** The node type; picks the icon square's tone from the shared map. */
|
||||
type: WorkflowNodeType;
|
||||
icon?: ReactNode;
|
||||
handles?: {
|
||||
source?: boolean;
|
||||
@@ -15,47 +18,25 @@ interface BaseNodeProps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The pill every canvas node but Note and If / Else is drawn as: a tinted
|
||||
* icon, the title, a muted meta block and the handles. Selected is the focus
|
||||
* ring look (`border-primary ring-3 ring-ring/50`), with no scale, so the
|
||||
* node stays put under the pointer.
|
||||
*/
|
||||
export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
title,
|
||||
children,
|
||||
selected,
|
||||
type = 'default',
|
||||
type,
|
||||
icon,
|
||||
handles = { source: true, target: true },
|
||||
}) => {
|
||||
let bgColor = 'bg-card';
|
||||
let borderColor = 'border-border';
|
||||
let iconBg = 'bg-muted';
|
||||
let iconColor = 'text-muted-foreground';
|
||||
|
||||
if (selected) {
|
||||
borderColor = 'border-primary ring-2 ring-primary';
|
||||
}
|
||||
|
||||
if (type === 'start') {
|
||||
iconBg = 'bg-success/10';
|
||||
iconColor = 'text-success';
|
||||
} else if (type === 'end') {
|
||||
iconBg = 'bg-destructive/10';
|
||||
iconColor = 'text-destructive';
|
||||
} else if (type === 'state') {
|
||||
iconBg = 'bg-muted';
|
||||
iconColor = 'text-muted-foreground';
|
||||
} else if (type === 'condition') {
|
||||
iconBg = 'bg-warning/10';
|
||||
iconColor = 'text-warning';
|
||||
} else if (type === 'code') {
|
||||
iconBg = 'bg-info/10';
|
||||
iconColor = 'text-info';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full border shadow-md transition hover:shadow-lg',
|
||||
bgColor,
|
||||
borderColor,
|
||||
selected && 'scale-105',
|
||||
'bg-card rounded-full border shadow-md transition hover:shadow-lg',
|
||||
selected ? 'border-primary ring-ring/50 ring-3' : 'border-border',
|
||||
'max-w-[250px] min-w-[180px]',
|
||||
)}
|
||||
>
|
||||
@@ -72,8 +53,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
iconBg,
|
||||
iconColor,
|
||||
nodeToneClass(type),
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -5,9 +5,7 @@ import { NodeProps } from 'reactflow';
|
||||
|
||||
import { CodeNodeConfig } from '../../types/workflow';
|
||||
import { BaseNode } from './BaseNode';
|
||||
|
||||
// Variable names are the user's own text: React escapes on render.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
import OutputVariableLine from './OutputVariableLine';
|
||||
|
||||
type CodeNodeData = {
|
||||
title?: string;
|
||||
@@ -46,18 +44,7 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
|
||||
</div>
|
||||
)}
|
||||
{config.output_variable && (
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={t('agents.workflow.nodes.output', {
|
||||
...NO_ESCAPE,
|
||||
variable: config.output_variable,
|
||||
})}
|
||||
>
|
||||
{t('agents.workflow.nodes.output', {
|
||||
...NO_ESCAPE,
|
||||
variable: config.output_variable,
|
||||
})}
|
||||
</div>
|
||||
<OutputVariableLine variable={config.output_variable} />
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { ConditionCase } from '../../types/workflow';
|
||||
import { nodeToneClass } from '../nodeTones';
|
||||
|
||||
type ConditionNodeData = {
|
||||
label?: string;
|
||||
@@ -44,7 +45,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
className={cn(
|
||||
'bg-card relative rounded-2xl border shadow-md transition',
|
||||
selected
|
||||
? 'border-primary ring-primary scale-105 ring-2'
|
||||
? 'border-primary ring-ring/50 ring-3'
|
||||
: 'border-border hover:shadow-lg',
|
||||
)}
|
||||
style={{ minWidth: 180, maxWidth: 220, height }}
|
||||
@@ -57,7 +58,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full',
|
||||
nodeToneClass('condition'),
|
||||
)}
|
||||
>
|
||||
<GitBranch className="size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pr-2">
|
||||
@@ -69,8 +75,8 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{data.config?.mode === 'advanced'
|
||||
? t('agents.workflow.nodes.modeAdvanced')
|
||||
: t('agents.workflow.nodes.modeSimple')}
|
||||
? t('agents.workflow.builder.modeAdvanced')
|
||||
: t('agents.workflow.builder.modeSimple')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Variable names are the user's own text: React escapes on render.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
/**
|
||||
* A node's output variable on the canvas: an arrow, then the name in mono,
|
||||
* because it is what the next node reads.
|
||||
*
|
||||
* Args:
|
||||
* variable: The output variable's name.
|
||||
*/
|
||||
export default function OutputVariableLine({ variable }: { variable: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs"
|
||||
title={t('agents.workflow.nodes.output', { ...NO_ESCAPE, variable })}
|
||||
>
|
||||
<ArrowRight className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate font-mono">{variable}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { nodeToneClass } from '../nodeTones';
|
||||
import { useModelDisplayName } from '../WorkflowModelsContext';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import OutputVariableLine from './OutputVariableLine';
|
||||
import CodeNode from './CodeNode';
|
||||
import ConditionNode from './ConditionNode';
|
||||
import SetStateNode from './SetStateNode';
|
||||
|
||||
// Variable names are the user's own text: React escapes on render.
|
||||
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
export const StartNode = memo(function StartNode({
|
||||
selected,
|
||||
}: {
|
||||
@@ -73,6 +73,12 @@ export const AgentNode = memo(function AgentNode({
|
||||
const { t } = useTranslation();
|
||||
const title = data.title || data.label || t('agents.workflow.nodes.agent');
|
||||
const config = data.config || {};
|
||||
const modelName = useModelDisplayName(config.model_id);
|
||||
const agentType =
|
||||
config.agent_type === 'research' || config.agent_type === 'classic'
|
||||
? t(`agents.form.agentTypes.${config.agent_type}`)
|
||||
: config.agent_type;
|
||||
const meta = [agentType, modelName].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<BaseNode
|
||||
title={title}
|
||||
@@ -81,35 +87,13 @@ export const AgentNode = memo(function AgentNode({
|
||||
icon={<Bot className="size-4" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{config.agent_type && (
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={config.agent_type}
|
||||
>
|
||||
{config.agent_type}
|
||||
</div>
|
||||
)}
|
||||
{config.model_id && (
|
||||
<div
|
||||
className="text-primary truncate text-xs"
|
||||
title={config.model_id}
|
||||
>
|
||||
{config.model_id}
|
||||
{meta && (
|
||||
<div className="text-muted-foreground truncate text-xs" title={meta}>
|
||||
{meta}
|
||||
</div>
|
||||
)}
|
||||
{config.output_variable && (
|
||||
<div
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={t('agents.workflow.nodes.output', {
|
||||
...NO_ESCAPE,
|
||||
variable: config.output_variable,
|
||||
})}
|
||||
>
|
||||
{t('agents.workflow.nodes.output', {
|
||||
...NO_ESCAPE,
|
||||
variable: config.output_variable,
|
||||
})}
|
||||
</div>
|
||||
<OutputVariableLine variable={config.output_variable} />
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
@@ -138,12 +122,17 @@ export const NoteNode = memo(function NoteNode({
|
||||
// over it as a flat gradient, so the canvas grid doesn't show through.
|
||||
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition',
|
||||
selected
|
||||
? 'border-warning ring-warning scale-105 ring-2'
|
||||
? 'border-warning ring-warning/50 ring-3'
|
||||
: 'border-warning/50 hover:shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
nodeToneClass('note'),
|
||||
)}
|
||||
>
|
||||
<StickyNote className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import AgentPanel from './AgentPanel';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const makeNode = (config: Record<string, unknown> = {}): Node => ({
|
||||
id: 'agent_1',
|
||||
type: 'agent',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Classify',
|
||||
config: {
|
||||
agent_type: 'classic',
|
||||
system_prompt: '',
|
||||
prompt_template: '',
|
||||
stream_to_user: true,
|
||||
sources: [],
|
||||
tools: [],
|
||||
...config,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('AgentPanel', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = (node: Node, jsonSchemaText = '') => {
|
||||
const onUpdate = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<AgentPanel
|
||||
node={node}
|
||||
onUpdate={onUpdate}
|
||||
nodes={[node]}
|
||||
edges={[]}
|
||||
availableModels={[]}
|
||||
availableTools={[]}
|
||||
sourceOptions={[]}
|
||||
documentOptions={[]}
|
||||
jsonSchemaText={jsonSchemaText}
|
||||
jsonSchemaError={null}
|
||||
modelSupportsStructuredOutput
|
||||
onJsonSchemaChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { onUpdate };
|
||||
};
|
||||
|
||||
const advancedToggle = () =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('agents.workflow.builder.advancedSettings'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
it('groups the fields under Model, Prompt, Knowledge and Output', () => {
|
||||
render(makeNode());
|
||||
const headings = Array.from(container.querySelectorAll('h3')).map(
|
||||
(h) => h.textContent,
|
||||
);
|
||||
expect(headings).toEqual([
|
||||
'agents.form.sections.model',
|
||||
'agents.form.sections.prompt',
|
||||
'agents.workflow.builder.knowledge',
|
||||
'agents.workflow.builder.output',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks Agent type and Model at full width in the narrow panel', () => {
|
||||
render(makeNode());
|
||||
const modelSection = container.querySelector('section')!;
|
||||
expect(modelSection.querySelector('.grid')).toBeNull();
|
||||
const triggers = modelSection.querySelectorAll('[role="combobox"]');
|
||||
expect(triggers).toHaveLength(2);
|
||||
triggers.forEach((trigger) =>
|
||||
expect(trigger.className).toContain('w-full'),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams through a Switch with a description, not a Checkbox', () => {
|
||||
const { onUpdate } = render(makeNode());
|
||||
expect(container.querySelector('[role="checkbox"]')).toBeNull();
|
||||
const toggle = container.querySelector(
|
||||
'[role="switch"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true');
|
||||
expect(container.textContent).toContain(
|
||||
'agents.workflow.builder.streamToUserDescription',
|
||||
);
|
||||
act(() => toggle.click());
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({ stream_to_user: false }),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps advanced settings closed when none is set', () => {
|
||||
render(makeNode());
|
||||
const toggle = advancedToggle();
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(container.textContent).not.toContain(
|
||||
'agents.workflow.builder.filePassing',
|
||||
);
|
||||
act(() => toggle.click());
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(container.textContent).toContain(
|
||||
'agents.workflow.builder.filePassing',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens advanced settings when one of them is set', () => {
|
||||
render(makeNode({ file_passing: 'native' }));
|
||||
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('opens advanced settings when a schema is present', () => {
|
||||
render(makeNode({ json_schema: { type: 'object' } }), '{"type":"object"}');
|
||||
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type Edge, type Node } from 'reactflow';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { getToolDisplayName } from '../../../utils/toolUtils';
|
||||
import NodeDocumentsControl from '../components/NodeDocumentsControl';
|
||||
import PromptTextArea from '../components/PromptTextArea';
|
||||
import {
|
||||
DEFAULT_FILE_PASSING,
|
||||
FILE_PASSING_OPTIONS,
|
||||
FilePassing,
|
||||
normalizeFilePassing,
|
||||
} from '../documentConfig';
|
||||
import { NO_ESCAPE, schemaErrorText, type UserTool } from '../workflowHelpers';
|
||||
import { type NodePanelBodyProps } from './types';
|
||||
|
||||
import type { Model } from '../../../models/types';
|
||||
|
||||
interface AgentPanelProps extends NodePanelBodyProps {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
availableModels: Model[];
|
||||
availableTools: UserTool[];
|
||||
sourceOptions: { value: string; label: string }[];
|
||||
/** Upstream file variables the agent can read. */
|
||||
documentOptions: { value: string; label: string }[];
|
||||
/** The structured-output schema as typed. */
|
||||
jsonSchemaText: string;
|
||||
/** The schema's validation fragment, or null. */
|
||||
jsonSchemaError: string | null;
|
||||
/** False when the picked model can't return structured output. */
|
||||
modelSupportsStructuredOutput: boolean;
|
||||
onJsonSchemaChange: (text: string) => void;
|
||||
}
|
||||
|
||||
const SCHEMA_PLACEHOLDER = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": { "type": "string" }
|
||||
},
|
||||
"required": ["summary"]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Whether any advanced setting (documents, file passing, structured output)
|
||||
* differs from its default, so the disclosure opens on its own.
|
||||
*
|
||||
* Args:
|
||||
* config: The agent node's config.
|
||||
* jsonSchemaText: The schema as typed.
|
||||
* jsonSchemaError: The schema's validation fragment, or null.
|
||||
*
|
||||
* Returns:
|
||||
* True when the advanced settings should start open.
|
||||
*/
|
||||
export function hasAdvancedAgentSettings(
|
||||
config: Record<string, unknown> | undefined,
|
||||
jsonSchemaText: string,
|
||||
jsonSchemaError: string | null,
|
||||
): boolean {
|
||||
const inputDocuments = config?.input_documents;
|
||||
return (
|
||||
(Array.isArray(inputDocuments) && inputDocuments.length > 0) ||
|
||||
normalizeFilePassing(config?.file_passing) !== DEFAULT_FILE_PASSING ||
|
||||
(config?.json_schema !== undefined && config?.json_schema !== null) ||
|
||||
jsonSchemaText.trim() !== '' ||
|
||||
Boolean(jsonSchemaError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings for an AI Agent node, grouped Model, Prompt, Knowledge and Output
|
||||
* like classic Overview, with documents, file passing and structured output
|
||||
* under an Advanced settings disclosure.
|
||||
*/
|
||||
export default function AgentPanel({
|
||||
node,
|
||||
onUpdate,
|
||||
nodes,
|
||||
edges,
|
||||
availableModels,
|
||||
availableTools,
|
||||
sourceOptions,
|
||||
documentOptions,
|
||||
jsonSchemaText,
|
||||
jsonSchemaError,
|
||||
modelSupportsStructuredOutput,
|
||||
onJsonSchemaChange,
|
||||
}: AgentPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const streamId = useId();
|
||||
const config = node.data.config || {};
|
||||
const [advancedOpen, setAdvancedOpen] = useState(() =>
|
||||
hasAdvancedAgentSettings(config, jsonSchemaText, jsonSchemaError),
|
||||
);
|
||||
|
||||
const updateConfig = (patch: Record<string, unknown>) =>
|
||||
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
|
||||
|
||||
const builtinModels = availableModels.filter((m) => m.source !== 'user');
|
||||
const userModels = availableModels.filter((m) => m.source === 'user');
|
||||
const schemaInvalid = jsonSchemaText.trim() !== '' && jsonSchemaError;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="flex flex-col gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.form.sections.model')}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.agentType')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Select
|
||||
value={config.agent_type || 'classic'}
|
||||
onValueChange={(value) => updateConfig({ agent_type: value })}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.agentTypePlaceholder',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">
|
||||
{t('agents.form.agentTypes.classic')}
|
||||
</SelectItem>
|
||||
<SelectItem value="research">
|
||||
{t('agents.form.agentTypes.research')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.model')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Select
|
||||
value={config.model_id || ''}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
model_id: value,
|
||||
llm_name:
|
||||
availableModels.find((m) => m.id === value)?.provider || '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t('agents.workflow.builder.modelPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{builtinModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{t('settings.customModels.modelsGroup.builtin')}
|
||||
</SelectLabel>
|
||||
{builtinModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.display_name} · {model.provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{userModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{t('settings.customModels.modelsGroup.user')}
|
||||
</SelectLabel>
|
||||
{userModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.display_name} · {model.provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.form.sections.prompt')}
|
||||
/>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.systemPrompt')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Textarea
|
||||
value={config.system_prompt ?? ''}
|
||||
onChange={(e) => updateConfig({ system_prompt: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={t('agents.workflow.builder.systemPromptPlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<PromptTextArea
|
||||
label={t('agents.workflow.builder.promptTemplate')}
|
||||
labelSurface="background"
|
||||
value={config.prompt_template || ''}
|
||||
onChange={(val) => updateConfig({ prompt_template: val })}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
selectedNodeId={node.id}
|
||||
placeholder={t('agents.workflow.builder.promptTemplatePlaceholder', {
|
||||
...NO_ESCAPE,
|
||||
example: '{{ agent.variable }}',
|
||||
})}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.workflow.builder.knowledge')}
|
||||
/>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.sources')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<MultiSelect
|
||||
options={sourceOptions}
|
||||
selected={config.sources || []}
|
||||
onChange={(newSources) => updateConfig({ sources: newSources })}
|
||||
placeholder={t('agents.form.placeholders.selectSources')}
|
||||
searchPlaceholder={t('agents.form.sourcePopup.searchPlaceholder')}
|
||||
emptyText={t('agents.form.sourcePopup.noOptionsMessage')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('agents.form.sections.tools')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<MultiSelect
|
||||
options={availableTools.map((tool) => ({
|
||||
value: tool.id,
|
||||
label: getToolDisplayName(tool),
|
||||
}))}
|
||||
selected={config.tools || []}
|
||||
onChange={(newTools) => updateConfig({ tools: newTools })}
|
||||
placeholder={t('agents.form.placeholders.selectTools')}
|
||||
searchPlaceholder={t('agents.form.toolsPopup.searchPlaceholder')}
|
||||
emptyText={t('agents.form.toolsPopup.noOptionsMessage')}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-5">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('agents.workflow.builder.output')}
|
||||
/>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.outputVariable')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.output_variable || ''}
|
||||
onChange={(e) => updateConfig({ output_variable: e.target.value })}
|
||||
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('agents.workflow.builder.streamToUser')}
|
||||
description={t('agents.workflow.builder.streamToUserDescription')}
|
||||
htmlFor={streamId}
|
||||
>
|
||||
<Switch
|
||||
id={streamId}
|
||||
checked={config.stream_to_user ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ stream_to_user: checked })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingRows>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
aria-expanded={advancedOpen}
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="-ml-3 w-fit justify-start"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
advancedOpen && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
{t('agents.workflow.builder.advancedSettings')}
|
||||
</Button>
|
||||
{advancedOpen && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<NodeDocumentsControl
|
||||
key={node.id}
|
||||
value={config.input_documents ?? []}
|
||||
onChange={(nextInputDocuments) =>
|
||||
updateConfig({ input_documents: nextInputDocuments })
|
||||
}
|
||||
options={documentOptions}
|
||||
label={t('agents.workflow.builder.documents')}
|
||||
helpText={t('agents.workflow.builder.documentsHint')}
|
||||
/>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.filePassing')}
|
||||
hint={t('agents.workflow.builder.filePassingHint')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Select
|
||||
value={normalizeFilePassing(config.file_passing)}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({ file_passing: value as FilePassing })
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILE_PASSING_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(`agents.workflow.filePassing.${option.value}`, {
|
||||
defaultValue: option.label,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.structuredOutput')}
|
||||
labelSurface="background"
|
||||
hint={
|
||||
[
|
||||
!modelSupportsStructuredOutput
|
||||
? t('agents.workflow.builder.modelNoStructuredOutput')
|
||||
: null,
|
||||
jsonSchemaText.trim() !== '' && !jsonSchemaError
|
||||
? t('agents.workflow.builder.validSchema')
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined
|
||||
}
|
||||
error={
|
||||
schemaInvalid
|
||||
? t('agents.workflow.builder.invalidSchema', {
|
||||
...NO_ESCAPE,
|
||||
error: schemaErrorText(t, jsonSchemaError),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={jsonSchemaText}
|
||||
onChange={(e) => onJsonSchemaChange(e.target.value)}
|
||||
className="font-mono"
|
||||
rows={4}
|
||||
placeholder={SCHEMA_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import NodeDocumentsControl from '../components/NodeDocumentsControl';
|
||||
import { NO_ESCAPE, schemaErrorText } from '../workflowHelpers';
|
||||
import { type NodePanelBodyProps } from './types';
|
||||
|
||||
interface CodePanelProps extends NodePanelBodyProps {
|
||||
/** Upstream file variables the code can take as inputs. */
|
||||
documentOptions: { value: string; label: string }[];
|
||||
/** The structured-output schema as typed. */
|
||||
jsonSchemaText: string;
|
||||
/** The schema's validation fragment, or null. */
|
||||
jsonSchemaError: string | null;
|
||||
onJsonSchemaChange: (text: string) => void;
|
||||
}
|
||||
|
||||
/** Settings for a Code node: code, inputs, output and schema. */
|
||||
export default function CodePanel({
|
||||
node: selectedNode,
|
||||
onUpdate: handleUpdateNodeData,
|
||||
documentOptions: selectedCodeDocumentOptions,
|
||||
jsonSchemaText: selectedCodeJsonSchemaText,
|
||||
jsonSchemaError: selectedCodeJsonSchemaError,
|
||||
onJsonSchemaChange: handleCodeJsonSchemaChange,
|
||||
}: CodePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.workflow.builder.codeIntro')}
|
||||
</p>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.workflow.nodes.code')}
|
||||
>
|
||||
<Textarea
|
||||
value={selectedNode.data.config?.code ?? ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
code: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
className="font-mono"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
placeholder={t('agents.workflow.builder.examplePlaceholder', {
|
||||
...NO_ESCAPE,
|
||||
example: 'print("hello world")',
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<NodeDocumentsControl
|
||||
key={selectedNode.id}
|
||||
value={selectedNode.data.config?.inputs ?? []}
|
||||
onChange={(nextInputs) =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
inputs: nextInputs,
|
||||
},
|
||||
})
|
||||
}
|
||||
options={selectedCodeDocumentOptions}
|
||||
label={t('agents.workflow.builder.inputFiles')}
|
||||
helpText={t('agents.workflow.builder.inputFilesHint')}
|
||||
/>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.workflow.builder.outputVariable')}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedNode.data.config?.output_variable || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
output_variable: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.workflow.builder.timeout')}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={selectedNode.data.config?.timeout ?? ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const parsed =
|
||||
raw.trim() === '' ? undefined : Number.parseInt(raw, 10);
|
||||
handleUpdateNodeData({
|
||||
config: {
|
||||
...(selectedNode.data.config || {}),
|
||||
timeout:
|
||||
parsed !== undefined && Number.isFinite(parsed)
|
||||
? parsed
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder={t('agents.workflow.builder.optional')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.workflow.builder.structuredOutput')}
|
||||
hint={
|
||||
selectedCodeJsonSchemaText.trim() !== '' &&
|
||||
!selectedCodeJsonSchemaError
|
||||
? t('agents.workflow.builder.validSchema')
|
||||
: undefined
|
||||
}
|
||||
error={
|
||||
selectedCodeJsonSchemaText.trim() !== '' &&
|
||||
selectedCodeJsonSchemaError
|
||||
? t('agents.workflow.builder.invalidSchema', {
|
||||
...NO_ESCAPE,
|
||||
error: schemaErrorText(t, selectedCodeJsonSchemaError),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={selectedCodeJsonSchemaText}
|
||||
onChange={(e) => handleCodeJsonSchemaChange(e.target.value)}
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
placeholder={`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": { "type": "string" }
|
||||
},
|
||||
"required": ["result"]
|
||||
}`}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
}));
|
||||
|
||||
import ConditionPanel from './ConditionPanel';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const node: Node = {
|
||||
id: 'condition_1',
|
||||
type: 'condition',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Route',
|
||||
config: {
|
||||
mode: 'simple',
|
||||
cases: [
|
||||
{
|
||||
name: 'People Ops',
|
||||
expression: 'category.contains("people")',
|
||||
sourceHandle: 'case_0',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ConditionPanel', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = () => {
|
||||
const onUpdate = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<ConditionPanel
|
||||
node={node}
|
||||
onUpdate={onUpdate}
|
||||
onRemoveBranch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { onUpdate };
|
||||
};
|
||||
|
||||
it('picks the mode with a ToggleGroup in the muted track', () => {
|
||||
const { onUpdate } = render();
|
||||
const group = container.querySelector('[role="radiogroup"]')!;
|
||||
expect(group.parentElement!.className).toContain('bg-muted');
|
||||
const advanced = Array.from(group.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === 'agents.workflow.builder.modeAdvanced',
|
||||
)!;
|
||||
act(() => advanced.click());
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({ mode: 'advanced' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the branch name, variable, operator and value fields', () => {
|
||||
render();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll('[data-slot="form-field-label"]'),
|
||||
).map((l) => l.textContent);
|
||||
expect(labels).toEqual([
|
||||
'agents.workflow.builder.branchName',
|
||||
'agents.workflow.builder.variable',
|
||||
'agents.workflow.builder.operator',
|
||||
'agents.workflow.builder.value',
|
||||
]);
|
||||
});
|
||||
|
||||
it('wires the operator label to its select, with no aria-label', () => {
|
||||
render();
|
||||
const trigger = container.querySelector('[role="combobox"]')!;
|
||||
expect(trigger.hasAttribute('aria-label')).toBe(false);
|
||||
const label = container.querySelector(`label[for="${trigger.id}"]`);
|
||||
expect(label?.textContent).toBe('agents.workflow.builder.operator');
|
||||
});
|
||||
|
||||
it('adds a condition with an outline pill button', () => {
|
||||
const { onUpdate } = render();
|
||||
const add = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('agents.workflow.builder.addCondition'),
|
||||
)!;
|
||||
expect(add.getAttribute('data-variant')).toBe('outline');
|
||||
act(() => add.click());
|
||||
const cases = onUpdate.mock.calls[0][0].config.cases;
|
||||
expect(cases).toHaveLength(2);
|
||||
expect(cases[1].sourceHandle).toBe('case_1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
|
||||
import { ConditionCase } from '../../types/workflow';
|
||||
import { buildSimpleCel, parseSimpleCel } from '../simpleCel';
|
||||
import {
|
||||
getNextConditionHandle,
|
||||
normalizeConditionCases,
|
||||
} from '../workflowHelpers';
|
||||
import { type NodePanelBodyProps } from './types';
|
||||
|
||||
interface ConditionPanelProps extends NodePanelBodyProps {
|
||||
/** Drop the edges leaving the node from a removed case's handle. */
|
||||
onRemoveBranch: (sourceHandle: string) => void;
|
||||
}
|
||||
|
||||
type ConditionMode = 'simple' | 'advanced';
|
||||
|
||||
/**
|
||||
* Settings for an If / Else node: Simple or Advanced mode, then one row box
|
||||
* per case (If, Else if, …) and Add condition.
|
||||
*/
|
||||
export default function ConditionPanel({
|
||||
node,
|
||||
onUpdate,
|
||||
onRemoveBranch,
|
||||
}: ConditionPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = node.data.config || {};
|
||||
const mode: ConditionMode =
|
||||
config.mode === 'advanced' ? 'advanced' : 'simple';
|
||||
const cases: ConditionCase[] = config.cases || [];
|
||||
|
||||
const updateConfig = (patch: Record<string, unknown>) =>
|
||||
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
|
||||
|
||||
const updateCase = (idx: number, patch: Partial<ConditionCase>) => {
|
||||
const next = [...cases];
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
updateConfig({ cases: next });
|
||||
};
|
||||
|
||||
const removeCase = (idx: number) => {
|
||||
const next = normalizeConditionCases([...cases]);
|
||||
const removedHandle = next[idx]?.sourceHandle;
|
||||
next.splice(idx, 1);
|
||||
updateConfig({ cases: next });
|
||||
if (removedHandle) onRemoveBranch(removedHandle);
|
||||
};
|
||||
|
||||
const addCase = () => {
|
||||
const next = normalizeConditionCases([...cases]);
|
||||
next.push({
|
||||
name: '',
|
||||
expression: '',
|
||||
sourceHandle: getNextConditionHandle(next),
|
||||
});
|
||||
updateConfig({ cases: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.workflow.builder.conditionIntro')}
|
||||
</p>
|
||||
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
value={mode}
|
||||
onValueChange={(next) => next && updateConfig({ mode: next })}
|
||||
aria-label={t('agents.workflow.builder.conditionMode')}
|
||||
className="flex-nowrap"
|
||||
>
|
||||
<ToggleGroupItem value="simple" className="flex-1">
|
||||
{t('agents.workflow.builder.modeSimple')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="advanced" className="flex-1">
|
||||
{t('agents.workflow.builder.modeAdvanced')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{cases.map((c, idx) => {
|
||||
const parsed = parseSimpleCel(c.expression);
|
||||
return (
|
||||
<Card
|
||||
key={c.sourceHandle}
|
||||
variant="subtle"
|
||||
padding="sm"
|
||||
className="gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-warning size-2 shrink-0 rounded-full"
|
||||
/>
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="xs"
|
||||
className="flex-1"
|
||||
title={
|
||||
idx === 0
|
||||
? t('agents.workflow.nodes.if')
|
||||
: t('agents.workflow.nodes.elseIf')
|
||||
}
|
||||
/>
|
||||
{cases.length > 1 && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-xs"
|
||||
label={t('agents.workflow.removeCondition', {
|
||||
index: idx + 1,
|
||||
})}
|
||||
icon={Trash2}
|
||||
onClick={() => removeCase(idx)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.branchName')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={c.name || ''}
|
||||
onChange={(e) => updateCase(idx, { name: e.target.value })}
|
||||
placeholder={t('agents.workflow.builder.caseNamePlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
{mode === 'simple' ? (
|
||||
<>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.variable')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="font-mono"
|
||||
value={parsed.variable}
|
||||
onChange={(e) =>
|
||||
updateCase(idx, {
|
||||
expression: buildSimpleCel(
|
||||
e.target.value,
|
||||
parsed.operator,
|
||||
parsed.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.variablePlaceholder',
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.operator')}
|
||||
labelSurface="background"
|
||||
className="w-32 shrink-0"
|
||||
>
|
||||
<Select
|
||||
value={parsed.operator}
|
||||
onValueChange={(op) =>
|
||||
updateCase(idx, {
|
||||
expression: buildSimpleCel(
|
||||
parsed.variable,
|
||||
op,
|
||||
parsed.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="==">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="contains">
|
||||
{t('agents.workflow.builder.opContains')}
|
||||
</SelectItem>
|
||||
<SelectItem value="startsWith">
|
||||
{t('agents.workflow.builder.opStartsWith')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.value')}
|
||||
labelSurface="background"
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={parsed.value}
|
||||
onChange={(e) =>
|
||||
updateCase(idx, {
|
||||
expression: buildSimpleCel(
|
||||
parsed.variable,
|
||||
parsed.operator,
|
||||
e.target.value,
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.valuePlaceholder',
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FormField
|
||||
label={t('agents.workflow.conditionRow', { index: idx + 1 })}
|
||||
labelSurface="background"
|
||||
hint={
|
||||
<>
|
||||
<Trans
|
||||
i18nKey="agents.workflow.builder.celHint"
|
||||
components={{ code: <code /> }}
|
||||
values={{ braced: '{{query}}' }}
|
||||
/>{' '}
|
||||
<Button
|
||||
variant="link"
|
||||
size="inline"
|
||||
asChild
|
||||
// eslint-disable-next-line shadcn/no-restyle -- a link in a 12px hint keeps the sentence's size and weight
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
<a
|
||||
href="https://cel.dev/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('agents.workflow.builder.learnMore')}
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Textarea
|
||||
value={c.expression}
|
||||
onChange={(e) =>
|
||||
updateCase(idx, { expression: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
placeholder={t(
|
||||
'agents.workflow.builder.conditionPlaceholder',
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={addCase}
|
||||
className="self-start"
|
||||
>
|
||||
<Plus />
|
||||
{t('agents.workflow.builder.addCondition')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import NodePanel from './NodePanel';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const agentNode: Node = {
|
||||
id: 'classify',
|
||||
type: 'agent',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { title: 'Classify request' },
|
||||
};
|
||||
|
||||
const startNode: Node = {
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: 'Start' },
|
||||
};
|
||||
|
||||
describe('NodePanel', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = (node: Node) => {
|
||||
const handlers = {
|
||||
onClose: vi.fn(),
|
||||
onDuplicate: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
};
|
||||
act(() => {
|
||||
root.render(
|
||||
<NodePanel node={node} {...handlers}>
|
||||
<div data-testid="body">body</div>
|
||||
</NodePanel>,
|
||||
);
|
||||
});
|
||||
return handlers;
|
||||
};
|
||||
|
||||
const menuTrigger = () =>
|
||||
container.querySelector(
|
||||
'[aria-label="agents.workflow.builder.nodeActions"]',
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
it('shows the icon, own title, type badge and id', () => {
|
||||
render(agentNode);
|
||||
const header = container.querySelector('header')!;
|
||||
expect(header.textContent).toContain('Classify request');
|
||||
const badge = header.querySelector('[data-slot="badge"]')!;
|
||||
expect(badge.textContent).toBe('agents.workflow.builder.aiAgent');
|
||||
expect(badge.getAttribute('data-variant')).toBe('neutral');
|
||||
expect(header.textContent).toContain('classify');
|
||||
expect(header.querySelector('.bg-secondary')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers Duplicate and Delete node in the node menu', () => {
|
||||
const { onDuplicate, onDelete } = render(agentNode);
|
||||
const trigger = menuTrigger();
|
||||
expect(trigger).not.toBeNull();
|
||||
act(() => {
|
||||
trigger!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
const items = Array.from(
|
||||
document.querySelectorAll('[role="menuitem"]'),
|
||||
) as HTMLElement[];
|
||||
expect(items.map((i) => i.textContent)).toEqual([
|
||||
'agents.workflow.builder.duplicateNode',
|
||||
'agents.workflow.builder.deleteNode',
|
||||
]);
|
||||
expect(items[1].getAttribute('data-variant')).toBe('destructive');
|
||||
act(() => items[0].click());
|
||||
expect(onDuplicate).toHaveBeenCalled();
|
||||
expect(onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has no node menu and no title field for the start node', () => {
|
||||
render(startNode);
|
||||
expect(menuTrigger()).toBeNull();
|
||||
expect(container.querySelector('input')).toBeNull();
|
||||
expect(container.textContent).not.toContain('cannotDeleteStart');
|
||||
});
|
||||
|
||||
it('closes from the header', () => {
|
||||
const { onClose } = render(agentNode);
|
||||
const close = container.querySelector(
|
||||
'[aria-label="agents.close"]',
|
||||
) as HTMLButtonElement;
|
||||
act(() => close.click());
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('edits the title on the background surface and renders the body', () => {
|
||||
const { onUpdate } = render(agentNode);
|
||||
const label = container.querySelector(
|
||||
'[data-slot="form-field-label"]',
|
||||
) as HTMLElement;
|
||||
expect(label.className).toContain('bg-background');
|
||||
expect(container.querySelector('[data-testid="body"]')).not.toBeNull();
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Copy, Trash2, X } from 'lucide-react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionMenu } from '@/components/ui/dropdown-menu';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
import { NODE_META, nodeToneClass, type WorkflowNodeType } from '../nodeTones';
|
||||
|
||||
interface NodePanelProps {
|
||||
/** The node being edited. */
|
||||
node: Node;
|
||||
onClose: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
/** Merge fields into the node's data (used by the title field). */
|
||||
onUpdate: (data: Record<string, unknown>) => void;
|
||||
/** The type's own settings (AgentPanel, ConditionPanel, …). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Start and End have fixed names and no settings of their own.
|
||||
const FIXED_TYPES = new Set(['start', 'end']);
|
||||
|
||||
const FIXED_HINT_KEYS: Record<string, string> = {
|
||||
start: 'agents.workflow.nodes.startHint',
|
||||
end: 'agents.workflow.nodes.endHint',
|
||||
};
|
||||
|
||||
/**
|
||||
* The node settings column docked at the canvas's right edge: a header with
|
||||
* the type's icon, the node's title, its type and id, a ⋯ menu (Duplicate,
|
||||
* Delete node; none for Start) and close, then the one scrolling body.
|
||||
*/
|
||||
export default function NodePanel({
|
||||
node,
|
||||
onClose,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
children,
|
||||
}: NodePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = NODE_META[node.type as WorkflowNodeType];
|
||||
const Icon = meta?.icon;
|
||||
const typeLabel = meta ? t(meta.labelKey) : (node.type ?? '');
|
||||
const fixed = FIXED_TYPES.has(node.type ?? '');
|
||||
const title = fixed
|
||||
? typeLabel
|
||||
: node.data.title || node.data.label || typeLabel;
|
||||
|
||||
return (
|
||||
<aside className="bg-background border-border flex w-96 shrink-0 flex-col border-l">
|
||||
<header className="border-border flex items-start gap-3 border-b px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||
nodeToneClass(node.type),
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="size-4" aria-hidden="true" />}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2
|
||||
className="text-foreground truncate text-sm font-semibold"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{title !== typeLabel && (
|
||||
<Badge variant="neutral">{typeLabel}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span
|
||||
className="text-muted-foreground truncate font-mono text-xs"
|
||||
title={node.id}
|
||||
>
|
||||
{node.id}
|
||||
</span>
|
||||
<CopyButton
|
||||
textToCopy={node.id}
|
||||
size="xs"
|
||||
copyLabel={t('agents.workflow.builder.copyNodeId')}
|
||||
side="bottom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{node.type !== 'start' && (
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('agents.workflow.builder.nodeActions')}
|
||||
options={[
|
||||
{
|
||||
label: t('agents.workflow.builder.duplicateNode'),
|
||||
icon: Copy,
|
||||
onClick: onDuplicate,
|
||||
},
|
||||
{
|
||||
label: t('agents.workflow.builder.deleteNode'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: onDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-sm"
|
||||
side="bottom"
|
||||
onClick={onClose}
|
||||
label={t('agents.close')}
|
||||
icon={X}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{fixed ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(FIXED_HINT_KEYS[node.type ?? ''])}
|
||||
</p>
|
||||
) : (
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.title')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={node.data.title || node.data.label || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({ title: e.target.value, label: e.target.value })
|
||||
}
|
||||
placeholder={t('agents.workflow.builder.titlePlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { type NodePanelBodyProps } from './types';
|
||||
|
||||
/** Settings for a note node: its text. */
|
||||
export default function NotePanel({
|
||||
node: selectedNode,
|
||||
onUpdate: handleUpdateNodeData,
|
||||
}: NodePanelBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormField
|
||||
labelSurface="background"
|
||||
label={t('agents.workflow.builder.noteContent')}
|
||||
>
|
||||
<Textarea
|
||||
value={selectedNode.data.content || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeData({
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={4}
|
||||
placeholder={t('agents.workflow.builder.noteContentPlaceholder')}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { index?: number }) =>
|
||||
opts?.index !== undefined ? `${key}:${opts.index}` : key,
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
}));
|
||||
|
||||
import StatePanel from './StatePanel';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const node: Node = {
|
||||
id: 'state_1',
|
||||
type: 'state',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: 'Set priority',
|
||||
config: {
|
||||
operations: [
|
||||
{ expression: '"P1"', target_variable: 'priority' },
|
||||
{ expression: '"4h"', target_variable: 'sla' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('StatePanel', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = () => {
|
||||
const onUpdate = vi.fn();
|
||||
act(() => {
|
||||
root.render(<StatePanel node={node} onUpdate={onUpdate} />);
|
||||
});
|
||||
return { onUpdate };
|
||||
};
|
||||
|
||||
it('explains CEL once, in the intro', () => {
|
||||
render();
|
||||
const matches = container.textContent!.match(
|
||||
/agents\.workflow\.builder\.celHint/g,
|
||||
);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelectorAll('a[href="https://cel.dev/"]'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('numbers each assignment and puts the variable before the value', () => {
|
||||
render();
|
||||
expect(container.textContent).toContain(
|
||||
'agents.workflow.builder.assignment:1',
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
'agents.workflow.builder.assignment:2',
|
||||
);
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll('[data-slot="form-field-label"]'),
|
||||
).map((l) => l.textContent);
|
||||
expect(labels.slice(0, 2)).toEqual([
|
||||
'agents.workflow.builder.variable',
|
||||
'agents.workflow.builder.valueCel',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds an assignment with an outline pill button', () => {
|
||||
const { onUpdate } = render();
|
||||
const add = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes('agents.workflow.builder.addAssignment'),
|
||||
)!;
|
||||
expect(add.getAttribute('data-variant')).toBe('outline');
|
||||
act(() => add.click());
|
||||
expect(onUpdate.mock.calls[0][0].config.operations).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/ui/form-field';
|
||||
import { IconButton } from '@/components/ui/icon-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
import { StateOperationConfig } from '../../types/workflow';
|
||||
import { NO_ESCAPE } from '../workflowHelpers';
|
||||
import { type NodePanelBodyProps } from './types';
|
||||
|
||||
/**
|
||||
* Settings for a Set State node: an intro that explains CEL once, then one
|
||||
* numbered row box per assignment (variable, then its CEL value).
|
||||
*/
|
||||
export default function StatePanel({ node, onUpdate }: NodePanelBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const operations: StateOperationConfig[] = node.data.config?.operations || [];
|
||||
|
||||
const setOperations = (next: StateOperationConfig[]) =>
|
||||
onUpdate({ config: { ...(node.data.config || {}), operations: next } });
|
||||
|
||||
const updateOperation = (
|
||||
idx: number,
|
||||
patch: Partial<StateOperationConfig>,
|
||||
) => {
|
||||
const next = [...operations];
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
setOperations(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('agents.workflow.builder.stateIntro')}{' '}
|
||||
<Trans
|
||||
i18nKey="agents.workflow.builder.celHint"
|
||||
components={{ code: <code /> }}
|
||||
values={{ braced: '{{query}}' }}
|
||||
/>{' '}
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<a href="https://cel.dev/" target="_blank" rel="noreferrer">
|
||||
{t('agents.workflow.builder.learnMore')}
|
||||
</a>
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
{operations.map((op, idx) => (
|
||||
<Card key={idx} variant="subtle" padding="sm" className="gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="xs"
|
||||
className="flex-1"
|
||||
title={t('agents.workflow.builder.assignment', {
|
||||
index: idx + 1,
|
||||
})}
|
||||
/>
|
||||
{operations.length > 1 && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-xs"
|
||||
label={t('agents.workflow.removeAssignment', {
|
||||
index: idx + 1,
|
||||
})}
|
||||
icon={Trash2}
|
||||
onClick={() =>
|
||||
setOperations(operations.filter((_, i) => i !== idx))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.variable')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="font-mono"
|
||||
value={op.target_variable}
|
||||
onChange={(e) =>
|
||||
updateOperation(idx, { target_variable: e.target.value })
|
||||
}
|
||||
placeholder={t('agents.workflow.builder.examplePlaceholder', {
|
||||
...NO_ESCAPE,
|
||||
example: 'variable_name',
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('agents.workflow.builder.valueCel')}
|
||||
labelSurface="background"
|
||||
>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
value={op.expression}
|
||||
onChange={(e) =>
|
||||
updateOperation(idx, { expression: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
placeholder={t('agents.workflow.builder.examplePlaceholder', {
|
||||
...NO_ESCAPE,
|
||||
example: 'query',
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() =>
|
||||
setOperations([
|
||||
...operations,
|
||||
{ expression: '', target_variable: '' },
|
||||
])
|
||||
}
|
||||
className="self-start"
|
||||
>
|
||||
<Plus />
|
||||
{t('agents.workflow.builder.addAssignment')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type Node } from 'reactflow';
|
||||
|
||||
/** Props every node settings panel body takes. */
|
||||
export interface NodePanelBodyProps {
|
||||
/** The node being edited. */
|
||||
node: Node;
|
||||
/** Merge fields into the node's data (a snapshot first unless disabled). */
|
||||
onUpdate: (
|
||||
data: Record<string, unknown>,
|
||||
options?: { snapshot?: boolean },
|
||||
) => void;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findFreePosition } from './workflowHelpers';
|
||||
|
||||
const box = (id: string, x: number, y: number, width = 200, height = 60) => ({
|
||||
id,
|
||||
position: { x, y },
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
describe('findFreePosition', () => {
|
||||
it('keeps the wanted spot when nothing is there', () => {
|
||||
expect(findFreePosition([box('a', 0, 0)], { x: 400, y: 0 })).toEqual({
|
||||
x: 400,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves down past a node that covers the spot', () => {
|
||||
const spot = findFreePosition([box('a', 380, 0)], { x: 400, y: 0 });
|
||||
expect(spot.x).toBe(400);
|
||||
expect(spot.y).toBeGreaterThanOrEqual(60 + 24);
|
||||
});
|
||||
|
||||
it('keeps moving down past a column of nodes', () => {
|
||||
const nodes = [box('a', 400, 0), box('b', 400, 84), box('c', 400, 168)];
|
||||
const spot = findFreePosition(nodes, { x: 400, y: 0 });
|
||||
expect(spot.y).toBeGreaterThanOrEqual(168 + 60 + 24);
|
||||
});
|
||||
|
||||
it('uses a default size for nodes React Flow has not measured yet', () => {
|
||||
const spot = findFreePosition([{ id: 'a', position: { x: 400, y: 0 } }], {
|
||||
x: 450,
|
||||
y: 10,
|
||||
});
|
||||
expect(spot.y).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { type TFunction } from 'i18next';
|
||||
|
||||
import { ConditionCase } from '../types/workflow';
|
||||
import { FilePassing } from './documentConfig';
|
||||
|
||||
// Names and handles are the user's own text: React escapes on render, so
|
||||
// i18next must not escape them first.
|
||||
export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
|
||||
|
||||
export interface AgentNodeConfig {
|
||||
agent_type: 'classic' | 'research';
|
||||
llm_name?: string;
|
||||
model_id?: string;
|
||||
system_prompt: string;
|
||||
prompt_template: string;
|
||||
output_variable?: string;
|
||||
stream_to_user: boolean;
|
||||
sources: string[];
|
||||
tools: string[];
|
||||
chunks?: string;
|
||||
retriever?: string;
|
||||
json_schema?: Record<string, unknown>;
|
||||
input_documents?: string[];
|
||||
file_passing?: FilePassing;
|
||||
}
|
||||
|
||||
export interface UserTool {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
customName?: string;
|
||||
// Workflow-only builtins (e.g. read_document) are kept here; the classic
|
||||
// agent picker filters them out.
|
||||
workflow_only?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an agent node's structured-output schema.
|
||||
*
|
||||
* Args:
|
||||
* schema: The parsed schema, or undefined when none is set.
|
||||
*
|
||||
* Returns:
|
||||
* A short English error fragment, or null when the schema is usable.
|
||||
*/
|
||||
export function validateJsonSchemaConfig(schema: unknown): string | null {
|
||||
if (schema === undefined || schema === null) return null;
|
||||
if (typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return 'must be a valid JSON object';
|
||||
}
|
||||
|
||||
const schemaObject = schema as Record<string, unknown>;
|
||||
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
|
||||
return 'must include either a "type" or "schema" field';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// The schema validators return short English fragments (tests and the
|
||||
// validation list key off them); these map each to its locale key.
|
||||
const SCHEMA_ERROR_KEYS: Record<string, string> = {
|
||||
'must be a valid JSON object': 'agents.workflow.schema.notObject',
|
||||
'must include either a "type" or "schema" field':
|
||||
'agents.workflow.schema.missingType',
|
||||
'must be valid JSON': 'agents.workflow.schema.invalidJson',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a JSON schema validation fragment.
|
||||
*
|
||||
* Args:
|
||||
* t: The i18next translate function.
|
||||
* fragment: The fragment a schema validator returned.
|
||||
*
|
||||
* Returns:
|
||||
* The translated fragment, or the fragment itself when it is unknown.
|
||||
*/
|
||||
export function schemaErrorText(t: TFunction, fragment: string): string {
|
||||
const key = SCHEMA_ERROR_KEYS[fragment];
|
||||
return key ? t(key) : fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give every condition case a unique `case_N` source handle.
|
||||
*
|
||||
* Args:
|
||||
* cases: The cases as stored.
|
||||
*
|
||||
* Returns:
|
||||
* The cases, with missing or duplicate handles replaced.
|
||||
*/
|
||||
export function normalizeConditionCases(
|
||||
cases: ConditionCase[],
|
||||
): ConditionCase[] {
|
||||
const usedHandles = new Set<string>();
|
||||
let nextIndex = 0;
|
||||
|
||||
return cases.map((conditionCase) => {
|
||||
const candidate = (conditionCase.sourceHandle || '').trim();
|
||||
if (candidate && !usedHandles.has(candidate)) {
|
||||
usedHandles.add(candidate);
|
||||
const match = candidate.match(/^case_(\d+)$/);
|
||||
if (match) {
|
||||
nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
|
||||
}
|
||||
return conditionCase;
|
||||
}
|
||||
|
||||
while (usedHandles.has(`case_${nextIndex}`)) {
|
||||
nextIndex += 1;
|
||||
}
|
||||
const generatedHandle = `case_${nextIndex}`;
|
||||
usedHandles.add(generatedHandle);
|
||||
nextIndex += 1;
|
||||
|
||||
return {
|
||||
...conditionCase,
|
||||
sourceHandle: generatedHandle,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The next free `case_N` handle for a new condition case.
|
||||
*
|
||||
* Args:
|
||||
* cases: The existing cases.
|
||||
*
|
||||
* Returns:
|
||||
* An unused handle.
|
||||
*/
|
||||
export function getNextConditionHandle(cases: ConditionCase[]): string {
|
||||
const usedHandles = new Set(
|
||||
cases.map((conditionCase) => conditionCase.sourceHandle).filter(Boolean),
|
||||
);
|
||||
const usedIndices = Array.from(usedHandles)
|
||||
.map((handle) => handle.match(/^case_(\d+)$/))
|
||||
.filter((match): match is RegExpMatchArray => Boolean(match))
|
||||
.map((match) => Number(match[1]));
|
||||
|
||||
let nextIndex = usedIndices.length > 0 ? Math.max(...usedIndices) + 1 : 0;
|
||||
while (usedHandles.has(`case_${nextIndex}`)) {
|
||||
nextIndex += 1;
|
||||
}
|
||||
|
||||
return `case_${nextIndex}`;
|
||||
}
|
||||
|
||||
interface PlacedNode {
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
// Nodes React Flow hasn't measured yet count as a typical node.
|
||||
const DEFAULT_NODE_WIDTH = 200;
|
||||
const DEFAULT_NODE_HEIGHT = 64;
|
||||
const NODE_GAP = 24;
|
||||
const MAX_PLACEMENT_STEPS = 50;
|
||||
|
||||
/**
|
||||
* Find a spot for a new node that doesn't cover an existing one.
|
||||
*
|
||||
* Starts at `wanted` (the new node's top-left corner) and steps straight down
|
||||
* until the new node's box, with a gap around it, clears every other node.
|
||||
*
|
||||
* Args:
|
||||
* nodes: The nodes already on the canvas.
|
||||
* wanted: Where the new node would ideally go.
|
||||
* size: The new node's expected size.
|
||||
*
|
||||
* Returns:
|
||||
* The first free top-left position at or below `wanted`.
|
||||
*/
|
||||
export function findFreePosition(
|
||||
nodes: PlacedNode[],
|
||||
wanted: { x: number; y: number },
|
||||
size: { width: number; height: number } = {
|
||||
width: DEFAULT_NODE_WIDTH,
|
||||
height: DEFAULT_NODE_HEIGHT,
|
||||
},
|
||||
): { x: number; y: number } {
|
||||
const overlapping = (y: number) =>
|
||||
nodes.find((node) => {
|
||||
const width = node.width ?? DEFAULT_NODE_WIDTH;
|
||||
const height = node.height ?? DEFAULT_NODE_HEIGHT;
|
||||
return (
|
||||
wanted.x < node.position.x + width + NODE_GAP &&
|
||||
wanted.x + size.width + NODE_GAP > node.position.x &&
|
||||
y < node.position.y + height + NODE_GAP &&
|
||||
y + size.height + NODE_GAP > node.position.y
|
||||
);
|
||||
});
|
||||
|
||||
let y = wanted.y;
|
||||
for (let step = 0; step < MAX_PLACEMENT_STEPS; step += 1) {
|
||||
const blocker = overlapping(y);
|
||||
if (!blocker) break;
|
||||
y = blocker.position.y + (blocker.height ?? DEFAULT_NODE_HEIGHT) + NODE_GAP;
|
||||
}
|
||||
return { x: wanted.x, y };
|
||||
}
|
||||
@@ -68,6 +68,19 @@ const endpoints = {
|
||||
ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`,
|
||||
SOURCE_GRAPH: (id: string, limit?: number) =>
|
||||
`/api/sources/${id}/graph${limit ? `?limit=${limit}` : ''}`,
|
||||
SOURCE_GRAPH_NODES: (
|
||||
id: string,
|
||||
params: { q?: string; type?: string; page?: number; perPage?: number },
|
||||
) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params.q) search.set('q', params.q);
|
||||
// An empty type filters to untyped nodes, so it is sent only when set.
|
||||
if (params.type !== undefined) search.set('type', params.type);
|
||||
if (params.page) search.set('page', String(params.page));
|
||||
if (params.perPage) search.set('per_page', String(params.perPage));
|
||||
const qs = search.toString();
|
||||
return `/api/sources/${id}/graph/nodes${qs ? `?${qs}` : ''}`;
|
||||
},
|
||||
SOURCE_GRAPH_NODE: (id: string, nodeId: string) =>
|
||||
`/api/sources/${id}/graph/node/${encodeURIComponent(nodeId)}`,
|
||||
TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`,
|
||||
@@ -141,6 +154,8 @@ const endpoints = {
|
||||
CUSTOM_MODEL_TEST: (id: string) => `/api/user/models/${id}/test`,
|
||||
CUSTOM_MODEL_TEST_PAYLOAD: '/api/user/models/test',
|
||||
AGENT_SCHEDULES: (agentId: string) => `/api/agents/${agentId}/schedules`,
|
||||
AGENT_SCHEDULE_STATS: (agentId: string, days?: number) =>
|
||||
`/api/agents/${agentId}/schedules/stats?days=${days ?? 30}`,
|
||||
SCHEDULE: (id: string) => `/api/schedules/${id}`,
|
||||
SCHEDULE_RUN_NOW: (id: string) => `/api/schedules/${id}/run`,
|
||||
SCHEDULE_RUNS: (id: string, limit?: number, offset?: number) =>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import apiClient from '../client';
|
||||
import schedulesService from './schedulesService';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const response = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('schedulesService.statsForAgent', () => {
|
||||
it('returns the stats body on success', async () => {
|
||||
const body = { days: 30, runs: 4, failed: 1, tokens: 120 };
|
||||
vi.spyOn(apiClient, 'get').mockResolvedValue(response(body));
|
||||
|
||||
await expect(schedulesService.statsForAgent('a1', 't')).resolves.toEqual(
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects on an error response instead of returning its body as stats', async () => {
|
||||
vi.spyOn(apiClient, 'get').mockResolvedValue(
|
||||
response({ success: false, message: 'Agent not found' }, 404),
|
||||
);
|
||||
|
||||
await expect(schedulesService.statsForAgent('a1', 't')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ScheduleResponse,
|
||||
ScheduleRunListResponse,
|
||||
ScheduleRunResponse,
|
||||
ScheduleStats,
|
||||
ScheduleUpdatePayload,
|
||||
} from '../../agents/types/schedule';
|
||||
|
||||
@@ -27,6 +28,23 @@ const schedulesService = {
|
||||
return (await json(r)) as ScheduleListResponse;
|
||||
},
|
||||
|
||||
statsForAgent: async (
|
||||
agentId: string,
|
||||
token: string | null,
|
||||
days = 30,
|
||||
): Promise<ScheduleStats> => {
|
||||
const r = await apiClient.get(
|
||||
endpoints.USER.AGENT_SCHEDULE_STATS(agentId, days),
|
||||
token,
|
||||
);
|
||||
// The error body ({success: false, message}) is not stats: reject so the
|
||||
// caller shows "—" instead of empty totals.
|
||||
if (!(r as Response).ok) {
|
||||
throw new Error(`Schedule stats failed: ${(r as Response).status}`);
|
||||
}
|
||||
return (await json(r)) as ScheduleStats;
|
||||
},
|
||||
|
||||
create: async (
|
||||
agentId: string,
|
||||
payload: ScheduleCreatePayload,
|
||||
|
||||
@@ -153,6 +153,15 @@ const userService = {
|
||||
limit?: number,
|
||||
): Promise<Response> =>
|
||||
throttledApiClient.get(endpoints.USER.SOURCE_GRAPH(sourceId, limit), token),
|
||||
getSourceGraphNodes: (
|
||||
sourceId: string,
|
||||
params: { q?: string; type?: string; page?: number; perPage?: number },
|
||||
token: string | null,
|
||||
): Promise<Response> =>
|
||||
throttledApiClient.get(
|
||||
endpoints.USER.SOURCE_GRAPH_NODES(sourceId, params),
|
||||
token,
|
||||
),
|
||||
getSourceGraphNode: (
|
||||
sourceId: string,
|
||||
nodeId: string,
|
||||
|
||||
@@ -43,6 +43,31 @@ describe('ArtifactSidebar', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
// Every Sheet closes with its built-in X (ghost-muted icon-sm, top-2
|
||||
// right-2); the header only names the artifact.
|
||||
it('closes the phone sheet with the built-in close button', async () => {
|
||||
documentMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
legacyMock.mockRejectedValue(new Error('offline'));
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ArtifactSidebar
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
artifactId="a1"
|
||||
conversationId="c1"
|
||||
variant="overlay"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
|
||||
// One close: the Sheet's built-in X, named in the UI language.
|
||||
const closes = sheet.querySelectorAll('button[aria-label="agents.close"]');
|
||||
expect(closes).toHaveLength(1);
|
||||
expect(closes[0].getAttribute('data-variant')).toBe('ghost-muted');
|
||||
expect(sheet.querySelector('[aria-label="Close"]')).toBeNull();
|
||||
expect(sheet.querySelector('[data-slot="sheet-title"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows a failed load as an alert with Retry that refetches', async () => {
|
||||
documentMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
legacyMock.mockRejectedValue(new Error('offline'));
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { markdownHeadings } from '@/lib/markdown';
|
||||
import { markdownHeadings, markdownTables } from '@/lib/markdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
@@ -26,7 +26,7 @@ import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
import { Sheet, SheetContent, SheetTitle } from './ui/sheet';
|
||||
|
||||
type TodoItem = {
|
||||
todo_id: number;
|
||||
@@ -197,6 +197,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...markdownHeadings,
|
||||
...markdownTables,
|
||||
code(props) {
|
||||
const {
|
||||
children,
|
||||
@@ -528,10 +529,10 @@ export default function ArtifactSidebar({
|
||||
<div className="h-14 shrink-0" />
|
||||
{/* Artifact panel */}
|
||||
<div className="border-border flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-transparent">
|
||||
<div className="flex w-full items-center justify-between px-4 py-2">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-4 py-2">
|
||||
<h2 className="text-foreground min-w-0 text-sm leading-tight font-semibold wrap-anywhere">
|
||||
{title}
|
||||
</span>
|
||||
</h2>
|
||||
<IconButton
|
||||
label={t('agents.close')}
|
||||
side="bottom"
|
||||
@@ -556,27 +557,13 @@ export default function ArtifactSidebar({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
title={title || t('components.artifact.preview')}
|
||||
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
|
||||
>
|
||||
<SheetContent side="right" closeLabel={t('agents.close')} className="p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-border flex w-full items-center justify-between border-b px-4 py-3">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<IconButton
|
||||
label={t('agents.close')}
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
shape="pill"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
{/* pr-12 keeps the title clear of the built-in close X. */}
|
||||
<div className="border-border flex w-full items-center border-b px-4 py-3 pr-12">
|
||||
<SheetTitle className="min-w-0 text-sm font-semibold wrap-anywhere">
|
||||
{title || t('components.artifact.preview')}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,87 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { dispatch, service, pagerProps } = vi.hoisted(() => ({
|
||||
pagerProps: {
|
||||
current: null as null | { onPageSizeChange?: (size: number) => void },
|
||||
},
|
||||
dispatch: vi.fn(),
|
||||
service: {
|
||||
getDocumentChunks: vi.fn(),
|
||||
addChunk: vi.fn(),
|
||||
updateChunk: vi.fn(),
|
||||
deleteChunk: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Keys come back as-is; interpolation values are appended so the tests can
|
||||
// read them ("key {"n":1}").
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts ? `${key} ${JSON.stringify(opts)}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDarkTheme: () => [false],
|
||||
useDebouncedValue: (value: unknown) => value,
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
useMediaQuery: () => ({ isMobile: false }),
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
useOutsideAlerter: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
page: 1,
|
||||
per_page: 5,
|
||||
total: 1,
|
||||
chunks: [{ doc_id: 'c1', text: 'Late pickup clause', metadata: {} }],
|
||||
}),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
vi.mock('../api/services/userService', () => ({ default: service }));
|
||||
|
||||
// The real pager, with its props kept so a test can pick a page size without
|
||||
// driving the Select popover.
|
||||
vi.mock('./ui/pagination', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('./ui/pagination')>();
|
||||
return {
|
||||
...mod,
|
||||
Pagination: (props: React.ComponentProps<typeof mod.Pagination>) => {
|
||||
pagerProps.current = props;
|
||||
return mod.Pagination(props);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import Chunks from './Chunks';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const setInputValue = (input: HTMLInputElement, value: string) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
const chunksResponse = (overrides: Record<string, unknown> = {}) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
page: 1,
|
||||
per_page: 12,
|
||||
total: 1,
|
||||
chunks: [
|
||||
{
|
||||
doc_id: 'c1',
|
||||
text: '## Late pickup clause',
|
||||
metadata: { token_count: 42 },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}),
|
||||
});
|
||||
|
||||
const setFieldValue = (
|
||||
field: HTMLInputElement | HTMLTextAreaElement,
|
||||
value: string,
|
||||
) => {
|
||||
const proto =
|
||||
field instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(field, value);
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
|
||||
describe('Chunks', () => {
|
||||
@@ -49,6 +89,12 @@ describe('Chunks', () => {
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
service.getDocumentChunks.mockReset();
|
||||
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
|
||||
service.addChunk.mockReset();
|
||||
service.updateChunk.mockReset();
|
||||
service.deleteChunk.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -57,104 +103,741 @@ describe('Chunks', () => {
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (onFileSelect = vi.fn()) => {
|
||||
const render = async (
|
||||
props: Partial<React.ComponentProps<typeof Chunks>> = {},
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Chunks
|
||||
documentId="doc"
|
||||
documentName="Contracts"
|
||||
handleGoBack={vi.fn()}
|
||||
onFileSearch={() => [
|
||||
{ name: 'msa.pdf', path: 'contracts/msa.pdf', isFile: true },
|
||||
{ name: 'sla.docx', path: 'contracts/sla.docx', isFile: true },
|
||||
]}
|
||||
onFileSelect={onFileSelect}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return onFileSelect;
|
||||
};
|
||||
|
||||
it('picks a file search result with the keyboard', async () => {
|
||||
const onFileSelect = await render();
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'[data-slot="command-input"]',
|
||||
)!;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => setInputValue(input, 'contracts'));
|
||||
const items = container.querySelectorAll('[data-slot="command-item"]');
|
||||
expect(items).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onFileSelect).toHaveBeenCalledWith('contracts/sla.docx');
|
||||
});
|
||||
|
||||
it('renders each chunk tile as a button', async () => {
|
||||
await render();
|
||||
const tile = container.querySelector<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
);
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('Late pickup clause');
|
||||
expect(tile!.className).not.toContain('hover:scale-105');
|
||||
});
|
||||
|
||||
const renderPath = async (props: {
|
||||
onPathSelect?: (depth: number) => void;
|
||||
handleGoBack?: () => void;
|
||||
}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Chunks
|
||||
documentId="doc"
|
||||
documentName="Contracts"
|
||||
handleGoBack={props.handleGoBack ?? vi.fn()}
|
||||
path="legal/2024/msa.pdf"
|
||||
displayPath="legal/2024/msa.pdf"
|
||||
onPathSelect={props.onPathSelect}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const crumbButton = (label: string) =>
|
||||
const tile = () =>
|
||||
container.querySelector<HTMLButtonElement>('button[data-slot="card"]');
|
||||
|
||||
const buttonByText = (text: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((el) => el.textContent?.trim() === text);
|
||||
|
||||
const crumbs = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
|
||||
const clickCrumb = async (label: string) => {
|
||||
const link = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="breadcrumb-link"]',
|
||||
),
|
||||
).find((el) => el.textContent === label);
|
||||
if (!link) throw new Error(`no crumb link ${label}`);
|
||||
await act(async () => link.click());
|
||||
};
|
||||
|
||||
it('opens the root and parent folders from the path crumbs', async () => {
|
||||
const onPathSelect = vi.fn();
|
||||
await renderPath({ onPathSelect });
|
||||
const buttonByLabel = (label: string) =>
|
||||
document.body.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${label}"]`,
|
||||
);
|
||||
|
||||
await act(async () => crumbButton('Contracts')!.click());
|
||||
expect(onPathSelect).toHaveBeenLastCalledWith(0);
|
||||
await act(async () => crumbButton('2024')!.click());
|
||||
expect(onPathSelect).toHaveBeenLastCalledWith(2);
|
||||
// The file is the current crumb, not a link.
|
||||
expect(crumbButton('msa.pdf')).toBeUndefined();
|
||||
it('has no file search column', async () => {
|
||||
await render();
|
||||
expect(container.querySelector('[data-slot="command-input"]')).toBeNull();
|
||||
expect(container.innerHTML).not.toContain('198px');
|
||||
});
|
||||
|
||||
it('without onPathSelect the root goes back and folders are text', async () => {
|
||||
const handleGoBack = vi.fn();
|
||||
await renderPath({ handleGoBack });
|
||||
it('draws each chunk as a filled tile with a cleaned preview and #n · tokens footer', async () => {
|
||||
await render();
|
||||
const card = tile()!;
|
||||
expect(card.dataset.variant).toBe('filled');
|
||||
expect(card.dataset.padding).toBe('lg');
|
||||
expect(card.querySelector('p')!.textContent).toBe('Late pickup clause');
|
||||
const footer = card.querySelector('[data-slot="card-footer"]')!;
|
||||
expect(footer.textContent).toBe(
|
||||
'settings.sources.chunkTileMeta {"n":1,"tokens":"42"}',
|
||||
);
|
||||
// No muted strip on the muted tile: it would disappear.
|
||||
expect(card.querySelector('.bg-muted')).toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => crumbButton('Contracts')!.click());
|
||||
it('numbers tiles from the page offset', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({
|
||||
page: 2,
|
||||
total: 30,
|
||||
chunks: [
|
||||
{ doc_id: 'a', text: 'A', metadata: {} },
|
||||
{ doc_id: 'b', text: 'B', metadata: {} },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
const footers = Array.from(
|
||||
container.querySelectorAll('[data-slot="card-footer"]'),
|
||||
).map((el) => el.textContent);
|
||||
expect(footers[0]).toContain('"n":13');
|
||||
expect(footers[1]).toContain('"n":14');
|
||||
});
|
||||
|
||||
// Decision 79: text tiles follow the container with an auto-fit track that
|
||||
// can't overflow a container narrower than 400px.
|
||||
it('lays the tiles out on the guarded auto-fit text-tile grid', async () => {
|
||||
await render();
|
||||
expect(tile()!.parentElement!.className).toBe(
|
||||
'grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the search field, the muted count and Add chunk', async () => {
|
||||
await render({ embedded: true });
|
||||
expect(container.textContent).toContain('settings.sources.searchChunks');
|
||||
expect(container.textContent).toContain(
|
||||
'settings.sources.chunkCount {"count":1,"formatted":"1"}',
|
||||
);
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
|
||||
});
|
||||
|
||||
// The header's byline already carries it; only a chunk list inside a file
|
||||
// tree (whose byline is the tree's totals) repeats the count in the toolbar.
|
||||
it('standalone: the chunk count shows once, in the byline', async () => {
|
||||
await render();
|
||||
const count = 'settings.sources.chunkCount {"count":1,"formatted":"1"}';
|
||||
expect(container.textContent!.split(count)).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector('p.text-sm.text-muted-foreground')?.textContent,
|
||||
).toBe(count);
|
||||
});
|
||||
|
||||
it('asks for 12 chunks per page and labels the page-size select', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({ total: 30 }),
|
||||
);
|
||||
await render();
|
||||
expect(service.getDocumentChunks.mock.calls[0][2]).toBe(12);
|
||||
const pager = container.querySelector('[data-slot="pagination"]')!;
|
||||
expect(pager.textContent).toContain('pagination.chunksPerPage');
|
||||
});
|
||||
|
||||
// Bugs row 1: at 48 per page a 30-chunk source still keeps its pager, so a
|
||||
// smaller page size can be picked again.
|
||||
it('keeps the pager when the page size outgrows the chunk count', async () => {
|
||||
service.getDocumentChunks.mockImplementation(
|
||||
async (_id: string, _page: number, perPage: number) =>
|
||||
chunksResponse({ total: 30, per_page: perPage }),
|
||||
);
|
||||
await render();
|
||||
await act(async () => pagerProps.current!.onPageSizeChange!(48));
|
||||
expect(service.getDocumentChunks.mock.lastCall![2]).toBe(48);
|
||||
const pager = container.querySelector('[data-slot="pagination"]');
|
||||
expect(pager).not.toBeNull();
|
||||
expect(pager!.textContent).toContain('pagination.chunksPerPage');
|
||||
});
|
||||
|
||||
it('draws no pager for 12 chunks or fewer', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({ total: 12 }),
|
||||
);
|
||||
await render();
|
||||
expect(container.querySelector('[data-slot="pagination"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('standalone: Sources › the source, and the Sources crumb leaves it', async () => {
|
||||
const handleGoBack = vi.fn();
|
||||
await render({ handleGoBack, headerAction: <span>retrieval</span> });
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
await clickCrumb('settings.sources.label');
|
||||
expect(handleGoBack).toHaveBeenCalledTimes(1);
|
||||
expect(crumbButton('legal')).toBeUndefined();
|
||||
expect(crumbButton('2024')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embedded: draws no header', async () => {
|
||||
await render({ embedded: true });
|
||||
expect(container.querySelector('[data-slot="breadcrumb-item"]')).toBeNull();
|
||||
});
|
||||
|
||||
const drawer = () => document.body.querySelector('[role="dialog"]');
|
||||
|
||||
// Chunk n of a 3-chunk list served one per page.
|
||||
const THREE = ['# First', '# Second', '# Third'];
|
||||
const serveThree = () =>
|
||||
service.getDocumentChunks.mockImplementation(
|
||||
async (_id: string, page: number, perPage: number) =>
|
||||
perPage === 1
|
||||
? chunksResponse({
|
||||
page,
|
||||
per_page: 1,
|
||||
total: 3,
|
||||
chunks: [
|
||||
{
|
||||
doc_id: `c${page}`,
|
||||
text: THREE[page - 1],
|
||||
metadata: { token_count: page },
|
||||
},
|
||||
],
|
||||
})
|
||||
: chunksResponse({
|
||||
total: 3,
|
||||
chunks: THREE.map((text, i) => ({
|
||||
doc_id: `c${i + 1}`,
|
||||
text,
|
||||
metadata: { token_count: i + 1 },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
it('opens a chunk rendered in the reader panel and returns to the list', async () => {
|
||||
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
|
||||
{ current: null };
|
||||
const onOpenChunkChange = vi.fn();
|
||||
await render({ embedded: true, controllerRef, onOpenChunkChange });
|
||||
await act(async () => tile()!.click());
|
||||
expect(tile()).toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
'settings.sources.chunkPosition {"n":1,"total":1,"tokens":"42"}',
|
||||
);
|
||||
// Markdown reads rendered: no textarea, the heading is a heading.
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
expect(container.querySelector('h2')?.textContent).toBe(
|
||||
'Late pickup clause',
|
||||
);
|
||||
// The host's crumbs close it; the reader has no Back of its own.
|
||||
expect(onOpenChunkChange).toHaveBeenLastCalledWith(1);
|
||||
expect(container.querySelector('.lucide-arrow-left')).toBeNull();
|
||||
|
||||
await act(async () => controllerRef.current!.closeChunk());
|
||||
expect(tile()).not.toBeNull();
|
||||
expect(onOpenChunkChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('renders a markdown heading as an h1', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({
|
||||
chunks: [{ doc_id: 'c1', text: '# Rates\n\nBody', metadata: {} }],
|
||||
}),
|
||||
);
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Rates');
|
||||
});
|
||||
|
||||
it('standalone: an open chunk is the last crumb and the source crumb closes it', async () => {
|
||||
const handleGoBack = vi.fn();
|
||||
await render({ handleGoBack });
|
||||
await act(async () => tile()!.click());
|
||||
expect(crumbs()).toEqual([
|
||||
'settings.sources.label',
|
||||
'Contracts',
|
||||
'settings.sources.chunkCrumb {"n":1}',
|
||||
]);
|
||||
await clickCrumb('Contracts');
|
||||
expect(handleGoBack).not.toHaveBeenCalled();
|
||||
expect(tile()).not.toBeNull();
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
|
||||
});
|
||||
|
||||
it('previous / next fetch chunk n one per page and stop at the ends', async () => {
|
||||
serveThree();
|
||||
await render({ embedded: true, path: 'a/b.md' });
|
||||
await act(async () => tile()!.click());
|
||||
const prev = () => buttonByLabel('settings.sources.previousChunk')!;
|
||||
const next = () => buttonByLabel('settings.sources.nextChunk')!;
|
||||
expect(prev().disabled).toBe(true);
|
||||
expect(next().disabled).toBe(false);
|
||||
|
||||
service.getDocumentChunks.mockClear();
|
||||
await act(async () => next().click());
|
||||
expect(service.getDocumentChunks).toHaveBeenCalledWith(
|
||||
'doc',
|
||||
2,
|
||||
1,
|
||||
null,
|
||||
'a/b.md',
|
||||
'',
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Second');
|
||||
expect(container.textContent).toContain(
|
||||
'settings.sources.chunkPosition {"n":2,"total":3,"tokens":"2"}',
|
||||
);
|
||||
|
||||
await act(async () => next().click());
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Third');
|
||||
expect(next().disabled).toBe(true);
|
||||
expect(prev().disabled).toBe(false);
|
||||
|
||||
await act(async () => prev().click());
|
||||
expect(service.getDocumentChunks).toHaveBeenLastCalledWith(
|
||||
'doc',
|
||||
2,
|
||||
1,
|
||||
null,
|
||||
'a/b.md',
|
||||
'',
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Second');
|
||||
});
|
||||
|
||||
it('the arrow keys walk the chunks while no field has focus', async () => {
|
||||
serveThree();
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
||||
});
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Second');
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||
});
|
||||
expect(container.querySelector('h1')?.textContent).toBe('First');
|
||||
});
|
||||
|
||||
// 13 chunks, 12 per page: chunk 13 sits on page 2.
|
||||
const serveThirteen = () =>
|
||||
service.getDocumentChunks.mockImplementation(
|
||||
async (_id: string, page: number, perPage: number) =>
|
||||
perPage === 1
|
||||
? chunksResponse({
|
||||
page,
|
||||
per_page: 1,
|
||||
total: 13,
|
||||
chunks: [
|
||||
{ doc_id: `c${page}`, text: `# C${page}`, metadata: {} },
|
||||
],
|
||||
})
|
||||
: chunksResponse({
|
||||
page,
|
||||
total: 13,
|
||||
chunks: Array.from({ length: page === 2 ? 1 : 12 }, (_, i) => ({
|
||||
doc_id: `p${page}-${i}`,
|
||||
text: `tile ${page}-${i}`,
|
||||
metadata: {},
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
it('back to the grid lands on the page holding the last chunk', async () => {
|
||||
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
|
||||
{ current: null };
|
||||
serveThirteen();
|
||||
await render({ embedded: true, controllerRef });
|
||||
const tiles = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
);
|
||||
await act(async () => tiles[11].click());
|
||||
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
|
||||
expect(container.querySelector('h1')?.textContent).toBe('C13');
|
||||
await act(async () => controllerRef.current!.closeChunk());
|
||||
const lastGridCall = service.getDocumentChunks.mock.calls
|
||||
.filter((call) => call[2] !== 1)
|
||||
.pop()!;
|
||||
expect(lastGridCall[1]).toBe(2);
|
||||
expect(tile()!.textContent).toContain('tile 2-0');
|
||||
});
|
||||
|
||||
it('Edit opens the drawer with the chunk text and saves it', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({
|
||||
chunks: [
|
||||
{
|
||||
doc_id: 'c1',
|
||||
text: '## Late pickup clause',
|
||||
metadata: { token_count: 42, title: 'Clause' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
service.updateChunk.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1-new' }),
|
||||
}));
|
||||
await render({ embedded: true, path: 'legal/msa.pdf' });
|
||||
await act(async () => tile()!.click());
|
||||
// No in-panel edit mode any more.
|
||||
expect(buttonByText('modals.chunk.cancel')).toBeUndefined();
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
expect(drawer()).not.toBeNull();
|
||||
expect(drawer()!.textContent).toContain('settings.sources.editChunk');
|
||||
expect(drawer()!.textContent).toContain(
|
||||
'settings.sources.editChunkDescription {"file":"legal/msa.pdf","n":1,"tokens":"42"',
|
||||
);
|
||||
const field = drawer()!.querySelector('textarea')!;
|
||||
expect(field.value).toBe('## Late pickup clause');
|
||||
|
||||
await act(async () => setFieldValue(field, '## New text'));
|
||||
service.getDocumentChunks.mockClear();
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
expect(service.updateChunk).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'doc',
|
||||
chunk_id: 'c1',
|
||||
text: '## New text',
|
||||
metadata: { title: 'Clause' },
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(drawer()).toBeNull();
|
||||
// That chunk and the grid page are fetched again.
|
||||
const sizes = service.getDocumentChunks.mock.calls.map((call) => call[2]);
|
||||
expect(sizes).toContain(1);
|
||||
expect(sizes).toContain(12);
|
||||
});
|
||||
|
||||
it('an edit the search no longer matches still shows the saved text', async () => {
|
||||
service.updateChunk.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1-new' }),
|
||||
}));
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
// After the save, the open position holds nothing (or another chunk).
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({ total: 0, chunks: [] }),
|
||||
);
|
||||
const field = drawer()!.querySelector('textarea')!;
|
||||
await act(async () => setFieldValue(field, '## Rewritten'));
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
expect(drawer()).toBeNull();
|
||||
expect(container.querySelector('h2')?.textContent).toBe('Rewritten');
|
||||
expect(container.textContent).toContain('chunkPositionUnplaced');
|
||||
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('deleting the only chunk on the last page lands on the page before', async () => {
|
||||
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
|
||||
{ current: null };
|
||||
serveThirteen();
|
||||
service.deleteChunk.mockImplementation(async () => ({ ok: true }));
|
||||
await render({ embedded: true, controllerRef });
|
||||
const tiles = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
);
|
||||
await act(async () => tiles[11].click());
|
||||
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
|
||||
expect(container.querySelector('h1')?.textContent).toBe('C13');
|
||||
|
||||
const trigger = buttonByLabel('settings.sources.menuAlt')!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const del = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((el) => el.textContent === 'modals.chunk.delete')!;
|
||||
await act(async () => del.click());
|
||||
await act(async () => buttonByText('modals.chunk.delete')!.click());
|
||||
|
||||
expect(service.deleteChunk).toHaveBeenCalledWith('doc', 'c13', null);
|
||||
const lastGridCall = service.getDocumentChunks.mock.calls
|
||||
.filter((call) => call[2] !== 1)
|
||||
.pop()!;
|
||||
expect(lastGridCall[1]).toBe(1);
|
||||
});
|
||||
|
||||
it('drops a grid response that a newer fetch overtook', async () => {
|
||||
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
|
||||
{ current: null };
|
||||
serveThirteen();
|
||||
await render({ embedded: true, controllerRef });
|
||||
// Open chunk 13 (page 2), then start a slow page-1 refresh and close the
|
||||
// chunk before it answers.
|
||||
const tiles = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
);
|
||||
await act(async () => tiles[11].click());
|
||||
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
|
||||
let release: () => void = () => undefined;
|
||||
const slow = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const fast = service.getDocumentChunks.getMockImplementation()! as (
|
||||
...a: unknown[]
|
||||
) => unknown;
|
||||
// Grid page 1 answers only once released.
|
||||
service.getDocumentChunks.mockImplementation(async (...args: unknown[]) => {
|
||||
if (args[1] === 1 && args[2] !== 1) await slow;
|
||||
return fast(...args);
|
||||
});
|
||||
service.updateChunk.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c13' }),
|
||||
}));
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
const field = drawer()!.querySelector('textarea')!;
|
||||
await act(async () => setFieldValue(field, '# C13 edited'));
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
await act(async () => controllerRef.current!.closeChunk());
|
||||
expect(tile()!.textContent).toContain('tile 2-0');
|
||||
await act(async () => release());
|
||||
expect(tile()!.textContent).toContain('tile 2-0');
|
||||
});
|
||||
|
||||
it('a failed save keeps the drawer open with a destructive alert', async () => {
|
||||
service.updateChunk.mockImplementation(async () => ({ ok: false }));
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
const field = drawer()!.querySelector('textarea')!;
|
||||
await act(async () => setFieldValue(field, 'New text'));
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
expect(service.updateChunk).toHaveBeenCalledTimes(1);
|
||||
expect(drawer()).not.toBeNull();
|
||||
const alert = drawer()!.querySelector('[data-slot="alert"]')!;
|
||||
expect(alert.getAttribute('data-variant')).toBe('destructive');
|
||||
expect(alert.textContent).toBe('settings.sources.chunkErrors.save');
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
// The edits stay in the drawer.
|
||||
expect(drawer()!.querySelector('textarea')!.value).toBe('New text');
|
||||
});
|
||||
|
||||
it('Add chunk opens the drawer empty, adds and stays on the grid', async () => {
|
||||
service.addChunk.mockImplementation(async () => ({ ok: true }));
|
||||
await render({ embedded: true, path: 'a.md' });
|
||||
await act(async () => buttonByText('settings.sources.addChunk')!.click());
|
||||
expect(
|
||||
drawer()!.querySelector('h2, [data-slot="sheet-title"]')!.textContent,
|
||||
).toBe('settings.sources.addChunk');
|
||||
const field = drawer()!.querySelector('textarea')!;
|
||||
expect(field.value).toBe('');
|
||||
// The title defaults to the file's name, as ingest sets it.
|
||||
expect(drawer()!.querySelector('input')!.value).toBe('a.md');
|
||||
await act(async () => setFieldValue(field, 'Fresh'));
|
||||
service.getDocumentChunks.mockClear();
|
||||
await act(async () => buttonByText('modals.chunk.add')!.click());
|
||||
expect(service.addChunk).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'doc',
|
||||
text: 'Fresh',
|
||||
metadata: { source: 'a.md', source_id: 'doc', title: 'a.md' },
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(drawer()).toBeNull();
|
||||
expect(service.getDocumentChunks).toHaveBeenCalledTimes(1);
|
||||
expect(tile()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Add chunk's title follows the file's other chunks, then its display name", async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({
|
||||
chunks: [
|
||||
{ doc_id: 'c1', text: 'x', metadata: { title: 'Carrier profile' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await render({
|
||||
embedded: true,
|
||||
path: 'x_1.docx',
|
||||
fileName: 'Carrier.docx',
|
||||
});
|
||||
await act(async () => buttonByText('settings.sources.addChunk')!.click());
|
||||
expect(drawer()!.querySelector('input')!.value).toBe('Carrier profile');
|
||||
await act(async () =>
|
||||
buttonByText('settings.sources.editor.cancel')!.click(),
|
||||
);
|
||||
|
||||
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
|
||||
await render({ embedded: true, path: 'x_2.docx', fileName: 'Other.docx' });
|
||||
await act(async () => buttonByText('settings.sources.addChunk')!.click());
|
||||
expect(drawer()!.querySelector('input')!.value).toBe('Other.docx');
|
||||
});
|
||||
|
||||
it('editing only the title enables Save and sends it', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () =>
|
||||
chunksResponse({
|
||||
chunks: [
|
||||
{ doc_id: 'c1', text: 'Body', metadata: { title: 'Old name' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
service.updateChunk.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1-new' }),
|
||||
}));
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
const title = drawer()!.querySelector('input')!;
|
||||
expect(title.value).toBe('Old name');
|
||||
expect(buttonByText('modals.chunk.save')!.disabled).toBe(true);
|
||||
await act(async () => setFieldValue(title, 'New name'));
|
||||
expect(buttonByText('modals.chunk.save')!.disabled).toBe(false);
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
expect(service.updateChunk).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'doc',
|
||||
chunk_id: 'c1',
|
||||
text: 'Body',
|
||||
metadata: { title: 'New name' },
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('saving an untitled chunk sends no empty title', async () => {
|
||||
service.updateChunk.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ chunk_id: 'c1-new' }),
|
||||
}));
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
await act(async () =>
|
||||
setFieldValue(drawer()!.querySelector('textarea')!, 'Rewritten'),
|
||||
);
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
expect(service.updateChunk).toHaveBeenCalledWith(
|
||||
{ id: 'doc', chunk_id: 'c1', text: 'Rewritten' },
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('an Add drawer opened before the grid lands closes without a discard prompt', async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const slow = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
service.getDocumentChunks.mockImplementation(async () => {
|
||||
await slow;
|
||||
return chunksResponse({
|
||||
chunks: [{ doc_id: 'c1', text: 'x', metadata: { title: 'Profile' } }],
|
||||
});
|
||||
});
|
||||
await render({ embedded: true, fileName: 'Carrier.docx' });
|
||||
await act(async () => buttonByText('settings.sources.addChunk')!.click());
|
||||
expect(drawer()!.querySelector('input')!.value).toBe('Carrier.docx');
|
||||
await act(async () => release());
|
||||
await act(async () =>
|
||||
buttonByText('settings.sources.editor.cancel')!.click(),
|
||||
);
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'settings.sources.editor.discardMessage',
|
||||
);
|
||||
expect(drawer()).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* A 3-chunk store the tests reorder: an update adds the new copy where
|
||||
* `placeAt` says (the end by default) and drops the old one.
|
||||
*/
|
||||
const serveStore = (placeAt?: number) => {
|
||||
const store = [
|
||||
{ doc_id: 'c1', text: '# First', metadata: {} },
|
||||
{ doc_id: 'c2', text: '# Second', metadata: {} },
|
||||
{ doc_id: 'c3', text: '# Third', metadata: {} },
|
||||
];
|
||||
service.getDocumentChunks.mockImplementation(
|
||||
async (_id: string, page: number, perPage: number) => {
|
||||
const start = (page - 1) * perPage;
|
||||
return chunksResponse({
|
||||
page,
|
||||
per_page: perPage,
|
||||
total: store.length,
|
||||
chunks: store.slice(start, start + perPage),
|
||||
});
|
||||
},
|
||||
);
|
||||
service.updateChunk.mockImplementation(
|
||||
async (body: { chunk_id: string; text: string }) => {
|
||||
const index = store.findIndex((c) => c.doc_id === body.chunk_id);
|
||||
const [old] = store.splice(index, 1);
|
||||
const copy = { ...old, doc_id: `${old.doc_id}-new`, text: body.text };
|
||||
store.splice(placeAt ?? store.length, 0, copy);
|
||||
return { ok: true, json: async () => ({ chunk_id: copy.doc_id }) };
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const editOpenChunk = async (text: string) => {
|
||||
await act(async () => buttonByText('modals.chunk.edit')!.click());
|
||||
await act(async () =>
|
||||
setFieldValue(drawer()!.querySelector('textarea')!, text),
|
||||
);
|
||||
await act(async () => buttonByText('modals.chunk.save')!.click());
|
||||
};
|
||||
|
||||
const position = () =>
|
||||
container
|
||||
.textContent!.match(/chunkPosition \{"n":(\d+),"total":(\d+)/)!
|
||||
.slice(1)
|
||||
.map(Number);
|
||||
|
||||
it('an edited chunk the store moved to the end is shown at its new position', async () => {
|
||||
serveStore();
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await editOpenChunk('# First edited');
|
||||
expect(container.querySelector('h1')?.textContent).toBe('First edited');
|
||||
expect(position()).toEqual([3, 3]);
|
||||
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
|
||||
// Previous walks the store's current order: nothing is skipped.
|
||||
await act(async () =>
|
||||
buttonByLabel('settings.sources.previousChunk')!.click(),
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Third');
|
||||
await act(async () =>
|
||||
buttonByLabel('settings.sources.previousChunk')!.click(),
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Second');
|
||||
});
|
||||
|
||||
it('an edited chunk the store moved off the probed positions keeps its text but not its place', async () => {
|
||||
serveStore(1);
|
||||
await render({ embedded: true });
|
||||
const tiles = container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-slot="card"]',
|
||||
);
|
||||
await act(async () => tiles[0].click());
|
||||
service.getDocumentChunks.mockClear();
|
||||
await editOpenChunk('# First edited');
|
||||
expect(container.querySelector('h1')?.textContent).toBe('First edited');
|
||||
// Its position is unknown: no number is claimed and paging is off, so
|
||||
// previous / next can't step from a place the chunk no longer holds.
|
||||
expect(container.textContent).not.toMatch(/chunkPosition \{/);
|
||||
expect(container.textContent).toContain('chunkPositionUnplaced');
|
||||
expect(buttonByLabel('settings.sources.previousChunk')!.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
|
||||
// Only the open and last positions are probed: the whole filtered list
|
||||
// (one page of `total`) is never fetched.
|
||||
const sizes = service.getDocumentChunks.mock.calls.map(
|
||||
(call: unknown[]) => call[2],
|
||||
);
|
||||
expect(sizes).toContain(1);
|
||||
expect(sizes).not.toContain(3);
|
||||
});
|
||||
|
||||
it('an edited chunk that keeps its place stays put', async () => {
|
||||
serveStore(0);
|
||||
await render({ embedded: true });
|
||||
await act(async () => tile()!.click());
|
||||
await editOpenChunk('# First edited');
|
||||
expect(position()).toEqual([1, 3]);
|
||||
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
|
||||
expect(container.querySelector('h1')?.textContent).toBe('Second');
|
||||
});
|
||||
|
||||
it('shows a destructive empty state with Retry when the fetch fails', async () => {
|
||||
service.getDocumentChunks.mockImplementation(async () => ({ ok: false }));
|
||||
await render();
|
||||
const alert = container.querySelector('[role="alert"]')!;
|
||||
expect(alert).not.toBeNull();
|
||||
expect(alert.textContent).toContain('settings.sources.chunkErrors.load');
|
||||
expect(tile()).toBeNull();
|
||||
|
||||
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
|
||||
await act(async () => buttonByText('retry')!.click());
|
||||
expect(tile()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+679
-582
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,147 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => vi.fn(),
|
||||
useStore: () => ({ getState: () => ({}), subscribe: () => () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDarkTheme: () => [false],
|
||||
useDebouncedValue: (value: unknown) => value,
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
useOutsideAlerter: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getDirectoryStructure: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
provider: 'google_drive',
|
||||
directory_structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
},
|
||||
}),
|
||||
})),
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ page: 1, per_page: 12, total: 0, chunks: [] }),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
import ConnectorTree, { providerLabel } from './ConnectorTree';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('providerLabel', () => {
|
||||
it('names known providers and title-cases the rest', () => {
|
||||
expect(providerLabel('google_drive')).toBe('Google Drive');
|
||||
expect(providerLabel('share_point')).toBe('SharePoint');
|
||||
expect(providerLabel('box_sync')).toBe('Box Sync');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectorTree and FileTree headers', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => root.render(node));
|
||||
};
|
||||
|
||||
const badge = () => container.querySelector('[data-slot="badge"]');
|
||||
const retrieval = <button type="button">retrieval</button>;
|
||||
|
||||
it('ConnectorTree shows the provider badge, headerAction and Sync', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()!.textContent).toBe('Google Drive');
|
||||
expect(badge()!.getAttribute('data-variant')).toBe('neutral');
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.sync');
|
||||
});
|
||||
|
||||
it('embedded ConnectorTree drops the badge and headerAction, keeps Sync', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
embedded
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()).toBeNull();
|
||||
expect(container.textContent).not.toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.sync');
|
||||
expect(container.textContent).not.toContain('settings.sources.label');
|
||||
});
|
||||
|
||||
it('embedded FileTree drops headerAction, keeps Add file', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
embedded
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()).toBeNull();
|
||||
expect(container.textContent).not.toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.addFile');
|
||||
});
|
||||
|
||||
const crumbs = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
|
||||
it('ConnectorTree and FileTree open the initialPath file', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
initialPath="b.docx"
|
||||
/>,
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Drive', 'b.docx']);
|
||||
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
initialPath="a.docx"
|
||||
/>,
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import { Check, Cloud, RefreshCw } from 'lucide-react';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { Crumb } from './tree/PathHeader';
|
||||
import TreeBrowser from './tree/TreeBrowser';
|
||||
import type { TreeBrowserController } from './tree/types';
|
||||
import { useReingestSseWaiter } from './tree/useReingestWait';
|
||||
@@ -19,6 +21,38 @@ interface ConnectorTreeProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control, rendered left of the Sync button. */
|
||||
headerAction?: React.ReactNode;
|
||||
/**
|
||||
* Inside another source view (the graph source's Files tab): no Sources
|
||||
* crumb, badge or byline, and no headerAction; Sync stays.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/** Embedded only: the host header's action slot (see TreeBrowser). */
|
||||
actionsTarget?: HTMLElement | null;
|
||||
/** A file to open once the structure loads (path, file name or display name). */
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
}
|
||||
|
||||
// Provider names are brand names, so they are not translated.
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
share_point: 'SharePoint',
|
||||
confluence: 'Confluence',
|
||||
};
|
||||
|
||||
/**
|
||||
* The display name of a connector provider: a known brand name, else the raw
|
||||
* id with underscores as spaces, title-cased ("box_sync" → "Box Sync").
|
||||
*/
|
||||
export function providerLabel(provider: string): string {
|
||||
const known = PROVIDER_LABELS[provider.toLowerCase()];
|
||||
if (known) return known;
|
||||
return provider
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
@@ -26,6 +60,10 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
embedded = false,
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -106,20 +144,13 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
|
||||
const topRightAction = (
|
||||
<>
|
||||
{headerAction}
|
||||
{embedded ? null : headerAction}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
title={
|
||||
isSyncing
|
||||
? `${t('settings.sources.syncing')} ${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
: t('settings.sources.sync')
|
||||
}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
@@ -133,7 +164,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? 'Done'
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
</>
|
||||
@@ -155,6 +186,18 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
badge={
|
||||
sourceProvider ? (
|
||||
<Badge variant="neutral">
|
||||
<Cloud />
|
||||
{providerLabel(sourceProvider)}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
columnOrder="tokens-first"
|
||||
sortEntries
|
||||
controllerRef={controllerRef}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Trash2 } from 'lucide-react';
|
||||
import { SOURCE_FILE_TREE_ACCEPT_ATTR } from '../constants/fileUpload';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { Crumb } from './tree/PathHeader';
|
||||
import TreeBrowser from './tree/TreeBrowser';
|
||||
import { Button } from './ui/button';
|
||||
import type { MenuOption } from './ui/dropdown-menu';
|
||||
@@ -26,6 +27,17 @@ interface FileTreeProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control, rendered left of "Add file". */
|
||||
headerAction?: React.ReactNode;
|
||||
/**
|
||||
* Inside another source view (the graph source's Files tab): no Sources
|
||||
* crumb, badge or byline, and no headerAction; Add file stays.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/** Embedded only: the host header's action slot (see TreeBrowser). */
|
||||
actionsTarget?: HTMLElement | null;
|
||||
/** A file to open once the structure loads (path, file name or display name). */
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
}
|
||||
|
||||
const FileTree: React.FC<FileTreeProps> = ({
|
||||
@@ -33,6 +45,10 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
embedded = false,
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -231,7 +247,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
// Add file button is suppressed then.
|
||||
const topRightAction = (
|
||||
<>
|
||||
{headerAction}
|
||||
{embedded ? null : headerAction}
|
||||
{!isProcessing ? (
|
||||
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
|
||||
{t('settings.sources.addFile')}
|
||||
@@ -263,6 +279,10 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
columnOrder="size-first"
|
||||
sortEntries={false}
|
||||
controllerRef={controllerRef}
|
||||
|
||||
@@ -65,6 +65,28 @@ describe('FileUpload', () => {
|
||||
).toBe('compact');
|
||||
});
|
||||
|
||||
it('shows the current image inside a tile until a new one is picked', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<FileUpload
|
||||
onUpload={vi.fn()}
|
||||
size="tile"
|
||||
showPreview
|
||||
currentImage="https://example.com/agent.png"
|
||||
uploadText="Avatar"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const zone = container.querySelector('[data-slot="dropzone"]')!;
|
||||
expect(zone.getAttribute('data-size')).toBe('tile');
|
||||
const img = zone.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/agent.png');
|
||||
expect(img.className).toContain('object-cover');
|
||||
expect(container.textContent).not.toContain(
|
||||
'components.fileUpload.fileTypes',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders highlighted upload text segments', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
||||
@@ -21,7 +21,11 @@ interface FileUploadProps {
|
||||
showPreview?: boolean;
|
||||
previewSize?: number;
|
||||
/** `compact` is a one-row target for forms and panels. */
|
||||
size?: 'default' | 'compact';
|
||||
size?: 'default' | 'compact' | 'tile';
|
||||
/** A tile's width; `fixed` keeps it 64px in a narrow drawer. */
|
||||
tileSize?: 'responsive' | 'fixed';
|
||||
/** An image already saved (an agent's avatar), shown in a tile until a new one is picked. */
|
||||
currentImage?: string;
|
||||
|
||||
children?: React.ReactNode;
|
||||
/** Layout classes only; the Dropzone owns colours, border and radius. */
|
||||
@@ -53,6 +57,8 @@ export const FileUpload = ({
|
||||
showPreview = false,
|
||||
previewSize = 80,
|
||||
size = 'default',
|
||||
tileSize,
|
||||
currentImage,
|
||||
children,
|
||||
className,
|
||||
uploadText,
|
||||
@@ -196,8 +202,36 @@ export const FileUpload = ({
|
||||
|
||||
// With a preview, the image takes the icon's place; the text block mirrors
|
||||
// the Dropzone's own title/description layout.
|
||||
// A tile shows the picked image (with Remove) or the saved one filling it.
|
||||
const tileImage = preview ?? currentImage;
|
||||
const tileContent =
|
||||
size === 'tile' && showPreview && tileImage ? (
|
||||
<span className="relative size-full">
|
||||
<img
|
||||
src={tileImage}
|
||||
alt={t('components.fileUpload.preview')}
|
||||
className="size-full rounded-xl object-cover"
|
||||
/>
|
||||
{preview ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove();
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 absolute -top-2 -right-2 rounded-full p-1 transition-colors"
|
||||
aria-label={t('components.fileUpload.remove')}
|
||||
>
|
||||
<X className="text-primary-foreground size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
const previewContent =
|
||||
showPreview && preview ? (
|
||||
size === 'tile' ? (
|
||||
tileContent
|
||||
) : showPreview && preview ? (
|
||||
<>
|
||||
{renderPreview()}
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
@@ -225,6 +259,7 @@ export const FileUpload = ({
|
||||
maxSize={maxSize}
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
tileSize={tileSize}
|
||||
title={title}
|
||||
description={description}
|
||||
icon={<ImageUp />}
|
||||
|
||||
@@ -1,103 +1,193 @@
|
||||
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
|
||||
import { ArrowLeft, Network, X } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
|
||||
import ForceGraph2D, {
|
||||
type ForceGraphMethods,
|
||||
type LinkObject,
|
||||
type NodeObject,
|
||||
} from 'react-force-graph-2d';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import { useThemeVersion } from '../utils/chartUtils';
|
||||
import { Badge } from './ui/badge';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { SectionHeader } from './ui/section-header';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import { formatCount } from '../utils/dateTimeUtils';
|
||||
import GraphCanvasControls from './graph/GraphCanvasControls';
|
||||
import GraphEntitySearch from './graph/GraphEntitySearch';
|
||||
import GraphNodePanel, {
|
||||
GraphNodePanelDock,
|
||||
type GraphNodeRef,
|
||||
} from './graph/GraphNodePanel';
|
||||
import { GraphSeriesDot } from './graph/GraphTypeDot';
|
||||
import {
|
||||
ForceGraphData,
|
||||
GraphNode,
|
||||
GraphNodeDetail,
|
||||
GraphOverview,
|
||||
DIM_ALPHA,
|
||||
OTHER_GROUP_KEY,
|
||||
buildAdjacency,
|
||||
endpointId,
|
||||
focusSet,
|
||||
legendGroupOf,
|
||||
linkTouches,
|
||||
nodeHasLabel,
|
||||
pickLabels,
|
||||
topHubIds,
|
||||
type LabelBox,
|
||||
} from './graph/graphCanvasUtils';
|
||||
import { useGraphNodeDetail } from './graph/useGraphNodeDetail';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { EmptyState } from './ui/empty-state';
|
||||
import { LoadingState } from './ui/loading-state';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
|
||||
import {
|
||||
type FoldedGraphTypes,
|
||||
type ForceGraphData,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
collideRadius,
|
||||
escapeDeselects,
|
||||
maxDegree,
|
||||
nodeAtPoint,
|
||||
nodeRadius,
|
||||
otherTypesList,
|
||||
readGraphPalette,
|
||||
toForceGraphData,
|
||||
} from './graphViewUtils';
|
||||
|
||||
/** The overview sizes the "Show top" control offers. */
|
||||
const GRAPH_LIMITS = [50, 100, 250] as const;
|
||||
|
||||
export type GraphLoadStatus = 'loading' | 'error' | 'ready';
|
||||
|
||||
interface GraphViewProps {
|
||||
docId: string;
|
||||
sourceName: string;
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control, right-aligned in the title row. */
|
||||
headerAction?: React.ReactNode;
|
||||
/** The loaded overview (top nodes by degree and the edges among them). */
|
||||
data: ForceGraphData;
|
||||
/** The one type fold shared with the header, the entity list and the panel. */
|
||||
fold: FoldedGraphTypes;
|
||||
status: GraphLoadStatus;
|
||||
onRetry: () => void;
|
||||
limit: number;
|
||||
onLimitChange: (limit: number) => void;
|
||||
selected: GraphNodeRef | null;
|
||||
onSelect: (node: GraphNodeRef | null) => void;
|
||||
/** False while another tab is showing; Escape then leaves the selection alone. */
|
||||
active?: boolean;
|
||||
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
}
|
||||
|
||||
const GRAPH_LIMIT = 100;
|
||||
const HIT_SLOP = 4;
|
||||
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
|
||||
|
||||
const HIT_SLOP = 4;
|
||||
const ZOOM_STEP = 1.3;
|
||||
const ZOOM_MS = 200;
|
||||
const FOCUS_MS = 400;
|
||||
const FOCUS_ZOOM = 2;
|
||||
const FIT_PADDING = 40;
|
||||
const LABEL_PX = 11;
|
||||
/** Screen-pixel clearance kept between two labels. */
|
||||
const LABEL_GAP_PX = 2;
|
||||
|
||||
/**
|
||||
* The Graph tab of a knowledge-graph source: entity search, the "Show top"
|
||||
* size, the type legend (a filter), and the canvas with its controls and the
|
||||
* docked node panel (a bottom sheet on a phone).
|
||||
*/
|
||||
const GraphView: React.FC<GraphViewProps> = ({
|
||||
docId,
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
data,
|
||||
fold,
|
||||
status,
|
||||
onRetry,
|
||||
limit,
|
||||
onLimitChange,
|
||||
selected,
|
||||
onSelect,
|
||||
active = true,
|
||||
onOpenInFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNodeDetail | null>(
|
||||
null,
|
||||
);
|
||||
const [loadingNode, setLoadingNode] = useState(false);
|
||||
const { isDesktop } = useMediaQuery();
|
||||
const showTopId = useId();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoveredNodeIdRef = useRef<string | null>(null);
|
||||
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
|
||||
const [size, setSize] = useState({ width: 0, height: 480 });
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphEdge> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const hoveredIdRef = useRef<string | null>(null);
|
||||
const fittedRef = useRef(false);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
|
||||
// The canvas can't read CSS variables: resolve the tokens, and re-read them
|
||||
// whenever the theme changes.
|
||||
const themeVersion = useThemeVersion();
|
||||
// themeVersion is not used inside the factory: it only signals a change.
|
||||
const palette = useMemo(() => readGraphPalette(), [themeVersion]);
|
||||
const fadedLink = `color-mix(in srgb, ${palette.link} 15%, transparent)`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
userService
|
||||
.getSourceGraph(docId, token, GRAPH_LIMIT)
|
||||
.then((response) => response.json())
|
||||
.then((body) => {
|
||||
if (cancelled) return;
|
||||
const overview: GraphOverview = {
|
||||
nodes: body?.nodes ?? [],
|
||||
edges: body?.edges ?? [],
|
||||
};
|
||||
setData(toForceGraphData(overview));
|
||||
})
|
||||
.catch((error) => console.error('Error loading graph:', error))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [docId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const element = containerRef.current;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setSize({ width: element.clientWidth, height: 480 });
|
||||
});
|
||||
observer.observe(element);
|
||||
setSize({ width: element.clientWidth, height: 480 });
|
||||
return () => observer.disconnect();
|
||||
}, [loading, data.nodes.length]);
|
||||
const selectedId = selected?.id ?? null;
|
||||
const nodeDetail = useGraphNodeDetail(docId, selectedId);
|
||||
|
||||
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
|
||||
const nodeById = useMemo(
|
||||
() => new Map(data.nodes.map((node) => [node.id, node])),
|
||||
[data.nodes],
|
||||
);
|
||||
const loadedIds = useMemo(() => new Set(nodeById.keys()), [nodeById]);
|
||||
const adjacency = useMemo(() => buildAdjacency(data.links), [data.links]);
|
||||
const focus = useMemo(
|
||||
() => focusSet(selectedId, adjacency, loadedIds),
|
||||
[selectedId, adjacency, loadedIds],
|
||||
);
|
||||
|
||||
const legendKeys = useMemo(
|
||||
() => [
|
||||
...fold.groups.map((group) => group.key),
|
||||
...(fold.other.count > 0 ? [OTHER_GROUP_KEY] : []),
|
||||
],
|
||||
[fold],
|
||||
);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(node: GraphNode | undefined) =>
|
||||
!!node && !hidden.has(legendGroupOf(fold, node.type)),
|
||||
[fold, hidden],
|
||||
);
|
||||
const visibleNodes = useMemo(
|
||||
() => data.nodes.filter((node) => isVisible(node)),
|
||||
[data.nodes, isVisible],
|
||||
);
|
||||
|
||||
// The busiest of what's shown keep their labels.
|
||||
const hubs = useMemo(() => topHubIds(visibleNodes), [visibleNodes]);
|
||||
|
||||
// A new overview (another "Show top") starts with a fresh fit.
|
||||
useEffect(() => {
|
||||
fittedRef.current = false;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => {
|
||||
// A hidden tab measures 0: keep the last size so the canvas survives.
|
||||
if (element.clientWidth === 0) return;
|
||||
setSize({ width: element.clientWidth, height: element.clientHeight });
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
measure();
|
||||
return () => observer.disconnect();
|
||||
}, [status, data.nodes.length]);
|
||||
|
||||
const hasCanvas = size.width > 0;
|
||||
useEffect(() => {
|
||||
if (!fgRef.current || data.nodes.length === 0) return;
|
||||
fgRef.current.d3Force(
|
||||
@@ -107,239 +197,480 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
),
|
||||
);
|
||||
fgRef.current.d3ReheatSimulation();
|
||||
}, [data, maxNodeDegree, size.width]);
|
||||
}, [data, maxNodeDegree, hasCanvas]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setLoadingNode(true);
|
||||
setSelectedNode(null);
|
||||
userService
|
||||
.getSourceGraphNode(docId, node.id, token)
|
||||
.then((response) => response.json())
|
||||
.then((body) => {
|
||||
if (body?.node) setSelectedNode(body.node as GraphNodeDetail);
|
||||
})
|
||||
.catch((error) => console.error('Error loading graph node:', error))
|
||||
.finally(() => setLoadingNode(false));
|
||||
};
|
||||
// A settled simulation stops drawing, so paint changes once.
|
||||
const repaint = useCallback(() => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
repaint();
|
||||
}, [palette, focus, hidden, fold, repaint]);
|
||||
|
||||
const fitView = useCallback(
|
||||
(durationMs = FOCUS_MS) => {
|
||||
fgRef.current?.zoomToFit(durationMs, FIT_PADDING, (node) =>
|
||||
isVisible(node as GraphNode),
|
||||
);
|
||||
},
|
||||
[isVisible],
|
||||
);
|
||||
|
||||
// Centre the selection; zoom in once per new selection. Re-centre when the
|
||||
// docked panel changes the canvas width.
|
||||
const zoomedForRef = useRef<string | null>(null);
|
||||
const hadSelectionRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const fg = fgRef.current;
|
||||
if (!fg) return;
|
||||
if (!selectedId) {
|
||||
zoomedForRef.current = null;
|
||||
if (!hadSelectionRef.current) return;
|
||||
hadSelectionRef.current = false;
|
||||
// Deselecting hands the width back to the canvas: refit.
|
||||
const frame = requestAnimationFrame(() => fitView());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
hadSelectionRef.current = true;
|
||||
const node = nodeById.get(selectedId) as PositionedNode | undefined;
|
||||
if (!node || node.x == null || node.y == null) return;
|
||||
const { x, y } = node;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
fg.centerAt(x, y, FOCUS_MS);
|
||||
if (zoomedForRef.current !== selectedId) {
|
||||
zoomedForRef.current = selectedId;
|
||||
fg.zoom(Math.max(fg.zoom(), FOCUS_ZOOM), FOCUS_MS);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [selectedId, nodeById, size.width, fitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !selectedId) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (escapeDeselects(event)) onSelect(null);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [active, selectedId, onSelect]);
|
||||
|
||||
// Geometric hit test, immune to canvas read-back farbling (e.g. Brave): map
|
||||
// the pointer into graph coordinates and pick the nearest node directly.
|
||||
// the pointer into graph coordinates and pick the nearest visible node.
|
||||
const pickNodeAt = (clientX: number, clientY: number): GraphNode | null => {
|
||||
const fg = fgRef.current;
|
||||
if (!fg || !containerRef.current) return null;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const g = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top);
|
||||
return nodeAtPoint(data.nodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
|
||||
return nodeAtPoint(visibleNodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
|
||||
};
|
||||
|
||||
const repaint = () => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom());
|
||||
};
|
||||
|
||||
// A settled simulation stops drawing, so paint the new colours once.
|
||||
useEffect(() => {
|
||||
repaint();
|
||||
}, [palette]);
|
||||
const onCanvas = (event: React.MouseEvent) =>
|
||||
event.target instanceof HTMLCanvasElement;
|
||||
|
||||
const handlePointerMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const node = pickNodeAt(event.clientX, event.clientY);
|
||||
const node = onCanvas(event)
|
||||
? pickNodeAt(event.clientX, event.clientY)
|
||||
: null;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = node ? 'pointer' : 'default';
|
||||
}
|
||||
const nextId = node?.id ?? null;
|
||||
if (nextId !== hoveredNodeIdRef.current) {
|
||||
hoveredNodeIdRef.current = nextId;
|
||||
if (nextId !== hoveredIdRef.current) {
|
||||
hoveredIdRef.current = nextId;
|
||||
repaint();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = 'default';
|
||||
if (hoveredNodeIdRef.current !== null) {
|
||||
hoveredNodeIdRef.current = null;
|
||||
if (hoveredIdRef.current !== null) {
|
||||
hoveredIdRef.current = null;
|
||||
repaint();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Clicks on the controls strip are not canvas clicks.
|
||||
if (!onCanvas(event)) return;
|
||||
const node = pickNodeAt(event.clientX, event.clientY);
|
||||
if (node) handleNodeClick(node);
|
||||
if (node) onSelect({ id: node.id, name: node.name, type: node.type });
|
||||
else if (selectedId) onSelect(null);
|
||||
};
|
||||
|
||||
const isEmpty = !loading && data.nodes.length === 0;
|
||||
const nodeVisibility = useCallback(
|
||||
(node: NodeObject<GraphNode>) => isVisible(node as GraphNode),
|
||||
[isVisible],
|
||||
);
|
||||
const linkVisibility = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) =>
|
||||
isVisible(nodeById.get(endpointId(link.source as never))) &&
|
||||
isVisible(nodeById.get(endpointId(link.target as never))),
|
||||
[isVisible, nodeById],
|
||||
);
|
||||
const linkColor = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) => {
|
||||
if (!focus) return palette.link;
|
||||
return linkTouches(link as GraphEdge, selectedId)
|
||||
? palette.primary
|
||||
: fadedLink;
|
||||
},
|
||||
[focus, selectedId, palette, fadedLink],
|
||||
);
|
||||
const linkWidth = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) =>
|
||||
focus && linkTouches(link as GraphEdge, selectedId) ? 1.5 : 1,
|
||||
[focus, selectedId],
|
||||
);
|
||||
const nodeVal = useCallback(
|
||||
(node: NodeObject<GraphNode>) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree),
|
||||
[maxNodeDegree],
|
||||
);
|
||||
|
||||
const nodeCanvasObject = useCallback(
|
||||
(
|
||||
node: NodeObject<GraphNode>,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
globalScale: number,
|
||||
) => {
|
||||
const graphNode = node as PositionedNode;
|
||||
if (graphNode.x == null || graphNode.y == null) return;
|
||||
const { x, y } = graphNode;
|
||||
const r = nodeRadius(graphNode.degree, maxNodeDegree);
|
||||
const hoveredId = hoveredIdRef.current;
|
||||
const isSelected = graphNode.id === selectedId;
|
||||
const series = fold.seriesOf(graphNode.type);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = focus && !focus.has(graphNode.id) ? DIM_ALPHA : 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle =
|
||||
series == null
|
||||
? palette.other
|
||||
: (palette.series[series] ?? palette.other);
|
||||
ctx.fill();
|
||||
if (isSelected || graphNode.id === hoveredId) {
|
||||
ctx.lineWidth = (isSelected ? 2.5 : 1.5) / globalScale;
|
||||
ctx.strokeStyle = palette.hoverStroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
[maxNodeDegree, selectedId, fold, focus, palette],
|
||||
);
|
||||
|
||||
// Labels go on top of every node (a per-node label would sit under the
|
||||
// nodes drawn after it): one pass after the frame. In priority order (the
|
||||
// selection, the hovered node, then by degree) a label that would sit on
|
||||
// one already placed is skipped; the first two always draw.
|
||||
const drawLabels = useCallback(
|
||||
(ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const hoveredId = hoveredIdRef.current;
|
||||
const rank = (node: GraphNode) =>
|
||||
node.id === selectedId ? 0 : node.id === hoveredId ? 1 : 2;
|
||||
const labelled = (visibleNodes as PositionedNode[])
|
||||
.filter(
|
||||
(node) =>
|
||||
node.x != null &&
|
||||
node.y != null &&
|
||||
nodeHasLabel(node.id, { hubs, focus, hoveredId }),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
(b.degree || 0) - (a.degree || 0) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
ctx.save();
|
||||
ctx.font = `${LABEL_PX / globalScale}px ${palette.font}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = 3 / globalScale;
|
||||
ctx.lineJoin = 'round';
|
||||
const boxes: (LabelBox & { label: string })[] = labelled.map((node) => {
|
||||
const label = node.name ?? '';
|
||||
return {
|
||||
id: node.id,
|
||||
label,
|
||||
x: node.x as number,
|
||||
y:
|
||||
(node.y as number) +
|
||||
nodeRadius(node.degree, maxNodeDegree) +
|
||||
2 / globalScale,
|
||||
width: ctx.measureText(label).width,
|
||||
height: LABEL_PX / globalScale,
|
||||
always: rank(node) < 2,
|
||||
};
|
||||
});
|
||||
const shown = pickLabels(boxes, LABEL_GAP_PX / globalScale);
|
||||
for (const box of boxes) {
|
||||
if (!shown.has(box.id)) continue;
|
||||
ctx.strokeStyle = palette.halo;
|
||||
ctx.strokeText(box.label, box.x, box.y);
|
||||
ctx.fillStyle = palette.label;
|
||||
ctx.fillText(box.label, box.x, box.y);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
[visibleNodes, hubs, focus, maxNodeDegree, palette, selectedId],
|
||||
);
|
||||
|
||||
// Once a new layout settles: fit it, or, with a node selected, centre on
|
||||
// that node (its position only exists now; the focus effect ran before).
|
||||
const handleEngineStop = useCallback(() => {
|
||||
if (fittedRef.current) return;
|
||||
fittedRef.current = true;
|
||||
const node = selectedId
|
||||
? (nodeById.get(selectedId) as PositionedNode | undefined)
|
||||
: undefined;
|
||||
if (node && node.x != null && node.y != null) {
|
||||
fgRef.current?.centerAt(node.x, node.y, FOCUS_MS);
|
||||
} else {
|
||||
fitView();
|
||||
}
|
||||
}, [fitView, selectedId, nodeById]);
|
||||
|
||||
// The graph reports zoom while it renders (a prop update can move the
|
||||
// view), so the readout follows on the next frame, at most once a frame.
|
||||
const zoomFrameRef = useRef<number | null>(null);
|
||||
const handleZoom = useCallback(({ k }: { k: number }) => {
|
||||
if (zoomFrameRef.current !== null)
|
||||
cancelAnimationFrame(zoomFrameRef.current);
|
||||
zoomFrameRef.current = requestAnimationFrame(() => {
|
||||
zoomFrameRef.current = null;
|
||||
setZoom(k);
|
||||
});
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (zoomFrameRef.current !== null)
|
||||
cancelAnimationFrame(zoomFrameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const zoomBy = (factor: number) => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom() * factor, ZOOM_MS);
|
||||
};
|
||||
|
||||
const legendValue = legendKeys.filter((key) => !hidden.has(key));
|
||||
const otherLabels = fold.other.labels;
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<GraphEntitySearch docId={docId} fold={fold} onPick={onSelect} />
|
||||
{/* One phrase at one size: "Show top [50 | 100 | 250] by
|
||||
connections"; the muted track holds only the group. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span id={showTopId} className="text-muted-foreground text-sm">
|
||||
{t('settings.sources.graphrag.view.showTop')}
|
||||
</span>
|
||||
<div className="bg-muted shrink-0 rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
className="flex-nowrap"
|
||||
value={String(limit)}
|
||||
onValueChange={(value) => value && onLimitChange(Number(value))}
|
||||
aria-labelledby={showTopId}
|
||||
>
|
||||
{GRAPH_LIMITS.map((option) => (
|
||||
<ToggleGroupItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t('settings.sources.graphrag.view.byConnections')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{legendKeys.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
size="xs"
|
||||
value={legendValue}
|
||||
onValueChange={(values) =>
|
||||
setHidden(
|
||||
new Set(legendKeys.filter((key) => !values.includes(key))),
|
||||
)
|
||||
}
|
||||
aria-label={t('settings.sources.graphrag.view.typeFilter')}
|
||||
>
|
||||
{fold.groups.map((group) => (
|
||||
<ToggleGroupItem key={group.key} value={group.key}>
|
||||
<GraphSeriesDot series={group.series} />
|
||||
{group.label}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatCount(group.count)}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{fold.other.count > 0 ? (
|
||||
<ToggleGroupItem value={OTHER_GROUP_KEY}>
|
||||
<GraphSeriesDot series={null} />
|
||||
{t('settings.analytics.otherSeries')}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatCount(fold.other.count)}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
) : null}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{otherLabels.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.otherTypes', {
|
||||
types: otherTypesList(otherLabels, (count) =>
|
||||
t('settings.sources.graphrag.view.otherTypesMore', {
|
||||
count,
|
||||
formatted: formatCount(count),
|
||||
}),
|
||||
),
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
tone="destructive"
|
||||
illustration="none"
|
||||
title={t('settings.sources.graphrag.view.loadFailed')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'ready' && data.nodes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t('settings.sources.graphrag.view.empty')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const panel = selected ? (
|
||||
<GraphNodePanel
|
||||
key={selected.id}
|
||||
docId={docId}
|
||||
node={selected}
|
||||
detail={nodeDetail.detail}
|
||||
status={nodeDetail.status}
|
||||
onRetry={nodeDetail.retry}
|
||||
fold={fold}
|
||||
onClose={() => onSelect(null)}
|
||||
showClose={isDesktop}
|
||||
onSelectNode={onSelect}
|
||||
overview={data}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4 flex items-center">
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
onClick={onBackToDocuments}
|
||||
label={t('settings.sources.backToAll')}
|
||||
icon={ArrowLeft}
|
||||
side="bottom"
|
||||
/>
|
||||
<span className="text-primary font-semibold wrap-break-word">
|
||||
{sourceName}
|
||||
</span>
|
||||
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
|
||||
<Network className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.sources.graphrag.view.title')}
|
||||
</span>{' '}
|
||||
{t('settings.sources.graphrag.view.explainer')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonLoader count={4} />
|
||||
) : isEmpty ? (
|
||||
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
|
||||
<Network className="size-7" aria-hidden="true" />
|
||||
<p>{t('settings.sources.graphrag.view.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.stats', {
|
||||
nodes: data.nodes.length,
|
||||
edges: data.links.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onClick={handleContainerClick}
|
||||
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
|
||||
>
|
||||
{size.width > 0 && (
|
||||
<ForceGraph2D
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolbar}
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="none"
|
||||
className="h-[70svh] flex-row gap-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onClick={handleContainerClick}
|
||||
className="relative min-w-0 flex-1"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<LoadingState fill="parent" />
|
||||
) : (
|
||||
<>
|
||||
{hasCanvas && (
|
||||
<ForceGraph2D<GraphNode, GraphEdge>
|
||||
ref={fgRef}
|
||||
graphData={data}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
nodeRelSize={1}
|
||||
minZoom={0.2}
|
||||
maxZoom={8}
|
||||
enablePointerInteraction={false}
|
||||
enableNodeDrag={false}
|
||||
nodeVal={(node) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree)
|
||||
}
|
||||
linkColor={() => palette.link}
|
||||
nodeVal={nodeVal}
|
||||
nodeVisibility={nodeVisibility}
|
||||
linkVisibility={linkVisibility}
|
||||
linkColor={linkColor}
|
||||
linkWidth={linkWidth}
|
||||
cooldownTicks={80}
|
||||
nodeCanvasObject={(node, ctx, globalScale) => {
|
||||
const graphNode = node as GraphNode & {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
if (graphNode.x == null) return;
|
||||
const r = nodeRadius(graphNode.degree, maxNodeDegree);
|
||||
const hovered = hoveredNodeIdRef.current === graphNode.id;
|
||||
ctx.beginPath();
|
||||
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = palette.node;
|
||||
ctx.fill();
|
||||
if (hovered) {
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = palette.hoverStroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
if (globalScale >= 1.2) {
|
||||
const label = graphNode.name ?? '';
|
||||
const x = graphNode.x;
|
||||
const y = (graphNode.y ?? 0) + r + 1;
|
||||
ctx.font = `${10 / globalScale}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = 3 / globalScale;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = palette.halo;
|
||||
ctx.strokeText(label, x, y);
|
||||
ctx.fillStyle = palette.label;
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}}
|
||||
onEngineStop={handleEngineStop}
|
||||
onZoom={handleZoom}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
onRenderFramePost={drawLabels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-border bg-card flex max-h-[480px] w-full shrink-0 flex-col overflow-y-auto rounded-xl border p-4 lg:w-80">
|
||||
{loadingNode ? (
|
||||
<SkeletonLoader count={3} />
|
||||
) : selectedNode ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
className="wrap-break-word"
|
||||
title={selectedNode.name}
|
||||
/>
|
||||
{selectedNode.type && (
|
||||
<Badge variant="neutral" className="mt-1">
|
||||
{selectedNode.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
label={t('settings.sources.graphrag.view.close')}
|
||||
icon={X}
|
||||
side="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNode.description && (
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
|
||||
{selectedNode.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="xs"
|
||||
title={t('settings.sources.graphrag.view.linkedChunks')}
|
||||
/>
|
||||
{selectedNode.chunks.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.noChunks')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{selectedNode.chunks.map((chunk) => (
|
||||
<li
|
||||
key={chunk.chunk_id}
|
||||
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
|
||||
>
|
||||
{chunk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{!selected ? (
|
||||
<p className="text-muted-foreground pointer-events-none absolute top-3 left-3 text-xs">
|
||||
{t('settings.sources.graphrag.view.selectNode')}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
<GraphCanvasControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => zoomBy(ZOOM_STEP)}
|
||||
onZoomOut={() => zoomBy(1 / ZOOM_STEP)}
|
||||
onFit={() => fitView()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDesktop && panel ? (
|
||||
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
|
||||
) : null}
|
||||
</Card>
|
||||
{!isDesktop ? (
|
||||
<Sheet
|
||||
open={!!selected && active}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSelect(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
handle
|
||||
title={selected?.name}
|
||||
// Open on the panel, not with a ring on its first control.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{panel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { markdownHeadings } from '@/lib/markdown';
|
||||
import { markdownHeadings, markdownTables } from '@/lib/markdown';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...markdownHeadings,
|
||||
...markdownTables,
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<Button variant="link" size="inline" asChild>
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user