mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
Big source revamp
This commit is contained in:
1 parent
4070b03053
commit
706a0cb2b2
103 files changed
+13687
-1937
No files matched your search
@@ -201,6 +201,9 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/agents/<string:agent_id>/schedules", "GET"): _rule(
|
||||
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
("/api/agents/<string:agent_id>/schedules/stats", "GET"): _rule(
|
||||
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
("/api/agents/<string:agent_id>/schedules", "POST"): _rule(
|
||||
"schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
|
||||
),
|
||||
@@ -225,6 +228,7 @@ RULES: dict[tuple[str, str], Rule] = {
|
||||
("/api/sources/<string:source_id>/graph/node/<string:node_id>", "GET"): _rule(
|
||||
"sources:read", (VIEW, "source_id")
|
||||
),
|
||||
("/api/sources/<string:source_id>/graph/nodes", "GET"): _rule("sources:read", (VIEW, "source_id")),
|
||||
# Ingestion and attachment extraction both report through this poll.
|
||||
("/api/task_status", "GET"): _rule(any_of=("sources:read", "sources:write", "chat:run"), open=True),
|
||||
("/api/upload", "POST"): _rule("sources:write"),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -39,8 +39,49 @@ MAX_SUBGRAPH_EDGES = 2000
|
||||
GRAPH_OVERVIEW_DEFAULT_LIMIT = 100
|
||||
GRAPH_OVERVIEW_MAX_LIMIT = 250
|
||||
|
||||
GRAPH_NODE_LIST_DEFAULT_LIMIT = 25
|
||||
GRAPH_NODE_LIST_MAX_LIMIT = 100
|
||||
# Highest 1-based page the node-list route accepts. Keeps ``OFFSET`` (at most
|
||||
# ~1e8 rows at the max page size) far inside Postgres' bigint range, so a huge
|
||||
# ``page`` query arg yields an empty page with the real total, not an error.
|
||||
GRAPH_NODE_LIST_MAX_PAGE = 1_000_000
|
||||
|
||||
MAX_NODE_RELATIONSHIPS = 300
|
||||
|
||||
PGVECTOR_SOURCE_COLUMN = "source_id"
|
||||
|
||||
def graph_type_key(type_value: Optional[str]) -> str:
|
||||
"""Return the grouping key for a node type.
|
||||
|
||||
``"Person"``, ``"PERSON"`` and ``"per son"`` all fold to ``"person"``, so
|
||||
the extractor's inconsistent spellings land in one facet. Computed in
|
||||
Python on purpose, never in SQL: ``lower()`` and ``[:alnum:]`` follow the
|
||||
database's ``LC_CTYPE``, and on a ``C``-locale database (common on managed
|
||||
Postgres) they fold every Cyrillic or CJK type to ``""``. Must agree with
|
||||
the frontend, which filters and colours by this key with
|
||||
``/[^\\p{L}\\p{N}]/gu`` and ``toLowerCase``.
|
||||
|
||||
Args:
|
||||
type_value: The raw node type, possibly ``None`` or empty.
|
||||
|
||||
Returns:
|
||||
str: The lower-cased type with non-alphanumerics removed; ``""`` for a
|
||||
missing type.
|
||||
"""
|
||||
return "".join(ch for ch in (type_value or "").lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
"""Escape ``value`` so ``LIKE``/``ILIKE`` treats it as a literal.
|
||||
|
||||
Args:
|
||||
value: Raw user text.
|
||||
|
||||
Returns:
|
||||
str: ``value`` with ``\\``, ``%`` and ``_`` backslash-escaped.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _safe_identifier(name: str) -> str:
|
||||
"""Return ``name`` if it is a bare SQL identifier, else raise.
|
||||
@@ -857,6 +898,174 @@ class GraphStore:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def count_edges(self, source_id: str) -> int:
|
||||
"""Number of edges for a source.
|
||||
|
||||
Args:
|
||||
source_id: Source whose edges to count.
|
||||
|
||||
Returns:
|
||||
int: The edge count, or ``0`` when the query fails.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT count(*) FROM graph_edges WHERE source_id = %s;",
|
||||
(source_id,),
|
||||
)
|
||||
return int(cursor.fetchone()[0])
|
||||
except Exception as e:
|
||||
logging.error(f"Error counting edges: {e}")
|
||||
return 0
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def list_nodes(
|
||||
self,
|
||||
source_id: str,
|
||||
query: Optional[str] = None,
|
||||
type_key: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
limit: int = GRAPH_NODE_LIST_DEFAULT_LIMIT,
|
||||
) -> Dict[str, Any]:
|
||||
"""One page of a source's nodes, highest degree first, with the match count.
|
||||
|
||||
Args:
|
||||
source_id: Source whose nodes to list.
|
||||
query: Case-insensitive substring of the node name; ``%``, ``_``
|
||||
and ``\\`` match literally. Blank means no name filter.
|
||||
type_key: Only nodes whose :func:`graph_type_key` equals this;
|
||||
``""`` selects untyped nodes and ``None`` applies no filter.
|
||||
offset: Rows to skip (floored at 0).
|
||||
limit: Page size, clamped to ``1..GRAPH_NODE_LIST_MAX_LIMIT``.
|
||||
|
||||
Returns:
|
||||
dict: ``{"nodes": [{id, name, type, degree, doc_freq}], "total": int}``
|
||||
where ``total`` counts every match, not just this page. A source
|
||||
with no graph yields ``{"nodes": [], "total": 0}``.
|
||||
|
||||
Raises:
|
||||
Exception: Any query failure, so a caller never mistakes a broken
|
||||
query for an empty graph.
|
||||
"""
|
||||
limit = max(1, min(int(limit), GRAPH_NODE_LIST_MAX_LIMIT))
|
||||
offset = max(0, int(offset))
|
||||
clauses = ["source_id = %s"]
|
||||
params: List[Any] = [source_id]
|
||||
clean = (query or "").strip()
|
||||
if clean:
|
||||
clauses.append("name ILIKE %s ESCAPE '\\'")
|
||||
params.append(f"%{_escape_like(clean)}%")
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
if type_key is not None:
|
||||
# The key is folded in Python (see ``graph_type_key``), so the
|
||||
# filter matches the raw spellings whose key equals it.
|
||||
cursor.execute(
|
||||
"SELECT DISTINCT type FROM graph_nodes WHERE source_id = %s;",
|
||||
(source_id,),
|
||||
)
|
||||
raw_types = sorted(
|
||||
row[0]
|
||||
for row in cursor.fetchall()
|
||||
if row[0] is not None and graph_type_key(row[0]) == type_key
|
||||
)
|
||||
if type_key == "":
|
||||
clauses.append("(type IS NULL OR type = ANY(%s))")
|
||||
elif not raw_types:
|
||||
return {"nodes": [], "total": 0}
|
||||
else:
|
||||
clauses.append("type = ANY(%s)")
|
||||
params.append(raw_types)
|
||||
where = " AND ".join(clauses)
|
||||
cursor.execute(
|
||||
f"SELECT count(*) FROM graph_nodes WHERE {where};", tuple(params)
|
||||
)
|
||||
total = int(cursor.fetchone()[0])
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT id, name, type, degree, doc_freq
|
||||
FROM graph_nodes
|
||||
WHERE {where}
|
||||
ORDER BY degree DESC, id
|
||||
LIMIT %s OFFSET %s;
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
)
|
||||
nodes = [
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"degree": row[3],
|
||||
"doc_freq": row[4],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
return {"nodes": nodes, "total": total}
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
|
||||
def node_type_facets(self, source_id: str) -> List[Dict[str, Any]]:
|
||||
"""Node counts per type key over every node of a source.
|
||||
|
||||
Spellings that share a :func:`graph_type_key` are folded into one
|
||||
facet labelled with the most frequent spelling (ties go to the
|
||||
alphabetically first). Untyped nodes form the ``""`` facet with a
|
||||
``None`` label.
|
||||
|
||||
Args:
|
||||
source_id: Source whose node types to count.
|
||||
|
||||
Returns:
|
||||
list: ``[{"key", "label", "count"}]`` sorted by count descending,
|
||||
then label. Empty for a source with no graph.
|
||||
|
||||
Raises:
|
||||
Exception: Any query failure, so a caller never mistakes a broken
|
||||
query for an empty graph.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
# Group by the raw spelling only; the key is folded in Python so it
|
||||
# does not depend on the database locale (see ``graph_type_key``).
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT type, count(*)
|
||||
FROM graph_nodes
|
||||
WHERE source_id = %s
|
||||
GROUP BY type;
|
||||
""",
|
||||
(source_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.rollback()
|
||||
spellings: Dict[str, List[tuple]] = {}
|
||||
for raw_type, n in rows:
|
||||
spellings.setdefault(graph_type_key(raw_type), []).append(
|
||||
(int(n), raw_type)
|
||||
)
|
||||
facets = []
|
||||
for key, variants in spellings.items():
|
||||
label = None
|
||||
if key:
|
||||
# Most frequent spelling; a tie goes to the alphabetically first.
|
||||
label = min(variants, key=lambda v: (-v[0], v[1]))[1]
|
||||
facets.append(
|
||||
{"key": key, "label": label, "count": sum(v[0] for v in variants)}
|
||||
)
|
||||
facets.sort(
|
||||
key=lambda f: (-f["count"], f["label"] is None, f["label"] or "")
|
||||
)
|
||||
return facets
|
||||
|
||||
def count_nodes_many(self, source_ids: List[str]) -> Dict[str, int]:
|
||||
"""Node counts for several sources in one round trip.
|
||||
|
||||
@@ -1390,11 +1599,26 @@ class GraphStore:
|
||||
def get_node_detail(
|
||||
self, source_id: str, node_id: str, max_chunks: int = 20
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""A node's full record plus a bounded list of its linked chunks.
|
||||
"""A node's full record, its relationships and a bounded list of its chunks.
|
||||
|
||||
Returns ``None`` when the node does not belong to the source. Chunk texts
|
||||
are read from the co-located pgvector table; at most ``max_chunks`` are
|
||||
returned so a hub node never streams an unbounded payload.
|
||||
Chunk texts are read from the co-located pgvector table; at most
|
||||
``max_chunks`` are returned so a hub node never streams an unbounded
|
||||
payload. ``relationships`` holds every edge touching the node in either
|
||||
direction (self-loops skipped), joined to the other endpoint, strongest
|
||||
neighbour first and capped at ``MAX_NODE_RELATIONSHIPS``;
|
||||
``relationships_total`` is how many such edges there are, so a capped
|
||||
list can say what it leaves out.
|
||||
|
||||
Args:
|
||||
source_id: Source the node must belong to.
|
||||
node_id: The node's id.
|
||||
max_chunks: Most linked chunks to return.
|
||||
|
||||
Returns:
|
||||
dict | None: ``{id, name, type, description, degree, doc_freq,
|
||||
relationships: [{id, name, type, degree, edge_type, direction}],
|
||||
relationships_total, chunks: [{chunk_id, text, metadata}]}``; ``None`` when the node does
|
||||
not belong to the source or the read fails.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
@@ -1421,6 +1645,15 @@ class GraphStore:
|
||||
"degree": row[4],
|
||||
"doc_freq": row[5],
|
||||
}
|
||||
relationships = self._node_relationships(conn, source_id, node_id)
|
||||
node["relationships"] = relationships
|
||||
node["relationships_total"] = (
|
||||
self._node_relationships_total(
|
||||
conn, source_id, node_id, len(relationships)
|
||||
)
|
||||
if len(relationships) >= MAX_NODE_RELATIONSHIPS
|
||||
else len(relationships)
|
||||
)
|
||||
|
||||
chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get(
|
||||
str(node_id), []
|
||||
@@ -1443,6 +1676,107 @@ class GraphStore:
|
||||
finally:
|
||||
conn.rollback()
|
||||
|
||||
def _node_relationships(
|
||||
self, conn, source_id: str, node_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Edges touching ``node_id``, joined to the neighbour at the other end.
|
||||
|
||||
Args:
|
||||
conn: Open connection; the caller owns the transaction.
|
||||
source_id: Source the edges and neighbours must belong to.
|
||||
node_id: The node whose relationships to read.
|
||||
|
||||
Returns:
|
||||
list: ``[{id, name, type, degree, edge_type, direction}]`` where
|
||||
``direction`` is ``"out"`` when the node is the edge's source,
|
||||
ordered by neighbour degree descending, neighbour id, edge type.
|
||||
Empty when the read fails (the transaction is rolled back).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT o.id, o.name, o.type, o.degree, e.type,
|
||||
CASE WHEN e.src_node_id = %s THEN 'out' ELSE 'in' END AS direction
|
||||
FROM graph_edges e
|
||||
JOIN graph_nodes o
|
||||
ON o.id = CASE WHEN e.src_node_id = %s
|
||||
THEN e.dst_node_id ELSE e.src_node_id END
|
||||
WHERE e.source_id = %s
|
||||
AND (e.src_node_id = %s OR e.dst_node_id = %s)
|
||||
AND e.src_node_id <> e.dst_node_id
|
||||
AND o.source_id = %s
|
||||
ORDER BY o.degree DESC, o.id, e.type
|
||||
LIMIT %s;
|
||||
""",
|
||||
(
|
||||
node_id, node_id, source_id, node_id, node_id, source_id,
|
||||
MAX_NODE_RELATIONSHIPS,
|
||||
),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"degree": row[3],
|
||||
"edge_type": row[4],
|
||||
"direction": row[5],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
# Degrade to no relationships rather than failing the whole node
|
||||
# detail; roll back so the caller's chunk read can reuse ``conn``.
|
||||
logging.error(f"Error reading node relationships: {e}")
|
||||
conn.rollback()
|
||||
return []
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def _node_relationships_total(
|
||||
self, conn, source_id: str, node_id: str, fallback: int
|
||||
) -> int:
|
||||
"""How many edges ``_node_relationships`` would return without its cap.
|
||||
|
||||
Same filters as the list: either direction, self-loops skipped, the
|
||||
neighbour in the same source.
|
||||
|
||||
Args:
|
||||
conn: Open connection; the caller owns the transaction.
|
||||
source_id: Source the edges and neighbours must belong to.
|
||||
node_id: The node whose relationships to count.
|
||||
fallback: What to return when the count fails (the list's length).
|
||||
|
||||
Returns:
|
||||
int: The edge count, or ``fallback`` when the read fails (the
|
||||
transaction is rolled back).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM graph_edges e
|
||||
JOIN graph_nodes o
|
||||
ON o.id = CASE WHEN e.src_node_id = %s
|
||||
THEN e.dst_node_id ELSE e.src_node_id END
|
||||
WHERE e.source_id = %s
|
||||
AND (e.src_node_id = %s OR e.dst_node_id = %s)
|
||||
AND e.src_node_id <> e.dst_node_id
|
||||
AND o.source_id = %s;
|
||||
""",
|
||||
(node_id, source_id, node_id, node_id, source_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return int(row[0]) if row else fallback
|
||||
except Exception as e:
|
||||
logging.error(f"Error counting node relationships: {e}")
|
||||
conn.rollback()
|
||||
return fallback
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def set_node_degrees(self, source_id: str):
|
||||
"""Recompute every node's degree from its incident edges for a source.
|
||||
|
||||
@@ -1506,6 +1840,74 @@ class GraphStore:
|
||||
|
||||
return self._write_with_reconnect(_write)
|
||||
|
||||
def remap_chunk(self, source_id: str, old_chunk_id: str, new_chunk_id: str) -> None:
|
||||
"""Point every graph reference to a chunk at its replacement id.
|
||||
|
||||
A chunk edit re-adds the chunk under a new id and deletes the old row,
|
||||
so without this the node links, edge provenance and extraction
|
||||
checkpoint would all name a chunk that no longer exists. The graph
|
||||
itself is not re-extracted from the edited text.
|
||||
|
||||
Args:
|
||||
source_id: Source the chunk belongs to.
|
||||
old_chunk_id: The id the graph currently references.
|
||||
new_chunk_id: The id of the chunk that replaced it.
|
||||
|
||||
Raises:
|
||||
Exception: The underlying write failure, after a rollback.
|
||||
"""
|
||||
self._ensure_tables_once()
|
||||
old_id, new_id = str(old_chunk_id), str(new_chunk_id)
|
||||
|
||||
def _write(conn):
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
_lock_source(cursor, source_id)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_node_chunks SET chunk_id = %s
|
||||
WHERE source_id = %s AND chunk_id = %s;
|
||||
""",
|
||||
(new_id, source_id, old_id),
|
||||
)
|
||||
# Rewrite the matching element in place, keeping array order.
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_edges
|
||||
SET source_chunk_ids = (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN t.v #>> '{}' = %s THEN to_jsonb(%s::text) ELSE t.v END
|
||||
ORDER BY t.ord
|
||||
)
|
||||
FROM jsonb_array_elements(source_chunk_ids)
|
||||
WITH ORDINALITY AS t(v, ord)
|
||||
)
|
||||
WHERE source_id = %s
|
||||
AND jsonb_typeof(source_chunk_ids) = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(source_chunk_ids) AS s(v)
|
||||
WHERE s.v #>> '{}' = %s
|
||||
);
|
||||
""",
|
||||
(old_id, new_id, source_id, old_id),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE graph_ingest_progress SET chunk_id = %s
|
||||
WHERE source_id = %s AND chunk_id = %s;
|
||||
""",
|
||||
(new_id, source_id, old_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
_safe_rollback(conn)
|
||||
logging.error(f"Error remapping graph chunk: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
return self._write_with_reconnect(_write)
|
||||
|
||||
def pending_chunks(self, source_id: str, all_chunk_ids: List[str]) -> List[str]:
|
||||
"""Chunk ids from ``all_chunk_ids`` not yet marked ``done`` for the source."""
|
||||
if not all_chunk_ids:
|
||||
|
||||
@@ -1,10 +1,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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -4,7 +4,12 @@ import re
|
||||
import defusedxml.ElementTree as ET
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import (
|
||||
BaseRemote,
|
||||
dedupe_virtual_paths,
|
||||
spans_multiple_hosts,
|
||||
url_to_virtual_path,
|
||||
)
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.core.url_validation import validate_url, SSRFError
|
||||
from docsgpt.security.safe_url import UnsafeUserUrlError, pinned_request
|
||||
@@ -32,6 +37,7 @@ class SitemapLoader(BaseRemote):
|
||||
return []
|
||||
|
||||
# Load content of extracted URLs
|
||||
include_host = spans_multiple_hosts(urls)
|
||||
documents = []
|
||||
processed_urls = 0 # Counter for processed URLs
|
||||
for url in urls:
|
||||
@@ -50,7 +56,13 @@ class SitemapLoader(BaseRemote):
|
||||
documents.append(
|
||||
Document(
|
||||
soup.get_text(separator="\n", strip=True),
|
||||
extra_info={"source": url},
|
||||
# Without file_path the worker had no tree key (no
|
||||
# title, key or doc_id), so sitemap pages never
|
||||
# appeared in the file tree.
|
||||
extra_info={
|
||||
"source": url,
|
||||
"file_path": url_to_virtual_path(url, include_host),
|
||||
},
|
||||
)
|
||||
)
|
||||
processed_urls += 1 # Increment the counter after processing each URL
|
||||
@@ -58,7 +70,7 @@ class SitemapLoader(BaseRemote):
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
return documents
|
||||
return dedupe_virtual_paths(documents)
|
||||
|
||||
def _extract_urls(self, sitemap_url):
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,12 @@ import logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from docsgpt.core.url_validation import SSRFError, validate_url
|
||||
from docsgpt.parser.remote.base import BaseRemote
|
||||
from docsgpt.parser.remote.base import (
|
||||
BaseRemote,
|
||||
dedupe_virtual_paths,
|
||||
spans_multiple_hosts,
|
||||
url_to_virtual_path,
|
||||
)
|
||||
from docsgpt.parser.schema.base import Document
|
||||
from docsgpt.security.safe_url import pinned_request
|
||||
|
||||
@@ -24,15 +29,17 @@ class WebLoader(BaseRemote):
|
||||
urls = inputs
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
documents = []
|
||||
valid_urls = []
|
||||
for url in urls:
|
||||
try:
|
||||
url = validate_url(url)
|
||||
valid_urls.append(validate_url(url))
|
||||
except SSRFError as e:
|
||||
logging.warning(
|
||||
f"Skipping URL due to SSRF validation failure: {url} - {e}"
|
||||
)
|
||||
continue
|
||||
include_host = spans_multiple_hosts(valid_urls)
|
||||
documents = []
|
||||
for url in valid_urls:
|
||||
try:
|
||||
response = pinned_request("GET", url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
@@ -45,6 +52,9 @@ class WebLoader(BaseRemote):
|
||||
html_tag = soup.find("html")
|
||||
if html_tag and html_tag.get("lang"):
|
||||
metadata["language"] = html_tag.get("lang")
|
||||
# The worker keys the file tree by file_path; without it the
|
||||
# tree fell back to the title, which the chunks view can't match.
|
||||
metadata["file_path"] = url_to_virtual_path(url, include_host)
|
||||
documents.append(
|
||||
Document(
|
||||
soup.get_text(separator="\n", strip=True),
|
||||
@@ -54,4 +64,4 @@ class WebLoader(BaseRemote):
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
return documents
|
||||
return dedupe_virtual_paths(documents)
|
||||
@@ -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``.
|
||||
|
||||
|
||||
@@ -306,6 +306,58 @@ class FaissStore(BaseVectorStore):
|
||||
self._save_to_storage()
|
||||
return ids[0]
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Replace a chunk in place, keeping its id and its place in the docstore.
|
||||
|
||||
The new vector is computed before anything changes, so a failed embed
|
||||
leaves the chunk as it was. Its old row is removed from the index and
|
||||
the new vector appended; the row mapping is renumbered to match, and
|
||||
the docstore entry is replaced where it stands so :meth:`get_chunks`
|
||||
keeps its order. Saved to storage once.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If ``chunk_id`` is not in this index.
|
||||
ValueError: If the new vector's width does not match the index.
|
||||
"""
|
||||
if chunk_id not in self.documents:
|
||||
raise KeyError(f"Chunk id not found in index: {chunk_id}")
|
||||
rows_by_id = {doc_id: row for row, doc_id in self.index_to_docstore_id.items()}
|
||||
if chunk_id not in rows_by_id:
|
||||
raise KeyError(f"Chunk id has no row in the FAISS index: {chunk_id}")
|
||||
|
||||
vector = np.array(self.embeddings.embed_documents([text]), dtype=np.float32)
|
||||
if vector.ndim != 2 or vector.shape != (1, self.index.d):
|
||||
raise ValueError(
|
||||
f"Embedding for chunk {chunk_id} has shape {vector.shape}, "
|
||||
f"expected (1, {self.index.d})"
|
||||
)
|
||||
|
||||
row = rows_by_id[chunk_id]
|
||||
self.index.remove_ids(np.array([row], dtype=np.int64))
|
||||
self.index.add(vector)
|
||||
# remove_ids compacts the index and add appends, so the edited chunk's
|
||||
# vector is now the last row; renumber as delete_index does.
|
||||
remaining = [
|
||||
doc_id
|
||||
for current_row, doc_id in sorted(self.index_to_docstore_id.items())
|
||||
if current_row != row
|
||||
]
|
||||
self.index_to_docstore_id = dict(enumerate(remaining + [chunk_id]))
|
||||
self.documents[chunk_id] = {
|
||||
"page_content": text,
|
||||
"metadata": dict(metadata or {}),
|
||||
}
|
||||
self._save_to_storage()
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a chunk and save to storage."""
|
||||
self.delete_index([chunk_id])
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from functools import cached_property
|
||||
|
||||
from docsgpt.core.settings import settings
|
||||
from docsgpt.vectorstore.base import BaseVectorStore
|
||||
from docsgpt.vectorstore.base import BaseVectorStore, InvalidChunkMetadataError
|
||||
from docsgpt.vectorstore.document_class import Document
|
||||
|
||||
|
||||
@@ -224,6 +224,68 @@ class MongoDBVectorStore(BaseVectorStore):
|
||||
result = self._collection.insert_one(chunk_data)
|
||||
return str(result.inserted_id)
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: dict) -> str:
|
||||
"""Rewrite a chunk's document in place, keeping its ``_id``.
|
||||
|
||||
Metadata lives as top-level fields, so the new metadata is ``$set``
|
||||
and any field the record carries but the new metadata lacks is
|
||||
``$unset``; the record then holds exactly the new metadata. ``_id``,
|
||||
the text, the embedding and ``source_id`` are reserved and cannot be
|
||||
overwritten through ``metadata``. The embedding is computed before
|
||||
anything is written.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no chunk with that id.
|
||||
InvalidChunkMetadataError: If a metadata key is empty, contains
|
||||
``.`` or starts with ``$``. Mongo reads those as a path or an
|
||||
operator, so ``$set`` would fail or write a nested field.
|
||||
ValueError: If no embedding could be generated.
|
||||
"""
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
for key in metadata or {}:
|
||||
if not isinstance(key, str) or not key or "." in key or key.startswith("$"):
|
||||
raise InvalidChunkMetadataError(
|
||||
f"Metadata key {key!r} is not allowed: keys must be non-empty, "
|
||||
"contain no '.' and not start with '$'"
|
||||
)
|
||||
|
||||
query = {"_id": ObjectId(chunk_id), "source_id": self._source_id}
|
||||
existing = self._collection.find_one(query)
|
||||
if existing is None:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
|
||||
embeddings = self._embedding.embed_documents([text])
|
||||
if not embeddings:
|
||||
raise ValueError("Could not generate embedding for chunk")
|
||||
|
||||
reserved = {"_id", self._text_key, self._embedding_key, "source_id"}
|
||||
fields = {k: v for k, v in (metadata or {}).items() if k not in reserved}
|
||||
update = {
|
||||
"$set": {
|
||||
self._text_key: text,
|
||||
self._embedding_key: embeddings[0],
|
||||
"source_id": self._source_id,
|
||||
**fields,
|
||||
}
|
||||
}
|
||||
stale = {k: "" for k in existing if k not in reserved and k not in fields}
|
||||
if stale:
|
||||
update["$unset"] = stale
|
||||
|
||||
result = self._collection.update_one(query, update)
|
||||
if result.matched_count == 0:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id):
|
||||
try:
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
@@ -637,7 +637,7 @@ class PGVectorStore(BaseVectorStore):
|
||||
select_query = f"""
|
||||
SELECT id, {self._text_column}, {self._metadata_column}
|
||||
FROM {self._table_name}
|
||||
WHERE source_id = %s;
|
||||
WHERE source_id = %s ORDER BY id;
|
||||
"""
|
||||
cursor.execute(select_query, (self._source_id,))
|
||||
results = cursor.fetchall()
|
||||
@@ -704,6 +704,58 @@ class PGVectorStore(BaseVectorStore):
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Rewrite a chunk's row in place, keeping its id.
|
||||
|
||||
One ``UPDATE`` scoped to this source. The embedding is computed first,
|
||||
so a failed embed writes nothing. ``get_chunks`` orders by id, so the
|
||||
chunk also keeps its place in the list.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the chunk to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata; ``source_id`` is
|
||||
stamped on it as :meth:`add_chunk` does.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no chunk with that id.
|
||||
ValueError: If no embedding could be generated.
|
||||
"""
|
||||
final_metadata = dict(metadata or {})
|
||||
final_metadata["source_id"] = self._source_id
|
||||
|
||||
embeddings = self._embedding.embed_documents([text])
|
||||
if not embeddings:
|
||||
raise ValueError("Could not generate embedding for chunk")
|
||||
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
update_query = f"""
|
||||
UPDATE {self._table_name}
|
||||
SET {self._text_column} = %s, {self._vector_column} = %s, {self._metadata_column} = %s
|
||||
WHERE id = %s AND source_id = %s;
|
||||
"""
|
||||
cursor.execute(
|
||||
update_query,
|
||||
(text, embeddings[0], Jsonb(final_metadata), int(chunk_id), self._source_id),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
conn.commit()
|
||||
return str(chunk_id)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Error updating chunk: {e}")
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a specific chunk by its ID"""
|
||||
conn = self._get_connection()
|
||||
|
||||
@@ -206,6 +206,50 @@ class QdrantStore(BaseVectorStore):
|
||||
ids = self.add_texts([text], [metadata or {}])
|
||||
return ids[0]
|
||||
|
||||
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
|
||||
"""Overwrite a chunk's point in place, keeping its id.
|
||||
|
||||
Upserting the same id replaces the vector and payload; ``scroll``
|
||||
orders by point id, so the chunk also keeps its place in
|
||||
:meth:`get_chunks`. The payload has the shape :meth:`add_texts` writes.
|
||||
|
||||
Args:
|
||||
chunk_id: Id of the point to replace.
|
||||
text: The chunk's new text.
|
||||
metadata: The chunk's complete new metadata; ``source_id`` is
|
||||
stamped on it for source scoping.
|
||||
|
||||
Returns:
|
||||
``chunk_id``, unchanged.
|
||||
|
||||
Raises:
|
||||
KeyError: If this source has no point with that id.
|
||||
"""
|
||||
records = self._client.retrieve(
|
||||
collection_name=self._collection,
|
||||
ids=[chunk_id],
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
payload = (records[0].payload or {}) if records else {}
|
||||
if (payload.get("metadata") or {}).get("source_id") != self._source_id:
|
||||
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
|
||||
|
||||
vector = self._embeddings.embed_documents([text])[0]
|
||||
payload_metadata = dict(metadata or {})
|
||||
payload_metadata["source_id"] = self._source_id
|
||||
self._client.upsert(
|
||||
collection_name=self._collection,
|
||||
points=[
|
||||
self._models.PointStruct(
|
||||
id=chunk_id,
|
||||
vector=vector,
|
||||
payload={"page_content": text, "metadata": payload_metadata},
|
||||
)
|
||||
],
|
||||
)
|
||||
return chunk_id
|
||||
|
||||
def delete_chunk(self, chunk_id: str) -> bool:
|
||||
"""Delete a single chunk by id."""
|
||||
try:
|
||||
|
||||
+217
-32
@@ -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.
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+657
-574
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,147 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => vi.fn(),
|
||||
useStore: () => ({ getState: () => ({}), subscribe: () => () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDarkTheme: () => [false],
|
||||
useDebouncedValue: (value: unknown) => value,
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
useOutsideAlerter: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getDirectoryStructure: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
provider: 'google_drive',
|
||||
directory_structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
},
|
||||
}),
|
||||
})),
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ page: 1, per_page: 12, total: 0, chunks: [] }),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
import ConnectorTree, { providerLabel } from './ConnectorTree';
|
||||
import FileTree from './FileTree';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('providerLabel', () => {
|
||||
it('names known providers and title-cases the rest', () => {
|
||||
expect(providerLabel('google_drive')).toBe('Google Drive');
|
||||
expect(providerLabel('share_point')).toBe('SharePoint');
|
||||
expect(providerLabel('box_sync')).toBe('Box Sync');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectorTree and FileTree headers', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (node: React.ReactNode) => {
|
||||
await act(async () => root.render(node));
|
||||
};
|
||||
|
||||
const badge = () => container.querySelector('[data-slot="badge"]');
|
||||
const retrieval = <button type="button">retrieval</button>;
|
||||
|
||||
it('ConnectorTree shows the provider badge, headerAction and Sync', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()!.textContent).toBe('Google Drive');
|
||||
expect(badge()!.getAttribute('data-variant')).toBe('neutral');
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.sync');
|
||||
});
|
||||
|
||||
it('embedded ConnectorTree drops the badge and headerAction, keeps Sync', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
embedded
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()).toBeNull();
|
||||
expect(container.textContent).not.toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.sync');
|
||||
expect(container.textContent).not.toContain('settings.sources.label');
|
||||
});
|
||||
|
||||
it('embedded FileTree drops headerAction, keeps Add file', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
embedded
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
/>,
|
||||
);
|
||||
expect(badge()).toBeNull();
|
||||
expect(container.textContent).not.toContain('retrieval');
|
||||
expect(container.textContent).toContain('settings.sources.addFile');
|
||||
});
|
||||
|
||||
const crumbs = () =>
|
||||
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
|
||||
it('ConnectorTree and FileTree open the initialPath file', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
initialPath="b.docx"
|
||||
/>,
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Drive', 'b.docx']);
|
||||
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
initialPath="a.docx"
|
||||
/>,
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Check, RefreshCw } from 'lucide-react';
|
||||
import { Check, Cloud, RefreshCw } from 'lucide-react';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { Crumb } from './tree/PathHeader';
|
||||
import TreeBrowser from './tree/TreeBrowser';
|
||||
import type { TreeBrowserController } from './tree/types';
|
||||
import { useReingestSseWaiter } from './tree/useReingestWait';
|
||||
@@ -19,6 +21,38 @@ interface ConnectorTreeProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control, rendered left of the Sync button. */
|
||||
headerAction?: React.ReactNode;
|
||||
/**
|
||||
* Inside another source view (the graph source's Files tab): no Sources
|
||||
* crumb, badge or byline, and no headerAction; Sync stays.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
/** Embedded only: the host header's action slot (see TreeBrowser). */
|
||||
actionsTarget?: HTMLElement | null;
|
||||
/** A file to open once the structure loads (path, file name or display name). */
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
}
|
||||
|
||||
// Provider names are brand names, so they are not translated.
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
google_drive: 'Google Drive',
|
||||
share_point: 'SharePoint',
|
||||
confluence: 'Confluence',
|
||||
};
|
||||
|
||||
/**
|
||||
* The display name of a connector provider: a known brand name, else the raw
|
||||
* id with underscores as spaces, title-cased ("box_sync" → "Box Sync").
|
||||
*/
|
||||
export function providerLabel(provider: string): string {
|
||||
const known = PROVIDER_LABELS[provider.toLowerCase()];
|
||||
if (known) return known;
|
||||
return provider
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
@@ -26,6 +60,10 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
embedded = false,
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -106,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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,103 +1,193 @@
|
||||
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
|
||||
import { ArrowLeft, Network, X } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
|
||||
import ForceGraph2D, {
|
||||
type ForceGraphMethods,
|
||||
type LinkObject,
|
||||
type NodeObject,
|
||||
} from 'react-force-graph-2d';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import { useThemeVersion } from '../utils/chartUtils';
|
||||
import { Badge } from './ui/badge';
|
||||
import { IconButton } from './ui/icon-button';
|
||||
import { SectionHeader } from './ui/section-header';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import { formatCount } from '../utils/dateTimeUtils';
|
||||
import GraphCanvasControls from './graph/GraphCanvasControls';
|
||||
import GraphEntitySearch from './graph/GraphEntitySearch';
|
||||
import GraphNodePanel, {
|
||||
GraphNodePanelDock,
|
||||
type GraphNodeRef,
|
||||
} from './graph/GraphNodePanel';
|
||||
import { GraphSeriesDot } from './graph/GraphTypeDot';
|
||||
import {
|
||||
ForceGraphData,
|
||||
GraphNode,
|
||||
GraphNodeDetail,
|
||||
GraphOverview,
|
||||
DIM_ALPHA,
|
||||
OTHER_GROUP_KEY,
|
||||
buildAdjacency,
|
||||
endpointId,
|
||||
focusSet,
|
||||
legendGroupOf,
|
||||
linkTouches,
|
||||
nodeHasLabel,
|
||||
pickLabels,
|
||||
topHubIds,
|
||||
type LabelBox,
|
||||
} from './graph/graphCanvasUtils';
|
||||
import { useGraphNodeDetail } from './graph/useGraphNodeDetail';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { EmptyState } from './ui/empty-state';
|
||||
import { LoadingState } from './ui/loading-state';
|
||||
import { Sheet, SheetContent } from './ui/sheet';
|
||||
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
|
||||
import {
|
||||
type FoldedGraphTypes,
|
||||
type ForceGraphData,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
collideRadius,
|
||||
escapeDeselects,
|
||||
maxDegree,
|
||||
nodeAtPoint,
|
||||
nodeRadius,
|
||||
otherTypesList,
|
||||
readGraphPalette,
|
||||
toForceGraphData,
|
||||
} from './graphViewUtils';
|
||||
|
||||
/** The overview sizes the "Show top" control offers. */
|
||||
const GRAPH_LIMITS = [50, 100, 250] as const;
|
||||
|
||||
export type GraphLoadStatus = 'loading' | 'error' | 'ready';
|
||||
|
||||
interface GraphViewProps {
|
||||
docId: string;
|
||||
sourceName: string;
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control, right-aligned in the title row. */
|
||||
headerAction?: React.ReactNode;
|
||||
/** The loaded overview (top nodes by degree and the edges among them). */
|
||||
data: ForceGraphData;
|
||||
/** The one type fold shared with the header, the entity list and the panel. */
|
||||
fold: FoldedGraphTypes;
|
||||
status: GraphLoadStatus;
|
||||
onRetry: () => void;
|
||||
limit: number;
|
||||
onLimitChange: (limit: number) => void;
|
||||
selected: GraphNodeRef | null;
|
||||
onSelect: (node: GraphNodeRef | null) => void;
|
||||
/** False while another tab is showing; Escape then leaves the selection alone. */
|
||||
active?: boolean;
|
||||
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
}
|
||||
|
||||
const GRAPH_LIMIT = 100;
|
||||
const HIT_SLOP = 4;
|
||||
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
|
||||
|
||||
const HIT_SLOP = 4;
|
||||
const ZOOM_STEP = 1.3;
|
||||
const ZOOM_MS = 200;
|
||||
const FOCUS_MS = 400;
|
||||
const FOCUS_ZOOM = 2;
|
||||
const FIT_PADDING = 40;
|
||||
const LABEL_PX = 11;
|
||||
/** Screen-pixel clearance kept between two labels. */
|
||||
const LABEL_GAP_PX = 2;
|
||||
|
||||
/**
|
||||
* The Graph tab of a knowledge-graph source: entity search, the "Show top"
|
||||
* size, the type legend (a filter), and the canvas with its controls and the
|
||||
* docked node panel (a bottom sheet on a phone).
|
||||
*/
|
||||
const GraphView: React.FC<GraphViewProps> = ({
|
||||
docId,
|
||||
sourceName,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
data,
|
||||
fold,
|
||||
status,
|
||||
onRetry,
|
||||
limit,
|
||||
onLimitChange,
|
||||
selected,
|
||||
onSelect,
|
||||
active = true,
|
||||
onOpenInFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNodeDetail | null>(
|
||||
null,
|
||||
);
|
||||
const [loadingNode, setLoadingNode] = useState(false);
|
||||
const { isDesktop } = useMediaQuery();
|
||||
const showTopId = useId();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const hoveredNodeIdRef = useRef<string | null>(null);
|
||||
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
|
||||
const [size, setSize] = useState({ width: 0, height: 480 });
|
||||
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphEdge> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const hoveredIdRef = useRef<string | null>(null);
|
||||
const fittedRef = useRef(false);
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
|
||||
// The canvas can't read CSS variables: resolve the tokens, and re-read them
|
||||
// whenever the theme changes.
|
||||
const themeVersion = useThemeVersion();
|
||||
// themeVersion is not used inside the factory: it only signals a change.
|
||||
const palette = useMemo(() => readGraphPalette(), [themeVersion]);
|
||||
const fadedLink = `color-mix(in srgb, ${palette.link} 15%, transparent)`;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
userService
|
||||
.getSourceGraph(docId, token, GRAPH_LIMIT)
|
||||
.then((response) => response.json())
|
||||
.then((body) => {
|
||||
if (cancelled) return;
|
||||
const overview: GraphOverview = {
|
||||
nodes: body?.nodes ?? [],
|
||||
edges: body?.edges ?? [],
|
||||
};
|
||||
setData(toForceGraphData(overview));
|
||||
})
|
||||
.catch((error) => console.error('Error loading graph:', error))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [docId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const element = containerRef.current;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setSize({ width: element.clientWidth, height: 480 });
|
||||
});
|
||||
observer.observe(element);
|
||||
setSize({ width: element.clientWidth, height: 480 });
|
||||
return () => observer.disconnect();
|
||||
}, [loading, data.nodes.length]);
|
||||
const selectedId = selected?.id ?? null;
|
||||
const nodeDetail = useGraphNodeDetail(docId, selectedId);
|
||||
|
||||
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
|
||||
const nodeById = useMemo(
|
||||
() => new Map(data.nodes.map((node) => [node.id, node])),
|
||||
[data.nodes],
|
||||
);
|
||||
const loadedIds = useMemo(() => new Set(nodeById.keys()), [nodeById]);
|
||||
const adjacency = useMemo(() => buildAdjacency(data.links), [data.links]);
|
||||
const focus = useMemo(
|
||||
() => focusSet(selectedId, adjacency, loadedIds),
|
||||
[selectedId, adjacency, loadedIds],
|
||||
);
|
||||
|
||||
const legendKeys = useMemo(
|
||||
() => [
|
||||
...fold.groups.map((group) => group.key),
|
||||
...(fold.other.count > 0 ? [OTHER_GROUP_KEY] : []),
|
||||
],
|
||||
[fold],
|
||||
);
|
||||
|
||||
const isVisible = useCallback(
|
||||
(node: GraphNode | undefined) =>
|
||||
!!node && !hidden.has(legendGroupOf(fold, node.type)),
|
||||
[fold, hidden],
|
||||
);
|
||||
const visibleNodes = useMemo(
|
||||
() => data.nodes.filter((node) => isVisible(node)),
|
||||
[data.nodes, isVisible],
|
||||
);
|
||||
|
||||
// The busiest of what's shown keep their labels.
|
||||
const hubs = useMemo(() => topHubIds(visibleNodes), [visibleNodes]);
|
||||
|
||||
// A new overview (another "Show top") starts with a fresh fit.
|
||||
useEffect(() => {
|
||||
fittedRef.current = false;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => {
|
||||
// A hidden tab measures 0: keep the last size so the canvas survives.
|
||||
if (element.clientWidth === 0) return;
|
||||
setSize({ width: element.clientWidth, height: element.clientHeight });
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
measure();
|
||||
return () => observer.disconnect();
|
||||
}, [status, data.nodes.length]);
|
||||
|
||||
const hasCanvas = size.width > 0;
|
||||
useEffect(() => {
|
||||
if (!fgRef.current || data.nodes.length === 0) return;
|
||||
fgRef.current.d3Force(
|
||||
@@ -107,239 +197,480 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
),
|
||||
);
|
||||
fgRef.current.d3ReheatSimulation();
|
||||
}, [data, maxNodeDegree, size.width]);
|
||||
}, [data, maxNodeDegree, hasCanvas]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setLoadingNode(true);
|
||||
setSelectedNode(null);
|
||||
userService
|
||||
.getSourceGraphNode(docId, node.id, token)
|
||||
.then((response) => response.json())
|
||||
.then((body) => {
|
||||
if (body?.node) setSelectedNode(body.node as GraphNodeDetail);
|
||||
})
|
||||
.catch((error) => console.error('Error loading graph node:', error))
|
||||
.finally(() => setLoadingNode(false));
|
||||
};
|
||||
// A settled simulation stops drawing, so paint changes once.
|
||||
const repaint = useCallback(() => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
repaint();
|
||||
}, [palette, focus, hidden, fold, repaint]);
|
||||
|
||||
const fitView = useCallback(
|
||||
(durationMs = FOCUS_MS) => {
|
||||
fgRef.current?.zoomToFit(durationMs, FIT_PADDING, (node) =>
|
||||
isVisible(node as GraphNode),
|
||||
);
|
||||
},
|
||||
[isVisible],
|
||||
);
|
||||
|
||||
// Centre the selection; zoom in once per new selection. Re-centre when the
|
||||
// docked panel changes the canvas width.
|
||||
const zoomedForRef = useRef<string | null>(null);
|
||||
const hadSelectionRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const fg = fgRef.current;
|
||||
if (!fg) return;
|
||||
if (!selectedId) {
|
||||
zoomedForRef.current = null;
|
||||
if (!hadSelectionRef.current) return;
|
||||
hadSelectionRef.current = false;
|
||||
// Deselecting hands the width back to the canvas: refit.
|
||||
const frame = requestAnimationFrame(() => fitView());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
hadSelectionRef.current = true;
|
||||
const node = nodeById.get(selectedId) as PositionedNode | undefined;
|
||||
if (!node || node.x == null || node.y == null) return;
|
||||
const { x, y } = node;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
fg.centerAt(x, y, FOCUS_MS);
|
||||
if (zoomedForRef.current !== selectedId) {
|
||||
zoomedForRef.current = selectedId;
|
||||
fg.zoom(Math.max(fg.zoom(), FOCUS_ZOOM), FOCUS_MS);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [selectedId, nodeById, size.width, fitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !selectedId) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (escapeDeselects(event)) onSelect(null);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [active, selectedId, onSelect]);
|
||||
|
||||
// Geometric hit test, immune to canvas read-back farbling (e.g. Brave): map
|
||||
// the pointer into graph coordinates and pick the nearest node directly.
|
||||
// the pointer into graph coordinates and pick the nearest visible node.
|
||||
const pickNodeAt = (clientX: number, clientY: number): GraphNode | null => {
|
||||
const fg = fgRef.current;
|
||||
if (!fg || !containerRef.current) return null;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const g = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top);
|
||||
return nodeAtPoint(data.nodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
|
||||
return nodeAtPoint(visibleNodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
|
||||
};
|
||||
|
||||
const repaint = () => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom());
|
||||
};
|
||||
|
||||
// A settled simulation stops drawing, so paint the new colours once.
|
||||
useEffect(() => {
|
||||
repaint();
|
||||
}, [palette]);
|
||||
const onCanvas = (event: React.MouseEvent) =>
|
||||
event.target instanceof HTMLCanvasElement;
|
||||
|
||||
const handlePointerMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const node = pickNodeAt(event.clientX, event.clientY);
|
||||
const node = onCanvas(event)
|
||||
? pickNodeAt(event.clientX, event.clientY)
|
||||
: null;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.cursor = node ? 'pointer' : 'default';
|
||||
}
|
||||
const nextId = node?.id ?? null;
|
||||
if (nextId !== hoveredNodeIdRef.current) {
|
||||
hoveredNodeIdRef.current = nextId;
|
||||
if (nextId !== hoveredIdRef.current) {
|
||||
hoveredIdRef.current = nextId;
|
||||
repaint();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = 'default';
|
||||
if (hoveredNodeIdRef.current !== null) {
|
||||
hoveredNodeIdRef.current = null;
|
||||
if (hoveredIdRef.current !== null) {
|
||||
hoveredIdRef.current = null;
|
||||
repaint();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Clicks on the controls strip are not canvas clicks.
|
||||
if (!onCanvas(event)) return;
|
||||
const node = pickNodeAt(event.clientX, event.clientY);
|
||||
if (node) handleNodeClick(node);
|
||||
if (node) onSelect({ id: node.id, name: node.name, type: node.type });
|
||||
else if (selectedId) onSelect(null);
|
||||
};
|
||||
|
||||
const isEmpty = !loading && data.nodes.length === 0;
|
||||
const nodeVisibility = useCallback(
|
||||
(node: NodeObject<GraphNode>) => isVisible(node as GraphNode),
|
||||
[isVisible],
|
||||
);
|
||||
const linkVisibility = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) =>
|
||||
isVisible(nodeById.get(endpointId(link.source as never))) &&
|
||||
isVisible(nodeById.get(endpointId(link.target as never))),
|
||||
[isVisible, nodeById],
|
||||
);
|
||||
const linkColor = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) => {
|
||||
if (!focus) return palette.link;
|
||||
return linkTouches(link as GraphEdge, selectedId)
|
||||
? palette.primary
|
||||
: fadedLink;
|
||||
},
|
||||
[focus, selectedId, palette, fadedLink],
|
||||
);
|
||||
const linkWidth = useCallback(
|
||||
(link: LinkObject<GraphNode, GraphEdge>) =>
|
||||
focus && linkTouches(link as GraphEdge, selectedId) ? 1.5 : 1,
|
||||
[focus, selectedId],
|
||||
);
|
||||
const nodeVal = useCallback(
|
||||
(node: NodeObject<GraphNode>) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree),
|
||||
[maxNodeDegree],
|
||||
);
|
||||
|
||||
const nodeCanvasObject = useCallback(
|
||||
(
|
||||
node: NodeObject<GraphNode>,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
globalScale: number,
|
||||
) => {
|
||||
const graphNode = node as PositionedNode;
|
||||
if (graphNode.x == null || graphNode.y == null) return;
|
||||
const { x, y } = graphNode;
|
||||
const r = nodeRadius(graphNode.degree, maxNodeDegree);
|
||||
const hoveredId = hoveredIdRef.current;
|
||||
const isSelected = graphNode.id === selectedId;
|
||||
const series = fold.seriesOf(graphNode.type);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = focus && !focus.has(graphNode.id) ? DIM_ALPHA : 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle =
|
||||
series == null
|
||||
? palette.other
|
||||
: (palette.series[series] ?? palette.other);
|
||||
ctx.fill();
|
||||
if (isSelected || graphNode.id === hoveredId) {
|
||||
ctx.lineWidth = (isSelected ? 2.5 : 1.5) / globalScale;
|
||||
ctx.strokeStyle = palette.hoverStroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
[maxNodeDegree, selectedId, fold, focus, palette],
|
||||
);
|
||||
|
||||
// Labels go on top of every node (a per-node label would sit under the
|
||||
// nodes drawn after it): one pass after the frame. In priority order (the
|
||||
// selection, the hovered node, then by degree) a label that would sit on
|
||||
// one already placed is skipped; the first two always draw.
|
||||
const drawLabels = useCallback(
|
||||
(ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const hoveredId = hoveredIdRef.current;
|
||||
const rank = (node: GraphNode) =>
|
||||
node.id === selectedId ? 0 : node.id === hoveredId ? 1 : 2;
|
||||
const labelled = (visibleNodes as PositionedNode[])
|
||||
.filter(
|
||||
(node) =>
|
||||
node.x != null &&
|
||||
node.y != null &&
|
||||
nodeHasLabel(node.id, { hubs, focus, hoveredId }),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
rank(a) - rank(b) ||
|
||||
(b.degree || 0) - (a.degree || 0) ||
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
ctx.save();
|
||||
ctx.font = `${LABEL_PX / globalScale}px ${palette.font}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = 3 / globalScale;
|
||||
ctx.lineJoin = 'round';
|
||||
const boxes: (LabelBox & { label: string })[] = labelled.map((node) => {
|
||||
const label = node.name ?? '';
|
||||
return {
|
||||
id: node.id,
|
||||
label,
|
||||
x: node.x as number,
|
||||
y:
|
||||
(node.y as number) +
|
||||
nodeRadius(node.degree, maxNodeDegree) +
|
||||
2 / globalScale,
|
||||
width: ctx.measureText(label).width,
|
||||
height: LABEL_PX / globalScale,
|
||||
always: rank(node) < 2,
|
||||
};
|
||||
});
|
||||
const shown = pickLabels(boxes, LABEL_GAP_PX / globalScale);
|
||||
for (const box of boxes) {
|
||||
if (!shown.has(box.id)) continue;
|
||||
ctx.strokeStyle = palette.halo;
|
||||
ctx.strokeText(box.label, box.x, box.y);
|
||||
ctx.fillStyle = palette.label;
|
||||
ctx.fillText(box.label, box.x, box.y);
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
[visibleNodes, hubs, focus, maxNodeDegree, palette, selectedId],
|
||||
);
|
||||
|
||||
// Once a new layout settles: fit it, or, with a node selected, centre on
|
||||
// that node (its position only exists now; the focus effect ran before).
|
||||
const handleEngineStop = useCallback(() => {
|
||||
if (fittedRef.current) return;
|
||||
fittedRef.current = true;
|
||||
const node = selectedId
|
||||
? (nodeById.get(selectedId) as PositionedNode | undefined)
|
||||
: undefined;
|
||||
if (node && node.x != null && node.y != null) {
|
||||
fgRef.current?.centerAt(node.x, node.y, FOCUS_MS);
|
||||
} else {
|
||||
fitView();
|
||||
}
|
||||
}, [fitView, selectedId, nodeById]);
|
||||
|
||||
// The graph reports zoom while it renders (a prop update can move the
|
||||
// view), so the readout follows on the next frame, at most once a frame.
|
||||
const zoomFrameRef = useRef<number | null>(null);
|
||||
const handleZoom = useCallback(({ k }: { k: number }) => {
|
||||
if (zoomFrameRef.current !== null)
|
||||
cancelAnimationFrame(zoomFrameRef.current);
|
||||
zoomFrameRef.current = requestAnimationFrame(() => {
|
||||
zoomFrameRef.current = null;
|
||||
setZoom(k);
|
||||
});
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (zoomFrameRef.current !== null)
|
||||
cancelAnimationFrame(zoomFrameRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const zoomBy = (factor: number) => {
|
||||
const fg = fgRef.current;
|
||||
if (fg) fg.zoom(fg.zoom() * factor, ZOOM_MS);
|
||||
};
|
||||
|
||||
const legendValue = legendKeys.filter((key) => !hidden.has(key));
|
||||
const otherLabels = fold.other.labels;
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<GraphEntitySearch docId={docId} fold={fold} onPick={onSelect} />
|
||||
{/* One phrase at one size: "Show top [50 | 100 | 250] by
|
||||
connections"; the muted track holds only the group. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span id={showTopId} className="text-muted-foreground text-sm">
|
||||
{t('settings.sources.graphrag.view.showTop')}
|
||||
</span>
|
||||
<div className="bg-muted shrink-0 rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
className="flex-nowrap"
|
||||
value={String(limit)}
|
||||
onValueChange={(value) => value && onLimitChange(Number(value))}
|
||||
aria-labelledby={showTopId}
|
||||
>
|
||||
{GRAPH_LIMITS.map((option) => (
|
||||
<ToggleGroupItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t('settings.sources.graphrag.view.byConnections')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{legendKeys.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
size="xs"
|
||||
value={legendValue}
|
||||
onValueChange={(values) =>
|
||||
setHidden(
|
||||
new Set(legendKeys.filter((key) => !values.includes(key))),
|
||||
)
|
||||
}
|
||||
aria-label={t('settings.sources.graphrag.view.typeFilter')}
|
||||
>
|
||||
{fold.groups.map((group) => (
|
||||
<ToggleGroupItem key={group.key} value={group.key}>
|
||||
<GraphSeriesDot series={group.series} />
|
||||
{group.label}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatCount(group.count)}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{fold.other.count > 0 ? (
|
||||
<ToggleGroupItem value={OTHER_GROUP_KEY}>
|
||||
<GraphSeriesDot series={null} />
|
||||
{t('settings.analytics.otherSeries')}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatCount(fold.other.count)}
|
||||
</span>
|
||||
</ToggleGroupItem>
|
||||
) : null}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{otherLabels.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.otherTypes', {
|
||||
types: otherTypesList(otherLabels, (count) =>
|
||||
t('settings.sources.graphrag.view.otherTypesMore', {
|
||||
count,
|
||||
formatted: formatCount(count),
|
||||
}),
|
||||
),
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
tone="destructive"
|
||||
illustration="none"
|
||||
title={t('settings.sources.graphrag.view.loadFailed')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'ready' && data.nodes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
illustration="none"
|
||||
title={t('settings.sources.graphrag.view.empty')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const panel = selected ? (
|
||||
<GraphNodePanel
|
||||
key={selected.id}
|
||||
docId={docId}
|
||||
node={selected}
|
||||
detail={nodeDetail.detail}
|
||||
status={nodeDetail.status}
|
||||
onRetry={nodeDetail.retry}
|
||||
fold={fold}
|
||||
onClose={() => onSelect(null)}
|
||||
showClose={isDesktop}
|
||||
onSelectNode={onSelect}
|
||||
overview={data}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4 flex items-center">
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
className="mr-3"
|
||||
onClick={onBackToDocuments}
|
||||
label={t('settings.sources.backToAll')}
|
||||
icon={ArrowLeft}
|
||||
side="bottom"
|
||||
/>
|
||||
<span className="text-primary font-semibold wrap-break-word">
|
||||
{sourceName}
|
||||
</span>
|
||||
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
|
||||
<Network className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
<span className="text-foreground font-medium">
|
||||
{t('settings.sources.graphrag.view.title')}
|
||||
</span>{' '}
|
||||
{t('settings.sources.graphrag.view.explainer')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonLoader count={4} />
|
||||
) : isEmpty ? (
|
||||
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
|
||||
<Network className="size-7" aria-hidden="true" />
|
||||
<p>{t('settings.sources.graphrag.view.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.stats', {
|
||||
nodes: data.nodes.length,
|
||||
edges: data.links.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onClick={handleContainerClick}
|
||||
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
|
||||
>
|
||||
{size.width > 0 && (
|
||||
<ForceGraph2D
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolbar}
|
||||
<Card
|
||||
variant="subtle"
|
||||
padding="none"
|
||||
className="h-[70svh] flex-row gap-0 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseMove={handlePointerMove}
|
||||
onMouseLeave={handlePointerLeave}
|
||||
onClick={handleContainerClick}
|
||||
className="relative min-w-0 flex-1"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<LoadingState fill="parent" />
|
||||
) : (
|
||||
<>
|
||||
{hasCanvas && (
|
||||
<ForceGraph2D<GraphNode, GraphEdge>
|
||||
ref={fgRef}
|
||||
graphData={data}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
nodeRelSize={1}
|
||||
minZoom={0.2}
|
||||
maxZoom={8}
|
||||
enablePointerInteraction={false}
|
||||
enableNodeDrag={false}
|
||||
nodeVal={(node) =>
|
||||
nodeRadius((node as GraphNode).degree, maxNodeDegree)
|
||||
}
|
||||
linkColor={() => palette.link}
|
||||
nodeVal={nodeVal}
|
||||
nodeVisibility={nodeVisibility}
|
||||
linkVisibility={linkVisibility}
|
||||
linkColor={linkColor}
|
||||
linkWidth={linkWidth}
|
||||
cooldownTicks={80}
|
||||
nodeCanvasObject={(node, ctx, globalScale) => {
|
||||
const graphNode = node as GraphNode & {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
if (graphNode.x == null) return;
|
||||
const r = nodeRadius(graphNode.degree, maxNodeDegree);
|
||||
const hovered = hoveredNodeIdRef.current === graphNode.id;
|
||||
ctx.beginPath();
|
||||
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = palette.node;
|
||||
ctx.fill();
|
||||
if (hovered) {
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = palette.hoverStroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
if (globalScale >= 1.2) {
|
||||
const label = graphNode.name ?? '';
|
||||
const x = graphNode.x;
|
||||
const y = (graphNode.y ?? 0) + r + 1;
|
||||
ctx.font = `${10 / globalScale}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.lineWidth = 3 / globalScale;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = palette.halo;
|
||||
ctx.strokeText(label, x, y);
|
||||
ctx.fillStyle = palette.label;
|
||||
ctx.fillText(label, x, y);
|
||||
}
|
||||
}}
|
||||
onEngineStop={handleEngineStop}
|
||||
onZoom={handleZoom}
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
onRenderFramePost={drawLabels}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="border-border bg-card flex max-h-[480px] w-full shrink-0 flex-col overflow-y-auto rounded-xl border p-4 lg:w-80">
|
||||
{loadingNode ? (
|
||||
<SkeletonLoader count={3} />
|
||||
) : selectedNode ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
className="wrap-break-word"
|
||||
title={selectedNode.name}
|
||||
/>
|
||||
{selectedNode.type && (
|
||||
<Badge variant="neutral" className="mt-1">
|
||||
{selectedNode.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
label={t('settings.sources.graphrag.view.close')}
|
||||
icon={X}
|
||||
side="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNode.description && (
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
|
||||
{selectedNode.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="xs"
|
||||
title={t('settings.sources.graphrag.view.linkedChunks')}
|
||||
/>
|
||||
{selectedNode.chunks.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.sources.graphrag.view.noChunks')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{selectedNode.chunks.map((chunk) => (
|
||||
<li
|
||||
key={chunk.chunk_id}
|
||||
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
|
||||
>
|
||||
{chunk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{!selected ? (
|
||||
<p className="text-muted-foreground pointer-events-none absolute top-3 left-3 text-xs">
|
||||
{t('settings.sources.graphrag.view.selectNode')}
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
<GraphCanvasControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => zoomBy(ZOOM_STEP)}
|
||||
onZoomOut={() => zoomBy(1 / ZOOM_STEP)}
|
||||
onFit={() => fitView()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isDesktop && panel ? (
|
||||
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
|
||||
) : null}
|
||||
</Card>
|
||||
{!isDesktop ? (
|
||||
<Sheet
|
||||
open={!!selected && active}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSelect(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
handle
|
||||
title={selected?.name}
|
||||
// Open on the panel, not with a ring on its first control.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{panel}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { markdownHeadings } from '@/lib/markdown';
|
||||
import { markdownHeadings, markdownTables } from '@/lib/markdown';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
...markdownHeadings,
|
||||
...markdownTables,
|
||||
a({ children, href }) {
|
||||
return (
|
||||
<Button variant="link" size="inline" asChild>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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(' ');
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Копировать",
|
||||
|
||||
@@ -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
@@ -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": "复制",
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
Reference in new issue
Block a user