Big source revamp

This commit is contained in:
Pavel committed 2026-09-28 17:35:16 +04:00
1 parent 4070b03053
commit 706a0cb2b2
103 files changed
+13687 -1937

No files matched your search

+4
View File
@@ -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"),
+85 -20
View File
@@ -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
)
+175 -7
View File
@@ -545,7 +545,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 +1247,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 +1342,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() 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,
+406 -4
View File
@@ -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:
+149 -1
View File
@@ -1,10 +1,158 @@
"""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, urldefrag, urlencode, urlparse
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.
Args:
urls: Page URLs about to be ingested together.
Returns:
True when paths alone could collide and need a host prefix.
"""
return len({urlparse(u).netloc for u in urls}) > 1
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): 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) 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 = urldefrag(str(info.get("source") or path))[0]
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."""
+4 -27
View File
@@ -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, 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
@@ -73,30 +72,8 @@ class CrawlerLoader(BaseRemote):
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)
+4 -28
View File
@@ -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, 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
@@ -91,7 +90,7 @@ class CrawlerLoader(BaseRemote):
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:
@@ -160,28 +159,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)
+15 -3
View File
@@ -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:
+15 -5
View File
@@ -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)
+34
View File
@@ -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,32 @@ 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.
"""
new_chunk_id = self.add_chunk(text, metadata)
if not self.delete_chunk(chunk_id):
logging.warning(
"Failed to delete old chunk %s, but new chunk %s was created",
chunk_id,
new_chunk_id,
)
return new_chunk_id
def delete_chunks_by_source_path(self, path) -> int:
"""Delete every chunk whose ``metadata.source`` equals ``path``.
+52
View File
@@ -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])
+63 -1
View File
@@ -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
+53 -1
View File
@@ -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()
+44
View File
@@ -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:
+217 -32
View File
@@ -71,7 +71,12 @@ ones `warning`. Series with no meaning (models, agents, sources) take
`chart-1` to `chart-5` in order. When there are more than five, the four
largest keep `chart-1` to `chart-4` and the rest are summed into one
"Other" series in `chart-5`, so no colour repeats (`settings/foldSeries.ts`).
`secondary` is never a chart colour.
`secondary` is never a chart colour. The knowledge graph is the one
place Other is not `chart-5`: its entity types have no meaning to map, and
half a graph often folds into Other, so red would read as "these failed".
There the four largest types take `chart-1` to `chart-4` and Other is
`muted-foreground` (`foldGraphTypes`, `readGraphPalette` in
`components/graphViewUtils.ts`).
The status set is `success | warning | destructive | info`. `default` is the
component's own base tone (brand on a Badge or Button, quiet on an Alert or
@@ -266,8 +271,17 @@ aria-current="page">` with `data-active`. The
the same pixels, plus `role="tablist"`/`"tab"`, `aria-selected` and
arrow-key focus; wrap the panel in `TabsContent` (FilePicker's My Files /
Shared with Me, Schedules' Recurring / One-time in
`agents/schedules/SchedulesView.tsx`). The `default` variant is a pill tab,
unused in the app.
`agents/schedules/SchedulesView.tsx`, the graph source view, the source
edit drawer). Each trigger keeps its `px-4`, so the first label sits 16px
in from the content edge and the underline runs past the label on both
sides. That inset is deliberate (decided 2026-09-28): the tab row reads as
its own strip, with a wider target per tab, so don't pull it flush with
`-ml-4` or strip the padding. Only the workflow builder's toolbar, where
the tabs share a row with a breadcrumb, uses the padding-free `Button
variant="tab" size="inline"`. The `default` variant is a pill tab, unused
in the app. Panels unmount when hidden, except one whose state is costly to
rebuild (the graph source view's laid-out canvas and zoom): that
`TabsContent` takes `forceMount` plus `data-[state=inactive]:hidden`.
- A section panel's disclosure header (NewAgent's Advanced and Guardrails
panels) is `variant="section-toggle" size="sm"` with `-ml-3 w-fit
justify-start` and `aria-expanded`: a lucide `ChevronRight` first
@@ -499,7 +513,10 @@ that already draws the frame (the renaming sidebar row, a search strip in a
bordered panel) is `variant="bare"`; the host shows focus, and any inset
padding goes on the host, not the field. A field on a muted panel (the
ImportSpec Base URL box) is `variant="filled"`, so it keeps the card fill
instead of showing the panel through; never pass `bg-card` for it.
instead of showing the panel through; never pass `bg-card` for it. A floating
label rests at the field's own text size (16px, 14px from `md`), so a
labelled search and a placeholder-only one read the same, and with a
`leftIcon` it rests where the text starts (40px in).
### SelectTrigger (`ui/select.tsx`)
@@ -520,9 +537,29 @@ lg` and CommandInput. The `sm` sizes stay 14px. A highlighted list row is
`none`, `vertical` (default), `both`. Same border, ring and invalid styling
as Input. `variant`: `default` (transparent), `filled` (card fill), with
the same rule as Input: a textarea on a muted panel is `variant="filled"`,
never `bg-card`. Three raw `<textarea>` elements stay, because each sits
under an overlay it must line up with: the chat composer, PromptTextArea's
variable highlighter and the chunk editor behind its line-number gutter.
never `bg-card`. Two raw `<textarea>` elements stay, because each sits
under an overlay it must line up with: the chat composer and PromptTextArea's
variable highlighter.
### Command (`ui/command.tsx`)
`Command` is the list primitive (cmdk) behind comboboxes, pickers, the
search palette and the source navigator; its `variant` is `default` or
`palette` (the search palette's spacing, see Modal, not Dialog).
`CommandInput`'s `variant` is `default`, the `h-9` row with a bottom border
at the top of a popover or palette list, or `field`, the 38px pill of a
list that sits on the page (a source view's navigator filter, see Page
chrome and Source views); nothing else hand-frames it. `CommandList` caps
itself at `max-h-75` and scrolls; a list that already scrolls inside its
host (a `Modal mobileVariant="sheet"` body) passes `max-h-none` so there is
one scroller. `CommandItem` rows are `cursor-default` like Select and menu
items, highlight `bg-accent` and mark the open item `checked`
(`bg-secondary`); don't add `cursor-pointer`. cmdk highlights the first row
by default, which reads as hover. A list used as navigation, where a current
item exists (the source navigator), starts the highlight on that item, or on
a value that matches no row while nothing is open
(`tree/SourceNavigator`'s `NO_HIGHLIGHT`); a search-then-pick list keeps
cmdk's first-row highlight.
### FormField (`ui/form-field.tsx`)
@@ -533,7 +570,9 @@ as its only child. The label sits on the field's border (12px muted), and
line: `card` (the default: forms in cards, modals and `outline` panels),
`background` (fields straight on a page or in a `subtle` panel), `muted`
(fields on a muted panel). Never pass a background class for it. It rests inside an empty, unfocused
`Input` or `Textarea` and moves up on focus; on a `SelectTrigger`, combobox,
`Input` or `Textarea` at the field's own text size (16px, 14px from `md`;
14px on an `sm` Input), where the text starts (40px in beside a `leftIcon`),
and moves up to the 12px border label on focus; on a `SelectTrigger`, combobox,
`MultiSelect`, number field or `Dropzone` it stays on the border. It turns
red (`text-destructive`) with an `error`, and dims while `disabled`. Under
the field come a muted `text-xs` hint and a red `text-xs` error
@@ -557,7 +596,7 @@ FormField with no single box to sit on: a list of checkboxes, several
controls in a row, a loading or error line in place of the field. Controls
whose label sits beside them (Switch, Checkbox, radio) use `SettingRow` or
an inline `Label`, not FormField. An editing surface that fills its area
(the chat composer, the chunk and wiki editors, editing a sent question) and
(the chat composer, the source edit drawer's field, editing a sent question) and
bare repeated rows in a list have no visible label and must have an
`aria-label`. A row box that is a small form (a workflow condition case or
state assignment) is not a bare row: each of its fields has a floating
@@ -686,7 +725,8 @@ art, page / panel / popover), `illustration` `no-files | none` (a "no
results" line is `size="xs" illustration="none"`), `title`, `description`
(plain `muted-foreground`), `action`. A page or panel whose fetch failed is
`EmptyState tone="destructive" illustration="none"` with a `Retry` action
(`t('retry')`): a red `CircleAlert`, a red title, `role="alert"`. Never a bare
(`t('retry')`, an `outline sm pill` Button at every EmptyState size): a red
`CircleAlert`, a red title, `role="alert"`. Never a bare
`text-destructive` paragraph.
### Progress (`ui/progress.tsx`)
@@ -833,12 +873,15 @@ Sources adds its count and a right chevron, and opens the All sources sheet.
an ellipsis. Pages pass only its width (`w-[16ch]`, `max-w-[32ch]`) and a
`title` with the full text; never `truncate` or typography. Give every
current crumb a width cap so a long name cannot push the row past the
screen. A crumb that runs a handler instead of navigating is
screen. It carries the link's focus ring: after a crumb step, PathHeader
moves focus to the new current crumb (`tabIndex={-1}`) so keyboard focus
never drops to the page. A crumb that runs a handler instead of navigating is
`BreadcrumbLink asChild` around a `<button type="button">`; the link carries
the focus ring. The current crumb is never a disabled button. The path row above a
source's file tree and chunk viewer is `components/tree/PathHeader`
(back button, chevron crumbs, the last capped at `max-w-[32ch]`, actions on
the right); don't hand-roll a `/`-separated path.
the focus ring. Parent crumbs cap at `max-w-[16ch]` with `truncate` on the
button itself (a parent with no handler is a plain truncating `<span>`, never
a disabled button). The current crumb is never a disabled button. The header of
every source view is `components/tree/PathHeader` (see Source views); don't
hand-roll a `/`-separated path.
### ListRow and DescriptionList (`ui/list-row.tsx`, `ui/description-list.tsx`)
@@ -849,6 +892,11 @@ bordered list for one). Rows are `px-4 py-3`, the title `text-sm
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
hovers to `bg-accent` and draws an inset focus ring. An icon square in
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
In a narrow side panel (the graph node panel's relationships) rows are
`size="sm"`: `px-2 py-1.5`, `gap-2.5`, `rounded-md` and top-aligned so a small
leading mark (a `GraphTypeDot` with `mt-1.5`) sits on the title line. They go
in a plain `<ul className="-mx-2">`, not `ListRows` (no divide rules), and
are `interactive asChild` around a `<button>` like the others.
Key/value rows are `DescriptionList` + `DescriptionItem`, never a hand-rolled
`flex` of label and value. `layout="columns"` (default) is an 8rem label
@@ -860,9 +908,24 @@ right-aligns the values (a phone card; `columns={2}` for a stats dialog).
### Pagination (`ui/pagination.tsx`)
The pager under a table, tile grid or list: "Page N of M" and four chevron
`IconButton`s. `pageSize` adds the Rows per page select; `summary` puts a
`IconButton`s. `pageSize` adds the Rows per page select (`pageSizeLabel`
renames it when the items aren't rows: the chunk grid's "Chunks per page",
with `pageSizeOptions={[12, 24, 48]}` so a full page fills 2, 3 or 4
columns); `summary` puts a
count on the left ("1,024 users"). `labels="text"` swaps the chevrons for
Previous / Next buttons. Don't hand-roll a Previous / Next row.
Previous / Next buttons. Don't hand-roll a Previous / Next row. With a page
size select, keep the pager while the total exceeds the smallest option, not
the current page size, so picking a bigger size never hides the way back
("Page 1 of 1", chevrons off). Counts and numbers in the UI format on the app
language: `formatCount` (`utils/dateTimeUtils`, `Intl` on `intlLocale()`), never
`toLocaleString()` or a raw `{{count}}`; plural keys get the number as `count`
and the formatted text as its own param. The one exception is a headline
total that can reach tens of thousands (the chunk count in the Chunks byline
and embedded toolbar): it may use `abbreviateCount` (`components/chunkUtils`,
`Intl` compact notation: "12K", "12 тыс.", "1.2万"; grouped digits where the
language has no short form). Counts in tables, pagers and inline text use
`formatCount`. Lists of names join with
`Intl.ListFormat`, never a hard-coded `', '`.
### Page chrome: SectionShell, PageToolbar, SearchInput
@@ -881,8 +944,13 @@ a limit warning), then `divider` (a `Separator`). With no `search` the intro
moves into the row's left slot (`max-w-2xl`) beside the action. A page search
is `components/SearchInput`: the 38px pill (`size="sm"` for 32px) with a
search icon and a floating `label` on `labelSurface="background"` (its
default); a placeholder-only search is named by its placeholder. The chunk viewer's and file tree's searches stay `CommandInput` in
their frame, because their results are `CommandItem`s.
default); a placeholder-only search is named by its placeholder. A source
view's navigator filter is a `CommandInput variant="field"` (the same 38px
pill, text 40px in, the ring on the frame), because its rows are
`CommandItem`s (see Source views and Command). `CommandInput`'s `default` variant is the
`h-9` row with a bottom border at the top of a popover or palette list; the
wrapper carries `data-variant` for styling from the parent. Nothing else
hand-frames a `CommandInput`.
An agent's tabs (Overview, Logs, Schedules) share one header,
`agents/components/AgentPageToolbar` on top of `PageToolbar`, under
@@ -940,6 +1008,116 @@ New Chat is `SquarePen` everywhere: the phone bar, the sidebar's New Chat row
and the collapsed rail. `Plus` means "add an item to this list", not "start
a chat".
### Source views (`components/tree/`, `WikiViewer`, `components/graph/`, `GraphView`)
Every source view (a file or connector tree, a one-document chunk list, a
wiki, a knowledge graph) is one shell:
- **Header**: `PathHeader`, 16px (`gap-4`) above the content in every
source view, crumbs and no back button: Sources (leaves the source view),
the source, its folders, the open file and the open chunk ("Chunk 12"),
each opening its level, the last one current. A wiki's open page is not a
crumb: its crumbs stop at the source, and the page is marked in the
navigator and named by its path in the reader's meta. Up one level is
a crumb, as on the Tools and Teams detail pages (`DetailBreadcrumb`); the
phone's `SectionBackLink` above the title is the only arrow, and it leaves
the section. Crumbs never take `text-primary`; parents are muted like any
crumb, truncate at `max-w-[16ch]` with a `title`, and the row stays on one
line (`flex-nowrap`). A tree embedded in another source view (a graph
source's Files tab) draws no Sources crumb and reports its crumbs to the
host (`onCrumbsChange`), whose header shows them while that tab is open. The
file table has no `..` row. `badge` is the source kind as a `neutral` Badge with a 12px lucide
icon (Living wiki, Knowledge graph, a connector's provider; plain uploads
have none); `byline` is one muted `text-sm` line of counts ("33 files ·
11,420 tokens", "12 pages · 8,111 tokens · …", "371 entities · 1,172
relationships"), where a kind that needs explaining adds one sentence. No
explainer box. Actions on the right: Test retrieval (`outline field pill`)
and the kind's one primary action (Add file, Sync).
- **Navigator**: `SourceNavigator`, a `w-64` column shown only when there is
more than one thing to open (files, wiki pages). A filter `CommandInput
variant="field"` over a `CommandList` of `CommandItem`s, so the arrow keys
walk from the field into the list; the open item is `checked`. Files are a
tree (folders expand, a folder opens its table on the right and expands,
24px of indent per level to depth 4 and 12px per level below it, every row
with its path as `title`); wiki pages are grouped under their top folder as
`CommandGroup` headings, labelled by title or by file name in sentence case
(`wikiPageLabel`). Typing flattens the list to matching leaves with their
full parent path as a muted second line. The column caps its list at
`max-h-[70svh]`. Below `lg` it is a `combobox field pill`
that opens the same list in a bottom sheet, uncapped (the sheet body
scrolls). A source with one file opens
straight on its chunk list, and a wiki with one page on its reader at full
width: no navigator, no table.
- **Reader**: `ReaderPanel`, the content you read (a wiki page, an open
chunk): a `subtle` Card with `padding="none"`, its meta (muted 12px lines)
left and its actions right in a `min-h-14 px-6` header row, a `Separator`,
then the body at `px-6 py-5`. The body is always rendered markdown,
`components/SourceMarkdown` (the chat answer's headings and tables, lists
outside the text); raw text is never the reading view. Actions: an open
chunk's previous / next (`IconButton ghost-muted icon-sm pill`, and the
arrow keys), then Edit (`outline sm pill`, `Pencil` first) and an
`ActionMenu size="toolbar"` (Copy text; a chunk's also has Delete, a
destructive item). A wiki page has Edit (editors only) and the same menu
with Copy text, and no previous / next; the navigator walks them.
- **Read in the page, edit in a drawer**: Edit and Add chunk open
`SourceEditSheet`, a right `Sheet size="wide"` (a working surface): title
and a mono description (path, version, tokens), then any `fields` edited
with the content (a chunk's Title, a `FormField` + `Input` with
`labelSurface="background"` and the hint "The name answers cite this
chunk by."; Add prefills it with the title the file's chunks already use,
else the file's name), Write · Preview
(`ui/tabs variant="underline"`; Preview renders the draft with
`SourceMarkdown`), one filling `font-mono` Textarea, then Cancel (`ghost lg
pill`) and Save (`default lg pill`, off until the draft or a field changes, and while the draft is blank). The drawer
edits a copy: closing with changes asks "Discard your unsaved changes?". A
save result stays in the drawer as an Alert above the field (a wiki
conflict `warning`, forbidden and failures `destructive`); success closes
it and the page re-renders behind. Never edit in place in the reader.
- **Chunk grid**: `SearchInput` ("Search chunks"), then Add chunk at the end.
The count is in the header's byline; only a chunk list inside a file tree,
whose byline is the tree's totals, repeats it as muted meta beside the
search; tiles show `chunkPreviewText` (heading markers and dot
leaders removed, display only) and `#n · tokens` in `CardFooter`.
- **Graph**: under the header, `ui/tabs variant="underline"` Graph · Entities
· Files (Files is the embedded file tree; a source with no folder structure
shows its embedded chunk list there instead, as the plain view does). A tab's own action goes in the
header's action row after Test retrieval, only while that tab is open (the
Files tab's Add file or Sync, portalled through `TreeBrowser`'s
`actionsTarget`), as on the agent pages; the embedded tree draws no row of
its own. The Graph tab's toolbar holds the
entity search (a Popover + Command over the server's `/graph/nodes?q=`),
the phrase "Show top [50 | 100 | 250] by connections" (muted `text-sm`
words, the `ToggleGroup xs` alone in its muted track) and the legend, which
is the type filter (`ToggleGroup type="multiple" xs` in its own muted track,
a colour dot per item). The surface is one `subtle` Card, `padding="none"`, at `h-[70svh]`;
the canvas controls (`graph/GraphCanvasControls`) are the workflow
builder's `CanvasControls` strip without React Flow or Undo / Redo; change
the two together. Type colours come from `graph/GraphTypeDot`
(`GraphTypeDot`, `GraphTypeBadge`, `GraphSeriesDot`), never a hand-picked
hue; the node panel docks
inside it only while a node is selected (a bottom Sheet on phones), as
`GraphNodePanelDock`: a `border-l` column at `w-80`, `xl:w-2/5` capped at
`max-w-xl` (the detail drawer's 576px). Its header is the workflow node
panel's (`px-4 py-3`, the close `ghost-muted icon-sm`); in the phone bottom
Sheet (`handle`) the panel passes `showClose={false}`, so there's no X and the
scrim closes it. The Entities tab is
the same frame: toolbar (`SearchInput` with a `label`, the type Select),
then the `h-[70svh]` Card holding the `Table` (no `TableContainer`, it
scrolls inside) and the same dock, the open entity's row `selected`, and the
pager under the frame. The canvas labels only the ten biggest hubs, the
hovered node and the selection with its neighbours, and dims everything
unconnected to the selection to 15%. The panel lists the node's
relationships, one row per neighbour with its normalised edge labels
(`groupRelationships`), each a button that selects that neighbour, then its
source chunks as `filled sm interactive` tiles. A tile opens a read drawer
(`Sheet size="detail"`, one record) with the chunk rendered and the entity's
name marked in the brand tint (`bg-secondary`), and Open in Files and Edit
(the same `SourceEditSheet`) in its footer; Cancel or Discard in that edit
drawer returns to the read drawer, Save closes both, and closing it leaves
the node selected. The relationship list is capped server-side: its heading
counts the true total (`relationships_total`) and, when the list is partial,
a muted `text-xs` line says how many are shown.
### Chat answer column
The answer column never scrolls sideways. Its boxes, from AnswerFlow down to
@@ -961,7 +1139,7 @@ columns (`lg:grid-cols-3`): their header row holds an initial, the name, a
role badge and a chevron. Stat rows: `grid grid-cols-2 gap-4 md:grid-cols-4`
(five tiles: `md:grid-cols-3 lg:grid-cols-5`; a dialog: `grid-cols-3`). Text
tiles whose body is the content (chunks) follow their container, since the
same list renders beside a file-search column: `grid grid-cols-1 gap-4
same list renders beside the source navigator: `grid grid-cols-1 gap-4
sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]`. A
grid inside a Modal keeps its own column counts, because breakpoints follow
the window, not the dialog.
@@ -1166,12 +1344,17 @@ this menu from `DropdownMenu`, and don't declare a local option type.
### Table, Label, dialog text
Every table is `ui/table`; never a raw `<table>` (enforced) or a table
utility class (the chat's markdown tables are the one exception, see Approved
exceptions). The header row is dense by
Every table is `ui/table`, markdown tables included (`markdownTables` renders
its parts); never a raw `<table>` (enforced) or a table utility class. The header row is dense by
default (`px-2 py-1 lg:px-3 text-sm font-normal text-foreground`, 28px, on
`TableHead`'s sticky `bg-muted` strip). A `TableRow` hovers (`bg-accent`,
pointer) only when it has an `onClick`; a read-only row does not. Use
pointer) only when it has an `onClick`; a read-only row does not. `selected`
marks the row whose detail is open beside the table (the graph's Entities):
`bg-secondary`, the brand tint of an open navigator item, kept on hover, with
`aria-current`. Use
A number column (sizes, tokens, counts) is `align="right"` with
`tabular-nums` on its cells, header included in the alignment, whatever the
column order (the file table, the graph's Entities). Use
`TableContainer` for the bordered, scrolling frame; a table that already sits
in a frame renders `Table` alone. `Table` keeps a 600px minimum so wide
tables scroll sideways; a narrow one inside a panel passes
@@ -1215,7 +1398,7 @@ runs under the tab bar, and the app shell can be dragged and bounce. ESLint
dropdown's `max-h-[calc(100dvh-200px)]`, `Modal`'s `85dvh`).
- **`svh`** (the smallest viewport, steady): a fixed-size panel inside a page
that scrolls (the Logs panel's `h-[55svh]`, a list's `max-h-[45svh]`, the
chunk editor's `min-h`), so it doesn't resize while Safari's bars slide in
graph surface's `h-[70svh]`, the source edit drawer's `min-h`), so it doesn't resize while Safari's bars slide in
and out.
- **`max-h-sheet`**: every bottom sheet (see Modal, not Dialog).
- A row or control has a fixed height (`h-12`), never a share of the viewport.
@@ -1246,7 +1429,11 @@ leading-tight font-semibold`. `DialogTitle` and `SheetTitle` default to it;
a heading: `text-sm text-muted-foreground`.
- **Markdown**: every renderer spreads `markdownHeadings` from
`lib/markdown.tsx` (h1 20px, h2 18px, h3 16px, semibold, `mt-4|3 mb-2`);
don't declare a local heading map.
don't declare a local heading map. Tables are `markdownTables` from the same
file: the `ui/table` parts in a `TableContainer` (the frame scrolls
sideways, `minWidth="min-w-0"` so a narrow table shrinks), so a table in a
chat answer, a wiki page, a chunk, a note or a preview looks like every
other table. Every markdown renderer spreads it.
- **Mono**: ids, keys and code snippets are `font-mono text-xs`; code fields
(textareas) follow the field size; code blocks follow the Card recipe
(see Card). A preview of text a person or a model wrote (a trace's query
@@ -1325,7 +1512,9 @@ shows.
keyboard open the trigger did have focus and still gets it back. Don't pass
`onCloseAutoFocus` from app code to remember focus yourself (ESLint rejects
it); the primitive already does. `onOpenAutoFocus` with `preventDefault()`
is still how a phone picker keeps the keyboard down (`MultiSelectPopover`).
is still how a phone picker keeps the keyboard down (`MultiSelectPopover`),
and how a phone panel sheet opens without a focus ring on its first control
(the graph node panel's bottom Sheet).
A dialog panel is a `tabIndex=-1` focus target that Radix can focus, so it
carries `outline-none`: Safari draws its own blue `outline: auto` there and
ignores our `outline-color`. Any new focusable container needs the same.
@@ -1348,10 +1537,7 @@ shadow-lg` in both themes: the knob is white on any track, and a white knob
panels and have none. Outside `ui/` the only shadows are: the workflow
canvas nodes (`shadow-md`, `hover:shadow-lg`: a node lifts off the canvas
while you drag it); the workflow builder's publish-error panel, which
floats over the canvas at popover elevation (`shadow-md`, `z-20`); the
chunk viewer's and file tree's search result
lists (`shadow-md`, in-page `z-20`, their results are `CommandItem`s inside
the search frame); the sliding panel in `navigation/SidebarLevel.tsx`,
floats over the canvas at popover elevation (`shadow-md`, `z-20`); the sliding panel in `navigation/SidebarLevel.tsx`,
which casts a horizontal shadow while it slides; and the Hero model
picker's menu (both Approved exceptions). Don't add new ones: a floating panel is a `Popover`,
`DropdownMenu` or `Modal`, which carry their own shadow and stacking; never
@@ -1408,7 +1594,6 @@ list stays reviewable.
| `admin/Overview.tsx` | `shadcn/no-restyle` | "View in Audit" under the denied-sign-ins tile keeps the tile's destructive tone at hint size (`text-destructive text-xs font-normal`). One disable. |
| `settings/PairDeviceModal.tsx` | `shadcn/no-restyle` | The install link in Pair a remote machine sits in a 12px hint (`text-xs font-normal`, `self-start` in its column). One disable. |
| `agents/workflow/WorkflowBuilder.tsx` | `shadcn/no-restyle` | The publish-error Alert floats over the canvas with its close button in the top-right corner, so it pads `pr-10` to keep a long title clear of the button. One disable. |
| `conversation/MarkdownAnswer.tsx` | `no-restricted-syntax` | Markdown answer tables render the model's own table markup with its own styling, so they stay a raw `<table>`. |
Note that a multi-line reason has to be a `/* ... */` block comment;
consecutive `//` lines only disable the next comment line, not the code.
+13
View File
@@ -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}`,
+9
View File
@@ -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,
+2 -1
View File
@@ -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';
@@ -197,6 +197,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
...markdownTables,
code(props) {
const {
children,
+750 -107
View File
@@ -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,129 +103,726 @@ 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}
{...props}
/>,
);
});
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();
const tile = () =>
container.querySelector<HTMLButtonElement>('button[data-slot="card"]');
await act(async () => setInputValue(input, 'contracts'));
const items = container.querySelectorAll('[data-slot="command-item"]');
expect(items).toHaveLength(2);
const buttonByText = (text: string) =>
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((el) => el.textContent?.trim() === text);
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"]',
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('Late pickup clause');
expect(tile!.className).not.toContain('hover:scale-105');
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());
};
const buttonByLabel = (label: string) =>
document.body.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
it('has no file search column', async () => {
await render();
expect(container.querySelector('[data-slot="command-input"]')).toBeNull();
expect(container.innerHTML).not.toContain('198px');
});
it('draws each chunk as a filled tile with its token count in the footer', async () => {
it('draws each chunk as a filled tile with a cleaned preview and #n · tokens footer', async () => {
await render();
const tile = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
)!;
expect(tile.dataset.variant).toBe('filled');
expect(tile.dataset.padding).toBe('lg');
const footer = tile.querySelector('[data-slot="card-footer"]')!;
expect(footer.textContent).toContain('settings.sources.tokensUnit');
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(tile.querySelector('.bg-muted')).toBeNull();
expect(card.querySelector('.bg-muted')).toBeNull();
});
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();
const grid = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
)!.parentElement!;
expect(grid.className).toBe(
expect(tile()!.parentElement!.className).toBe(
'grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]',
);
});
const renderPath = async (props: {
onPathSelect?: (depth: number) => void;
handleGoBack?: () => void;
}) => {
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);
});
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 () => {
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}
/>,
);
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');
});
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 crumbButton = (label: string) =>
Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((el) => el.textContent === label);
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());
};
it('opens the root and parent folders from the path crumbs', async () => {
const onPathSelect = vi.fn();
await renderPath({ onPathSelect });
const position = () =>
container
.textContent!.match(/chunkPosition \{"n":(\d+),"total":(\d+)/)!
.slice(1)
.map(Number);
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('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('without onPathSelect the root goes back and folders are text', async () => {
const handleGoBack = vi.fn();
await renderPath({ handleGoBack });
it('an edited chunk the store moved elsewhere is found by its id', async () => {
serveStore(1);
await render({ embedded: true });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[2].click());
await editOpenChunk('# Third edited');
expect(container.querySelector('h1')?.textContent).toBe('Third edited');
expect(position()).toEqual([2, 3]);
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
await act(async () => crumbButton('Contracts')!.click());
expect(handleGoBack).toHaveBeenCalledTimes(1);
expect(crumbButton('legal')).toBeUndefined();
expect(crumbButton('2024')).toBeUndefined();
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();
});
});
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']);
});
});
+53 -3
View File
@@ -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,7 +144,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
const topRightAction = (
<>
{headerAction}
{embedded ? null : headerAction}
<Button
type="button"
size="field"
@@ -126,7 +164,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
{isSyncing
? `${syncProgress}%`
: syncDone
? 'Done'
? t('settings.sources.syncDone')
: t('settings.sources.sync')}
</Button>
</>
@@ -148,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}
+21 -1
View File
@@ -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}
+587 -256
View File
@@ -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>
);
};
+2 -1
View File
@@ -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>
@@ -124,7 +124,7 @@ export function MultiSelectPopover({
if (!item.disabled) onToggle(item.id);
}}
checked={isSelected}
className="cursor-pointer justify-between"
className="justify-between"
aria-selected={isSelected}
>
<div className="mr-3 flex grow items-center gap-3 overflow-hidden">
@@ -0,0 +1,55 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import SourceMarkdown from './SourceMarkdown';
const render = (content: string, highlight?: string) =>
renderToStaticMarkup(
<SourceMarkdown content={content} highlight={highlight} />,
);
describe('SourceMarkdown', () => {
it('renders headings, lists and tables like a chat answer', () => {
const html = render(
'# Title\n\n- one\n- two\n\n| A | B |\n|---|---|\n| 1 | 2 |',
);
expect(html).toContain('<h1 class="mt-4 mb-2 text-xl font-semibold">');
expect(html).toContain('list-outside list-disc pl-5');
expect(html).toContain('<table');
expect(html).toContain('overflow-x-auto');
});
it('marks every case-insensitive match of the highlight in text, not in code', () => {
const html = render(
'Nordhaven Logistics B.V. is a carrier; nordhaven logistics b.v. again. `Nordhaven Logistics B.V.`',
'Nordhaven Logistics B.V.',
);
expect(html.match(/<mark/g)).toHaveLength(2);
expect(html).toContain('>Nordhaven Logistics B.V.</mark>');
expect(html).toContain('>nordhaven logistics b.v.</mark>');
expect(html).toContain('<code');
});
it('treats the highlight as literal text, not a pattern', () => {
const html = render('Assured+ tier and Assured tier', 'Assured+');
expect(html.match(/<mark/g)).toHaveLength(1);
});
it('renders no marks without a highlight or for a blank one', () => {
expect(render('plain text')).not.toContain('<mark');
expect(render('plain text', ' ')).not.toContain('<mark');
});
it('puts fenced code in a scrolling box, and keeps the pill for inline code', () => {
const html = render(
'Run `npm ci` first.\n\n```sh\nnpm ci\nnpm run build\n```',
);
const host = document.createElement('div');
host.innerHTML = html;
const pre = host.querySelector('pre')!;
expect(pre.className).toContain('overflow-x-auto');
expect(pre.className).toContain('border');
expect(pre.textContent).toContain('npm run build');
expect(host.querySelector('p code')!.className).toContain('bg-muted');
});
});
+128
View File
@@ -0,0 +1,128 @@
import React from 'react';
import ReactMarkdown, {
type Components,
type ExtraProps,
} from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { Button } from './ui/button';
type TableProps = React.ComponentProps<'table'> & ExtraProps;
// The shared table frame, with the paragraph's bottom margin so the next
// block does not butt against it.
const SharedTable = markdownTables.table as React.ComponentType<TableProps>;
const TableFrame = (props: TableProps) => (
<div className="mb-3">
<SharedTable {...props} />
</div>
);
const components: Components = {
...markdownHeadings,
...markdownTables,
table: TableFrame,
p: ({ children }) => <p className="mb-3">{children}</p>,
ul: ({ children }) => (
<ul className="mb-3 list-outside list-disc pl-5">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-3 list-outside list-decimal pl-5">{children}</ol>
),
a: ({ children, href }) => (
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</Button>
),
// Fenced and indented code keep their lines and scroll sideways in a
// bordered box; the inline pill below applies to inline code only.
pre: ({ children }) => (
<pre className="border-border mb-3 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-5 [&>code]:bg-transparent [&>code]:p-0">
{children}
</pre>
),
code: ({ children }) => (
<code className="bg-muted rounded-sm px-1 py-0.5 font-mono text-xs">
{children}
</code>
),
mark: ({ children }) => (
<mark className="bg-secondary text-foreground rounded-xs px-0.5">
{children}
</mark>
),
};
type MdNode = {
type: string;
value?: string;
children?: MdNode[];
data?: { hName?: string };
};
/**
* A remark plugin that wraps every case-insensitive, literal match of `term`
* in text (never in code) in a `<mark>`.
*/
function remarkHighlight(term: string) {
const needle = term.toLowerCase();
const split = (value: string): MdNode[] => {
const out: MdNode[] = [];
const lower = value.toLowerCase();
let from = 0;
let at = lower.indexOf(needle);
while (at !== -1) {
if (at > from) out.push({ type: 'text', value: value.slice(from, at) });
const hit = value.slice(at, at + needle.length);
out.push({
type: 'highlight',
data: { hName: 'mark' },
children: [{ type: 'text', value: hit }],
});
from = at + needle.length;
at = lower.indexOf(needle, from);
}
if (from < value.length)
out.push({ type: 'text', value: value.slice(from) });
return out;
};
const walk = (node: MdNode) => {
if (!node.children) return;
node.children = node.children.flatMap((child) => {
if (child.type === 'text' && child.value) return split(child.value);
walk(child);
return [child];
});
};
return () => (tree: MdNode) => walk(tree);
}
/**
* Markdown as a source view renders it: a wiki page, an open chunk, the edit
* drawer's preview. The chat answer's headings and tables, lists outside the
* text, links as `link inline`. `highlight` marks an entity's name (the graph's
* chunk drawer).
*/
export default function SourceMarkdown({
content,
highlight,
}: {
content: string;
highlight?: string;
}) {
const term = highlight?.trim();
return (
<div className="text-foreground text-sm leading-relaxed wrap-break-word">
<ReactMarkdown
remarkPlugins={term ? [remarkGfm, remarkHighlight(term)] : [remarkGfm]}
components={components}
>
{content}
</ReactMarkdown>
</div>
);
}
+407
View File
@@ -0,0 +1,407 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && key === 'settings.sources.wiki.byline'
? `${opts.pages} pages · ${opts.tokens} tokens`
: key,
}),
}));
const auth = vi.hoisted(() => ({ token: null as string | null }));
vi.mock('react-redux', () => ({
useSelector: () => auth.token,
useDispatch: () => vi.fn(),
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
const PAGES = [
{ path: 'company/meeting-types.md', title: null, token_count: 1000 },
{ path: 'index.md', title: null, token_count: 200 },
{ path: 'company/async_by-default.md', title: null, token_count: 34 },
];
const ok = (body: unknown) => ({
ok: true,
status: 200,
json: async () => body,
});
vi.mock('../api/services/userService', () => ({
default: {
getWikiPages: vi.fn(),
getWikiPage: vi.fn(),
updateWikiPage: vi.fn(),
},
}));
import i18next from 'i18next';
import userService from '../api/services/userService';
import { TooltipProvider } from './ui/tooltip';
import WikiViewer from './WikiViewer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const service = userService as unknown as {
getWikiPages: ReturnType<typeof vi.fn>;
getWikiPage: ReturnType<typeof vi.fn>;
updateWikiPage: ReturnType<typeof vi.fn>;
};
describe('WikiViewer', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
auth.token = null;
service.getWikiPages.mockReset();
service.getWikiPage.mockReset();
service.updateWikiPage.mockReset();
service.getWikiPages.mockResolvedValue(ok({ pages: PAGES }));
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
ok({ page: { path, content: '# Hello\n\nBody text', version: 1 } }),
);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = async (canEdit = false) => {
await act(async () => {
root.render(
<TooltipProvider>
<WikiViewer
docId="doc"
sourceName="Handbook"
canEdit={canEdit}
onBackToDocuments={vi.fn()}
/>
</TooltipProvider>,
);
});
};
const buttonByText = (text: string) =>
Array.from(document.body.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === text,
);
const sheet = () =>
document.body.querySelector<HTMLElement>('[data-slot="sheet-content"]');
const editor = () =>
sheet()?.querySelector<HTMLTextAreaElement>('textarea') ?? null;
const typeDraft = async (value: string) => {
const field = editor()!;
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!;
await act(async () => {
setter.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
};
const openEditor = async () => {
await act(async () => buttonByText('settings.sources.wiki.edit')!.click());
};
const save = async () => {
await act(async () => buttonByText('settings.sources.wiki.save')!.click());
};
// Bugs row 8: the app language's separators, not the browser's.
it('formats the byline counts in the app language', async () => {
const language = i18next.language;
i18next.language = 'de';
try {
await render();
expect(container.textContent).toContain('3 pages · 1.234 tokens');
} finally {
i18next.language = language;
}
});
it('renders the living wiki badge and the page and token counts', async () => {
await render();
const text = container.textContent ?? '';
expect(text).toContain('Handbook');
expect(text).toContain('settings.sources.wiki.badge');
expect(text).toContain(
`3 pages · ${(1234).toLocaleString()} tokens · settings.sources.wiki.explainer`,
);
expect(text).not.toContain('settings.sources.wiki.livingTitle');
});
it('uses the editable explainer when the wiki can be edited', async () => {
await render(true);
expect(container.textContent).toContain(
'settings.sources.wiki.explainerEditable',
);
});
it('groups pages under their folder with readable labels', async () => {
await render();
const nav = container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
)!;
const text = nav.textContent ?? '';
expect(text).toContain('settings.sources.wiki.home');
expect(text).toContain('company');
expect(text).toContain('Meeting types');
expect(text).toContain('Async by default');
// The home page leads, before the folder group.
expect(text.indexOf('settings.sources.wiki.home')).toBeLessThan(
text.indexOf('company'),
);
// The first page in the list opens, its path in the reader meta.
expect(service.getWikiPage).toHaveBeenCalledWith(
'doc',
'company/meeting-types.md',
null,
);
expect(container.textContent).toContain('Body text');
});
it('hides Edit without canEdit', async () => {
await render(false);
expect(buttonByText('settings.sources.wiki.edit')).toBeUndefined();
expect(sheet()).toBeNull();
});
// The chunk reader's actions: Edit, then the toolbar ⋯ with Copy text. A
// viewer who can't edit still gets the menu.
it('puts a toolbar ⋯ menu after Edit, for editors and viewers alike', async () => {
const menu = () =>
container.querySelector<HTMLButtonElement>(
'button[aria-label="settings.sources.menuAlt"]',
);
await render(true);
const edit = buttonByText('settings.sources.wiki.edit')!;
expect(menu()).not.toBeNull();
expect(
edit.compareDocumentPosition(menu()!) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await render(false);
expect(menu()).not.toBeNull();
});
it('renders the page body as markdown through SourceMarkdown, tables included', async () => {
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
ok({
page: {
path,
content: '# Hello\n\n| Name | Role |\n| --- | --- |\n| Ana | Lead |',
version: 1,
},
}),
);
await render();
const table = container.querySelector('table');
expect(table).not.toBeNull();
expect(table?.textContent).toContain('Ana');
expect(container.querySelector('h1, h2, h3')?.textContent).toBe('Hello');
expect(container.querySelector('textarea')).toBeNull();
});
it('opens the edit drawer with the page content as the draft', async () => {
await render(true);
expect(sheet()).toBeNull();
await openEditor();
expect(sheet()).not.toBeNull();
expect(sheet()!.textContent).toContain('settings.sources.wiki.editTitle');
expect(sheet()!.textContent).toContain('company/meeting-types.md');
expect(editor()?.value).toBe('# Hello\n\nBody text');
// The path is the drawer's description; the field is named by a label.
expect(editor()?.getAttribute('aria-label')).toBe('modals.chunk.bodyText');
// The panel itself never swaps to an editor.
expect(container.querySelector('textarea')).toBeNull();
});
it('closes the drawer and shows the new content when the save succeeds', async () => {
service.updateWikiPage.mockResolvedValue(
ok({
page: {
path: 'company/meeting-types.md',
content: '# Hello\n\nNew body',
version: 2,
},
}),
);
await render(true);
await openEditor();
await typeDraft('# Hello\n\nNew body');
await save();
expect(service.updateWikiPage).toHaveBeenCalled();
expect(sheet()).toBeNull();
expect(container.textContent).toContain('New body');
expect(container.textContent).not.toContain('Body text');
});
it('a token refresh keeps the open drawer and its draft', async () => {
auth.token = 'old';
await render(true);
await openEditor();
await typeDraft('# Hello\n\nHalf-written');
service.getWikiPages.mockClear();
service.getWikiPage.mockClear();
// SSE 401 recovery dispatches a fresh token for the same user.
auth.token = 'new';
await render(true);
expect(sheet()).not.toBeNull();
expect(editor()?.value).toBe('# Hello\n\nHalf-written');
expect(service.getWikiPage).not.toHaveBeenCalled();
expect(service.getWikiPages).not.toHaveBeenCalled();
// Later requests carry the fresh token.
service.updateWikiPage.mockResolvedValue(ok({ page: null }));
await save();
expect(service.updateWikiPage.mock.calls[0]).toContain('new');
});
it('a saved page updates the byline token total', async () => {
service.updateWikiPage.mockResolvedValue(
ok({
page: {
path: 'company/meeting-types.md',
content: '# Hello\n\nLonger body',
token_count: 1500,
version: 2,
},
}),
);
await render(true);
await openEditor();
await typeDraft('# Hello\n\nLonger body');
await save();
expect(container.textContent).toContain(
`3 pages · ${(1734).toLocaleString()} tokens`,
);
});
it('keeps the drawer open with a warning and the draft when the save conflicts', async () => {
service.updateWikiPage.mockResolvedValue({
ok: false,
status: 409,
json: async () => ({}),
});
await render(true);
await openEditor();
await typeDraft('My edit');
await save();
expect(sheet()).not.toBeNull();
const alert = sheet()!.querySelector('[data-slot="alert"]');
expect(alert?.getAttribute('data-variant')).toBe('warning');
expect(alert?.textContent).toContain('settings.sources.wiki.conflict');
expect(editor()?.value).toBe('My edit');
});
it('shows a destructive Alert in the drawer when the save is forbidden', async () => {
service.updateWikiPage.mockResolvedValue({
ok: false,
status: 403,
json: async () => ({}),
});
await render(true);
await openEditor();
await typeDraft('My edit');
await save();
expect(sheet()).not.toBeNull();
const alert = sheet()!.querySelector('[data-slot="alert"]');
expect(alert?.getAttribute('data-variant')).toBe('destructive');
expect(alert?.textContent).toContain('settings.sources.wiki.forbidden');
expect(editor()?.value).toBe('My edit');
});
// DESIGN.md: the navigator shows only when there is more than one thing to
// open; a one-page wiki reads full width, like a one-file source.
it('hides the navigator for a one-page wiki and opens that page', async () => {
service.getWikiPages.mockResolvedValue(
ok({ pages: [{ path: 'index.md', title: null, token_count: 5 }] }),
);
await render();
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).toBeNull();
expect(service.getWikiPage).toHaveBeenCalledWith('doc', 'index.md', null);
expect(container.textContent).toContain('index.md');
expect(container.textContent).toContain('Body text');
});
it('shows the empty state when the wiki has no pages', async () => {
service.getWikiPages.mockResolvedValue(ok({ pages: [] }));
await render();
expect(container.textContent).toContain('settings.sources.wiki.empty');
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).toBeNull();
});
it('offers Retry when the pages fail to load', async () => {
service.getWikiPages.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({}),
});
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await render();
expect(container.textContent).toContain('settings.sources.wiki.loadFailed');
await act(async () => buttonByText('retry')!.click());
expect(service.getWikiPages).toHaveBeenCalledTimes(2);
expect(
container.querySelector(
'nav[aria-label="settings.sources.wiki.pagesTitle"]',
),
).not.toBeNull();
spy.mockRestore();
});
// Bug: a failed page switch kept the previous page's "Edited by · vN".
it("drops the previous page's stamp when the next page fails to load", async () => {
service.getWikiPage.mockImplementation(async (_id: string, path: string) =>
path === 'index.md'
? { ok: false, status: 500, json: async () => ({}) }
: ok({ page: { path, content: 'Body text', version: 3 } }),
);
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await render();
expect(container.textContent).toContain(
'settings.sources.wiki.stamp.editedBy',
);
const home = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="command-item"]'),
).find((el) => el.textContent?.includes('settings.sources.wiki.home'))!;
await act(async () => home.click());
expect(container.textContent).toContain(
'settings.sources.wiki.pageLoadFailed',
);
expect(container.textContent).not.toContain(
'settings.sources.wiki.stamp.editedBy',
);
expect(container.textContent).not.toContain('Body text');
spy.mockRestore();
});
});
+351 -203
View File
@@ -1,22 +1,37 @@
import { ArrowLeft, BookOpen } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import copy from 'copy-to-clipboard';
import {
BookOpen,
CircleAlert,
Copy,
FileText,
Pencil,
TriangleAlert,
} from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { cn } from '@/lib/utils';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
import { formatRelative } from '../utils/dateTimeUtils';
import { formatCount, formatRelative } from '../utils/dateTimeUtils';
import { decodeJwtPayload } from '../utils/jwtUtils';
import SourceMarkdown from './SourceMarkdown';
import PathHeader from './tree/PathHeader';
import ReaderPanel from './tree/ReaderPanel';
import SourceEditSheet from './tree/SourceEditSheet';
import SourceNavigator from './tree/SourceNavigator';
import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { IconButton } from './ui/icon-button';
import { Textarea } from './ui/textarea';
import SkeletonLoader from './SkeletonLoader';
import { WikiPageNode, provenanceKey, saveWikiPage } from './wikiViewerUtils';
import { ActionMenu } from './ui/dropdown-menu';
import { EmptyState } from './ui/empty-state';
import { Skeleton } from './ui/skeleton';
import {
WikiPageNode,
buildWikiNavigator,
provenanceKey,
saveWikiPage,
} from './wikiViewerUtils';
interface WikiViewerProps {
docId: string;
@@ -27,30 +42,19 @@ interface WikiViewerProps {
headerAction?: React.ReactNode;
}
const markdownComponents = {
...markdownHeadings,
p: ({ children }: { children?: React.ReactNode }) => (
<p className="mb-3">{children}</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="mb-3 list-inside list-disc pl-4">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="mb-3 list-inside list-decimal pl-4">{children}</ol>
),
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
<Button variant="link" size="inline" asChild>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</Button>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="bg-accent rounded-md px-1.5 py-0.5 text-xs">
{children}
</code>
),
};
type EditError = { kind: 'conflict' | 'forbidden' | 'failed' } | null;
/** Bars in the reader body while a page loads. */
function ReaderBars() {
return (
<div className="flex flex-col gap-3" data-testid="wiki-reader-loading">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
);
}
const WikiViewer: React.FC<WikiViewerProps> = ({
docId,
@@ -60,6 +64,7 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
headerAction,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const currentUserSub = token
? ((decodeJwtPayload(token)?.sub as string | undefined) ?? null)
@@ -70,70 +75,108 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
const [page, setPage] = useState<WikiPageNode | null>(null);
const [content, setContent] = useState<string>('');
const [loadingPages, setLoadingPages] = useState(true);
const [pagesFailed, setPagesFailed] = useState(false);
const [pagesAttempt, setPagesAttempt] = useState(0);
const [loadingContent, setLoadingContent] = useState(false);
const [contentFailed, setContentFailed] = useState(false);
const [contentAttempt, setContentAttempt] = useState(0);
const [isEditing, setIsEditing] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [draft, setDraft] = useState<string>('');
const [saving, setSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editError, setEditError] = useState<EditError>(null);
// The loads read the token through a ref: a refresh (setToken during SSE
// 401 recovery) is the same user, so it must not refetch, and above all not
// reset the page and close an open editor with its draft.
const tokenRef = useRef(token);
tokenRef.current = token;
useEffect(() => {
let cancelled = false;
setLoadingPages(true);
setPagesFailed(false);
userService
.getWikiPages(docId, token)
.then((response) => response.json())
.getWikiPages(docId, tokenRef.current)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => {
if (cancelled) return;
const list: WikiPageNode[] = data?.pages ?? [];
setPages(list);
if (list.length > 0) setSelectedPath(list[0].path);
setSelectedPath((prev) =>
prev && list.some((p) => p.path === prev)
? prev
: (list[0]?.path ?? null),
);
})
.catch((error) => {
console.error('Error loading wiki pages:', error);
if (!cancelled) setPagesFailed(true);
})
.catch((error) => console.error('Error loading wiki pages:', error))
.finally(() => {
if (!cancelled) setLoadingPages(false);
});
return () => {
cancelled = true;
};
}, [docId, token]);
}, [docId, pagesAttempt]);
useEffect(() => {
setIsEditing(false);
setEditorOpen(false);
setEditError(null);
if (!selectedPath) {
setPage(null);
setContent('');
return;
}
setContentFailed(false);
// Drop the last page before the next loads, so a failed load never shows
// its stamp (editor, version) or its text under the new path.
setPage(null);
setContent('');
if (!selectedPath) return;
let cancelled = false;
setLoadingContent(true);
userService
.getWikiPage(docId, selectedPath, token)
.then((response) => response.json())
.getWikiPage(docId, selectedPath, tokenRef.current)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((data) => {
if (cancelled) return;
const node: WikiPageNode | null = data?.page ?? null;
setPage(node);
setContent((data?.page?.content as string) ?? '');
})
.catch((error) => console.error('Error loading wiki page:', error))
.catch((error) => {
console.error('Error loading wiki page:', error);
if (!cancelled) setContentFailed(true);
})
.finally(() => {
if (!cancelled) setLoadingContent(false);
});
return () => {
cancelled = true;
};
}, [docId, selectedPath, token]);
}, [docId, selectedPath, contentAttempt]);
const homeLabel = t('settings.sources.wiki.home');
const navigatorNodes = useMemo(
() => buildWikiNavigator(pages, homeLabel),
[pages, homeLabel],
);
const totalTokens = useMemo(
() => pages.reduce((sum, p) => sum + (p.token_count ?? 0), 0),
[pages],
);
const startEditing = () => {
setDraft(content);
setEditError(null);
setIsEditing(true);
setEditorOpen(true);
};
const cancelEditing = () => {
setIsEditing(false);
const closeEditor = () => {
setEditorOpen(false);
setEditError(null);
};
@@ -152,26 +195,38 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
);
switch (outcome.status) {
case 'saved':
if (outcome.page) setPage(outcome.page);
if (outcome.page) {
const saved = outcome.page;
setPage(saved);
// The list feeds the byline's token total and the navigator's
// labels; keep its entry in step (the list carries no content).
setPages((list) =>
list.map((p) =>
p.path === selectedPath
? { ...p, ...saved, content: p.content }
: p,
),
);
}
setContent(draft);
setIsEditing(false);
setEditorOpen(false);
break;
case 'conflict':
if (outcome.page) {
setPage(outcome.page);
setContent(outcome.page.content ?? '');
}
setEditError(t('settings.sources.wiki.conflict'));
setEditError({ kind: 'conflict' });
break;
case 'forbidden':
setEditError(t('settings.sources.wiki.forbidden'));
setEditError({ kind: 'forbidden' });
break;
default:
setEditError(t('settings.sources.wiki.saveFailed'));
setEditError({ kind: 'failed' });
}
} catch (error) {
console.error('Error saving wiki page:', error);
setEditError(t('settings.sources.wiki.saveFailed'));
setEditError({ kind: 'failed' });
} finally {
setSaving(false);
}
@@ -180,7 +235,7 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
const provenanceLabel = (via?: string | null, by?: string | null): string =>
t(`settings.sources.wiki.stamp.${provenanceKey(via, by, currentUserSub)}`);
const renderStamp = () => {
const stamp = (): string | null => {
if (!page) return null;
const who = provenanceLabel(page.updated_via, page.updated_by);
const when = formatRelative(page.updated_at);
@@ -191,150 +246,243 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
t('settings.sources.wiki.stamp.version', { version: page.version }),
);
}
return parts.join(' · ');
};
const explainer = canEdit
? t('settings.sources.wiki.explainerEditable')
: t('settings.sources.wiki.explainer');
const byline = loadingPages
? explainer
: `${t('settings.sources.wiki.byline', {
count: pages.length,
pages: formatCount(pages.length),
tokens: formatCount(totalTokens),
})} · ${explainer}`;
const renderEditError = () => {
if (!editError) return null;
if (editError.kind === 'conflict') {
return (
<Alert variant="warning">
<TriangleAlert />
<AlertDescription>
{t('settings.sources.wiki.conflict')}
</AlertDescription>
</Alert>
);
}
return (
<p className="text-muted-foreground mb-3 text-xs">{parts.join(' · ')}</p>
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>
{editError.kind === 'forbidden'
? t('settings.sources.wiki.forbidden')
: t('settings.sources.wiki.saveFailed')}
</AlertDescription>
</Alert>
);
};
// The chunk reader's actions: Edit (editors only), then the toolbar ⋯.
const readerActions =
loadingContent || contentFailed ? null : (
<>
{canEdit ? (
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={startEditing}
>
<Pencil />
{t('settings.sources.wiki.edit')}
</Button>
) : null}
<ActionMenu
size="toolbar"
triggerLabel={t('settings.sources.menuAlt')}
options={[
{
icon: Copy,
label: t('settings.sources.copyText'),
onClick: () => {
copy(content);
dispatch(
showActionToast({
variant: 'success',
message: t('conversation.copied'),
}),
);
},
},
]}
/>
</>
);
const readerMeta = selectedPath ? (
<>
<span className="font-mono wrap-anywhere">{selectedPath}</span>
{!loadingContent && !contentFailed && stamp() ? (
<span>{stamp()}</span>
) : null}
</>
) : null;
const readerBody = () => {
if (loadingContent) return <ReaderBars />;
if (contentFailed) {
return (
<EmptyState
size="xs"
tone="destructive"
illustration="none"
title={t('settings.sources.wiki.pageLoadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setContentAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
return <SourceMarkdown content={content} />;
};
const renderBody = () => {
if (loadingPages) {
return (
<div
className="flex flex-col gap-4 lg:flex-row lg:gap-6"
data-testid="wiki-loading"
>
<div className="flex w-full shrink-0 flex-col gap-2 lg:w-64">
<Skeleton className="h-9.5 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-5/6" />
<Skeleton className="h-8 w-4/6" />
<Skeleton className="h-8 w-5/6" />
</div>
<ReaderPanel
className="min-w-0 flex-1"
meta={<Skeleton className="h-3 w-40" />}
>
<ReaderBars />
</ReaderPanel>
</div>
);
}
if (pagesFailed) {
return (
<EmptyState
tone="destructive"
illustration="none"
title={t('settings.sources.wiki.loadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setPagesAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
if (pages.length === 0) {
return (
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.wiki.empty')}
/>
);
}
const reader = selectedPath ? (
<ReaderPanel meta={readerMeta} actions={readerActions}>
{readerBody()}
</ReaderPanel>
) : (
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.wiki.selectPage')}
/>
);
// One page: nothing to choose between, so no navigator; the reader takes
// the full width, as TreeBrowser does for a one-file source.
if (pages.length === 1) return reader;
return (
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
<SourceNavigator
nodes={navigatorNodes}
selectedId={selectedPath}
onSelect={(node) => {
if (node.kind === 'leaf') setSelectedPath(node.id);
}}
filterLabel={t('settings.sources.wiki.filterPages')}
emptyLabel={t('settings.sources.noResults')}
title={t('settings.sources.wiki.pagesTitle')}
folderMode="groups"
leafIcon={FileText}
/>
<div className="min-w-0 flex-1">{reader}</div>
</div>
);
};
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"
<div className="flex flex-col gap-4">
<PathHeader
root={{
label: t('settings.sources.label'),
onSelect: onBackToDocuments,
}}
segments={[{ label: sourceName }]}
badge={
<Badge variant="neutral">
<BookOpen />
{t('settings.sources.wiki.badge')}
</Badge>
}
byline={byline}
actions={headerAction}
/>
{renderBody()}
{canEdit && selectedPath ? (
<SourceEditSheet
open={editorOpen}
onClose={closeEditor}
title={t('settings.sources.wiki.editTitle')}
description={
page?.version != null
? `${selectedPath} · ${t('settings.sources.wiki.stamp.version', {
version: page.version,
})}`
: selectedPath
}
value={draft}
onChange={setDraft}
dirty={draft !== content}
onSave={handleSave}
saving={saving}
saveLabel={t('settings.sources.wiki.save')}
alert={renderEditError()}
fieldLabel={t('modals.chunk.bodyText')}
placeholder={t('settings.sources.wiki.editPlaceholder')}
/>
<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">
<BookOpen className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
<span className="text-foreground font-medium">
{t('settings.sources.wiki.livingTitle')}
</span>{' '}
{t('settings.sources.wiki.livingExplainer')}
{canEdit && (
<> {t('settings.sources.wiki.livingExplainerEditable')}</>
)}
</p>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<div className="border-border md:w-64 md:shrink-0 md:border-r md:pr-4">
{loadingPages ? (
<SkeletonLoader count={3} />
) : pages.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t('settings.sources.wiki.empty')}
</p>
) : (
<ul className="flex flex-col gap-1">
{pages.map((p) => (
<li key={p.path}>
<button
type="button"
onClick={() => setSelectedPath(p.path)}
className={cn(
'w-full truncate rounded-lg px-3 py-2 text-left text-sm transition-colors',
selectedPath === p.path
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent',
)}
title={p.path}
>
{p.title || p.path}
</button>
</li>
))}
</ul>
)}
</div>
<div className="min-w-0 flex-1">
{loadingContent ? (
<SkeletonLoader count={4} />
) : selectedPath ? (
<div className="flex flex-col">
<div className="mb-2 flex items-center justify-between gap-2">
{renderStamp()}
{canEdit && !isEditing && (
<Button
type="button"
variant="outline"
size="sm"
onClick={startEditing}
>
{t('settings.sources.wiki.edit')}
</Button>
)}
</div>
{editError && (
<p
role="alert"
className="border-border text-muted-foreground mb-3 rounded-md border px-3 py-2 text-xs"
>
{editError}
</p>
)}
{isEditing ? (
<div className="flex flex-col gap-3">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('settings.sources.wiki.editPlaceholder')}
variant="filled"
className="min-h-[320px] font-mono"
aria-label={selectedPath}
/>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={handleSave}
loading={saving}
>
{t('settings.sources.wiki.save')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelEditing}
disabled={saving}
>
{t('settings.sources.wiki.cancel')}
</Button>
</div>
</div>
) : (
<article className="text-foreground max-w-none text-sm leading-relaxed wrap-break-word">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</article>
)}
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
{t('settings.sources.wiki.selectPage')}
</p>
)}
</div>
</div>
) : null}
</div>
);
};
+67 -8
View File
@@ -1,20 +1,36 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { formatChunkTokens, UNKNOWN_TOKEN_COUNT } from './chunkUtils';
import i18next from 'i18next';
import {
abbreviateCount,
chunkPreviewText,
formatChunkTokens,
UNKNOWN_TOKEN_COUNT,
} from './chunkUtils';
type ChunkMetadata = Parameters<typeof formatChunkTokens>[0];
describe('formatChunkTokens', () => {
const language = i18next.language;
afterEach(() => {
i18next.language = language;
});
it('formats a numeric count with separators', () => {
expect(formatChunkTokens({ token_count: 1234 })).toBe(
(1234).toLocaleString(),
);
i18next.language = 'en';
expect(formatChunkTokens({ token_count: 1234 })).toBe('1,234');
});
it('formats a count a store handed back as a string', () => {
expect(formatChunkTokens({ token_count: '1234' })).toBe(
(1234).toLocaleString(),
);
i18next.language = 'en';
expect(formatChunkTokens({ token_count: '1234' })).toBe('1,234');
});
// Bugs row 8: the app language's separators, not the browser's.
it('groups in the app language', () => {
i18next.language = 'de';
expect(formatChunkTokens({ token_count: 11420 })).toBe('11.420');
});
it('falls back to a dash when the count is unusable', () => {
@@ -29,3 +45,46 @@ describe('formatChunkTokens', () => {
);
});
});
describe('chunkPreviewText', () => {
it('strips markdown heading markers and folds line breaks', () => {
expect(
chunkPreviewText('##### Taxation and\n\n##### Customs Union\n# 2026'),
).toBe('Taxation and · Customs Union · 2026');
});
it('collapses dot leaders and runs of whitespace', () => {
expect(chunkPreviewText('List of Tables......................12')).toBe(
'List of Tables … 12',
);
expect(chunkPreviewText('a b\t\tc')).toBe('a b c');
});
it('keeps ordinary text, an ellipsis and a hash inside a line', () => {
expect(chunkPreviewText('Wait... issue #12 is open.')).toBe(
'Wait... issue #12 is open.',
);
expect(chunkPreviewText('')).toBe('');
});
});
describe('abbreviateCount', () => {
it('shortens where the language has a short form', () => {
expect(abbreviateCount(1234, 'en')).toBe('1.2K');
expect(abbreviateCount(1500000, 'en')).toBe('1.5M');
expect(abbreviateCount(1234, 'ru')).toBe('1,2\u00a0тыс.');
expect(abbreviateCount(1500000, 'jp')).toBe('150万');
});
// de and ja have no compact thousands: the digits still group.
it('groups the digits where it has none', () => {
expect(abbreviateCount(1234, 'de')).toBe('1.234');
expect(abbreviateCount(1234, 'jp')).toBe('1,234');
expect(abbreviateCount(1500000, 'de')).toBe('1,5\u00a0Mio.');
});
it('leaves counts under a thousand as they are', () => {
expect(abbreviateCount(999, 'en')).toBe('999');
expect(abbreviateCount(0, 'zhTW')).toBe('0');
});
});
+44 -2
View File
@@ -3,7 +3,10 @@
* the formatting rules are unit-testable in isolation.
*/
import i18next from 'i18next';
import { ChunkType } from '../settings/types';
import { formatCount, intlLocale } from '../utils/dateTimeUtils';
/** What a chunk card shows when the token count is genuinely unknown. */
export const UNKNOWN_TOKEN_COUNT = '-';
@@ -14,7 +17,7 @@ export const UNKNOWN_TOKEN_COUNT = '-';
* The backend fills the count in for chunks that were indexed without one, but
* stores round-trip metadata types differently: pgvector keeps JSON numbers
* while other backends hand the same value back as a string. Both are counts,
* so both get thousands separators; only a missing or unusable value falls
* so both get the UI language's thousands separators; only a missing or unusable value falls
* back to a dash.
*/
export function formatChunkTokens(metadata: ChunkType['metadata']): string {
@@ -23,5 +26,44 @@ export function formatChunkTokens(metadata: ChunkType['metadata']): string {
if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) {
return UNKNOWN_TOKEN_COUNT;
}
return count.toLocaleString();
return formatCount(count);
}
/**
* The chunk count beside the search and in the byline: short where the
* language has a short form ("1.2K", "1,2 тыс.", "150万"), grouped digits where
* it has none (de and ja below 10,000: "1.234").
*
* @param total The chunk count.
* @param language An app language code; defaults to the current one.
* @returns The formatted count.
*/
export function abbreviateCount(
total: number,
language: string = i18next.language,
): string {
return new Intl.NumberFormat(intlLocale(language), {
notation: 'compact',
useGrouping: 'always',
}).format(total);
}
/**
* The text a chunk tile previews: the stored text with markdown heading
* markers stripped, table-of-contents dot leaders collapsed to "…", runs of
* whitespace folded and line breaks joined with " · ". Display only; the
* stored text and the editor keep the original.
*/
export function chunkPreviewText(text: string): string {
return (text ?? '')
.split(/\r?\n/)
.map((line) =>
line
.replace(/^\s*#+\s*/, '')
.replace(/\.{4,}/g, ' … ')
.replace(/[ \t]+/g, ' ')
.trim(),
)
.filter(Boolean)
.join(' · ');
}
@@ -0,0 +1,58 @@
import { Maximize, Minus, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '../ui/icon-button';
interface GraphCanvasControlsProps {
/** The current zoom factor (1 = 100%). */
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onFit: () => void;
}
/**
* The graph canvas's control strip, bottom left: zoom out, the zoom level,
* zoom in, then fit view. The workflow builder's `CanvasControls` recipe
* without React Flow.
*/
export default function GraphCanvasControls({
zoom,
onZoomIn,
onZoomOut,
onFit,
}: GraphCanvasControlsProps) {
const { t } = useTranslation();
return (
<div className="bg-card border-border absolute bottom-3 left-3 flex items-center gap-0.5 rounded-full border p-1">
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onZoomOut}
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={onZoomIn}
label={t('agents.workflow.zoomIn')}
icon={Plus}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onFit}
label={t('agents.workflow.fitView')}
icon={Maximize}
/>
</div>
);
}
@@ -0,0 +1,214 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const dispatch = vi.fn();
vi.mock('react-redux', () => ({
useSelector: () => 'tok',
useDispatch: () => dispatch,
}));
vi.mock('../../api/services/userService', () => ({
default: { updateChunk: vi.fn() },
}));
import userService from '../../api/services/userService';
import type { GraphNodeChunk } from '../graphViewUtils';
import GraphChunkSheet from './GraphChunkSheet';
const updateChunk = (
userService as unknown as { updateChunk: ReturnType<typeof vi.fn> }
).updateChunk;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('GraphChunkSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
updateChunk.mockReset();
updateChunk.mockResolvedValue({ ok: true, status: 200 });
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
dispatch.mockReset();
});
const button = (text: string) =>
[...document.body.querySelectorAll('button')].find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
const saveEdit = async (chunk: GraphNodeChunk, value: string) => {
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={chunk}
highlight=""
onClose={vi.fn()}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const field = document.body.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => button('modals.chunk.save')!.click());
};
it('saving an untitled chunk sends no title', async () => {
await saveEdit(
{ chunk_id: 'c1', text: 'Old', metadata: { source: 'a.md' } },
'New',
);
expect(updateChunk).toHaveBeenCalledTimes(1);
const body = updateChunk.mock.calls[0][0];
expect(body).toEqual({ id: 'doc', chunk_id: 'c1', text: 'New' });
expect(body.metadata?.title).toBeUndefined();
});
it('saving a titled chunk keeps its title', async () => {
await saveEdit(
{ chunk_id: 'c1', text: 'Old', metadata: { title: 'Brief.md' } },
'New',
);
expect(updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'New',
metadata: { title: 'Brief.md' },
},
'tok',
);
});
it('the edit drawer edits the title like Chunks', async () => {
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Body', metadata: { title: 'Old' } }}
highlight=""
onClose={vi.fn()}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const input = document.body.querySelector(
'input[type="text"], input:not([type])',
) as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.value).toBe('Old');
expect(document.body.textContent).toContain('modals.chunk.title');
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!.call(input, ' New title ');
input.dispatchEvent(new Event('input', { bubbles: true }));
});
// A title-only edit is a change: Save is enabled and sends it trimmed.
await act(async () => button('modals.chunk.save')!.click());
expect(updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'Body',
metadata: { title: 'New title' },
},
'tok',
);
});
it('Open in Files opens the tree path, not a web page URL', async () => {
const onOpenInFiles = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{
chunk_id: 'c1',
text: 'Body',
metadata: {
source: 'https://docs.example.com/guides/setup',
file_path: 'guides/setup.md',
title: 'Setup',
},
}}
highlight=""
onClose={vi.fn()}
onOpenInFiles={onOpenInFiles}
/>,
);
});
await act(async () =>
button('settings.sources.graphrag.view.openInFiles')!.click(),
);
expect(onOpenInFiles).toHaveBeenCalledWith('guides/setup.md');
});
// Leaving the edit drawer (Cancel, Discard, Esc, X) returns to the read
// drawer on the same chunk; only a successful save closes both.
it('Cancel in the edit drawer returns to the read drawer', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Old body', metadata: {} }}
highlight=""
onClose={onClose}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
expect(document.body.querySelector('textarea')).not.toBeNull();
await act(async () => button('settings.sources.editor.cancel')!.click());
expect(onClose).not.toHaveBeenCalled();
expect(document.body.querySelector('textarea')).toBeNull();
expect(button('modals.chunk.edit')).toBeDefined();
expect(document.body.textContent).toContain('Old body');
});
it('a successful save closes both drawers', async () => {
const onClose = vi.fn();
await act(async () => {
root.render(
<GraphChunkSheet
docId="doc"
chunk={{ chunk_id: 'c1', text: 'Old', metadata: {} }}
highlight=""
onClose={onClose}
/>,
);
});
await act(async () => button('modals.chunk.edit')!.click());
const field = document.body.querySelector('textarea')!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!.call(field, 'New');
field.dispatchEvent(new Event('input', { bubbles: true }));
});
await act(async () => button('modals.chunk.save')!.click());
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,237 @@
import { Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { showActionToast } from '../../notifications/actionToastSlice';
import { selectToken } from '../../preferences/preferenceSlice';
import { UNKNOWN_TOKEN_COUNT, formatChunkTokens } from '../chunkUtils';
import SourceMarkdown from '../SourceMarkdown';
import SourceEditSheet from '../tree/SourceEditSheet';
import { Alert, AlertDescription } from '../ui/alert';
import { Button } from '../ui/button';
import { FormField } from '../ui/form-field';
import { Input } from '../ui/input';
import { Separator } from '../ui/separator';
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../ui/sheet';
import type { GraphNodeChunk } from '../graphViewUtils';
import { chunkFileName, chunkFilePath, chunkHeading } from './graphCanvasUtils';
interface GraphChunkSheetProps {
docId: string;
/** The chunk to show; null closes both drawers. */
chunk: GraphNodeChunk | null;
/** The entity's name, marked in the rendered chunk. */
highlight: string;
/** Called once the drawers close (the node stays selected). */
onClose: () => void;
/** Show the chunk's file on the Files tab; omitted hides the button. */
onOpenInFiles?: (path: string) => void;
/** Called after a saved edit, to refetch the node detail. */
onSaved?: () => void;
}
/**
* A source chunk of a graph entity, opened from the node panel: a read drawer
* with the chunk rendered (the entity's name marked), "Open in Files" and
* Edit; Edit swaps it for the shared edit drawer. Built like
* WorkflowDetailsSheet: header, one scrolling body, then the actions.
*/
export default function GraphChunkSheet({
docId,
chunk,
highlight,
onClose,
onOpenInFiles,
onSaved,
}: GraphChunkSheetProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [draftTitle, setDraftTitle] = useState('');
const [saving, setSaving] = useState(false);
const [saveFailed, setSaveFailed] = useState(false);
// Every newly opened chunk starts in the read drawer.
useEffect(() => {
setEditing(false);
setSaveFailed(false);
}, [chunk?.chunk_id]);
if (!chunk) return null;
const file = chunkFileName(chunk.metadata);
// The tree path "Open in Files" opens (a web page's is not its URL).
const path = chunkFilePath(chunk.metadata);
const tokens = formatChunkTokens(
(chunk.metadata ?? {}) as Parameters<typeof formatChunkTokens>[0],
);
const hasTokens = tokens !== UNKNOWN_TOKEN_COUNT;
const heading = chunkHeading(chunk.text);
const title = heading || file || t('settings.sources.graphrag.view.chunk');
const meta =
file && hasTokens
? t('settings.sources.graphrag.view.chunkMeta', {
file,
tokens,
interpolation: { escapeValue: false },
})
: hasTokens
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
: file;
// When the file name is already the title, the read header shows only the
// token count; the edit drawer keeps the full line.
const readMeta =
heading || !file
? meta
: hasTokens
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
: '';
const close = () => {
setEditing(false);
setSaveFailed(false);
onClose();
};
// Leaving the edit drawer without saving returns to the read drawer.
const leaveEdit = () => {
setEditing(false);
setSaveFailed(false);
};
const savedTitle =
typeof chunk.metadata?.title === 'string' ? chunk.metadata.title : '';
const startEdit = () => {
setDraft(chunk.text ?? '');
setDraftTitle(savedTitle);
setSaveFailed(false);
setEditing(true);
};
const save = async () => {
setSaving(true);
setSaveFailed(false);
// The backend merges metadata: send a title only when the chunk had one
// (clearing it sends '') or one was entered, never '' onto an untitled
// one. As Chunks does.
const sendTitle = !!savedTitle || !!draftTitle.trim();
try {
const response = await userService.updateChunk(
{
id: docId,
chunk_id: chunk.chunk_id,
text: draft,
...(sendTitle ? { metadata: { title: draftTitle.trim() } } : {}),
},
token,
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
close();
onSaved?.();
dispatch(
showActionToast({
variant: 'success',
message: t('settings.sources.graphrag.view.chunkSaved'),
}),
);
} catch (error) {
console.error('Error saving chunk:', error);
setSaveFailed(true);
} finally {
setSaving(false);
}
};
return (
<>
<Sheet open={!editing} onOpenChange={(open) => !open && close()}>
<SheetContent
side="right"
size="detail"
className="p-0"
closeLabel={t('settings.sources.editor.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 className="wrap-break-word">{title}</SheetTitle>
{readMeta ? (
<SheetDescription className="wrap-anywhere">
{readMeta}
</SheetDescription>
) : null}
</div>
<Separator />
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<SourceMarkdown
content={chunk.text ?? ''}
highlight={highlight}
/>
</div>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
{onOpenInFiles && path ? (
<Button
type="button"
variant="outline"
size="lg"
shape="pill"
onClick={() => {
close();
onOpenInFiles(path);
}}
>
{t('settings.sources.graphrag.view.openInFiles')}
</Button>
) : null}
<Button type="button" size="lg" shape="pill" onClick={startEdit}>
<Pencil />
{t('modals.chunk.edit')}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<SourceEditSheet
open={editing}
onClose={leaveEdit}
title={t('settings.sources.graphrag.view.editChunk')}
description={meta || undefined}
value={draft}
onChange={setDraft}
dirty={draft !== (chunk.text ?? '') || draftTitle.trim() !== savedTitle}
fields={
<FormField
label={t('modals.chunk.title')}
hint={t('settings.sources.chunkTitleHint')}
labelSurface="background"
>
<Input
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
disabled={saving}
/>
</FormField>
}
onSave={save}
saving={saving}
saveLabel={t('modals.chunk.save')}
alert={
saveFailed ? (
<Alert variant="destructive">
<AlertDescription>
{t('settings.sources.chunkErrors.save')}
</AlertDescription>
</Alert>
) : null
}
fieldLabel={t('modals.chunk.bodyText')}
/>
</>
);
}
@@ -0,0 +1,350 @@
import { Waypoints } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { useDebouncedValue, useMediaQuery } from '../../hooks';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatCount } from '../../utils/dateTimeUtils';
import SearchInput from '../SearchInput';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { EmptyState } from '../ui/empty-state';
import { Pagination } from '../ui/pagination';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Sheet, SheetContent } from '../ui/sheet';
import { Skeleton } from '../ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../ui/table';
import type {
FoldedGraphTypes,
ForceGraphData,
GraphNodeSummary,
GraphTypeFacet,
} from '../graphViewUtils';
import GraphNodePanel, {
GraphNodePanelDock,
type GraphNodeRef,
} from './GraphNodePanel';
import { GraphTypeBadge } from './GraphTypeDot';
import { useGraphNodeDetail } from './useGraphNodeDetail';
const ENTITIES_PER_PAGE = 25;
// Radix Select can't hold "" as a value, and "" is the untyped key.
const ALL_TYPES = '__all';
const UNTYPED = '__untyped';
type ListStatus = 'loading' | 'error' | 'ready';
/**
* The Entities tab: every node of the source, searchable and filterable by
* type, busiest first, paged. The frame is the Graph tab's: one `subtle`
* Card at `h-[70svh]` with the table scrolling inside and a row's node panel
* docked at its right edge (a bottom sheet on a phone), whose "Show in graph"
* jumps to the canvas.
*/
export default function GraphEntities({
docId,
fold,
onShowInGraph,
overview,
onOpenInFiles,
}: {
docId: string;
fold: FoldedGraphTypes;
onShowInGraph: (node: GraphNodeRef) => void;
/** The loaded overview, for the node panel's relationships fallback. */
overview?: ForceGraphData;
/** Show a chunk's file on the Files tab. */
onOpenInFiles?: (path: string) => void;
}) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const { isDesktop } = useMediaQuery();
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query.trim(), 300);
const [typeFilter, setTypeFilter] = useState(ALL_TYPES);
const [page, setPage] = useState(1);
const [nodes, setNodes] = useState<GraphNodeSummary[]>([]);
const [total, setTotal] = useState(0);
const [types, setTypes] = useState<GraphTypeFacet[]>([]);
const [status, setStatus] = useState<ListStatus>('loading');
const [attempt, setAttempt] = useState(0);
const [selected, setSelected] = useState<GraphNodeRef | null>(null);
const nodeDetail = useGraphNodeDetail(docId, selected?.id ?? null);
// A new search or type filter starts on page 1; that page change then
// fetches, so the old page is never requested with the new filter.
const filterKey = `${debouncedQuery}\u0000${typeFilter}`;
const fetchedFilterRef = useRef(filterKey);
useEffect(() => {
if (fetchedFilterRef.current !== filterKey) {
fetchedFilterRef.current = filterKey;
if (page !== 1) {
setPage(1);
return;
}
}
let cancelled = false;
setStatus('loading');
userService
.getSourceGraphNodes(
docId,
{
q: debouncedQuery || undefined,
type:
typeFilter === ALL_TYPES
? undefined
: typeFilter === UNTYPED
? ''
: typeFilter,
page,
perPage: ENTITIES_PER_PAGE,
},
token,
)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((body) => {
if (cancelled) return;
setNodes(body?.nodes ?? []);
setTotal(body?.total ?? 0);
setTypes(body?.types ?? []);
setStatus('ready');
})
.catch((error) => {
if (cancelled) return;
console.error('Error loading graph entities:', error);
setStatus('error');
});
return () => {
cancelled = true;
};
}, [docId, filterKey, page, token, attempt]);
const pageCount = Math.max(1, Math.ceil(total / ENTITIES_PER_PAGE));
const untypedLabel = t('settings.sources.graphrag.view.untyped');
const filtered = debouncedQuery !== '' || typeFilter !== ALL_TYPES;
const panel = selected ? (
<GraphNodePanel
key={selected.id}
docId={docId}
node={selected}
detail={nodeDetail.detail}
status={nodeDetail.status}
onRetry={nodeDetail.retry}
fold={fold}
onClose={() => setSelected(null)}
showClose={isDesktop}
onSelectNode={setSelected}
overview={overview}
onOpenInFiles={onOpenInFiles}
onChunkSaved={nodeDetail.reload}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => onShowInGraph(selected)}
>
<Waypoints />
{t('settings.sources.graphrag.view.showInGraph')}
</Button>
}
/>
) : null;
const body =
status === 'error' ? (
<EmptyState
size="sm"
tone="destructive"
illustration="none"
title={t('settings.sources.graphrag.view.entitiesLoadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setAttempt((n) => n + 1)}
>
{t('retry')}
</Button>
}
/>
) : status === 'ready' && nodes.length === 0 ? (
<EmptyState
size="xs"
illustration="none"
title={
filtered
? t('settings.sources.graphrag.view.noEntities')
: t('settings.sources.graphrag.view.empty')
}
/>
) : (
<div className="scrollbar-overlay min-h-0 flex-1 overflow-auto">
<Table minWidth="min-w-0">
<TableHead>
<TableRow>
<TableHeader>
{t('settings.sources.graphrag.view.entityName')}
</TableHeader>
<TableHeader>{t('settings.sources.type')}</TableHeader>
<TableHeader align="right">
{t('settings.sources.graphrag.view.connections')}
</TableHeader>
<TableHeader align="right">
{t('settings.sources.chunks')}
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{status === 'loading'
? Array.from({ length: 6 }, (_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-3 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-3 w-20" />
</TableCell>
<TableCell align="right">
<Skeleton className="ml-auto h-3 w-8" />
</TableCell>
<TableCell align="right">
<Skeleton className="ml-auto h-3 w-8" />
</TableCell>
</TableRow>
))
: nodes.map((node) => (
<TableRow
key={node.id}
selected={selected?.id === node.id}
onClick={() =>
setSelected({
id: node.id,
name: node.name,
type: node.type,
})
}
>
<TableCell className="font-medium wrap-break-word">
{node.name}
</TableCell>
<TableCell>
<GraphTypeBadge fold={fold} type={node.type} />
</TableCell>
<TableCell align="right" className="tabular-nums">
{formatCount(node.degree)}
</TableCell>
<TableCell align="right" className="tabular-nums">
{formatCount(node.doc_freq ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
// Kept through a page load (the previous page's rows stay until the next
// arrive), so Prev / Next do not shift the layout.
const showPager = status !== 'error' && nodes.length > 0;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<div className="w-full sm:w-72">
<SearchInput
label={t('settings.sources.graphrag.view.findEntity')}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger
size="field"
shape="pill"
className="w-full sm:w-56"
aria-label={t('settings.sources.type')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TYPES}>
{t('settings.sources.graphrag.view.allTypes')}
</SelectItem>
{types.map((facet) => (
<SelectItem
key={facet.key || UNTYPED}
value={facet.key || UNTYPED}
>
{facet.key ? (facet.label ?? facet.key) : untypedLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card
variant="subtle"
padding="none"
className="h-[70svh] flex-row gap-0 overflow-hidden"
>
<div className="flex min-w-0 flex-1 flex-col justify-center">
{body}
</div>
{isDesktop && panel ? (
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
) : null}
</Card>
{showPager ? (
<Pagination
page={page}
pageCount={pageCount}
onPageChange={setPage}
summary={t('settings.sources.graphrag.view.entityCount', {
count: total,
formatted: formatCount(total),
})}
/>
) : null}
{!isDesktop ? (
<Sheet
open={!!selected}
onOpenChange={(open) => {
if (!open) setSelected(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>
);
}
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { useDebouncedValue } from '../../hooks';
import { selectToken } from '../../preferences/preferenceSlice';
import SearchInput from '../SearchInput';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from '../ui/command';
import { EmptyState } from '../ui/empty-state';
import { LoadingState } from '../ui/loading-state';
import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover';
import type { FoldedGraphTypes, GraphNodeSummary } from '../graphViewUtils';
import type { GraphNodeRef } from './GraphNodePanel';
import { GraphTypeDot, useGraphTypeLabel } from './GraphTypeDot';
const SEARCH_PAGE_SIZE = 8;
/**
* "Find an entity": searches every node of the source by name (not only the
* loaded overview) and selects the pick. The results are a Popover + Command
* anchored under the field; arrows and Enter drive it from the field.
*/
export default function GraphEntitySearch({
docId,
fold,
onPick,
}: {
docId: string;
fold: FoldedGraphTypes;
onPick: (node: GraphNodeRef) => void;
}) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const labelOf = useGraphTypeLabel(fold);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [hits, setHits] = useState<GraphNodeSummary[]>([]);
const [loading, setLoading] = useState(false);
const [active, setActive] = useState('');
const anchorRef = useRef<HTMLDivElement>(null);
const debounced = useDebouncedValue(query.trim(), 250);
useEffect(() => {
if (!debounced) {
setHits([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
userService
.getSourceGraphNodes(
docId,
{ q: debounced, perPage: SEARCH_PAGE_SIZE },
token,
)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((body) => {
if (cancelled) return;
const nodes: GraphNodeSummary[] = body?.nodes ?? [];
setHits(nodes);
setActive(nodes[0]?.id ?? '');
})
.catch((error) => {
if (cancelled) return;
console.error('Error searching graph nodes:', error);
setHits([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [docId, debounced, token]);
const pick = (hit: GraphNodeSummary) => {
setQuery('');
setOpen(false);
onPick({ id: hit.id, name: hit.name, type: hit.type });
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Escape') {
if (open) {
event.preventDefault();
setOpen(false);
}
return;
}
if (!hits.length) return;
const index = hits.findIndex((hit) => hit.id === active);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setOpen(true);
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = (index + step + hits.length) % hits.length;
setActive(hits[next].id);
} else if (event.key === 'Enter' && open) {
event.preventDefault();
// Still debouncing: the listed hits belong to the previous query.
if (query.trim() !== debounced) return;
const hit = hits[index] ?? hits[0];
if (hit) pick(hit);
}
};
const showPopover = open && query.trim() !== '';
// Still typing, or the request is out: don't flash "No entities found".
const pending = loading || query.trim() !== debounced;
return (
<Popover open={showPopover} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div ref={anchorRef} className="w-full sm:w-72">
<SearchInput
label={t('settings.sources.graphrag.view.findEntity')}
value={query}
role="combobox"
aria-expanded={showPopover}
aria-autocomplete="list"
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onFocus={() => {
if (query.trim()) setOpen(true);
}}
onKeyDown={onKeyDown}
/>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
// A click back in the field keeps the list open.
if (anchorRef.current?.contains(event.target as Node)) {
event.preventDefault();
}
}}
>
<Command shouldFilter={false} value={active} onValueChange={setActive}>
<CommandList>
{pending && hits.length === 0 ? (
<LoadingState fill="block" size="sm" />
) : (
<CommandEmpty>
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.graphrag.view.noEntities')}
/>
</CommandEmpty>
)}
{hits.length ? (
<CommandGroup>
{hits.map((hit) => (
<CommandItem
key={hit.id}
value={hit.id}
onSelect={() => pick(hit)}
>
<GraphTypeDot fold={fold} type={hit.type} />
<span className="min-w-0 flex-1 truncate" title={hit.name}>
{hit.name}
</span>
<span className="text-muted-foreground max-w-[40%] truncate text-xs">
{labelOf(hit.type)}
</span>
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,459 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
// "key:count", then any other params: "key:20 {"formatted":"20"}".
t: (key: string, opts?: Record<string, unknown>) => {
if (!opts || !('count' in opts)) return key;
const { count, ...rest } = opts;
return Object.keys(rest).length
? `${key}:${count} ${JSON.stringify(rest)}`
: `${key}:${count}`;
},
}),
}));
const dispatch = vi.fn();
vi.mock('react-redux', () => ({
useSelector: () => 'tok',
useDispatch: () => dispatch,
}));
vi.mock('../../api/services/userService', () => ({
default: { updateChunk: vi.fn() },
}));
import i18next from 'i18next';
import userService from '../../api/services/userService';
import { foldGraphTypes, type GraphNodeDetail } from '../graphViewUtils';
import GraphNodePanel, { RELATIONSHIP_PREVIEW } from './GraphNodePanel';
const updateChunk = (
userService as unknown as {
updateChunk: ReturnType<typeof vi.fn>;
}
).updateChunk;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const fold = foldGraphTypes([
{ type: 'Company' },
{ type: 'Company' },
{ type: 'Person' },
{ type: 'Lane' },
]);
const DETAIL: GraphNodeDetail = {
id: 'nord',
name: 'Nordhaven Logistics B.V.',
type: 'Company',
description: 'Dutch road haulier based in Schiedam.',
degree: 55,
doc_freq: 20,
relationships: [
{
id: 'anneke',
name: 'Anneke de Vries',
type: 'Person',
degree: 40,
edge_type: 'MANAGES',
direction: 'in',
},
{
id: 'anneke',
name: 'Anneke de Vries',
type: 'Person',
degree: 40,
edge_type: 'manages',
direction: 'out',
},
{
id: 'anneke',
name: 'Anneke de Vries',
type: 'Person',
degree: 40,
edge_type: 'CARRIER_MANAGER_FOR',
direction: 'in',
},
{
id: 'duisport',
name: 'Duisport',
type: 'Port',
degree: 12,
edge_type: null,
direction: 'out',
},
],
chunks: [
{
chunk_id: 'c1',
text: 'Carrier profile — Nordhaven Logistics B.V.',
metadata: { source: 'inputs/local/Carrier_profile_Nordhaven.docx' },
},
],
};
describe('GraphNodePanel', () => {
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();
document.body.innerHTML = '';
updateChunk.mockReset();
dispatch.mockReset();
});
const render = async (
props: Partial<React.ComponentProps<typeof GraphNodePanel>> = {},
) => {
const handlers = {
onClose: vi.fn(),
onSelectNode: vi.fn(),
onRetry: vi.fn(),
onOpenInFiles: vi.fn(),
onChunkSaved: vi.fn(),
};
await act(async () => {
root.render(
<GraphNodePanel
docId="doc"
node={{ id: 'nord', name: 'Nordhaven', type: 'Company' }}
detail={DETAIL}
status="ready"
fold={fold}
{...handlers}
{...props}
/>,
);
});
return handlers;
};
const relationshipButtons = () =>
Array.from(container.querySelectorAll('ul button'));
it('shows the facts, one row per neighbour and the chunk file', async () => {
await render();
const text = container.textContent ?? '';
expect(container.querySelector('h3')?.textContent).toBe(
'Nordhaven Logistics B.V.',
);
expect(text).toContain('55');
expect(text).toContain('settings.sources.graphrag.view.chunkCount:20');
const rows = relationshipButtons();
expect(rows).toHaveLength(2);
// The busiest neighbour first, its labels normalised and deduplicated.
expect(rows[0].textContent).toContain('Anneke de Vries');
expect(rows[0].textContent).toContain('manages · carrier manager for');
// No label on the edge: the "related to" fallback.
expect(rows[1].textContent).toContain('Duisport');
expect(rows[1].textContent).toContain(
'settings.sources.graphrag.view.relatedTo',
);
expect(text).toContain('Carrier profile — Nordhaven Logistics B.V.');
expect(text).toContain('Carrier_profile_Nordhaven.docx');
expect(text).not.toContain('inputs/local');
});
it('draws its close X unless the sheet hides it', async () => {
const closeButton = () =>
container.querySelector(
'button[aria-label="settings.sources.graphrag.view.close"]',
);
const { onClose } = await render();
await act(async () => {
(closeButton() as HTMLButtonElement).click();
});
expect(onClose).toHaveBeenCalled();
await render({ showClose: false });
expect(closeButton()).toBeNull();
});
it('selects the neighbour a row points at', async () => {
const { onSelectNode } = await render();
await act(async () => {
(relationshipButtons()[1] as HTMLButtonElement).click();
});
expect(onSelectNode).toHaveBeenCalledWith({
id: 'duisport',
name: 'Duisport',
type: 'Port',
});
});
it('shows the first rows and a Show all toggle for a busy node', async () => {
const relationships = Array.from({ length: 15 }, (_, i) => ({
id: `n${i}`,
name: `Neighbour ${i}`,
type: 'Person',
degree: 30 - i,
edge_type: 'knows',
}));
await render({ detail: { ...DETAIL, relationships } });
expect(relationshipButtons()).toHaveLength(RELATIONSHIP_PREVIEW);
const toggle = Array.from(container.querySelectorAll('button')).find(
(b) =>
b.textContent ===
'settings.sources.graphrag.view.showAll:15 {"formatted":"15"}',
)!;
await act(async () => toggle.click());
expect(relationshipButtons()).toHaveLength(15);
});
const relationshipsHeader = () =>
Array.from(container.querySelectorAll('h4')).find((h) =>
h.textContent?.startsWith('settings.sources.graphrag.view.relationships'),
)!;
const cappedNote = () =>
Array.from(container.querySelectorAll('p')).find((p) =>
p.textContent?.startsWith(
'settings.sources.graphrag.view.relationshipsCapped',
),
);
// Bugs row 10: the backend caps the list; the header counts every edge and
// a note says the list is partial.
it('heads a capped list with the true total and says what it shows', async () => {
await render({ detail: { ...DETAIL, relationships_total: 1204 } });
expect(relationshipsHeader().textContent).toContain('1,204');
const note = cappedNote();
expect(note?.textContent).toBe(
'settings.sources.graphrag.view.relationshipsCapped:1204 {"shown":"4","total":"1,204"}',
);
expect(note?.className).toContain('text-xs');
expect(note?.className).toContain('text-muted-foreground');
});
it('adds no note when every relationship came back', async () => {
await render({ detail: { ...DETAIL, relationships_total: 4 } });
expect(relationshipsHeader().textContent).toContain('4');
expect(cappedNote()).toBeUndefined();
});
it('counts the neighbour rows when the API sends no total', async () => {
await render();
expect(relationshipsHeader().textContent).toMatch(/2$/);
expect(cappedNote()).toBeUndefined();
});
// Bugs row 8: counts in the app language, not the browser's.
it('formats its counts in the app language', async () => {
const language = i18next.language;
i18next.language = 'de';
try {
await render({
detail: { ...DETAIL, degree: 1234, doc_freq: 5678 },
});
const text = container.textContent ?? '';
expect(text).toContain('1.234');
expect(text).toContain(
'settings.sources.graphrag.view.chunkCount:5678 {"formatted":"5.678"}',
);
} finally {
i18next.language = language;
}
});
it('offers Show more only for a long description', async () => {
await render();
expect(container.textContent).not.toContain(
'settings.sources.graphrag.view.showMore',
);
await render({
detail: { ...DETAIL, description: 'Long. '.repeat(60) },
});
expect(container.textContent).toContain(
'settings.sources.graphrag.view.showMore',
);
});
it('shows the known name while loading, and Retry on failure', async () => {
await render({ detail: null, status: 'loading' });
expect(container.querySelector('h3')?.textContent).toBe('Nordhaven');
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
const { onRetry } = await render({ detail: null, status: 'error' });
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'settings.sources.graphrag.view.nodeLoadFailed',
);
const retry = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'retry',
)!;
await act(async () => retry.click());
expect(onRetry).toHaveBeenCalled();
});
const CHUNK_DETAIL: GraphNodeDetail = {
...DETAIL,
chunks: [
{
chunk_id: 'c1',
text: '# Account brief\n\nNordhaven Logistics B.V. hauls the Rotterdam lane.',
metadata: {
source: 'inputs/local/Account_brief.md',
title: 'Account_brief.md',
token_count: 1234,
},
},
],
};
const bodyButton = (text: string) =>
[...document.body.querySelectorAll('button')].find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
const openTile = async () => {
const tile = container.querySelector(
'[data-slot="card"]',
) as HTMLButtonElement;
expect(tile.tagName).toBe('BUTTON');
await act(async () => tile.click());
};
const typeDraft = async (value: string) => {
const field = document.body.querySelector('textarea')!;
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
)!.set!;
await act(async () => {
setter.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
});
};
it('previews a tile without markdown markers', async () => {
await render({ detail: CHUNK_DETAIL });
const tile = container.querySelector('[data-slot="card"]')!;
expect(tile.textContent).toContain(
'Account brief · Nordhaven Logistics B.V. hauls the Rotterdam lane.',
);
expect(tile.textContent).not.toContain('#');
});
it('opens a tile in a read drawer with the entity name marked', async () => {
await render({ detail: CHUNK_DETAIL });
await openTile();
const dialog = document.body.querySelector('[role="dialog"]')!;
expect(dialog.querySelector('h2')?.textContent).toBe('Account brief');
expect(dialog.textContent).toContain(
'settings.sources.graphrag.view.chunkMeta',
);
// The body renders the heading and marks the entity's name.
const rendered = [...dialog.querySelectorAll('h1, h2, h3')].filter(
(h) => h.textContent === 'Account brief',
);
expect(rendered).toHaveLength(2);
expect(dialog.textContent).not.toContain('# Account brief');
expect(dialog.querySelector('mark')?.textContent).toBe(
'Nordhaven Logistics B.V.',
);
});
it('opens the file on the Files tab and closes the drawer', async () => {
const { onOpenInFiles, onClose } = await render({ detail: CHUNK_DETAIL });
await openTile();
await act(async () =>
bodyButton('settings.sources.graphrag.view.openInFiles')!.click(),
);
expect(onOpenInFiles).toHaveBeenCalledWith('inputs/local/Account_brief.md');
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
// The node stays selected.
expect(onClose).not.toHaveBeenCalled();
});
it('edits a chunk in the edit drawer, saves and refetches', async () => {
updateChunk.mockResolvedValue({ ok: true, status: 200 });
const { onChunkSaved } = await render({ detail: CHUNK_DETAIL });
await openTile();
await act(async () => bodyButton('modals.chunk.edit')!.click());
expect(document.body.textContent).toContain(
'settings.sources.graphrag.view.editChunk',
);
await typeDraft('# Account brief\n\nUpdated.');
await act(async () => bodyButton('modals.chunk.save')!.click());
expect(updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: '# Account brief\n\nUpdated.',
metadata: { title: 'Account_brief.md' },
},
'tok',
);
expect(onChunkSaved).toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ variant: 'success' }),
}),
);
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
it('keeps the edit drawer open with an alert when the save fails', async () => {
updateChunk.mockResolvedValue({ ok: false, status: 500 });
const { onChunkSaved } = await render({ detail: CHUNK_DETAIL });
await openTile();
await act(async () => bodyButton('modals.chunk.edit')!.click());
await typeDraft('Changed');
await act(async () => bodyButton('modals.chunk.save')!.click());
const dialog = document.body.querySelector('[role="dialog"]')!;
expect(dialog.querySelector('[role="alert"]')?.textContent).toBe(
'settings.sources.chunkErrors.save',
);
expect(document.body.querySelector('textarea')?.value).toBe('Changed');
expect(onChunkSaved).not.toHaveBeenCalled();
});
it('falls back to the overview links when the detail has no relationships', async () => {
const overview = {
nodes: [
{
id: 'nord',
name: 'Nordhaven Logistics B.V.',
type: 'Company',
degree: 2,
},
{ id: 'duisport', name: 'Duisport', type: 'Port', degree: 7 },
{ id: 'marta', name: 'Marta Keller', type: 'Person', degree: 3 },
{ id: 'far', name: 'Far away', type: 'Port', degree: 1 },
],
links: [
{ source: 'nord', target: 'duisport', type: 'SHIPS_VIA' },
{ source: 'marta', target: 'nord', type: 'manages' },
{ source: 'marta', target: 'far', type: 'visits' },
],
};
await render({
detail: { ...DETAIL, relationships: undefined },
overview,
});
const rows = relationshipButtons();
expect(rows.map((row) => row.textContent)).toEqual([
'Duisportships via',
'Marta Kellermanages',
]);
expect(container.textContent).not.toContain(
'settings.sources.graphrag.view.noRelationships',
);
await render({ detail: { ...DETAIL, relationships: undefined } });
expect(container.textContent).toContain(
'settings.sources.graphrag.view.noRelationships',
);
});
});
@@ -0,0 +1,419 @@
import { File, X } from 'lucide-react';
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import { Card, CardFooter } from '../ui/card';
import { DescriptionItem, DescriptionList } from '../ui/description-list';
import { EmptyState } from '../ui/empty-state';
import { IconButton } from '../ui/icon-button';
import { ListRow } from '../ui/list-row';
import { SectionHeader } from '../ui/section-header';
import { Separator } from '../ui/separator';
import { Skeleton } from '../ui/skeleton';
import { formatCount } from '../../utils/dateTimeUtils';
import { chunkPreviewText } from '../chunkUtils';
import {
groupRelationships,
type ForceGraphData,
type GraphNodeChunk,
type GraphNodeDetail,
type FoldedGraphTypes,
} from '../graphViewUtils';
import GraphChunkSheet from './GraphChunkSheet';
import {
chunkFileName,
overviewRelationships,
relationshipLabelText,
} from './graphCanvasUtils';
import { GraphTypeBadge, GraphTypeDot } from './GraphTypeDot';
import type { GraphNodeDetailStatus } from './useGraphNodeDetail';
/** What the views know about a node before its detail loads. */
export interface GraphNodeRef {
id: string;
name: string;
type?: string | null;
}
/** Relationship rows shown before "Show all". */
export const RELATIONSHIP_PREVIEW = 12;
/** A description longer than this gets a Show more toggle. */
const LONG_DESCRIPTION = 240;
interface GraphNodePanelProps {
/** The source id (a chunk edit saves against it). */
docId: string;
node: GraphNodeRef;
detail: GraphNodeDetail | null;
status: GraphNodeDetailStatus;
onRetry: () => void;
fold: FoldedGraphTypes;
onClose: () => void;
/**
* Draw the panel's own close X. Off in the phone bottom sheet, whose
* handle means no X (the scrim closes it).
*/
showClose?: boolean;
/** Select another node (a relationship row). */
onSelectNode: (node: GraphNodeRef) => void;
/** An extra control under the type badge ("Show in graph"). */
action?: ReactNode;
/**
* The loaded overview: its links around the node stand in for the
* relationships when the detail has none (an API that predates them).
*/
overview?: ForceGraphData;
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
onOpenInFiles?: (path: string) => void;
/** Refetch the detail after a chunk edit, keeping it on screen. */
onChunkSaved?: () => void;
}
/**
* The node panel docked in a graph frame, the Graph tab's canvas and the
* Entities table alike: a `border-l` column at 320px, 40% of the frame from
* `xl` (capped at the 576px detail-drawer width), so the relationship list has
* room once the screen does.
*/
export function GraphNodePanelDock({ children }: { children: ReactNode }) {
return (
<aside className="border-border flex w-80 shrink-0 flex-col border-l xl:w-2/5 xl:max-w-xl">
{children}
</aside>
);
}
/**
* One entity's panel: its facts, the relationships you can follow, then the
* chunks it was extracted from. Docked beside the canvas or the entity table
* on desktop, in a bottom sheet on a phone. Key it on the node id so the
* disclosures reset per node.
*/
export default function GraphNodePanel({
docId,
node,
detail,
status,
onRetry,
fold,
onClose,
showClose = true,
onSelectNode,
action,
overview,
onOpenInFiles,
onChunkSaved,
}: GraphNodePanelProps) {
const { t } = useTranslation();
const name = detail?.name ?? node.name;
const type = detail ? detail.type : node.type;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-1.5">
<SectionHeader
as="h3"
size="xs"
className="wrap-break-word"
title={name}
/>
<GraphTypeBadge fold={fold} type={type} />
</div>
{action ? <div>{action}</div> : null}
</div>
{showClose ? (
<IconButton
variant="ghost-muted"
size="icon-sm"
className="shrink-0"
onClick={onClose}
label={t('settings.sources.graphrag.view.close')}
icon={X}
side="bottom"
/>
) : null}
</div>
<Separator />
<div className="scrollbar-overlay flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-4">
{status === 'error' ? (
<EmptyState
size="sm"
tone="destructive"
illustration="none"
title={t('settings.sources.graphrag.view.nodeLoadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onRetry}
>
{t('retry')}
</Button>
}
/>
) : detail && status === 'ready' ? (
<NodeDetailBody
docId={docId}
detail={detail}
fold={fold}
onSelectNode={onSelectNode}
overview={overview}
onOpenInFiles={onOpenInFiles}
onChunkSaved={onChunkSaved}
/>
) : (
<div
className="flex flex-col gap-3"
aria-busy="true"
aria-label={t('settings.sources.graphrag.view.loadingNode')}
>
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/5" />
<Skeleton className="mt-3 h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-3/4" />
</div>
)}
</div>
</div>
);
}
function NodeDetailBody({
docId,
detail,
fold,
onSelectNode,
overview,
onOpenInFiles,
onChunkSaved,
}: {
docId: string;
detail: GraphNodeDetail;
fold: FoldedGraphTypes;
onSelectNode: (node: GraphNodeRef) => void;
overview?: ForceGraphData;
onOpenInFiles?: (path: string) => void;
onChunkSaved?: () => void;
}) {
const { t } = useTranslation();
const [descriptionOpen, setDescriptionOpen] = useState(false);
const [allRelationships, setAllRelationships] = useState(false);
const [openChunk, setOpenChunk] = useState<GraphNodeChunk | null>(null);
const rows = useMemo(
() =>
groupRelationships(
detail.relationships ??
(overview
? overviewRelationships(detail.id, overview.links, overview.nodes)
: []),
),
[detail.relationships, detail.id, overview],
);
const mentions = detail.doc_freq ?? detail.chunks.length;
// The list is capped server-side; the total counts every edge.
const returned = detail.relationships?.length ?? 0;
const relationshipsTotal = detail.relationships_total ?? rows.length;
const capped =
detail.relationships_total !== undefined &&
detail.relationships_total > returned;
const shownRows = allRelationships
? rows
: rows.slice(0, RELATIONSHIP_PREVIEW);
const description = (detail.description ?? '').trim();
const relatedTo = t('settings.sources.graphrag.view.relatedTo');
return (
<>
<DescriptionList size="xs">
<DescriptionItem
label={t('settings.sources.graphrag.view.connections')}
>
<span className="tabular-nums">{formatCount(detail.degree)}</span>
</DescriptionItem>
<DescriptionItem
label={t('settings.sources.graphrag.view.mentionedIn')}
>
<span className="tabular-nums">
{t('settings.sources.graphrag.view.chunkCount', {
count: mentions,
formatted: formatCount(mentions),
})}
</span>
</DescriptionItem>
</DescriptionList>
{description ? (
<div className="flex flex-col gap-1">
<p
className={cn(
'text-muted-foreground text-sm leading-relaxed wrap-break-word',
!descriptionOpen && 'line-clamp-4',
)}
>
{description}
</p>
{description.length > LONG_DESCRIPTION ? (
<Button
type="button"
variant="link"
size="sm"
className="-ml-3 w-fit justify-start"
aria-expanded={descriptionOpen}
onClick={() => setDescriptionOpen((open) => !open)}
>
{descriptionOpen
? t('settings.sources.graphrag.view.showLess')
: t('settings.sources.graphrag.view.showMore')}
</Button>
) : null}
</div>
) : null}
<section className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="xs"
title={
<>
{t('settings.sources.graphrag.view.relationships')}{' '}
<span className="text-muted-foreground font-normal tabular-nums">
{formatCount(relationshipsTotal)}
</span>
</>
}
/>
{rows.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noRelationships')}
</p>
) : (
<ul className="-mx-2 flex flex-col">
{shownRows.map((row) => {
const labels = relationshipLabelText(row.labels, relatedTo);
return (
<ListRow
key={row.id}
interactive
asChild
size="sm"
leading={
<GraphTypeDot
fold={fold}
type={row.type}
className="mt-1.5"
/>
}
title={<span title={row.name}>{row.name}</span>}
description={<span title={labels}>{labels}</span>}
>
<button
type="button"
onClick={() =>
onSelectNode({
id: row.id,
name: row.name,
type: row.type,
})
}
/>
</ListRow>
);
})}
</ul>
)}
{rows.length > RELATIONSHIP_PREVIEW ? (
<Button
type="button"
variant="link"
size="sm"
className="-ml-3 w-fit justify-start"
aria-expanded={allRelationships}
onClick={() => setAllRelationships((open) => !open)}
>
{allRelationships
? t('settings.sources.graphrag.view.showFewer')
: t('settings.sources.graphrag.view.showAll', {
count: rows.length,
formatted: formatCount(rows.length),
})}
</Button>
) : null}
{capped ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.relationshipsCapped', {
count: relationshipsTotal,
shown: formatCount(returned),
total: formatCount(relationshipsTotal),
})}
</p>
) : null}
</section>
<section className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="xs"
title={
<>
{t('settings.sources.graphrag.view.sourceChunks')}{' '}
<span className="text-muted-foreground font-normal tabular-nums">
{formatCount(detail.chunks.length)}
</span>
</>
}
/>
{detail.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<div className="flex flex-col gap-2">
{detail.chunks.map((chunk) => {
const file = chunkFileName(chunk.metadata);
return (
<Card
key={chunk.chunk_id}
variant="filled"
padding="sm"
interactive
className="gap-2 text-left"
asChild
>
<button type="button" onClick={() => setOpenChunk(chunk)}>
<p className="text-foreground line-clamp-3 text-xs leading-relaxed wrap-break-word">
{chunkPreviewText(chunk.text)}
</p>
{file ? (
<CardFooter className="min-w-0 gap-1.5">
<File className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate" title={file}>
{file}
</span>
</CardFooter>
) : null}
</button>
</Card>
);
})}
</div>
)}
</section>
<GraphChunkSheet
docId={docId}
chunk={openChunk}
highlight={detail.name}
onClose={() => setOpenChunk(null)}
onOpenInFiles={onOpenInFiles}
onSaved={onChunkSaved}
/>
</>
);
}
@@ -0,0 +1,573 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
// The header totals: plural keys joined by the stats key.
if (key === 'settings.sources.graphrag.view.stats' && opts) {
return `${opts.entities} · ${opts.relationships}`;
}
if (key === 'settings.sources.graphrag.view.entityCount' && opts) {
return `${opts.formatted} ${opts.count === 1 ? 'entity' : 'entities'}`;
}
if (key === 'settings.sources.graphrag.view.relationshipCount' && opts) {
return `${opts.formatted} ${opts.count === 1 ? 'relationship' : 'relationships'}`;
}
return opts && 'count' in opts ? `${key}:${opts.count}` : key;
},
}),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
}));
vi.mock('../../hooks', () => ({
useDebouncedValue: (value: unknown) => value,
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
}));
// The canvas can't render in the DOM test environment.
vi.mock('react-force-graph-2d', () => ({
default: () => <canvas data-testid="force-graph" />,
}));
vi.mock('../FileTree', async () => {
const { createPortal } = await import('react-dom');
const { useEffect } = await import('react');
return {
default: (props: {
embedded?: boolean;
initialPath?: string;
actionsTarget?: HTMLElement | null;
onCrumbsChange?: (crumbs: { label: string }[]) => void;
}) => {
const { onCrumbsChange } = props;
useEffect(() => {
onCrumbsChange?.([{ label: 'Key Accounts' }, { label: 'carriers' }]);
}, [onCrumbsChange]);
return (
<div
data-testid="file-tree"
data-initial-path={props.initialPath ?? ''}
>
{props.embedded ? 'embedded' : 'page'}
{props.actionsTarget
? createPortal(
<button type="button">ADD FILE</button>,
props.actionsTarget,
)
: null}
</div>
);
},
};
});
// The embedded chunk list of a source with no folder structure; its "open
// chunk" button reports position 2 like the real list does.
vi.mock('../Chunks', async () => {
const { useEffect, useState } = await import('react');
return {
default: (props: {
embedded?: boolean;
documentId: string;
onOpenChunkChange?: (position: number | null) => void;
controllerRef?: { current: { closeChunk: () => boolean } | null };
}) => {
const [open, setOpen] = useState(false);
const { onOpenChunkChange, controllerRef } = props;
useEffect(() => {
onOpenChunkChange?.(open ? 2 : null);
}, [open, onOpenChunkChange]);
if (controllerRef) {
controllerRef.current = {
closeChunk: () => {
setOpen(false);
return false;
},
};
}
return (
<div data-testid="chunks" data-doc={props.documentId}>
{props.embedded ? 'embedded' : 'page'}
<button type="button" onClick={() => setOpen(true)}>
OPEN CHUNK
</button>
</div>
);
},
};
});
vi.mock('../ConnectorTree', () => ({
default: () => <div data-testid="connector-tree" />,
}));
vi.mock('../../api/services/userService', () => ({
default: {
getSourceGraph: vi.fn(),
getSourceGraphNodes: vi.fn(),
getSourceGraphNode: vi.fn(),
},
}));
import i18next from 'i18next';
import userService from '../../api/services/userService';
import GraphSourceView from './GraphSourceView';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const service = userService as unknown as {
getSourceGraph: ReturnType<typeof vi.fn>;
getSourceGraphNodes: ReturnType<typeof vi.fn>;
getSourceGraphNode: ReturnType<typeof vi.fn>;
};
const ok = (body: unknown) => ({
ok: true,
status: 200,
json: async () => body,
});
const failed = () => ({ ok: false, status: 400, json: async () => ({}) });
const OVERVIEW = {
nodes: [
{ id: 'a', name: 'Anneke de Vries', type: 'Person', degree: 5 },
{ id: 'b', name: 'Bram', type: 'PERSON', degree: 3 },
{ id: 'n', name: 'Nordhaven', type: 'Company', degree: 9 },
],
edges: [
{ source: 'a', target: 'n', type: 'manages' },
{ source: 'b', target: 'n', type: 'works_for' },
],
stats: { nodes: 371, edges: 1172 },
};
const flush = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
describe('GraphSourceView', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
service.getSourceGraph.mockReset();
service.getSourceGraphNodes.mockReset();
service.getSourceGraphNode.mockReset();
service.getSourceGraph.mockResolvedValue(ok(OVERVIEW));
service.getSourceGraphNodes.mockResolvedValue(
ok({
nodes: [
{
id: 'n',
name: 'Nordhaven',
type: 'Company',
degree: 9,
doc_freq: 4,
},
],
total: 1,
page: 1,
per_page: 25,
types: [{ key: 'company', label: 'Company', count: 1 }],
}),
);
service.getSourceGraphNode.mockResolvedValue(
ok({
node: {
id: 'n',
name: 'Nordhaven',
type: 'Company',
degree: 9,
doc_freq: 4,
relationships: [],
chunks: [],
},
}),
);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (
sourceType?: string,
onBack = vi.fn(),
isNested?: boolean,
) => {
await act(async () => {
root.render(
<GraphSourceView
docId="doc"
sourceName="Key Accounts"
sourceType={sourceType}
isNested={isNested}
onBackToDocuments={onBack}
headerAction={<button type="button">Test retrieval</button>}
/>,
);
});
await flush();
};
const buttonByText = (text: string) =>
Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent?.trim() === text,
);
const openTab = async (label: string) => {
const tab = Array.from(container.querySelectorAll('[role="tab"]')).find(
(b) => b.textContent === label,
) as HTMLButtonElement;
await act(async () => {
tab.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
});
await flush();
};
// Bugs row 8: the app language's separators, not the browser's.
it('formats the graph totals in the app language', async () => {
const language = i18next.language;
i18next.language = 'de';
try {
await render();
expect(container.textContent).toContain(
'371 entities · 1.172 relationships',
);
} finally {
i18next.language = language;
}
});
it('pluralises the graph totals by the raw counts', async () => {
service.getSourceGraph.mockResolvedValue(
ok({ ...OVERVIEW, stats: { nodes: 1, edges: 1 } }),
);
await render();
expect(container.textContent).toContain('1 entity · 1 relationship');
});
it('heads the view with the whole graph totals and the kind badge', async () => {
await render();
const text = container.textContent ?? '';
expect(text).toContain('Key Accounts');
expect(text).toContain('settings.sources.graphrag.view.title');
expect(text).toContain(
`${(371).toLocaleString()} entities · ${(1172).toLocaleString()} relationships`,
);
expect(buttonByText('Test retrieval')).toBeDefined();
expect(service.getSourceGraph).toHaveBeenCalledWith('doc', null, 100);
});
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
it('crumbs: Sources › the source; the Files tab adds where the tree is', async () => {
const onBack = vi.fn();
await render(undefined, onBack);
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
expect(container.querySelector('.lucide-arrow-left')).toBeNull();
await openTab('settings.sources.graphrag.view.tabs.files');
expect(crumbs()).toEqual([
'settings.sources.label',
'Key Accounts',
'carriers',
]);
await openTab('settings.sources.graphrag.view.tabs.graph');
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
const sources = container.querySelector<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
)!;
await act(async () => sources.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
it('builds the legend from one fold of the loaded types', async () => {
await render();
const legend = container.querySelector(
'[aria-label="settings.sources.graphrag.view.typeFilter"]',
)!;
const items = Array.from(legend.querySelectorAll('button')).map(
(b) => b.textContent,
);
// "Person" and "PERSON" fold into one group.
expect(items).toEqual(['Person2', 'Company1']);
// xs groups sit in a muted track (DESIGN.md ToggleGroup).
expect(legend.parentElement!.className).toContain('bg-muted');
});
it('reads "Show top [50 | 100 | 250] by connections" at one size, the track holding only the group', async () => {
await render();
const limits = buttonByText('100')!.closest('[role="radiogroup"]')!;
const track = limits.parentElement!;
expect(track.className).toContain('bg-muted');
expect(track.textContent).not.toContain(
'settings.sources.graphrag.view.showTop',
);
const label = Array.from(container.querySelectorAll('span')).find(
(el) => el.textContent === 'settings.sources.graphrag.view.showTop',
)!;
expect(label.className).toContain('text-sm');
expect(label.className).not.toContain('text-xs');
});
it('refetches the overview with the chosen size', async () => {
await render();
await act(async () => buttonByText('250')!.click());
await flush();
expect(service.getSourceGraph).toHaveBeenLastCalledWith('doc', null, 250);
});
it('shows the empty state for a graph with no entities', async () => {
service.getSourceGraph.mockResolvedValue(
ok({ nodes: [], edges: [], stats: { nodes: 0, edges: 0 } }),
);
await render();
expect(container.textContent).toContain(
'settings.sources.graphrag.view.empty',
);
expect(container.querySelector('[data-testid="force-graph"]')).toBeNull();
});
it('shows a retry when the overview fails', async () => {
service.getSourceGraph.mockResolvedValueOnce(failed());
await render();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'settings.sources.graphrag.view.loadFailed',
);
await act(async () => buttonByText('retry')!.click());
await flush();
expect(service.getSourceGraph).toHaveBeenCalledTimes(2);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it('opens an entity from the table and shows it in the graph', async () => {
await render();
await openTab('settings.sources.graphrag.view.tabs.entities');
expect(service.getSourceGraphNodes).toHaveBeenCalledWith(
'doc',
{ q: undefined, type: undefined, page: 1, perPage: 25 },
null,
);
const row = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
r.textContent?.includes('Nordhaven'),
) as HTMLTableRowElement;
expect(row.textContent).toContain('Company');
await act(async () => row.click());
await flush();
expect(service.getSourceGraphNode).toHaveBeenCalledWith('doc', 'n', null);
// One frame, as on the Graph tab: the table and the panel docked beside
// it (border-l, 40% from xl), the open entity's row marked.
const openRow = Array.from(container.querySelectorAll('tbody tr')).find(
(r) => r.textContent?.includes('Nordhaven'),
) as HTMLTableRowElement;
expect(openRow.getAttribute('aria-current')).toBe('true');
const frame = openRow.closest('[data-slot="card"]')!;
expect(frame.getAttribute('data-variant')).toBe('subtle');
expect(frame.className).toContain('h-[70svh]');
const dock = frame.querySelector('aside')!;
expect(dock.className).toContain('border-l');
expect(dock.className).toContain('xl:w-2/5');
expect(dock.querySelector('h3')?.textContent).toBe('Nordhaven');
expect(container.querySelector('[data-slot="table-container"]')).toBeNull();
const close = dock.querySelector(
'button[aria-label="settings.sources.graphrag.view.close"]',
)!;
expect(close.getAttribute('data-size')).toBe('icon-sm');
await act(async () =>
buttonByText('settings.sources.graphrag.view.showInGraph')!.click(),
);
await flush();
const graphTab = Array.from(
container.querySelectorAll('[role="tab"]'),
).find(
(b) => b.textContent === 'settings.sources.graphrag.view.tabs.graph',
)!;
expect(graphTab.getAttribute('aria-selected')).toBe('true');
// The graph tab's docked panel has the node open.
expect(container.querySelector('aside h3')?.textContent).toBe('Nordhaven');
});
it('renders the embedded file view on the Files tab', async () => {
await render();
await openTab('settings.sources.graphrag.view.tabs.files');
expect(
container.querySelector('[data-testid="file-tree"]')?.textContent,
).toBe('embedded');
});
it("puts the Files tab's action in the header, only on that tab", async () => {
await render();
const addFile = () =>
Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'ADD FILE',
);
expect(addFile()).toBeUndefined();
await openTab('settings.sources.graphrag.view.tabs.files');
const button = addFile();
expect(button).toBeDefined();
// It sits in the header, above the tab list, not inside the Files panel.
const tabList = container.querySelector('[role="tablist"]')!;
expect(
button!.compareDocumentPosition(tabList) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
container.querySelector('[data-testid="file-tree"]')?.contains(button!),
).toBe(false);
await openTab('settings.sources.graphrag.view.tabs.graph');
expect(addFile()).toBeUndefined();
});
// A source with no directory structure (one uploaded file) shows its chunk
// list, as the plain source view does.
it('shows the embedded chunk list on the Files tab of a flat source', async () => {
await render(undefined, vi.fn(), false);
await openTab('settings.sources.graphrag.view.tabs.files');
const chunks = container.querySelector('[data-testid="chunks"]');
expect(chunks?.textContent).toContain('embedded');
expect(chunks?.getAttribute('data-doc')).toBe('doc');
expect(container.querySelector('[data-testid="file-tree"]')).toBeNull();
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
// An open chunk adds its crumb; the source crumb closes it.
await act(async () => buttonByText('OPEN CHUNK')!.click());
expect(crumbs()).toEqual([
'settings.sources.label',
'Key Accounts',
'settings.sources.chunkCrumb',
]);
const source = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((b) => b.textContent === 'Key Accounts')!;
await act(async () => source.click());
expect(crumbs()).toEqual(['settings.sources.label', 'Key Accounts']);
});
it('uses the connector tree for a connector source', async () => {
await render('connector:file');
await openTab('settings.sources.graphrag.view.tabs.files');
expect(
container.querySelector('[data-testid="connector-tree"]'),
).not.toBeNull();
});
it("opens a chunk's file on the Files tab from the chunk drawer", async () => {
service.getSourceGraphNode.mockResolvedValue(
ok({
node: {
id: 'n',
name: 'Nordhaven',
type: 'Company',
degree: 9,
relationships: [],
chunks: [
{
chunk_id: 'c1',
text: 'Nordhaven runs the lane.',
metadata: { source: 'briefs/Nordhaven.md' },
},
],
},
}),
);
await render();
await openTab('settings.sources.graphrag.view.tabs.entities');
const row = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
r.textContent?.includes('Nordhaven'),
) as HTMLTableRowElement;
await act(async () => row.click());
await flush();
const tile = Array.from(
container.querySelectorAll('button[data-slot="card"]'),
).find((b) =>
b.textContent?.includes('Nordhaven runs the lane.'),
) as HTMLButtonElement;
await act(async () => tile.click());
const openInFiles = Array.from(
document.body.querySelectorAll('button'),
).find(
(b) => b.textContent === 'settings.sources.graphrag.view.openInFiles',
)!;
await act(async () => openInFiles.click());
await flush();
const filesTab = Array.from(
container.querySelectorAll('[role="tab"]'),
).find(
(b) => b.textContent === 'settings.sources.graphrag.view.tabs.files',
)!;
expect(filesTab.getAttribute('aria-selected')).toBe('true');
expect(
container
.querySelector('[data-testid="file-tree"]')
?.getAttribute('data-initial-path'),
).toBe('briefs/Nordhaven.md');
// The request is one-off: a later visit opens the tree at its root.
await openTab('settings.sources.graphrag.view.tabs.entities');
await openTab('settings.sources.graphrag.view.tabs.files');
expect(
container
.querySelector('[data-testid="file-tree"]')
?.getAttribute('data-initial-path'),
).toBe('');
});
it('a new entity filter fetches page 1 once, not the old page first', async () => {
service.getSourceGraphNodes.mockImplementation(async () =>
ok({
nodes: [{ id: 'n', name: 'Nordhaven', type: 'Company', degree: 9 }],
total: 60,
types: [{ type: 'Company', count: 60 }],
}),
);
await render();
await openTab('settings.sources.graphrag.view.tabs.entities');
const next = container.querySelector<HTMLButtonElement>(
'button[aria-label="pagination.nextPage"]',
)!;
await act(async () => next.click());
await flush();
expect(service.getSourceGraphNodes.mock.lastCall![1].page).toBe(2);
service.getSourceGraphNodes.mockClear();
const search = container.querySelector<HTMLInputElement>(
'[role="tabpanel"][data-state="active"] input',
)!;
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
await act(async () => {
setter.call(search, 'nord');
search.dispatchEvent(new Event('input', { bubbles: true }));
});
await flush();
const pages = service.getSourceGraphNodes.mock.calls.map(
(call) => call[1].page,
);
expect(pages).toEqual([1]);
});
});
@@ -0,0 +1,293 @@
import { Waypoints } from 'lucide-react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatCount } from '../../utils/dateTimeUtils';
import Chunks, { type ChunksController } from '../Chunks';
import ConnectorTree from '../ConnectorTree';
import FileTree from '../FileTree';
import GraphView, { type GraphLoadStatus } from '../GraphView';
import PathHeader, { type Crumb } from '../tree/PathHeader';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import {
foldGraphTypes,
toForceGraphData,
type ForceGraphData,
type GraphStats,
} from '../graphViewUtils';
import GraphEntities from './GraphEntities';
import type { GraphNodeRef } from './GraphNodePanel';
const DEFAULT_LIMIT = 100;
type GraphTab = 'graph' | 'entities' | 'files';
interface GraphSourceViewProps {
docId: string;
sourceName: string;
/** The source's `type`; `connector:file` sources browse as a connector tree. */
sourceType?: string;
/**
* Whether the source has a folder structure (`bool(directory_structure)`).
* Without one the Files tab shows the chunk list, as the plain source view
* does. Defaults to true.
*/
isNested?: boolean;
onBackToDocuments: () => void;
/** Extra header control (Test retrieval), right-aligned in the title row. */
headerAction?: ReactNode;
}
/**
* A knowledge-graph source: the header (source name, the Knowledge graph
* badge, the whole graph's totals) over three tabs, Graph (the canvas),
* Entities (a searchable table) and Files (the ordinary file view). The
* overview is fetched here so the header, the canvas and the entity list
* share one fold of the types, and so one type has one colour everywhere.
*/
export default function GraphSourceView({
docId,
sourceName,
sourceType,
isNested = true,
onBackToDocuments,
headerAction,
}: GraphSourceViewProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [tab, setTab] = useState<GraphTab>('graph');
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
const [stats, setStats] = useState<GraphStats | null>(null);
const [status, setStatus] = useState<GraphLoadStatus>('loading');
const [attempt, setAttempt] = useState(0);
const [selected, setSelected] = useState<GraphNodeRef | null>(null);
// The file a chunk drawer's "Open in Files" asked the Files tab to open.
const [filesPath, setFilesPath] = useState<string | undefined>(undefined);
// Where the Files tab's tree is (the source, folders, file, chunk); the
// header shows it while that tab is open.
const [filesCrumbs, setFilesCrumbs] = useState<Crumb[]>([]);
// A flat source's open chunk (its crumb), reported by the chunk list.
const [openChunkPosition, setOpenChunkPosition] = useState<number | null>(
null,
);
const chunksControllerRef = useRef<ChunksController | null>(null);
useEffect(() => {
let cancelled = false;
setStatus('loading');
userService
.getSourceGraph(docId, token, limit)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((body) => {
if (cancelled) return;
const nodes = body?.nodes ?? [];
const edges = body?.edges ?? [];
setData(toForceGraphData({ nodes, edges }));
setStats(body?.stats ?? { nodes: nodes.length, edges: edges.length });
setStatus('ready');
})
.catch((error) => {
if (cancelled) return;
console.error('Error loading graph:', error);
setStatus('error');
});
return () => {
cancelled = true;
};
}, [docId, token, limit, attempt]);
const fold = useMemo(() => foldGraphTypes(data.nodes), [data.nodes]);
const retry = useCallback(() => setAttempt((n) => n + 1), []);
// "Open in Files" is a one-off request: leaving the Files tab drops it, so
// a later visit opens the tree at its root rather than on that file again.
const changeTab = useCallback(
(next: GraphTab) => {
if (tab === 'files' && next !== 'files') setFilesPath(undefined);
setTab(next);
},
[tab],
);
const showInGraph = useCallback(
(node: GraphNodeRef) => {
setSelected(node);
changeTab('graph');
},
[changeTab],
);
const [filesActions, setFilesActions] = useState<HTMLDivElement | null>(null);
// A flat source has no file to open: "Open in Files" just shows the tab.
const openInFiles = useCallback(
(path: string) => {
if (isNested) setFilesPath(path);
setTab('files');
},
[isNested],
);
// The flat source's crumbs: the source, then the open chunk; the source
// crumb closes the chunk.
const chunkCrumbs: Crumb[] = [
{
label: sourceName,
onSelect:
openChunkPosition !== null
? () => chunksControllerRef.current?.closeChunk()
: undefined,
},
...(openChunkPosition !== null
? [
{
label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
},
]
: []),
];
const filesSegments = isNested ? filesCrumbs : chunkCrumbs;
const files = !isNested ? (
<Chunks
embedded
documentId={docId}
documentName={sourceName}
handleGoBack={onBackToDocuments}
controllerRef={chunksControllerRef}
onOpenChunkChange={setOpenChunkPosition}
/>
) : sourceType === 'connector:file' ? (
<ConnectorTree
embedded
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
initialPath={filesPath}
actionsTarget={filesActions}
onCrumbsChange={setFilesCrumbs}
/>
) : (
<FileTree
embedded
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
initialPath={filesPath}
actionsTarget={filesActions}
onCrumbsChange={setFilesCrumbs}
/>
);
return (
<div className="flex flex-col gap-4">
<PathHeader
root={{
label: t('settings.sources.label'),
onSelect: onBackToDocuments,
}}
segments={
tab === 'files' && filesSegments.length
? filesSegments
: [{ label: sourceName }]
}
badge={
<Badge variant="neutral">
<Waypoints />
{t('settings.sources.graphrag.view.title')}
</Badge>
}
byline={
stats
? t('settings.sources.graphrag.view.stats', {
entities: t('settings.sources.graphrag.view.entityCount', {
count: stats.nodes,
formatted: formatCount(stats.nodes),
}),
relationships: t(
'settings.sources.graphrag.view.relationshipCount',
{
count: stats.edges,
formatted: formatCount(stats.edges),
},
),
interpolation: { escapeValue: false },
})
: undefined
}
actions={
<>
{headerAction}
{/* The Files tab's own action (Add file, Sync) portals in here,
beside Test retrieval, like on a plain source. */}
{tab === 'files' && isNested ? (
<div ref={setFilesActions} className="flex items-center gap-2" />
) : null}
</>
}
/>
<Tabs value={tab} onValueChange={(value) => changeTab(value as GraphTab)}>
<TabsList variant="underline">
<TabsTrigger value="graph" variant="underline">
{t('settings.sources.graphrag.view.tabs.graph')}
</TabsTrigger>
<TabsTrigger value="entities" variant="underline">
{t('settings.sources.graphrag.view.tabs.entities')}
</TabsTrigger>
<TabsTrigger value="files" variant="underline">
{t('settings.sources.graphrag.view.tabs.files')}
</TabsTrigger>
</TabsList>
{/* Kept mounted so the layout and zoom survive a trip to another tab. */}
<TabsContent
value="graph"
forceMount
className="mt-4 data-[state=inactive]:hidden"
>
<GraphView
docId={docId}
data={data}
fold={fold}
status={status}
onRetry={retry}
limit={limit}
onLimitChange={setLimit}
selected={selected}
onSelect={setSelected}
active={tab === 'graph'}
onOpenInFiles={openInFiles}
/>
</TabsContent>
<TabsContent value="entities" className="mt-4">
<GraphEntities
docId={docId}
fold={fold}
onShowInGraph={showInGraph}
overview={data}
onOpenInFiles={openInFiles}
/>
</TabsContent>
<TabsContent value="files" className="mt-4">
{/* Remounted per requested file so the tree opens on it. */}
<div key={filesPath ?? ''}>{files}</div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,66 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Badge } from '../ui/badge';
import type { FoldedGraphTypes } from '../graphViewUtils';
import { seriesDotClass } from './graphCanvasUtils';
/** The 8px colour dot of a type's fold series (Other is muted). */
export function GraphTypeDot({
fold,
type,
className,
}: {
fold: FoldedGraphTypes;
type: string | null | undefined;
className?: string;
}) {
return (
<span
aria-hidden="true"
className={cn(
'inline-block size-2 shrink-0 rounded-full',
seriesDotClass(fold.seriesOf(type)),
className,
)}
/>
);
}
/** The dot for a fold series index (null for Other), for the legend. */
export function GraphSeriesDot({ series }: { series: number | null }) {
return (
<span
aria-hidden="true"
className={cn(
'inline-block size-2 shrink-0 rounded-full',
seriesDotClass(series),
)}
/>
);
}
/** The display label of a type: its folded spelling, or "Untyped". */
export function useGraphTypeLabel(fold: FoldedGraphTypes) {
const { t } = useTranslation();
return (type: string | null | undefined) =>
fold.labelOf(type) || t('settings.sources.graphrag.view.untyped');
}
/** A neutral Badge with the type's dot and folded label. */
export function GraphTypeBadge({
fold,
type,
}: {
fold: FoldedGraphTypes;
type: string | null | undefined;
}) {
const labelOf = useGraphTypeLabel(fold);
return (
<Badge variant="neutral" className="max-w-full min-w-0">
<GraphTypeDot fold={fold} type={type} />
<span className="truncate">{labelOf(type)}</span>
</Badge>
);
}
@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest';
import { foldGraphTypes } from '../graphViewUtils';
import {
OTHER_GROUP_KEY,
buildAdjacency,
chunkFileName,
chunkFilePath,
chunkHeading,
endpointId,
focusSet,
labelBoxesOverlap,
legendGroupOf,
linkTouches,
overviewRelationships,
pickLabels,
relationshipLabelText,
seriesDotClass,
topHubIds,
} from './graphCanvasUtils';
const nodes = Array.from({ length: 14 }, (_, i) => ({
id: `n${i}`,
name: `Node ${i}`,
degree: 14 - i,
}));
describe('topHubIds', () => {
it('keeps the ten busiest nodes', () => {
const hubs = topHubIds(nodes);
expect(hubs.size).toBe(10);
expect(hubs.has('n0')).toBe(true);
expect(hubs.has('n9')).toBe(true);
expect(hubs.has('n10')).toBe(false);
});
});
describe('buildAdjacency', () => {
it('links both ends and reads node objects from a running simulation', () => {
const adjacency = buildAdjacency([
{ source: 'a', target: 'b' },
{
source: { id: 'b' } as unknown as string,
target: { id: 'c' } as unknown as string,
},
{ source: 'a', target: 'a' },
]);
expect([...(adjacency.get('b') ?? [])].sort()).toEqual(['a', 'c']);
expect([...(adjacency.get('a') ?? [])]).toEqual(['b']);
});
it('reads either end shape', () => {
expect(endpointId('x')).toBe('x');
expect(endpointId({ id: 'y' })).toBe('y');
expect(endpointId(null)).toBe('');
});
});
describe('focusSet (dimming)', () => {
const adjacency = buildAdjacency([
{ source: 'a', target: 'b' },
{ source: 'a', target: 'c' },
{ source: 'c', target: 'd' },
]);
const loaded = new Set(['a', 'b', 'c', 'd']);
it('is the selection and its neighbours', () => {
expect([...(focusSet('a', adjacency, loaded) ?? [])].sort()).toEqual([
'a',
'b',
'c',
]);
});
it('is null with no selection, or one outside the loaded overview', () => {
expect(focusSet(null, adjacency, loaded)).toBeNull();
expect(focusSet('zz', adjacency, loaded)).toBeNull();
});
it('keeps an isolated selection on its own', () => {
expect([...(focusSet('e', new Map(), new Set(['e'])) ?? [])]).toEqual([
'e',
]);
});
});
describe('linkTouches', () => {
it('matches either end', () => {
expect(linkTouches({ source: 'a', target: 'b' }, 'b')).toBe(true);
expect(linkTouches({ source: 'a', target: 'b' }, 'c')).toBe(false);
expect(linkTouches({ source: 'a', target: 'b' }, null)).toBe(false);
});
});
describe('relationshipLabelText', () => {
it('falls back when there are no labels', () => {
expect(relationshipLabelText([], 'related to')).toBe('related to');
});
it('joins two labels', () => {
expect(relationshipLabelText(['manages', 'owns'], 'x')).toBe(
'manages · owns',
);
});
it('counts the rest after two', () => {
expect(relationshipLabelText(['a', 'b', 'c', 'd'], 'x')).toBe('a · b · +2');
});
});
describe('chunkFileName', () => {
it('takes the last segment of the source path', () => {
expect(chunkFileName({ source: 'inputs/u/Carrier_profile.docx' })).toBe(
'Carrier_profile.docx',
);
});
it('falls back to the title, then nothing', () => {
expect(chunkFileName({ title: 'Notes.md' })).toBe('Notes.md');
expect(chunkFileName(undefined)).toBe('');
});
it("names a web page by its tree path, not the URL's last segment", () => {
expect(
chunkFileName({
source: 'https://docs.example.com/guides/setup?x=1',
file_path: 'guides/setup.md',
title: 'Setup',
}),
).toBe('setup.md');
});
it('never splits a title', () => {
expect(
chunkFileName({ source: 'https://example.com/a/b', title: 'A / B' }),
).toBe('A / B');
});
});
describe('chunkFilePath', () => {
it('prefers file_path, then key', () => {
expect(
chunkFilePath({
file_path: 'guides/setup.md',
key: 'other.md',
source: 'https://x.dev/guides/setup',
}),
).toBe('guides/setup.md');
expect(
chunkFilePath({ key: 'docs/a.md', source: 'inputs/u/docs/a.md' }),
).toBe('docs/a.md');
});
it('uses a local source path but never a URL', () => {
expect(chunkFilePath({ source: 'inputs/u/Brief.docx' })).toBe(
'inputs/u/Brief.docx',
);
expect(
chunkFilePath({ source: 'https://x.dev/page', title: 'Page title' }),
).toBe('Page title');
expect(chunkFilePath({ source: 'https://x.dev/page' })).toBe('');
});
});
describe('legend groups and dots', () => {
const fold = foldGraphTypes([
{ type: 'Person' },
{ type: 'Person' },
{ type: 'Port' },
{ type: 'Lane' },
{ type: 'Company' },
{ type: 'Event' },
{ type: null },
]);
it('maps a type to its fold group, the rest to Other', () => {
expect(legendGroupOf(fold, 'PERSON')).toBe('person');
expect(legendGroupOf(fold, null)).toBe(OTHER_GROUP_KEY);
});
it('uses a static class per series', () => {
expect(seriesDotClass(0)).toBe('bg-chart-1');
expect(seriesDotClass(3)).toBe('bg-chart-4');
expect(seriesDotClass(null)).toBe('bg-muted-foreground');
});
});
describe('overviewRelationships', () => {
const overviewNodes = [
{ id: 'n', name: 'Nordhaven', type: 'Company', degree: 9 },
{ id: 'a', name: 'Anneke', type: 'Person', degree: 5 },
{ id: 'b', name: 'Bram', type: 'Person', degree: 3 },
];
it('joins each link touching the node to its neighbour, both directions', () => {
const rels = overviewRelationships(
'n',
[
{ source: 'a', target: 'n', type: 'manages' },
// A simulation link: node objects at the ends.
{
source: { id: 'n' } as never,
target: { id: 'b' } as never,
type: null,
},
{ source: 'a', target: 'b', type: 'knows' },
{ source: 'n', target: 'missing', type: 'x' },
],
overviewNodes,
);
expect(rels).toEqual([
{
id: 'a',
name: 'Anneke',
type: 'Person',
degree: 5,
edge_type: 'manages',
direction: 'in',
},
{
id: 'b',
name: 'Bram',
type: 'Person',
degree: 3,
edge_type: null,
direction: 'out',
},
]);
});
});
describe('label collision', () => {
const box = (id: string, x: number, y: number, always = false) => ({
id,
x,
y,
width: 40,
height: 10,
always,
});
it('tests intersection of centred boxes', () => {
expect(labelBoxesOverlap(box('a', 0, 0), box('b', 39, 9))).toBe(true);
expect(labelBoxesOverlap(box('a', 0, 0), box('b', 40, 0))).toBe(false);
expect(labelBoxesOverlap(box('a', 0, 0), box('b', 0, 10))).toBe(false);
expect(labelBoxesOverlap(box('a', 0, 0), box('b', 0, 11), 2)).toBe(true);
});
it('keeps labels in priority order and skips the ones they cover', () => {
const picked = pickLabels([
box('selected', 0, 0, true),
box('hovered', 10, 2, true),
box('hub', 20, 5),
box('clear', 100, 0),
box('under-clear', 110, 4),
]);
expect([...picked]).toEqual(['selected', 'hovered', 'clear']);
});
});
describe('chunkHeading', () => {
it('reads a first-line markdown heading, else nothing', () => {
expect(chunkHeading('# Account brief — Nordhaven\nBody')).toBe(
'Account brief — Nordhaven',
);
expect(chunkHeading('\n ### Rates ##\ntext')).toBe('Rates');
expect(chunkHeading('Plain text\n# Later')).toBeNull();
expect(chunkHeading('#hashtag')).toBeNull();
expect(chunkHeading('')).toBeNull();
});
});
@@ -0,0 +1,281 @@
import type {
FoldedGraphTypes,
GraphEdge,
GraphNode,
GraphRelationship,
} from '../graphViewUtils';
/** The legend's key for everything folded into "Other". */
export const OTHER_GROUP_KEY = '__other';
/** How many of the busiest nodes keep a label while nothing is selected. */
const HUB_LABEL_COUNT = 10;
/** How much of a node or edge outside the selection stays visible. */
export const DIM_ALPHA = 0.15;
// Static class strings so Tailwind sees them: series 0..3, then Other.
const SERIES_DOT_CLASSES = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
] as const;
const OTHER_DOT_CLASS = 'bg-muted-foreground';
/**
* The dot colour class for a fold series.
*
* @param series The series index from `foldGraphTypes().seriesOf`, or null for Other.
* @returns A static `bg-*` class.
*/
export function seriesDotClass(series: number | null | undefined): string {
if (series == null) return OTHER_DOT_CLASS;
return SERIES_DOT_CLASSES[series] ?? OTHER_DOT_CLASS;
}
/**
* The legend group a type belongs to: its fold group's key, or Other.
*
* @param fold The shared type fold.
* @param type A node's raw type.
* @returns A group key, or `OTHER_GROUP_KEY`.
*/
export function legendGroupOf(
fold: FoldedGraphTypes,
type: string | null | undefined,
): string {
const series = fold.seriesOf(type);
if (series == null) return OTHER_GROUP_KEY;
return (
fold.groups.find((group) => group.series === series)?.key ?? OTHER_GROUP_KEY
);
}
type LinkEnd = string | { id?: string | number } | undefined | null;
/**
* The node id at one end of a link. The force simulation swaps the ids for
* the node objects once it starts, so both shapes are read.
*/
export function endpointId(end: LinkEnd): string {
if (end == null) return '';
if (typeof end === 'object') return String(end.id ?? '');
return String(end);
}
/** Neighbour ids per node id, from the loaded links (both directions). */
export function buildAdjacency(
links: Pick<GraphEdge, 'source' | 'target'>[],
): Map<string, Set<string>> {
const adjacency = new Map<string, Set<string>>();
const add = (from: string, to: string) => {
const set = adjacency.get(from) ?? new Set<string>();
set.add(to);
adjacency.set(from, set);
};
for (const link of links) {
const source = endpointId(link.source as LinkEnd);
const target = endpointId(link.target as LinkEnd);
if (!source || !target || source === target) continue;
add(source, target);
add(target, source);
}
return adjacency;
}
/** Ids of the `count` highest-degree nodes (ties by id, so the pick is stable). */
export function topHubIds(
nodes: Pick<GraphNode, 'id' | 'degree'>[],
count = HUB_LABEL_COUNT,
): Set<string> {
return new Set(
[...nodes]
.sort(
(a, b) => (b.degree || 0) - (a.degree || 0) || a.id.localeCompare(b.id),
)
.slice(0, count)
.map((node) => node.id),
);
}
/**
* The nodes that stay at full strength while one is selected: the selection
* and its neighbours. Null when nothing on the canvas is selected (a pick
* outside the loaded overview draws no highlight).
*/
export function focusSet(
selectedId: string | null | undefined,
adjacency: Map<string, Set<string>>,
loadedIds: Set<string>,
): Set<string> | null {
if (!selectedId || !loadedIds.has(selectedId)) return null;
return new Set([selectedId, ...(adjacency.get(selectedId) ?? [])]);
}
interface LabelContext {
hubs: Set<string>;
focus: Set<string> | null;
hoveredId: string | null | undefined;
}
/**
* Whether a node gets a label: with a selection, the selection and its
* neighbours; without one, the hubs. The hovered node always does.
*/
export function nodeHasLabel(
id: string,
{ hubs, focus, hoveredId }: LabelContext,
): boolean {
if (id === hoveredId) return true;
return focus ? focus.has(id) : hubs.has(id);
}
/** Whether a link touches the node. */
export function linkTouches(
link: Pick<GraphEdge, 'source' | 'target'>,
nodeId: string | null | undefined,
): boolean {
if (!nodeId) return false;
return (
endpointId(link.source as LinkEnd) === nodeId ||
endpointId(link.target as LinkEnd) === nodeId
);
}
/**
* A relationship row's label line: the first two labels joined by " · ",
* then "+N" for the rest; the fallback ("related to") when there are none.
*/
export function relationshipLabelText(
labels: string[],
fallback: string,
): string {
if (labels.length === 0) return fallback;
const shown = labels.slice(0, 2).join(' · ');
return labels.length > 2 ? `${shown} · +${labels.length - 2}` : shown;
}
const pickString = (value: unknown): string =>
typeof value === 'string' && value.trim() ? value.trim() : '';
/**
* The tree path a chunk belongs to, as the Files tab (`directory_structure`)
* keys it: `metadata.file_path`, else `key`, else `source` unless it is a URL
* (a web page's source is its address, not its tree path), else `title`.
* Mirrors the backend's `_chunk_matches_path`.
*
* @param metadata The chunk's metadata.
* @returns The path, or '' when the chunk names none.
*/
export function chunkFilePath(
metadata: Record<string, unknown> | undefined,
): string {
const filePath = pickString(metadata?.file_path);
if (filePath) return filePath;
const key = pickString(metadata?.key);
if (key) return key;
const source = pickString(metadata?.source);
if (source && !source.includes('://')) return source;
return pickString(metadata?.title);
}
/**
* The file a chunk came from, for its tile's meta row: the last segment of
* its tree path (see `chunkFilePath`), or its title as is.
*/
export function chunkFileName(
metadata: Record<string, unknown> | undefined,
): string {
// A title is a name, not a path: shown whole.
const path = chunkFilePath({ ...metadata, title: undefined });
if (!path) return pickString(metadata?.title);
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
/**
* The relationships of a node as the loaded overview knows them: one entry
* per link touching it, joined to the node at the other end. The fallback
* for an API whose node detail predates `relationships`.
*
* @param nodeId The node.
* @param links The overview links (ids or simulation node objects at the ends).
* @param nodes The overview nodes, for the neighbour's name, type and degree.
* @returns Relationships in the shape of `GraphNodeDetail.relationships`.
*/
export function overviewRelationships(
nodeId: string,
links: Pick<GraphEdge, 'source' | 'target' | 'type'>[],
nodes: Pick<GraphNode, 'id' | 'name' | 'type' | 'degree'>[],
): GraphRelationship[] {
const byId = new Map(nodes.map((node) => [node.id, node]));
const out: GraphRelationship[] = [];
for (const link of links) {
const source = endpointId(link.source as LinkEnd);
const target = endpointId(link.target as LinkEnd);
if (source === target) continue;
const direction =
source === nodeId ? 'out' : target === nodeId ? 'in' : null;
if (!direction) continue;
const other = byId.get(direction === 'out' ? target : source);
if (!other) continue;
out.push({
id: other.id,
name: other.name,
type: other.type,
degree: other.degree,
edge_type: link.type ?? null,
direction,
});
}
return out;
}
/** A label's text box on the canvas: centred on `x`, its top at `y`. */
export interface LabelBox {
id: string;
x: number;
y: number;
width: number;
height: number;
/** Drawn whatever it overlaps (the selection, the hovered node). */
always?: boolean;
}
/** Whether two label boxes intersect, with `gap` of clearance kept between them. */
export function labelBoxesOverlap(a: LabelBox, b: LabelBox, gap = 0): boolean {
return (
Math.abs(a.x - b.x) * 2 < a.width + b.width + gap * 2 &&
a.y < b.y + b.height + gap &&
b.y < a.y + a.height + gap
);
}
/**
* Which labels to draw so none sits on another: walk the boxes in priority
* order and keep each one that clears every box kept so far. `always` boxes
* are kept regardless (and still block the ones after them).
*
* @param boxes Label boxes, highest priority first.
* @param gap Clearance between two kept boxes.
* @returns The ids to draw.
*/
export function pickLabels(boxes: LabelBox[], gap = 0): Set<string> {
const kept: LabelBox[] = [];
for (const box of boxes) {
if (box.always || !kept.some((other) => labelBoxesOverlap(box, other, gap)))
kept.push(box);
}
return new Set(kept.map((box) => box.id));
}
/**
* A chunk's heading for its drawer title: the first line's text when that
* line is a markdown heading (the `#`s stripped), else null.
*/
export function chunkHeading(text: string | null | undefined): string | null {
const first = (text ?? '').trimStart().split(/\r?\n/, 1)[0] ?? '';
const match = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(first);
const heading = match?.[1]?.trim();
return heading ? heading : null;
}
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import { selectToken } from '../../preferences/preferenceSlice';
import type { GraphNodeDetail } from '../graphViewUtils';
export type GraphNodeDetailStatus = 'idle' | 'loading' | 'ready' | 'error';
/**
* Fetch one graph node's detail (description, relationships, chunks).
*
* @param docId The source id.
* @param nodeId The node to load; null loads nothing.
* @returns The detail, its status, a retry (shows the loading state) and a
* reload (refetches behind the loaded detail, e.g. after a chunk edit).
*/
export function useGraphNodeDetail(docId: string, nodeId: string | null) {
const token = useSelector(selectToken);
const [detail, setDetail] = useState<GraphNodeDetail | null>(null);
const [status, setStatus] = useState<GraphNodeDetailStatus>('idle');
const [attempt, setAttempt] = useState(0);
// Set by reload: keep the current detail on screen while refetching.
const silentRef = useRef(false);
const shownIdRef = useRef<string | null>(null);
useEffect(() => {
const silent = silentRef.current && shownIdRef.current === nodeId;
silentRef.current = false;
if (!silent) setDetail(null);
if (!nodeId) {
shownIdRef.current = null;
setStatus('idle');
return;
}
let cancelled = false;
if (!silent) setStatus('loading');
userService
.getSourceGraphNode(docId, nodeId, token)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then((body) => {
if (cancelled) return;
if (!body?.node) throw new Error('No node');
const node = body.node as GraphNodeDetail;
shownIdRef.current = nodeId;
setDetail({ ...node, chunks: node.chunks ?? [] });
setStatus('ready');
})
.catch((error) => {
if (cancelled) return;
console.error('Error loading graph node:', error);
// A failed background reload keeps what is shown.
if (!silent) setStatus('error');
});
return () => {
cancelled = true;
};
}, [docId, nodeId, token, attempt]);
const retry = useCallback(() => setAttempt((n) => n + 1), []);
const reload = useCallback(() => {
silentRef.current = true;
setAttempt((n) => n + 1);
}, []);
return { detail, status, retry, reload };
}
+219 -23
View File
@@ -4,10 +4,15 @@ import {
GraphNode,
GraphOverview,
collideRadius,
escapeDeselects,
foldGraphTypes,
graphTypeKey,
groupRelationships,
maxDegree,
nodeAtPoint,
nodeLabelEl,
nodeRadius,
normalizeEdgeLabel,
otherTypesList,
readGraphPalette,
toForceGraphData,
} from './graphViewUtils';
@@ -58,20 +63,6 @@ describe('maxDegree', () => {
});
});
describe('nodeLabelEl', () => {
it('renders an untrusted name as text, not live HTML', () => {
const payload = '<img src=x onerror=alert(1)>';
const el = nodeLabelEl(payload);
expect(el.textContent).toBe(payload);
expect(el.querySelector('img')).toBeNull();
expect(el.innerHTML).not.toContain('<img');
});
it('handles a missing name', () => {
expect(nodeLabelEl('').textContent).toBe('');
});
});
describe('nodeRadius', () => {
it('returns the minimum radius when there is no spread', () => {
expect(nodeRadius(0, 0)).toBe(3);
@@ -132,32 +123,237 @@ describe('nodeAtPoint', () => {
});
describe('readGraphPalette', () => {
const tokens = ['--primary', '--foreground', '--border', '--card'];
const tokens = [
'--chart-1',
'--chart-2',
'--chart-3',
'--chart-4',
'--muted-foreground',
'--primary',
'--foreground',
'--border',
'--background',
'--font-sans',
];
afterEach(() =>
tokens.forEach((name) => document.body.style.removeProperty(name)),
);
it('maps nodes, hover ring, links, labels and halo onto the theme tokens', () => {
document.body.style.setProperty('--primary', '#976af3');
it('maps the type series, Other, links, labels, halo and font onto the theme tokens', () => {
document.body.style.setProperty('--chart-1', '#8855f1');
document.body.style.setProperty('--chart-2', '#60a5fa');
document.body.style.setProperty('--chart-3', '#22c55e');
document.body.style.setProperty('--chart-4', '#facc15');
document.body.style.setProperty('--muted-foreground', '#a1a1a1');
document.body.style.setProperty('--primary', '#8855f1');
document.body.style.setProperty('--foreground', '#fafafa');
document.body.style.setProperty('--border', '#44454c');
document.body.style.setProperty('--card', '#2b2c31');
document.body.style.setProperty('--background', '#222327');
document.body.style.setProperty('--font-sans', 'Inter, sans-serif');
expect(readGraphPalette()).toEqual({
node: '#976af3',
series: ['#8855f1', '#60a5fa', '#22c55e', '#facc15'],
other: '#a1a1a1',
primary: '#8855f1',
hoverStroke: '#fafafa',
link: '#44454c',
label: '#fafafa',
halo: '#2b2c31',
halo: '#222327',
font: 'Inter, sans-serif',
});
});
it('falls back to the light tokens', () => {
it('falls back to the light tokens, with grey (not chart-5 red) for Other', () => {
expect(readGraphPalette()).toEqual({
node: '#7d54d1',
series: ['#7d54d1', '#2563eb', '#079455', '#ca8a04'],
other: '#737373',
primary: '#7d54d1',
hoverStroke: '#171717',
link: '#d9d9d9',
label: '#171717',
halo: '#ffffff',
font: 'sans-serif',
});
});
});
describe('graphTypeKey', () => {
it('lower-cases and drops everything but letters and digits', () => {
expect(graphTypeKey('Service Lane')).toBe('servicelane');
expect(graphTypeKey('ServiceLane')).toBe('servicelane');
expect(graphTypeKey('PORT')).toBe('port');
expect(graphTypeKey(null)).toBe('');
expect(graphTypeKey(undefined)).toBe('');
});
});
describe('foldGraphTypes', () => {
const nodes = [
...Array.from({ length: 5 }, () => ({ type: 'Person' })),
{ type: 'PERSON' },
...Array.from({ length: 4 }, () => ({ type: 'Organization' })),
...Array.from({ length: 3 }, () => ({ type: 'Location' })),
...Array.from({ length: 2 }, () => ({ type: 'Service Lane' })),
{ type: 'ServiceLane' },
{ type: 'Port' },
{ type: null },
];
it('keeps the four largest keys as series and folds the rest into Other', () => {
const folded = foldGraphTypes(nodes);
expect(
folded.groups.map((g) => [g.key, g.label, g.count, g.series]),
).toEqual([
['person', 'Person', 6, 0],
['organization', 'Organization', 4, 1],
['location', 'Location', 3, 2],
['servicelane', 'Service Lane', 3, 3],
]);
expect(folded.other).toEqual({ count: 2, labels: ['Port'] });
expect(folded.seriesOf('SERVICELANE')).toBe(3);
expect(folded.seriesOf('Port')).toBeNull();
expect(folded.seriesOf(null)).toBeNull();
expect(folded.labelOf('PERSON')).toBe('Person');
expect(folded.labelOf('port')).toBe('Port');
});
it('has no Other when every type fits', () => {
const folded = foldGraphTypes([{ type: 'A' }, { type: 'b' }]);
expect(folded.groups).toHaveLength(2);
expect(folded.other).toEqual({ count: 0, labels: [] });
});
});
describe('normalizeEdgeLabel', () => {
it('lower-cases, turns underscores into spaces and trims', () => {
expect(normalizeEdgeLabel('PREFERRED_CARRIER_FOR')).toBe(
'preferred carrier for',
);
expect(normalizeEdgeLabel(' serves ')).toBe('serves');
expect(normalizeEdgeLabel(null)).toBe('');
});
});
describe('groupRelationships', () => {
it('makes one row per neighbour with distinct labels, busiest neighbour first', () => {
const rows = groupRelationships([
{
id: 'm',
name: 'Meridian',
type: 'Org',
degree: 119,
edge_type: 'carrier_for',
},
{
id: 'a',
name: 'Anneke',
type: 'Person',
degree: 40,
edge_type: 'MANAGES',
},
{
id: 'm',
name: 'Meridian',
type: 'Org',
degree: 119,
edge_type: 'CARRIER_FOR',
},
{
id: 'm',
name: 'Meridian',
type: 'Org',
degree: 119,
edge_type: 'serves',
},
{
id: 'd',
name: 'Duisport',
type: 'Location',
degree: 9,
edge_type: null,
},
]);
expect(rows.map((r) => [r.id, r.labels])).toEqual([
['m', ['carrier for', 'serves']],
['a', ['manages']],
['d', []],
]);
});
});
describe('escapeDeselects', () => {
const press = (target: EventTarget, init: KeyboardEventInit = {}) => {
let result: boolean | undefined;
const listener = (event: Event) => {
result = escapeDeselects(event as KeyboardEvent);
};
document.addEventListener('keydown', listener);
target.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
...init,
}),
);
document.removeEventListener('keydown', listener);
return result;
};
afterEach(() => {
document.body.innerHTML = '';
});
it('deselects on Escape outside a field', () => {
const div = document.body.appendChild(document.createElement('div'));
expect(press(div)).toBe(true);
expect(press(document.body, { key: 'Enter' })).toBe(false);
});
it('leaves Escape to a field being typed in', () => {
for (const tag of ['input', 'textarea', 'select']) {
const field = document.body.appendChild(document.createElement(tag));
expect(press(field)).toBe(false);
}
const editable = document.body.appendChild(document.createElement('div'));
editable.contentEditable = 'true';
// jsdom does not derive isContentEditable from the attribute.
Object.defineProperty(editable, 'isContentEditable', { value: true });
expect(press(editable)).toBe(false);
});
it('leaves an Escape something else already handled', () => {
const div = document.body.appendChild(document.createElement('div'));
div.addEventListener('keydown', (event) => event.preventDefault());
expect(press(div)).toBe(false);
});
});
describe('otherTypesList', () => {
const more = (count: number) => `${count} more`;
const labels = ['Vessel', 'Port', 'Terminal', 'Broker', 'Law', 'Insurer'];
it('joins the folded types as a list in the app language', () => {
expect(otherTypesList(['Vessel', 'Port'], more, 'en')).toBe(
'Vessel and Port',
);
expect(otherTypesList(labels, more, 'en')).toBe(
'Vessel, Port, Terminal, Broker, Law, and Insurer',
);
});
it('names the rest past six as its last item', () => {
const nine = [...labels, 'Carrier', 'Forwarder', 'Shipper'];
expect(otherTypesList(nine, more, 'en')).toBe(
'Vessel, Port, Terminal, Broker, Law, Insurer, and 3 more',
);
expect(otherTypesList(nine, (n) => `${n} weitere`, 'de')).toBe(
'Vessel, Port, Terminal, Broker, Law, Insurer und 3 weitere',
);
});
it('maps the app codes Intl does not know', () => {
expect(otherTypesList(['A', 'B'], more, 'jp')).toBe(
new Intl.ListFormat('ja', { type: 'conjunction' }).format(['A', 'B']),
);
});
});
+234 -13
View File
@@ -1,4 +1,7 @@
import { readCssVar } from '../utils/chartUtils';
import i18next from 'i18next';
import { readCssVar, SERIES_TOKENS } from '../utils/chartUtils';
import { intlLocale } from '../utils/dateTimeUtils';
export interface GraphNode {
id: string;
@@ -26,9 +29,45 @@ export interface GraphNodeChunk {
metadata?: Record<string, unknown>;
}
/** One edge touching a node, joined to the node at its other end. */
export interface GraphRelationship {
id: string;
name: string;
type?: string | null;
degree: number;
edge_type?: string | null;
direction?: 'out' | 'in';
}
export interface GraphNodeDetail extends GraphNode {
doc_freq?: number;
chunks: GraphNodeChunk[];
/** The strongest edges, capped server-side (`MAX_NODE_RELATIONSHIPS`). */
relationships?: GraphRelationship[];
/** Every edge the node takes part in; above `relationships.length` when capped. */
relationships_total?: number;
}
/** A row of the paged `/graph/nodes` list. */
export interface GraphNodeSummary {
id: string;
name: string;
type?: string | null;
degree: number;
doc_freq?: number | null;
}
/** One type key over every node of the source (the `/graph/nodes` facet). */
export interface GraphTypeFacet {
key: string;
label: string | null;
count: number;
}
/** Totals for the whole graph, beyond the loaded overview. */
export interface GraphStats {
nodes: number;
edges: number;
}
export interface ForceGraphData {
@@ -94,24 +133,24 @@ export function nodeAtPoint(
return best;
}
export function nodeLabelEl(name: string): HTMLDivElement {
const el = document.createElement('div');
el.textContent = name ?? '';
return el;
}
/** Resolved canvas colours for the graph view. */
export interface GraphPalette {
/** Node fill (`--primary`). */
node: string;
/** Ring around the hovered node (`--foreground`). */
/** The four type series (`--chart-1` to `--chart-4`). */
series: string[];
/** Nodes folded into "Other" (`--muted-foreground`, not chart-5's red). */
other: string;
/** The selection's edges (`--primary`). */
primary: string;
/** Ring around the hovered and selected node (`--foreground`). */
hoverStroke: string;
/** Edge colour (`--border`). */
link: string;
/** Label text (`--foreground`). */
label: string;
/** Label halo (`--card`, the canvas surface, so it works in both themes). */
/** Label halo (`--background`, the canvas surface, so it works in both themes). */
halo: string;
/** The app font (`--font-sans`) for labels. */
font: string;
}
/**
@@ -125,10 +164,192 @@ export interface GraphPalette {
export function readGraphPalette(): GraphPalette {
const foreground = readCssVar('--foreground', '#171717');
return {
node: readCssVar('--primary', '#7d54d1'),
series: SERIES_TOKENS.slice(0, GRAPH_TYPE_SERIES).map(([name, fallback]) =>
readCssVar(name, fallback),
),
other: readCssVar('--muted-foreground', '#737373'),
primary: readCssVar('--primary', '#7d54d1'),
hoverStroke: foreground,
link: readCssVar('--border', '#d9d9d9'),
label: foreground,
halo: readCssVar('--card', '#ffffff'),
halo: readCssVar('--background', '#ffffff'),
font: readCssVar('--font-sans', 'sans-serif'),
};
}
/** How many entity types get their own colour; the rest fold into Other. */
const GRAPH_TYPE_SERIES = 4;
/**
* The key that folds spelling variants of one type together ("Service Lane",
* "ServiceLane", "SERVICE_LANE"): lower-cased, letters and digits only. The
* backend's `/graph/nodes?type=` filter uses the same rule.
*/
export function graphTypeKey(type: string | null | undefined): string {
return (type ?? '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
}
export interface GraphTypeGroup {
key: string;
/** The most frequent spelling of the key. */
label: string;
count: number;
/** Index into the palette's series. */
series: number;
}
export interface FoldedGraphTypes {
groups: GraphTypeGroup[];
/** Everything outside the groups, untyped nodes included. */
other: { count: number; labels: string[] };
/** The series index of a type, or null for Other. */
seriesOf: (type: string | null | undefined) => number | null;
/** The display spelling of a type's key. */
labelOf: (type: string | null | undefined) => string;
}
/**
* Fold the loaded nodes' types for colour: the largest keys keep a series
* each, and the rest (and untyped nodes) fold into one "Other".
*
* @param nodes Nodes with a `type`.
* @param seriesCount How many keys keep their own colour.
* @returns The groups, the Other bucket and lookups by raw type.
*/
export function foldGraphTypes(
nodes: { type?: string | null }[],
seriesCount = GRAPH_TYPE_SERIES,
): FoldedGraphTypes {
const spellings = new Map<string, Map<string, number>>();
let untyped = 0;
for (const node of nodes) {
const key = graphTypeKey(node.type);
if (!key) {
untyped += 1;
continue;
}
const counts = spellings.get(key) ?? new Map<string, number>();
const raw = (node.type ?? '').trim();
counts.set(raw, (counts.get(raw) ?? 0) + 1);
spellings.set(key, counts);
}
const ranked = [...spellings.entries()]
.map(([key, counts]) => {
const [label] = [...counts.entries()].sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
)[0];
const count = [...counts.values()].reduce((a, b) => a + b, 0);
return { key, label, count };
})
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
const groups = ranked
.slice(0, seriesCount)
.map((group, series) => ({ ...group, series }));
const rest = ranked.slice(seriesCount);
const labels = new Map(ranked.map((g) => [g.key, g.label]));
const seriesByKey = new Map(groups.map((g) => [g.key, g.series]));
return {
groups,
other: {
count: untyped + rest.reduce((sum, g) => sum + g.count, 0),
labels: rest.map((g) => g.label),
},
seriesOf: (type) => seriesByKey.get(graphTypeKey(type)) ?? null,
labelOf: (type) => labels.get(graphTypeKey(type)) ?? (type ?? '').trim(),
};
}
/**
* An edge label for reading: lower-cased, underscores as spaces, trimmed.
* `PREFERRED_CARRIER_FOR` and `preferred_carrier_for` become one label.
*/
export function normalizeEdgeLabel(label: string | null | undefined): string {
return (label ?? '')
.replace(/_+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
export interface RelationshipRow {
id: string;
name: string;
type?: string | null;
degree: number;
/** Distinct normalised labels, first seen first; empty when none had one. */
labels: string[];
}
/**
* One row per neighbour: the node's edges grouped by the node at the other
* end, their labels normalised and deduplicated, the busiest neighbour first.
*
* @param relationships The node detail's `relationships`.
* @returns Rows for the node panel's Relationships list.
*/
export function groupRelationships(
relationships: GraphRelationship[],
): RelationshipRow[] {
const rows = new Map<string, RelationshipRow>();
for (const rel of relationships) {
const row = rows.get(rel.id) ?? {
id: rel.id,
name: rel.name,
type: rel.type,
degree: rel.degree,
labels: [],
};
const label = normalizeEdgeLabel(rel.edge_type);
if (label && !row.labels.includes(label)) row.labels.push(label);
rows.set(rel.id, row);
}
return [...rows.values()].sort(
(a, b) => b.degree - a.degree || a.name.localeCompare(b.name),
);
}
/**
* Whether a keydown should clear the graph's selection: Escape, unless a
* field is being typed in (the Graph tab's search) or something else already
* handled the key.
*
* @param event The keydown event.
* @returns True when the selection should be cleared.
*/
export function escapeDeselects(event: KeyboardEvent): boolean {
if (event.key !== 'Escape' || event.defaultPrevented) return false;
const target = event.target as HTMLElement | null;
if (
target &&
(target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
)
return false;
return true;
}
/** Folded type names the "Other: …" line spells out before "and N more". */
export const OTHER_TYPES_SHOWN = 6;
/**
* The folded types as a list in the UI language ("A, B, and C"; "A, B und 3
* weitere"): the first six, then the rest as one last item.
*
* @param labels The folded type names, in legend order.
* @param more Words the rest ("3 more"); gets how many were left out.
* @param language An app language code; defaults to the current one.
* @returns The joined list.
*/
export function otherTypesList(
labels: string[],
more: (count: number) => string,
language: string = i18next.language,
): string {
const items = labels.slice(0, OTHER_TYPES_SHOWN);
if (labels.length > OTHER_TYPES_SHOWN) {
items.push(more(labels.length - OTHER_TYPES_SHOWN));
}
return new Intl.ListFormat(intlLocale(language), {
type: 'conjunction',
}).format(items);
}
@@ -11,8 +11,6 @@ describe('PathHeader', () => {
it('renders a breadcrumb with clickable parents and a truncating last crumb', () => {
const html = render(
<PathHeader
backLabel="Back"
onBack={vi.fn()}
root={{ label: 'Contracts', onSelect: vi.fn() }}
segments={[
{ label: 'carriers', onSelect: vi.fn() },
@@ -26,13 +24,38 @@ describe('PathHeader', () => {
expect(html).toContain('max-w-[32ch]');
expect(html).toContain('title="a-very-long-file-name.pdf"');
expect(html).not.toContain('text-primary');
expect(html).toContain('aria-label="Back"');
});
// Up one level is a crumb, as on the Tools and Teams detail pages
// (DetailBreadcrumb), never a second back arrow.
it('has no back button: the crumbs are the way up', () => {
const html = render(
<PathHeader
root={{ label: 'Sources', onSelect: vi.fn() }}
segments={[{ label: 'Contracts' }]}
/>,
);
expect(html).not.toContain('lucide-arrow-left');
expect(html.match(/<button/g)).toHaveLength(1);
});
it('keeps the crumbs on one line: parents truncate with their full name as a title', () => {
const html = render(
<PathHeader
root={{ label: 'Sources', onSelect: vi.fn() }}
segments={[
{ label: 'Key Accounts & Carrier Network', onSelect: vi.fn() },
{ label: 'carriers' },
]}
/>,
);
expect(html).toContain('flex-nowrap');
expect(html).toContain('title="Key Accounts &amp; Carrier Network"');
expect(html).toMatch(/class="[^"]*truncate[^"]*"[^>]*>Key Accounts/);
});
it('makes the root the current crumb when there are no segments', () => {
const html = render(
<PathHeader backLabel="Back" onBack={vi.fn()} root={{ label: 'Docs' }} />,
);
const html = render(<PathHeader root={{ label: 'Docs' }} />);
expect(html).toContain('data-slot="breadcrumb-page"');
expect(html).not.toContain('data-slot="breadcrumb-link"');
});
@@ -40,12 +63,32 @@ describe('PathHeader', () => {
it('renders actions on the right', () => {
const html = render(
<PathHeader
backLabel="Back"
onBack={vi.fn()}
root={{ label: 'Docs' }}
actions={<button type="button">Sync</button>}
/>,
);
expect(html).toContain('Sync');
});
it('puts the kind badge after the crumbs and the byline under the row', () => {
const html = render(
<PathHeader
root={{ label: 'Wiki' }}
badge={<span data-testid="badge">Living wiki</span>}
byline="12 pages · 8,111 tokens"
/>,
);
expect(html.indexOf('data-slot="breadcrumb"')).toBeLessThan(
html.indexOf('data-testid="badge"'),
);
expect(html).toContain(
'<p class="text-muted-foreground text-sm">12 pages · 8,111 tokens</p>',
);
});
it('a lone crumb is the current page and no button', () => {
const html = render(<PathHeader root={{ label: 'Files' }} />);
expect(html).not.toContain('<button');
expect(html).toContain('data-slot="breadcrumb-page"');
});
});
+87 -56
View File
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Fragment } from 'react';
import { Fragment, useEffect, useRef } from 'react';
import {
Breadcrumb,
@@ -10,78 +9,110 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '../ui/breadcrumb';
import { IconButton } from '../ui/icon-button';
type Crumb = { label: string; onSelect?: () => void };
export type Crumb = { label: string; onSelect?: () => void };
/**
* The path row above a source's file tree and chunk viewer: a back button,
* the source and folder crumbs, and actions (search, sync, edit) on the
* right. The last crumb is the current one and truncates.
* The header of every source view (files, chunks, wiki, graph): the crumbs
* (Sources, the source, its folders, the open file and chunk), the source
* kind's badge, and actions (Test retrieval, the kind's primary action) on
* the right; the byline (the source's counts and a one-sentence explainer)
* goes under the row. Up one level is a crumb, as on the Tools and Teams
* detail pages (DetailBreadcrumb), never a back arrow. The crumbs stay on one
* line: parents truncate, and the last crumb is the current one.
*/
export default function PathHeader({
root,
segments = [],
onBack,
backLabel,
badge,
byline,
actions,
}: {
/** The first crumb: Sources, or the source when a host draws Sources. */
root: Crumb;
segments?: Crumb[];
onBack: () => void;
backLabel: string;
/** The source kind (a neutral Badge), after the crumbs. */
badge?: ReactNode;
/** Muted meta line under the row. */
byline?: ReactNode;
actions?: ReactNode;
}) {
const crumbs = [root, ...segments];
// A crumb step replaces the focused button (it becomes the current crumb,
// or goes), which would drop focus to <body>. Once the trail changes,
// focus lands on the new current crumb instead.
const currentRef = useRef<HTMLSpanElement>(null);
const refocusRef = useRef(false);
const trail = crumbs.map((crumb) => crumb.label).join('/');
useEffect(() => {
if (!refocusRef.current) return;
refocusRef.current = false;
currentRef.current?.focus();
}, [trail]);
const select = (crumb: Crumb) => {
refocusRef.current = true;
crumb.onSelect?.();
};
return (
<div className="flex min-h-9.5 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full min-w-0 items-center sm:w-auto">
<IconButton
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
label={backLabel}
icon={ArrowLeft}
side="bottom"
onClick={onBack}
/>
<Breadcrumb className="min-w-0">
<BreadcrumbList>
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;
return (
<Fragment key={`${index}-${crumb.label}`}>
{index > 0 ? <BreadcrumbSeparator /> : null}
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage
className="max-w-[32ch]"
title={crumb.label}
>
{crumb.label}
</BreadcrumbPage>
) : crumb.onSelect ? (
<BreadcrumbLink asChild>
<button type="button" onClick={crumb.onSelect}>
<div className="flex flex-col gap-2">
<div className="flex min-h-9.5 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full min-w-0 items-center gap-3 sm:w-auto">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1;
return (
<Fragment key={`${index}-${crumb.label}`}>
{index > 0 ? (
<BreadcrumbSeparator className="shrink-0" />
) : null}
<BreadcrumbItem className="min-w-0">
{isLast ? (
<BreadcrumbPage
ref={currentRef}
tabIndex={-1}
className="max-w-[32ch]"
title={crumb.label}
>
{crumb.label}
</button>
</BreadcrumbLink>
) : (
<span>{crumb.label}</span>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
{actions ? (
<div className="relative flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:w-auto">
{actions}
</BreadcrumbPage>
) : crumb.onSelect ? (
<BreadcrumbLink asChild>
<button
type="button"
title={crumb.label}
className="max-w-[16ch] truncate"
onClick={() => select(crumb)}
>
{crumb.label}
</button>
</BreadcrumbLink>
) : (
<span
title={crumb.label}
className="max-w-[16ch] truncate"
>
{crumb.label}
</span>
)}
</BreadcrumbItem>
</Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
{badge ? <span className="flex shrink-0">{badge}</span> : null}
</div>
{actions ? (
<div className="flex w-full flex-row flex-nowrap items-center justify-end gap-2 sm:w-auto">
{actions}
</div>
) : null}
</div>
{byline ? (
<p className="text-muted-foreground text-sm">{byline}</p>
) : null}
</div>
);
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Card } from '../ui/card';
import { Separator } from '../ui/separator';
/**
* The reader of a source view: the content you read and edit (a wiki page,
* an open chunk) in a `subtle` panel, a place per DESIGN.md's Card surfaces.
* Its meta sits left and its actions right in the panel's own header row, so
* Edit, then Cancel and Save, stay beside the text they act on.
*/
export default function ReaderPanel({
meta,
actions,
children,
className,
}: {
/** Muted 12px lines (provenance, token count, path). */
meta?: ReactNode;
actions?: ReactNode;
children: ReactNode;
/** Layout only. */
className?: string;
}) {
return (
<Card
variant="subtle"
padding="none"
className={cn('min-w-0 gap-0', className)}
>
<div className="flex min-h-14 flex-wrap items-center justify-between gap-3 px-6 py-3">
<div className="text-muted-foreground flex min-w-0 flex-col text-xs">
{meta}
</div>
{actions ? (
<div className="flex items-center gap-2">{actions}</div>
) : null}
</div>
<Separator />
<div className="min-w-0 px-6 py-5">{children}</div>
</Card>
);
}
@@ -0,0 +1,112 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { TooltipProvider } from '../ui/tooltip';
import SourceEditSheet, { type SourceEditSheetProps } from './SourceEditSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('SourceEditSheet', () => {
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();
document.body.innerHTML = '';
});
const render = async (props: Partial<SourceEditSheetProps> = {}) => {
const all: SourceEditSheetProps = {
open: true,
onClose: vi.fn(),
title: 'Edit page',
description: '/a.md · v1',
value: '# Hello',
onChange: vi.fn(),
dirty: false,
onSave: vi.fn(),
saveLabel: 'Save',
fieldLabel: 'Page content',
...props,
};
await act(async () => {
root.render(
<TooltipProvider>
<SourceEditSheet {...all} />
</TooltipProvider>,
);
});
return all;
};
const button = (text: string) =>
[...document.body.querySelectorAll('button')].find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
it('shows the draft in a labelled field and keeps Save off until it changes', async () => {
await render();
const field = document.body.querySelector('textarea');
expect(field?.value).toBe('# Hello');
expect(field?.getAttribute('aria-label')).toBe('Page content');
expect(button('Save')?.disabled).toBe(true);
await render({ dirty: true });
expect(button('Save')?.disabled).toBe(false);
});
it('closes at once when nothing changed', async () => {
const props = await render();
await act(async () => button('settings.sources.editor.cancel')?.click());
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('asks before dropping unsaved edits', async () => {
const props = await render({ dirty: true });
await act(async () => button('settings.sources.editor.cancel')?.click());
expect(props.onClose).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
'settings.sources.editor.discardMessage',
);
await act(async () => button('settings.sources.editor.discard')?.click());
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('renders the alert above the field', async () => {
await render({ alert: <div role="alert">Conflict</div> });
const alert = document.body.querySelector('[role="alert"]');
const field = document.body.querySelector('textarea');
expect(alert).not.toBeNull();
expect(
alert!.compareDocumentPosition(field!) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('reopens on Write after the caller closed it from Preview', async () => {
await render();
const preview = [...document.body.querySelectorAll('[role="tab"]')].find(
(b) => b.textContent === 'settings.sources.editor.preview',
)!;
await act(async () => {
preview.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
);
});
expect(document.body.querySelector('textarea')).toBeNull();
// A save: the caller closes the sheet itself, then opens it again.
await render({ open: false });
await render({ open: true });
expect(document.body.querySelector('textarea')).not.toBeNull();
});
});
@@ -0,0 +1,183 @@
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ConfirmationModal from '../../modals/ConfirmationModal';
import type { ActiveState } from '../../models/misc';
import SourceMarkdown from '../SourceMarkdown';
import { Button } from '../ui/button';
import { EmptyState } from '../ui/empty-state';
import { Separator } from '../ui/separator';
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Textarea } from '../ui/textarea';
export interface SourceEditSheetProps {
open: boolean;
/** Called once the drawer may close (after the discard check). */
onClose: () => void;
title: string;
description?: ReactNode;
/** The draft, controlled by the caller. */
value: string;
onChange: (value: string) => void;
/** Whether the draft differs from what's saved; enables Save and the discard check. */
dirty: boolean;
onSave: () => void;
saving?: boolean;
/** Submit label ("Save", "Add chunk"). */
saveLabel: string;
/** A save result the user must read (conflict, failure), above the field. */
alert?: ReactNode;
/**
* Fields edited with the content (a chunk's title), under the header and
* above the Write / Preview tabs. Their changes count toward `dirty`.
*/
fields?: ReactNode;
/** Accessible name of the editor. */
fieldLabel: string;
placeholder?: string;
}
/**
* The drawer a source view edits in (a wiki page, a chunk): the rendered
* content stays on the page behind the scrim while the raw markdown is
* edited here, with a Preview tab that renders the draft the same way. Built
* like WorkflowDetailsSheet: header, one filling body, then Cancel and Save.
* Closing with unsaved edits asks first.
*/
export default function SourceEditSheet({
open,
onClose,
title,
description,
value,
onChange,
dirty,
onSave,
saving = false,
saveLabel,
alert,
fields,
fieldLabel,
placeholder,
}: SourceEditSheetProps) {
const { t } = useTranslation();
const [tab, setTab] = useState('write');
const [discardState, setDiscardState] = useState<ActiveState>('INACTIVE');
// Every open starts on Write, however the last one closed (the caller
// closes the sheet itself after a save).
useEffect(() => {
if (open) setTab('write');
}, [open]);
const requestClose = () => {
if (saving) return;
if (dirty) setDiscardState('ACTIVE');
else close();
};
const close = () => {
setTab('write');
onClose();
};
return (
<>
<Sheet open={open} onOpenChange={(next) => !next && requestClose()}>
<SheetContent
side="right"
size="wide"
className="p-0"
closeLabel={t('settings.sources.editor.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>{title}</SheetTitle>
{description ? (
<SheetDescription className="font-mono text-xs wrap-anywhere">
{description}
</SheetDescription>
) : null}
</div>
{fields ? (
<div className="flex flex-col gap-5 px-6 pt-2 pb-5">{fields}</div>
) : null}
<Tabs
value={tab}
onValueChange={setTab}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList variant="underline" className="mx-6">
<TabsTrigger variant="underline" value="write">
{t('settings.sources.editor.write')}
</TabsTrigger>
<TabsTrigger variant="underline" value="preview">
{t('settings.sources.editor.preview')}
</TabsTrigger>
</TabsList>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-6">
{alert}
<TabsContent value="write" className="flex min-h-0 flex-1">
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={fieldLabel}
placeholder={placeholder}
resize="none"
className="min-h-[50svh] flex-1 font-mono"
disabled={saving}
/>
</TabsContent>
<TabsContent value="preview">
{value.trim() ? (
<SourceMarkdown content={value} />
) : (
<EmptyState
size="xs"
illustration="none"
title={t('settings.sources.editor.previewEmpty')}
/>
)}
</TabsContent>
</div>
</Tabs>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="ghost"
size="lg"
shape="pill"
onClick={requestClose}
disabled={saving}
>
{t('settings.sources.editor.cancel')}
</Button>
<Button
type="button"
size="lg"
shape="pill"
disabled={!dirty || !value.trim()}
loading={saving}
onClick={onSave}
>
{saveLabel}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<ConfirmationModal
message={t('settings.sources.editor.discardMessage')}
modalState={discardState}
setModalState={setDiscardState}
submitLabel={t('settings.sources.editor.discard')}
handleSubmit={close}
variant="destructive"
/>
</>
);
}
@@ -0,0 +1,175 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const media = vi.hoisted(() => ({ isDesktop: true }));
vi.mock('../../hooks', () => ({
useMediaQuery: () => ({
isMobile: !media.isDesktop,
isDesktop: media.isDesktop,
}),
}));
import type { NavigatorNode } from './navigatorUtils';
import SourceNavigator from './SourceNavigator';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const folder = (path: string, children: NavigatorNode[]): NavigatorNode => ({
id: path,
kind: 'folder',
label: path.split('/').pop()!,
path,
count: children.length,
children,
});
const leaf = (path: string): NavigatorNode => ({
id: path,
kind: 'leaf',
label: path.split('/').pop()!,
path,
});
// Six folders deep, a file at depth 6.
const DEEP: NavigatorNode[] = [
folder('a', [
folder('a/b', [
folder('a/b/c', [
folder('a/b/c/d', [
folder('a/b/c/d/e', [
folder('a/b/c/d/e/f', [leaf('a/b/c/d/e/f/deep.pdf')]),
]),
]),
]),
]),
]),
];
const TREE: NavigatorNode[] = [
folder('europe', [
folder('europe/germany', [
folder('europe/germany/bremen', [leaf('europe/germany/bremen/a.pdf')]),
leaf('europe/germany/hamburg.pdf'),
]),
]),
folder('rate-cards', [leaf('rate-cards/q1.pdf')]),
];
describe('SourceNavigator', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
media.isDesktop = true;
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = async (
nodes: NavigatorNode[],
selectedId: string | null,
onSelect = vi.fn(),
) => {
await act(async () => {
root.render(
<SourceNavigator
nodes={nodes}
selectedId={selectedId}
onSelect={onSelect}
filterLabel="Filter"
emptyLabel="None"
title="Files"
/>,
);
});
};
const row = (label: string) =>
Array.from(container.querySelectorAll<HTMLElement>('[cmdk-item]')).find(
(el) => el.textContent?.startsWith(label),
);
const indent = (label: string) =>
row(label)
?.querySelector<HTMLElement>('span[aria-hidden]')
?.style.getPropertyValue('--indent') ?? '';
it('steps 24px per level through depth 4, then 12px per level', async () => {
await render(DEEP, 'a/b/c/d/e/f/deep.pdf');
expect(row('a')?.querySelector('span[aria-hidden]')).toBeNull();
expect(indent('b')).toBe('24px');
expect(indent('c')).toBe('48px');
expect(indent('d')).toBe('72px');
expect(indent('e')).toBe('96px');
expect(indent('f')).toBe('108px');
expect(indent('deep.pdf')).toBe('120px');
});
it('titles folder rows with their full path, like files', async () => {
await render(DEEP, 'a/b/c/d/e/f/deep.pdf');
expect(row('f')?.getAttribute('title')).toBe('a/b/c/d/e/f');
expect(row('deep.pdf')?.getAttribute('title')).toBe('a/b/c/d/e/f/deep.pdf');
});
it('expands a folder opened elsewhere (a crumb, a table row), keeping other open folders', async () => {
await render(TREE, 'rate-cards/q1.pdf');
expect(row('rate-cards')?.getAttribute('aria-expanded')).toBe('true');
expect(row('germany')).toBeUndefined();
await render(TREE, 'europe/germany');
expect(row('europe')?.getAttribute('aria-expanded')).toBe('true');
expect(row('germany')?.getAttribute('aria-expanded')).toBe('true');
expect(row('bremen')).toBeDefined();
expect(row('hamburg.pdf')).toBeDefined();
// Rate cards, opened before, stays open.
expect(row('q1.pdf')).toBeDefined();
});
it('expands a folder that is open on the first render', async () => {
await render(TREE, 'europe/germany');
expect(row('germany')?.getAttribute('aria-expanded')).toBe('true');
expect(row('bremen')).toBeDefined();
});
it('clicking the open folder still collapses it', async () => {
const onSelect = vi.fn();
await render(TREE, 'europe/germany', onSelect);
await act(async () => row('germany')!.click());
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({ id: 'europe/germany' }),
);
expect(row('germany')?.getAttribute('aria-expanded')).toBe('false');
expect(row('bremen')).toBeUndefined();
});
// The column caps its list; the phone sheet's body already scrolls, so a
// cap there would nest a second scroller inside it.
it('caps the list in the column but not in the phone sheet', async () => {
await render(TREE, null);
const columnList = document.body.querySelector(
'[data-slot="command-list"]',
)!;
expect(columnList.className).toContain('max-h-[70svh]');
await act(async () => root.unmount());
root = createRoot(container);
media.isDesktop = false;
await render(TREE, null);
const trigger = container.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLButtonElement;
await act(async () => trigger.click());
const sheetList = document.body.querySelector(
'[data-slot="command-list"]',
)!;
expect(sheetList).not.toBeNull();
expect(sheetList.className).not.toContain('max-h-[70svh]');
expect(sheetList.className).toContain('max-h-none');
});
});
@@ -0,0 +1,277 @@
import {
ChevronDown,
ChevronRight,
File,
Folder,
type LucideIcon,
} from 'lucide-react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useMediaQuery } from '@/hooks';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../ui/command';
import { Modal } from '../ui/modal';
import {
ancestorIds,
filterNavigatorLeaves,
findNode,
type NavigatorNode,
} from './navigatorUtils';
const NO_HIGHLIGHT = '__none__';
/**
* Tree indent, before the row's icons: a 24px step per level through depth 4
* (96px), then 12px per further level, so deep rows still step in but keep
* room for a label in the w-64 column.
*/
function Indent({ depth }: { depth: number }) {
if (depth <= 0) return null;
const width = Math.min(depth, 4) * 24 + Math.max(depth - 4, 0) * 12;
return (
<span
aria-hidden
className="w-(--indent) shrink-0"
style={{ '--indent': `${width}px` } as React.CSSProperties}
/>
);
}
/** The folders to expand to show a node: its ancestors, and itself if a folder. */
function revealIds(nodes: NavigatorNode[], id: string): string[] {
const trail = ancestorIds(nodes, id);
return findNode(nodes, id)?.kind === 'folder' ? [...trail, id] : trail;
}
export interface SourceNavigatorProps {
nodes: NavigatorNode[];
/** The open leaf or folder. */
selectedId: string | null;
onSelect: (node: NavigatorNode) => void;
/** Placeholder and accessible name of the filter ("Filter files"). */
filterLabel: string;
/** Shown when the filter matches nothing. */
emptyLabel: string;
/** Names the phone picker button and its sheet ("Files", "Pages"). */
title: string;
/**
* `tree`: folders expand and collapse, and a folder can be opened (the file
* tree). `groups`: each folder is a heading over its pages (the wiki).
*/
folderMode?: 'tree' | 'groups';
leafIcon?: LucideIcon;
/** Layout only. */
className?: string;
}
/**
* The navigator column of a source view: a filter field over the source's
* files or pages, both in one Command, so the arrow keys walk from the field
* into the list. The open item is `checked`. Below `lg` it collapses into a
* combobox that opens the same list in a bottom sheet.
*/
export default function SourceNavigator({
nodes,
selectedId,
onSelect,
filterLabel,
emptyLabel,
title,
folderMode = 'tree',
leafIcon: LeafIcon = File,
className,
}: SourceNavigatorProps) {
const { isDesktop } = useMediaQuery();
const [query, setQuery] = useState('');
const [sheetOpen, setSheetOpen] = useState(false);
// cmdk highlights its first row by default, which reads as hover; start the
// highlight on the open item instead and let the keys and pointer move it.
// With nothing open, a value that matches no row: an empty one lets cmdk
// pick the first row again.
const [highlight, setHighlight] = useState(selectedId ?? NO_HIGHLIGHT);
useEffect(() => setHighlight(selectedId ?? NO_HIGHLIGHT), [selectedId]);
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(selectedId ? revealIds(nodes, selectedId) : []),
);
// Opening an item elsewhere (a crumb, a table row, a search) reveals it in
// the tree; an opened folder expands too. Other open folders stay open.
useEffect(() => {
if (!selectedId) return;
const trail = revealIds(nodes, selectedId);
if (trail.length === 0) return;
setExpanded((prev) =>
trail.every((id) => prev.has(id)) ? prev : new Set([...prev, ...trail]),
);
}, [nodes, selectedId]);
const matches = useMemo(
() => filterNavigatorLeaves(nodes, query),
[nodes, query],
);
const choose = (node: NavigatorNode) => {
if (node.kind === 'folder' && folderMode === 'tree') {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(node.id) && node.id === selectedId) next.delete(node.id);
else next.add(node.id);
return next;
});
}
onSelect(node);
if (node.kind === 'leaf') {
setQuery('');
setSheetOpen(false);
}
};
const leafItem = (node: NavigatorNode, depth: number, meta?: string) => (
<CommandItem
key={node.id}
value={node.id}
checked={node.id === selectedId}
onSelect={() => choose(node)}
title={node.path}
>
<Indent depth={depth} />
<LeafIcon />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{node.label}</span>
{meta ? (
<span className="text-muted-foreground truncate text-xs">{meta}</span>
) : null}
</span>
</CommandItem>
);
const treeRows = (list: NavigatorNode[], depth: number): React.ReactNode =>
list.map((node) => {
if (node.kind === 'leaf') return leafItem(node, depth);
const open = expanded.has(node.id);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<Fragment key={node.id}>
<CommandItem
value={node.id}
checked={node.id === selectedId}
onSelect={() => choose(node)}
aria-expanded={open}
title={node.path}
>
<Indent depth={depth} />
<Chevron />
<Folder className="text-primary" />
<span className="min-w-0 flex-1 truncate">{node.label}</span>
{node.count !== undefined ? (
<span className="text-muted-foreground text-xs tabular-nums">
{node.count}
</span>
) : null}
</CommandItem>
{open ? treeRows(node.children ?? [], depth + 1) : null}
</Fragment>
);
});
const list = (
<Command
shouldFilter={false}
className="contents"
loop
value={highlight}
onValueChange={setHighlight}
>
<CommandInput
variant="field"
value={query}
onValueChange={setQuery}
placeholder={filterLabel}
aria-label={filterLabel}
/>
{/* The column caps its own list; in the phone sheet the Modal body
scrolls, so the list grows with it (no second scroller). */}
<CommandList
className={
isDesktop ? 'max-h-[70svh] overscroll-contain' : 'max-h-none'
}
>
{query.trim() ? (
matches.length === 0 ? (
<CommandEmpty>{emptyLabel}</CommandEmpty>
) : (
<CommandGroup>
{matches.map(({ node, parentPath }) =>
leafItem(node, 0, parentPath || undefined),
)}
</CommandGroup>
)
) : folderMode === 'groups' ? (
<>
<CommandGroup>
{nodes
.filter((node) => node.kind === 'leaf')
.map((node) => leafItem(node, 0))}
</CommandGroup>
{nodes
.filter((node) => node.kind === 'folder')
.map((folder) => (
<CommandGroup key={folder.id} heading={folder.label}>
{(folder.children ?? []).map((node) => leafItem(node, 0))}
</CommandGroup>
))}
</>
) : (
<CommandGroup>{treeRows(nodes, 0)}</CommandGroup>
)}
</CommandList>
</Command>
);
if (isDesktop) {
return (
<nav
aria-label={title}
className={cn('flex w-64 shrink-0 flex-col gap-2', className)}
>
{list}
</nav>
);
}
const current = selectedId ? findNode(nodes, selectedId) : undefined;
return (
<>
<Button
type="button"
variant="combobox"
size="field"
shape="pill"
className={cn('w-full justify-between', className)}
onClick={() => setSheetOpen(true)}
data-placeholder={current ? undefined : ''}
aria-haspopup="dialog"
>
<span className="truncate">{current?.label ?? title}</span>
<ChevronDown className="opacity-50" />
</Button>
<Modal
open={sheetOpen}
onOpenChange={setSheetOpen}
title={title}
mobileVariant="sheet"
>
<div className="flex flex-col gap-2">{list}</div>
</Modal>
</>
);
}
+366 -22
View File
@@ -1,61 +1,96 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const { state } = vi.hoisted(() => ({
state: {
structure: {} as Record<string, unknown>,
chunks: [] as Record<string, unknown>[],
},
}));
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: () => vi.fn(),
}));
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: {
getDirectoryStructure: vi.fn(async () => ({
json: async () => ({
directory_structure: {
legal: { '2024': { 'msa.pdf': { type: 'pdf', size_bytes: 10 } } },
},
}),
json: async () => ({ directory_structure: state.structure }),
})),
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({ page: 1, per_page: 5, total: 0, chunks: [] }),
json: async () => ({
page: 1,
per_page: 12,
total: state.chunks.length,
chunks: state.chunks,
}),
})),
},
}));
import i18next from 'i18next';
import userService from '../../api/services/userService';
import TreeBrowser from './TreeBrowser';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('TreeBrowser path crumbs', () => {
const SOURCES = 'settings.sources.label';
const NESTED = {
legal: {
'2024': { 'msa.pdf': { type: 'pdf', size_bytes: 10, token_count: 700 } },
},
'readme.md': { type: 'md', size_bytes: 5, token_count: 300 },
};
describe('TreeBrowser', () => {
let container: HTMLDivElement;
let root: Root;
let onBack: ReturnType<typeof vi.fn<() => void>>;
beforeEach(async () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const render = async (
structure: Record<string, unknown>,
props: Partial<React.ComponentProps<typeof TreeBrowser>> = {},
) => {
state.structure = structure;
await act(async () => {
root.render(
<TreeBrowser
docId="doc"
sourceName="Contracts"
onBackToDocuments={vi.fn()}
onBackToDocuments={onBack}
columnOrder="size-first"
topRightAction={null}
topRightAction={<button type="button">Add file</button>}
{...props}
/>,
);
});
};
beforeEach(() => {
onBack = vi.fn<() => void>();
state.chunks = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
@@ -63,6 +98,11 @@ describe('TreeBrowser path crumbs', () => {
container.remove();
});
const rows = () =>
Array.from(container.querySelectorAll('tbody tr')).map((tr) =>
tr.querySelector('td')?.textContent?.trim(),
);
const openRow = async (name: string) => {
const row = Array.from(container.querySelectorAll('tr')).find(
(tr) => tr.querySelector('td')?.textContent?.trim() === name,
@@ -86,26 +126,330 @@ describe('TreeBrowser path crumbs', () => {
await act(async () => link.click());
};
const navItem = (label: string) =>
Array.from(
container.querySelectorAll<HTMLElement>(
'nav[aria-label="settings.sources.files"] [data-slot="command-item"]',
),
).find((el) => el.textContent?.includes(label));
const tile = () =>
container.querySelector<HTMLButtonElement>('button[data-slot="card"]');
const chunkListOpen = () =>
container.textContent?.includes('settings.sources.searchChunks') ?? false;
it('returns to the root and to a parent folder from the crumbs', async () => {
await render(NESTED);
await openRow('legal');
await openRow('2024');
expect(crumbs()).toEqual(['Contracts', 'legal', '2024']);
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'legal', '2024']);
await clickCrumb('legal');
expect(crumbs()).toEqual(['Contracts', 'legal']);
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'legal']);
await clickCrumb('Contracts');
expect(crumbs()).toEqual(['Contracts']);
expect(crumbs()).toEqual([SOURCES, 'Contracts']);
});
it('a folder crumb in the chunk viewer returns to that folder', async () => {
it('one header: the open file is the last crumb and a folder crumb returns to it', async () => {
await render(NESTED);
await openRow('legal');
await openRow('2024');
await openRow('msa.pdf');
expect(crumbs()).toEqual(['Contracts', 'legal', '2024', 'msa.pdf']);
expect(crumbs()).toEqual([
SOURCES,
'Contracts',
'legal',
'2024',
'msa.pdf',
]);
expect(container.querySelectorAll('[data-slot="breadcrumb"]')).toHaveLength(
1,
);
expect(chunkListOpen()).toBe(true);
await clickCrumb('legal');
expect(crumbs()).toEqual(['Contracts', 'legal']);
expect(container.textContent).toContain('2024');
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'legal']);
expect(rows()).toContain('2024');
});
// Bugs row 8: the app language's separators, not the browser's.
it('formats the byline and token cells in the app language', async () => {
const language = i18next.language;
i18next.language = 'de';
try {
await render({
'big.pdf': { type: 'pdf', size_bytes: 10, token_count: 12345 },
'small.md': { type: 'md', size_bytes: 5, token_count: 300 },
});
expect(container.textContent).toContain(
'settings.sources.filesByline {"count":2,"files":"2","tokens":"12.645"}',
);
const cells = Array.from(container.querySelectorAll('td')).map(
(td) => td.textContent,
);
expect(cells).toContain('12.345');
} finally {
i18next.language = language;
}
});
it('shows the byline, badge and actions', async () => {
await render(NESTED, { badge: <span>BADGE</span>, statusLabel: 'Busy' });
expect(container.textContent).toContain(
'settings.sources.filesByline {"count":2,"files":"2","tokens":"1,000"}',
);
expect(container.textContent).toContain('BADGE');
expect(container.textContent).toContain('Busy');
expect(container.textContent).toContain('Add file');
expect(
container.querySelector('[data-slot="command-input"]'),
).not.toBeNull();
// The old header search is gone; the navigator's filter replaces it.
expect(container.textContent).not.toContain('settings.sources.searchFiles');
});
it('opens folders and files from the navigator', async () => {
await render(NESTED);
await act(async () => navItem('legal')!.click());
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'legal']);
expect(rows()).toEqual(['2024']);
expect(navItem('legal')!.dataset.checked).toBeDefined();
await act(async () => navItem('readme.md')!.click());
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
expect(chunkListOpen()).toBe(true);
expect(navItem('readme.md')!.getAttribute('data-checked')).not.toBeNull();
});
it('no back button or .. row: the Sources crumb leaves the source', async () => {
await render(NESTED);
await openRow('legal');
expect(container.querySelector('.lucide-arrow-left')).toBeNull();
expect(rows()).not.toContain('..');
await clickCrumb('Contracts');
expect(onBack).not.toHaveBeenCalled();
await clickCrumb(SOURCES);
expect(onBack).toHaveBeenCalledTimes(1);
});
it('an open chunk is the last crumb, and the file crumb closes it', async () => {
state.chunks = [{ doc_id: 'c1', text: '# Rates', metadata: {} }];
await render(NESTED);
await openRow('readme.md');
await act(async () => tile()!.click());
expect(crumbs()).toEqual([
SOURCES,
'Contracts',
'readme.md',
'settings.sources.chunkCrumb {"n":1}',
]);
await clickCrumb('readme.md');
expect(tile()).not.toBeNull();
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
});
it('a one-file source opens straight on its chunk list', async () => {
await render({ 'report.pdf': { type: 'pdf', display_name: 'Report' } });
expect(
container.querySelector('nav[aria-label="settings.sources.files"]'),
).toBeNull();
expect(container.querySelector('table')).toBeNull();
expect(chunkListOpen()).toBe(true);
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'Report']);
expect(
container.querySelectorAll('[data-slot="breadcrumb-link"]'),
).toHaveLength(1);
await clickCrumb(SOURCES);
expect(onBack).toHaveBeenCalledTimes(1);
});
it('a one-file source inside a folder also opens straight on it', async () => {
await render({ docs: { 'a.pdf': { type: 'pdf' } } });
expect(
container.querySelector('nav[aria-label="settings.sources.files"]'),
).toBeNull();
expect(container.querySelector('table')).toBeNull();
expect(chunkListOpen()).toBe(true);
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'a.pdf']);
});
it("a one-file source keeps the row menu's Delete in the header", async () => {
const getRowMenuOptions = vi.fn(
({
defaultViewOption,
name,
}: {
defaultViewOption: unknown;
name: string;
}) => [defaultViewOption, { label: `delete ${name}`, onClick: vi.fn() }],
);
await render({ docs: { 'a.pdf': { type: 'pdf' } } }, {
getRowMenuOptions,
} as Partial<React.ComponentProps<typeof TreeBrowser>>);
expect(getRowMenuOptions).toHaveBeenCalledWith(
expect.objectContaining({ name: 'docs/a.pdf', isFile: true }),
);
expect(
container.querySelector('button[aria-label="settings.sources.menuAlt"]'),
).not.toBeNull();
});
it('a failed load says so and retries', async () => {
const getDirectoryStructure = vi.mocked(userService.getDirectoryStructure);
getDirectoryStructure.mockImplementationOnce(async () => {
throw new Error('offline');
});
await render(NESTED);
expect(container.textContent).toContain('settings.sources.filesLoadError');
const retry = Array.from(container.querySelectorAll('button')).find(
(el) => el.textContent === 'retry',
)!;
await act(async () => retry.click());
expect(container.textContent).not.toContain(
'settings.sources.filesLoadError',
);
expect(rows()).toEqual(['legal', 'readme.md']);
});
it('unmounting clears the crumbs it reported', async () => {
const onCrumbsChange = vi.fn();
await render(NESTED, { embedded: true, onCrumbsChange });
expect(onCrumbsChange.mock.lastCall![0]).toHaveLength(1);
await act(async () => root.unmount());
expect(onCrumbsChange.mock.lastCall![0]).toEqual([]);
root = createRoot(container);
});
it('a crumb step keeps focus in the header, on the new current crumb', async () => {
await render(NESTED);
await openRow('legal');
await openRow('2024');
const link = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((el) => el.textContent === 'legal')!;
link.focus();
await act(async () => link.click());
const current = container.querySelector('[data-slot="breadcrumb-page"]')!;
expect(current.textContent).toBe('legal');
expect(document.activeElement).toBe(current);
});
it('embedded: no Sources crumb, badge or byline', async () => {
await render(NESTED, { embedded: true, badge: <span>BADGE</span> });
expect(crumbs()).toEqual(['Contracts']);
expect(container.textContent).not.toContain('BADGE');
expect(container.textContent).not.toContain('settings.sources.filesByline');
expect(container.textContent).toContain('Add file');
});
it('embedded with an actions slot: no row of its own, actions in the slot', async () => {
const slot = document.createElement('div');
document.body.appendChild(slot);
const onCrumbsChange = vi.fn();
await render(NESTED, {
embedded: true,
actionsTarget: slot,
onCrumbsChange,
});
expect(container.querySelector('[data-slot="breadcrumb"]')).toBeNull();
expect(container.textContent).not.toContain('Add file');
expect(slot.textContent).toContain('Add file');
slot.remove();
// The host draws the crumbs: the tree reports where it is.
const labels = () =>
(
onCrumbsChange.mock.lastCall![0] as {
label: string;
onSelect?: () => void;
}[]
).map((crumb) => crumb.label);
expect(labels()).toEqual(['Contracts']);
await openRow('legal');
expect(labels()).toEqual(['Contracts', 'legal']);
await act(async () => onCrumbsChange.mock.lastCall![0][0].onSelect());
expect(labels()).toEqual(['Contracts']);
});
it('initialPath opens a nested file by its name, and again when it changes', async () => {
await render(NESTED, { initialPath: 'msa.pdf' });
expect(crumbs()).toEqual([
SOURCES,
'Contracts',
'legal',
'2024',
'msa.pdf',
]);
expect(chunkListOpen()).toBe(true);
expect(navItem('msa.pdf')!.getAttribute('data-checked')).not.toBeNull();
await render(NESTED, { initialPath: 'readme.md' });
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'readme.md']);
});
it('initialPath matches a full path or a display name', async () => {
const structure = {
docs: {
'x_1.docx': { type: 'docx', display_name: 'Carrier profile.docx' },
},
'y.md': { type: 'md' },
};
await render(structure, { initialPath: 'Carrier profile.docx' });
expect(crumbs()).toEqual([
SOURCES,
'Contracts',
'docs',
'Carrier profile.docx',
]);
await render(structure, { initialPath: 'y.md' });
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'y.md']);
});
it('an initialPath not in the tree yet opens once a reload has it', async () => {
const controllerRef: React.ComponentProps<
typeof TreeBrowser
>['controllerRef'] = { current: null };
await render(NESTED, { initialPath: 'new.pdf', controllerRef });
expect(chunkListOpen()).toBe(false);
state.structure = { ...NESTED, 'new.pdf': { type: 'pdf' } };
await act(async () => {
await controllerRef.current!.refreshDirectory();
});
expect(crumbs()).toEqual([SOURCES, 'Contracts', 'new.pdf']);
});
it('an unknown initialPath leaves the root open', async () => {
await render(NESTED, { initialPath: 'missing.pdf' });
expect(crumbs()).toEqual([SOURCES, 'Contracts']);
expect(chunkListOpen()).toBe(false);
});
// Numbers read down a column: size and tokens are right-aligned with
// tabular figures in either column order, headers included.
it.each(['size-first', 'tokens-first'] as const)(
'right-aligns the numeric columns (%s)',
async (columnOrder) => {
await render(NESTED, { columnOrder });
const headers = Array.from(container.querySelectorAll('thead th'));
[1, 2].forEach((index) =>
expect(headers[index].className).toContain('text-right'),
);
const row = Array.from(container.querySelectorAll('tbody tr')).find(
(tr) => tr.querySelector('td')?.textContent?.trim() === 'readme.md',
)!;
const cells = Array.from(row.querySelectorAll('td'));
[1, 2].forEach((index) => {
expect(cells[index].className).toContain('text-right');
expect(cells[index].className).toContain('tabular-nums');
});
},
);
});
+402 -348
View File
@@ -1,29 +1,26 @@
import { createPortal } from 'react-dom';
import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatCount } from '../../utils/dateTimeUtils';
import { formatBytes } from '../../utils/stringUtils';
import userService from '../../api/services/userService';
import { Eye, File, Folder } from 'lucide-react';
import { useLoaderState, useOutsideAlerter } from '../../hooks';
import Chunks from '../Chunks';
import PathHeader from './PathHeader';
import { Button } from '../ui/button';
import { EmptyState } from '../ui/empty-state';
import { useLoaderState } from '../../hooks';
import Chunks, { type ChunksController } from '../Chunks';
import PathHeader, { type Crumb } from './PathHeader';
import SourceNavigator from './SourceNavigator';
import SkeletonLoader from '../SkeletonLoader';
import { cn, fieldFrame } from '@/lib/utils';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../ui/command';
import {
Table,
TableBody,
@@ -34,11 +31,15 @@ import {
TableRow,
} from '../ui/table';
import { ActionMenu, type MenuOption } from '../ui/dropdown-menu';
import {
countLeaves,
directoryToNavigator,
type NavigatorNode,
} from './navigatorUtils';
import type {
DirectoryStructure,
FileNode,
RowMenuContext,
SearchResult,
TreeBrowserController,
} from './types';
@@ -86,15 +87,70 @@ export interface TreeBrowserProps {
* navigation state.
*/
onCurrentPathChange?: (path: string[]) => void;
/** The source kind (a neutral Badge) after the crumbs. */
badge?: React.ReactNode;
/**
* Rendered inside another source view (the graph source's Files tab): no
* Sources crumb, no badge and no byline.
*/
embedded?: boolean;
/**
* Embedded only: the tree's crumbs (the source, its folders, the open file
* and chunk), for a host that draws them in its own header.
*/
onCrumbsChange?: (crumbs: Crumb[]) => void;
/**
* Embedded only: the host header's action slot. The tree then draws no row
* of its own and portals its status and `topRightAction` (Add file, Sync)
* into the slot, so a tab's action sits in the page header.
*/
actionsTarget?: HTMLElement | null;
/**
* A file to open once the structure loads (the graph's "Open in Files"):
* its full path, its file name or its display name. Applied again when it
* changes.
*/
initialPath?: string;
}
// The search field's frame: the pill Input look around a CommandInput
// (38px, focus ring while the input has keyboard focus). CommandInput's row
// is 36px with a bottom rule; pt-px + overflow-hidden clips that rule.
const SEARCH_FRAME = cn(
fieldFrame,
'border-border has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-ring/50 h-9.5 overflow-hidden rounded-full px-2 pt-px has-[input:focus-visible]:ring-3',
);
/**
* Every file in the navigator tree, in tree order.
*
* @param nodes The navigator tree.
* @returns The leaves.
*/
function collectLeaves(nodes: NavigatorNode[]): NavigatorNode[] {
const leaves: NavigatorNode[] = [];
const walk = (list: NavigatorNode[]) => {
for (const node of list) {
if (node.kind === 'leaf') leaves.push(node);
else walk(node.children ?? []);
}
};
walk(nodes);
return leaves;
}
/**
* The file an `initialPath` names: an exact path first, then a file whose
* name (last path segment) or display name matches.
*
* @param nodes The navigator tree.
* @param target The path or name to look for.
* @returns The leaf, or undefined.
*/
function findFileByPathOrName(
nodes: NavigatorNode[],
target: string,
): NavigatorNode | undefined {
const leaves = collectLeaves(nodes);
return (
leaves.find((leaf) => leaf.path === target) ??
leaves.find(
(leaf) => leaf.path.split('/').pop() === target || leaf.label === target,
)
);
}
function calculateDirectoryStats(structure: DirectoryStructure): {
totalSize: number;
@@ -117,81 +173,6 @@ function calculateDirectoryStats(structure: DirectoryStructure): {
return { totalSize, totalTokens };
}
function searchFiles(
query: string,
structure: DirectoryStructure,
currentPath: string[] = [],
): SearchResult[] {
let results: SearchResult[] = [];
Object.entries(structure).forEach(([name, node]) => {
const fullPath = [...currentPath, name].join('/');
const displayName =
typeof node.display_name === 'string' && node.display_name.trim()
? node.display_name
: '';
const queryLower = query.toLowerCase();
const matchTarget = displayName ? `${name} ${displayName}` : name;
if (matchTarget.toLowerCase().includes(queryLower)) {
results.push({
name: displayName || name,
path: fullPath,
isFile: !!node.type,
});
}
if (!node.type) {
results = [
...results,
...searchFiles(query, node as DirectoryStructure, [
...currentPath,
name,
]),
];
}
});
return results;
}
function resolveDisplayName(
directoryStructure: DirectoryStructure | null,
path: string,
): string {
if (!directoryStructure) {
return path.split('/').pop() || path;
}
let structure: any = directoryStructure;
if (typeof structure === 'string') {
try {
structure = JSON.parse(structure);
} catch (e) {
return path.split('/').pop() || path;
}
}
if (typeof structure !== 'object' || structure === null) {
return path.split('/').pop() || path;
}
const parts = path.split('/').filter(Boolean);
let current: any = structure;
for (const part of parts) {
if (!current || typeof current !== 'object') {
return parts[parts.length - 1] || path;
}
current = current[part];
}
if (
current &&
typeof current === 'object' &&
typeof current.display_name === 'string' &&
current.display_name.trim()
) {
return current.display_name;
}
return parts[parts.length - 1] || path;
}
const TreeBrowser: React.FC<TreeBrowserProps> = ({
docId,
sourceName,
@@ -205,10 +186,17 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
onDirectoryDataLoaded,
controllerRef,
onCurrentPathChange,
badge,
embedded = false,
actionsTarget,
initialPath,
onCrumbsChange,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useLoaderState(true, 500);
const [, setError] = useState<string | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
// Bumped to refetch the structure (Retry).
const [reloadKey, setReloadKey] = useState(0);
const [directoryStructure, setDirectoryStructure] =
useState<DirectoryStructure | null>(null);
const [currentPath, setCurrentPath] = useState<string[]>([]);
@@ -217,10 +205,12 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
id: string;
name: string;
} | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const searchDropdownRef = useRef<HTMLDivElement>(null);
const mountedRef = useRef(true);
// The open file's open chunk (its crumb), reported by Chunks.
const [openChunkPosition, setOpenChunkPosition] = useState<number | null>(
null,
);
const chunksControllerRef = useRef<ChunksController | null>(null);
useEffect(
() => () => {
@@ -239,16 +229,6 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
onCurrentPathChangeRef.current?.(currentPath);
}, [currentPath]);
useOutsideAlerter(
searchDropdownRef,
() => {
setSearchQuery('');
setSearchResults([]);
},
[],
false,
);
const handleFileClick = useCallback(
(fileName: string, displayName?: string) => {
const fullPath = [...currentPath, fileName].join('/');
@@ -261,10 +241,6 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
setCurrentPath((prev) => [...prev, dirName]);
}, []);
const navigateUp = useCallback(() => {
setCurrentPath((prev) => prev.slice(0, -1));
}, []);
const refreshDirectory = useCallback(async () => {
try {
const response = await userService.getDirectoryStructure(docId, token);
@@ -283,6 +259,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
}, [docId, token]);
const resetPath = useCallback(() => {
setSelectedFile(null);
setCurrentPath([]);
}, []);
@@ -293,30 +270,34 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
);
useEffect(() => {
if (!docId) return;
let cancelled = false;
const fetchDirectoryStructure = async () => {
try {
setLoading(true);
setLoadFailed(false);
const response = await userService.getDirectoryStructure(docId, token);
const data = await response.json();
if (cancelled) return;
if (data && data.directory_structure) {
setDirectoryStructure(data.directory_structure);
onDirectoryDataLoadedRef.current?.(data);
} else {
setError('Invalid response format');
setLoadFailed(true);
}
} catch (err) {
setError('Failed to load directory structure');
console.error(err);
if (!cancelled) setLoadFailed(true);
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
};
if (docId) {
fetchDirectoryStructure();
}
}, [docId, token]);
fetchDirectoryStructure();
return () => {
cancelled = true;
};
}, [docId, token, reloadKey]);
const getCurrentDirectory = useCallback((): DirectoryStructure => {
if (!directoryStructure) return {};
@@ -353,51 +334,67 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
return current;
}, [directoryStructure, currentPath]);
const handleBackNavigation = () => {
if (selectedFile) {
setSelectedFile(null);
} else if (currentPath.length === 0) {
onBackToDocuments?.();
} else {
navigateUp();
// The navigator's tree; a source with exactly one file (at any depth)
// opens straight on that file, with no navigator and no table.
const navigatorNodes = useMemo(
() => directoryToNavigator(directoryStructure),
[directoryStructure],
);
const singleFile = useMemo(() => {
const leaves = collectLeaves(navigatorNodes);
return leaves.length === 1 ? leaves[0] : null;
}, [navigatorNodes]);
const openFile = singleFile
? { id: singleFile.path, name: singleFile.label }
: selectedFile;
const byline = useMemo(() => {
if (embedded || !directoryStructure) return undefined;
let structure: unknown = directoryStructure;
if (typeof structure === 'string') {
try {
structure = JSON.parse(structure);
} catch {
structure = {};
}
}
};
const handleSearchSelect = (result: SearchResult) => {
if (result.isFile) {
const pathParts = result.path.split('/');
const fileName = pathParts.pop() || '';
setCurrentPath(pathParts);
setSelectedFile({
id: result.path,
name: result.name || fileName,
});
} else {
setCurrentPath(result.path.split('/'));
setSelectedFile(null);
}
setSearchQuery('');
setSearchResults([]);
};
const handleFileSearch = (q: string): SearchResult[] => {
if (directoryStructure) {
return searchFiles(q, directoryStructure);
}
return [];
};
const handleFileSelect = (path: string) => {
const pathParts = path.split('/');
const fileName = pathParts.pop() || '';
setCurrentPath(pathParts);
setSelectedFile({
id: path,
name: resolveDisplayName(directoryStructure, path) || fileName,
const { totalTokens } = calculateDirectoryStats(
structure && typeof structure === 'object'
? (structure as DirectoryStructure)
: {},
);
const files = countLeaves(navigatorNodes);
return t('settings.sources.filesByline', {
count: files,
files: formatCount(files),
tokens: formatCount(totalTokens),
});
}, [embedded, directoryStructure, navigatorNodes, t]);
const handleNavigatorSelect = (node: NavigatorNode) => {
const parts = node.path.split('/');
if (node.kind === 'folder') {
setSelectedFile(null);
setCurrentPath(parts);
} else {
setCurrentPath(parts.slice(0, -1));
setSelectedFile({ id: node.path, name: node.label });
}
};
// Open the file the caller asked for, once per new initialPath; a path not
// found yet is tried again when the structure reloads.
const appliedInitialPath = useRef<string | null>(null);
useEffect(() => {
if (!initialPath || !directoryStructure) return;
if (appliedInitialPath.current === initialPath) return;
const file = findFileByPathOrName(navigatorNodes, initialPath);
if (!file) return;
appliedInitialPath.current = initialPath;
setCurrentPath(file.path.split('/').slice(0, -1));
setSelectedFile({ id: file.path, name: file.label });
}, [initialPath, directoryStructure, navigatorNodes]);
const buildDefaultViewOption = (
name: string,
isFile: boolean,
@@ -453,28 +450,36 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
*/
const renderColumnPair = (sizeBytes: number, tokens: number) => {
const sizeDisplay = sizeBytes > 0 ? formatBytes(sizeBytes) : '-';
const tokensDisplay = tokens > 0 ? tokens.toLocaleString() : '-';
const tokensDisplay = tokens > 0 ? formatCount(tokens) : '-';
if (columnOrder === 'size-first') {
return (
<>
<TableCell width="30%" align="left">
{sizeDisplay}
</TableCell>
<TableCell width="20%" align="right">
{tokensDisplay}
</TableCell>
</>
);
}
return (
// Numbers read down a column: right-aligned, tabular figures.
const size = (
<TableCell
width={columnOrder === 'size-first' ? '30%' : '20%'}
align="right"
className="whitespace-nowrap tabular-nums"
>
{sizeDisplay}
</TableCell>
);
const tokenCell = (
<TableCell
width={columnOrder === 'size-first' ? '20%' : '30%'}
align="right"
className="tabular-nums"
>
{tokensDisplay}
</TableCell>
);
return columnOrder === 'size-first' ? (
<>
<TableCell width="30%" align="left">
{tokensDisplay}
</TableCell>
<TableCell width="20%" align="left">
{sizeDisplay}
</TableCell>
{size}
{tokenCell}
</>
) : (
<>
{tokenCell}
{size}
</>
);
};
@@ -496,30 +501,6 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
const directories = sortedEntries.filter(([, node]) => !node.type);
const files = sortedEntries.filter(([, node]) => node.type);
const parentRow =
currentPath.length > 0
? [
<TableRow key="parent-dir" onClick={navigateUp}>
<TableCell width="40%" align="left">
<div className="flex items-center gap-2">
<Folder className="text-primary size-4 shrink-0" />
<span className="truncate">..</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
-
</TableCell>
<TableCell
width="20%"
align={columnOrder === 'size-first' ? 'right' : 'left'}
>
-
</TableCell>
<TableCell width="10%" align="right"></TableCell>
</TableRow>,
]
: [];
const directoryRows = directories.map(([name, node]) => {
const itemId = `dir-${name}`;
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
@@ -570,162 +551,235 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
);
});
return [...parentRow, ...directoryRows, ...fileRows];
return [...directoryRows, ...fileRows];
};
const renderFileSearch = () => (
<div className="relative w-52" ref={searchDropdownRef}>
{/* `contents`: the Command only scopes cmdk (the arrow keys move from
the input to the rows); the frame and the dropdown draw the look. */}
<Command shouldFilter={false} className="contents">
<div className={SEARCH_FRAME}>
<CommandInput
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
if (directoryStructure) {
setSearchResults(searchFiles(value, directoryStructure));
}
}}
placeholder={t('settings.sources.searchFiles')}
/>
</div>
{searchQuery && (
<div className="border-border bg-popover text-popover-foreground absolute top-full right-0 left-0 z-20 mt-1 w-full overflow-hidden rounded-xl border shadow-md">
<CommandList className="max-h-[calc(100dvh-200px)] overscroll-contain">
{searchResults.length === 0 ? (
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
) : (
<CommandGroup>
{searchResults.map((result) => (
<CommandItem
key={result.path}
value={result.path}
title={result.path}
onSelect={() => handleSearchSelect(result)}
>
{result.isFile ? (
<File />
) : (
<Folder className="text-primary" />
)}
<span className="min-w-0 flex-1 truncate">
{result.name}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</div>
)}
</Command>
</div>
);
// Crumb n opens the folder made of the first n path segments (0 = root).
const openPathDepth = useCallback((depth: number) => {
setSelectedFile(null);
setCurrentPath((prev) => prev.slice(0, depth));
}, []);
const renderPathNavigation = () => (
<PathHeader
root={{
label: sourceName,
onSelect: currentPath.length > 0 ? () => openPathDepth(0) : undefined,
}}
segments={currentPath.map((dir, index) => ({
label: dir,
onSelect:
index < currentPath.length - 1
? () => openPathDepth(index + 1)
: undefined,
}))}
onBack={handleBackNavigation}
backLabel={t('settings.sources.back')}
actions={
<>
{statusLabel && (
<div className="text-muted-foreground text-sm">{statusLabel}</div>
)}
{renderFileSearch()}
{topRightAction}
</>
}
/>
// The source, the open folder's path, the open file, then the open chunk;
// every crumb but the current (last) one opens its level.
const folderPath = singleFile ? [] : currentPath;
const chunkOpen = !!openFile && openChunkPosition !== null;
const depth = folderPath.length + (openFile ? 1 : 0) + (chunkOpen ? 1 : 0);
const treeCrumbs: Crumb[] = [
{
label: sourceName,
onSelect: !singleFile && depth > 0 ? () => openPathDepth(0) : undefined,
},
...folderPath.map((dir, index) => ({
label: dir,
onSelect: index < depth - 1 ? () => openPathDepth(index + 1) : undefined,
})),
...(openFile
? [
{
label: openFile.name,
onSelect: chunkOpen
? () => chunksControllerRef.current?.closeChunk()
: undefined,
},
]
: []),
...(chunkOpen
? [
{
label: t('settings.sources.chunkCrumb', { n: openChunkPosition }),
},
]
: []),
];
// An embedded tree's host draws the crumbs; report them when they change.
const treeCrumbsRef = useRef(treeCrumbs);
const onCrumbsChangeRef = useRef(onCrumbsChange);
useEffect(() => {
treeCrumbsRef.current = treeCrumbs;
onCrumbsChangeRef.current = onCrumbsChange;
});
const crumbsKey = treeCrumbs
.map((crumb) => `${crumb.label}${crumb.onSelect ? '>' : ''}`)
.join('/');
useEffect(() => {
onCrumbsChangeRef.current?.(treeCrumbsRef.current);
}, [crumbsKey]);
// Leaving (the host's tab changes) clears them, so a remount never shows
// the previous trail.
useEffect(() => () => onCrumbsChangeRef.current?.([]), []);
// A single-file source shows no table, so its row menu (Delete) moves to
// the header, less the View option (the file is already open). The path
// sits at the root, so the file's name is its full path.
useEffect(() => {
if (singleFile) setCurrentPath((prev) => (prev.length ? [] : prev));
}, [singleFile]);
const singleFileMenu = (() => {
if (!singleFile || !getRowMenuOptions) return null;
const defaultViewOption = buildDefaultViewOption(
singleFile.path,
true,
singleFile.label,
);
const options = getRowMenuOptions({
name: singleFile.path,
isFile: true,
itemId: `file-${singleFile.path}`,
displayName: singleFile.label,
defaultViewOption,
}).filter((option) => option !== defaultViewOption);
if (options.length === 0) return null;
return (
<ActionMenu
size="toolbar"
options={options}
triggerLabel={t('settings.sources.menuAlt')}
/>
);
})();
const headerActions = (
<>
{statusLabel && (
<div className="text-muted-foreground text-sm">{statusLabel}</div>
)}
{singleFileMenu}
{topRightAction}
</>
);
const renderPathNavigation = () => {
const [sourceCrumb, ...rest] = treeCrumbs;
return (
<PathHeader
root={
embedded
? sourceCrumb
: {
label: t('settings.sources.label'),
onSelect: onBackToDocuments,
}
}
segments={embedded ? rest : treeCrumbs}
badge={embedded ? undefined : badge}
byline={byline}
actions={headerActions}
/>
);
};
const currentDirectory = getCurrentDirectory();
return (
<div>
{selectedFile ? (
<div className="flex">
<div className="flex-1">
<Chunks
documentId={docId}
documentName={sourceName}
handleGoBack={() => setSelectedFile(null)}
path={selectedFile.id}
displayPath={[...currentPath, selectedFile.name].join('/')}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
onPathSelect={openPathDepth}
/>
</div>
</div>
) : (
<div className="flex w-full max-w-full flex-col overflow-x-clip">
<div className="mb-2">{renderPathNavigation()}</div>
const renderChunks = (file: { id: string; name: string }) => (
<Chunks
key={file.id}
embedded
documentId={docId}
documentName={sourceName}
handleGoBack={() => setSelectedFile(null)}
path={file.id}
fileName={file.name}
controllerRef={chunksControllerRef}
onOpenChunkChange={setOpenChunkPosition}
/>
);
<div className="w-full">
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader width="40%" align="left">
{t('settings.sources.fileName')}
</TableHeader>
{columnOrder === 'size-first' ? (
<>
<TableHeader width="30%" align="left">
{t('settings.sources.size')}
</TableHeader>
<TableHeader width="20%" align="right">
{t('settings.sources.tokens')}
</TableHeader>
</>
) : (
<>
<TableHeader width="30%" align="left">
{t('settings.sources.tokens')}
</TableHeader>
<TableHeader width="20%" align="left">
{t('settings.sources.size')}
</TableHeader>
</>
)}
<TableHeader width="10%" align="right">
<span className="sr-only">
{t('settings.sources.actions')}
</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<SkeletonLoader component="fileTable" />
) : (
renderFileTree(currentDirectory)
)}
</TableBody>
</Table>
</TableContainer>
</div>
const renderTable = () => (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader width="40%" align="left">
{t('settings.sources.fileName')}
</TableHeader>
{columnOrder === 'size-first' ? (
<>
<TableHeader width="30%" align="right">
{t('settings.sources.size')}
</TableHeader>
<TableHeader width="20%" align="right">
{t('settings.sources.tokens')}
</TableHeader>
</>
) : (
<>
<TableHeader width="30%" align="right">
{t('settings.sources.tokens')}
</TableHeader>
<TableHeader width="20%" align="right">
{t('settings.sources.size')}
</TableHeader>
</>
)}
<TableHeader width="10%" align="right">
<span className="sr-only">{t('settings.sources.actions')}</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<SkeletonLoader component="fileTable" />
) : (
renderFileTree(currentDirectory)
)}
</TableBody>
</Table>
</TableContainer>
);
const renderContent = () => {
if (loading) return renderTable();
if (loadFailed) {
return (
<EmptyState
tone="destructive"
illustration="none"
title={t('settings.sources.filesLoadError')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() => setReloadKey((key) => key + 1)}
>
{t('retry')}
</Button>
}
/>
);
}
if (singleFile) return renderChunks(openFile!);
if (navigatorNodes.length === 0) return renderTable();
return (
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6">
<SourceNavigator
nodes={navigatorNodes}
selectedId={
selectedFile?.id ??
(currentPath.length > 0 ? currentPath.join('/') : null)
}
onSelect={handleNavigatorSelect}
folderMode="tree"
filterLabel={t('settings.sources.filterFiles')}
emptyLabel={t('settings.sources.noResults')}
title={t('settings.sources.files')}
/>
<div className="min-w-0 flex-1">
{selectedFile ? renderChunks(selectedFile) : renderTable()}
</div>
)}
</div>
);
};
return (
<div className="flex w-full max-w-full min-w-0 flex-col gap-4">
{embedded && actionsTarget
? createPortal(headerActions, actionsTarget)
: renderPathNavigation()}
{renderContent()}
{extraContent}
</div>
);
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
ancestorIds,
countLeaves,
directoryToNavigator,
filterNavigatorLeaves,
findNode,
type NavigatorNode,
} from './navigatorUtils';
const tree: NavigatorNode[] = [
{
id: 'briefs',
kind: 'folder',
label: 'briefs',
path: 'briefs',
children: [
{
id: 'briefs/kestrel.docx',
kind: 'leaf',
label: 'Kestrel brief',
path: 'briefs/kestrel.docx',
},
{
id: 'briefs/deep',
kind: 'folder',
label: 'deep',
path: 'briefs/deep',
children: [
{
id: 'briefs/deep/nordhaven.docx',
kind: 'leaf',
label: 'nordhaven.docx',
path: 'briefs/deep/nordhaven.docx',
},
],
},
],
},
{ id: 'readme.md', kind: 'leaf', label: 'readme.md', path: 'readme.md' },
];
describe('filterNavigatorLeaves', () => {
it('matches leaves by label or path, case-insensitively, with their folder', () => {
expect(filterNavigatorLeaves(tree, 'NORD')).toEqual([
{ node: tree[0].children![1].children![0], parentPath: 'briefs/deep' },
]);
expect(filterNavigatorLeaves(tree, 'briefs').map((r) => r.node.id)).toEqual(
['briefs/kestrel.docx', 'briefs/deep/nordhaven.docx'],
);
});
it('gives a root leaf an empty parent path and returns nothing for a blank query', () => {
expect(filterNavigatorLeaves(tree, 'readme')).toEqual([
{ node: tree[1], parentPath: '' },
]);
expect(filterNavigatorLeaves(tree, ' ')).toEqual([]);
});
});
describe('ancestorIds / findNode / countLeaves', () => {
it('lists the folders above a node, outermost first', () => {
expect(ancestorIds(tree, 'briefs/deep/nordhaven.docx')).toEqual([
'briefs',
'briefs/deep',
]);
expect(ancestorIds(tree, 'readme.md')).toEqual([]);
expect(ancestorIds(tree, 'missing')).toEqual([]);
});
it('finds a node anywhere in the tree', () => {
expect(findNode(tree, 'briefs/deep')?.label).toBe('deep');
expect(findNode(tree, 'nope')).toBeUndefined();
});
it('counts leaves under a list of nodes', () => {
expect(countLeaves(tree)).toBe(3);
expect(countLeaves(tree[0].children!)).toBe(2);
});
});
describe('directoryToNavigator', () => {
it('builds folders first, then files, with display names and leaf counts', () => {
const nodes = directoryToNavigator({
'z.md': { type: 'text/markdown', display_name: 'Zed notes' },
docs: {
'b.pdf': { type: 'application/pdf' },
'a.pdf': { type: 'application/pdf' },
} as never,
});
expect(nodes.map((n) => [n.id, n.kind, n.label, n.count])).toEqual([
['docs', 'folder', 'docs', 2],
['z.md', 'leaf', 'Zed notes', undefined],
]);
expect(nodes[0].children!.map((n) => n.id)).toEqual([
'docs/a.pdf',
'docs/b.pdf',
]);
});
it('tolerates a structure stored as a JSON string', () => {
const nodes = directoryToNavigator(
JSON.stringify({ 'a.txt': { type: 'text/plain' } }) as never,
);
expect(nodes).toHaveLength(1);
expect(nodes[0].path).toBe('a.txt');
});
});
@@ -0,0 +1,174 @@
/**
* Pure helpers for the source navigator (the file tree and the wiki pages
* column): the node model, filtering and lookups. Kept free of React so the
* rules are unit-testable.
*/
import type { DirectoryStructure } from './types';
export interface NavigatorNode {
/** Unique within the tree; a file's path, a folder's path, a wiki page's path. */
id: string;
kind: 'folder' | 'leaf';
label: string;
/** Full path, matched by the filter and shown as a folder's second line. */
path: string;
/** Leaves under a folder. */
count?: number;
children?: NavigatorNode[];
}
export interface NavigatorMatch {
node: NavigatorNode;
/** The leaf's own folder, its path minus the name ('' at the root). */
parentPath: string;
}
/** A path minus its last segment: 'a/b/c.md' → 'a/b', '/x.md' → ''. */
function parentOf(path: string): string {
const cut = path.lastIndexOf('/');
return cut > 0 ? path.slice(0, cut) : '';
}
/**
* Leaves whose label or path contains the query, in tree order.
*
* @param nodes The navigator tree.
* @param query What the user typed; blank returns nothing.
* @returns Each match with its parent path, taken from the leaf's own path
* so a page deep inside a wiki group reads its full folder, not the group's.
*/
export function filterNavigatorLeaves(
nodes: NavigatorNode[],
query: string,
): NavigatorMatch[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const matches: NavigatorMatch[] = [];
const walk = (list: NavigatorNode[]) => {
for (const node of list) {
if (node.kind === 'folder') {
walk(node.children ?? []);
} else if (
node.label.toLowerCase().includes(q) ||
node.path.toLowerCase().includes(q)
) {
matches.push({ node, parentPath: parentOf(node.path) });
}
}
};
walk(nodes);
return matches;
}
/**
* The ids of the folders above a node, outermost first.
*
* @param nodes The navigator tree.
* @param id The node to look for.
* @returns Folder ids, or an empty list at the root or when not found.
*/
export function ancestorIds(nodes: NavigatorNode[], id: string): string[] {
const walk = (list: NavigatorNode[], trail: string[]): string[] | null => {
for (const node of list) {
if (node.id === id) return trail;
if (node.children) {
const found = walk(node.children, [...trail, node.id]);
if (found) return found;
}
}
return null;
};
return walk(nodes, []) ?? [];
}
/**
* Find a node anywhere in the tree.
*
* @param nodes The navigator tree.
* @param id The node id.
* @returns The node, or undefined.
*/
export function findNode(
nodes: NavigatorNode[],
id: string,
): NavigatorNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const found = node.children ? findNode(node.children, id) : undefined;
if (found) return found;
}
return undefined;
}
/**
* Count the leaves under a list of nodes.
*
* @param nodes Nodes to count through.
* @returns The number of leaves.
*/
export function countLeaves(nodes: NavigatorNode[]): number {
return nodes.reduce(
(sum, node) =>
sum + (node.kind === 'leaf' ? 1 : countLeaves(node.children ?? [])),
0,
);
}
function parseStructure(
structure: DirectoryStructure | string | null | undefined,
): DirectoryStructure {
if (!structure) return {};
if (typeof structure === 'string') {
try {
const parsed = JSON.parse(structure);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
return structure;
}
/**
* Turn a source's directory structure into navigator nodes: folders first,
* then files, each group sorted by name; a file shows its display name.
*
* @param structure The source's `directory_structure` (object or JSON string).
* @param parent The path prefix (used by the recursion).
* @returns Navigator nodes whose ids are the file and folder paths.
*/
export function directoryToNavigator(
structure: DirectoryStructure | string | null | undefined,
parent = '',
): NavigatorNode[] {
const entries = Object.entries(parseStructure(structure));
const folders: NavigatorNode[] = [];
const files: NavigatorNode[] = [];
for (const [name, node] of entries) {
const path = parent ? `${parent}/${name}` : name;
if (node && typeof node === 'object' && !node.type) {
const children = directoryToNavigator(
node as unknown as DirectoryStructure,
path,
);
folders.push({
id: path,
kind: 'folder',
label: name,
path,
count: countLeaves(children),
children,
});
} else {
const displayName =
typeof node?.display_name === 'string' && node.display_name.trim()
? node.display_name
: name;
files.push({ id: path, kind: 'leaf', label: displayName, path });
}
}
const byName = (a: NavigatorNode, b: NavigatorNode) =>
a.path.localeCompare(b.path);
return [...folders.sort(byName), ...files.sort(byName)];
}
-6
View File
@@ -12,12 +12,6 @@ export interface DirectoryStructure {
[key: string]: FileNode;
}
export interface SearchResult {
name: string;
path: string;
isFile: boolean;
}
/**
* Row context passed to consumers' getRowMenuOptions callbacks so they
* can build per-row menus (e.g. add a Delete option for upload trees).
+6 -1
View File
@@ -59,7 +59,12 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground truncate font-normal', className)}
// Focusable from script only (tabIndex -1): PathHeader moves focus here
// after a crumb step, so it takes the link's ring.
className={cn(
`${focusRing} text-foreground truncate rounded-sm font-normal outline-none`,
className,
)}
{...props}
/>
);
@@ -145,4 +145,48 @@ describe('CommandInput', () => {
);
expect(classes).not.toContain('h-10');
});
it('is a plain row with a bottom rule by default', async () => {
await render(
<Command>
<CommandInput placeholder="Search" />
</Command>,
);
const wrapper = document.querySelector<HTMLElement>(
'[data-slot="command-input-wrapper"]',
)!;
expect(wrapper.dataset.variant).toBe('default');
expect(wrapper.className).toContain('border-b');
});
// A filter field above a list the arrow keys walk into (a source view's
// navigator): SearchInput's 38px pill, its text 40px in.
it('variant="field" is the SearchInput pill with the ring on the frame', async () => {
await render(
<Command>
<CommandInput variant="field" placeholder="Filter files" />
</Command>,
);
const wrapper = document.querySelector<HTMLElement>(
'[data-slot="command-input-wrapper"]',
)!;
const classes = wrapper.className.split(' ');
expect(wrapper.dataset.variant).toBe('field');
expect(classes).toEqual(
expect.arrayContaining([
'border-input',
'shadow-xs',
'h-9.5',
'rounded-full',
'px-3',
'gap-3',
'has-[input:focus-visible]:ring-3',
'has-[input:focus-visible]:border-ring',
]),
);
expect(classes).not.toContain('border-b');
const icon = wrapper.querySelector('svg')!;
expect(icon.getAttribute('class')).toContain('text-muted-foreground');
expect(icon.getAttribute('class')).not.toContain('opacity-50');
});
});
+28 -4
View File
@@ -9,7 +9,9 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn, fieldFrame } from '@/lib/utils';
// The search-palette spacing: a 48px input row and roomier rows. Shared by
// CommandDialog and any palette rendered elsewhere (e.g. in a bottom sheet),
@@ -74,16 +76,38 @@ function CommandDialog({
);
}
const commandInputWrapperVariants = cva('flex items-center', {
variants: {
variant: {
// The row at the top of a popover or palette list.
default: 'h-9 gap-2 border-b px-3',
// A filter field over a list the arrow keys walk into (a source view's
// navigator): SearchInput's 38px pill, text 40px in, the focus ring on
// the frame while the input has keyboard focus.
field: `${fieldFrame} has-[input:focus-visible]:border-ring has-[input:focus-visible]:ring-ring/50 h-9.5 gap-3 rounded-full px-3 has-[input:focus-visible]:ring-3`,
},
},
defaultVariants: { variant: 'default' },
});
function CommandInput({
className,
variant = 'default',
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
}: React.ComponentProps<typeof CommandPrimitive.Input> &
VariantProps<typeof commandInputWrapperVariants>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
data-variant={variant}
className={commandInputWrapperVariants({ variant })}
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<SearchIcon
className={cn(
'size-4 shrink-0',
variant === 'field' ? 'text-muted-foreground' : 'opacity-50',
)}
/>
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
+5 -4
View File
@@ -84,15 +84,16 @@ const LABEL_SURFACE_CLASSES = {
} as const;
// Where the floating label rests while its Input or Textarea is empty and
// unfocused, per control size; every other control keeps it on the border.
// unfocused, per control size, at the field's own text size (16px, 14px from
// md); every other control keeps it on the border.
// Only the field itself counts (up to two levels down, for an Input with an
// icon or a Textarea under an overlay), not a search box in an inline menu.
const FLOATING_LABEL_REST = [
'group-has-[>input:placeholder-shown:not(:focus),>*>input:placeholder-shown:not(:focus)]/float:top-2 group-has-[>input:placeholder-shown:not(:focus),>*>input:placeholder-shown:not(:focus)]/float:text-base',
'group-has-[>input:placeholder-shown:not(:focus),>*>input:placeholder-shown:not(:focus)]/float:top-2 group-has-[>input:placeholder-shown:not(:focus),>*>input:placeholder-shown:not(:focus)]/float:text-base md:group-has-[>input:placeholder-shown:not(:focus),>*>input:placeholder-shown:not(:focus)]/float:text-sm',
'group-has-[>input[data-size=sm]:placeholder-shown:not(:focus)]/float:top-1.5 group-has-[>input[data-size=sm]:placeholder-shown:not(:focus)]/float:text-sm',
'group-has-[>input[data-size=lg]:placeholder-shown:not(:focus)]/float:top-3.5',
'group-has-[>*>input[data-left-icon]:placeholder-shown:not(:focus)]/float:left-7',
'group-has-[>textarea:placeholder-shown:not(:focus),>*>textarea:placeholder-shown:not(:focus)]/float:top-2 group-has-[>textarea:placeholder-shown:not(:focus),>*>textarea:placeholder-shown:not(:focus)]/float:text-base',
'group-has-[>*>input[data-left-icon]:placeholder-shown:not(:focus)]/float:left-8',
'group-has-[>textarea:placeholder-shown:not(:focus),>*>textarea:placeholder-shown:not(:focus)]/float:top-2 group-has-[>textarea:placeholder-shown:not(:focus),>*>textarea:placeholder-shown:not(:focus)]/float:text-base md:group-has-[>textarea:placeholder-shown:not(:focus),>*>textarea:placeholder-shown:not(:focus)]/float:text-sm',
'group-has-[>textarea[data-size=lg]:placeholder-shown:not(:focus),>*>textarea[data-size=lg]:placeholder-shown:not(:focus)]/float:top-3',
].join(' ');
+8 -4
View File
@@ -55,14 +55,18 @@ const inputVariants = cva(
);
// Where the floating label sits while the field is empty, per input height.
// It rests at the field's own text size (16px, 14px from md), so it doesn't
// change size when the first character replaces it.
const LABEL_RESTING_CLASSES: Record<
NonNullable<VariantProps<typeof inputVariants>['size']>,
string
> = {
default: 'peer-placeholder-shown:top-2 peer-placeholder-shown:text-base',
default:
'peer-placeholder-shown:top-2 peer-placeholder-shown:text-base md:peer-placeholder-shown:text-sm md:peer-focus:text-xs',
sm: 'peer-placeholder-shown:top-1.5 peer-placeholder-shown:text-sm',
lg: 'peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base',
field: 'peer-placeholder-shown:top-2 peer-placeholder-shown:text-base',
lg: 'peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base md:peer-placeholder-shown:text-sm md:peer-focus:text-xs',
field:
'peer-placeholder-shown:top-2 peer-placeholder-shown:text-base md:peer-placeholder-shown:text-sm md:peer-focus:text-xs',
};
// The floating label sits on the field's border, so its background has to
@@ -168,7 +172,7 @@ function Input(inputProps: InputProps) {
'text-muted-foreground pointer-events-none absolute -top-2.5 left-3 max-w-[calc(100%-24px)] overflow-hidden px-2 text-xs text-ellipsis whitespace-nowrap transition-all select-none',
LABEL_RESTING_CLASSES[size ?? 'default'],
leftIcon
? 'peer-placeholder-shown:left-7'
? 'peer-placeholder-shown:left-8'
: 'peer-placeholder-shown:left-3',
'peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs',
LABEL_SURFACE_CLASSES[labelSurface],
@@ -34,4 +34,15 @@ describe('ListRow', () => {
expect(html).toContain('focus-visible:ring-inset');
expect(html).toContain('Sources');
});
it('is dense, rounded and top-aligned at size sm', () => {
const html = renderToStaticMarkup(
<ListRow size="sm" interactive asChild title="Duisport">
<button type="button" />
</ListRow>,
);
expect(html).toContain('items-start gap-2.5 rounded-md px-2 py-1.5');
expect(html).not.toContain('px-4 py-3');
expect(html).toContain('focus-visible:ring-inset');
});
});
+12 -1
View File
@@ -24,6 +24,13 @@ type ListRowProps = Omit<React.ComponentProps<'li'>, 'title'> & {
trailing?: React.ReactNode;
/** The whole row is a target: hover fill and an inset focus ring. */
interactive?: boolean;
/**
* `sm` is the dense row of a narrow side panel (the graph node panel's
* relationships): 6px / 8px padding, rounded, top-aligned so a small
* leading mark sits on the title line. Use it in a plain list, not in
* `ListRows` (no divide rules).
*/
size?: 'default' | 'sm';
/**
* Render the row's content into its single child (a `<Link>` or
* `<button>`), wrapped in the `<li>`.
@@ -38,13 +45,17 @@ function ListRow({
description,
trailing,
interactive = false,
size = 'default',
asChild = false,
className,
children,
...props
}: ListRowProps) {
const rowClass = cn(
'flex items-center gap-3 px-4 py-3',
'flex',
size === 'sm'
? 'items-start gap-2.5 rounded-md px-2 py-1.5'
: 'items-center gap-3 px-4 py-3',
// Inset, because a row list usually sits in an overflow-hidden rounded
// box that would clip an outer ring.
interactive &&
@@ -177,7 +177,6 @@ export function MultiSelect({
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{/* Visual only: the row is the control (cmdk handles the
click and Enter), so the box takes no focus or events. */}
@@ -96,4 +96,20 @@ describe('Pagination', () => {
);
expect(texts).toEqual(['pagination.previousPage', 'pagination.nextPage']);
});
it('names the page-size select with pageSizeLabel', async () => {
await render(
<Pagination
page={1}
pageCount={3}
onPageChange={() => undefined}
pageSize={12}
pageSizeOptions={[12, 24]}
pageSizeLabel="Chunks per page"
/>,
);
expect(container.textContent).toContain('Chunks per page:');
expect(button('Chunks per page')).not.toBeNull();
expect(container.textContent).not.toContain('pagination.rowsPerPage');
});
});
+6 -4
View File
@@ -28,6 +28,8 @@ type PaginationProps = {
onPageChange: (page: number) => void;
/** Shows the "Rows per page" select. */
pageSize?: number;
/** Names the page-size select when the items aren't rows ("Chunks per page"). */
pageSizeLabel?: string;
pageSizeOptions?: number[];
onPageSizeChange?: (size: number) => void;
/** A count on the left ("1,024 users"); the row then spreads to both ends. */
@@ -43,6 +45,7 @@ function Pagination({
pageCount,
onPageChange,
pageSize,
pageSizeLabel,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
onPageSizeChange,
summary,
@@ -50,6 +53,7 @@ function Pagination({
className,
}: PaginationProps) {
const { t } = useTranslation();
const sizeLabel = pageSizeLabel ?? t('pagination.rowsPerPage');
const atStart = page <= 1;
const atEnd = page >= pageCount;
const goTo = (next: number) =>
@@ -70,14 +74,12 @@ function Pagination({
<div className="flex items-center gap-4">
{pageSize !== undefined ? (
<div className="flex items-center gap-2">
<span className="text-foreground">
{t('pagination.rowsPerPage')}:
</span>
<span className="text-foreground">{sizeLabel}:</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange?.(Number(value))}
>
<SelectTrigger size="sm" aria-label={t('pagination.rowsPerPage')}>
<SelectTrigger size="sm" aria-label={sizeLabel}>
<SelectValue />
</SelectTrigger>
<SelectContent>
+21
View File
@@ -73,6 +73,27 @@ describe('Table parts', () => {
expect(classes).not.toContain('text-muted-foreground');
});
// The row whose detail is open beside the table (the graph's Entities):
// the brand tint of an open navigator item, so it never reads as hover.
it('marks the open row with the secondary tint and aria-current', () => {
const host = render(
<table>
<tbody>
<TableRow data-testid="open" selected onClick={vi.fn()} />
<TableRow data-testid="other" onClick={vi.fn()} />
</tbody>
</table>,
);
const open = host.querySelector<HTMLElement>('[data-testid="open"]')!;
expect(open.dataset.selected).toBe('');
expect(open.getAttribute('aria-current')).toBe('true');
expect(open.className).toContain('bg-secondary');
expect(open.className).toContain('hover:bg-secondary');
const other = host.querySelector<HTMLElement>('[data-testid="other"]')!;
expect(other.dataset.selected).toBeUndefined();
expect(other.className).not.toContain('bg-secondary');
});
it('hovers only rows that have an onClick', () => {
const host = render(
<table>
+8 -2
View File
@@ -112,14 +112,17 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
/**
* A table row. With `onClick` the whole row is the target, so it also takes
* focus and opens with Enter or Space for keyboard users.
* focus and opens with Enter or Space for keyboard users. `selected` marks the
* row whose detail is open beside the table, with the brand tint of an open
* navigator item (`CommandItem checked`), so it never reads as hover.
*/
function TableRow({
className,
onClick,
onKeyDown,
selected = false,
...props
}: React.ComponentProps<'tr'>) {
}: React.ComponentProps<'tr'> & { selected?: boolean }) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLTableRowElement>) => {
onKeyDown?.(event);
// Keys on a control inside the row belong to that control.
@@ -132,10 +135,13 @@ function TableRow({
return (
<tr
data-slot="table-row"
data-selected={selected ? '' : undefined}
aria-current={selected ? 'true' : undefined}
className={cn(
'border-border border-b',
onClick &&
`hover:bg-accent cursor-pointer transition-colors outline-none ${focusRing}`,
selected && 'bg-secondary text-secondary-foreground hover:bg-secondary',
className,
)}
onClick={onClick}
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
import { provenanceKey, saveWikiPage } from './wikiViewerUtils';
import {
buildWikiNavigator,
provenanceKey,
saveWikiPage,
wikiPageLabel,
} from './wikiViewerUtils';
import { filterNavigatorLeaves } from './tree/navigatorUtils';
const jsonResponse = (status: number, body: unknown): Response =>
({
@@ -116,3 +122,60 @@ describe('saveWikiPage', () => {
expect(outcome).toEqual({ status: 'error' });
});
});
describe('wikiPageLabel', () => {
it('prefers the stored title', () => {
expect(wikiPageLabel({ path: '/a/b.md', title: 'Hello' })).toBe('Hello');
});
it('turns a file name into sentence case', () => {
expect(
wikiPageLabel({ path: '/company/meeting-and-decision-norms.md' }),
).toBe('Meeting and decision norms');
expect(wikiPageLabel({ path: '/people/leave_and_hours.md' })).toBe(
'Leave and hours',
);
});
it('calls the root index Home', () => {
expect(wikiPageLabel({ path: '/index.md' })).toBe('Home');
expect(wikiPageLabel({ path: '/sales/index.md' })).toBe('Index');
});
});
describe('buildWikiNavigator', () => {
it('puts root pages first (Home leading), then one group per top folder', () => {
const nodes = buildWikiNavigator([
{ path: '/people/onboarding.md' },
{ path: '/company/mission.md' },
{ path: '/arc53.md' },
{ path: '/index.md' },
{ path: '/company/org/model.md' },
]);
expect(nodes.map((n) => [n.kind, n.label])).toEqual([
['leaf', 'Home'],
['leaf', 'Arc53'],
['folder', 'company'],
['folder', 'people'],
]);
expect(nodes[2].children!.map((n) => n.id)).toEqual([
'/company/mission.md',
'/company/org/model.md',
]);
expect(nodes[2].count).toBe(2);
});
// A filter match's second line is the page's own folder, not only the top
// folder its group is named after.
it('gives each filter match its full parent path', () => {
const nodes = buildWikiNavigator([
{ path: '/index.md' },
{ path: '/contracts/europe/2026/hamburg-port-dues.md' },
{ path: '/playbooks/hamburg-strike-plan.md' },
]);
expect(
filterNavigatorLeaves(nodes, 'hamburg').map((m) => m.parentPath),
).toEqual(['/contracts/europe/2026', '/playbooks']);
expect(filterNavigatorLeaves(nodes, 'index')[0].parentPath).toBe('');
});
});
@@ -1,3 +1,5 @@
import type { NavigatorNode } from './tree/navigatorUtils';
export interface WikiPageNode {
path: string;
title?: string | null;
@@ -65,3 +67,78 @@ export async function saveWikiPage(
if (response.status === 403) return { status: 'forbidden' };
return { status: 'error' };
}
const ROOT_INDEX = /^\/?index\.md$/i;
/**
* The name a wiki page shows in the pages column: its stored title, else its
* file name without `.md`, dashes and underscores as spaces, in sentence case.
* The root `index.md` is the wiki's home page.
*
* @param page The page (only `path` and `title` are read).
* @param homeLabel The translated name of the root index page.
* @returns The label.
*/
export function wikiPageLabel(
page: Pick<WikiPageNode, 'path' | 'title'>,
homeLabel = 'Home',
): string {
const title = page.title?.trim();
if (title) return title;
if (ROOT_INDEX.test(page.path)) return homeLabel;
const file = page.path.split('/').filter(Boolean).pop() ?? page.path;
const words = file.replace(/\.md$/i, '').replace(/[-_]+/g, ' ').trim();
if (!words) return page.path;
return words.charAt(0).toUpperCase() + words.slice(1);
}
/**
* Group wiki pages for the navigator: pages at the root first (the home page
* leading), then one group per top-level folder holding every page under it.
*
* @param pages The wiki's pages.
* @param homeLabel The translated name of the root index page.
* @returns Navigator nodes whose leaf ids are the page paths.
*/
export function buildWikiNavigator(
pages: Pick<WikiPageNode, 'path' | 'title'>[],
homeLabel = 'Home',
): NavigatorNode[] {
const roots: NavigatorNode[] = [];
const groups = new Map<string, NavigatorNode[]>();
for (const page of pages) {
const parts = page.path.split('/').filter(Boolean);
const leaf: NavigatorNode = {
id: page.path,
kind: 'leaf',
label: wikiPageLabel(page, homeLabel),
path: page.path,
};
if (parts.length <= 1) {
roots.push(leaf);
} else {
const list = groups.get(parts[0]) ?? [];
list.push(leaf);
groups.set(parts[0], list);
}
}
const isHome = (node: NavigatorNode) => ROOT_INDEX.test(node.path);
roots.sort(
(a, b) =>
Number(isHome(b)) - Number(isHome(a)) || a.label.localeCompare(b.label),
);
const folders = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, children]): NavigatorNode => {
children.sort((a, b) => a.path.localeCompare(b.path));
return {
id: `folder:/${name}`,
kind: 'folder',
label: name,
path: `/${name}`,
count: children.length,
children,
};
});
return [...roots, ...folders];
}
+2 -31
View File
@@ -12,7 +12,7 @@ import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import { markdownHeadings } from '@/lib/markdown';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import CopyButton from '../components/CopyButton';
import MermaidRenderer from '../components/MermaidRenderer';
@@ -203,6 +203,7 @@ export default function MarkdownAnswer({
urlTransform={sandboxUrlTransform}
components={{
...markdownHeadings,
...markdownTables,
a({ href, children }) {
// A generated file is already on the turn as a download
// chip, but the model links it with a `sandbox:`/`artifact:`
@@ -351,36 +352,6 @@ export default function MarkdownAnswer({
</ol>
);
},
table({ children }) {
return (
<div className="border-border relative overflow-x-auto rounded-lg border">
{/* eslint-disable-next-line no-restricted-syntax -- the model's own markdown table, styled by the answer (DESIGN.md, Approved exceptions) */}
<table className="text-foreground w-full text-left">
{children}
</table>
</div>
);
},
thead({ children }) {
return (
<thead className="bg-muted text-foreground text-xs uppercase">
{children}
</thead>
);
},
tr({ children }) {
return (
<tr className="border-border odd:bg-card even:bg-muted border-b">
{children}
</tr>
);
},
th({ children }) {
return <th className="px-6 py-3">{children}</th>;
},
td({ children }) {
return <td className="px-6 py-3">{children}</td>;
},
}}
>
{segment.content}
+89 -1
View File
@@ -35,6 +35,8 @@ import {
Trash2,
Undo2,
Redo2,
File,
Folder,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
@@ -1702,6 +1704,34 @@ export default function DesignSystem() {
</ListRows>
</Card>
</Example>
<Example
title="Dense rows (side panel)"
code='<ul className="-mx-2"><ListRow size="sm" interactive asChild leading title description>'
>
<ul className="-mx-2 flex max-w-80 flex-col">
{[
['Oslo Freight Terminal', 'operates · ships via', 'bg-chart-2'],
['Meridian Freight Group', 'contracted by', 'bg-chart-1'],
].map(([name, meta, dot]) => (
<ListRow
key={name}
size="sm"
interactive
asChild
leading={
<span
aria-hidden="true"
className={`mt-1.5 size-2 shrink-0 rounded-full ${dot}`}
/>
}
title={name}
description={meta}
>
<button type="button" />
</ListRow>
))}
</ul>
</Example>
<Example
title="Key/value rows"
code='<DescriptionList layout="columns | justified" size="sm | xs"><DescriptionItem label mono>'
@@ -1728,7 +1758,7 @@ export default function DesignSystem() {
</Example>
<Example
title="Pager"
code='<Pagination page pageCount onPageChange pageSize? summary? labels="icons | text">'
code='<Pagination page pageCount onPageChange pageSize? pageSizeLabel? summary? labels="icons | text">'
>
<div className="flex flex-col gap-4">
<Pagination
@@ -1744,6 +1774,15 @@ export default function DesignSystem() {
onPageChange={setPagerPage}
summary="1,024 users"
/>
<Pagination
page={pagerPage}
pageCount={9}
onPageChange={setPagerPage}
pageSize={12}
pageSizeOptions={[12, 24, 48]}
pageSizeLabel="Chunks per page"
onPageSizeChange={() => undefined}
/>
</div>
</Example>
<Example
@@ -2623,6 +2662,35 @@ export default function DesignSystem() {
</Table>
</TableContainer>
</Example>
<Example
title="Clickable rows, one open beside the table"
code="<TableRow selected onClick={…}>"
>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableHeader>Entity</TableHeader>
<TableHeader align="right">Connections</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{TABLE_ROWS.map((row, index) => (
<TableRow
key={row.name}
selected={index === 1}
onClick={() => undefined}
>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell align="right" className="tabular-nums">
{row.runs}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Example>
</Section>
<Section
@@ -3053,6 +3121,26 @@ export default function DesignSystem() {
</div>
</div>
</Example>
<Example
title="Filter field over a list (source navigator)"
code='<CommandInput variant="field" placeholder="Filter files" />'
>
<div className="flex w-64 flex-col gap-2">
<Command className="contents">
<CommandInput variant="field" placeholder="Filter files" />
<CommandList>
<CommandItem>
<Folder />
carriers
</CommandItem>
<CommandItem checked>
<File />
rate-card-2026.pdf
</CommandItem>
</CommandList>
</Command>
</div>
</Example>
<Example
title="Chosen item in a picker"
code="<CommandItem checked={item.id === selectedId}>"
+54 -1
View File
@@ -1,8 +1,9 @@
import { renderToStaticMarkup } from 'react-dom/server';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { describe, expect, it } from 'vitest';
import { markdownHeadings } from './markdown';
import { markdownHeadings, markdownTables } from './markdown';
describe('markdownHeadings', () => {
it('maps h1-h3 to the shared semibold sizes with margins', () => {
@@ -22,3 +23,55 @@ describe('markdownHeadings', () => {
);
});
});
const TABLE = `| Lane | Carrier |
| --- | --- |
| Rotterdam – Oslo | Baltic Star |
| Rotterdam – Milan | Rhinehaul |`;
const render = () => {
const host = document.createElement('div');
host.innerHTML = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownTables}>
{TABLE}
</ReactMarkdown>,
);
return host;
};
describe('markdownTables', () => {
// One table style across the app: markdown tables (chat answers, wiki
// pages, chunks, previews) are the ui/table parts, not a copy of them.
it('renders the ui/table parts in their bordered, scrolling frame', () => {
const host = render();
expect(host.querySelector('[data-slot="table-container"]')).not.toBeNull();
expect(host.querySelector('[data-slot="table"]')).not.toBeNull();
expect(host.querySelector('thead')!.dataset.slot).toBe('table-head');
expect(host.querySelectorAll('[data-slot="table-header"]')).toHaveLength(2);
expect(host.querySelectorAll('[data-slot="table-cell"]')).toHaveLength(4);
expect(host.querySelector('th')!.textContent).toBe('Lane');
});
it('keeps the GFM column alignment', () => {
const host = document.createElement('div');
host.innerHTML = renderToStaticMarkup(
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownTables}>
{'| A | B | C |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |'}
</ReactMarkdown>,
);
const cells = host.querySelectorAll('[data-slot="table-cell"]');
expect(cells[0].className).toContain('text-left');
expect(cells[1].className).toContain('text-center');
expect(cells[2].className).toContain('text-right');
expect(
host.querySelectorAll('[data-slot="table-header"]')[2].className,
).toContain('text-right');
});
it('keeps the authors’ capitals and lets a narrow table shrink', () => {
const host = render();
expect(host.innerHTML).not.toContain('uppercase');
expect(host.innerHTML).not.toContain('even:bg-muted');
expect(host.querySelector('table')!.className).toContain('min-w-0');
});
});
+52
View File
@@ -1,5 +1,16 @@
import type { CSSProperties } from 'react';
import type { Components } from 'react-markdown';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
/**
* Heading renderers shared by every markdown surface (chat answers, the wiki
* view, note artifacts, document previews). Spread into `components`.
@@ -15,3 +26,44 @@ export const markdownHeadings: Components = {
<h3 className="mt-3 mb-2 text-base font-semibold">{children}</h3>
),
};
/**
* A GFM column's alignment (`:---:`, `---:`), which react-markdown hands a
* cell as `style.textAlign`.
*
* @param style The cell's style prop.
* @returns The `ui/table` align value, or undefined for the default.
*/
function cellAlign(
style: CSSProperties | undefined,
): 'left' | 'right' | 'center' | undefined {
const align = style?.textAlign;
return align === 'left' || align === 'right' || align === 'center'
? align
: undefined;
}
/**
* Table renderers shared by every markdown surface (chat answers, wiki pages,
* chunks, note artifacts, document previews): the `ui/table` parts, so a
* markdown table looks like every other table in the app. The bordered frame
* scrolls sideways, so a wide table never widens the column, and a narrow
* one shrinks to fit (`min-w-0`). Spread into `components` after
* `markdownHeadings`.
*/
export const markdownTables: Components = {
table: ({ children }) => (
<TableContainer>
<Table minWidth="min-w-0">{children}</Table>
</TableContainer>
),
thead: ({ children }) => <TableHead>{children}</TableHead>,
tbody: ({ children }) => <TableBody>{children}</TableBody>,
tr: ({ children }) => <TableRow>{children}</TableRow>,
th: ({ children, style }) => (
<TableHeader align={cellAlign(style)}>{children}</TableHeader>
),
td: ({ children, style }) => (
<TableCell align={cellAlign(style)}>{children}</TableCell>
),
};
+95 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "Vorgeladen",
"private": "Privat",
"sync": "Synchronisieren",
"syncDone": "Fertig",
"syncNow": "Jetzt synchronisieren",
"syncing": "Synchronisiere...",
"reingest": "Erneut indexieren",
@@ -131,14 +132,10 @@
"selectPage": "Wähle eine Seite aus, um ihren Inhalt anzuzeigen.",
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"saveFailed": "Die Seite konnte nicht gespeichert werden. Bitte versuche es erneut.",
"forbidden": "Du hast keine Berechtigung, diese Seite zu bearbeiten.",
"conflict": "Diese Seite wurde seit dem Öffnen geändert und neu geladen. Deine Änderungen stehen unten – übernimm sie erneut und speichere noch einmal.",
"editPlaceholder": "Schreibe die Seite in Markdown…",
"livingTitle": "Lebendiges Wiki.",
"livingExplainer": "Eine von Agenten bearbeitbare Quelle: Seiten werden gelesen und umgeschrieben, während Agenten arbeiten, und sie ist Agenten standardmäßig über das agentische Suchwerkzeug zugänglich.",
"livingExplainerEditable": "Jeder Agent, der diese Quelle verwendet, kann sie bearbeiten.",
"stamp": {
"editedBy": "Bearbeitet von {{who}}",
"agent": "Agent",
@@ -168,7 +165,18 @@
"forbidden": "Du hast keine Berechtigung, diese Quelle umzuwandeln.",
"conflict": "Diese Quelle wird noch indexiert. Warte, bis der Vorgang abgeschlossen ist, bevor du sie umwandelst."
}
}
},
"badge": "Lebendes Wiki",
"byline_one": "{{pages}} Seite · {{tokens}} Tokens",
"byline_other": "{{pages}} Seiten · {{tokens}} Tokens",
"explainer": "Agenten lesen und überarbeiten diese Seiten bei ihrer Arbeit.",
"explainerEditable": "Agenten lesen und überarbeiten diese Seiten bei ihrer Arbeit; jeder Agent, der diese Quelle nutzt, kann sie bearbeiten.",
"home": "Startseite",
"filterPages": "Seiten filtern",
"pagesTitle": "Seiten",
"loadFailed": "Die Wiki-Seiten konnten nicht geladen werden.",
"pageLoadFailed": "Diese Seite konnte nicht geladen werden.",
"editTitle": "Seite bearbeiten"
},
"graphrag": {
"badge": "GraphRAG",
@@ -201,13 +209,56 @@
"view": {
"action": "Graph anzeigen",
"title": "Wissensgraph",
"explainer": "Knoten sind Entitäten, Kanten ihre Beziehungen. Klicke auf einen Knoten, um seine Beschreibung und die Chunks zu sehen, aus denen er extrahiert wurde.",
"stats": "{{nodes}} Entitäten · {{edges}} Beziehungen",
"stats": "{{entities}} · {{relationships}}",
"empty": "Noch kein Graph – die Extraktion läuft möglicherweise noch.",
"selectNode": "Wähle einen Knoten aus, um seine Details zu sehen.",
"linkedChunks": "Verknüpfte Chunks",
"noChunks": "Keine verknüpften Chunks für diese Entität.",
"close": "Schließen"
"close": "Schließen",
"tabs": {
"graph": "Graph",
"entities": "Entitäten",
"files": "Dateien"
},
"findEntity": "Entität suchen",
"entityName": "Name",
"noEntities": "Keine Entitäten gefunden",
"showTop": "Top anzeigen",
"byConnections": "nach Verbindungen",
"typeFilter": "Nach Typ filtern",
"otherTypes": "Sonstige: {{types}}",
"otherTypesMore_one": "{{formatted}} weitere",
"otherTypesMore_other": "{{formatted}} weitere",
"untyped": "Ohne Typ",
"loadFailed": "Der Graph konnte nicht geladen werden.",
"entitiesLoadFailed": "Die Entitäten konnten nicht geladen werden.",
"nodeLoadFailed": "Diese Entität konnte nicht geladen werden.",
"loadingNode": "Entität wird geladen",
"connections": "Verbindungen",
"mentionedIn": "Erwähnt in",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen",
"relationships": "Beziehungen",
"relationshipsCapped_one": "Die {{shown}} stärksten von {{total}} Beziehung.",
"relationshipsCapped_other": "Die {{shown}} stärksten von {{total}} Beziehungen.",
"noRelationships": "Keine Beziehungen für diese Entität.",
"relatedTo": "verbunden mit",
"showAll": "Alle {{formatted}} anzeigen",
"showFewer": "Weniger anzeigen",
"sourceChunks": "Quell-Chunks",
"showInGraph": "Im Graph zeigen",
"allTypes": "Alle Typen",
"chunkCount_one": "{{formatted}} Chunk",
"chunkCount_other": "{{formatted}} Chunks",
"entityCount_one": "{{formatted}} Entität",
"entityCount_other": "{{formatted}} Entitäten",
"relationshipCount_one": "{{formatted}} Beziehung",
"relationshipCount_other": "{{formatted}} Beziehungen",
"chunk": "Chunk",
"chunkMeta": "{{file}} · {{tokens}} Tokens",
"chunkTokens": "{{tokens}} Tokens",
"openInFiles": "In Dateien öffnen",
"editChunk": "Chunk bearbeiten",
"chunkSaved": "Chunk gespeichert"
}
},
"testRetrieval": {
@@ -240,14 +291,11 @@
"shareWithTeam": "Mit Team teilen",
"deleteWarning": "Bist du sicher, dass du \"{{name}}\" löschen möchtest?",
"confirmDelete": "Bist du sicher, dass du diese Datei löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"backToAll": "Zurück zu allen Quellen",
"back": "Zurück",
"chunks": "Chunks",
"noChunks": "Keine Chunks gefunden",
"noChunksAlt": "Keine Chunks gefunden",
"goToSources": "Zu den Quellen",
"uploadNew": "Neu hochladen",
"searchFiles": "Dateien suchen...",
"noResults": "Keine Ergebnisse gefunden",
"fileName": "Name",
"tokens": "Tokens",
@@ -354,7 +402,39 @@
"forbidden": "Du hast keine Berechtigung, die Konfiguration dieser Quelle zu bearbeiten.",
"saveFailed": "Konfiguration konnte nicht gespeichert werden. Bitte versuche es erneut."
}
}
},
"chunkErrors": {
"add": "Der Chunk konnte nicht hinzugefügt werden",
"save": "Der Chunk konnte nicht gespeichert werden",
"delete": "Der Chunk konnte nicht gelöscht werden",
"load": "Die Chunks konnten nicht geladen werden"
},
"chunkPosition": "Chunk {{n}} von {{total}} · {{tokens}} Tokens",
"chunkCrumb": "Chunk {{n}}",
"chunkTileMeta": "#{{n}} · {{tokens}} Tokens",
"copyText": "Text kopieren",
"chunkTitleHint": "Der Name, unter dem Antworten diesen Chunk zitieren.",
"files": "Dateien",
"filesLoadError": "Die Dateien konnten nicht geladen werden",
"filesByline_one": "{{files}} Datei · {{tokens}} Tokens",
"filesByline_other": "{{files}} Dateien · {{tokens}} Tokens",
"filterFiles": "Dateien filtern",
"searchChunks": "Chunks durchsuchen",
"chunkCount_one": "{{formatted}} Chunk",
"chunkCount_other": "{{formatted}} Chunks",
"editor": {
"write": "Schreiben",
"preview": "Vorschau",
"previewEmpty": "Noch nichts für die Vorschau.",
"cancel": "Abbrechen",
"close": "Schließen",
"discardMessage": "Ungespeicherte Änderungen verwerfen?",
"discard": "Verwerfen"
},
"editChunk": "Chunk bearbeiten",
"editChunkDescription": "{{file}} · Chunk {{n}} · {{tokens}} Tokens",
"previousChunk": "Vorheriger Chunk",
"nextChunk": "Nächster Chunk"
},
"analytics": {
"label": "Analytik",
@@ -1252,7 +1332,6 @@
"promptText": "Prompt-Text",
"save": "Speichern",
"close": "Schließen",
"cancel": "Abbrechen",
"delete": "Löschen",
"deleteConfirmation": "Bist du sicher, dass du diesen Chunk löschen möchtest?"
},
@@ -1349,7 +1428,8 @@
"firstPage": "Erste Seite",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"lastPage": "Letzte Seite"
"lastPage": "Letzte Seite",
"chunksPerPage": "Chunks pro Seite"
},
"conversation": {
"copy": "Kopieren",
+95 -15
View File
@@ -115,6 +115,7 @@
"preLoaded": "Pre-loaded",
"private": "Private",
"sync": "Sync",
"syncDone": "Done",
"syncNow": "Sync now",
"syncing": "Syncing...",
"reingest": "Reingest",
@@ -136,14 +137,10 @@
"selectPage": "Select a page to view its contents.",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"saveFailed": "Could not save the page. Please try again.",
"forbidden": "You do not have permission to edit this page.",
"conflict": "This page changed since you opened it and has been reloaded. Your edits are below — reapply and save again.",
"editPlaceholder": "Write the page in Markdown…",
"livingTitle": "Living wiki.",
"livingExplainer": "An agent-editable source: pages are read and rewritten as agents work, and it is exposed to agents through the agentic search tool by default.",
"livingExplainerEditable": "Any agent using this source can edit it.",
"stamp": {
"editedBy": "Edited by {{who}}",
"agent": "agent",
@@ -173,7 +170,18 @@
"forbidden": "You don't have permission to convert this source.",
"conflict": "This source is still ingesting. Wait for it to finish before converting."
}
}
},
"badge": "Living wiki",
"byline_one": "{{pages}} page · {{tokens}} tokens",
"byline_other": "{{pages}} pages · {{tokens}} tokens",
"explainer": "Agents read and rewrite these pages as they work.",
"explainerEditable": "Agents read and rewrite these pages as they work; any agent using this source can edit it.",
"home": "Home",
"filterPages": "Filter pages",
"pagesTitle": "Pages",
"loadFailed": "Could not load the wiki pages.",
"pageLoadFailed": "Could not load this page.",
"editTitle": "Edit page"
},
"graphrag": {
"badge": "GraphRAG",
@@ -206,13 +214,56 @@
"view": {
"action": "View graph",
"title": "Knowledge graph",
"explainer": "Nodes are entities and edges are their relationships. Click a node to see its description and the chunks it was extracted from.",
"stats": "{{nodes}} entities · {{edges}} relationships",
"stats": "{{entities}} · {{relationships}}",
"empty": "No graph yet — extraction may still be running.",
"selectNode": "Select a node to see its details.",
"linkedChunks": "Linked chunks",
"noChunks": "No linked chunks for this entity.",
"close": "Close"
"close": "Close",
"tabs": {
"graph": "Graph",
"entities": "Entities",
"files": "Files"
},
"findEntity": "Find an entity",
"entityName": "Name",
"noEntities": "No entities found",
"showTop": "Show top",
"byConnections": "by connections",
"typeFilter": "Filter by type",
"otherTypes": "Other: {{types}}",
"otherTypesMore_one": "{{formatted}} more",
"otherTypesMore_other": "{{formatted}} more",
"untyped": "Untyped",
"loadFailed": "Couldn't load the graph.",
"entitiesLoadFailed": "Couldn't load the entities.",
"nodeLoadFailed": "Couldn't load this entity.",
"loadingNode": "Loading entity",
"connections": "Connections",
"mentionedIn": "Mentioned in",
"showMore": "Show more",
"showLess": "Show less",
"relationships": "Relationships",
"relationshipsCapped_one": "Showing the {{shown}} strongest of {{total}} relationship.",
"relationshipsCapped_other": "Showing the {{shown}} strongest of {{total}} relationships.",
"noRelationships": "No relationships for this entity.",
"relatedTo": "related to",
"showAll": "Show all {{formatted}}",
"showFewer": "Show fewer",
"sourceChunks": "Source chunks",
"showInGraph": "Show in graph",
"allTypes": "All types",
"chunkCount_one": "{{formatted}} chunk",
"chunkCount_other": "{{formatted}} chunks",
"entityCount_one": "{{formatted}} entity",
"entityCount_other": "{{formatted}} entities",
"relationshipCount_one": "{{formatted}} relationship",
"relationshipCount_other": "{{formatted}} relationships",
"chunk": "Chunk",
"chunkMeta": "{{file}} · {{tokens}} tokens",
"chunkTokens": "{{tokens}} tokens",
"openInFiles": "Open in Files",
"editChunk": "Edit chunk",
"chunkSaved": "Chunk saved"
}
},
"testRetrieval": {
@@ -245,14 +296,11 @@
"shareWithTeam": "Share with team",
"deleteWarning": "Are you sure you want to delete \"{{name}}\"?",
"confirmDelete": "Are you sure you want to delete this file? This action cannot be undone.",
"backToAll": "Back to all sources",
"back": "Back",
"chunks": "Chunks",
"noChunks": "No chunks found",
"noChunksAlt": "No chunks found",
"goToSources": "Go to Sources",
"uploadNew": "Upload new",
"searchFiles": "Search files...",
"noResults": "No results found",
"fileName": "Name",
"tokens": "Tokens",
@@ -359,7 +407,39 @@
"forbidden": "You don't have permission to edit this source's config.",
"saveFailed": "Failed to save config. Please try again."
}
}
},
"chunkErrors": {
"add": "Could not add the chunk",
"save": "Could not save the chunk",
"delete": "Could not delete the chunk",
"load": "Could not load the chunks"
},
"chunkPosition": "Chunk {{n}} of {{total}} · {{tokens}} tokens",
"chunkCrumb": "Chunk {{n}}",
"chunkTileMeta": "#{{n}} · {{tokens}} tokens",
"copyText": "Copy text",
"chunkTitleHint": "The name answers cite this chunk by.",
"files": "Files",
"filesLoadError": "Could not load the files",
"filesByline_one": "{{files}} file · {{tokens}} tokens",
"filesByline_other": "{{files}} files · {{tokens}} tokens",
"filterFiles": "Filter files",
"searchChunks": "Search chunks",
"chunkCount_one": "{{formatted}} chunk",
"chunkCount_other": "{{formatted}} chunks",
"editor": {
"write": "Write",
"preview": "Preview",
"previewEmpty": "Nothing to preview yet.",
"cancel": "Cancel",
"close": "Close",
"discardMessage": "Discard your unsaved changes?",
"discard": "Discard"
},
"editChunk": "Edit chunk",
"editChunkDescription": "{{file}} · chunk {{n}} · {{tokens}} tokens",
"previousChunk": "Previous chunk",
"nextChunk": "Next chunk"
},
"analytics": {
"label": "Analytics",
@@ -1258,7 +1338,6 @@
"promptText": "Prompt Text",
"save": "Save",
"close": "Close",
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirmation": "Are you sure you want to delete this chunk?"
},
@@ -1355,7 +1434,8 @@
"firstPage": "First page",
"previousPage": "Previous page",
"nextPage": "Next page",
"lastPage": "Last page"
"lastPage": "Last page",
"chunksPerPage": "Chunks per page"
},
"conversation": {
"copy": "Copy",
+95 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "Precargado",
"private": "Privado",
"sync": "Sincronizar",
"syncDone": "Listo",
"syncNow": "Sincronizar ahora",
"syncing": "Sincronizando...",
"reingest": "Reindexar",
@@ -131,14 +132,10 @@
"selectPage": "Selecciona una página para ver su contenido.",
"edit": "Editar",
"save": "Guardar",
"cancel": "Cancelar",
"saveFailed": "No se pudo guardar la página. Inténtalo de nuevo.",
"forbidden": "No tienes permiso para editar esta página.",
"conflict": "Esta página cambió desde que la abriste y se ha recargado. Tus ediciones están abajo — vuelve a aplicarlas y guarda de nuevo.",
"editPlaceholder": "Escribe la página en Markdown…",
"livingTitle": "Wiki viva.",
"livingExplainer": "Una fuente editable por agentes: las páginas se leen y reescriben mientras los agentes trabajan, y se expone a los agentes de forma predeterminada a través de la herramienta de búsqueda agéntica.",
"livingExplainerEditable": "Cualquier agente que use esta fuente puede editarla.",
"stamp": {
"editedBy": "Editado por {{who}}",
"agent": "agente",
@@ -168,7 +165,18 @@
"forbidden": "No tienes permiso para convertir esta fuente.",
"conflict": "Esta fuente todavía se está indexando. Espera a que termine antes de convertirla."
}
}
},
"badge": "Wiki vivo",
"byline_one": "{{pages}} página · {{tokens}} tokens",
"byline_other": "{{pages}} páginas · {{tokens}} tokens",
"explainer": "Los agentes leen y reescriben estas páginas mientras trabajan.",
"explainerEditable": "Los agentes leen y reescriben estas páginas mientras trabajan; cualquier agente que use esta fuente puede editarla.",
"home": "Inicio",
"filterPages": "Filtrar páginas",
"pagesTitle": "Páginas",
"loadFailed": "No se pudieron cargar las páginas del wiki.",
"pageLoadFailed": "No se pudo cargar esta página.",
"editTitle": "Editar página"
},
"graphrag": {
"badge": "GraphRAG",
@@ -201,13 +209,56 @@
"view": {
"action": "Ver grafo",
"title": "Grafo de conocimiento",
"explainer": "Los nodos son entidades y las aristas son sus relaciones. Haz clic en un nodo para ver su descripción y los fragmentos de los que se extrajo.",
"stats": "{{nodes}} entidades · {{edges}} relaciones",
"stats": "{{entities}} · {{relationships}}",
"empty": "Aún no hay grafo — la extracción puede seguir en curso.",
"selectNode": "Selecciona un nodo para ver sus detalles.",
"linkedChunks": "Fragmentos vinculados",
"noChunks": "No hay fragmentos vinculados para esta entidad.",
"close": "Cerrar"
"close": "Cerrar",
"tabs": {
"graph": "Grafo",
"entities": "Entidades",
"files": "Archivos"
},
"findEntity": "Buscar una entidad",
"entityName": "Nombre",
"noEntities": "No se encontraron entidades",
"showTop": "Mostrar los",
"byConnections": "por conexiones",
"typeFilter": "Filtrar por tipo",
"otherTypes": "Otros: {{types}}",
"otherTypesMore_one": "{{formatted}} más",
"otherTypesMore_other": "{{formatted}} más",
"untyped": "Sin tipo",
"loadFailed": "No se pudo cargar el grafo.",
"entitiesLoadFailed": "No se pudieron cargar las entidades.",
"nodeLoadFailed": "No se pudo cargar esta entidad.",
"loadingNode": "Cargando entidad",
"connections": "Conexiones",
"mentionedIn": "Mencionada en",
"showMore": "Mostrar más",
"showLess": "Mostrar menos",
"relationships": "Relaciones",
"relationshipsCapped_one": "Se muestran las {{shown}} más fuertes de {{total}} relación.",
"relationshipsCapped_other": "Se muestran las {{shown}} más fuertes de {{total}} relaciones.",
"noRelationships": "Esta entidad no tiene relaciones.",
"relatedTo": "relacionada con",
"showAll": "Mostrar las {{formatted}}",
"showFewer": "Mostrar menos",
"sourceChunks": "Fragmentos de origen",
"showInGraph": "Mostrar en el grafo",
"allTypes": "Todos los tipos",
"chunkCount_one": "{{formatted}} fragmento",
"chunkCount_other": "{{formatted}} fragmentos",
"entityCount_one": "{{formatted}} entidad",
"entityCount_other": "{{formatted}} entidades",
"relationshipCount_one": "{{formatted}} relación",
"relationshipCount_other": "{{formatted}} relaciones",
"chunk": "Fragmento",
"chunkMeta": "{{file}} · {{tokens}} tokens",
"chunkTokens": "{{tokens}} tokens",
"openInFiles": "Abrir en Archivos",
"editChunk": "Editar fragmento",
"chunkSaved": "Fragmento guardado"
}
},
"testRetrieval": {
@@ -240,14 +291,11 @@
"shareWithTeam": "Compartir con el equipo",
"deleteWarning": "¿Estás seguro de que deseas eliminar \"{{name}}\"?",
"confirmDelete": "¿Estás seguro de que deseas eliminar este archivo? Esta acción no se puede deshacer.",
"backToAll": "Volver a todas las fuentes",
"back": "Volver",
"chunks": "Fragmentos",
"noChunks": "No se encontraron fragmentos",
"noChunksAlt": "No se encontraron fragmentos",
"goToSources": "Ir a Fuentes",
"uploadNew": "Subir nuevo",
"searchFiles": "Buscar archivos...",
"noResults": "No se encontraron resultados",
"fileName": "Nombre",
"tokens": "Tokens",
@@ -354,7 +402,39 @@
"forbidden": "No tienes permiso para editar la configuración de esta fuente.",
"saveFailed": "No se pudo guardar la configuración. Inténtalo de nuevo."
}
}
},
"chunkErrors": {
"add": "No se pudo añadir el fragmento",
"save": "No se pudo guardar el fragmento",
"delete": "No se pudo eliminar el fragmento",
"load": "No se pudieron cargar los fragmentos"
},
"chunkPosition": "Fragmento {{n}} de {{total}} · {{tokens}} tokens",
"chunkCrumb": "Fragmento {{n}}",
"chunkTileMeta": "#{{n}} · {{tokens}} tokens",
"copyText": "Copiar texto",
"chunkTitleHint": "El nombre con el que las respuestas citan este fragmento.",
"files": "Archivos",
"filesLoadError": "No se pudieron cargar los archivos",
"filesByline_one": "{{files}} archivo · {{tokens}} tokens",
"filesByline_other": "{{files}} archivos · {{tokens}} tokens",
"filterFiles": "Filtrar archivos",
"searchChunks": "Buscar fragmentos",
"chunkCount_one": "{{formatted}} fragmento",
"chunkCount_other": "{{formatted}} fragmentos",
"editor": {
"write": "Escribir",
"preview": "Vista previa",
"previewEmpty": "Aún no hay nada que mostrar.",
"cancel": "Cancelar",
"close": "Cerrar",
"discardMessage": "¿Descartar los cambios sin guardar?",
"discard": "Descartar"
},
"editChunk": "Editar fragmento",
"editChunkDescription": "{{file}} · fragmento {{n}} · {{tokens}} tokens",
"previousChunk": "Fragmento anterior",
"nextChunk": "Fragmento siguiente"
},
"analytics": {
"label": "Analítica",
@@ -1252,7 +1332,6 @@
"promptText": "Texto del prompt",
"save": "Guardar",
"close": "Cerrar",
"cancel": "Cancelar",
"delete": "Eliminar",
"deleteConfirmation": "¿Estás seguro de que deseas eliminar este fragmento?"
},
@@ -1349,7 +1428,8 @@
"firstPage": "Primera página",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
"lastPage": "Última página"
"lastPage": "Última página",
"chunksPerPage": "Fragmentos por página"
},
"conversation": {
"copy": "Copiar",
+87 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "プリロード済み",
"private": "プライベート",
"sync": "同期",
"syncDone": "完了",
"syncNow": "今すぐ同期",
"syncing": "同期中...",
"reingest": "再インデックス",
@@ -131,14 +132,10 @@
"selectPage": "ページを選択して内容を表示します。",
"edit": "編集",
"save": "保存",
"cancel": "キャンセル",
"saveFailed": "ページを保存できませんでした。もう一度お試しください。",
"forbidden": "このページを編集する権限がありません。",
"conflict": "このページは開いた後に変更されたため、再読み込みされました。あなたの編集内容は下にあります。再適用して、もう一度保存してください。",
"editPlaceholder": "Markdownでページを記述…",
"livingTitle": "リビングWiki。",
"livingExplainer": "エージェントが編集可能なソースです。エージェントの作業中にページが読み書きされ、デフォルトでエージェント検索ツールを通じてエージェントに公開されます。",
"livingExplainerEditable": "このソースを使用するすべてのエージェントが編集できます。",
"stamp": {
"editedBy": "{{who}}が編集",
"agent": "エージェント",
@@ -168,7 +165,17 @@
"forbidden": "このソースを変換する権限がありません。",
"conflict": "このソースはまだインデックス作成中です。完了してから変換してください。"
}
}
},
"badge": "リビングWiki",
"byline_other": "{{pages}} ページ · {{tokens}} トークン",
"explainer": "エージェントは作業しながらこれらのページを読み、書き換えます。",
"explainerEditable": "エージェントは作業しながらこれらのページを読み、書き換えます。このソースを使うエージェントは誰でも編集できます。",
"home": "ホーム",
"filterPages": "ページを絞り込む",
"pagesTitle": "ページ",
"loadFailed": "Wikiページを読み込めませんでした。",
"pageLoadFailed": "このページを読み込めませんでした。",
"editTitle": "ページを編集"
},
"graphrag": {
"badge": "GraphRAG",
@@ -201,13 +208,51 @@
"view": {
"action": "グラフを表示",
"title": "ナレッジグラフ",
"explainer": "ノードはエンティティ、エッジはその関係を表します。ノードをクリックすると、説明と抽出元のチャンクが表示されます。",
"stats": "{{nodes}}件のエンティティ · {{edges}}件の関係",
"stats": "{{entities}} · {{relationships}}",
"empty": "グラフはまだありません。抽出が実行中の可能性があります。",
"selectNode": "ノードを選択すると詳細が表示されます。",
"linkedChunks": "リンクされたチャンク",
"noChunks": "このエンティティにリンクされたチャンクはありません。",
"close": "閉じる"
"close": "閉じる",
"tabs": {
"graph": "グラフ",
"entities": "エンティティ",
"files": "ファイル"
},
"findEntity": "エンティティを検索",
"entityName": "名前",
"noEntities": "エンティティが見つかりません",
"showTop": "上位を表示",
"byConnections": "(接続数順)",
"typeFilter": "種類で絞り込む",
"otherTypes": "その他: {{types}}",
"otherTypesMore_other": "他{{formatted}}件",
"untyped": "種類なし",
"loadFailed": "グラフを読み込めませんでした。",
"entitiesLoadFailed": "エンティティを読み込めませんでした。",
"nodeLoadFailed": "このエンティティを読み込めませんでした。",
"loadingNode": "エンティティを読み込み中",
"connections": "接続数",
"mentionedIn": "出現箇所",
"showMore": "もっと見る",
"showLess": "表示を減らす",
"relationships": "関係",
"relationshipsCapped_other": "{{total}}件の関係のうち、強い順に{{shown}}件を表示しています。",
"noRelationships": "このエンティティには関係がありません。",
"relatedTo": "関連",
"showAll": "すべて表示({{formatted}}件)",
"showFewer": "表示を減らす",
"sourceChunks": "ソースチャンク",
"showInGraph": "グラフで表示",
"allTypes": "すべての種類",
"chunkCount_other": "{{formatted}}件のチャンク",
"entityCount_other": "{{formatted}}件のエンティティ",
"relationshipCount_other": "{{formatted}}件の関係",
"chunk": "チャンク",
"chunkMeta": "{{file}} · {{tokens}} トークン",
"chunkTokens": "{{tokens}} トークン",
"openInFiles": "ファイルで開く",
"editChunk": "チャンクを編集",
"chunkSaved": "チャンクを保存しました"
}
},
"testRetrieval": {
@@ -239,14 +284,11 @@
"shareWithTeam": "チームと共有",
"deleteWarning": "\"{{name}}\"を削除してもよろしいですか?",
"confirmDelete": "このファイルを削除してもよろしいですか?この操作は元に戻せません。",
"backToAll": "すべてのソースに戻る",
"back": "戻る",
"chunks": "チャンク",
"noChunks": "チャンクが見つかりません",
"noChunksAlt": "チャンクが見つかりません",
"goToSources": "ソースへ移動",
"uploadNew": "新規アップロード",
"searchFiles": "ファイルを検索...",
"noResults": "結果が見つかりません",
"fileName": "名前",
"tokens": "トークン",
@@ -353,7 +395,37 @@
"forbidden": "このソースの設定を編集する権限がありません。",
"saveFailed": "設定の保存に失敗しました。もう一度お試しください。"
}
}
},
"chunkErrors": {
"add": "チャンクを追加できませんでした",
"save": "チャンクを保存できませんでした",
"delete": "チャンクを削除できませんでした",
"load": "チャンクを読み込めませんでした"
},
"chunkPosition": "チャンク {{n}} / {{total}} · {{tokens}} トークン",
"chunkCrumb": "チャンク {{n}}",
"chunkTileMeta": "#{{n}} · {{tokens}} トークン",
"copyText": "テキストをコピー",
"chunkTitleHint": "回答がこのチャンクを引用するときの名前です。",
"files": "ファイル",
"filesLoadError": "ファイルを読み込めませんでした",
"filesByline_other": "{{files}} ファイル · {{tokens}} トークン",
"filterFiles": "ファイルを絞り込む",
"searchChunks": "チャンクを検索",
"chunkCount_other": "{{formatted}} 件のチャンク",
"editor": {
"write": "編集",
"preview": "プレビュー",
"previewEmpty": "プレビューする内容がまだありません。",
"cancel": "キャンセル",
"close": "閉じる",
"discardMessage": "保存されていない変更を破棄しますか?",
"discard": "破棄"
},
"editChunk": "チャンクを編集",
"editChunkDescription": "{{file}} · チャンク {{n}} · {{tokens}} トークン",
"previousChunk": "前のチャンク",
"nextChunk": "次のチャンク"
},
"analytics": {
"label": "分析",
@@ -1251,7 +1323,6 @@
"promptText": "プロンプトテキスト",
"save": "保存",
"close": "閉じる",
"cancel": "キャンセル",
"delete": "削除",
"deleteConfirmation": "このチャンクを削除してもよろしいですか?"
},
@@ -1347,7 +1418,8 @@
"firstPage": "最初のページ",
"previousPage": "前のページ",
"nextPage": "次のページ",
"lastPage": "最後のページ"
"lastPage": "最後のページ",
"chunksPerPage": "1ページあたりのチャンク数"
},
"conversation": {
"copy": "コピー",
+111 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "Предзагруженный",
"private": "Частный",
"sync": "Синхронизация",
"syncDone": "Готово",
"syncNow": "Синхронизировать сейчас",
"syncing": "Синхронизация...",
"reingest": "Переиндексировать",
@@ -131,14 +132,10 @@
"selectPage": "Выберите страницу, чтобы просмотреть её содержимое.",
"edit": "Редактировать",
"save": "Сохранить",
"cancel": "Отмена",
"saveFailed": "Не удалось сохранить страницу. Пожалуйста, попробуйте ещё раз.",
"forbidden": "У вас нет прав на редактирование этой страницы.",
"conflict": "Страница изменилась с момента открытия и была перезагружена. Ваши правки ниже — примените их заново и сохраните ещё раз.",
"editPlaceholder": "Напишите страницу в Markdown…",
"livingTitle": "Живая вики.",
"livingExplainer": "Источник, редактируемый агентами: страницы читаются и переписываются по мере работы агентов, и по умолчанию источник доступен агентам через инструмент агентного поиска.",
"livingExplainerEditable": "Любой агент, использующий этот источник, может его редактировать.",
"stamp": {
"editedBy": "Изменено: {{who}}",
"agent": "агент",
@@ -172,7 +169,20 @@
"forbidden": "У вас нет прав на преобразование этого источника.",
"conflict": "Источник ещё индексируется. Дождитесь завершения, прежде чем преобразовывать."
}
}
},
"badge": "Живая вики",
"byline_one": "Страниц: {{pages}} · токенов: {{tokens}}",
"byline_few": "Страниц: {{pages}} · токенов: {{tokens}}",
"byline_many": "Страниц: {{pages}} · токенов: {{tokens}}",
"byline_other": "Страниц: {{pages}} · токенов: {{tokens}}",
"explainer": "Агенты читают и переписывают эти страницы в ходе работы.",
"explainerEditable": "Агенты читают и переписывают эти страницы в ходе работы; любой агент, использующий этот источник, может его редактировать.",
"home": "Главная",
"filterPages": "Фильтр страниц",
"pagesTitle": "Страницы",
"loadFailed": "Не удалось загрузить страницы вики.",
"pageLoadFailed": "Не удалось загрузить эту страницу.",
"editTitle": "Редактировать страницу"
},
"graphrag": {
"badge": "GraphRAG",
@@ -211,13 +221,66 @@
"view": {
"action": "Просмотр графа",
"title": "Граф знаний",
"explainer": "Узлы — это сущности, а рёбра — их связи. Нажмите на узел, чтобы увидеть его описание и фрагменты, из которых он был извлечён.",
"stats": "{{nodes}} сущностей · {{edges}} связей",
"stats": "{{entities}} · {{relationships}}",
"empty": "Графа пока нет — извлечение, возможно, ещё выполняется.",
"selectNode": "Выберите узел, чтобы увидеть подробности.",
"linkedChunks": "Связанные фрагменты",
"noChunks": "Для этой сущности нет связанных фрагментов.",
"close": "Закрыть"
"close": "Закрыть",
"tabs": {
"graph": "Граф",
"entities": "Сущности",
"files": "Файлы"
},
"findEntity": "Найти сущность",
"entityName": "Название",
"noEntities": "Сущности не найдены",
"showTop": "Показать топ",
"byConnections": "по числу связей",
"typeFilter": "Фильтр по типу",
"otherTypes": "Прочие: {{types}}",
"otherTypesMore_one": "ещё {{formatted}}",
"otherTypesMore_few": "ещё {{formatted}}",
"otherTypesMore_many": "ещё {{formatted}}",
"otherTypesMore_other": "ещё {{formatted}}",
"untyped": "Без типа",
"loadFailed": "Не удалось загрузить граф.",
"entitiesLoadFailed": "Не удалось загрузить сущности.",
"nodeLoadFailed": "Не удалось загрузить эту сущность.",
"loadingNode": "Загрузка сущности",
"connections": "Связи",
"mentionedIn": "Упоминается в",
"showMore": "Показать больше",
"showLess": "Показать меньше",
"relationships": "Связи с сущностями",
"relationshipsCapped_one": "Показаны {{shown}} самых сильных из {{total}} связи.",
"relationshipsCapped_few": "Показаны {{shown}} самых сильных из {{total}} связей.",
"relationshipsCapped_many": "Показаны {{shown}} самых сильных из {{total}} связей.",
"relationshipsCapped_other": "Показаны {{shown}} самых сильных из {{total}} связи.",
"noRelationships": "У этой сущности нет связей.",
"relatedTo": "связана с",
"showAll": "Показать все ({{formatted}})",
"showFewer": "Показать меньше",
"sourceChunks": "Исходные фрагменты",
"showInGraph": "Показать на графе",
"allTypes": "Все типы",
"chunkCount_one": "{{formatted}} фрагменте",
"chunkCount_few": "{{formatted}} фрагментах",
"chunkCount_many": "{{formatted}} фрагментах",
"chunkCount_other": "{{formatted}} фрагментах",
"entityCount_one": "{{formatted}} сущность",
"entityCount_few": "{{formatted}} сущности",
"entityCount_many": "{{formatted}} сущностей",
"entityCount_other": "{{formatted}} сущности",
"relationshipCount_one": "{{formatted}} связь",
"relationshipCount_few": "{{formatted}} связи",
"relationshipCount_many": "{{formatted}} связей",
"relationshipCount_other": "{{formatted}} связи",
"chunk": "Фрагмент",
"chunkMeta": "{{file}} · токенов: {{tokens}}",
"chunkTokens": "токенов: {{tokens}}",
"openInFiles": "Открыть в файлах",
"editChunk": "Редактировать фрагмент",
"chunkSaved": "Фрагмент сохранён"
}
},
"testRetrieval": {
@@ -252,14 +315,11 @@
"shareWithTeam": "Поделиться с командой",
"deleteWarning": "Вы уверены, что хотите удалить \"{{name}}\"?",
"confirmDelete": "Вы уверены, что хотите удалить этот файл? Это действие нельзя отменить.",
"backToAll": "Вернуться ко всем источникам",
"back": "Назад",
"chunks": "Фрагменты",
"noChunks": "Фрагменты не найдены",
"noChunksAlt": "Фрагменты не найдены",
"goToSources": "Перейти к источникам",
"uploadNew": "Загрузить новый",
"searchFiles": "Поиск файлов...",
"noResults": "Результаты не найдены",
"fileName": "Имя",
"tokens": "Токены",
@@ -366,7 +426,43 @@
"forbidden": "У вас нет прав на изменение настроек этого источника.",
"saveFailed": "Не удалось сохранить настройки. Пожалуйста, попробуйте ещё раз."
}
}
},
"chunkErrors": {
"add": "Не удалось добавить фрагмент",
"save": "Не удалось сохранить фрагмент",
"delete": "Не удалось удалить фрагмент",
"load": "Не удалось загрузить фрагменты"
},
"chunkPosition": "Фрагмент {{n}} из {{total}} · токенов: {{tokens}}",
"chunkCrumb": "Фрагмент {{n}}",
"chunkTileMeta": "#{{n}} · токенов: {{tokens}}",
"copyText": "Копировать текст",
"chunkTitleHint": "Название, под которым ответы ссылаются на этот фрагмент.",
"files": "Файлы",
"filesLoadError": "Не удалось загрузить файлы",
"filesByline_one": "Файлов: {{files}} · токенов: {{tokens}}",
"filesByline_few": "Файлов: {{files}} · токенов: {{tokens}}",
"filesByline_many": "Файлов: {{files}} · токенов: {{tokens}}",
"filesByline_other": "Файлов: {{files}} · токенов: {{tokens}}",
"filterFiles": "Фильтр файлов",
"searchChunks": "Поиск по фрагментам",
"chunkCount_one": "{{formatted}} фрагмент",
"chunkCount_few": "{{formatted}} фрагмента",
"chunkCount_many": "{{formatted}} фрагментов",
"chunkCount_other": "{{formatted}} фрагмента",
"editor": {
"write": "Редактор",
"preview": "Предпросмотр",
"previewEmpty": "Пока нечего показать.",
"cancel": "Отмена",
"close": "Закрыть",
"discardMessage": "Отменить несохранённые изменения?",
"discard": "Отменить изменения"
},
"editChunk": "Редактировать фрагмент",
"editChunkDescription": "{{file}} · фрагмент {{n}} · токенов: {{tokens}}",
"previousChunk": "Предыдущий фрагмент",
"nextChunk": "Следующий фрагмент"
},
"analytics": {
"label": "Аналитика",
@@ -1292,7 +1388,6 @@
"promptText": "Текст подсказки",
"save": "Сохранить",
"close": "Закрыть",
"cancel": "Отмена",
"delete": "Удалить",
"deleteConfirmation": "Вы уверены, что хотите удалить этот фрагмент?"
},
@@ -1391,7 +1486,8 @@
"firstPage": "Первая страница",
"previousPage": "Предыдущая страница",
"nextPage": "Следующая страница",
"lastPage": "Последняя страница"
"lastPage": "Последняя страница",
"chunksPerPage": "Фрагментов на странице"
},
"conversation": {
"copy": "Копировать",
+87 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "預載入",
"private": "私人",
"sync": "同步",
"syncDone": "完成",
"syncNow": "立即同步",
"syncing": "同步中...",
"reingest": "重新索引",
@@ -131,14 +132,10 @@
"selectPage": "選擇頁面以查看其內容。",
"edit": "編輯",
"save": "儲存",
"cancel": "取消",
"saveFailed": "無法儲存頁面。請再試一次。",
"forbidden": "您沒有編輯此頁面的權限。",
"conflict": "此頁面在您開啟後已變更,並已重新載入。您的編輯內容如下——請重新套用後再次儲存。",
"editPlaceholder": "以 Markdown 撰寫頁面…",
"livingTitle": "動態 Wiki。",
"livingExplainer": "可由代理編輯的來源:代理工作時會讀取並改寫頁面,且預設透過代理搜尋工具向代理開放。",
"livingExplainerEditable": "任何使用此來源的代理都可以編輯它。",
"stamp": {
"editedBy": "由 {{who}} 編輯",
"agent": "代理",
@@ -168,7 +165,17 @@
"forbidden": "您沒有轉換此來源的權限。",
"conflict": "此來源仍在索引中。請等候完成後再轉換。"
}
}
},
"badge": "動態 Wiki",
"byline_other": "{{pages}} 個頁面 · {{tokens}} 個 token",
"explainer": "代理在工作時會閱讀並改寫這些頁面。",
"explainerEditable": "代理在工作時會閱讀並改寫這些頁面;任何使用此來源的代理都可以編輯它。",
"home": "首頁",
"filterPages": "篩選頁面",
"pagesTitle": "頁面",
"loadFailed": "無法載入 Wiki 頁面。",
"pageLoadFailed": "無法載入此頁面。",
"editTitle": "編輯頁面"
},
"graphrag": {
"badge": "GraphRAG",
@@ -201,13 +208,51 @@
"view": {
"action": "查看圖譜",
"title": "知識圖譜",
"explainer": "節點代表實體,邊代表它們之間的關係。點擊節點可查看其描述及擷取來源的區塊。",
"stats": "{{nodes}} 個實體 · {{edges}} 個關係",
"stats": "{{entities}} · {{relationships}}",
"empty": "尚無圖譜——擷取可能仍在進行中。",
"selectNode": "選擇節點以查看其詳細資訊。",
"linkedChunks": "關聯區塊",
"noChunks": "此實體沒有關聯的區塊。",
"close": "關閉"
"close": "關閉",
"tabs": {
"graph": "圖譜",
"entities": "實體",
"files": "檔案"
},
"findEntity": "尋找實體",
"entityName": "名稱",
"noEntities": "找不到實體",
"showTop": "顯示前",
"byConnections": "(依連結數)",
"typeFilter": "依類型篩選",
"otherTypes": "其他:{{types}}",
"otherTypesMore_other": "另外 {{formatted}} 個",
"untyped": "無類型",
"loadFailed": "無法載入圖譜。",
"entitiesLoadFailed": "無法載入實體。",
"nodeLoadFailed": "無法載入此實體。",
"loadingNode": "正在載入實體",
"connections": "連結數",
"mentionedIn": "出現於",
"showMore": "顯示更多",
"showLess": "收合",
"relationships": "關係",
"relationshipsCapped_other": "顯示 {{total}} 個關係中最強的 {{shown}} 個。",
"noRelationships": "此實體沒有關係。",
"relatedTo": "相關",
"showAll": "顯示全部 {{formatted}} 個",
"showFewer": "收合",
"sourceChunks": "來源區塊",
"showInGraph": "在圖譜中顯示",
"allTypes": "所有類型",
"chunkCount_other": "{{formatted}} 個區塊",
"entityCount_other": "{{formatted}} 個實體",
"relationshipCount_other": "{{formatted}} 個關係",
"chunk": "區塊",
"chunkMeta": "{{file}} · {{tokens}} Token",
"chunkTokens": "{{tokens}} Token",
"openInFiles": "在檔案中開啟",
"editChunk": "編輯區塊",
"chunkSaved": "區塊已儲存"
}
},
"testRetrieval": {
@@ -239,14 +284,11 @@
"shareWithTeam": "與團隊分享",
"deleteWarning": "您確定要刪除 \"{{name}}\" 嗎?",
"confirmDelete": "您確定要刪除此檔案嗎?此操作無法復原。",
"backToAll": "返回所有來源",
"back": "返回",
"chunks": "文本塊",
"noChunks": "未找到文本塊",
"noChunksAlt": "未找到文本塊",
"goToSources": "前往來源",
"uploadNew": "上傳新文件",
"searchFiles": "搜尋檔案...",
"noResults": "未找到結果",
"fileName": "名稱",
"tokens": "Token",
@@ -353,7 +395,37 @@
"forbidden": "您沒有編輯此來源設定的權限。",
"saveFailed": "儲存設定失敗。請再試一次。"
}
}
},
"chunkErrors": {
"add": "無法新增文本塊",
"save": "無法儲存文本塊",
"delete": "無法刪除文本塊",
"load": "無法載入文本塊"
},
"chunkPosition": "第 {{n}} 個文本塊,共 {{total}} 個 · {{tokens}} Token",
"chunkCrumb": "第 {{n}} 個文本塊",
"chunkTileMeta": "#{{n}} · {{tokens}} Token",
"copyText": "複製文字",
"chunkTitleHint": "回答引用此文本塊時使用的名稱。",
"files": "檔案",
"filesLoadError": "無法載入檔案",
"filesByline_other": "{{files}} 個檔案 · {{tokens}} Token",
"filterFiles": "篩選檔案",
"searchChunks": "搜尋文本塊",
"chunkCount_other": "{{formatted}} 個文本塊",
"editor": {
"write": "編輯",
"preview": "預覽",
"previewEmpty": "目前沒有可預覽的內容。",
"cancel": "取消",
"close": "關閉",
"discardMessage": "要捨棄未儲存的變更嗎?",
"discard": "捨棄"
},
"editChunk": "編輯文本塊",
"editChunkDescription": "{{file}} · 第 {{n}} 個文本塊 · {{tokens}} Token",
"previousChunk": "上一個文本塊",
"nextChunk": "下一個文本塊"
},
"analytics": {
"label": "分析",
@@ -1251,7 +1323,6 @@
"promptText": "提示文字",
"save": "儲存",
"close": "關閉",
"cancel": "取消",
"delete": "刪除",
"deleteConfirmation": "您確定要刪除此區塊嗎?"
},
@@ -1347,7 +1418,8 @@
"firstPage": "第一頁",
"previousPage": "上一頁",
"nextPage": "下一頁",
"lastPage": "最後一頁"
"lastPage": "最後一頁",
"chunksPerPage": "每頁文本塊數"
},
"conversation": {
"copy": "複製",
+87 -15
View File
@@ -111,6 +111,7 @@
"preLoaded": "预加载",
"private": "私有",
"sync": "同步",
"syncDone": "完成",
"syncNow": "立即同步",
"syncing": "同步中...",
"reingest": "重新索引",
@@ -131,14 +132,10 @@
"selectPage": "选择一个页面以查看其内容。",
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"saveFailed": "无法保存页面,请重试。",
"forbidden": "您没有权限编辑此页面。",
"conflict": "此页面在您打开后已被修改,并已重新加载。您的编辑内容在下方——请重新应用并再次保存。",
"editPlaceholder": "使用 Markdown 编写页面…",
"livingTitle": "动态 Wiki。",
"livingExplainer": "可由代理编辑的来源:代理在工作时会读取和重写页面,并且它默认通过代理搜索工具向代理开放。",
"livingExplainerEditable": "任何使用此来源的代理都可以编辑它。",
"stamp": {
"editedBy": "由{{who}}编辑",
"agent": "代理",
@@ -168,7 +165,17 @@
"forbidden": "您没有权限转换此来源。",
"conflict": "此来源仍在索引中。请等待完成后再转换。"
}
}
},
"badge": "动态 Wiki",
"byline_other": "{{pages}} 个页面 · {{tokens}} 个 token",
"explainer": "代理在工作时会阅读并改写这些页面。",
"explainerEditable": "代理在工作时会阅读并改写这些页面;任何使用此来源的代理都可以编辑它。",
"home": "首页",
"filterPages": "筛选页面",
"pagesTitle": "页面",
"loadFailed": "无法加载 Wiki 页面。",
"pageLoadFailed": "无法加载此页面。",
"editTitle": "编辑页面"
},
"graphrag": {
"badge": "GraphRAG",
@@ -201,13 +208,51 @@
"view": {
"action": "查看图谱",
"title": "知识图谱",
"explainer": "节点表示实体,边表示它们之间的关系。点击节点可查看其描述以及提取它的文本块。",
"stats": "{{nodes}} 个实体 · {{edges}} 个关系",
"stats": "{{entities}} · {{relationships}}",
"empty": "暂无图谱——提取可能仍在进行中。",
"selectNode": "选择一个节点以查看其详情。",
"linkedChunks": "关联的文本块",
"noChunks": "此实体没有关联的文本块。",
"close": "关闭"
"close": "关闭",
"tabs": {
"graph": "图谱",
"entities": "实体",
"files": "文件"
},
"findEntity": "查找实体",
"entityName": "名称",
"noEntities": "未找到实体",
"showTop": "显示前",
"byConnections": "(按连接数)",
"typeFilter": "按类型筛选",
"otherTypes": "其他:{{types}}",
"otherTypesMore_other": "另外 {{formatted}} 个",
"untyped": "无类型",
"loadFailed": "无法加载图谱。",
"entitiesLoadFailed": "无法加载实体。",
"nodeLoadFailed": "无法加载此实体。",
"loadingNode": "正在加载实体",
"connections": "连接数",
"mentionedIn": "出现于",
"showMore": "显示更多",
"showLess": "收起",
"relationships": "关系",
"relationshipsCapped_other": "显示 {{total}} 个关系中最强的 {{shown}} 个。",
"noRelationships": "此实体没有关系。",
"relatedTo": "相关",
"showAll": "显示全部 {{formatted}} 个",
"showFewer": "收起",
"sourceChunks": "来源分块",
"showInGraph": "在图谱中显示",
"allTypes": "所有类型",
"chunkCount_other": "{{formatted}} 个分块",
"entityCount_other": "{{formatted}} 个实体",
"relationshipCount_other": "{{formatted}} 个关系",
"chunk": "分块",
"chunkMeta": "{{file}} · {{tokens}} 个令牌",
"chunkTokens": "{{tokens}} 个令牌",
"openInFiles": "在文件中打开",
"editChunk": "编辑分块",
"chunkSaved": "分块已保存"
}
},
"testRetrieval": {
@@ -239,14 +284,11 @@
"shareWithTeam": "与团队共享",
"deleteWarning": "您确定要删除 \"{{name}}\" 吗?",
"confirmDelete": "您确定要删除此文件吗?此操作无法撤销。",
"backToAll": "返回所有来源",
"back": "返回",
"chunks": "文本块",
"noChunks": "未找到文本块",
"noChunksAlt": "未找到文本块",
"goToSources": "前往来源",
"uploadNew": "上传新文档",
"searchFiles": "搜索文件...",
"noResults": "未找到结果",
"fileName": "名称",
"tokens": "令牌",
@@ -353,7 +395,37 @@
"forbidden": "您没有权限编辑此来源的配置。",
"saveFailed": "保存配置失败,请重试。"
}
}
},
"chunkErrors": {
"add": "无法添加文本块",
"save": "无法保存文本块",
"delete": "无法删除文本块",
"load": "无法加载文本块"
},
"chunkPosition": "第 {{n}} 个文本块,共 {{total}} 个 · {{tokens}} 个令牌",
"chunkCrumb": "第 {{n}} 个文本块",
"chunkTileMeta": "#{{n}} · {{tokens}} 个令牌",
"copyText": "复制文本",
"chunkTitleHint": "回答引用此文本块时使用的名称。",
"files": "文件",
"filesLoadError": "无法加载文件",
"filesByline_other": "{{files}} 个文件 · {{tokens}} 个令牌",
"filterFiles": "筛选文件",
"searchChunks": "搜索文本块",
"chunkCount_other": "{{formatted}} 个文本块",
"editor": {
"write": "编辑",
"preview": "预览",
"previewEmpty": "暂无可预览的内容。",
"cancel": "取消",
"close": "关闭",
"discardMessage": "放弃未保存的更改?",
"discard": "放弃"
},
"editChunk": "编辑文本块",
"editChunkDescription": "{{file}} · 第 {{n}} 个文本块 · {{tokens}} 个令牌",
"previousChunk": "上一个文本块",
"nextChunk": "下一个文本块"
},
"analytics": {
"label": "分析",
@@ -1251,7 +1323,6 @@
"promptText": "提示文本",
"save": "保存",
"close": "关闭",
"cancel": "取消",
"delete": "删除",
"deleteConfirmation": "您确定要删除此块吗?"
},
@@ -1347,7 +1418,8 @@
"firstPage": "第一页",
"previousPage": "上一页",
"nextPage": "下一页",
"lastPage": "最后一页"
"lastPage": "最后一页",
"chunksPerPage": "每页文本块数"
},
"conversation": {
"copy": "复制",
+4 -2
View File
@@ -50,7 +50,7 @@ import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
import Chunks from '../components/Chunks';
import WikiViewer from '../components/WikiViewer';
import GraphView from '../components/GraphView';
import GraphSourceView from '../components/graph/GraphSourceView';
import ConvertToWikiModal from './ConvertToWikiModal';
import EnableGraphRAGModal from './EnableGraphRAGModal';
import { clearGraphBuild, selectGraphBuilds } from './graphBuildSlice';
@@ -536,9 +536,11 @@ export default function Sources({
headerAction={testRetrievalAction}
/>
) : documentToView.config?.kind === 'graphrag' ? (
<GraphView
<GraphSourceView
docId={documentToView.id || ''}
sourceName={documentToView.name}
sourceType={documentToView.type}
isNested={!!documentToView.isNested}
onBackToDocuments={() => setDocumentToView(undefined)}
headerAction={testRetrievalAction}
/>
@@ -8,7 +8,7 @@ import { Card } from '../../components/ui/card';
import { SectionHeader } from '../../components/ui/section-header';
import { Progress } from '../../components/ui/progress';
import { selectToken } from '../../preferences/preferenceSlice';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { formatDateTime, intlLocale } from '../../utils/dateTimeUtils';
type Budget = { limit: number | null; used: number };
type Bucket = {
@@ -80,8 +80,8 @@ export default function UsageQuota() {
if (buckets.length === 0) return null;
const number = new Intl.NumberFormat(i18n.language);
const usd = new Intl.NumberFormat(i18n.language, {
const number = new Intl.NumberFormat(intlLocale(i18n.language));
const usd = new Intl.NumberFormat(intlLocale(i18n.language), {
style: 'currency',
currency: 'USD',
});
+23 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatDateOnly, formatDateTime } from './dateTimeUtils';
import {
formatCount,
formatDate,
formatDateOnly,
formatDateTime,
} from './dateTimeUtils';
describe('dateTimeUtils', () => {
it('formats date-only values as DD/MM/YYYY', () => {
@@ -98,3 +103,20 @@ describe('formatRelative', () => {
).toBe(formatDateOnly(old));
});
});
describe('formatCount', () => {
it('groups digits the way the app language does', () => {
expect(formatCount(1234567, 'en')).toBe('1,234,567');
expect(formatCount(1234567, 'de')).toBe('1.234.567');
expect(formatCount(1234, 'ru')).toBe('1\u00a0234');
});
it('maps the app codes Intl does not know', () => {
expect(formatCount(1234, 'jp')).toBe('1,234');
expect(formatCount(1234, 'zhTW')).toBe('1,234');
});
it('leaves small numbers alone', () => {
expect(formatCount(7, 'de')).toBe('7');
});
});
+18
View File
@@ -82,6 +82,24 @@ export function intlLocale(language: string = i18next.language): string {
return INTL_LOCALES[language] ?? language;
}
/**
* A count with the UI language's digit grouping ("1,234", "1.234", "1 234").
*
* Args:
* value: the number.
* language: an app language code; defaults to the current one.
*
* Returns:
* The formatted number. Pass it to a plural key as its own param and keep
* `count` numeric, so i18next still picks the plural form.
*/
export function formatCount(
value: number,
language: string = i18next.language,
): string {
return new Intl.NumberFormat(intlLocale(language)).format(value);
}
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 31_536_000_000],
['month', 2_592_000_000],
+271 -3
View File
@@ -49,6 +49,56 @@ class TestResolveSource:
assert got is not None
assert str(got["id"]) == str(src["id"])
def test_team_viewer_can_read(self, pg_conn):
from docsgpt.api.user.sources.chunks import _resolve_source
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
from docsgpt.storage.db.repositories.teams import TeamsRepository
owner, viewer = "u-resolve-owner", "u-resolve-viewer"
src = _seed_source(pg_conn, user=owner)
team = TeamsRepository(pg_conn).create("Acme", "acme-chunks", owner)
TeamMembersRepository(pg_conn).add_member(team["id"], viewer, role="team_member")
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", str(src["id"]), owner_id=owner, granted_by=owner,
access_level="viewer",
)
with _patch_db(pg_conn):
got = _resolve_source(str(src["id"]), viewer)
stranger = _resolve_source(str(src["id"]), "u-resolve-stranger")
assert got is not None
assert str(got["id"]) == str(src["id"])
assert stranger is None
class TestChunkMatchesPath:
@pytest.mark.parametrize("metadata, path", [
({"source": "/a/b/file.txt"}, "b/file.txt"),
({"source": "file.txt"}, "file.txt"),
({"source": "https://x.io/guides/setup", "file_path": "guides/setup.md"}, "guides/setup.md"),
({"source": "https://github.com/o/r/blob/main/web/app.py", "title": "web/app.py"}, "web/app.py"),
({"source": "s3://bkt/docs/a.pdf", "key": "docs/a.pdf", "title": "a.pdf"}, "docs/a.pdf"),
# Web and Reddit chunks carry no file_path/key: the tree keys them by title.
({"source": "https://docs.docsgpt.cloud/", "title": "Home - DocsGPT"}, "Home - DocsGPT"),
({"source": "https://reddit.com/r/x/comments/1", "title": "A post"}, "A post"),
])
def test_matches(self, metadata, path):
from docsgpt.api.user.sources.chunks import _chunk_matches_path
assert _chunk_matches_path(metadata, path)
@pytest.mark.parametrize("metadata, path", [
({"source": "/other.txt"}, "b/file.txt"),
# Suffixes only match at a path boundary.
({"source": "docs/data.md"}, "a.md"),
# Title only stands in for remote chunks the tree could not key by file_path/key.
({"source": "docs/readme.md", "title": "Readme"}, "Readme"),
({"source": "s3://bkt/docs/a.pdf", "key": "docs/a.pdf", "title": "Report"}, "Report"),
({"source": "https://x.io/a", "file_path": "a.md", "title": "A"}, "A"),
])
def test_rejects(self, metadata, path):
from docsgpt.api.user.sources.chunks import _chunk_matches_path
assert not _chunk_matches_path(metadata, path)
class TestGetChunks:
def test_returns_401_unauthenticated(self, app):
@@ -146,6 +196,35 @@ class TestGetChunks:
assert response.status_code == 200
assert response.json["total"] == 1
def test_filters_title_keyed_web_page(self, app, pg_conn):
# Single-URL sources ingested before WebLoader set ``file_path`` key
# their only tree file by the page title; opening it must still list
# the page's chunks without a re-ingest.
from docsgpt.api.user.sources.chunks import GetChunks
user = "u-path-web"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"text": "home", "metadata": {
"source": "https://docs.docsgpt.cloud/",
"title": "Home - DocsGPT Documentation",
}},
]
with _patch_db(pg_conn), patch(
"docsgpt.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
f"/api/get_chunks?id={src['id']}&path=Home - DocsGPT Documentation"
):
from flask import request
request.decoded_token = {"sub": user}
response = GetChunks().get()
assert response.status_code == 200
assert response.json["total"] == 1
def test_filters_by_search(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import GetChunks
@@ -523,8 +602,7 @@ class TestUpdateChunk:
"metadata": {"title": "T"},
}
]
fake_store.add_chunk.return_value = "new-chunk-id"
fake_store.delete_chunk.return_value = True
fake_store.update_chunk.return_value = "chunk-123"
with _patch_db(pg_conn), patch(
"docsgpt.api.user.sources.chunks.get_vector_store",
@@ -541,4 +619,194 @@ class TestUpdateChunk:
request.decoded_token = {"sub": user}
response = UpdateChunk().put()
assert response.status_code == 200
assert response.json["chunk_id"] == "new-chunk-id"
assert response.json == {
"message": "Chunk updated successfully",
"chunk_id": "chunk-123",
"original_chunk_id": "chunk-123",
}
fake_store.update_chunk.assert_called_once()
chunk_id, new_text, new_metadata = fake_store.update_chunk.call_args[0]
assert (chunk_id, new_text) == ("chunk-123", "new text")
assert new_metadata["title"] == "T"
assert new_metadata["token_count"] > 0
fake_store.add_chunk.assert_not_called()
fake_store.delete_chunk.assert_not_called()
def test_update_failure_returns_500(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import UpdateChunk
user = "u-upd-fail"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"doc_id": "chunk-123", "text": "old", "metadata": {}}
]
fake_store.update_chunk.side_effect = RuntimeError("embed down")
with _patch_db(pg_conn), patch(
"docsgpt.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
"/api/update_chunk", method="PUT",
json={"id": str(src["id"]), "chunk_id": "chunk-123", "text": "new"},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateChunk().put()
assert response.status_code == 500
assert response.json == {"error": "Failed to update chunk - addition failed"}
def test_invalid_metadata_keys_return_400(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import UpdateChunk
from docsgpt.vectorstore.base import InvalidChunkMetadataError
user = "u-upd-bad-meta"
src = _seed_source(pg_conn, user=user)
fake_store = MagicMock()
fake_store.get_chunks.return_value = [
{"doc_id": "chunk-123", "text": "old", "metadata": {}}
]
fake_store.update_chunk.side_effect = InvalidChunkMetadataError(
"Metadata key 'a.b' is not allowed"
)
with _patch_db(pg_conn), patch(
"docsgpt.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), app.test_request_context(
"/api/update_chunk", method="PUT",
json={
"id": str(src["id"]),
"chunk_id": "chunk-123",
"metadata": {"a.b": 1},
},
):
from flask import request
request.decoded_token = {"sub": user}
response = UpdateChunk().put()
assert response.status_code == 400
# Generic message; the offending key is only logged.
assert response.json == {"error": "Invalid metadata"}
class TestUpdateChunkGraphLinks:
"""A store that re-ids an edited chunk must take the graph links with it."""
def _put(self, app, pg_conn, src, user, graph_store, fake_store=None):
from docsgpt.api.user.sources.chunks import UpdateChunk
if fake_store is None:
fake_store = MagicMock()
# The base fallback: the edit comes back under a new id.
fake_store.update_chunk.return_value = "chunk-new"
fake_store.get_chunks.return_value = [
{"doc_id": "chunk-old", "text": "old", "metadata": {}}
]
with _patch_db(pg_conn), patch(
"docsgpt.api.user.sources.chunks.get_vector_store",
return_value=fake_store,
), patch(
"docsgpt.graphrag.store.GraphStore", return_value=graph_store
), app.test_request_context(
"/api/update_chunk", method="PUT",
json={"id": str(src["id"]), "chunk_id": "chunk-old", "text": "new"},
):
from flask import request
request.decoded_token = {"sub": user}
return UpdateChunk().put()
def _graph_source(self, pg_conn, user):
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.source_config import SourceConfig
return SourcesRepository(pg_conn).create(
"g", user_id=user, config=SourceConfig.parse({}).graph_enabled()
)
def test_graphrag_source_remaps_links_to_the_new_chunk(self, app, pg_conn):
user = "u-upd-graph"
src = self._graph_source(pg_conn, user)
graph_store = MagicMock()
response = self._put(app, pg_conn, src, user, graph_store)
assert response.status_code == 200
graph_store.remap_chunk.assert_called_once_with(
str(src["id"]), "chunk-old", "chunk-new"
)
def test_classic_source_skips_the_graph(self, app, pg_conn):
user = "u-upd-classic"
src = _seed_source(pg_conn, user=user)
graph_store = MagicMock()
response = self._put(app, pg_conn, src, user, graph_store)
assert response.status_code == 200
graph_store.remap_chunk.assert_not_called()
def test_remap_failure_keeps_the_saved_edit(self, app, pg_conn):
user = "u-upd-graph-fail"
src = self._graph_source(pg_conn, user)
graph_store = MagicMock()
graph_store.remap_chunk.side_effect = RuntimeError("boom")
response = self._put(app, pg_conn, src, user, graph_store)
assert response.status_code == 200
assert response.json["chunk_id"] == "chunk-new"
def test_in_place_update_skips_the_graph_remap(self, app, pg_conn):
user = "u-upd-graph-inplace"
src = self._graph_source(pg_conn, user)
graph_store = MagicMock()
fake_store = MagicMock()
fake_store.update_chunk.return_value = "chunk-old"
response = self._put(app, pg_conn, src, user, graph_store, fake_store)
assert response.status_code == 200
assert response.json["chunk_id"] == "chunk-old"
assert response.json["original_chunk_id"] == "chunk-old"
graph_store.remap_chunk.assert_not_called()
def test_default_fallback_store_remaps_to_the_new_id(self, app, pg_conn):
"""A store without its own update (e.g. Milvus) re-adds and re-ids."""
from docsgpt.vectorstore.base import BaseVectorStore
class _FallbackStore(BaseVectorStore):
def __init__(self):
super().__init__()
self.deleted = []
def search(self, *args, **kwargs):
return []
def add_texts(self, texts, metadatas=None, *args, **kwargs):
return []
def get_chunks(self):
return [{"doc_id": "chunk-old", "text": "old", "metadata": {}}]
def add_chunk(self, text, metadata=None):
return "chunk-new"
def delete_chunk(self, chunk_id):
self.deleted.append(chunk_id)
return True
user = "u-upd-graph-fallback"
src = self._graph_source(pg_conn, user)
graph_store = MagicMock()
store = _FallbackStore()
store_proxy = MagicMock(wraps=store)
response = self._put(app, pg_conn, src, user, graph_store, store_proxy)
assert response.status_code == 200
assert response.json["chunk_id"] == "chunk-new"
assert store.deleted == ["chunk-old"]
graph_store.remap_chunk.assert_called_once_with(
str(src["id"]), "chunk-old", "chunk-new"
)
+321
View File
@@ -89,6 +89,7 @@ class TestSourceGraph:
store = MagicMock()
store.count_nodes.return_value = 3
store.count_edges.return_value = 5
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": "person",
@@ -114,6 +115,9 @@ class TestSourceGraph:
assert response.json["success"] is True
assert {n["id"] for n in response.json["nodes"]} == {"n1", "n2"}
assert response.json["edges"][0]["source"] == "n1"
# Totals describe the whole graph, not the bounded overview.
assert response.json["stats"] == {"nodes": 3, "edges": 5}
store.count_edges.assert_called_once_with(sid)
# The store receives the source's resolved id and the clamped limit.
args = store.get_graph_overview.call_args.args
assert args[0] == sid
@@ -140,6 +144,7 @@ class TestSourceGraph:
assert response.status_code == 200
assert response.json["nodes"] == []
assert response.json["edges"] == []
assert response.json["stats"] == {"nodes": 0, "edges": 0}
# No graph rows → never query the overview.
store.get_graph_overview.assert_not_called()
@@ -172,6 +177,7 @@ class TestSourceGraph:
store = MagicMock()
store.count_nodes.return_value = 1
store.count_edges.return_value = 0
store.get_graph_overview.return_value = {
"nodes": [
{"id": "n1", "name": "A", "type": None,
@@ -189,8 +195,315 @@ class TestSourceGraph:
assert response.status_code == 200
assert {n["id"] for n in response.json["nodes"]} == {"n1"}
assert response.json["stats"] == {"nodes": 1, "edges": 0}
def _node_list_store():
store = MagicMock()
store.list_nodes.return_value = {
"nodes": [
{"id": "n1", "name": "Ada", "type": "Person", "degree": 3,
"doc_freq": 2},
],
"total": 41,
}
store.node_type_facets.return_value = [
{"key": "person", "label": "Person", "count": 30},
{"key": "", "label": None, "count": 11},
]
return store
@pytest.mark.unit
class TestSourceGraphNodes:
def test_returns_401_unauthenticated(self, app):
from docsgpt.api.user.sources.routes import SourceGraphNodes
with app.test_request_context("/api/sources/x/graph/nodes"):
from flask import request
request.decoded_token = None
response = SourceGraphNodes().get("x")
assert response.status_code == 401
def test_owner_gets_paged_nodes_and_type_facets(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-owner"
sid = _graphrag_source(pg_conn, user)
store = _node_list_store()
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/nodes"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert response.json == {
"success": True,
"nodes": [
{"id": "n1", "name": "Ada", "type": "Person", "degree": 3,
"doc_freq": 2},
],
"total": 41,
"page": 1,
"per_page": 25,
"types": [
{"key": "person", "label": "Person", "count": 30},
{"key": "", "label": None, "count": 11},
],
}
store.list_nodes.assert_called_once_with(
sid, query=None, type_key=None, offset=0, limit=25
)
store.node_type_facets.assert_called_once_with(sid)
def test_forwards_filters_and_clamps_paging(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-params"
sid = _graphrag_source(pg_conn, user)
store = _node_list_store()
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(
f"/api/sources/{sid}/graph/nodes?q=%20ada%20&type=person&page=3&per_page=500"
):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert response.json["page"] == 3
assert response.json["per_page"] == 100
store.list_nodes.assert_called_once_with(
sid, query="ada", type_key="person", offset=200, limit=100
)
@pytest.mark.parametrize(
"qs, page, per_page",
[
("page=0&per_page=0", 1, 1),
("page=-4&per_page=-9", 1, 1),
("page=abc&per_page=xyz", 1, 25),
],
)
def test_bad_paging_values_fall_back(self, app, pg_conn, qs, page, per_page):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-bad-paging"
sid = _graphrag_source(pg_conn, user)
store = _node_list_store()
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/nodes?{qs}"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert (response.json["page"], response.json["per_page"]) == (page, per_page)
kwargs = store.list_nodes.call_args.kwargs
assert kwargs["offset"] == (page - 1) * per_page
assert kwargs["limit"] == per_page
def test_non_owner_without_grant_404(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
owner = "u-graph-nodes-owner2"
stranger = "u-graph-nodes-stranger"
sid = _graphrag_source(pg_conn, owner)
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore"
) as mock_store, app.test_request_context(
f"/api/sources/{sid}/graph/nodes"
):
from flask import request
request.decoded_token = {"sub": stranger}
response = SourceGraphNodes().get(sid)
assert response.status_code == 404
mock_store.assert_not_called()
def test_team_viewer_can_read(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
owner = "alice-graph-nodes"
viewer = "bob-graph-nodes-viewer"
sid = _graphrag_source(pg_conn, owner)
_grant_team_access(pg_conn, owner, viewer, sid, "viewer")
store = _node_list_store()
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/nodes"):
from flask import request
request.decoded_token = {"sub": viewer}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert response.json["total"] == 41
assert store.list_nodes.call_args.args == (sid,)
def test_store_failure_returns_400(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-fail"
sid = _graphrag_source(pg_conn, user)
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", side_effect=RuntimeError("no db")
), app.test_request_context(f"/api/sources/{sid}/graph/nodes"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 400
def _real_store_with_mock_conn(fail: bool = False, total: int = 0):
"""A real ``GraphStore`` whose connection is a mock, so the store's own
error handling runs. ``fail`` makes every query raise."""
from docsgpt.graphrag.store import GraphStore
store = GraphStore.__new__(GraphStore)
cursor = MagicMock()
cursor.fetchone.return_value = [total]
cursor.fetchall.return_value = []
if fail:
cursor.execute.side_effect = RuntimeError("connection reset")
conn = MagicMock()
conn.cursor.return_value = cursor
store._connection = conn
store._get_connection = lambda: conn
store._tables_ensured = True
return store, cursor
@pytest.mark.unit
class TestSourceGraphNodesRealStore:
def test_query_failure_returns_400_not_empty_200(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-query-fail"
sid = _graphrag_source(pg_conn, user)
store, _ = _real_store_with_mock_conn(fail=True)
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/nodes"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 400
assert response.json == {"success": False}
def test_empty_graph_returns_empty_200(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
user = "u-graph-nodes-empty"
sid = _graphrag_source(pg_conn, user)
store, _ = _real_store_with_mock_conn(total=0)
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(f"/api/sources/{sid}/graph/nodes"):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert response.json["nodes"] == []
assert response.json["total"] == 0
assert response.json["types"] == []
def _get(self, app, pg_conn, user, store=None, store_error=None, qs=""):
from docsgpt.api.user.sources.routes import SourceGraphNodes
sid = _graphrag_source(pg_conn, user)
kwargs = {"side_effect": store_error} if store_error else {"return_value": store}
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", **kwargs
), app.test_request_context(f"/api/sources/{sid}/graph/nodes{qs}"):
from flask import request
request.decoded_token = {"sub": user}
return SourceGraphNodes().get(sid)
def test_missing_graph_tables_return_empty_200(self, app, pg_conn):
import psycopg
store, cursor = _real_store_with_mock_conn()
cursor.execute.side_effect = psycopg.errors.UndefinedTable(
'relation "graph_nodes" does not exist'
)
response = self._get(
app, pg_conn, "u-graph-nodes-no-tables", store=store,
qs="?type=person&page=2&per_page=10",
)
assert response.status_code == 200
assert response.json == {
"success": True,
"nodes": [],
"total": 0,
"page": 2,
"per_page": 10,
"types": [],
}
def test_unconfigured_graph_store_returns_empty_200(self, app, pg_conn):
response = self._get(
app, pg_conn, "u-graph-nodes-unconfigured",
store_error=ValueError("PostgreSQL connection string is required."),
)
assert response.status_code == 200
assert response.json["nodes"] == []
assert response.json["total"] == 0
assert response.json["types"] == []
def test_other_store_errors_still_return_400(self, app, pg_conn):
import psycopg
store, cursor = _real_store_with_mock_conn()
cursor.execute.side_effect = psycopg.errors.InvalidTextRepresentation("bad")
response = self._get(app, pg_conn, "u-graph-nodes-bad-input", store=store)
assert response.status_code == 400
assert response.json == {"success": False}
def test_huge_page_is_clamped_and_keeps_the_total(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SourceGraphNodes
from docsgpt.graphrag.store import GRAPH_NODE_LIST_MAX_PAGE
user = "u-graph-nodes-huge-page"
sid = _graphrag_source(pg_conn, user)
store, cursor = _real_store_with_mock_conn(total=41)
with _patch_db(pg_conn), patch(
"docsgpt.graphrag.store.GraphStore", return_value=store
), app.test_request_context(
f"/api/sources/{sid}/graph/nodes?page=100000000000000000000&per_page=100"
):
from flask import request
request.decoded_token = {"sub": user}
response = SourceGraphNodes().get(sid)
assert response.status_code == 200
assert response.json["total"] == 41
assert response.json["nodes"] == []
assert response.json["page"] == GRAPH_NODE_LIST_MAX_PAGE
offset = cursor.execute.call_args_list[1].args[1][-1]
assert offset == (GRAPH_NODE_LIST_MAX_PAGE - 1) * 100
assert offset < 2**63
@pytest.mark.unit
class TestSourceGraphNode:
def test_returns_401_unauthenticated(self, app):
@@ -216,6 +529,11 @@ class TestSourceGraphNode:
"description": "A mathematician.",
"degree": 3,
"doc_freq": 2,
"relationships": [
{"id": "n2", "name": "Alan", "type": "person", "degree": 2,
"edge_type": "knows", "direction": "out"},
],
"relationships_total": 1204,
"chunks": [{"chunk_id": "5", "text": "body", "metadata": {}}],
}
@@ -229,6 +547,9 @@ class TestSourceGraphNode:
assert response.status_code == 200
assert response.json["node"]["name"] == "Ada"
assert response.json["node"]["chunks"][0]["text"] == "body"
# The capped list and its true total pass through untouched.
assert len(response.json["node"]["relationships"]) == 1
assert response.json["node"]["relationships_total"] == 1204
store.get_node_detail.assert_called_once_with(sid, "n1")
def test_unknown_node_404(self, app, pg_conn):
+43
View File
@@ -887,6 +887,49 @@ class TestDirectoryStructure:
assert data["provider"] == "gdrive"
assert data["base_path"] == "/data/nested"
def test_team_viewer_can_read(self, app, pg_conn):
from docsgpt.api.user.sources.routes import DirectoryStructure
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
from docsgpt.storage.db.repositories.teams import TeamsRepository
owner, viewer = "u-dir-owner", "u-dir-viewer"
src = _seed_source(
pg_conn, owner, name="shared",
directory_structure={"a.docx": {"type": "application/docx"}},
)
team = TeamsRepository(pg_conn).create("Acme", "acme-dir", owner)
TeamMembersRepository(pg_conn).add_member(team["id"], viewer, role="team_member")
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", str(src["id"]), owner_id=owner, granted_by=owner,
access_level="viewer",
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/directory_structure?id={src['id']}"
):
from flask import request
request.decoded_token = {"sub": viewer}
response = DirectoryStructure().get()
assert response.status_code == 200
assert response.json["directory_structure"] == {"a.docx": {"type": "application/docx"}}
def test_non_owner_without_grant_404(self, app, pg_conn):
from docsgpt.api.user.sources.routes import DirectoryStructure
src = _seed_source(
pg_conn, "u-dir-owner2", name="private",
directory_structure={"a.txt": None},
)
with _patch_db(pg_conn), app.test_request_context(
f"/api/directory_structure?id={src['id']}"
):
from flask import request
request.decoded_token = {"sub": "u-dir-stranger"}
response = DirectoryStructure().get()
assert response.status_code == 404
class TestSourceConfigResource:
def test_returns_401_unauthenticated(self, app):
+614
View File
@@ -444,6 +444,197 @@ class TestGraphStoreLive:
finally:
store.delete_by_source(source_id)
def _seed_typed_graph(self, store, source_id):
"""Six nodes whose types spell ``person`` three ways, plus edges for degree."""
ids = {
"ada": store.upsert_node(source_id, "Ada Lovelace", "ada lovelace", "Person"),
"alan": store.upsert_node(source_id, "Alan Turing", "alan turing", "Person"),
"grace": store.upsert_node(source_id, "Grace Hopper", "grace hopper", "PERSON"),
"babbage": store.upsert_node(source_id, "Babbage", "babbage", "per son"),
"acme": store.upsert_node(source_id, "Acme_Corp", "acme_corp", "org"),
"sure": store.upsert_node(source_id, "100%_Sure", "100%_sure"),
}
# Degrees: ada 3, alan 2, acme 2, grace 1, babbage 0, sure 0.
store.add_edge(source_id, ids["ada"], ids["alan"], "knows")
store.add_edge(source_id, ids["ada"], ids["acme"], "works_at")
store.add_edge(source_id, ids["grace"], ids["ada"], "cites")
store.add_edge(source_id, ids["alan"], ids["acme"], "works_at")
store.set_node_degrees(source_id)
return ids
def test_list_nodes_filters_pages_and_counts(self, store, source_id):
try:
ids = self._seed_typed_graph(store, source_id)
everything = store.list_nodes(source_id)
assert everything["total"] == 6
ordered = [n["id"] for n in everything["nodes"]]
assert ordered[0] == ids["ada"]
# Degree DESC, then id: alan and acme tie on 2.
assert set(ordered[1:3]) == {ids["alan"], ids["acme"]}
assert ordered[1:3] == sorted(ordered[1:3])
first = everything["nodes"][0]
assert set(first) == {"id", "name", "type", "degree", "doc_freq"}
assert first["degree"] == 3 and first["doc_freq"] == 1
by_name = store.list_nodes(source_id, query="ADA")
assert [n["id"] for n in by_name["nodes"]] == [ids["ada"]]
assert by_name["total"] == 1
# ``%`` and ``_`` are literals, not wildcards.
assert [n["id"] for n in store.list_nodes(source_id, query="%")["nodes"]] == [
ids["sure"]
]
underscored = store.list_nodes(source_id, query="_")
assert {n["id"] for n in underscored["nodes"]} == {ids["acme"], ids["sure"]}
people = store.list_nodes(source_id, type_key="person")
assert people["total"] == 4
assert {n["id"] for n in people["nodes"]} == {
ids["ada"], ids["alan"], ids["grace"], ids["babbage"]
}
untyped = store.list_nodes(source_id, type_key="")
assert [n["id"] for n in untyped["nodes"]] == [ids["sure"]]
both = store.list_nodes(source_id, query="a", type_key="person")
assert both["total"] == 4
page = store.list_nodes(source_id, type_key="person", offset=1, limit=2)
assert page["total"] == 4
assert [n["id"] for n in page["nodes"]] == [
n["id"] for n in people["nodes"][1:3]
]
beyond = store.list_nodes(source_id, offset=50, limit=10)
assert beyond == {"nodes": [], "total": 6}
finally:
store.delete_by_source(source_id)
def test_node_type_facets_fold_spellings_into_one_key(self, store, source_id):
try:
self._seed_typed_graph(store, source_id)
facets = store.node_type_facets(source_id)
assert facets == [
{"key": "person", "label": "Person", "count": 4},
{"key": "org", "label": "org", "count": 1},
{"key": "", "label": None, "count": 1},
]
assert store.node_type_facets(str(uuid.uuid4())) == []
finally:
store.delete_by_source(source_id)
def test_count_edges(self, store, source_id):
try:
assert store.count_edges(source_id) == 0
self._seed_typed_graph(store, source_id)
assert store.count_edges(source_id) == 4
assert store.count_edges(str(uuid.uuid4())) == 0
finally:
store.delete_by_source(source_id)
def test_node_detail_lists_relationships_in_both_directions(self, store, source_id):
try:
ids = self._seed_typed_graph(store, source_id)
# ``add_edge`` drops self-loops, so plant one directly: the reader
# must skip it too.
conn = store._get_connection()
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO graph_edges (id, source_id, src_node_id, dst_node_id, type) "
"VALUES (%s, %s, %s, %s, 'self');",
(str(uuid.uuid4()), source_id, ids["ada"], ids["ada"]),
)
conn.commit()
detail = store.get_node_detail(source_id, ids["ada"])
assert detail["name"] == "Ada Lovelace"
assert "chunks" in detail
rels = detail["relationships"]
assert len(rels) == 3
# Neighbour degree DESC, then neighbour id: alan and acme tie on 2.
assert [r["id"] for r in rels[:2]] == sorted([ids["alan"], ids["acme"]])
by_id = {r["id"]: r for r in rels}
assert by_id[ids["alan"]] == {
"id": ids["alan"], "name": "Alan Turing", "type": "Person",
"degree": 2, "edge_type": "knows", "direction": "out",
}
assert by_id[ids["acme"]]["edge_type"] == "works_at"
assert by_id[ids["acme"]]["direction"] == "out"
assert rels[2] == {
"id": ids["grace"], "name": "Grace Hopper", "type": "PERSON",
"degree": 1, "edge_type": "cites", "direction": "in",
}
assert detail["relationships_total"] == 3
lonely = store.get_node_detail(source_id, ids["babbage"])
assert lonely["relationships"] == []
assert lonely["relationships_total"] == 0
finally:
store.delete_by_source(source_id)
def test_node_detail_counts_every_relationship_past_the_cap(
self, store, source_id, monkeypatch
):
try:
ids = self._seed_typed_graph(store, source_id)
conn = store._get_connection()
with conn.cursor() as cursor:
cursor.execute(
"INSERT INTO graph_edges (id, source_id, src_node_id, dst_node_id, type) "
"VALUES (%s, %s, %s, %s, 'self');",
(str(uuid.uuid4()), source_id, ids["ada"], ids["ada"]),
)
conn.commit()
monkeypatch.setattr(store_module, "MAX_NODE_RELATIONSHIPS", 2)
detail = store.get_node_detail(source_id, ids["ada"])
# The list stops at the cap; the total counts both directions and
# still skips the self-loop.
assert len(detail["relationships"]) == 2
assert detail["relationships_total"] == 3
finally:
store.delete_by_source(source_id)
def test_remap_chunk_moves_every_link_to_the_new_id(self, store, source_id):
try:
ada = store.upsert_node(source_id, "Ada", "ada")
alan = store.upsert_node(source_id, "Alan", "alan")
store.link_node_chunk(source_id, ada, "old")
store.link_node_chunk(source_id, alan, "keep")
store.add_edge(
source_id, ada, alan, "knows", source_chunk_ids=["keep", "old"]
)
store.mark_chunk(source_id, "old", "done")
other = str(uuid.uuid4())
other_node = store.upsert_node(other, "Ada", "ada")
store.link_node_chunk(other, other_node, "old")
store.remap_chunk(source_id, "old", "new")
links = store.get_chunk_ids_for_nodes(source_id, [ada, alan])
assert links == {ada: ["new"], alan: ["keep"]}
conn = store._get_connection()
with conn.cursor() as cursor:
cursor.execute(
"SELECT source_chunk_ids FROM graph_edges WHERE source_id = %s;",
(source_id,),
)
assert cursor.fetchone()[0] == ["keep", "new"]
conn.rollback()
assert store.pending_chunks(source_id, ["old", "new"]) == ["old"]
# Another source's link to the same chunk id is left alone.
assert store.get_chunk_ids_for_nodes(other, [other_node]) == {
other_node: ["old"]
}
finally:
store.delete_by_source(source_id)
store.delete_by_source(other)
def test_checkpoint_pending_and_mark(self, store, source_id):
try:
all_chunks = ["c1", "c2", "c3"]
@@ -648,6 +839,238 @@ class TestGraphStoreParameterization:
# limit is clamped to the hard cap before binding.
assert params == (sid, GRAPH_OVERVIEW_MAX_LIMIT)
def test_count_edges_binds_source(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchone.return_value = [7]
sid = str(uuid.uuid4())
assert store.count_edges(sid) == 7
sql, params = cursor.execute.call_args.args
assert "FROM graph_edges" in sql
assert "source_id = %s" in sql
assert sid not in sql
assert params == (sid,)
def test_count_edges_failure_reports_zero(self):
store, cursor = self._store_with_mock_conn()
cursor.execute.side_effect = RuntimeError("relation does not exist")
assert store.count_edges(str(uuid.uuid4())) == 0
def test_count_edges_has_no_strict_mode(self):
import inspect
assert "strict" not in inspect.signature(GraphStore.count_edges).parameters
def test_list_nodes_binds_values_and_escapes_the_pattern(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchone.return_value = [0]
cursor.fetchall.side_effect = [[("Person",), ("person",), ("Org",)], []]
sid = str(uuid.uuid4())
query = "50%_a\\b"
result = store.list_nodes(
sid, query=query, type_key="person", offset=40, limit=10_000
)
assert result == {"nodes": [], "total": 0}
pattern = "%50\\%\\_a\\\\b%"
types_sql, types_params = cursor.execute.call_args_list[0].args
assert "DISTINCT type" in types_sql
assert types_params == (sid,)
for call in cursor.execute.call_args_list[1:]:
sql, params = call.args
assert sid not in sql
assert query not in sql
assert "person" not in sql
assert "ILIKE %s" in sql
assert "type = ANY(%s)" in sql
assert params[:3] == (sid, pattern, ["Person", "person"])
count_sql, count_params = cursor.execute.call_args_list[1].args
assert "count(*)" in count_sql
page_sql, page_params = cursor.execute.call_args_list[-1].args
assert "ORDER BY degree DESC, id" in page_sql
# The page size is clamped to the hard cap before binding.
assert page_params[3:] == (store_module.GRAPH_NODE_LIST_MAX_LIMIT, 40)
def test_list_nodes_without_filters_binds_only_the_source(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchone.return_value = [0]
sid = str(uuid.uuid4())
store.list_nodes(sid, query=" ", offset=-5, limit=0)
count_sql, count_params = cursor.execute.call_args_list[0].args
assert "ILIKE" not in count_sql
assert "regexp_replace" not in count_sql
assert count_params == (sid,)
_, page_params = cursor.execute.call_args_list[-1].args
assert page_params == (sid, 1, 0)
def test_list_nodes_failure_propagates(self):
store, cursor = self._store_with_mock_conn()
cursor.execute.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError):
store.list_nodes("sid")
cursor.close.assert_called_once()
def test_list_nodes_empty_graph_returns_empty(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchone.return_value = [0]
cursor.fetchall.return_value = []
assert store.list_nodes("sid") == {"nodes": [], "total": 0}
def test_node_type_facets_bind_source(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchall.return_value = [("Person", 4), (None, 1)]
sid = str(uuid.uuid4())
facets = store.node_type_facets(sid)
sql, params = cursor.execute.call_args.args
assert sid not in sql
assert params == (sid,)
assert facets == [
{"key": "person", "label": "Person", "count": 4},
{"key": "", "label": None, "count": 1},
]
def test_node_type_facets_failure_propagates(self):
store, cursor = self._store_with_mock_conn()
cursor.execute.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError):
store.node_type_facets("sid")
cursor.close.assert_called_once()
def test_node_type_facets_empty_graph_returns_empty(self):
store, cursor = self._store_with_mock_conn()
cursor.fetchall.return_value = []
assert store.node_type_facets("sid") == []
def test_node_detail_relationships_bind_ids_and_cap(self):
store, cursor = self._store_with_mock_conn()
sid = str(uuid.uuid4())
nid = str(uuid.uuid4())
other = str(uuid.uuid4())
cursor.fetchone.return_value = (nid, "Ada", "person", "d", 1, 1)
cursor.fetchall.side_effect = [
[(other, "Alan", "person", 2, "knows", "out")], # relationships
[], # chunk ids
]
detail = store.get_node_detail(sid, nid)
assert detail["relationships"] == [
{"id": other, "name": "Alan", "type": "person", "degree": 2,
"edge_type": "knows", "direction": "out"},
]
rel_call = next(
c for c in cursor.execute.call_args_list if "direction" in c.args[0]
)
sql, params = rel_call.args
assert sid not in sql and nid not in sql
assert "src_node_id <> e.dst_node_id" in sql
assert sid in params and nid in params
assert params[-1] == store_module.MAX_NODE_RELATIONSHIPS == 300
# Under the cap the list is the whole set: no COUNT query.
assert detail["relationships_total"] == 1
assert not any(
"COUNT(*)" in c.args[0] for c in cursor.execute.call_args_list
)
def test_node_detail_counts_relationships_when_capped(self, monkeypatch):
store, cursor = self._store_with_mock_conn()
monkeypatch.setattr(store_module, "MAX_NODE_RELATIONSHIPS", 2)
sid = str(uuid.uuid4())
nid = str(uuid.uuid4())
rel = (str(uuid.uuid4()), "Alan", "person", 2, "knows", "out")
cursor.fetchone.side_effect = [
(nid, "Ada", "person", "d", 1, 1), # the node
(1204,), # relationships total
]
cursor.fetchall.side_effect = [[rel, rel], []]
detail = store.get_node_detail(sid, nid)
assert len(detail["relationships"]) == 2
assert detail["relationships_total"] == 1204
count_sql, count_params = next(
c.args for c in cursor.execute.call_args_list if "COUNT(*)" in c.args[0]
)
assert sid not in count_sql and nid not in count_sql
assert "src_node_id <> e.dst_node_id" in count_sql
assert "LIMIT" not in count_sql
assert sid in count_params and nid in count_params
def test_node_detail_total_falls_back_when_the_count_fails(self, monkeypatch):
store, cursor = self._store_with_mock_conn()
monkeypatch.setattr(store_module, "MAX_NODE_RELATIONSHIPS", 1)
sid = str(uuid.uuid4())
nid = str(uuid.uuid4())
cursor.fetchone.return_value = (nid, "Ada", "person", "d", 1, 1)
cursor.fetchall.side_effect = [
[(str(uuid.uuid4()), "Alan", "person", 2, "knows", "out")],
[],
]
def execute(sql, params=None):
if "COUNT(*)" in sql:
raise RuntimeError("boom")
cursor.execute.side_effect = execute
detail = store.get_node_detail(sid, nid)
assert detail["relationships_total"] == 1
assert len(detail["relationships"]) == 1
def test_remap_chunk_binds_ids_under_the_source_lock(self):
store, cursor = self._store_with_mock_conn()
store._write_with_reconnect = lambda write: write(store._connection)
sid = str(uuid.uuid4())
store.remap_chunk(sid, "old-id", "new-id")
calls = cursor.execute.call_args_list
assert "pg_advisory_xact_lock" in calls[0].args[0]
tables = ["graph_node_chunks", "graph_edges", "graph_ingest_progress"]
for table in tables:
sql, params = next(c.args for c in calls if table in c.args[0])
assert sid not in sql and "old-id" not in sql and "new-id" not in sql
assert {sid, "old-id", "new-id"} <= set(params)
store._connection.commit.assert_called_once()
def test_node_detail_survives_a_relationships_failure(self):
# A failed relationships read degrades to an empty list; the node and
# its chunks still come back instead of the whole detail 404ing.
store, cursor = self._store_with_mock_conn()
conn = store._connection
sid = str(uuid.uuid4())
nid = str(uuid.uuid4())
cursor.fetchone.return_value = (nid, "Ada", "person", "d", 1, 1)
def execute(sql, params=None):
if "direction" in sql:
raise RuntimeError("boom")
cursor.execute.side_effect = execute
cursor.fetchall.return_value = []
detail = store.get_node_detail(sid, nid)
assert detail is not None
assert detail["name"] == "Ada"
assert detail["relationships"] == []
assert detail["relationships_total"] == 0
assert detail["chunks"] == []
# The aborted transaction is rolled back before the chunk read reuses it.
assert conn.rollback.call_count >= 2
def test_upsert_node_binds_all_values(self):
store, cursor = self._store_with_mock_conn()
sid = str(uuid.uuid4())
@@ -1028,6 +1451,14 @@ class TestGraphSchemaIsBootOwned:
lambda s: s.get_chunk_ids_for_nodes("sid", ["n"]),
lambda s: s.pending_chunks("sid", ["c1"]),
lambda s: s.get_progress("sid"),
lambda s: s.count_edges("sid"),
# list_nodes now surfaces query errors, so feed it a numeric count row.
lambda s: (
setattr(s._get_connection().cursor(), "fetchone", lambda: [0]),
s.list_nodes("sid", query="a", type_key="person"),
),
lambda s: s.node_type_facets("sid"),
lambda s: s.get_node_detail("sid", "n"),
],
ids=[
"count_nodes",
@@ -1039,6 +1470,10 @@ class TestGraphSchemaIsBootOwned:
"get_chunk_ids_for_nodes",
"pending_chunks",
"get_progress",
"count_edges",
"list_nodes",
"node_type_facets",
"get_node_detail",
],
)
def test_reads_never_create_tables(self, call):
@@ -1546,3 +1981,182 @@ class TestApplyChunkIsReplaySafe:
assert node["doc_freq"] == 2
finally:
store.delete_by_source(source_id)
@pytest.mark.unit
class TestGraphTypeKey:
"""The Python type key must match the frontend's."""
@pytest.mark.parametrize(
"raw, key",
[
("Person", "person"),
("PERSON", "person"),
("per son", "person"),
("Org-Unit_2", "orgunit2"),
("", ""),
(None, ""),
("---", ""),
],
)
def test_key(self, raw, key):
assert store_module.graph_type_key(raw) == key
# Non-ASCII types fold the same way the frontend's ``\p{L}\p{N}`` does.
assert store_module.graph_type_key("Ком-пания") == "компания"
assert store_module.graph_type_key("人 物") == "人物"
@pytest.mark.integration
class TestTypeKeysOnACLocaleDatabase:
"""Type keys must not depend on the database's ``LC_CTYPE``.
Managed Postgres commonly runs ``C``: there ``lower()`` leaves Cyrillic
alone and ``[:alnum:]`` matches ASCII only, so a key computed in SQL folds
``Компания`` to ``""``. Only the columns these reads touch are created, so
the test runs without the pgvector extension.
"""
@pytest.fixture
def c_store(self, postgresql):
import psycopg
base = _ephemeral_dsn(postgresql.info)
dbname = f"graph_c_{uuid.uuid4().hex[:8]}"
with psycopg.connect(base, autocommit=True) as admin:
admin.execute(
f"CREATE DATABASE {dbname} TEMPLATE template0 ENCODING 'UTF8' "
"LC_COLLATE 'C' LC_CTYPE 'C';"
)
dsn = base.rsplit("/", 1)[0] + f"/{dbname}"
with psycopg.connect(dsn, autocommit=True) as conn:
conn.execute(
"""
CREATE TABLE graph_nodes (
id UUID PRIMARY KEY, source_id UUID NOT NULL, name TEXT,
normalized_name TEXT, type TEXT, description TEXT,
degree INT DEFAULT 0, doc_freq INT DEFAULT 0
);
"""
)
store = GraphStore(connection_string=dsn)
store._pool_max_size = 0
try:
yield store, dsn
finally:
store.close()
with psycopg.connect(base, autocommit=True) as admin:
admin.execute(f"DROP DATABASE IF EXISTS {dbname} WITH (FORCE);")
def test_non_ascii_types_facet_and_filter(self, c_store):
import psycopg
store, dsn = c_store
sid, other = str(uuid.uuid4()), str(uuid.uuid4())
rows = [
(sid, "a", "Компания", 5),
(sid, "b", "Компания", 4),
(sid, "c", "компания", 3),
(sid, "d", "人物", 2),
(sid, "e", "人 物", 1),
(sid, "f", None, 0),
(sid, "g", "---", 0),
(sid, "h", "Person", 0),
(other, "x", "Компания", 9),
]
with psycopg.connect(dsn, autocommit=True) as conn:
for source, name, type_, degree in rows:
conn.execute(
"INSERT INTO graph_nodes (id, source_id, name, normalized_name, "
"type, degree) VALUES (%s, %s, %s, %s, %s, %s);",
(str(uuid.uuid4()), source, name, name, type_, degree),
)
assert store.node_type_facets(sid) == [
{"key": "компания", "label": "Компания", "count": 3},
{"key": "人物", "label": "人 物", "count": 2},
{"key": "", "label": None, "count": 2},
{"key": "person", "label": "Person", "count": 1},
]
companies = store.list_nodes(sid, type_key="компания")
assert companies["total"] == 3
assert [n["name"] for n in companies["nodes"]] == ["a", "b", "c"]
assert store.list_nodes(sid, type_key="人物")["total"] == 2
untyped = store.list_nodes(sid, type_key="")
assert sorted(n["name"] for n in untyped["nodes"]) == ["f", "g"]
assert store.list_nodes(sid, type_key="person", query="h")["total"] == 1
assert store.list_nodes(sid, type_key="missing") == {"nodes": [], "total": 0}
@pytest.mark.unit
class TestTypeKeyFoldingInPython:
"""Facets and the type filter fold raw types with ``graph_type_key``."""
def _store(self):
store = GraphStore.__new__(GraphStore)
cursor = MagicMock()
conn = MagicMock()
conn.cursor.return_value = cursor
store._connection = conn
store._get_connection = lambda: conn
store._tables_ensured = True
return store, cursor
def test_facets_group_raw_types_and_pick_the_label(self):
store, cursor = self._store()
cursor.fetchall.return_value = [
("Компания", 2), ("компания", 2), ("КОМПАНИЯ", 1),
(None, 1), ("---", 3), ("人物", 5),
]
sid = str(uuid.uuid4())
facets = store.node_type_facets(sid)
sql_text, params = cursor.execute.call_args.args
assert "GROUP BY type" in sql_text
assert "regexp_replace" not in sql_text
assert params == (sid,)
# Most frequent spelling wins; a tie goes to the alphabetically first.
assert facets == [
{"key": "компания", "label": "Компания", "count": 5},
{"key": "人物", "label": "人物", "count": 5},
{"key": "", "label": None, "count": 4},
]
def test_type_filter_binds_the_matching_raw_types(self):
store, cursor = self._store()
cursor.fetchall.side_effect = [
[("Компания",), ("компания",), ("Person",), (None,)],
[],
]
cursor.fetchone.return_value = [3]
sid = str(uuid.uuid4())
result = store.list_nodes(sid, type_key="компания")
assert result == {"nodes": [], "total": 3}
distinct_sql, distinct_params = cursor.execute.call_args_list[0].args
assert "DISTINCT type" in distinct_sql
assert distinct_params == (sid,)
count_sql, count_params = cursor.execute.call_args_list[1].args
assert "type = ANY(%s)" in count_sql
assert "IS NULL" not in count_sql
assert count_params == (sid, ["Компания", "компания"])
def test_untyped_filter_includes_null_and_types_that_fold_to_empty(self):
store, cursor = self._store()
cursor.fetchall.side_effect = [[("---",), ("Person",), (None,)], []]
cursor.fetchone.return_value = [2]
sid = str(uuid.uuid4())
store.list_nodes(sid, type_key="")
count_sql, count_params = cursor.execute.call_args_list[1].args
assert "type IS NULL OR type = ANY(%s)" in count_sql
assert count_params == (sid, ["---"])
def test_unknown_type_key_short_circuits(self):
store, cursor = self._store()
cursor.fetchall.return_value = [("Person",)]
assert store.list_nodes("sid", type_key="org") == {"nodes": [], "total": 0}
assert cursor.execute.call_count == 1
+133
View File
@@ -0,0 +1,133 @@
"""Tests for the shared web-ingest path helpers in docsgpt/parser/remote/base.py."""
import pytest
from docsgpt.parser.remote.base import (
MAX_QUERY_SEGMENT_LENGTH,
dedupe_virtual_paths,
url_to_virtual_path,
)
from docsgpt.parser.schema.base import Document
@pytest.mark.unit
class TestUrlToVirtualPath:
@pytest.mark.parametrize(
"url, path",
[
# Paths without a query string are unchanged.
("https://x.io/", "index.md"),
("https://x.io", "index.md"),
("https://x.io/guides/setup", "guides/setup.md"),
("https://x.io/guides/setup/", "guides/setup.md"),
("https://x.io/page.html", "page.md"),
("https://x.io/readme.md", "readme.md"),
# The fragment names a spot on the same page.
("https://x.io/p#install", "p.md"),
("https://x.io/p?#install", "p.md"),
],
)
def test_query_less_paths_are_unchanged(self, url, path):
assert url_to_virtual_path(url) == path
def test_distinct_queries_get_distinct_paths(self):
first = url_to_virtual_path("https://x.io/p?page=1")
second = url_to_virtual_path("https://x.io/p?page=2")
assert first == "p__page=1.md"
assert second == "p__page=2.md"
assert url_to_virtual_path("https://x.io/p") == "p.md"
def test_parameter_order_does_not_matter(self):
assert (
url_to_virtual_path("https://x.io/p?b=2&a=1")
== url_to_virtual_path("https://x.io/p?a=1&b=2")
== "p__a=1&b=2.md"
)
def test_query_on_the_root_and_on_page_extensions(self):
assert url_to_virtual_path("https://x.io/?page=2") == "index__page=2.md"
assert url_to_virtual_path("https://x.io/a.php?id=7#top") == "a__id=7.md"
assert url_to_virtual_path("https://x.io/doc.md?v=2") == "doc__v=2.md"
def test_query_is_sanitized_to_a_single_tree_segment(self):
path = url_to_virtual_path("https://x.io/p?q=a/b%20c&next=..%2Fetc&flag")
assert path == "p__flag&next=..-etc&q=a-b-c.md"
assert path.count("/") == 0
assert "?" not in path and "#" not in path and "%" not in path
def test_unicode_query_values_survive(self):
assert url_to_virtual_path("https://x.io/s?q=%D0%BA%D0%BE%D1%82") == "s__q=кот.md"
def test_long_queries_are_bounded_with_a_stable_hash(self):
long_a = "https://x.io/p?q=" + "a" * 500
long_b = "https://x.io/p?q=" + "a" * 499 + "b"
path_a = url_to_virtual_path(long_a)
path_b = url_to_virtual_path(long_b)
segment = path_a[len("p__"):-len(".md")]
assert len(segment) <= MAX_QUERY_SEGMENT_LENGTH
assert path_a != path_b
assert url_to_virtual_path(long_a) == path_a
def test_host_prefix_still_applies(self):
assert (
url_to_virtual_path("https://x.io/p?page=1", include_host=True)
== "x.io/p__page=1.md"
)
def _doc(url, file_path=None):
return Document(
"text",
extra_info={"source": url, "file_path": file_path or url_to_virtual_path(url)},
)
def _paths(docs):
return [d.extra_info["file_path"] for d in docs]
@pytest.mark.unit
class TestDedupeVirtualPaths:
def test_distinct_pages_are_left_alone(self):
docs = [_doc("https://x.io/"), _doc("https://x.io/a"), _doc("https://x.io/b")]
assert _paths(dedupe_virtual_paths(docs)) == ["index.md", "a.md", "b.md"]
def test_extension_collision_gets_a_numbered_suffix(self):
docs = [_doc("https://x.io/a.html"), _doc("https://x.io/a")]
assert _paths(dedupe_virtual_paths(docs)) == ["a-2.md", "a.md"]
def test_suffixes_do_not_depend_on_input_order(self):
urls = ["https://x.io/a", "https://x.io/a.htm", "https://x.io/a.html"]
forward = _paths(dedupe_virtual_paths([_doc(u) for u in urls]))
backward = _paths(dedupe_virtual_paths([_doc(u) for u in reversed(urls)]))
assert forward == ["a.md", "a-2.md", "a-3.md"]
assert backward == list(reversed(forward))
def test_suffix_skips_a_path_another_page_already_has(self):
docs = [
_doc("https://x.io/g/a-2"),
_doc("https://x.io/g/a"),
_doc("https://x.io/g/a.html"),
]
assert _paths(dedupe_virtual_paths(docs)) == ["g/a-2.md", "g/a.md", "g/a-3.md"]
def test_the_same_page_reached_twice_keeps_one_path(self):
# A crawler that followed ``#section`` fetched the same page again.
docs = [_doc("https://x.io/a"), _doc("https://x.io/a#section")]
assert _paths(dedupe_virtual_paths(docs)) == ["a.md", "a.md"]
def test_documents_without_a_file_path_are_ignored(self):
doc = Document("text", extra_info={"source": "https://x.io/a"})
assert dedupe_virtual_paths([doc]) == [doc]
assert "file_path" not in doc.extra_info
@@ -183,3 +183,28 @@ class TestCrawlerLoaderGaps:
result = loader.load_data("https://example.com")
assert result[0].text == "test"
@patch("docsgpt.parser.remote.crawler_loader.validate_url", side_effect=_mock_validate_url)
@patch("docsgpt.parser.remote.crawler_loader.pinned_request")
def test_colliding_pages_get_distinct_file_paths(mock_pinned_request, mock_validate_url):
responses = {
"http://example.com": DummyResponse(
"<html><body><a href='/a'>A</a><a href='/a.html'>A2</a>"
"<a href='/l?page=2'>L</a></body></html>"
),
"http://example.com/a": DummyResponse("<html><body>a</body></html>"),
"http://example.com/a.html": DummyResponse("<html><body>a2</body></html>"),
"http://example.com/l?page=2": DummyResponse("<html><body>l</body></html>"),
}
mock_pinned_request.side_effect = lambda _m, url, timeout=30: responses[url]
result = CrawlerLoader(limit=10).load_data("http://example.com")
paths = {doc.extra_info["source"]: doc.extra_info["file_path"] for doc in result}
assert paths == {
"http://example.com": "index.md",
"http://example.com/a": "a.md",
"http://example.com/a.html": "a-2.md",
"http://example.com/l?page=2": "l__page=2.md",
}
@@ -179,3 +179,28 @@ def test_load_data_returns_empty_on_ssrf_validation_failure(monkeypatch):
assert result == []
def test_colliding_pages_get_distinct_file_paths(monkeypatch, _patch_markdownify):
root_html = (
"<html><head><title>Home</title></head><body>"
"<a href='/a'>A</a><a href='/a.html'>A2</a><a href='/l?page=2'>L</a>"
"</body></html>"
)
responses = {
"http://example.com": DummyResponse(root_html),
"http://example.com/a": DummyResponse("<html><body>a</body></html>"),
"http://example.com/a.html": DummyResponse("<html><body>a2</body></html>"),
"http://example.com/l?page=2": DummyResponse("<html><body>l</body></html>"),
}
_patch_pinned_request(monkeypatch, lambda url: responses[url])
docs = CrawlerLoader(limit=10).load_data("http://example.com")
paths = {doc.extra_info["source"]: doc.extra_info["file_path"] for doc in docs}
assert paths == {
"http://example.com": "index.md",
"http://example.com/a": "a.md",
"http://example.com/a.html": "a-2.md",
"http://example.com/l?page=2": "l__page=2.md",
}
+22 -1
View File
@@ -246,7 +246,10 @@ class TestSitemapLoaderLoadData:
assert len(docs) == 1
assert isinstance(docs[0], Document)
assert docs[0].text == "Page body"
assert docs[0].extra_info == {"source": "https://example.com/page1"}
assert docs[0].extra_info == {
"source": "https://example.com/page1",
"file_path": "page1.md",
}
@patch("docsgpt.parser.remote.sitemap_loader.validate_url")
def test_load_data_no_urls(self, mock_validate):
@@ -330,3 +333,21 @@ class TestSitemapLoaderLoadData:
with patch.object(loader, "_extract_urls", return_value=urls):
docs = loader.load_data("https://example.com/sitemap.xml")
assert len(docs) == 5
@pytest.mark.unit
class TestSitemapLoaderDistinctPaths:
@patch("docsgpt.parser.remote.sitemap_loader.validate_url", side_effect=lambda url: url)
@patch("docsgpt.parser.remote.sitemap_loader.pinned_request")
def test_colliding_pages_get_distinct_file_paths(self, mock_pinned_request, mock_validate):
loader = SitemapLoader(limit=None)
response = MagicMock()
response.text = "Page body"
response.raise_for_status.return_value = None
mock_pinned_request.return_value = response
urls = ["https://x.io/p?page=2", "https://x.io/p", "https://x.io/p.htm"]
with patch.object(loader, "_extract_urls", return_value=urls):
docs = loader.load_data("https://x.io/sitemap.xml")
assert [d.extra_info["file_path"] for d in docs] == ["p__page=2.md", "p.md", "p-2.md"]
+42 -5
View File
@@ -60,6 +60,20 @@ class TestWebLoaderHeaders:
class TestWebLoaderLoadData:
"""Test WebLoader load_data method."""
@patch("docsgpt.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("docsgpt.parser.remote.web_loader.pinned_request")
def test_same_host_urls_get_path_only_file_paths(self, mock_pinned_request, mock_validate, web_loader):
# The tree and the chunk path filter both key off file_path, so each
# page needs a stable, distinct one (not its title).
mock_pinned_request.side_effect = [
_fake_response("<html><head><title>Home</title></head><body>a</body></html>"),
_fake_response("<html><head><title>Setup</title></head><body>b</body></html>"),
]
result = web_loader.load_data(["https://docs.x.io/", "https://docs.x.io/guides/setup.html"])
assert [d.extra_info["file_path"] for d in result] == ["index.md", "guides/setup.md"]
@patch("docsgpt.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("docsgpt.parser.remote.web_loader.pinned_request")
def test_load_data_single_url_string(self, mock_pinned_request, mock_validate, web_loader):
@@ -77,6 +91,7 @@ class TestWebLoaderLoadData:
"source": "https://example.com",
"title": "Test Page",
"language": "en",
"file_path": "index.md",
}
mock_pinned_request.assert_called_once_with(
"GET", "https://example.com", headers=headers, timeout=30
@@ -97,8 +112,9 @@ class TestWebLoaderLoadData:
assert all(isinstance(doc, Document) for doc in result)
assert result[0].text == "Content from site 1"
assert result[1].text == "Content from site 2"
assert result[0].extra_info == {"source": "https://site1.com"}
assert result[1].extra_info == {"source": "https://site2.com"}
# Two hosts would both map to index.md, so the host prefixes the path.
assert result[0].extra_info == {"source": "https://site1.com", "file_path": "site1.com/index.md"}
assert result[1].extra_info == {"source": "https://site2.com", "file_path": "site2.com/index.md"}
assert mock_pinned_request.call_count == 2
mock_pinned_request.assert_any_call(
@@ -146,7 +162,7 @@ class TestWebLoaderLoadData:
assert len(result) == 1
assert result[0].text == "Bare body"
assert result[0].extra_info == {"source": "https://example.com"}
assert result[0].extra_info == {"source": "https://example.com", "file_path": "index.md"}
@patch("docsgpt.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("docsgpt.parser.remote.web_loader.pinned_request")
@@ -203,7 +219,7 @@ class TestWebLoaderErrorHandling:
assert len(result) == 1
assert result[0].text == "Success content"
assert result[0].extra_info == {"source": "https://good-url.com"}
assert result[0].extra_info == {"source": "https://good-url.com", "file_path": "good-url.com/index.md"}
mock_logging.error.assert_called_once()
error_call = mock_logging.error.call_args
@@ -276,7 +292,7 @@ class TestWebLoaderEdgeCases:
assert len(result) == 1
assert result[0].text == ""
assert result[0].extra_info == {"source": "https://empty-page.com"}
assert result[0].extra_info == {"source": "https://empty-page.com", "file_path": "index.md"}
def test_url_scheme_detection(self):
"""Test URL scheme detection logic."""
@@ -314,3 +330,24 @@ class TestWebLoaderIntegration:
assert "Test web page content" in result[0].page_content
assert result[0].metadata["source"] == "https://example.com"
assert result[0].metadata["title"] == "Test Page"
@pytest.mark.unit
class TestWebLoaderDistinctPaths:
@patch("docsgpt.parser.remote.web_loader.validate_url", side_effect=_mock_validate_url)
@patch("docsgpt.parser.remote.web_loader.pinned_request")
def test_colliding_pages_get_distinct_file_paths(self, mock_pinned_request, mock_validate, web_loader):
mock_pinned_request.side_effect = lambda *a, **k: _fake_response("<html><body>x</body></html>")
result = web_loader.load_data(
[
"https://x.io/list?page=1",
"https://x.io/list?page=2",
"https://x.io/a.html",
"https://x.io/a",
]
)
assert [d.extra_info["file_path"] for d in result] == [
"list__page=1.md", "list__page=2.md", "a-2.md", "a.md"
]
+52
View File
@@ -660,3 +660,55 @@ class TestGetEmbeddingsResolver:
assert result is sentinel
mock_resolver.assert_called_once_with("a-name", "a-key")
class _RecordingStore(ConcreteVectorStore):
"""Store whose add/delete calls are recorded, for the update fallback."""
def __init__(self, delete_result=True):
super().__init__()
self.calls = []
self._delete_result = delete_result
def add_chunk(self, text, metadata=None, *args, **kwargs):
self.calls.append(("add", text, metadata))
return "new-id"
def delete_chunk(self, chunk_id, *args, **kwargs):
self.calls.append(("delete", chunk_id))
return self._delete_result
@pytest.mark.unit
class TestBaseUpdateChunkFallback:
def test_adds_then_deletes_and_returns_the_new_id(self):
store = _RecordingStore()
new_id = store.update_chunk("old-id", "new text", {"k": "v"})
assert new_id == "new-id"
assert store.calls == [("add", "new text", {"k": "v"}), ("delete", "old-id")]
def test_failed_delete_is_logged_not_raised(self, caplog):
store = _RecordingStore(delete_result=False)
with caplog.at_level("WARNING"):
new_id = store.update_chunk("old-id", "new text", {})
assert new_id == "new-id"
assert "old-id" in caplog.text
def test_failed_add_skips_the_delete(self):
store = _RecordingStore()
store.add_chunk = Mock(side_effect=RuntimeError("embed down"))
with pytest.raises(RuntimeError):
store.update_chunk("old-id", "new text", {})
assert ("delete", "old-id") not in store.calls
def test_milvus_keeps_the_default(self):
"""Milvus has no in-place update here; it re-adds under a new id."""
from docsgpt.vectorstore.milvus import MilvusStore
assert MilvusStore.update_chunk is BaseVectorStore.update_chunk
+85
View File
@@ -287,3 +287,88 @@ class TestBuildFromDocumentsBatching:
store._build_from_documents(self._docs(3))
assert len(store.documents) == 3
assert all(len(k) == 36 for k in store.documents), "uuid4 ids by default"
def _assert_rows_match_documents(store):
"""Every FAISS row must hold the embedding of the chunk it maps to."""
import numpy as np
embeddings = _FakeEmbeddings()
assert store.index.ntotal == len(store.documents) == len(store.index_to_docstore_id)
assert sorted(store.index_to_docstore_id) == list(range(store.index.ntotal))
for row, doc_id in store.index_to_docstore_id.items():
expected = embeddings.embed_documents([store.documents[doc_id]["page_content"]])[0]
assert np.allclose(store.index.reconstruct(row), expected), (row, doc_id)
@pytest.mark.unit
class TestFaissUpdateChunk:
def test_keeps_id_and_position(self, populated):
before = [c["doc_id"] for c in populated.get_chunks()]
target = before[1]
returned = populated.update_chunk(target, "Redis caches things.", {"source": "cache.txt"})
assert returned == target
chunks = populated.get_chunks()
assert [c["doc_id"] for c in chunks] == before
assert chunks[1]["text"] == "Redis caches things."
assert chunks[1]["metadata"] == {"source": "cache.txt"}
assert populated.documents[target] == {
"page_content": "Redis caches things.",
"metadata": {"source": "cache.txt"},
}
def test_replaces_the_vector_and_keeps_rows_consistent(self, populated):
target = populated.get_chunks()[0]["doc_id"]
populated.update_chunk(target, "Redis caches things.", {})
_assert_rows_match_documents(populated)
# The old "Paris" vector is gone and the neutral query finds the edit.
assert "Paris" not in str(populated.search("Paris", k=1)[0])
assert "Redis" in str(populated.search("something neutral", k=1)[0])
def test_saves_once(self, populated):
target = populated.get_chunks()[0]["doc_id"]
with patch.object(populated, "_save_to_storage") as save:
populated.update_chunk(target, "Redis caches things.", {})
save.assert_called_once()
def test_embedding_failure_leaves_the_chunk_untouched(self, populated):
chunks_before = populated.get_chunks()
mapping_before = dict(populated.index_to_docstore_id)
target = chunks_before[1]["doc_id"]
with patch.object(
populated.embeddings, "embed_documents", side_effect=RuntimeError("embed down")
), patch.object(populated, "_save_to_storage") as save:
with pytest.raises(RuntimeError):
populated.update_chunk(target, "Redis caches things.", {})
assert populated.get_chunks() == chunks_before
assert populated.index_to_docstore_id == mapping_before
assert populated.index.ntotal == 3
save.assert_not_called()
def test_unknown_id_raises(self, populated):
with pytest.raises(KeyError):
populated.update_chunk("nope", "text", {})
assert populated.index.ntotal == 3
@pytest.mark.parametrize("sidecar", ["json", "pickle"])
def test_survives_save_and_reload(self, populated, make_store, tmp_path, sidecar):
before = [c["doc_id"] for c in populated.get_chunks()]
target = before[1]
populated.update_chunk(target, "Redis caches things.", {"source": "cache.txt"})
if sidecar == "pickle":
(tmp_path / "indexes" / "src" / "index.json").unlink()
reloaded = make_store()
chunks = reloaded.get_chunks()
assert [c["doc_id"] for c in chunks] == before
assert chunks[1]["text"] == "Redis caches things."
assert chunks[1]["metadata"] == {"source": "cache.txt"}
_assert_rows_match_documents(reloaded)
assert "Redis" in str(reloaded.search("something neutral", k=1)[0])
+122
View File
@@ -324,3 +324,125 @@ class TestMongoDBSearchWithScores:
pipeline = mock_collection.aggregate.call_args[0][0]
assert any("$addFields" in stage for stage in pipeline)
assert not any("$match" in stage for stage in pipeline)
def _fake_bson():
"""``bson.objectid`` stand-in: pymongo is an optional extra."""
objectid = MagicMock()
objectid.ObjectId = lambda value: ("oid", value)
return {"bson": MagicMock(objectid=objectid), "bson.objectid": objectid}
@pytest.mark.unit
class TestMongoDBVectorStoreUpdateChunk:
def _existing(self, **extra):
return {
"_id": ("oid", "abc"),
"text": "old",
"embedding": [0.0],
"source_id": "src1",
**extra,
}
def test_updates_in_place_with_set(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_emb.embed_documents.return_value = [[0.4, 0.5]]
mock_collection.find_one.return_value = self._existing(title="T")
mock_collection.update_one.return_value = Mock(matched_count=1)
with patch.dict("sys.modules", _fake_bson()):
returned = store.update_chunk("abc", "new text", {"title": "T2", "extra": 1})
assert returned == "abc"
query, update = mock_collection.update_one.call_args[0]
assert query == {"_id": ("oid", "abc"), "source_id": "src1"}
assert update == {
"$set": {
"text": "new text",
"embedding": [0.4, 0.5],
"source_id": "src1",
"title": "T2",
"extra": 1,
}
}
mock_collection.insert_one.assert_not_called()
mock_collection.delete_one.assert_not_called()
def test_unsets_keys_missing_from_the_new_metadata(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_emb.embed_documents.return_value = [[0.4]]
mock_collection.find_one.return_value = self._existing(title="T", stale="x")
mock_collection.update_one.return_value = Mock(matched_count=1)
with patch.dict("sys.modules", _fake_bson()):
store.update_chunk("abc", "new text", {"title": "T"})
_, update = mock_collection.update_one.call_args[0]
assert update["$unset"] == {"stale": ""}
assert "source_id" not in update["$unset"]
def test_reserved_keys_in_metadata_cannot_clobber_the_record(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_emb.embed_documents.return_value = [[0.4]]
mock_collection.find_one.return_value = self._existing()
mock_collection.update_one.return_value = Mock(matched_count=1)
with patch.dict("sys.modules", _fake_bson()):
store.update_chunk(
"abc", "new text",
{"_id": "x", "text": "x", "embedding": [9], "source_id": "other"},
)
_, update = mock_collection.update_one.call_args[0]
assert update["$set"] == {
"text": "new text", "embedding": [0.4], "source_id": "src1"
}
def test_embedding_failure_writes_nothing(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_emb.embed_documents.side_effect = RuntimeError("embed down")
mock_collection.find_one.return_value = self._existing()
with patch.dict("sys.modules", _fake_bson()):
with pytest.raises(RuntimeError):
store.update_chunk("abc", "new text", {})
mock_collection.update_one.assert_not_called()
@pytest.mark.parametrize("key", ["a.b", "$where", "", "meta.$x"])
def test_keys_mongo_cannot_store_are_rejected_before_any_write(self, key):
from docsgpt.vectorstore.base import InvalidChunkMetadataError
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_collection.find_one.return_value = self._existing()
with patch.dict("sys.modules", _fake_bson()):
with pytest.raises(InvalidChunkMetadataError) as excinfo:
store.update_chunk("abc", "new text", {"title": "T", key: 1})
assert isinstance(excinfo.value, ValueError)
mock_emb.embed_documents.assert_not_called()
mock_collection.update_one.assert_not_called()
def test_dollar_inside_a_key_is_allowed(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_emb.embed_documents.return_value = [[0.4]]
mock_collection.find_one.return_value = self._existing()
mock_collection.update_one.return_value = Mock(matched_count=1)
with patch.dict("sys.modules", _fake_bson()):
store.update_chunk("abc", "new text", {"price$": 1})
_, update = mock_collection.update_one.call_args[0]
assert update["$set"]["price$"] == 1
def test_unknown_id_raises(self):
store, mock_collection, mock_emb = _make_mongodb_store(source_id="src1")
mock_collection.find_one.return_value = None
with patch.dict("sys.modules", _fake_bson()):
with pytest.raises(KeyError):
store.update_chunk("abc", "new text", {})
mock_emb.embed_documents.assert_not_called()
mock_collection.update_one.assert_not_called()
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more