diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 9464bef9..e946075e 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -21,7 +21,12 @@ from .models import models_ns from .prompts import prompts_ns from .schedules import schedules_ns from .sharing import sharing_ns -from .sources import sources_chunks_ns, sources_ns, sources_upload_ns +from .sources import ( + sources_chunks_ns, + sources_ns, + sources_search_ns, + sources_upload_ns, +) from .teams import teams_ns from .tools import tools_mcp_ns, tools_ns from .workflows import workflows_ns @@ -63,9 +68,10 @@ api.add_namespace(schedules_ns) # Sharing api.add_namespace(sharing_ns) -# Sources (main, chunks, upload) +# Sources (main, chunks, retrieval test, upload) api.add_namespace(sources_ns) api.add_namespace(sources_chunks_ns) +api.add_namespace(sources_search_ns) api.add_namespace(sources_upload_ns) # Teams (CRUD, membership, resource-sharing grants) diff --git a/application/api/user/sources/__init__.py b/application/api/user/sources/__init__.py index 07b380b7..d45fc136 100644 --- a/application/api/user/sources/__init__.py +++ b/application/api/user/sources/__init__.py @@ -1,7 +1,13 @@ """Sources module.""" from .chunks import sources_chunks_ns +from .retrieval_test import sources_search_ns from .routes import sources_ns from .upload import sources_upload_ns -__all__ = ["sources_ns", "sources_chunks_ns", "sources_upload_ns"] +__all__ = [ + "sources_ns", + "sources_chunks_ns", + "sources_search_ns", + "sources_upload_ns", +] diff --git a/application/api/user/sources/retrieval_test.py b/application/api/user/sources/retrieval_test.py new file mode 100644 index 00000000..de052adb --- /dev/null +++ b/application/api/user/sources/retrieval_test.py @@ -0,0 +1,254 @@ +"""Retrieval test — preview the chunks a query actually retrieves from a source. + +The chunk browser's ``search`` (``/api/get_chunks``) is a substring filter over +stored text; it says nothing about what a RAG query would retrieve. This +endpoint runs the *production* retrieval path against a single source so a user +can see the real ranked chunks for a query, and try retrieval settings without +saving them. + +It deliberately builds a ``Dispatcher`` — the same object the answer pipeline +uses — so retriever selection, per-source ``chunks`` / ``score_threshold``, the +prescreen stage and the token budget all behave exactly as they do at answer +time, rather than drifting from them. +""" + +import logging +import math +import time + +from flask import jsonify, make_response, request +from flask_restx import fields, Namespace, Resource +from pydantic import ValidationError + +from application.api import api +from application.api.user.sources.routes import _resolve_readable_source +from application.core.model_utils import get_default_model_id +from application.retriever.dispatcher import Dispatcher +from application.retriever.retriever_creator import RetrieverCreator +from application.storage.db.session import db_readonly +from application.storage.db.source_config import RetrievalConfig, SourceConfig +from application.utils import num_tokens_from_string + +logger = logging.getLogger(__name__) + +sources_search_ns = Namespace( + "sources", description="Source retrieval testing", path="/api" +) + +# A retrieval query is a search query, not a document. +MAX_QUERY_LENGTH = 2000 + +# The answer pipeline's default; keeps the preview's budgeting identical to it. +DOC_TOKEN_LIMIT = 50000 + +# Cost-attribution tag for any LLM call the preview makes (prescreen only — +# with no chat history the rephrase side-call is skipped entirely). +USAGE_SOURCE = "retrieval_test" + +# Ceiling on the prescreen LLM calls a single test may trigger. +MAX_PRESCREEN_BATCHES = 20 + + +@sources_search_ns.route("/sources//search") +class SourceSearch(Resource): + search_model = api.model( + "SourceSearchModel", + { + "query": fields.String( + required=True, description="The query to retrieve for" + ), + "retrieval": fields.Raw( + required=False, + description="Ad-hoc retrieval config to test with. Omit to use " + "the source's saved config. Never persisted.", + ), + }, + ) + + @api.expect(search_model) + @api.doc( + description="Run the real retrieval pipeline against one source and " + "return the ranked chunks it produces, optionally under an ad-hoc " + "retrieval config. Read-only: nothing is saved." + ) + def post(self, source_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + + body = request.get_json(silent=True) + if not isinstance(body, dict): + return make_response( + jsonify({"success": False, "message": "Invalid request body"}), 400 + ) + query = (body.get("query") or "").strip() + if not query: + return make_response( + jsonify({"success": False, "message": "Query is required"}), 400 + ) + if len(query) > MAX_QUERY_LENGTH: + return make_response( + jsonify( + { + "success": False, + "message": f"Query must be at most {MAX_QUERY_LENGTH} characters", + } + ), + 400, + ) + + try: + # Read access = owner or any team grant (viewer included), matching + # the other source read endpoints (wiki pages, graph). + with db_readonly() as conn: + doc = _resolve_readable_source(conn, source_id, user) + except Exception as e: + # An unresolvable id yields None (→ 404); reaching here means the + # lookup itself failed, which is ours, not the caller's. + logger.error(f"Error resolving source: {e}", exc_info=True) + return make_response( + jsonify({"success": False, "message": "Could not resolve source"}), 500 + ) + if not doc: + return make_response( + jsonify( + {"success": False, "message": "Source not found or access denied"} + ), + 404, + ) + resolved_id = str(doc["id"]) + + # A supplied config is validated exactly as strictly as a saved one (D7 + # strict-on-write), with the same static message so validation internals + # aren't echoed back. Absent → the source's saved config. + saved = SourceConfig.parse(doc.get("config")).retrieval + if body.get("retrieval") is not None: + try: + retrieval = RetrievalConfig.model_validate(body["retrieval"]) + except ValidationError: + return make_response( + jsonify( + { + "success": False, + "message": "Invalid retrieval config: one or more " + "fields failed validation.", + } + ), + 400, + ) + else: + retrieval = saved + + # "Ad-hoc" means the caller is testing something the source is NOT + # already configured to do. The client always sends the config it has on + # screen — including an untouched one — so compare by value rather than + # trusting the body's presence, or a source whose saved config is + # expensive could never be tested at all (see the ceiling below). + ad_hoc = retrieval != saved + + # RetrievalConfig.retriever is a free string, so an unknown key would + # only blow up inside RetrieverCreator — a caller's typo must be a 400, + # not a 500. + if retrieval.retriever not in RetrieverCreator.retrievers: + return make_response( + jsonify( + { + "success": False, + "message": f"Unknown retriever '{retrieval.retriever}'.", + } + ), + 400, + ) + + # Prescreen screens candidate_k candidates in batches of batch_size, one + # synchronous LLM call each, so candidate_k=500 / batch_size=1 is 500 + # provider calls in one request; RetrievalConfig bounds each field but + # not their ratio. Only an ad-hoc config is capped: a config already + # saved on the source runs at answer time anyway, so refusing to test it + # would defeat the endpoint. + if ad_hoc: + ps = retrieval.prescreen_config() + if ps is not None: + batches = math.ceil(ps.candidate_k / ps.batch_size) + if batches > MAX_PRESCREEN_BATCHES: + return make_response( + jsonify( + { + "success": False, + "message": ( + f"This prescreen config needs {batches} LLM " + f"calls to test (limit " + f"{MAX_PRESCREEN_BATCHES}). Raise batch_size " + "or lower candidate_k." + ), + } + ), + 400, + ) + + # A test has no chat behind it, so no model was requested. Resolve the + # same instance default the answer path falls back to: with a bogus id + # the prescreen stage would call the provider, fail, and silently keep + # every candidate. On an instance with no models configured this is + # None — leave the retriever's own default in place rather than + # forwarding it. + dispatcher_kwargs = {} + default_model_id = get_default_model_id() + if default_model_id: + dispatcher_kwargs["model_id"] = default_model_id + + try: + started = time.monotonic() + # Dispatcher directly, NOT build_dispatcher: the + # PER_SOURCE_RETRIEVAL_ENABLED kill-switch would fall back to a + # stock classic retriever and ignore the config under test. + retriever = Dispatcher( + source={"active_docs": [resolved_id], "question": query}, + chat_history=[], # no history ⇒ no rephrase side-call + chunks=retrieval.chunks, + doc_token_limit=DOC_TOKEN_LIMIT, + decoded_token=decoded_token, + sources=[{"id": resolved_id, "retrieval": retrieval}], + include_scores=True, + usage_source=USAGE_SOURCE, + **dispatcher_kwargs, + ) + docs = retriever.search(query) or [] + latency_ms = int((time.monotonic() - started) * 1000) + except Exception as e: + logger.error(f"Retrieval test failed: {e}", exc_info=True) + return make_response( + jsonify({"success": False, "message": "Retrieval failed"}), 500 + ) + + chunks = [ + { + "rank": idx, + "text": d.get("text", ""), + "title": d.get("title"), + "filename": d.get("filename"), + "source": d.get("source"), + "tokens": num_tokens_from_string(d.get("text", "")), + # None for retrievers/stores that produce no comparable score + # (graphrag's PPR ranking, stores without a score seam). + "score": d.get("score"), + "score_kind": d.get("score_kind"), + } + for idx, d in enumerate(docs, start=1) + ] + + return make_response( + jsonify( + { + "success": True, + "query": query, + "retriever": retrieval.retriever, + "retrieval": retrieval.model_dump(), + "total": len(chunks), + "latency_ms": latency_ms, + "chunks": chunks, + } + ), + 200, + ) diff --git a/application/retriever/classic_rag.py b/application/retriever/classic_rag.py index f8cdc560..dbf395c6 100644 --- a/application/retriever/classic_rag.py +++ b/application/retriever/classic_rag.py @@ -9,6 +9,10 @@ from application.vectorstore.vector_creator import VectorCreator class ClassicRAG(BaseRetriever): + # The group's real top-k, set by the Dispatcher when it inflates ``chunks`` + # for a prescreen fetch. None → ``chunks`` is already the top-k. + base_chunks = None + def __init__( self, source, @@ -25,7 +29,9 @@ class ClassicRAG(BaseRetriever): model_user_id=None, defer_rephrase=False, request_id=None, + include_scores=False, ): + self.include_scores = include_scores self.original_question = source.get("question", "") self.chat_history = chat_history if chat_history is not None else [] self.prompt = prompt @@ -147,8 +153,10 @@ class ClassicRAG(BaseRetriever): def _fetch_candidates(self, docsearch, question, src_k, score_threshold): """Fetch candidate hits for one vector store (vector search). - Subclasses override this to change candidate sourcing (e.g. RRF fusion) - while inheriting the surrounding per-source resolution and budgeting. + Returns plain hits, or ``(hit, score)`` pairs when ``include_scores`` is + set. Subclasses override this to change candidate sourcing (e.g. RRF + fusion) while inheriting the surrounding per-source resolution and + budgeting. """ # ``score_threshold`` is honoured by pgvector/mongodb and safely ignored # by stores whose ``search`` swallows kwargs. The candidate count is @@ -157,8 +165,14 @@ class ClassicRAG(BaseRetriever): search_kwargs = {"k": k} if score_threshold is not None: search_kwargs["score_threshold"] = score_threshold + if self.include_scores: + return docsearch.search_with_scores(question, **search_kwargs) return docsearch.search(question, **search_kwargs) + def _score_kind(self, docsearch): + """Label for the scores ``_fetch_candidates`` attaches (None if unscored).""" + return getattr(docsearch, "score_kind", None) + def _get_data(self): if self.chunks == 0 or not self.vectorstores: logging.info( @@ -168,7 +182,13 @@ class ClassicRAG(BaseRetriever): return [] all_docs = [] - chunks_per_source = max(1, self.chunks // len(self.vectorstores)) + # The Dispatcher inflates ``chunks`` to a prescreen source's candidate_k + # so the fetch is large enough for the screening stage. That inflated + # number must not become the top-k of the *other* sources in the group, + # so the fallback splits the group's real top-k (``base_chunks``) when + # the Dispatcher supplied one. + base_chunks = self.base_chunks if self.base_chunks is not None else self.chunks + chunks_per_source = max(1, base_chunks // len(self.vectorstores)) token_budget = max(int(self.doc_token_limit * 0.9), 100) cumulative_tokens = 0 @@ -211,11 +231,25 @@ class ClassicRAG(BaseRetriever): docs_temp = self._fetch_candidates( docsearch, question, src_k, score_threshold ) + score_kind = ( + self._score_kind(docsearch) if self.include_scores else None + ) + + # ``_fetch_candidates`` over-fetches (k >= 20) so a prescreen + # stage has candidates to filter; trim back to src_k so + # ``chunks`` is the final top-k it claims to be. With + # prescreen on, src_k is already raised to candidate_k above, + # so the stage still sees its full candidate set. + kept = 0 for doc in docs_temp: - if cumulative_tokens >= token_budget: + if kept >= src_k or cumulative_tokens >= token_budget: break + score = None + if isinstance(doc, tuple): + doc, score = doc + if hasattr(doc, "page_content") and hasattr(doc, "metadata"): page_content = doc.page_content metadata = doc.metadata @@ -231,8 +265,13 @@ class ClassicRAG(BaseRetriever): doc_tokens = num_tokens_from_string(doc_text_with_header) if cumulative_tokens + doc_tokens < token_budget: - all_docs.append({"text": page_content, **labels}) + entry = {"text": page_content, **labels} + if self.include_scores: + entry["score"] = score + entry["score_kind"] = score_kind + all_docs.append(entry) cumulative_tokens += doc_tokens + kept += 1 if cumulative_tokens >= token_budget: break diff --git a/application/retriever/dispatcher.py b/application/retriever/dispatcher.py index 38ebd325..21d419a5 100644 --- a/application/retriever/dispatcher.py +++ b/application/retriever/dispatcher.py @@ -68,6 +68,8 @@ class Dispatcher(BaseRetriever): request_id=None, sources: Optional[List[Dict[str, Any]]] = None, stages: Optional[List[Stage]] = None, + include_scores: bool = False, + usage_source: str = "rag_prescreen", ): """Build the dispatcher. @@ -83,8 +85,15 @@ class Dispatcher(BaseRetriever): falls back to the single classic group over ``source``. stages: Optional post-retrieval stages applied to each group's candidates before final budgeting. Default: none (pass-through). + include_scores: Ask each group's retriever to attach the raw store + score to every doc it returns. Off for the answer pipeline; on + for the retrieval-test endpoint. + usage_source: Cost-attribution tag for the prescreen stage's LLM + calls. """ + self._usage_source = usage_source self._ctor_kwargs = dict( + include_scores=include_scores, chat_history=chat_history, prompt=prompt, doc_token_limit=doc_token_limit, @@ -229,9 +238,12 @@ class Dispatcher(BaseRetriever): kwargs["defer_rephrase"] = True retriever = RetrieverCreator.create_retriever(retriever_key, **kwargs) # Hand the per-source retrieval configs to the classic retriever so it - # can honour per-source chunks/score_threshold/rephrase in its loop. + # can honour per-source chunks/score_threshold/rephrase in its loop, plus + # the un-inflated top-k so a prescreen source's candidate_k doesn't + # become the top-k of the sources beside it in the group. if group["retrievals"]: setattr(retriever, "per_source_retrieval", group["retrievals"]) + setattr(retriever, "base_chunks", self.chunks) return retriever def _group_stages(self, group: Dict[str, Any]) -> List[Stage]: @@ -251,6 +263,7 @@ class Dispatcher(BaseRetriever): agent_id=self._ctor_kwargs.get("agent_id"), model_user_id=self._ctor_kwargs.get("model_user_id"), request_id=self._ctor_kwargs.get("request_id"), + usage_source=self._usage_source, ) return list(self.stages) + prescreen diff --git a/application/retriever/graph_rag.py b/application/retriever/graph_rag.py index 9e41a678..240d0f19 100644 --- a/application/retriever/graph_rag.py +++ b/application/retriever/graph_rag.py @@ -39,6 +39,10 @@ def _idf(doc_freq: Any) -> float: class GraphRAGRetriever(BaseRetriever): """Per-source PPR retriever; falls back to ClassicRAG when a source has no graph.""" + # Set by the Dispatcher (see ClassicRAG.base_chunks); forwarded to the inner + # ClassicRAG on the fallback path. + base_chunks = None + def __init__( self, source, @@ -55,7 +59,11 @@ class GraphRAGRetriever(BaseRetriever): model_user_id=None, defer_rephrase=False, request_id=None, + include_scores=False, ): + # Graph docs are ranked by PPR, which yields no per-chunk similarity, so + # they stay unscored; the flag only matters to the classic fallback, + # which retrieves the sources that have no graph. self._classic = ClassicRAG( source=source, chat_history=chat_history, @@ -71,6 +79,7 @@ class GraphRAGRetriever(BaseRetriever): model_user_id=model_user_id, defer_rephrase=defer_rephrase, request_id=request_id, + include_scores=include_scores, ) self.original_question = self._classic.original_question self.chunks = self._classic.chunks @@ -129,6 +138,25 @@ class GraphRAGRetriever(BaseRetriever): candidates = max(self.chunks * 2, self.chunks + 5) return ranked[: max(1, candidates)] + def _source_top_k(self, source_id) -> int: + """How many chunks this source may contribute — its own top-k. + + Mirrors ClassicRAG's resolution: a per-source override wins (raised to + candidate_k when it prescreens, so the stage has candidates to filter); + otherwise the group's real top-k is split across the sources. Without + this, a prescreen source elsewhere in the group inflates ``chunks`` and + this source would return that inflated count. + """ + cfg = self.per_source_retrieval.get(source_id) + if cfg is not None: + top_k = max(1, int(cfg.chunks)) + ps = cfg.prescreen_config() if hasattr(cfg, "prescreen_config") else None + if ps is not None: + top_k = max(top_k, int(ps.candidate_k)) + return top_k + base = self.base_chunks if self.base_chunks is not None else self.chunks + return max(1, base // max(1, len(self.vectorstores))) + def _graph_docs_for_source(self, store, source_id) -> List[Dict[str, Any]]: """Local PPR retrieval for one source (caller guarantees it has a graph).""" question = self._classic._get_rephrased_question() @@ -160,8 +188,9 @@ class GraphRAGRetriever(BaseRetriever): docs: List[Dict[str, Any]] = [] token_budget = max(int(self.doc_token_limit * 0.9), 100) cumulative_tokens = 0 + source_top_k = self._source_top_k(source_id) for chunk_id in chunk_ids: - if len(docs) >= self.chunks: + if len(docs) >= source_top_k: break chunk = chunk_data.get(chunk_id) text = chunk.get("text") if chunk else None @@ -179,15 +208,20 @@ class GraphRAGRetriever(BaseRetriever): """Reuse the composed ClassicRAG to retrieve one source's chunks.""" original = self._classic.vectorstores original_overrides = self._classic.per_source_retrieval + original_base = self._classic.base_chunks try: self._classic.vectorstores = [source_id] self._classic.per_source_retrieval = { k: v for k, v in self.per_source_retrieval.items() if k == source_id } + # The Dispatcher sets these on *this* object; the inner retriever is + # the one that reads them. + self._classic.base_chunks = self.base_chunks return self._classic._get_data() finally: self._classic.vectorstores = original self._classic.per_source_retrieval = original_overrides + self._classic.base_chunks = original_base def _get_data(self) -> List[Dict[str, Any]]: if not self.vectorstores: diff --git a/application/retriever/hybrid_rag.py b/application/retriever/hybrid_rag.py index b789ddc5..cf739253 100644 --- a/application/retriever/hybrid_rag.py +++ b/application/retriever/hybrid_rag.py @@ -25,13 +25,14 @@ def _doc_key(doc): return (source, content) -def reciprocal_rank_fusion(vector_hits, keyword_hits, k=RRF_K): - """Fuse two ranked hit lists into one by Reciprocal Rank Fusion. +def fuse_with_scores(vector_hits, keyword_hits, k=RRF_K): + """Fuse two ranked hit lists by RRF, keeping each hit's fused score. Each list contributes ``1 / (k + rank)`` per document (rank 0-based); - documents are returned ordered by summed score, highest first. A document - present in only one list is ranked solely on that list's contribution, so - an empty ``keyword_hits`` yields exactly the vector ordering. + documents are returned as ``(doc, fused_score)`` ordered by score, highest + first. A document present in only one list is ranked solely on that list's + contribution, so an empty ``keyword_hits`` yields exactly the vector + ordering. """ scores = {} docs = {} @@ -42,12 +43,18 @@ def reciprocal_rank_fusion(vector_hits, keyword_hits, k=RRF_K): if key not in docs: docs[key] = doc ordered = sorted(docs.keys(), key=lambda key: scores[key], reverse=True) - return [docs[key] for key in ordered] + return [(docs[key], scores[key]) for key in ordered] class HybridRetriever(ClassicRAG): """ClassicRAG variant that fuses vector + keyword search with RRF.""" + def _score_kind(self, docsearch): + """RRF scores rank hits against each other, not against a similarity + cutoff — they are not comparable to the store's cosine scores, so they + carry their own label.""" + return "rrf" + def _fetch_candidates(self, docsearch, question, src_k, score_threshold): """Return RRF-fused vector+keyword hits for one vector store. @@ -59,4 +66,7 @@ class HybridRetriever(ClassicRAG): candidate_k = min(max(src_k * 2, 20), 500) vector_hits = docsearch.search(question, k=candidate_k) keyword_hits = docsearch.keyword_search(question, k=candidate_k) - return reciprocal_rank_fusion(vector_hits, keyword_hits) + fused = fuse_with_scores(vector_hits, keyword_hits) + if self.include_scores: + return fused + return [doc for doc, _ in fused] diff --git a/application/retriever/stages/prescreen.py b/application/retriever/stages/prescreen.py index 4050784a..4c1e61bf 100644 --- a/application/retriever/stages/prescreen.py +++ b/application/retriever/stages/prescreen.py @@ -56,6 +56,7 @@ class PreScreenStage: agent_id: Optional[str] = None, model_user_id: Optional[str] = None, request_id: Optional[str] = None, + usage_source: str = "rag_prescreen", ): """Build the stage. @@ -68,6 +69,7 @@ class PreScreenStage: decoded_token: Caller identity for BYOM resolution. agent_id: Agent context for BYOM resolution. model_user_id: BYOM-resolution scope for shared-agent dispatch. + usage_source: Cost-attribution tag for the screening calls. """ self.config = config self.llm_name = llm_name @@ -78,6 +80,7 @@ class PreScreenStage: self.agent_id = agent_id self.model_user_id = model_user_id self.request_id = request_id + self.usage_source = usage_source def _resolve_model(self) -> Optional[str]: """Use the configured model, else fall back to the request model.""" @@ -96,7 +99,7 @@ class PreScreenStage: ) # Tag rows so the screening calls land as a distinct cost source, and # stamp the originating request so the rows correlate to it. - llm._token_usage_source = "rag_prescreen" + llm._token_usage_source = self.usage_source llm._request_id = self.request_id return llm @@ -188,6 +191,7 @@ def build_prescreen_stages( agent_id: Optional[str] = None, model_user_id: Optional[str] = None, request_id: Optional[str] = None, + usage_source: str = "rag_prescreen", ) -> List[Stage]: """Build prescreen stages from a group's per-source retrieval configs. @@ -222,6 +226,7 @@ def build_prescreen_stages( agent_id=agent_id, model_user_id=model_user_id, request_id=request_id, + usage_source=usage_source, ) ) return stages diff --git a/application/vectorstore/base.py b/application/vectorstore/base.py index 142f75f8..b0f73836 100644 --- a/application/vectorstore/base.py +++ b/application/vectorstore/base.py @@ -218,6 +218,26 @@ class BaseVectorStore(ABC): """ return [] + # What ``search_with_scores`` reports, so a caller can label the number. + # ``cosine_similarity`` is higher-is-better in [0, 1]; ``l2_distance`` is + # lower-is-better and unbounded. None = this store reports no score. + score_kind = None + + def search_with_scores(self, question, k=2, *args, **kwargs): + """Search, pairing each hit with its raw relevance score. + + Default pairs every hit from :meth:`search` with ``None`` so stores that + surface no score still satisfy the contract. Stores that already compute + one override this and set :attr:`score_kind`. + + Returns: + A list of ``(Document, score | None)`` in the same rank order + :meth:`search` would return. + """ + return [ + (doc, None) for doc in self.search(question, k, *args, **kwargs) or [] + ] + @abstractmethod def add_texts(self, texts, metadatas=None, *args, **kwargs): """Add texts with their embeddings to the vectorstore""" diff --git a/application/vectorstore/faiss.py b/application/vectorstore/faiss.py index c40a09c9..795669e3 100644 --- a/application/vectorstore/faiss.py +++ b/application/vectorstore/faiss.py @@ -83,12 +83,26 @@ class FaissStore(BaseVectorStore): self.assert_embedding_dimensions(self.embeddings) + # LangChain's FAISS wrapper ranks by L2 distance (lower is better), not by + # a cosine similarity — so the number here is NOT comparable to the + # ``score_threshold`` the other stores honour, and must not be shown as one. + score_kind = "l2_distance" + def search(self, *args, **kwargs): # FAISS has no relevance-threshold knob; drop it so the per-source # score_threshold is safely ignored rather than crashing the forward. kwargs.pop("score_threshold", None) return self.docsearch.similarity_search(*args, **kwargs) + def search_with_scores(self, *args, **kwargs): + """Same search as :meth:`search`, pairing each hit with its L2 distance.""" + kwargs.pop("score_threshold", None) + results = self.docsearch.similarity_search_with_score(*args, **kwargs) + # The Documents come straight from the live in-memory docstore — return + # them untouched (the score rides alongside, never in their metadata) so + # the index can't be polluted and later persisted back to storage. + return [(doc, float(score)) for doc, score in results] + def add_texts(self, *args, **kwargs): return self.docsearch.add_texts(*args, **kwargs) diff --git a/application/vectorstore/mongodb.py b/application/vectorstore/mongodb.py index 2faa13dd..64e8591a 100644 --- a/application/vectorstore/mongodb.py +++ b/application/vectorstore/mongodb.py @@ -52,6 +52,8 @@ class MongoDBVectorStore(BaseVectorStore): def _collection(self): return self._database[self._collection_name] + score_kind = "cosine_similarity" + def search(self, question, k=2, *args, score_threshold=None, **kwargs): """Search via Atlas ``$vectorSearch``. @@ -61,6 +63,19 @@ class MongoDBVectorStore(BaseVectorStore): score_threshold: Optional ``vectorSearchScore`` floor in ``[0, 1]``; results scoring below it are dropped. """ + return [ + doc + for doc, _ in self.search_with_scores( + question, k, *args, score_threshold=score_threshold, **kwargs + ) + ] + + def search_with_scores(self, question, k=2, *args, score_threshold=None, **kwargs): + """Same search as :meth:`search`, pairing each hit with its score. + + The score is Atlas' ``vectorSearchScore`` — the same quantity + ``score_threshold`` is compared against. + """ query_vector = self._embedding.embed_query(question) pipeline = [ @@ -73,10 +88,10 @@ class MongoDBVectorStore(BaseVectorStore): "index": self._index_name, "filter": {"source_id": {"$eq": self._source_id}}, } - } + }, + {"$addFields": {"_score": {"$meta": "vectorSearchScore"}}}, ] if score_threshold is not None: - pipeline.append({"$addFields": {"_score": {"$meta": "vectorSearchScore"}}}) pipeline.append({"$match": {"_score": {"$gte": float(score_threshold)}}}) cursor = self._collection.aggregate(pipeline) @@ -87,9 +102,11 @@ class MongoDBVectorStore(BaseVectorStore): doc.pop("_id") doc.pop(self._text_key) doc.pop(self._embedding_key) - doc.pop("_score", None) + score = doc.pop("_score", None) metadata = doc - results.append(Document(text, metadata)) + results.append( + (Document(text, metadata), None if score is None else float(score)) + ) return results def _insert_texts(self, texts, metadatas): diff --git a/application/vectorstore/pgvector.py b/application/vectorstore/pgvector.py index 5fd8b117..c4b445d1 100644 --- a/application/vectorstore/pgvector.py +++ b/application/vectorstore/pgvector.py @@ -122,6 +122,8 @@ class PGVectorStore(BaseVectorStore): finally: cursor.close() + score_kind = "cosine_similarity" + def search( self, question: str, @@ -139,6 +141,27 @@ class PGVectorStore(BaseVectorStore): Cosine distance = ``1 - similarity``; rows with similarity below the threshold (distance above ``1 - threshold``) are dropped. """ + return [ + doc + for doc, _ in self.search_with_scores( + question, k, *args, score_threshold=score_threshold, **kwargs + ) + ] + + def search_with_scores( + self, + question: str, + k: int = 2, + *args, + score_threshold: float = None, + **kwargs, + ) -> List[tuple]: + """Same search as :meth:`search`, pairing each hit with its similarity. + + The score is the cosine similarity (``1 - cosine_distance``) — the exact + quantity ``score_threshold`` is compared against, so a caller can read a + result's score and pick a threshold from it directly. + """ query_vector = self._embedding.embed_query(question) conn = self._get_connection() @@ -167,10 +190,13 @@ class PGVectorStore(BaseVectorStore): if max_distance is not None and distance is not None and distance > max_distance: continue metadata = metadata or {} - documents.append(Document(page_content=text, metadata=metadata)) + score = None if distance is None else 1.0 - float(distance) + documents.append( + (Document(page_content=text, metadata=metadata), score) + ) return documents - + except Exception as e: logging.error(f"Error searching documents: {e}", exc_info=True) return [] diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 8f23f896..edc89b97 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -56,6 +56,7 @@ const endpoints = { SYNC_SOURCE: '/api/sync_source', REINGEST_SOURCE: '/api/sources/reingest', SOURCE_CONFIG: (id: string) => `/api/sources/${id}/config`, + SOURCE_SEARCH: (id: string) => `/api/sources/${id}/search`, CREATE_WIKI: '/api/sources/wiki', CONVERT_TO_WIKI: (id: string) => `/api/sources/${id}/wiki/convert`, ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`, diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index bd65491c..563743b5 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -102,6 +102,12 @@ const userService = { token: string | null, ): Promise => apiClient.patch(endpoints.USER.SOURCE_CONFIG(sourceId), config, token), + testSourceRetrieval: ( + sourceId: string, + data: { query: string; retrieval?: any }, + token: string | null, + ): Promise => + apiClient.post(endpoints.USER.SOURCE_SEARCH(sourceId), data, token), createWiki: ( data: { name: string; initial_content?: string }, token: string | null, diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx index 7b0dfad3..e01ead13 100644 --- a/frontend/src/components/Chunks.tsx +++ b/frontend/src/components/Chunks.tsx @@ -108,6 +108,8 @@ interface ChunksProps { displayPath?: string; onFileSearch?: (query: string) => SearchResult[]; onFileSelect?: (path: string) => void; + /** Extra header control, rendered left of the chunk actions. */ + headerAction?: React.ReactNode; } const Chunks: React.FC = ({ @@ -118,6 +120,7 @@ const Chunks: React.FC = ({ displayPath, onFileSearch, onFileSelect, + headerAction, }) => { const [fileSearchQuery, setFileSearchQuery] = useState(''); const [fileSearchResults, setFileSearchResults] = useState( @@ -349,6 +352,7 @@ const Chunks: React.FC = ({
+ {headerAction} {editingChunk ? ( !isEditing ? ( <> diff --git a/frontend/src/components/ConnectorTree.tsx b/frontend/src/components/ConnectorTree.tsx index db8ff6db..99bb30ca 100644 --- a/frontend/src/components/ConnectorTree.tsx +++ b/frontend/src/components/ConnectorTree.tsx @@ -16,12 +16,15 @@ interface ConnectorTreeProps { docId: string; sourceName: string; onBackToDocuments: () => void; + /** Extra header control, rendered left of the Sync button. */ + headerAction?: React.ReactNode; } const ConnectorTree: React.FC = ({ docId, sourceName, onBackToDocuments, + headerAction, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -101,33 +104,36 @@ const ConnectorTree: React.FC = ({ }; const topRightAction = ( - + : t('settings.sources.sync')} + + ); const extraContent = ( diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index e9e6fbf1..34586151 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -26,12 +26,15 @@ interface FileTreeProps { docId: string; sourceName: string; onBackToDocuments: () => void; + /** Extra header control, rendered left of "Add file". */ + headerAction?: React.ReactNode; } const FileTree: React.FC = ({ docId, sourceName, onBackToDocuments, + headerAction, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -231,15 +234,22 @@ const FileTree: React.FC = ({ : t('settings.sources.deletingTitle') : null; - const topRightAction = !isProcessing ? ( - - ) : null; + // headerAction stays visible while an upload/delete is in flight — only the + // Add file button is suppressed then. + const topRightAction = ( + <> + {headerAction} + {!isProcessing ? ( + + ) : null} + + ); const extraContent = ( void; + /** Extra header control, right-aligned in the title row. */ + headerAction?: React.ReactNode; } const GRAPH_LIMIT = 100; @@ -35,6 +37,7 @@ const GraphView: React.FC = ({ docId, sourceName, onBackToDocuments, + headerAction, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -169,6 +172,7 @@ const GraphView: React.FC = ({ {sourceName} + {headerAction ?
{headerAction}
: null}
diff --git a/frontend/src/components/WikiViewer.tsx b/frontend/src/components/WikiViewer.tsx index e5e7ca6d..6ef4181d 100644 --- a/frontend/src/components/WikiViewer.tsx +++ b/frontend/src/components/WikiViewer.tsx @@ -23,6 +23,8 @@ interface WikiViewerProps { sourceName: string; canEdit?: boolean; onBackToDocuments: () => void; + /** Extra header control, right-aligned in the title row. */ + headerAction?: React.ReactNode; } const markdownComponents = { @@ -66,6 +68,7 @@ const WikiViewer: React.FC = ({ sourceName, canEdit = false, onBackToDocuments, + headerAction, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -220,6 +223,7 @@ const WikiViewer: React.FC = ({ {sourceName} + {headerAction ?
{headerAction}
: null}
diff --git a/frontend/src/components/tree/TreeBrowser.tsx b/frontend/src/components/tree/TreeBrowser.tsx index 4bb87ef5..64ce9e3f 100644 --- a/frontend/src/components/tree/TreeBrowser.tsx +++ b/frontend/src/components/tree/TreeBrowser.tsx @@ -633,7 +633,7 @@ const TreeBrowser: React.FC = ({ /> {searchQuery && ( -
+
{searchResults.length === 0 ? (
@@ -744,7 +744,7 @@ const TreeBrowser: React.FC = ({
) : ( -
+
{renderPathNavigation()}
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 03521228..60e40eee 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -12,6 +12,18 @@ "emailUs": "E-Mail senden", "documentation": "Dokumentation", "manageAgents": "Agenten verwalten", + "teamAccess": { + "editor": "Bearbeiter", + "viewer": "Betrachter" + }, + "notifications": { + "memberAddedTitle": "Zu einem Team hinzugefügt", + "memberAddedBody": "Du wurdest als {{role}} zu {{team}} hinzugefügt", + "sharedTitle": "Mit dir geteilt", + "sharedBody": "{{type}} mit deinem Team geteilt: {{name}} ({{access}})", + "sharedBodyNoName": "{{type}} wurde mit deinem Team geteilt ({{access}})", + "dismiss": "Schließen" + }, "demo": [ { "header": "Über DocsGPT lernen", @@ -30,6 +42,15 @@ "query": "Schreibe mögliche Fragen zum Kontext" } ], + "auth": { + "signInToContinue": "Melde dich an, um mit DocsGPT fortzufahren", + "signInWithSSO": "Mit SSO anmelden", + "signInWith": "Mit {{provider}} anmelden", + "notAuthorized": "Dein Konto ist für diesen Workspace nicht autorisiert. Melde dich mit einem anderen Konto an oder kontaktiere deinen Administrator.", + "accountDisabled": "Dein Konto wurde deaktiviert. Kontaktiere deinen Administrator.", + "signOut": "Abmelden", + "account": "Konto" + }, "settings": { "label": "Einstellungen", "general": { @@ -82,6 +103,119 @@ }, "actions": "Aktionen", "view": "Anzeigen", + "wiki": { + "view": "Wiki anzeigen", + "empty": "Dieses Wiki hat noch keine Seiten.", + "selectPage": "Wähle eine Seite aus, um ihren Inhalt anzuzeigen.", + "edit": "Bearbeiten", + "save": "Speichern", + "cancel": "Abbrechen", + "saving": "Speichern…", + "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", + "human": "Mensch", + "you": "dir", + "unknown": "unbekannt", + "version": "v{{version}}" + }, + "convert": { + "action": "In Wiki umwandeln", + "title": "In ein lebendiges Wiki umwandeln", + "intro": "Wandle \"{{name}}\" in ein lebendiges Wiki aus bearbeitbaren Seiten um, die Agenten lesen und umschreiben können.", + "introGeneric": "Wandle diese Quelle in ein lebendiges Wiki aus bearbeitbaren Seiten um, die Agenten lesen und umschreiben können.", + "costReparse": "Dateien werden neu in bearbeitbare Seiten geparst. Die Formatierung wird vereinfacht, besonders bei PDFs.", + "costSkipped": "Nicht-Text-Dateien (Bilder, Binärdateien) werden übersprungen.", + "costIrreversible": "Dies ist nicht umkehrbar – die Wiki-Seiten sind dann die maßgebliche Quelle.", + "confirm": "In Wiki umwandeln", + "inProgress": "Dateien werden in Wiki-Seiten umgewandelt…", + "summaryPages_one": "{{count}} Seite erstellt", + "summaryPages_other": "{{count}} Seiten erstellt", + "summarySkipped_one": "{{count}} Datei übersprungen", + "summarySkipped_other": "{{count}} Dateien übersprungen", + "skippedHeading": "Übersprungene Dateien", + "done": "Fertig", + "errors": { + "generic": "Umwandlung fehlgeschlagen. Bitte versuche es erneut.", + "forbidden": "Du hast keine Berechtigung, diese Quelle umzuwandeln.", + "conflict": "Diese Quelle wird noch indexiert. Warte, bis der Vorgang abgeschlossen ist, bevor du sie umwandelst." + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "Graph wird erstellt…", + "buildingPct": "Graph wird erstellt… {{pct}}%", + "enable": { + "action": "GraphRAG aktivieren", + "title": "GraphRAG aktivieren", + "intro": "Erstelle einen Wissensgraphen über \"{{name}}\", damit Agenten über verknüpfte Entitäten und Beziehungen hinweg schlussfolgern können.", + "introGeneric": "Erstelle einen Wissensgraphen über diese Quelle, damit Agenten über verknüpfte Entitäten und Beziehungen hinweg schlussfolgern können.", + "costExtraction": "Entitäten und Beziehungen werden extrahiert, indem ein LLM über die Chunks dieser Quelle ausgeführt wird.", + "costCost": "Dabei erfolgt ein LLM-Aufruf pro Chunk; das verbraucht Tokens und kann bei großen Quellen eine Weile dauern.", + "estimate": "~1 LLM-Aufruf pro Chunk · ~{{lo}}–{{hi}} Tokens insgesamt (etwa halb Eingabe, halb Ausgabe); die Ausgabe variiert je nachdem, wie entitätsreich das Dokument ist.", + "confirm": "GraphRAG aktivieren", + "inProgress": "Graph wird erstellt…", + "inProgressPct": "Graph wird erstellt… {{pct}}%", + "summaryNodes_one": "{{count}} Entität", + "summaryNodes_other": "{{count}} Entitäten", + "summaryEdges_one": "{{count}} Beziehung", + "summaryEdges_other": "{{count}} Beziehungen", + "summaryChunks_one": "{{count}} Chunk verarbeitet", + "summaryChunks_other": "{{count}} Chunks verarbeitet", + "done": "Fertig", + "errors": { + "generic": "GraphRAG konnte nicht aktiviert werden. Bitte versuche es erneut.", + "forbidden": "Du hast keine Berechtigung, GraphRAG für diese Quelle zu aktivieren.", + "conflict": "Diese Quelle wird noch indexiert. Warte, bis der Vorgang abgeschlossen ist, bevor du GraphRAG aktivierst." + } + }, + "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", + "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" + } + }, + "testRetrieval": { + "action": "Retrieval testen", + "title": "Retrieval testen", + "subtitle": "Sieh dir an, welche Chunks eine Anfrage tatsächlich aus \"{{name}}\" abruft – mit der echten Retrieval-Pipeline.", + "subtitleGeneric": "Sieh dir an, welche Chunks eine Anfrage tatsächlich abruft – mit der echten Retrieval-Pipeline.", + "queryPlaceholder": "Frage etwas, das diese Quelle beantworten sollte...", + "run": "Ausführen", + "notSavedHint": "Diese Einstellungen gelten nur für diesen Test – sie werden nicht in der Quelle gespeichert. Ändere sie in den Quelleneinstellungen, um sie dauerhaft zu übernehmen.", + "resultSummary": "{{total}} Chunks abgerufen · {{retriever}}", + "latency": "{{ms}} ms", + "empty": "Keine Chunks für diese Anfrage abgerufen.", + "emptyWithThreshold": "Keine Chunks haben den Score-Schwellenwert ({{threshold}}) erreicht. Senke ihn, um knappe Treffer zu sehen.", + "showMore": "Ganzen Chunk anzeigen", + "showLess": "Weniger anzeigen", + "noScore": "kein Score", + "noScoreHint": "Dieser Retriever ordnet Chunks, ohne einen vergleichbaren Score pro Chunk zu erzeugen.", + "scoreKinds": { + "cosine_similarity": "Ähnlichkeit", + "l2_distance": "Distanz", + "rrf": "RRF" + }, + "errors": { + "failed": "Retrieval-Test fehlgeschlagen. Bitte versuche es erneut." + } + }, + "editConfig": "Quelleneinstellungen", + "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", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "Dateien werden hochgeladen...", "deletingTitle": "Wird gelöscht...", "deleteDirectoryWarning": "Bist du sicher, dass du das Verzeichnis \"{{name}}\" und seinen gesamten Inhalt löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", - "searchAlt": "Suchen" + "searchAlt": "Suchen", + "retrievalOptions": { + "title": "Erweiterte Einstellungen", + "retrieval": { + "title": "Retrieval", + "note": "Diese Änderungen wirken sofort, kein erneutes Indexieren nötig.", + "tag": "kein erneutes Indexieren", + "chunks": "Abzurufende Chunks (Top-k)", + "scoreThreshold": "Score-Schwellenwert", + "scoreThresholdPlaceholder": "Keiner", + "scoreThresholdHint": "Nur Chunks über diesem Relevanz-Score (0–1) zurückgeben; höher ist strenger.", + "rephraseQuery": "Anfrage vor der Suche umformulieren", + "retriever": "Retriever", + "retrievers": { + "classic": "Klassisch", + "hybrid": "Hybrid", + "graphrag": "GraphRAG" + }, + "retrieverHint": "Hybrid kombiniert Stichwort- und Vektorsuche. GraphRAG erstellt einen Wissensgraphen über diese Quelle für Multi-Hop-Fragen.", + "exposure": "Suchzugriff", + "exposures": { + "prefetch": "Vorab in den Kontext laden", + "agentic_tool": "Suchwerkzeug auf Abruf" + }, + "exposureHint": "Lade diese Quelle vorab in den Prompt oder lass den Agenten sie bei Bedarf als Werkzeug durchsuchen." + }, + "prescreen": { + "enable": "LLM-Vorfilterung aktivieren", + "warning": "Ruft eine größere Kandidatenmenge ab und filtert sie mit einem LLM. Das erhöht Latenz und Kosten pro Anfrage.", + "candidateK": "Abzurufende Kandidaten", + "maxKeep": "Max. zu behaltende Chunks", + "batchSize": "Batch-Größe", + "model": "Modell (optional)", + "modelPlaceholder": "Standardmäßig das Modell der Anfrage" + }, + "graph": { + "title": "Graph-Extraktion", + "tag": "erneutes Indexieren nötig", + "extractionModel": "Extraktionsmodell", + "extractionModelHint": "LLM, das Entitäten und Beziehungen aus jedem Chunk extrahiert.", + "defaultModel": "Standardmodell verwenden", + "maxChunks": "Max. zu extrahierende Chunks", + "maxChunksHint": "Begrenzt, wie viele Chunks zur Extraktion gesendet werden. Leer lassen, um den Instanz-Standard zu verwenden.", + "maxChunksPlaceholder": "Standard" + }, + "chunking": { + "title": "Chunking", + "note": "Einstellungen für die Indexierung. Änderungen erfordern ein erneutes Indexieren der Quelle.", + "tag": "erneutes Indexieren nötig", + "strategy": "Chunking-Strategie", + "strategies": { + "classic_chunk": "Klassisch", + "recursive": "Rekursiv", + "markdown": "Markdown", + "parent_child": "Eltern / Kind", + "semantic": "Semantisch" + }, + "maxTokens": "Max. Tokens pro Chunk", + "minTokens": "Min. Tokens pro Chunk", + "duplicateHeaders": "Überschriften über Chunks hinweg duplizieren" + } + }, + "configModal": { + "title": "Quelleneinstellungen", + "subtitle": "Konfiguriere, wie \"{{name}}\" in Chunks aufgeteilt und abgerufen wird.", + "subtitleGeneric": "Konfiguriere, wie diese Quelle in Chunks aufgeteilt und abgerufen wird.", + "save": "Speichern", + "saving": "Speichern…", + "readOnly": "Du hast nur Lesezugriff auf diese geteilte Quelle und kannst ihre Konfiguration nicht ändern.", + "chunkingChangeHint": "Du hast eine Chunking-Einstellung geändert. Nach dem Speichern muss diese Quelle erneut indexiert werden, damit die Änderung wirkt.", + "prescreenInvalidHint": "Prüfe die Vorfilter-Werte: Die abzurufenden Kandidaten müssen mindestens der Chunk-Anzahl entsprechen, und die zu behaltenden Chunks dürfen die Kandidatenzahl nicht überschreiten.", + "reingestRequired": "Deine Änderungen wurden gespeichert. Die geänderten Chunking-Einstellungen wirken erst nach einem erneuten Indexieren. Jetzt erneut indexieren?", + "reingestLater": "Später", + "errors": { + "forbidden": "Du hast keine Berechtigung, die Konfiguration dieser Quelle zu bearbeiten.", + "saveFailed": "Konfiguration konnte nicht gespeichert werden. Bitte versuche es erneut." + } + } }, "apiKeys": { "label": "Chatbots", @@ -138,7 +349,30 @@ "filterPlaceholder": "Filter", "none": "Keine", "positiveFeedback": "Positives Feedback", - "negativeFeedback": "Negatives Feedback" + "negativeFeedback": "Negatives Feedback", + "groupBy": "Gruppieren nach", + "groupOptions": { + "total": "Gesamt", + "model": "Nach Modell", + "agent": "Nach Agent", + "source": "Nach Quelle" + }, + "includeSideChannel": "Hintergrund-Tokens", + "promptTokens": "Prompt-Tokens", + "generatedTokens": "Generierte Tokens", + "scheduledRuns": "Geplante Ausführungen", + "toolUsage": "Werkzeugnutzung", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "skipped": "Übersprungen", + "successful": "Erfolgreich", + "stats": { + "messages": "Nachrichten", + "tokens": "Tokens", + "toolCalls": "Werkzeugaufrufe", + "runSuccess": "Erfolgsquote", + "feedback": "Feedback" + } }, "logs": { "label": "Protokolle", @@ -146,7 +380,131 @@ "filterByChatbot": "Nach Chatbot filtern", "selectChatbot": "Chatbot auswählen", "none": "Keine", - "tableHeader": "API-generierte / Chatbot-Konversationen" + "tableHeader": "API-generierte / Chatbot-Konversationen", + "searchPlaceholder": "Protokolle suchen...", + "noLogs": "Keine Protokolle gefunden", + "levels": { + "all": "Alle Stufen", + "info": "Info", + "error": "Fehler", + "warning": "Warnung" + }, + "types": { + "all": "Alle Ereignisse", + "chat": "Chat", + "schedule": "Geplant", + "webhook": "Webhook", + "workflow": "Workflow", + "system": "System" + }, + "detail": { + "agent": "Agent", + "status": "Status", + "trigger": "Auslöser", + "errorType": "Fehlertyp", + "tokens": "Tokens", + "duration": "Dauer", + "conversation": "Konversation", + "endpoint": "Endpunkt", + "instruction": "Anweisung", + "response": "Antwort", + "output": "Ausgabe", + "error": "Fehler", + "toolCalls": "Werkzeugaufrufe", + "sources": "Quellen", + "workflow": "Workflow", + "activity": "Aktivität", + "steps": "Schritte", + "result": "Ergebnis" + } + }, + "teams": { + "label": "Teams", + "subtitle": "Verwalte deine Teams, Mitglieder und geteilten Ressourcen.", + "newTeam": "Neues Team", + "backToTeams": "Zurück zu den Teams", + "createTeam": "Team erstellen", + "createTeamDescription": "Gib deinem Team einen Namen. Mitglieder und Ressourcen kannst du nach dem Erstellen hinzufügen.", + "editTeam": "Bearbeiten", + "teamNamePlaceholder": "Teamname", + "descriptionLabel": "Beschreibung", + "descriptionPlaceholder": "Wofür ist dieses Team? (optional)", + "create": "Erstellen", + "save": "Speichern", + "createTeamError": "Team konnte nicht erstellt werden.", + "noTeams": "Noch keine Teams.", + "noDescription": "Keine Beschreibung", + "memberCountOne": "{{count}} Mitglied", + "memberCountOther": "{{count}} Mitglieder", + "sharedCount": "{{count}} geteilt", + "loadError": "Teams konnten nicht geladen werden.", + "roleAdmin": "Admin", + "roleMember": "Mitglied", + "sourceManual": "Manuell hinzugefügt", + "sourceSso": "über SSO", + "sourceScim": "über SCIM", + "teamsHeading": "Deine Teams", + "selectTeamHint": "Wähle ein Team aus, um seine Mitglieder und geteilten Ressourcen zu verwalten.", + "selectTeamTitle": "Kein Team ausgewählt", + "teamSubtitle": "Verwalte Mitglieder und die mit diesem Team geteilten Ressourcen.", + "deleteTeam": "Team löschen", + "deleteTeamConfirmation": "Bist du sicher, dass du \"{{name}}\" löschen möchtest? Dies kann nicht rückgängig gemacht werden.", + "members": "Mitglieder", + "addMember": "Mitglied hinzufügen", + "addMemberTitle": "Mitglied hinzufügen", + "addMemberDescription": "Lade jemanden per E-Mail ein. Die Person muss sich mindestens einmal angemeldet haben.", + "teamActions": "Team-Aktionen", + "memberEmailLabel": "E-Mail des Mitglieds", + "memberEmailPlaceholder": "name@beispiel.com", + "memberRoleLabel": "Rolle", + "add": "Hinzufügen", + "remove": "Entfernen", + "removeMemberConfirmation": "Dieses Mitglied aus dem Team entfernen?", + "addMemberError": "Mitglied konnte nicht hinzugefügt werden (nur Team-Admins).", + "memberNotFound": "Kein Benutzer mit dieser E-Mail – die Person muss sich zuerst einmal anmelden.", + "noMembers": "Noch keine Mitglieder. Nutze „Mitglied hinzufügen“, um jemanden per E-Mail einzuladen.", + "roleChangeError": "Rolle konnte nicht geändert werden (der letzte Admin ist geschützt).", + "removeMemberError": "Mitglied konnte nicht entfernt werden.", + "deleteTeamError": "Team konnte nicht gelöscht werden.", + "updateFailed": "Aktualisierung fehlgeschlagen", + "openTeamError": "Team-Details konnten nicht geladen werden.", + "sharedResources": "Geteilte Ressourcen", + "sharedWithTeam": "Mit diesem Team geteilt ({{count}})", + "nothingShared": "Noch nichts geteilt. Teile einen Agenten, eine Quelle, einen Prompt oder ein Werkzeug über dessen Menü.", + "unshare": "Freigabe aufheben", + "unshareError": "Freigabe der Ressource konnte nicht aufgehoben werden.", + "resourceType": { + "agent": "Agent", + "source": "Quelle", + "prompt": "Prompt", + "tool": "Werkzeug" + }, + "share": { + "titleNamed": "\"{{name}}\" teilen", + "titleGeneric": "{{type}} teilen", + "subtitle": "Teammitglieder können es verwenden; Bearbeiter können es auch ändern. Du bleibst Eigentümer.", + "noTeams": "Du bist noch kein Mitglied eines Teams. Erstelle eins unter Einstellungen → Teams.", + "accessLevel": { + "viewer": "Betrachter", + "editor": "Bearbeiter" + }, + "done": "Fertig", + "loadError": "Aktuelle Freigaben konnten nicht geladen werden.", + "shareError": "Konnte nicht mit dem Team geteilt werden.", + "unshareError": "Freigabe für das Team konnte nicht aufgehoben werden.", + "addPlaceholder": "Personen oder Teams hinzufügen…", + "searchPlaceholder": "Personen oder Teams hinzufügen…", + "noMatches": "Keine Personen oder Teams gefunden.", + "peopleWithAccess": "Personen mit Zugriff", + "you": "Du", + "owner": "Eigentümer", + "teamsGroup": "Teams", + "peopleGroup": "Personen", + "teamLabel": "Team", + "viaTeam": "über {{team}}", + "removeAccess": "Zugriff entfernen", + "access": "Zugriff" + } }, "tools": { "label": "Werkzeuge", @@ -161,7 +519,14 @@ "manageTools": "Zu den Werkzeugen", "edit": "Bearbeiten", "delete": "Löschen", + "reconnect": "Erneut verbinden", + "shareWithTeam": "Mit Team teilen", "builtIn": "Integriert", + "authStatus": { + "connected": "Verbunden", + "needsAuth": "Authentifizierung nötig", + "configured": "Konfiguriert" + }, "deleteWarning": "Bist du sicher, dass du das Werkzeug \"{{toolName}}\" löschen möchtest?", "unsavedChanges": "Du hast ungespeicherte Änderungen, die verloren gehen, wenn du ohne Speichern verlässt.", "leaveWithoutSaving": "Ohne Speichern verlassen", @@ -188,6 +553,8 @@ "deleteActionWarning": "Bist du sicher, dass du die Aktion \"{{name}}\" löschen möchtest?", "backToAllTools": "Zurück zu allen Werkzeugen", "save": "Speichern", + "saving": "Speichere...", + "saveFailed": "Werkzeug-Konfiguration konnte nicht gespeichert werden", "fieldName": "Feldname", "fieldType": "Feldtyp", "filledByLLM": "Vom LLM ausgefüllt", @@ -203,6 +570,8 @@ "mcp": { "addServer": "MCP-Server hinzufügen", "editServer": "Server bearbeiten", + "reconnectServer": "Server erneut verbinden", + "reenterCredentials": "Gib deine Zugangsdaten erneut ein, um die Verbindung zu testen und zu aktualisieren.", "serverName": "Servername", "serverUrl": "Server-URL", "headerName": "Header-Name", @@ -215,6 +584,8 @@ "noAuth": "Keine Authentifizierung", "oauthInProgress": "Warte auf OAuth-Abschluss...", "oauthCompleted": "OAuth erfolgreich abgeschlossen", + "oauthPopupBlocked": "Popup vom Browser blockiert. Klicke unten, um zu autorisieren:", + "openAuthPage": "Autorisierungsseite öffnen", "authType": "Authentifizierungstyp", "defaultServerName": "Mein MCP-Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "Geräte", + "subtitle": "Kopple entfernte Rechner, die Agenten über Shell-Befehle steuern können.", + "pair": "Gerät koppeln", + "empty": "Keine gekoppelten Geräte.", + "revoke": "Widerrufen", + "edit": "Bearbeiten", + "revokeWarning": "{{name}} widerrufen? Die CLI wird getrennt.", + "approvalMode": "Genehmigungsmodus", + "approvalAsk": "Nachfragen", + "approvalFull": "Vollzugriff", + "online": "Online", + "offline": "Offline", + "lastSeen": "Zuletzt gesehen: {{time}}", + "lastSeenLabel": "Zuletzt gesehen", + "seenAgo": "vor {{time}} gesehen", + "nameLabel": "Gerätename", + "descriptionLabel": "Beschreibung (für den Agenten sichtbar)", + "descriptionPlaceholder": "Wofür ist dieser Rechner?", + "identity": "Identität", + "access": "Zugriff", + "connection": "Verbindung", + "hostLabel": "Host", + "dangerZone": "Gefahrenzone", + "approvalAskDescription": "Jeden Befehl vor der Ausführung genehmigen.", + "approvalFullDescription": "Führt jeden Befehl ohne Nachfrage aus, mit Ausnahme der Sicherheits-Sperrliste.", + "fullAccessWarning": "Vollzugriff: Agenten können auf diesem Rechner jeden Befehl ohne Nachfrage ausführen. Die Sicherheits-Sperrliste gilt weiterhin.", + "deviceIdLabel": "Geräte-ID", + "offlineHint": "Nicht verbunden. Führe `docsgpt-cli host` auf dem Rechner aus, um es online zu bringen.", + "dangerZoneDescription": "Das Widerrufen stoppt den Daemon auf dem Gerät und entfernt die Kopplung.", + "auditTitle": "Letzte Aktivität", + "auditLoading": "Laden…", + "auditEmpty": "Noch keine Aktivität.", + "auditDecision": "Entscheidung", + "auditExit": "Exit", + "auditDuration": "Dauer", + "pairing": { + "title": "Entfernten Rechner koppeln", + "subtitle": "Führe den folgenden Befehl auf dem entfernten Rechner aus und gib den Code ein, wenn du dazu aufgefordert wirst.", + "formSubtitle": "Lege Name, Beschreibung und Genehmigungsmodus für das neue Gerät fest.", + "nameLabel": "Gerätename (optional)", + "namePlaceholder": "z. B. edge-server", + "descriptionLabel": "Beschreibung (optional)", + "descriptionPlaceholder": "Für den Agenten sichtbar, damit er das richtige Gerät wählen kann.", + "fullAccessWarning": "Vollzugriff: Agenten können auf diesem Rechner jeden Befehl ohne Nachfrage ausführen. Die Sicherheits-Sperrliste gilt weiterhin.", + "stepOne": "1. Führe dies auf deinem entfernten Rechner aus:", + "installLink": "Du hast docsgpt-cli noch nicht? Installiere es", + "stepTwo": "2. Gib diesen Code ein, wenn du dazu aufgefordert wirst:", + "start": "Code generieren", + "starting": "Bitte warten…", + "waitingForCli": "Warte, bis die CLI den Code einlöst…", + "cancel": "Abbrechen" + } + }, "customModels": { "label": "Eigene Modelle", "subtitle": "Verbinde dein eigenes OpenAI-kompatibles Modell.", @@ -295,6 +720,15 @@ "capabilities": { "title": "Fähigkeiten", "contextWindowShort": "Kontextfenster", + "apiFlavor": "API-Protokoll", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "Reasoning-Aufwand", + "reasoningEfforts": { + "default": "Anbieter-Standard" + }, "chips": { "tools": "Tools", "structuredOutput": "Strukturierte Ausgabe", @@ -335,6 +769,7 @@ "uploadedFiles": "Hochgeladene Dateien", "cancel": "Abbrechen", "train": "Trainieren", + "create": "Erstellen", "link": "Link", "urlLink": "URL-Link", "repoUrl": "Repository-URL", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "Von Confluence hochladen" + }, + "wiki": { + "label": "Neues Wiki", + "heading": "Lebendiges Wiki erstellen" } }, "connectors": { @@ -433,6 +872,24 @@ "remove": "Entfernen", "folderAlt": "Ordner", "fileAlt": "Datei" + }, + "sharePoint": { + "connect": "Mit SharePoint verbinden", + "sessionExpired": "Sitzung abgelaufen. Bitte verbinde dich erneut mit SharePoint.", + "sessionExpiredGeneric": "Sitzung abgelaufen. Bitte verbinde dein Konto erneut.", + "validateFailed": "Sitzung konnte nicht validiert werden. Bitte verbinde dich erneut.", + "noSession": "Keine gültige Sitzung gefunden. Bitte verbinde dich erneut mit SharePoint.", + "noAccessToken": "Kein Zugriffstoken verfügbar. Bitte verbinde dich erneut mit SharePoint.", + "pickerFailed": "Dateiauswahl konnte nicht geöffnet werden. Bitte versuche es erneut.", + "selectedFiles": "Ausgewählte Dateien", + "selectFiles": "Dateien auswählen", + "loading": "Laden...", + "noFilesSelected": "Keine Dateien oder Ordner ausgewählt", + "folders": "Ordner", + "files": "Dateien", + "remove": "Entfernen", + "folderAlt": "Ordner", + "fileAlt": "Datei" } } }, @@ -509,7 +966,8 @@ "systemTime": "Aktuelle Uhrzeit (HH:MM:SS)", "systemTimestamp": "ISO 8601 Zeitstempel", "systemRequestId": "Eindeutige Anfrage-ID", - "systemUserId": "Aktuelle Benutzer-ID" + "systemUserId": "Aktuelle Benutzer-ID", + "artifactsLookup": "Artefakt-Metadaten nach ID" }, "learnAboutPrompts": "Mehr über Prompts erfahren →", "publicPromptEditDisabled": "Öffentliche Prompts können nicht bearbeitet werden", @@ -632,14 +1090,38 @@ "remove": "Anhang entfernen" }, "retry": "Erneut versuchen", - "reasoning": "Begründung" + "reasoning": "Begründung", + "wikiWrite": { + "edited": "Wiki bearbeitet", + "create": "Wiki-Seite erstellt", + "str_replace": "Wiki-Seite bearbeitet", + "insert": "Wiki-Seite bearbeitet", + "delete": "Wiki-Seite gelöscht", + "rename": "Wiki-Seite umbenannt", + "failed": "fehlgeschlagen" + } }, "agents": { "title": "Agenten", "edit": "Bearbeiten", "description": "Entdecke und erstelle benutzerdefinierte Versionen von DocsGPT, die Anweisungen, zusätzliches Wissen und beliebige Kombinationen von Fähigkeiten kombinieren", "newAgent": "Neuer Agent", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "Mit Team teilen", "backToAll": "Zurück zu allen Agenten", + "pageHeader": { + "crumbs": { + "agents": "Agenten" + }, + "tabs": { + "overview": "Übersicht", + "logs": "Protokolle", + "schedules": "Zeitpläne" + }, + "fallbackName": "Unbenannter Agent", + "subnavAriaLabel": "Agenten-Unternavigation" + }, "searchPlaceholder": "Suchen...", "noSearchResults": "Keine Agenten gefunden", "tryDifferentSearch": "Versuche einen anderen Suchbegriff", @@ -647,8 +1129,13 @@ "all": "Alle", "byDocsGPT": "Von DocsGPT", "byMe": "Von mir", + "team": "Team", "shared": "Mit mir geteilt" }, + "teamBadge": { + "editor": "Bearbeiter", + "viewer": "Betrachter" + }, "sections": { "template": { "title": "Von DocsGPT", @@ -660,6 +1147,11 @@ "description": "Von dir erstellte oder veröffentlichte Agenten", "emptyState": "Du hast noch keine Agenten erstellt." }, + "team": { + "title": "Team", + "description": "Agenten, die deine Teamkollegen mit dir geteilt haben", + "emptyState": "Mit deinem Team wurden noch keine Agenten geteilt." + }, "shared": { "title": "Mit mir geteilt", "description": "Über einen öffentlichen Link importierte Agenten", @@ -679,6 +1171,7 @@ "cancel": "Abbrechen", "delete": "Löschen", "logs": "Protokolle", + "schedules": "Zeitpläne", "accessDetails": "Zugangsdaten", "add": "Hinzufügen", "moreActions": "Weitere Aktionen" @@ -713,7 +1206,10 @@ "toolsPopup": { "title": "Werkzeuge auswählen", "searchPlaceholder": "Werkzeuge suchen...", - "noOptionsMessage": "Keine Werkzeuge verfügbar" + "noOptionsMessage": "Keine Werkzeuge verfügbar", + "groupBuiltin": "Integriert", + "groupDefault": "Standard", + "groupCustom": "Benutzerdefiniert" }, "modelsPopup": { "title": "Modelle auswählen", @@ -759,6 +1255,75 @@ "noUsageHistory": "Kein Nutzungsverlauf", "tableHeader": "Agenten-Endpunkt-Protokolle" }, + "schedules": { + "title": "Agenten-Zeitpläne", + "heading": "Zeitpläne", + "newRecurring": "Neuer Zeitplan", + "closeForm": "Formular schließen", + "edit": "Bearbeiten", + "recurring": "Wiederkehrend", + "oneTime": "Einmalige Aufgaben", + "noRecurring": "Noch keine wiederkehrenden Zeitpläne.", + "noOneTime": "Noch keine einmaligen Aufgaben.", + "pause": "Pausieren", + "resume": "Fortsetzen", + "runNow": "Jetzt ausführen", + "delete": "Löschen", + "deleteConfirm": "Diesen Zeitplan löschen? Damit wird auch sein Ausführungsverlauf gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.", + "cancel": "Abbrechen", + "showRuns": "Ausführungen anzeigen", + "hideRuns": "Ausführungen ausblenden", + "modal": { + "titleCreate": "Neuer Zeitplan", + "titleEdit": "Zeitplan bearbeiten", + "namePlaceholder": "Name der Aufgabe", + "frequency": { + "once": "Einmalig", + "daily": "Täglich", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "yearly": "Jährlich" + }, + "on": "Am", + "at": "Um", + "pickDate": "Datum wählen", + "timezone": "Zeitzone", + "timezonePlaceholder": "Zeitzone auswählen", + "timezoneSearchPlaceholder": "Zeitzone suchen…", + "timezoneEmpty": "Keine Zeitzone gefunden.", + "instructionsLabel": "Anweisungen", + "instructionsPlaceholder": "Prompt hier eingeben.", + "create": "Aufgabe erstellen", + "save": "Änderungen speichern", + "errors": { + "instructionRequired": "Anweisungen sind erforderlich.", + "runAtInPast": "Wähle ein Datum/eine Uhrzeit in der Zukunft." + }, + "days": { + "mon": "Mo", + "tue": "Di", + "wed": "Mi", + "thu": "Do", + "fri": "Fr", + "sat": "Sa", + "sun": "So" + }, + "months": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mär", + "apr": "Apr", + "may": "Mai", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Okt", + "nov": "Nov", + "dec": "Dez" + } + } + }, "shared": { "notFound": "Kein Agent gefunden. Bitte stelle sicher, dass der Agent geteilt ist." }, @@ -779,11 +1344,10 @@ "move": "Verschieben", "noFolder": "Kein Ordner (Stammverzeichnis)", "backToRoot": "Zurück", + "currentFolder": "Dieser Ordner", "noSubfolders": "Keine Unterordner", "noFolders": "Noch keine Ordner" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -823,6 +1387,17 @@ "navigation": { "agents": "Agenten" }, + "teams": { + "switcher": { + "ariaLabel": "Team wechseln", + "personal": "Persönliches Konto", + "personalSubtitle": "Persönlicher Workspace", + "roleAdmin": "Admin", + "roleMember": "Mitglied", + "manageTeam": "Team verwalten", + "createTeam": "Team erstellen" + } + }, "notification": { "ariaLabel": "Benachrichtigung", "closeAriaLabel": "Benachrichtigung schließen" diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index f25b028b..472961b0 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -189,6 +189,31 @@ "close": "Close" } }, + "testRetrieval": { + "action": "Test retrieval", + "title": "Test retrieval", + "subtitle": "See the chunks a query actually retrieves from \"{{name}}\", using the real retrieval pipeline.", + "subtitleGeneric": "See the chunks a query actually retrieves, using the real retrieval pipeline.", + "queryPlaceholder": "Ask something this source should answer...", + "run": "Run", + "notSavedHint": "These settings are only used for this test — they are not saved to the source. Change them in Source settings to make them permanent.", + "resultSummary": "{{total}} chunks retrieved · {{retriever}}", + "latency": "{{ms}} ms", + "empty": "No chunks retrieved for this query.", + "emptyWithThreshold": "No chunks scored at or above the score threshold ({{threshold}}). Lower it to see near misses.", + "showMore": "Show full chunk", + "showLess": "Show less", + "noScore": "no score", + "noScoreHint": "This retriever ranks chunks without producing a comparable per-chunk score.", + "scoreKinds": { + "cosine_similarity": "similarity", + "l2_distance": "distance", + "rrf": "RRF" + }, + "errors": { + "failed": "Retrieval test failed. Please try again." + } + }, "editConfig": "Source settings", "shareWithTeam": "Share with team", "deleteWarning": "Are you sure you want to delete \"{{name}}\"?", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index b23a9a08..700d8732 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -12,6 +12,18 @@ "emailUs": "Envíanos un correo", "documentation": "Documentación", "manageAgents": "Administrar Agentes", + "teamAccess": { + "editor": "Editor", + "viewer": "Lector" + }, + "notifications": { + "memberAddedTitle": "Añadido a un equipo", + "memberAddedBody": "Te añadieron a {{team}} como {{role}}", + "sharedTitle": "Compartido contigo", + "sharedBody": "{{type}} compartido con tu equipo: {{name}} ({{access}})", + "sharedBodyNoName": "Se compartió un {{type}} con tu equipo ({{access}})", + "dismiss": "Descartar" + }, "demo": [ { "header": "Aprende sobre DocsGPT", @@ -30,6 +42,15 @@ "query": "Escribe posibles preguntas para el contexto" } ], + "auth": { + "signInToContinue": "Inicia sesión para continuar en DocsGPT", + "signInWithSSO": "Iniciar sesión con SSO", + "signInWith": "Iniciar sesión con {{provider}}", + "notAuthorized": "Tu cuenta no está autorizada para acceder a este espacio de trabajo. Inicia sesión con otra cuenta o contacta a tu administrador.", + "accountDisabled": "Tu cuenta ha sido deshabilitada. Contacta a tu administrador.", + "signOut": "Cerrar sesión", + "account": "Cuenta" + }, "settings": { "label": "Configuración", "general": { @@ -42,7 +63,7 @@ "prompt": "Prompt Activo", "deleteAllLabel": "Eliminar todas las conversaciones", "deleteAllBtn": "Eliminar todo", - "add": "Añadir", + "addNew": "Añadir Nuevo", "convHistory": "Historial de conversaciones", "none": "Ninguno", "low": "Bajo", @@ -50,7 +71,7 @@ "high": "Alto", "unlimited": "Ilimitado", "default": "Predeterminado", - "addNew": "Añadir Nuevo" + "add": "Añadir" }, "sources": { "title": "Aquí puedes gestionar todos los archivos fuente que están disponibles para ti y los que has subido.", @@ -82,6 +103,119 @@ }, "actions": "Acciones", "view": "Ver", + "wiki": { + "view": "Ver wiki", + "empty": "Esta wiki aún no tiene páginas.", + "selectPage": "Selecciona una página para ver su contenido.", + "edit": "Editar", + "save": "Guardar", + "cancel": "Cancelar", + "saving": "Guardando…", + "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", + "human": "humano", + "you": "tú", + "unknown": "desconocido", + "version": "v{{version}}" + }, + "convert": { + "action": "Convertir en wiki", + "title": "Convertir en una wiki viva", + "intro": "Convierte \"{{name}}\" en una wiki viva de páginas editables que los agentes pueden leer y reescribir.", + "introGeneric": "Convierte esta fuente en una wiki viva de páginas editables que los agentes pueden leer y reescribir.", + "costReparse": "Los archivos se vuelven a analizar como páginas editables. El formato se simplifica, especialmente en los PDF.", + "costSkipped": "Los archivos que no son de texto (imágenes, binarios) se omiten.", + "costIrreversible": "Esto no es reversible — las páginas de la wiki pasan a ser la fuente de verdad.", + "confirm": "Convertir en wiki", + "inProgress": "Convirtiendo archivos en páginas de wiki…", + "summaryPages_one": "{{count}} página creada", + "summaryPages_other": "{{count}} páginas creadas", + "summarySkipped_one": "{{count}} archivo omitido", + "summarySkipped_other": "{{count}} archivos omitidos", + "skippedHeading": "Archivos omitidos", + "done": "Listo", + "errors": { + "generic": "La conversión falló. Inténtalo de nuevo.", + "forbidden": "No tienes permiso para convertir esta fuente.", + "conflict": "Esta fuente todavía se está indexando. Espera a que termine antes de convertirla." + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "Construyendo grafo…", + "buildingPct": "Construyendo grafo… {{pct}}%", + "enable": { + "action": "Habilitar GraphRAG", + "title": "Habilitar GraphRAG", + "intro": "Construye un grafo de conocimiento sobre \"{{name}}\" para que los agentes puedan razonar a través de entidades y relaciones vinculadas.", + "introGeneric": "Construye un grafo de conocimiento sobre esta fuente para que los agentes puedan razonar a través de entidades y relaciones vinculadas.", + "costExtraction": "Las entidades y relaciones se extraen ejecutando un LLM sobre los fragmentos de esta fuente.", + "costCost": "Esto hace una llamada al LLM por fragmento, por lo que consume tokens y puede tardar bastante en fuentes grandes.", + "estimate": "~1 llamada al LLM por fragmento · ~{{lo}}–{{hi}} tokens en total (aproximadamente mitad de entrada, mitad de salida); la salida varía según la densidad de entidades del documento.", + "confirm": "Habilitar GraphRAG", + "inProgress": "Construyendo grafo…", + "inProgressPct": "Construyendo grafo… {{pct}}%", + "summaryNodes_one": "{{count}} entidad", + "summaryNodes_other": "{{count}} entidades", + "summaryEdges_one": "{{count}} relación", + "summaryEdges_other": "{{count}} relaciones", + "summaryChunks_one": "{{count}} fragmento procesado", + "summaryChunks_other": "{{count}} fragmentos procesados", + "done": "Listo", + "errors": { + "generic": "No se pudo habilitar GraphRAG. Inténtalo de nuevo.", + "forbidden": "No tienes permiso para habilitar GraphRAG en esta fuente.", + "conflict": "Esta fuente todavía se está indexando. Espera a que termine antes de habilitar GraphRAG." + } + }, + "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", + "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" + } + }, + "testRetrieval": { + "action": "Probar recuperación", + "title": "Probar recuperación", + "subtitle": "Mira los fragmentos que una consulta realmente recupera de \"{{name}}\", usando el pipeline de recuperación real.", + "subtitleGeneric": "Mira los fragmentos que una consulta realmente recupera, usando el pipeline de recuperación real.", + "queryPlaceholder": "Pregunta algo que esta fuente debería responder...", + "run": "Ejecutar", + "notSavedHint": "Estos ajustes solo se usan para esta prueba — no se guardan en la fuente. Cámbialos en Configuración de la fuente para hacerlos permanentes.", + "resultSummary": "{{total}} fragmentos recuperados · {{retriever}}", + "latency": "{{ms}} ms", + "empty": "No se recuperaron fragmentos para esta consulta.", + "emptyWithThreshold": "Ningún fragmento alcanzó el umbral de puntuación ({{threshold}}). Bájalo para ver los que estuvieron cerca.", + "showMore": "Mostrar fragmento completo", + "showLess": "Mostrar menos", + "noScore": "sin puntuación", + "noScoreHint": "Este recuperador ordena los fragmentos sin producir una puntuación comparable por fragmento.", + "scoreKinds": { + "cosine_similarity": "similitud", + "l2_distance": "distancia", + "rrf": "RRF" + }, + "errors": { + "failed": "La prueba de recuperación falló. Inténtalo de nuevo." + } + }, + "editConfig": "Configuración de la fuente", + "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", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "Subiendo archivos...", "deletingTitle": "Eliminando...", "deleteDirectoryWarning": "¿Está seguro de que desea eliminar el directorio \"{{name}}\" y todo su contenido? Esta acción no se puede deshacer.", - "searchAlt": "Buscar" + "searchAlt": "Buscar", + "retrievalOptions": { + "title": "Configuración avanzada", + "retrieval": { + "title": "Recuperación", + "note": "Estos cambios surten efecto de inmediato, sin necesidad de reindexar.", + "tag": "sin reindexar", + "chunks": "Fragmentos a recuperar (top-k)", + "scoreThreshold": "Umbral de puntuación", + "scoreThresholdPlaceholder": "Ninguno", + "scoreThresholdHint": "Solo devuelve fragmentos por encima de esta puntuación de relevancia (0–1); cuanto más alta, más estricta.", + "rephraseQuery": "Reformular la consulta antes de buscar", + "retriever": "Recuperador", + "retrievers": { + "classic": "Clásico", + "hybrid": "Híbrido", + "graphrag": "GraphRAG" + }, + "retrieverHint": "Híbrido combina búsqueda por palabras clave + vectorial. GraphRAG construye un grafo de conocimiento sobre esta fuente para preguntas de varios saltos.", + "exposure": "Exposición de búsqueda", + "exposures": { + "prefetch": "Precargar en el contexto", + "agentic_tool": "Herramienta de búsqueda bajo demanda" + }, + "exposureHint": "Precarga esta fuente en el prompt, o deja que el agente la busque bajo demanda como herramienta." + }, + "prescreen": { + "enable": "Habilitar preselección con LLM", + "warning": "Obtiene un conjunto de candidatos más grande y usa un LLM para filtrarlo. Esto añade latencia y costo por consulta.", + "candidateK": "Candidatos a obtener", + "maxKeep": "Máximo de fragmentos a conservar", + "batchSize": "Tamaño de lote", + "model": "Modelo (opcional)", + "modelPlaceholder": "Por defecto, el modelo de la solicitud" + }, + "graph": { + "title": "Extracción de grafo", + "tag": "reindexar para aplicar", + "extractionModel": "Modelo de extracción", + "extractionModelHint": "LLM usado para extraer entidades y relaciones de cada fragmento.", + "defaultModel": "Usar modelo predeterminado", + "maxChunks": "Máximo de fragmentos a extraer", + "maxChunksHint": "Limita cuántos fragmentos se envían para extracción. Déjalo vacío para usar el valor predeterminado de la instancia.", + "maxChunksPlaceholder": "Predeterminado" + }, + "chunking": { + "title": "Fragmentación", + "note": "Ajustes de indexación. Cambiarlos requiere reindexar la fuente.", + "tag": "reindexar para aplicar", + "strategy": "Estrategia de fragmentación", + "strategies": { + "classic_chunk": "Clásica", + "recursive": "Recursiva", + "markdown": "Markdown", + "parent_child": "Padre / hijo", + "semantic": "Semántica" + }, + "maxTokens": "Máximo de tokens por fragmento", + "minTokens": "Mínimo de tokens por fragmento", + "duplicateHeaders": "Duplicar encabezados entre fragmentos" + } + }, + "configModal": { + "title": "Configuración de la fuente", + "subtitle": "Configura cómo se fragmenta y recupera \"{{name}}\".", + "subtitleGeneric": "Configura cómo se fragmenta y recupera esta fuente.", + "save": "Guardar", + "saving": "Guardando…", + "readOnly": "Tienes acceso de solo lectura a esta fuente compartida y no puedes cambiar su configuración.", + "chunkingChangeHint": "Cambiaste un ajuste de fragmentación. Al guardar será necesario reindexar esta fuente para que surta efecto.", + "prescreenInvalidHint": "Revisa los valores de preselección: los candidatos a obtener deben ser al menos el número de fragmentos, y los fragmentos a conservar no deben superar los candidatos a obtener.", + "reingestRequired": "Tus cambios se guardaron. Los ajustes de fragmentación que cambiaste solo surten efecto tras reindexar. ¿Reindexar ahora?", + "reingestLater": "Más tarde", + "errors": { + "forbidden": "No tienes permiso para editar la configuración de esta fuente.", + "saveFailed": "No se pudo guardar la configuración. Inténtalo de nuevo." + } + } }, "apiKeys": { "label": "Chatbots", @@ -138,7 +349,30 @@ "filterPlaceholder": "Filtrar", "none": "Ninguno", "positiveFeedback": "Retroalimentación Positiva", - "negativeFeedback": "Retroalimentación Negativa" + "negativeFeedback": "Retroalimentación Negativa", + "groupBy": "Agrupar por", + "groupOptions": { + "total": "Total", + "model": "Por modelo", + "agent": "Por agente", + "source": "Por fuente" + }, + "includeSideChannel": "Tokens en segundo plano", + "promptTokens": "Tokens de Prompt", + "generatedTokens": "Tokens Generados", + "scheduledRuns": "Ejecuciones Programadas", + "toolUsage": "Uso de Herramientas", + "completed": "Completadas", + "failed": "Fallidas", + "skipped": "Omitidas", + "successful": "Exitosas", + "stats": { + "messages": "Mensajes", + "tokens": "Tokens", + "toolCalls": "Llamadas a Herramientas", + "runSuccess": "Éxito de Ejecución", + "feedback": "Retroalimentación" + } }, "logs": { "label": "Registros", @@ -146,7 +380,131 @@ "filterByChatbot": "Filtrar por chatbot", "selectChatbot": "Seleccionar chatbot", "none": "Ninguno", - "tableHeader": "Conversaciones generadas por API / chatbot" + "tableHeader": "Conversaciones generadas por API / chatbot", + "searchPlaceholder": "Buscar registros...", + "noLogs": "No se encontraron registros", + "levels": { + "all": "Todos los niveles", + "info": "Información", + "error": "Error", + "warning": "Advertencia" + }, + "types": { + "all": "Todos los eventos", + "chat": "Chat", + "schedule": "Programado", + "webhook": "Webhook", + "workflow": "Flujo de trabajo", + "system": "Sistema" + }, + "detail": { + "agent": "Agente", + "status": "Estado", + "trigger": "Disparador", + "errorType": "Tipo de error", + "tokens": "Tokens", + "duration": "Duración", + "conversation": "Conversación", + "endpoint": "Endpoint", + "instruction": "Instrucción", + "response": "Respuesta", + "output": "Salida", + "error": "Error", + "toolCalls": "Llamadas a herramientas", + "sources": "Fuentes", + "workflow": "Flujo de trabajo", + "activity": "Actividad", + "steps": "Pasos", + "result": "Resultado" + } + }, + "teams": { + "label": "Equipos", + "subtitle": "Gestiona tus equipos, miembros y recursos compartidos.", + "newTeam": "Nuevo equipo", + "backToTeams": "Volver a equipos", + "createTeam": "Crear un equipo", + "createTeamDescription": "Dale un nombre a tu equipo. Podrás añadir miembros y compartir recursos una vez creado.", + "editTeam": "Editar", + "teamNamePlaceholder": "Nombre del equipo", + "descriptionLabel": "Descripción", + "descriptionPlaceholder": "¿Para qué es este equipo? (opcional)", + "create": "Crear", + "save": "Guardar", + "createTeamError": "No se pudo crear el equipo.", + "noTeams": "Aún no hay equipos.", + "noDescription": "Sin descripción", + "memberCountOne": "{{count}} miembro", + "memberCountOther": "{{count}} miembros", + "sharedCount": "{{count}} compartidos", + "loadError": "No se pudieron cargar los equipos.", + "roleAdmin": "admin", + "roleMember": "miembro", + "sourceManual": "Añadido manualmente", + "sourceSso": "vía SSO", + "sourceScim": "vía SCIM", + "teamsHeading": "Tus equipos", + "selectTeamHint": "Selecciona un equipo para gestionar sus miembros y recursos compartidos.", + "selectTeamTitle": "Ningún equipo seleccionado", + "teamSubtitle": "Gestiona los miembros y los recursos compartidos con este equipo.", + "deleteTeam": "Eliminar equipo", + "deleteTeamConfirmation": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.", + "members": "Miembros", + "addMember": "Añadir miembro", + "addMemberTitle": "Añadir un miembro", + "addMemberDescription": "Invita a alguien por correo. Debe haber iniciado sesión al menos una vez.", + "teamActions": "Acciones del equipo", + "memberEmailLabel": "Correo del miembro", + "memberEmailPlaceholder": "nombre@ejemplo.com", + "memberRoleLabel": "Rol", + "add": "Añadir", + "remove": "Quitar", + "removeMemberConfirmation": "¿Quitar a este miembro del equipo?", + "addMemberError": "No se pudo añadir al miembro (solo administradores del equipo).", + "memberNotFound": "No hay ningún usuario con ese correo — primero debe iniciar sesión una vez.", + "noMembers": "Aún no hay miembros. Usa “Añadir miembro” para invitar a alguien por correo.", + "roleChangeError": "No se pudo cambiar el rol (el último admin está protegido).", + "removeMemberError": "No se pudo quitar al miembro.", + "deleteTeamError": "No se pudo eliminar el equipo.", + "updateFailed": "Error al actualizar", + "openTeamError": "No se pudieron cargar los detalles del equipo.", + "sharedResources": "Recursos compartidos", + "sharedWithTeam": "Compartido con este equipo ({{count}})", + "nothingShared": "Aún no se ha compartido nada. Comparte un agente, fuente, prompt o herramienta desde su menú.", + "unshare": "Dejar de compartir", + "unshareError": "No se pudo dejar de compartir el recurso.", + "resourceType": { + "agent": "Agente", + "source": "Fuente", + "prompt": "Prompt", + "tool": "Herramienta" + }, + "share": { + "titleNamed": "Compartir \"{{name}}\"", + "titleGeneric": "Compartir este {{type}}", + "subtitle": "Los miembros del equipo pueden usarlo; los editores también pueden modificarlo. Tú sigues siendo el propietario.", + "noTeams": "Aún no eres miembro de ningún equipo. Crea uno en Configuración → Equipos.", + "accessLevel": { + "viewer": "Lector", + "editor": "Editor" + }, + "done": "Listo", + "loadError": "No se pudieron cargar los accesos actuales.", + "shareError": "No se pudo compartir con el equipo.", + "unshareError": "No se pudo dejar de compartir con el equipo.", + "addPlaceholder": "Añadir personas o equipos…", + "searchPlaceholder": "Añadir personas o equipos…", + "noMatches": "No se encontraron personas ni equipos.", + "peopleWithAccess": "Personas con acceso", + "you": "Tú", + "owner": "Propietario", + "teamsGroup": "Equipos", + "peopleGroup": "Personas", + "teamLabel": "Equipo", + "viaTeam": "vía {{team}}", + "removeAccess": "Quitar acceso", + "access": "Acceso" + } }, "tools": { "label": "Herramientas", @@ -161,7 +519,14 @@ "manageTools": "Ir a Herramientas", "edit": "Editar", "delete": "Eliminar", + "reconnect": "Reconectar", + "shareWithTeam": "Compartir con el equipo", "builtIn": "Integrada", + "authStatus": { + "connected": "Conectado", + "needsAuth": "Requiere autenticación", + "configured": "Configurado" + }, "deleteWarning": "¿Estás seguro de que deseas eliminar la herramienta \"{{toolName}}\"?", "unsavedChanges": "Tienes cambios sin guardar que se perderán si sales sin guardar.", "leaveWithoutSaving": "Salir sin Guardar", @@ -186,23 +551,27 @@ "queryParameters": "Parámetros de Consulta", "body": "Cuerpo", "deleteActionWarning": "¿Estás seguro de que deseas eliminar la acción \"{{name}}\"?", + "backToAllTools": "Volver a todas las herramientas", "save": "Guardar", - "name": "Nombre", - "type": "Tipo", + "saving": "Guardando...", + "saveFailed": "No se pudo guardar la configuración de la herramienta", + "fieldName": "Nombre del campo", + "fieldType": "Tipo de campo", "filledByLLM": "Completado por LLM", + "fieldDescription": "Descripción del campo", "value": "Valor", "addProperty": "Agregar propiedad", "propertyName": "Nueva clave de propiedad", - "backToAllTools": "Volver a todas las herramientas", - "fieldName": "Nombre del campo", - "fieldType": "Tipo de campo", - "fieldDescription": "Descripción del campo", "add": "Añadir", "cancel": "Cancelar", "addNew": "Añadir Nuevo", + "name": "Nombre", + "type": "Tipo", "mcp": { "addServer": "Add MCP Server", "editServer": "Edit Server", + "reconnectServer": "Reconectar servidor", + "reenterCredentials": "Vuelve a introducir tus credenciales para probar y actualizar la conexión.", "serverName": "Server Name", "serverUrl": "Server URL", "headerName": "Header Name", @@ -215,6 +584,8 @@ "noAuth": "No Authentication", "oauthInProgress": "Waiting for OAuth completion...", "oauthCompleted": "OAuth completed successfully", + "oauthPopupBlocked": "El navegador bloqueó la ventana emergente. Haz clic abajo para autorizar:", + "openAuthPage": "Abrir página de autorización", "authType": "Authentication Type", "defaultServerName": "My MCP Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "Dispositivos", + "subtitle": "Empareja máquinas remotas que los agentes pueden controlar mediante comandos de shell.", + "pair": "Emparejar un dispositivo", + "empty": "No hay dispositivos emparejados.", + "revoke": "Revocar", + "edit": "Editar", + "revokeWarning": "¿Revocar {{name}}? La CLI se desconectará.", + "approvalMode": "Modo de aprobación", + "approvalAsk": "Preguntar", + "approvalFull": "Acceso completo", + "online": "En línea", + "offline": "Desconectado", + "lastSeen": "Visto por última vez: {{time}}", + "lastSeenLabel": "Visto por última vez", + "seenAgo": "visto hace {{time}}", + "nameLabel": "Nombre del dispositivo", + "descriptionLabel": "Descripción (visible para el agente)", + "descriptionPlaceholder": "¿Para qué es esta máquina?", + "identity": "Identidad", + "access": "Acceso", + "connection": "Conexión", + "hostLabel": "Host", + "dangerZone": "Zona de peligro", + "approvalAskDescription": "Aprueba cada comando antes de que se ejecute.", + "approvalFullDescription": "Ejecuta todos los comandos sin preguntar, excepto la lista de denegación de seguridad.", + "fullAccessWarning": "Acceso completo: los agentes pueden ejecutar cualquier comando en esta máquina sin preguntar. La lista de denegación de seguridad sigue aplicándose.", + "deviceIdLabel": "ID del dispositivo", + "offlineHint": "No conectado. Ejecuta `docsgpt-cli host` en la máquina para ponerla en línea.", + "dangerZoneDescription": "Revocar detiene el daemon en el dispositivo y elimina el emparejamiento.", + "auditTitle": "Actividad reciente", + "auditLoading": "Cargando…", + "auditEmpty": "Aún no hay actividad.", + "auditDecision": "Decisión", + "auditExit": "Salida", + "auditDuration": "Duración", + "pairing": { + "title": "Emparejar una máquina remota", + "subtitle": "Ejecuta el comando de abajo en la máquina remota e introduce el código cuando se te pida.", + "formSubtitle": "Define un nombre, una descripción y un modo de aprobación para el nuevo dispositivo.", + "nameLabel": "Nombre del dispositivo (opcional)", + "namePlaceholder": "p. ej. edge-server", + "descriptionLabel": "Descripción (opcional)", + "descriptionPlaceholder": "Visible para el agente para que pueda elegir el dispositivo correcto.", + "fullAccessWarning": "Acceso completo: los agentes pueden ejecutar cualquier comando en esta máquina sin preguntar. La lista de denegación de seguridad sigue aplicándose.", + "stepOne": "1. Ejecuta esto en tu máquina remota:", + "installLink": "¿No tienes docsgpt-cli? Instálalo", + "stepTwo": "2. Introduce este código cuando se te pida:", + "start": "Generar código", + "starting": "Procesando…", + "waitingForCli": "Esperando a que la CLI canjee el código…", + "cancel": "Cancelar" + } + }, "customModels": { "label": "Modelos personalizados", "subtitle": "Trae tu propio modelo compatible con OpenAI.", @@ -295,6 +720,15 @@ "capabilities": { "title": "Capacidades", "contextWindowShort": "Ventana de contexto", + "apiFlavor": "Protocolo de API", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "Esfuerzo de razonamiento", + "reasoningEfforts": { + "default": "Predeterminado del proveedor" + }, "chips": { "tools": "Herramientas", "structuredOutput": "Salida estructurada", @@ -335,6 +769,7 @@ "uploadedFiles": "Archivos Subidos", "cancel": "Cancelar", "train": "Entrenar", + "create": "Crear", "link": "Enlace", "urlLink": "Enlace URL", "repoUrl": "URL del Repositorio", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "Subir desde Confluence" + }, + "wiki": { + "label": "Nueva wiki", + "heading": "Crear una wiki viva" } }, "connectors": { @@ -527,7 +966,8 @@ "systemTime": "Hora actual (HH:MM:SS)", "systemTimestamp": "Marca de tiempo ISO 8601", "systemRequestId": "Identificador único de solicitud", - "systemUserId": "ID del usuario actual" + "systemUserId": "ID del usuario actual", + "artifactsLookup": "Metadatos de artefactos por id" }, "learnAboutPrompts": "Aprende sobre los Prompts →", "publicPromptEditDisabled": "Los prompts públicos no se pueden editar", @@ -640,9 +1080,9 @@ }, "sources": { "title": "Fuentes", + "text": "Elegir tus fuentes", "link": "Enlace fuente", "view_more": "Ver {{count}} más fuentes", - "text": "Elegir tus fuentes", "noSourcesAvailable": "No hay fuentes disponibles" }, "attachments": { @@ -650,14 +1090,38 @@ "remove": "Eliminar adjunto" }, "retry": "Reintentar", - "reasoning": "Razonamiento" + "reasoning": "Razonamiento", + "wikiWrite": { + "edited": "Wiki editada", + "create": "Página de wiki creada", + "str_replace": "Página de wiki editada", + "insert": "Página de wiki editada", + "delete": "Página de wiki eliminada", + "rename": "Página de wiki renombrada", + "failed": "falló" + } }, "agents": { "title": "Agentes", "edit": "Editar", "description": "Descubre y crea versiones personalizadas de DocsGPT que combinan instrucciones, conocimiento adicional y cualquier combinación de habilidades", "newAgent": "Nuevo Agente", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "Compartir con el equipo", "backToAll": "Volver a todos los agentes", + "pageHeader": { + "crumbs": { + "agents": "Agentes" + }, + "tabs": { + "overview": "Resumen", + "logs": "Registros", + "schedules": "Programaciones" + }, + "fallbackName": "Agente sin título", + "subnavAriaLabel": "Subnavegación del agente" + }, "searchPlaceholder": "Buscar...", "noSearchResults": "No se encontraron agentes", "tryDifferentSearch": "Prueba con un término de búsqueda diferente", @@ -665,8 +1129,13 @@ "all": "Todos", "byDocsGPT": "Por DocsGPT", "byMe": "Por mí", + "team": "Equipo", "shared": "Compartidos conmigo" }, + "teamBadge": { + "editor": "Editor", + "viewer": "Lector" + }, "sections": { "template": { "title": "Por DocsGPT", @@ -678,6 +1147,11 @@ "description": "Agentes creados o publicados por ti", "emptyState": "Aún no tienes agentes creados." }, + "team": { + "title": "Equipo", + "description": "Agentes que tus compañeros de equipo compartieron contigo", + "emptyState": "Aún no se han compartido agentes con tu equipo." + }, "shared": { "title": "Compartidos conmigo", "description": "Agentes importados mediante un enlace público", @@ -697,6 +1171,7 @@ "cancel": "Cancelar", "delete": "Eliminar", "logs": "Registros", + "schedules": "Programaciones", "accessDetails": "Detalles de Acceso", "add": "Agregar", "moreActions": "Más acciones" @@ -731,7 +1206,10 @@ "toolsPopup": { "title": "Seleccionar Herramientas", "searchPlaceholder": "Buscar herramientas...", - "noOptionsMessage": "No hay herramientas disponibles" + "noOptionsMessage": "No hay herramientas disponibles", + "groupBuiltin": "Integradas", + "groupDefault": "Predeterminadas", + "groupCustom": "Personalizadas" }, "modelsPopup": { "title": "Seleccionar Modelos", @@ -777,6 +1255,75 @@ "noUsageHistory": "Sin historial de uso", "tableHeader": "Registros del endpoint del agente" }, + "schedules": { + "title": "Programaciones del Agente", + "heading": "Programaciones", + "newRecurring": "Nueva programación", + "closeForm": "Cerrar formulario", + "edit": "Editar", + "recurring": "Recurrentes", + "oneTime": "Tareas únicas", + "noRecurring": "Aún no hay programaciones recurrentes.", + "noOneTime": "Aún no hay tareas únicas.", + "pause": "Pausar", + "resume": "Reanudar", + "runNow": "Ejecutar ahora", + "delete": "Eliminar", + "deleteConfirm": "¿Eliminar esta programación? También se eliminará su historial de ejecuciones. Esta acción no se puede deshacer.", + "cancel": "Cancelar", + "showRuns": "Mostrar ejecuciones", + "hideRuns": "Ocultar ejecuciones", + "modal": { + "titleCreate": "Nueva programación", + "titleEdit": "Editar programación", + "namePlaceholder": "Nombre de la tarea", + "frequency": { + "once": "Una vez", + "daily": "Diario", + "weekly": "Semanal", + "monthly": "Mensual", + "yearly": "Anual" + }, + "on": "El", + "at": "A las", + "pickDate": "Elegir una fecha", + "timezone": "Zona horaria", + "timezonePlaceholder": "Seleccionar zona horaria", + "timezoneSearchPlaceholder": "Buscar zona horaria…", + "timezoneEmpty": "No se encontró la zona horaria.", + "instructionsLabel": "Instrucciones", + "instructionsPlaceholder": "Escribe el prompt aquí.", + "create": "Crear tarea", + "save": "Guardar cambios", + "errors": { + "instructionRequired": "Las instrucciones son obligatorias.", + "runAtInPast": "Elige una fecha y hora en el futuro." + }, + "days": { + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom" + }, + "months": { + "jan": "Ene", + "feb": "Feb", + "mar": "Mar", + "apr": "Abr", + "may": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Ago", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dic" + } + } + }, "shared": { "notFound": "No se encontró el agente. Asegúrate de que el agente esté compartido." }, @@ -797,11 +1344,10 @@ "move": "Mover", "noFolder": "Sin carpeta (raíz)", "backToRoot": "Volver", + "currentFolder": "Esta carpeta", "noSubfolders": "Sin subcarpetas", "noFolders": "No hay carpetas todavía" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -841,6 +1387,17 @@ "navigation": { "agents": "Agentes" }, + "teams": { + "switcher": { + "ariaLabel": "Cambiar de equipo", + "personal": "Cuenta personal", + "personalSubtitle": "Espacio de trabajo personal", + "roleAdmin": "Admin", + "roleMember": "Miembro", + "manageTeam": "Gestionar equipo", + "createTeam": "Crear equipo" + } + }, "notification": { "ariaLabel": "Notificación", "closeAriaLabel": "Cerrar notificación" diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index a6f8a7cb..02157c05 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -12,6 +12,18 @@ "emailUs": "メールを送る", "documentation": "ドキュメント", "manageAgents": "エージェント管理", + "teamAccess": { + "editor": "編集者", + "viewer": "閲覧者" + }, + "notifications": { + "memberAddedTitle": "チームに追加されました", + "memberAddedBody": "{{role}}として{{team}}に追加されました", + "sharedTitle": "あなたに共有されました", + "sharedBody": "チームに{{type}}が共有されました: {{name}}({{access}})", + "sharedBodyNoName": "チームに{{type}}が共有されました({{access}})", + "dismiss": "閉じる" + }, "demo": [ { "header": "DocsGPTについて学ぶ", @@ -30,6 +42,15 @@ "query": "このコンテンツに対する可能な質問を書いてください" } ], + "auth": { + "signInToContinue": "続行するにはDocsGPTにサインインしてください", + "signInWithSSO": "SSOでサインイン", + "signInWith": "{{provider}}でサインイン", + "notAuthorized": "このアカウントにはこのワークスペースへのアクセス権がありません。別のアカウントでサインインするか、管理者にお問い合わせください。", + "accountDisabled": "アカウントが無効化されています。管理者にお問い合わせください。", + "signOut": "サインアウト", + "account": "アカウント" + }, "settings": { "label": "設定", "general": { @@ -82,6 +103,119 @@ }, "actions": "アクション", "view": "表示", + "wiki": { + "view": "Wikiを表示", + "empty": "このWikiにはまだページがありません。", + "selectPage": "ページを選択して内容を表示します。", + "edit": "編集", + "save": "保存", + "cancel": "キャンセル", + "saving": "保存中…", + "saveFailed": "ページを保存できませんでした。もう一度お試しください。", + "forbidden": "このページを編集する権限がありません。", + "conflict": "このページは開いた後に変更されたため、再読み込みされました。あなたの編集内容は下にあります。再適用して、もう一度保存してください。", + "editPlaceholder": "Markdownでページを記述…", + "livingTitle": "リビングWiki。", + "livingExplainer": "エージェントが編集可能なソースです。エージェントの作業中にページが読み書きされ、デフォルトでエージェント検索ツールを通じてエージェントに公開されます。", + "livingExplainerEditable": "このソースを使用するすべてのエージェントが編集できます。", + "stamp": { + "editedBy": "{{who}}が編集", + "agent": "エージェント", + "human": "人間", + "you": "あなた", + "unknown": "不明", + "version": "v{{version}}" + }, + "convert": { + "action": "Wikiに変換", + "title": "リビングWikiに変換", + "intro": "「{{name}}」を、エージェントが読み書きできる編集可能なページのリビングWikiに変換します。", + "introGeneric": "このソースを、エージェントが読み書きできる編集可能なページのリビングWikiに変換します。", + "costReparse": "ファイルは編集可能なページに再解析されます。特にPDFでは書式が簡略化されます。", + "costSkipped": "テキスト以外のファイル(画像、バイナリ)はスキップされます。", + "costIrreversible": "この操作は元に戻せません。以後はWikiページが正式な情報源になります。", + "confirm": "Wikiに変換", + "inProgress": "ファイルをWikiページに変換中…", + "summaryPages_one": "{{count}}ページを作成しました", + "summaryPages_other": "{{count}}ページを作成しました", + "summarySkipped_one": "{{count}}件のファイルをスキップしました", + "summarySkipped_other": "{{count}}件のファイルをスキップしました", + "skippedHeading": "スキップされたファイル", + "done": "完了", + "errors": { + "generic": "変換に失敗しました。もう一度お試しください。", + "forbidden": "このソースを変換する権限がありません。", + "conflict": "このソースはまだインデックス作成中です。完了してから変換してください。" + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "グラフを構築中…", + "buildingPct": "グラフを構築中… {{pct}}%", + "enable": { + "action": "GraphRAGを有効にする", + "title": "GraphRAGを有効にする", + "intro": "「{{name}}」上にナレッジグラフを構築し、エージェントが関連するエンティティと関係をまたいで推論できるようにします。", + "introGeneric": "このソース上にナレッジグラフを構築し、エージェントが関連するエンティティと関係をまたいで推論できるようにします。", + "costExtraction": "このソースのチャンクに対してLLMを実行し、エンティティと関係を抽出します。", + "costCost": "チャンクごとに1回のLLM呼び出しを行うため、トークンを消費し、大きなソースでは時間がかかる場合があります。", + "estimate": "チャンクあたり約1回のLLM呼び出し · 合計約{{lo}}–{{hi}}トークン(入力と出力がおよそ半分ずつ)。出力はドキュメントのエンティティ密度によって変わります。", + "confirm": "GraphRAGを有効にする", + "inProgress": "グラフを構築中…", + "inProgressPct": "グラフを構築中… {{pct}}%", + "summaryNodes_one": "{{count}}件のエンティティ", + "summaryNodes_other": "{{count}}件のエンティティ", + "summaryEdges_one": "{{count}}件の関係", + "summaryEdges_other": "{{count}}件の関係", + "summaryChunks_one": "{{count}}件のチャンクを処理しました", + "summaryChunks_other": "{{count}}件のチャンクを処理しました", + "done": "完了", + "errors": { + "generic": "GraphRAGの有効化に失敗しました。もう一度お試しください。", + "forbidden": "このソースでGraphRAGを有効にする権限がありません。", + "conflict": "このソースはまだインデックス作成中です。完了してからGraphRAGを有効にしてください。" + } + }, + "view": { + "action": "グラフを表示", + "title": "ナレッジグラフ", + "explainer": "ノードはエンティティ、エッジはその関係を表します。ノードをクリックすると、説明と抽出元のチャンクが表示されます。", + "stats": "{{nodes}}件のエンティティ · {{edges}}件の関係", + "empty": "グラフはまだありません。抽出が実行中の可能性があります。", + "selectNode": "ノードを選択すると詳細が表示されます。", + "linkedChunks": "リンクされたチャンク", + "noChunks": "このエンティティにリンクされたチャンクはありません。", + "close": "閉じる" + } + }, + "testRetrieval": { + "action": "検索テスト", + "title": "検索テスト", + "subtitle": "実際の検索パイプラインを使用して、クエリが「{{name}}」から実際に取得するチャンクを確認します。", + "subtitleGeneric": "実際の検索パイプラインを使用して、クエリが実際に取得するチャンクを確認します。", + "queryPlaceholder": "このソースが回答できそうな質問を入力...", + "run": "実行", + "notSavedHint": "これらの設定はこのテストにのみ使用され、ソースには保存されません。恒久的に変更するにはソース設定で変更してください。", + "resultSummary": "{{total}}件のチャンクを取得 · {{retriever}}", + "latency": "{{ms}} ms", + "empty": "このクエリで取得されたチャンクはありません。", + "emptyWithThreshold": "スコアしきい値({{threshold}})以上のチャンクがありません。しきい値を下げると、惜しい候補を確認できます。", + "showMore": "チャンク全体を表示", + "showLess": "折りたたむ", + "noScore": "スコアなし", + "noScoreHint": "このリトリーバーは、比較可能なチャンクごとのスコアを生成せずにチャンクをランク付けします。", + "scoreKinds": { + "cosine_similarity": "類似度", + "l2_distance": "距離", + "rrf": "RRF" + }, + "errors": { + "failed": "検索テストに失敗しました。もう一度お試しください。" + } + }, + "editConfig": "ソース設定", + "shareWithTeam": "チームと共有", "deleteWarning": "\"{{name}}\"を削除してもよろしいですか?", "confirmDelete": "このファイルを削除してもよろしいですか?この操作は元に戻せません。", "backToAll": "すべてのソースに戻る", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "ファイルをアップロード中...", "deletingTitle": "削除中...", "deleteDirectoryWarning": "ディレクトリ \"{{name}}\" とその内容をすべて削除してもよろしいですか?この操作は元に戻せません。", - "searchAlt": "検索" + "searchAlt": "検索", + "retrievalOptions": { + "title": "詳細設定", + "retrieval": { + "title": "検索", + "note": "これらの変更は即時に反映されます。再インデックスは不要です。", + "tag": "再インデックス不要", + "chunks": "取得するチャンク数(top-k)", + "scoreThreshold": "スコアしきい値", + "scoreThresholdPlaceholder": "なし", + "scoreThresholdHint": "この関連度スコア(0–1)を超えるチャンクのみを返します。高いほど厳格になります。", + "rephraseQuery": "検索前にクエリを言い換える", + "retriever": "リトリーバー", + "retrievers": { + "classic": "クラシック", + "hybrid": "ハイブリッド", + "graphrag": "GraphRAG" + }, + "retrieverHint": "ハイブリッドはキーワード検索とベクトル検索を組み合わせます。GraphRAGはこのソース上にナレッジグラフを構築し、マルチホップの質問に対応します。", + "exposure": "検索の公開方法", + "exposures": { + "prefetch": "コンテキストに事前取得", + "agentic_tool": "オンデマンド検索ツール" + }, + "exposureHint": "このソースをプロンプトに事前取得するか、エージェントがツールとして必要に応じて検索できるようにします。" + }, + "prescreen": { + "enable": "LLMプリスクリーニングを有効にする", + "warning": "より多くの候補を取得し、LLMでフィルタリングします。クエリ時のレイテンシとコストが増加します。", + "candidateK": "取得する候補数", + "maxKeep": "保持する最大チャンク数", + "batchSize": "バッチサイズ", + "model": "モデル(任意)", + "modelPlaceholder": "デフォルトではリクエストのモデルを使用" + }, + "graph": { + "title": "グラフ抽出", + "tag": "再インデックスで適用", + "extractionModel": "抽出モデル", + "extractionModelHint": "各チャンクからエンティティと関係を抽出するために使用するLLM。", + "defaultModel": "デフォルトモデルを使用", + "maxChunks": "抽出する最大チャンク数", + "maxChunksHint": "抽出に送信するチャンク数の上限を設定します。空欄の場合はインスタンスのデフォルトを使用します。", + "maxChunksPlaceholder": "デフォルト" + }, + "chunking": { + "title": "チャンク分割", + "note": "インデックス作成時の設定です。変更にはソースの再インデックスが必要です。", + "tag": "再インデックスで適用", + "strategy": "チャンク分割戦略", + "strategies": { + "classic_chunk": "クラシック", + "recursive": "再帰", + "markdown": "Markdown", + "parent_child": "親 / 子", + "semantic": "セマンティック" + }, + "maxTokens": "チャンクあたりの最大トークン数", + "minTokens": "チャンクあたりの最小トークン数", + "duplicateHeaders": "チャンク間でヘッダーを複製" + } + }, + "configModal": { + "title": "ソース設定", + "subtitle": "「{{name}}」のチャンク分割と検索の方法を設定します。", + "subtitleGeneric": "このソースのチャンク分割と検索の方法を設定します。", + "save": "保存", + "saving": "保存中…", + "readOnly": "この共有ソースには閲覧のみのアクセス権があるため、設定を変更できません。", + "chunkingChangeHint": "チャンク分割の設定を変更しました。保存後、反映にはこのソースの再インデックスが必要です。", + "prescreenInvalidHint": "プリスクリーニングの値を確認してください。取得する候補数はチャンク数以上、保持するチャンク数は取得する候補数以下である必要があります。", + "reingestRequired": "変更を保存しました。変更したチャンク分割の設定は再インデックス後にのみ反映されます。今すぐ再インデックスしますか?", + "reingestLater": "後で", + "errors": { + "forbidden": "このソースの設定を編集する権限がありません。", + "saveFailed": "設定の保存に失敗しました。もう一度お試しください。" + } + } }, "apiKeys": { "label": "チャットボット", @@ -134,11 +345,34 @@ }, "messages": "メッセージ", "tokenUsage": "トークン使用量", + "userFeedback": "ユーザーフィードバック", "filterPlaceholder": "フィルター", "none": "なし", "positiveFeedback": "肯定的なフィードバック", "negativeFeedback": "否定的なフィードバック", - "userFeedback": "ユーザーフィードバック" + "groupBy": "グループ化", + "groupOptions": { + "total": "合計", + "model": "モデル別", + "agent": "エージェント別", + "source": "ソース別" + }, + "includeSideChannel": "バックグラウンドトークン", + "promptTokens": "プロンプトトークン", + "generatedTokens": "生成トークン", + "scheduledRuns": "スケジュール実行", + "toolUsage": "ツール使用状況", + "completed": "完了", + "failed": "失敗", + "skipped": "スキップ", + "successful": "成功", + "stats": { + "messages": "メッセージ", + "tokens": "トークン", + "toolCalls": "ツール呼び出し", + "runSuccess": "実行成功率", + "feedback": "フィードバック" + } }, "logs": { "label": "ログ", @@ -146,7 +380,131 @@ "filterByChatbot": "チャットボットでフィルター", "selectChatbot": "チャットボットを選択", "none": "なし", - "tableHeader": "API生成 / チャットボットの会話" + "tableHeader": "API生成 / チャットボットの会話", + "searchPlaceholder": "ログを検索...", + "noLogs": "ログが見つかりません", + "levels": { + "all": "すべてのレベル", + "info": "情報", + "error": "エラー", + "warning": "警告" + }, + "types": { + "all": "すべてのイベント", + "chat": "チャット", + "schedule": "スケジュール", + "webhook": "Webhook", + "workflow": "ワークフロー", + "system": "システム" + }, + "detail": { + "agent": "エージェント", + "status": "ステータス", + "trigger": "トリガー", + "errorType": "エラータイプ", + "tokens": "トークン", + "duration": "所要時間", + "conversation": "会話", + "endpoint": "エンドポイント", + "instruction": "指示", + "response": "応答", + "output": "出力", + "error": "エラー", + "toolCalls": "ツール呼び出し", + "sources": "ソース", + "workflow": "ワークフロー", + "activity": "アクティビティ", + "steps": "ステップ", + "result": "結果" + } + }, + "teams": { + "label": "チーム", + "subtitle": "チーム、メンバー、共有リソースを管理します。", + "newTeam": "新しいチーム", + "backToTeams": "チーム一覧に戻る", + "createTeam": "チームを作成", + "createTeamDescription": "チームに名前を付けてください。作成後にメンバーの追加やリソースの共有ができます。", + "editTeam": "編集", + "teamNamePlaceholder": "チーム名", + "descriptionLabel": "説明", + "descriptionPlaceholder": "このチームの目的は?(任意)", + "create": "作成", + "save": "保存", + "createTeamError": "チームを作成できませんでした。", + "noTeams": "チームはまだありません。", + "noDescription": "説明なし", + "memberCountOne": "{{count}}人のメンバー", + "memberCountOther": "{{count}}人のメンバー", + "sharedCount": "{{count}}件の共有", + "loadError": "チームの読み込みに失敗しました。", + "roleAdmin": "管理者", + "roleMember": "メンバー", + "sourceManual": "手動で追加", + "sourceSso": "SSO経由", + "sourceScim": "SCIM経由", + "teamsHeading": "あなたのチーム", + "selectTeamHint": "チームを選択して、メンバーと共有リソースを管理します。", + "selectTeamTitle": "チームが選択されていません", + "teamSubtitle": "このチームのメンバーと共有リソースを管理します。", + "deleteTeam": "チームを削除", + "deleteTeamConfirmation": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。", + "members": "メンバー", + "addMember": "メンバーを追加", + "addMemberTitle": "メンバーを追加", + "addMemberDescription": "メールアドレスで招待します。相手は少なくとも一度サインインしている必要があります。", + "teamActions": "チームアクション", + "memberEmailLabel": "メンバーのメールアドレス", + "memberEmailPlaceholder": "name@example.com", + "memberRoleLabel": "ロール", + "add": "追加", + "remove": "削除", + "removeMemberConfirmation": "このメンバーをチームから削除しますか?", + "addMemberError": "メンバーの追加に失敗しました(チーム管理者のみ可能です)。", + "memberNotFound": "そのメールアドレスのユーザーが見つかりません。先に一度サインインしてもらう必要があります。", + "noMembers": "メンバーはまだいません。「メンバーを追加」からメールアドレスで招待してください。", + "roleChangeError": "ロールを変更できませんでした(最後の管理者は保護されています)。", + "removeMemberError": "メンバーを削除できませんでした。", + "deleteTeamError": "チームを削除できませんでした。", + "updateFailed": "更新に失敗しました", + "openTeamError": "チームの詳細を読み込めませんでした。", + "sharedResources": "共有リソース", + "sharedWithTeam": "このチームと共有中({{count}})", + "nothingShared": "まだ何も共有されていません。エージェント、ソース、プロンプト、ツールのメニューから共有できます。", + "unshare": "共有を解除", + "unshareError": "リソースの共有を解除できませんでした。", + "resourceType": { + "agent": "エージェント", + "source": "ソース", + "prompt": "プロンプト", + "tool": "ツール" + }, + "share": { + "titleNamed": "「{{name}}」を共有", + "titleGeneric": "この{{type}}を共有", + "subtitle": "チームメンバーは使用でき、編集者は変更もできます。所有者はあなたのままです。", + "noTeams": "まだどのチームにも所属していません。設定 → チームで作成してください。", + "accessLevel": { + "viewer": "閲覧者", + "editor": "編集者" + }, + "done": "完了", + "loadError": "現在の共有状況を読み込めませんでした。", + "shareError": "チームと共有できませんでした。", + "unshareError": "チームとの共有を解除できませんでした。", + "addPlaceholder": "ユーザーまたはチームを追加…", + "searchPlaceholder": "ユーザーまたはチームを追加…", + "noMatches": "ユーザーまたはチームが見つかりません。", + "peopleWithAccess": "アクセス権を持つユーザー", + "you": "あなた", + "owner": "所有者", + "teamsGroup": "チーム", + "peopleGroup": "ユーザー", + "teamLabel": "チーム", + "viaTeam": "{{team}}経由", + "removeAccess": "アクセス権を削除", + "access": "アクセス" + } }, "tools": { "label": "ツール", @@ -161,7 +519,14 @@ "manageTools": "ツールへ移動", "edit": "編集", "delete": "削除", + "reconnect": "再接続", + "shareWithTeam": "チームと共有", "builtIn": "ビルトイン", + "authStatus": { + "connected": "接続済み", + "needsAuth": "要認証", + "configured": "設定済み" + }, "deleteWarning": "ツール \"{{toolName}}\" を削除してもよろしいですか?", "unsavedChanges": "保存されていない変更があります。保存せずに離れると失われます。", "leaveWithoutSaving": "保存せずに離れる", @@ -188,6 +553,8 @@ "deleteActionWarning": "アクション \"{{name}}\" を削除してもよろしいですか?", "backToAllTools": "すべてのツールに戻る", "save": "保存", + "saving": "保存中...", + "saveFailed": "ツール設定の保存に失敗しました", "fieldName": "フィールド名", "fieldType": "フィールドタイプ", "filledByLLM": "LLMによる入力", @@ -203,6 +570,8 @@ "mcp": { "addServer": "Add MCP Server", "editServer": "Edit Server", + "reconnectServer": "サーバーに再接続", + "reenterCredentials": "接続をテストして更新するには、認証情報を再入力してください。", "serverName": "Server Name", "serverUrl": "Server URL", "headerName": "Header Name", @@ -215,6 +584,8 @@ "noAuth": "No Authentication", "oauthInProgress": "Waiting for OAuth completion...", "oauthCompleted": "OAuth completed successfully", + "oauthPopupBlocked": "ブラウザによってポップアップがブロックされました。以下をクリックして認証してください:", + "openAuthPage": "認証ページを開く", "authType": "Authentication Type", "defaultServerName": "My MCP Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "デバイス", + "subtitle": "エージェントがシェルコマンドで操作できるリモートマシンをペアリングします。", + "pair": "デバイスをペアリング", + "empty": "ペアリング済みのデバイスはありません。", + "revoke": "取り消し", + "edit": "編集", + "revokeWarning": "{{name}}を取り消しますか?CLIは切断されます。", + "approvalMode": "承認モード", + "approvalAsk": "毎回確認", + "approvalFull": "フルアクセス", + "online": "オンライン", + "offline": "オフライン", + "lastSeen": "最終接続: {{time}}", + "lastSeenLabel": "最終接続", + "seenAgo": "{{time}}前に確認", + "nameLabel": "デバイス名", + "descriptionLabel": "説明(エージェントに表示されます)", + "descriptionPlaceholder": "このマシンの用途は?", + "identity": "識別情報", + "access": "アクセス", + "connection": "接続", + "hostLabel": "ホスト", + "dangerZone": "危険ゾーン", + "approvalAskDescription": "実行前にすべてのコマンドを承認します。", + "approvalFullDescription": "安全のための拒否リストを除き、すべてのコマンドを確認なしで実行します。", + "fullAccessWarning": "フルアクセス: エージェントは確認なしでこのマシン上の任意のコマンドを実行できます。安全のための拒否リストは引き続き適用されます。", + "deviceIdLabel": "デバイスID", + "offlineHint": "接続されていません。マシン上で `docsgpt-cli host` を実行してオンラインにしてください。", + "dangerZoneDescription": "取り消すと、デバイス上のデーモンが停止し、ペアリングが解除されます。", + "auditTitle": "最近のアクティビティ", + "auditLoading": "読み込み中…", + "auditEmpty": "アクティビティはまだありません。", + "auditDecision": "判定", + "auditExit": "終了コード", + "auditDuration": "所要時間", + "pairing": { + "title": "リモートマシンをペアリング", + "subtitle": "リモートマシンで以下のコマンドを実行し、求められたらコードを入力してください。", + "formSubtitle": "新しいデバイスの名前、説明、承認モードを設定します。", + "nameLabel": "デバイス名(任意)", + "namePlaceholder": "例: edge-server", + "descriptionLabel": "説明(任意)", + "descriptionPlaceholder": "エージェントが適切なデバイスを選べるように表示されます。", + "fullAccessWarning": "フルアクセス: エージェントは確認なしでこのマシン上の任意のコマンドを実行できます。安全のための拒否リストは引き続き適用されます。", + "stepOne": "1. リモートマシンで次を実行:", + "installLink": "docsgpt-cliがない場合はインストール", + "stepTwo": "2. 求められたらこのコードを入力:", + "start": "コードを生成", + "starting": "処理中…", + "waitingForCli": "CLIがコードを使用するのを待機中…", + "cancel": "キャンセル" + } + }, "customModels": { "label": "カスタムモデル", "subtitle": "OpenAI 互換のモデルを自分で接続できます。", @@ -295,6 +720,15 @@ "capabilities": { "title": "機能", "contextWindowShort": "コンテキストウィンドウ", + "apiFlavor": "API プロトコル", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "推論の強度", + "reasoningEfforts": { + "default": "プロバイダーのデフォルト" + }, "chips": { "tools": "ツール", "structuredOutput": "構造化出力", @@ -335,6 +769,7 @@ "uploadedFiles": "アップロードされたファイル", "cancel": "キャンセル", "train": "トレーニング", + "create": "作成", "link": "リンク", "urlLink": "URLリンク", "repoUrl": "リポジトリURL", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "Confluenceからアップロード" + }, + "wiki": { + "label": "新しいWiki", + "heading": "リビングWikiを作成" } }, "connectors": { @@ -527,7 +966,8 @@ "systemTime": "Current time (HH:MM:SS)", "systemTimestamp": "ISO 8601 timestamp", "systemRequestId": "Unique request identifier", - "systemUserId": "Current user ID" + "systemUserId": "Current user ID", + "artifactsLookup": "IDによるアーティファクトメタデータ" }, "learnAboutPrompts": "プロンプトについて学ぶ →", "publicPromptEditDisabled": "公開プロンプトは編集できません", @@ -650,14 +1090,38 @@ "remove": "添付ファイルを削除" }, "retry": "再試行", - "reasoning": "推論" + "reasoning": "推論", + "wikiWrite": { + "edited": "Wikiを編集", + "create": "Wikiページを作成", + "str_replace": "Wikiページを編集", + "insert": "Wikiページを編集", + "delete": "Wikiページを削除", + "rename": "Wikiページの名前を変更", + "failed": "失敗" + } }, "agents": { "title": "エージェント", "edit": "編集", "description": "指示、追加知識、スキルの組み合わせを含むDocsGPTのカスタムバージョンを発見して作成します", "newAgent": "新しいエージェント", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "チームと共有", "backToAll": "すべてのエージェントに戻る", + "pageHeader": { + "crumbs": { + "agents": "エージェント" + }, + "tabs": { + "overview": "概要", + "logs": "ログ", + "schedules": "スケジュール" + }, + "fallbackName": "無題のエージェント", + "subnavAriaLabel": "エージェントのサブナビゲーション" + }, "searchPlaceholder": "検索...", "noSearchResults": "エージェントが見つかりません", "tryDifferentSearch": "別の検索語をお試しください", @@ -665,8 +1129,13 @@ "all": "すべて", "byDocsGPT": "DocsGPT提供", "byMe": "自分の", + "team": "チーム", "shared": "共有された" }, + "teamBadge": { + "editor": "編集者", + "viewer": "閲覧者" + }, "sections": { "template": { "title": "DocsGPT提供", @@ -678,6 +1147,11 @@ "description": "あなたが作成または公開したエージェント", "emptyState": "まだ作成されたエージェントがありません。" }, + "team": { + "title": "チーム", + "description": "チームメイトが共有したエージェント", + "emptyState": "チームと共有されたエージェントはまだありません。" + }, "shared": { "title": "共有されたエージェント", "description": "公開リンクを使用してインポートされたエージェント", @@ -697,6 +1171,7 @@ "cancel": "キャンセル", "delete": "削除", "logs": "ログ", + "schedules": "スケジュール", "accessDetails": "アクセス詳細", "add": "追加", "moreActions": "その他のアクション" @@ -731,7 +1206,10 @@ "toolsPopup": { "title": "ツールを選択", "searchPlaceholder": "ツールを検索...", - "noOptionsMessage": "利用可能なツールがありません" + "noOptionsMessage": "利用可能なツールがありません", + "groupBuiltin": "ビルトイン", + "groupDefault": "デフォルト", + "groupCustom": "カスタム" }, "modelsPopup": { "title": "モデルを選択", @@ -777,6 +1255,75 @@ "noUsageHistory": "使用履歴がありません", "tableHeader": "エージェントエンドポイントログ" }, + "schedules": { + "title": "エージェントスケジュール", + "heading": "スケジュール", + "newRecurring": "新しいスケジュール", + "closeForm": "フォームを閉じる", + "edit": "編集", + "recurring": "定期", + "oneTime": "1回限りのタスク", + "noRecurring": "定期スケジュールはまだありません。", + "noOneTime": "1回限りのタスクはまだありません。", + "pause": "一時停止", + "resume": "再開", + "runNow": "今すぐ実行", + "delete": "削除", + "deleteConfirm": "このスケジュールを削除しますか?実行履歴も削除されます。この操作は元に戻せません。", + "cancel": "キャンセル", + "showRuns": "実行履歴を表示", + "hideRuns": "実行履歴を非表示", + "modal": { + "titleCreate": "新しいスケジュール", + "titleEdit": "スケジュールを編集", + "namePlaceholder": "タスク名", + "frequency": { + "once": "1回", + "daily": "毎日", + "weekly": "毎週", + "monthly": "毎月", + "yearly": "毎年" + }, + "on": "実行日", + "at": "時刻", + "pickDate": "日付を選択", + "timezone": "タイムゾーン", + "timezonePlaceholder": "タイムゾーンを選択", + "timezoneSearchPlaceholder": "タイムゾーンを検索…", + "timezoneEmpty": "タイムゾーンが見つかりません。", + "instructionsLabel": "指示", + "instructionsPlaceholder": "プロンプトを入力してください。", + "create": "タスクを作成", + "save": "変更を保存", + "errors": { + "instructionRequired": "指示は必須です。", + "runAtInPast": "未来の日時を選択してください。" + }, + "days": { + "mon": "月", + "tue": "火", + "wed": "水", + "thu": "木", + "fri": "金", + "sat": "土", + "sun": "日" + }, + "months": { + "jan": "1月", + "feb": "2月", + "mar": "3月", + "apr": "4月", + "may": "5月", + "jun": "6月", + "jul": "7月", + "aug": "8月", + "sep": "9月", + "oct": "10月", + "nov": "11月", + "dec": "12月" + } + } + }, "shared": { "notFound": "エージェントが見つかりません。エージェントが共有されていることを確認してください。" }, @@ -797,11 +1344,10 @@ "move": "移動", "noFolder": "フォルダなし (ルート)", "backToRoot": "戻る", + "currentFolder": "このフォルダ", "noSubfolders": "サブフォルダなし", "noFolders": "フォルダがありません" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -841,6 +1387,17 @@ "navigation": { "agents": "エージェント" }, + "teams": { + "switcher": { + "ariaLabel": "チームを切り替え", + "personal": "個人アカウント", + "personalSubtitle": "個人ワークスペース", + "roleAdmin": "管理者", + "roleMember": "メンバー", + "manageTeam": "チームを管理", + "createTeam": "チームを作成" + } + }, "notification": { "ariaLabel": "通知", "closeAriaLabel": "通知を閉じる" diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index d92475f0..df5d395f 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -12,6 +12,18 @@ "emailUs": "Напишите нам", "documentation": "Документация", "manageAgents": "Управление агентами", + "teamAccess": { + "editor": "Редактор", + "viewer": "Читатель" + }, + "notifications": { + "memberAddedTitle": "Вы добавлены в команду", + "memberAddedBody": "Вас добавили в {{team}} с ролью {{role}}", + "sharedTitle": "Поделились с вами", + "sharedBody": "С вашей командой поделились: {{type}} «{{name}}» ({{access}})", + "sharedBodyNoName": "С вашей командой поделились: {{type}} ({{access}})", + "dismiss": "Закрыть" + }, "demo": [ { "header": "Узнайте о DocsGPT", @@ -30,6 +42,15 @@ "query": "Написать возможные вопросы для этого контента" } ], + "auth": { + "signInToContinue": "Войдите, чтобы продолжить работу с DocsGPT", + "signInWithSSO": "Войти через SSO", + "signInWith": "Войти через {{provider}}", + "notAuthorized": "У вашей учетной записи нет доступа к этому рабочему пространству. Войдите с другой учетной записью или обратитесь к администратору.", + "accountDisabled": "Ваша учетная запись отключена. Обратитесь к администратору.", + "signOut": "Выйти", + "account": "Аккаунт" + }, "settings": { "label": "Настройки", "general": { @@ -82,6 +103,119 @@ }, "actions": "Действия", "view": "Просмотр", + "wiki": { + "view": "Просмотр вики", + "empty": "В этой вики пока нет страниц.", + "selectPage": "Выберите страницу, чтобы просмотреть её содержимое.", + "edit": "Редактировать", + "save": "Сохранить", + "cancel": "Отмена", + "saving": "Сохранение…", + "saveFailed": "Не удалось сохранить страницу. Пожалуйста, попробуйте ещё раз.", + "forbidden": "У вас нет прав на редактирование этой страницы.", + "conflict": "Страница изменилась с момента открытия и была перезагружена. Ваши правки ниже — примените их заново и сохраните ещё раз.", + "editPlaceholder": "Напишите страницу в Markdown…", + "livingTitle": "Живая вики.", + "livingExplainer": "Источник, редактируемый агентами: страницы читаются и переписываются по мере работы агентов, и по умолчанию источник доступен агентам через инструмент агентного поиска.", + "livingExplainerEditable": "Любой агент, использующий этот источник, может его редактировать.", + "stamp": { + "editedBy": "Изменено: {{who}}", + "agent": "агент", + "human": "человек", + "you": "вы", + "unknown": "неизвестно", + "version": "v{{version}}" + }, + "convert": { + "action": "Преобразовать в вики", + "title": "Преобразовать в живую вики", + "intro": "Преобразуйте «{{name}}» в живую вики из редактируемых страниц, которые агенты могут читать и переписывать.", + "introGeneric": "Преобразуйте этот источник в живую вики из редактируемых страниц, которые агенты могут читать и переписывать.", + "costReparse": "Файлы повторно разбираются в редактируемые страницы. Форматирование упрощается, особенно для PDF.", + "costSkipped": "Нетекстовые файлы (изображения, двоичные файлы) пропускаются.", + "costIrreversible": "Это действие необратимо — страницы вики становятся источником истины.", + "confirm": "Преобразовать в вики", + "inProgress": "Преобразование файлов в страницы вики…", + "summaryPages_one": "Создана {{count}} страница", + "summaryPages_other": "Создано страниц: {{count}}", + "summarySkipped_one": "Пропущен {{count}} файл", + "summarySkipped_other": "Пропущено файлов: {{count}}", + "skippedHeading": "Пропущенные файлы", + "done": "Готово", + "errors": { + "generic": "Не удалось выполнить преобразование. Пожалуйста, попробуйте ещё раз.", + "forbidden": "У вас нет прав на преобразование этого источника.", + "conflict": "Источник ещё индексируется. Дождитесь завершения, прежде чем преобразовывать." + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "Построение графа…", + "buildingPct": "Построение графа… {{pct}}%", + "enable": { + "action": "Включить GraphRAG", + "title": "Включить GraphRAG", + "intro": "Постройте граф знаний по «{{name}}», чтобы агенты могли рассуждать о связанных сущностях и отношениях между ними.", + "introGeneric": "Постройте граф знаний по этому источнику, чтобы агенты могли рассуждать о связанных сущностях и отношениях между ними.", + "costExtraction": "Сущности и связи извлекаются путём запуска LLM по фрагментам этого источника.", + "costCost": "На каждый фрагмент выполняется один вызов LLM, поэтому это расходует токены и может занять время на больших источниках.", + "estimate": "~1 вызов LLM на фрагмент · всего ~{{lo}}–{{hi}} токенов (примерно поровну на вход и выход); объём вывода зависит от насыщенности документа сущностями.", + "confirm": "Включить GraphRAG", + "inProgress": "Построение графа…", + "inProgressPct": "Построение графа… {{pct}}%", + "summaryNodes_one": "{{count}} сущность", + "summaryNodes_other": "Сущностей: {{count}}", + "summaryEdges_one": "{{count}} связь", + "summaryEdges_other": "Связей: {{count}}", + "summaryChunks_one": "Обработан {{count}} фрагмент", + "summaryChunks_other": "Обработано фрагментов: {{count}}", + "done": "Готово", + "errors": { + "generic": "Не удалось включить GraphRAG. Пожалуйста, попробуйте ещё раз.", + "forbidden": "У вас нет прав на включение GraphRAG для этого источника.", + "conflict": "Источник ещё индексируется. Дождитесь завершения, прежде чем включать GraphRAG." + } + }, + "view": { + "action": "Просмотр графа", + "title": "Граф знаний", + "explainer": "Узлы — это сущности, а рёбра — их связи. Нажмите на узел, чтобы увидеть его описание и фрагменты, из которых он был извлечён.", + "stats": "{{nodes}} сущностей · {{edges}} связей", + "empty": "Графа пока нет — извлечение, возможно, ещё выполняется.", + "selectNode": "Выберите узел, чтобы увидеть подробности.", + "linkedChunks": "Связанные фрагменты", + "noChunks": "Для этой сущности нет связанных фрагментов.", + "close": "Закрыть" + } + }, + "testRetrieval": { + "action": "Тест извлечения", + "title": "Тест извлечения", + "subtitle": "Посмотрите, какие фрагменты запрос действительно извлекает из «{{name}}», используя реальный конвейер извлечения.", + "subtitleGeneric": "Посмотрите, какие фрагменты запрос действительно извлекает, используя реальный конвейер извлечения.", + "queryPlaceholder": "Спросите что-нибудь, на что этот источник должен ответить...", + "run": "Запустить", + "notSavedHint": "Эти настройки используются только для этого теста — они не сохраняются в источнике. Чтобы сделать их постоянными, измените их в настройках источника.", + "resultSummary": "Извлечено фрагментов: {{total}} · {{retriever}}", + "latency": "{{ms}} мс", + "empty": "По этому запросу фрагменты не извлечены.", + "emptyWithThreshold": "Ни один фрагмент не набрал оценку не ниже порога ({{threshold}}). Понизьте порог, чтобы увидеть близкие результаты.", + "showMore": "Показать фрагмент целиком", + "showLess": "Свернуть", + "noScore": "без оценки", + "noScoreHint": "Этот ретривер ранжирует фрагменты без сопоставимой оценки для каждого фрагмента.", + "scoreKinds": { + "cosine_similarity": "сходство", + "l2_distance": "расстояние", + "rrf": "RRF" + }, + "errors": { + "failed": "Тест извлечения не удался. Пожалуйста, попробуйте ещё раз." + } + }, + "editConfig": "Настройки источника", + "shareWithTeam": "Поделиться с командой", "deleteWarning": "Вы уверены, что хотите удалить \"{{name}}\"?", "confirmDelete": "Вы уверены, что хотите удалить этот файл? Это действие нельзя отменить.", "backToAll": "Вернуться ко всем источникам", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "Загрузка файлов...", "deletingTitle": "Удаление...", "deleteDirectoryWarning": "Вы уверены, что хотите удалить каталог \"{{name}}\" и все его содержимое? Это действие нельзя отменить.", - "searchAlt": "Поиск" + "searchAlt": "Поиск", + "retrievalOptions": { + "title": "Расширенные настройки", + "retrieval": { + "title": "Извлечение", + "note": "Эти изменения вступают в силу сразу, переиндексация не требуется.", + "tag": "без переиндексации", + "chunks": "Извлекаемые фрагменты (top-k)", + "scoreThreshold": "Порог оценки", + "scoreThresholdPlaceholder": "Нет", + "scoreThresholdHint": "Возвращать только фрагменты с оценкой релевантности выше этого значения (0–1); чем выше, тем строже.", + "rephraseQuery": "Переформулировать запрос перед поиском", + "retriever": "Ретривер", + "retrievers": { + "classic": "Классический", + "hybrid": "Гибридный", + "graphrag": "GraphRAG" + }, + "retrieverHint": "Гибридный сочетает поиск по ключевым словам и векторный поиск. GraphRAG строит граф знаний по этому источнику для многошаговых вопросов.", + "exposure": "Доступ для поиска", + "exposures": { + "prefetch": "Предзагрузка в контекст", + "agentic_tool": "Инструмент поиска по запросу" + }, + "exposureHint": "Предзагружать этот источник в промпт или позволить агенту искать по нему по мере необходимости как по инструменту." + }, + "prescreen": { + "enable": "Включить предварительный отбор LLM", + "warning": "Извлекается расширенный набор кандидатов, который затем фильтруется с помощью LLM. Это увеличивает задержку и стоимость запроса.", + "candidateK": "Кандидатов для выборки", + "maxKeep": "Макс. фрагментов в результате", + "batchSize": "Размер пакета", + "model": "Модель (необязательно)", + "modelPlaceholder": "По умолчанию — модель запроса" + }, + "graph": { + "title": "Извлечение графа", + "tag": "требуется переиндексация", + "extractionModel": "Модель извлечения", + "extractionModelHint": "LLM, используемая для извлечения сущностей и связей из каждого фрагмента.", + "defaultModel": "Использовать модель по умолчанию", + "maxChunks": "Макс. фрагментов для извлечения", + "maxChunksHint": "Ограничьте число фрагментов, отправляемых на извлечение. Оставьте пустым, чтобы использовать значение по умолчанию для инстанса.", + "maxChunksPlaceholder": "По умолчанию" + }, + "chunking": { + "title": "Разбиение на фрагменты", + "note": "Настройки этапа индексации. Их изменение требует переиндексации источника.", + "tag": "требуется переиндексация", + "strategy": "Стратегия разбиения", + "strategies": { + "classic_chunk": "Классическая", + "recursive": "Рекурсивная", + "markdown": "Markdown", + "parent_child": "Родитель / потомок", + "semantic": "Семантическая" + }, + "maxTokens": "Макс. токенов на фрагмент", + "minTokens": "Мин. токенов на фрагмент", + "duplicateHeaders": "Дублировать заголовки между фрагментами" + } + }, + "configModal": { + "title": "Настройки источника", + "subtitle": "Настройте, как «{{name}}» разбивается на фрагменты и извлекается.", + "subtitleGeneric": "Настройте, как этот источник разбивается на фрагменты и извлекается.", + "save": "Сохранить", + "saving": "Сохранение…", + "readOnly": "У вас доступ только для просмотра этого общего источника, вы не можете изменять его настройки.", + "chunkingChangeHint": "Вы изменили настройку разбиения на фрагменты. После сохранения потребуется переиндексация источника, чтобы изменения вступили в силу.", + "prescreenInvalidHint": "Проверьте значения предварительного отбора: число кандидатов должно быть не меньше числа фрагментов, а число оставляемых фрагментов не должно превышать число кандидатов.", + "reingestRequired": "Изменения сохранены. Изменённые настройки разбиения вступят в силу только после переиндексации. Переиндексировать сейчас?", + "reingestLater": "Позже", + "errors": { + "forbidden": "У вас нет прав на изменение настроек этого источника.", + "saveFailed": "Не удалось сохранить настройки. Пожалуйста, попробуйте ещё раз." + } + } }, "apiKeys": { "label": "API ключи", @@ -138,7 +349,30 @@ "filterPlaceholder": "Фильтр", "none": "Нет", "positiveFeedback": "Положительная обратная связь", - "negativeFeedback": "Отрицательная обратная связь" + "negativeFeedback": "Отрицательная обратная связь", + "groupBy": "Группировать по", + "groupOptions": { + "total": "Всего", + "model": "По модели", + "agent": "По агенту", + "source": "По источнику" + }, + "includeSideChannel": "Фоновые токены", + "promptTokens": "Токены промпта", + "generatedTokens": "Сгенерированные токены", + "scheduledRuns": "Запуски по расписанию", + "toolUsage": "Использование инструментов", + "completed": "Завершено", + "failed": "Не удалось", + "skipped": "Пропущено", + "successful": "Успешно", + "stats": { + "messages": "Сообщения", + "tokens": "Токены", + "toolCalls": "Вызовы инструментов", + "runSuccess": "Успешность запусков", + "feedback": "Обратная связь" + } }, "logs": { "label": "Журналы", @@ -146,7 +380,131 @@ "filterByChatbot": "Фильтровать по чат-боту", "selectChatbot": "Выбрать чат-бота", "none": "Нет", - "tableHeader": "API сгенерировано / разговоры с чат-ботом" + "tableHeader": "API сгенерировано / разговоры с чат-ботом", + "searchPlaceholder": "Поиск в журналах...", + "noLogs": "Журналы не найдены", + "levels": { + "all": "Все уровни", + "info": "Информация", + "error": "Ошибка", + "warning": "Предупреждение" + }, + "types": { + "all": "Все события", + "chat": "Чат", + "schedule": "По расписанию", + "webhook": "Вебхук", + "workflow": "Рабочий процесс", + "system": "Система" + }, + "detail": { + "agent": "Агент", + "status": "Статус", + "trigger": "Триггер", + "errorType": "Тип ошибки", + "tokens": "Токены", + "duration": "Длительность", + "conversation": "Разговор", + "endpoint": "Эндпоинт", + "instruction": "Инструкция", + "response": "Ответ", + "output": "Вывод", + "error": "Ошибка", + "toolCalls": "Вызовы инструментов", + "sources": "Источники", + "workflow": "Рабочий процесс", + "activity": "Активность", + "steps": "Шаги", + "result": "Результат" + } + }, + "teams": { + "label": "Команды", + "subtitle": "Управляйте командами, участниками и общими ресурсами.", + "newTeam": "Новая команда", + "backToTeams": "Назад к командам", + "createTeam": "Создать команду", + "createTeamDescription": "Дайте команде название. После создания вы сможете добавить участников и делиться ресурсами.", + "editTeam": "Редактировать", + "teamNamePlaceholder": "Название команды", + "descriptionLabel": "Описание", + "descriptionPlaceholder": "Для чего эта команда? (необязательно)", + "create": "Создать", + "save": "Сохранить", + "createTeamError": "Не удалось создать команду.", + "noTeams": "Команд пока нет.", + "noDescription": "Без описания", + "memberCountOne": "{{count}} участник", + "memberCountOther": "Участников: {{count}}", + "sharedCount": "Общих ресурсов: {{count}}", + "loadError": "Не удалось загрузить команды.", + "roleAdmin": "администратор", + "roleMember": "участник", + "sourceManual": "Добавлен вручную", + "sourceSso": "через SSO", + "sourceScim": "через SCIM", + "teamsHeading": "Ваши команды", + "selectTeamHint": "Выберите команду, чтобы управлять её участниками и общими ресурсами.", + "selectTeamTitle": "Команда не выбрана", + "teamSubtitle": "Управляйте участниками и ресурсами, которыми поделились с этой командой.", + "deleteTeam": "Удалить команду", + "deleteTeamConfirmation": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.", + "members": "Участники", + "addMember": "Добавить участника", + "addMemberTitle": "Добавить участника", + "addMemberDescription": "Пригласите пользователя по email. Он должен был войти в систему хотя бы один раз.", + "teamActions": "Действия с командой", + "memberEmailLabel": "Email участника", + "memberEmailPlaceholder": "name@example.com", + "memberRoleLabel": "Роль", + "add": "Добавить", + "remove": "Удалить", + "removeMemberConfirmation": "Удалить этого участника из команды?", + "addMemberError": "Не удалось добавить участника (только для администраторов команды).", + "memberNotFound": "Пользователь с таким email не найден — он должен сначала войти в систему.", + "noMembers": "Участников пока нет. Используйте «Добавить участника», чтобы пригласить кого-нибудь по email.", + "roleChangeError": "Не удалось изменить роль (последний администратор защищён).", + "removeMemberError": "Не удалось удалить участника.", + "deleteTeamError": "Не удалось удалить команду.", + "updateFailed": "Не удалось обновить", + "openTeamError": "Не удалось загрузить данные команды.", + "sharedResources": "Общие ресурсы", + "sharedWithTeam": "Доступно этой команде ({{count}})", + "nothingShared": "Пока ничем не поделились. Поделитесь агентом, источником, промптом или инструментом из его меню.", + "unshare": "Отменить доступ", + "unshareError": "Не удалось отменить доступ к ресурсу.", + "resourceType": { + "agent": "Агент", + "source": "Источник", + "prompt": "Промпт", + "tool": "Инструмент" + }, + "share": { + "titleNamed": "Поделиться «{{name}}»", + "titleGeneric": "Поделиться: {{type}}", + "subtitle": "Участники команды смогут использовать ресурс; редакторы также могут изменять его. Вы остаётесь владельцем.", + "noTeams": "Вы пока не состоите ни в одной команде. Создайте её в Настройки → Команды.", + "accessLevel": { + "viewer": "Читатель", + "editor": "Редактор" + }, + "done": "Готово", + "loadError": "Не удалось загрузить текущие доступы.", + "shareError": "Не удалось поделиться с командой.", + "unshareError": "Не удалось отменить доступ для команды.", + "addPlaceholder": "Добавьте людей или команды…", + "searchPlaceholder": "Добавьте людей или команды…", + "noMatches": "Люди или команды не найдены.", + "peopleWithAccess": "Пользователи с доступом", + "you": "Вы", + "owner": "Владелец", + "teamsGroup": "Команды", + "peopleGroup": "Люди", + "teamLabel": "Команда", + "viaTeam": "через {{team}}", + "removeAccess": "Убрать доступ", + "access": "Доступ" + } }, "tools": { "label": "Инструменты", @@ -161,7 +519,14 @@ "manageTools": "Перейти к инструментам", "edit": "Редактировать", "delete": "Удалить", + "reconnect": "Переподключить", + "shareWithTeam": "Поделиться с командой", "builtIn": "Встроенный", + "authStatus": { + "connected": "Подключено", + "needsAuth": "Требуется авторизация", + "configured": "Настроено" + }, "deleteWarning": "Вы уверены, что хотите удалить инструмент \"{{toolName}}\"?", "unsavedChanges": "У вас есть несохраненные изменения, которые будут потеряны, если вы уйдете без сохранения.", "leaveWithoutSaving": "Уйти без сохранения", @@ -188,6 +553,8 @@ "deleteActionWarning": "Вы уверены, что хотите удалить действие \"{{name}}\"?", "backToAllTools": "Вернуться ко всем инструментам", "save": "Сохранить", + "saving": "Сохранение...", + "saveFailed": "Не удалось сохранить настройки инструмента", "fieldName": "Имя поля", "fieldType": "Тип поля", "filledByLLM": "Заполняется LLM", @@ -203,6 +570,8 @@ "mcp": { "addServer": "Add MCP Server", "editServer": "Edit Server", + "reconnectServer": "Переподключить сервер", + "reenterCredentials": "Введите учетные данные ещё раз, чтобы проверить и обновить подключение.", "serverName": "Server Name", "serverUrl": "Server URL", "headerName": "Header Name", @@ -215,6 +584,8 @@ "noAuth": "No Authentication", "oauthInProgress": "Waiting for OAuth completion...", "oauthCompleted": "OAuth completed successfully", + "oauthPopupBlocked": "Браузер заблокировал всплывающее окно. Нажмите ниже для авторизации:", + "openAuthPage": "Открыть страницу авторизации", "authType": "Authentication Type", "defaultServerName": "My MCP Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "Устройства", + "subtitle": "Подключайте удалённые машины, которыми агенты могут управлять через shell-команды.", + "pair": "Подключить устройство", + "empty": "Нет подключённых устройств.", + "revoke": "Отозвать", + "edit": "Редактировать", + "revokeWarning": "Отозвать {{name}}? CLI будет отключён.", + "approvalMode": "Режим подтверждения", + "approvalAsk": "Спрашивать", + "approvalFull": "Полный доступ", + "online": "В сети", + "offline": "Не в сети", + "lastSeen": "Последняя активность: {{time}}", + "lastSeenLabel": "Последняя активность", + "seenAgo": "в сети {{time}} назад", + "nameLabel": "Название устройства", + "descriptionLabel": "Описание (видно агенту)", + "descriptionPlaceholder": "Для чего эта машина?", + "identity": "Идентификация", + "access": "Доступ", + "connection": "Подключение", + "hostLabel": "Хост", + "dangerZone": "Опасная зона", + "approvalAskDescription": "Подтверждать каждую команду перед запуском.", + "approvalFullDescription": "Выполняет каждую команду без запроса, кроме запрещённых из списка безопасности.", + "fullAccessWarning": "Полный доступ: агенты могут выполнять любые команды на этой машине без запроса. Список запрещённых команд по-прежнему действует.", + "deviceIdLabel": "ID устройства", + "offlineHint": "Нет подключения. Запустите `docsgpt-cli host` на машине, чтобы подключить её.", + "dangerZoneDescription": "Отзыв останавливает демон на устройстве и удаляет сопряжение.", + "auditTitle": "Недавняя активность", + "auditLoading": "Загрузка…", + "auditEmpty": "Активности пока нет.", + "auditDecision": "Решение", + "auditExit": "Код выхода", + "auditDuration": "Длительность", + "pairing": { + "title": "Подключение удалённой машины", + "subtitle": "Выполните команду ниже на удалённой машине и введите код по запросу.", + "formSubtitle": "Задайте название, описание и режим подтверждения для нового устройства.", + "nameLabel": "Название устройства (необязательно)", + "namePlaceholder": "напр. edge-server", + "descriptionLabel": "Описание (необязательно)", + "descriptionPlaceholder": "Видно агенту, чтобы он мог выбрать нужное устройство.", + "fullAccessWarning": "Полный доступ: агенты могут выполнять любые команды на этой машине без запроса. Список запрещённых команд по-прежнему действует.", + "stepOne": "1. Выполните это на удалённой машине:", + "installLink": "Нет docsgpt-cli? Установите его", + "stepTwo": "2. Введите этот код по запросу:", + "start": "Сгенерировать код", + "starting": "Выполнение…", + "waitingForCli": "Ожидание активации кода в CLI…", + "cancel": "Отмена" + } + }, "customModels": { "label": "Свои модели", "subtitle": "Подключите свою модель, совместимую с OpenAI.", @@ -295,6 +720,15 @@ "capabilities": { "title": "Возможности", "contextWindowShort": "Контекстное окно", + "apiFlavor": "Протокол API", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "Уровень рассуждений", + "reasoningEfforts": { + "default": "По умолчанию у провайдера" + }, "chips": { "tools": "Инструменты", "structuredOutput": "Структурированный вывод", @@ -335,6 +769,7 @@ "uploadedFiles": "Загруженные файлы", "cancel": "Отмена", "train": "Тренировка", + "create": "Создать", "link": "Ссылка", "urlLink": "URL ссылка", "repoUrl": "URL репозитория", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "Загрузить из Confluence" + }, + "wiki": { + "label": "Новая вики", + "heading": "Создать живую вики" } }, "connectors": { @@ -527,7 +966,8 @@ "systemTime": "Текущее время (ЧЧ:ММ:СС)", "systemTimestamp": "Отметка времени ISO 8601", "systemRequestId": "Уникальный идентификатор запроса", - "systemUserId": "Идентификатор текущего пользователя" + "systemUserId": "Идентификатор текущего пользователя", + "artifactsLookup": "Метаданные артефакта по идентификатору" }, "learnAboutPrompts": "Узнать о промптах →", "publicPromptEditDisabled": "Публичные промпты нельзя редактировать", @@ -650,14 +1090,38 @@ "remove": "Удалить вложение" }, "retry": "Повторить", - "reasoning": "Рассуждение" + "reasoning": "Рассуждение", + "wikiWrite": { + "edited": "Вики отредактирована", + "create": "Создана страница вики", + "str_replace": "Отредактирована страница вики", + "insert": "Отредактирована страница вики", + "delete": "Удалена страница вики", + "rename": "Переименована страница вики", + "failed": "не удалось" + } }, "agents": { "title": "Агенты", "edit": "Редактировать", "description": "Откройте и создайте пользовательские версии DocsGPT, которые объединяют инструкции, дополнительные знания и любую комбинацию навыков", "newAgent": "Новый Агент", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "Поделиться с командой", "backToAll": "Вернуться ко всем агентам", + "pageHeader": { + "crumbs": { + "agents": "Агенты" + }, + "tabs": { + "overview": "Обзор", + "logs": "Логи", + "schedules": "Расписания" + }, + "fallbackName": "Агент без названия", + "subnavAriaLabel": "Навигация по разделам агента" + }, "searchPlaceholder": "Поиск...", "noSearchResults": "Агенты не найдены", "tryDifferentSearch": "Попробуйте другой поисковый запрос", @@ -665,8 +1129,13 @@ "all": "Все", "byDocsGPT": "От DocsGPT", "byMe": "Мои", + "team": "Команда", "shared": "Поделились со мной" }, + "teamBadge": { + "editor": "Редактор", + "viewer": "Читатель" + }, "sections": { "template": { "title": "От DocsGPT", @@ -678,6 +1147,11 @@ "description": "Агенты, созданные или опубликованные вами", "emptyState": "У вас пока нет созданных агентов." }, + "team": { + "title": "Команда", + "description": "Агенты, которыми с вами поделились участники команды", + "emptyState": "С вашей командой пока не поделились агентами." + }, "shared": { "title": "Поделились со мной", "description": "Агенты, импортированные по публичной ссылке", @@ -697,6 +1171,7 @@ "cancel": "Отмена", "delete": "Удалить", "logs": "Логи", + "schedules": "Расписания", "accessDetails": "Детали Доступа", "add": "Добавить", "moreActions": "Другие действия" @@ -731,7 +1206,10 @@ "toolsPopup": { "title": "Выберите Инструменты", "searchPlaceholder": "Поиск инструментов...", - "noOptionsMessage": "Нет доступных инструментов" + "noOptionsMessage": "Нет доступных инструментов", + "groupBuiltin": "Встроенные", + "groupDefault": "По умолчанию", + "groupCustom": "Пользовательские" }, "modelsPopup": { "title": "Выберите Модели", @@ -777,6 +1255,75 @@ "noUsageHistory": "Нет истории использования", "tableHeader": "Логи конечной точки агента" }, + "schedules": { + "title": "Расписания агента", + "heading": "Расписания", + "newRecurring": "Новое расписание", + "closeForm": "Закрыть форму", + "edit": "Редактировать", + "recurring": "Повторяющиеся", + "oneTime": "Разовые задачи", + "noRecurring": "Повторяющихся расписаний пока нет.", + "noOneTime": "Разовых задач пока нет.", + "pause": "Приостановить", + "resume": "Возобновить", + "runNow": "Запустить сейчас", + "delete": "Удалить", + "deleteConfirm": "Удалить это расписание? История его запусков также будет удалена. Это действие нельзя отменить.", + "cancel": "Отмена", + "showRuns": "Показать запуски", + "hideRuns": "Скрыть запуски", + "modal": { + "titleCreate": "Новое расписание", + "titleEdit": "Редактировать расписание", + "namePlaceholder": "Название задачи", + "frequency": { + "once": "Однократно", + "daily": "Ежедневно", + "weekly": "Еженедельно", + "monthly": "Ежемесячно", + "yearly": "Ежегодно" + }, + "on": "День", + "at": "Время", + "pickDate": "Выберите дату", + "timezone": "Часовой пояс", + "timezonePlaceholder": "Выберите часовой пояс", + "timezoneSearchPlaceholder": "Поиск часового пояса…", + "timezoneEmpty": "Часовой пояс не найден.", + "instructionsLabel": "Инструкции", + "instructionsPlaceholder": "Введите промпт здесь.", + "create": "Создать задачу", + "save": "Сохранить изменения", + "errors": { + "instructionRequired": "Инструкции обязательны.", + "runAtInPast": "Выберите дату и время в будущем." + }, + "days": { + "mon": "Пн", + "tue": "Вт", + "wed": "Ср", + "thu": "Чт", + "fri": "Пт", + "sat": "Сб", + "sun": "Вс" + }, + "months": { + "jan": "Янв", + "feb": "Фев", + "mar": "Мар", + "apr": "Апр", + "may": "Май", + "jun": "Июн", + "jul": "Июл", + "aug": "Авг", + "sep": "Сен", + "oct": "Окт", + "nov": "Ноя", + "dec": "Дек" + } + } + }, "shared": { "notFound": "Агент не найден. Убедитесь, что агент является общим." }, @@ -797,11 +1344,10 @@ "move": "Переместить", "noFolder": "Без папки (корень)", "backToRoot": "Назад", + "currentFolder": "Эта папка", "noSubfolders": "Нет подпапок", "noFolders": "Пока нет папок" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -841,6 +1387,17 @@ "navigation": { "agents": "Агенты" }, + "teams": { + "switcher": { + "ariaLabel": "Сменить команду", + "personal": "Личный аккаунт", + "personalSubtitle": "Личное рабочее пространство", + "roleAdmin": "Администратор", + "roleMember": "Участник", + "manageTeam": "Управление командой", + "createTeam": "Создать команду" + } + }, "notification": { "ariaLabel": "Уведомление", "closeAriaLabel": "Закрыть уведомление" diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 068e9cfe..03087484 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -12,6 +12,18 @@ "emailUs": "給我們發電郵", "documentation": "文件", "manageAgents": "管理代理", + "teamAccess": { + "editor": "編輯者", + "viewer": "檢視者" + }, + "notifications": { + "memberAddedTitle": "已加入團隊", + "memberAddedBody": "您已被加入 {{team}},角色為 {{role}}", + "sharedTitle": "已與您分享", + "sharedBody": "已與您的團隊分享{{type}}:{{name}}({{access}})", + "sharedBodyNoName": "已與您的團隊分享一個{{type}}({{access}})", + "dismiss": "關閉" + }, "demo": [ { "header": "了解 DocsGPT", @@ -30,6 +42,15 @@ "query": "為此內容撰寫可能的問題" } ], + "auth": { + "signInToContinue": "登入以繼續使用 DocsGPT", + "signInWithSSO": "使用 SSO 登入", + "signInWith": "使用 {{provider}} 登入", + "notAuthorized": "您的帳戶未獲授權存取此工作區。請改用其他帳戶登入,或聯絡您的管理員。", + "accountDisabled": "您的帳戶已被停用。請聯絡您的管理員。", + "signOut": "登出", + "account": "帳戶" + }, "settings": { "label": "設定", "general": { @@ -82,6 +103,119 @@ }, "actions": "操作", "view": "查看", + "wiki": { + "view": "查看 Wiki", + "empty": "此 Wiki 尚無頁面。", + "selectPage": "選擇頁面以查看其內容。", + "edit": "編輯", + "save": "儲存", + "cancel": "取消", + "saving": "儲存中…", + "saveFailed": "無法儲存頁面。請再試一次。", + "forbidden": "您沒有編輯此頁面的權限。", + "conflict": "此頁面在您開啟後已變更,並已重新載入。您的編輯內容如下——請重新套用後再次儲存。", + "editPlaceholder": "以 Markdown 撰寫頁面…", + "livingTitle": "動態 Wiki。", + "livingExplainer": "可由代理編輯的來源:代理工作時會讀取並改寫頁面,且預設透過代理搜尋工具向代理開放。", + "livingExplainerEditable": "任何使用此來源的代理都可以編輯它。", + "stamp": { + "editedBy": "由 {{who}} 編輯", + "agent": "代理", + "human": "人工", + "you": "您", + "unknown": "未知", + "version": "v{{version}}" + }, + "convert": { + "action": "轉換為 Wiki", + "title": "轉換為動態 Wiki", + "intro": "將「{{name}}」轉換為由可編輯頁面組成的動態 Wiki,供代理讀取與改寫。", + "introGeneric": "將此來源轉換為由可編輯頁面組成的動態 Wiki,供代理讀取與改寫。", + "costReparse": "檔案會重新解析為可編輯頁面。格式會被簡化,PDF 尤其明顯。", + "costSkipped": "非文字檔案(圖片、二進位檔)將被略過。", + "costIrreversible": "此操作無法復原——Wiki 頁面將成為資料的唯一來源。", + "confirm": "轉換為 Wiki", + "inProgress": "正在將檔案轉換為 Wiki 頁面…", + "summaryPages_one": "已建立 {{count}} 個頁面", + "summaryPages_other": "已建立 {{count}} 個頁面", + "summarySkipped_one": "已略過 {{count}} 個檔案", + "summarySkipped_other": "已略過 {{count}} 個檔案", + "skippedHeading": "已略過的檔案", + "done": "完成", + "errors": { + "generic": "轉換失敗。請再試一次。", + "forbidden": "您沒有轉換此來源的權限。", + "conflict": "此來源仍在索引中。請等候完成後再轉換。" + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "正在建立圖譜…", + "buildingPct": "正在建立圖譜… {{pct}}%", + "enable": { + "action": "啟用 GraphRAG", + "title": "啟用 GraphRAG", + "intro": "為「{{name}}」建立知識圖譜,讓代理能跨關聯的實體與關係進行推理。", + "introGeneric": "為此來源建立知識圖譜,讓代理能跨關聯的實體與關係進行推理。", + "costExtraction": "系統會對此來源的區塊執行 LLM,以擷取實體與關係。", + "costCost": "每個區塊會呼叫一次 LLM,因此會消耗 Token,來源較大時可能需要較長時間。", + "estimate": "每個區塊約 1 次 LLM 呼叫 · 總計約 {{lo}}–{{hi}} 個 Token(輸入與輸出約各半);輸出量依文件中實體的密集程度而異。", + "confirm": "啟用 GraphRAG", + "inProgress": "正在建立圖譜…", + "inProgressPct": "正在建立圖譜… {{pct}}%", + "summaryNodes_one": "{{count}} 個實體", + "summaryNodes_other": "{{count}} 個實體", + "summaryEdges_one": "{{count}} 個關係", + "summaryEdges_other": "{{count}} 個關係", + "summaryChunks_one": "已處理 {{count}} 個區塊", + "summaryChunks_other": "已處理 {{count}} 個區塊", + "done": "完成", + "errors": { + "generic": "啟用 GraphRAG 失敗。請再試一次。", + "forbidden": "您沒有為此來源啟用 GraphRAG 的權限。", + "conflict": "此來源仍在索引中。請等候完成後再啟用 GraphRAG。" + } + }, + "view": { + "action": "查看圖譜", + "title": "知識圖譜", + "explainer": "節點代表實體,邊代表它們之間的關係。點擊節點可查看其描述及擷取來源的區塊。", + "stats": "{{nodes}} 個實體 · {{edges}} 個關係", + "empty": "尚無圖譜——擷取可能仍在進行中。", + "selectNode": "選擇節點以查看其詳細資訊。", + "linkedChunks": "關聯區塊", + "noChunks": "此實體沒有關聯的區塊。", + "close": "關閉" + } + }, + "testRetrieval": { + "action": "測試檢索", + "title": "測試檢索", + "subtitle": "使用實際的檢索管線,查看查詢實際從「{{name}}」檢索到的區塊。", + "subtitleGeneric": "使用實際的檢索管線,查看查詢實際檢索到的區塊。", + "queryPlaceholder": "輸入此來源應能回答的問題...", + "run": "執行", + "notSavedHint": "這些設定僅用於此次測試,不會儲存到來源。若要永久生效,請至來源設定中修改。", + "resultSummary": "檢索到 {{total}} 個區塊 · {{retriever}}", + "latency": "{{ms}} ms", + "empty": "此查詢未檢索到任何區塊。", + "emptyWithThreshold": "沒有區塊的分數達到分數門檻({{threshold}})。調低門檻可查看接近的結果。", + "showMore": "顯示完整區塊", + "showLess": "收合", + "noScore": "無分數", + "noScoreHint": "此檢索器對區塊排序時,不會產生可比較的個別區塊分數。", + "scoreKinds": { + "cosine_similarity": "相似度", + "l2_distance": "距離", + "rrf": "RRF" + }, + "errors": { + "failed": "檢索測試失敗。請再試一次。" + } + }, + "editConfig": "來源設定", + "shareWithTeam": "與團隊分享", "deleteWarning": "您確定要刪除 \"{{name}}\" 嗎?", "confirmDelete": "您確定要刪除此檔案嗎?此操作無法復原。", "backToAll": "返回所有來源", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "正在上傳檔案...", "deletingTitle": "正在刪除...", "deleteDirectoryWarning": "您確定要刪除目錄 \"{{name}}\" 及其所有內容嗎?此操作無法復原。", - "searchAlt": "搜尋" + "searchAlt": "搜尋", + "retrievalOptions": { + "title": "進階設定", + "retrieval": { + "title": "檢索", + "note": "這些變更立即生效,無需重新索引。", + "tag": "無需重新索引", + "chunks": "檢索區塊數(top-k)", + "scoreThreshold": "分數門檻", + "scoreThresholdPlaceholder": "無", + "scoreThresholdHint": "僅回傳相關性分數高於此值(0–1)的區塊;數值越高越嚴格。", + "rephraseQuery": "搜尋前改寫查詢", + "retriever": "檢索器", + "retrievers": { + "classic": "經典", + "hybrid": "混合", + "graphrag": "GraphRAG" + }, + "retrieverHint": "混合檢索結合關鍵字與向量搜尋。GraphRAG 會為此來源建立知識圖譜,適合需要多步推理的問題。", + "exposure": "搜尋提供方式", + "exposures": { + "prefetch": "預先載入至脈絡", + "agentic_tool": "隨選搜尋工具" + }, + "exposureHint": "將此來源預先載入提示中,或讓代理以工具形式隨選搜尋。" + }, + "prescreen": { + "enable": "啟用 LLM 預篩選", + "warning": "會擷取較大的候選集合並使用 LLM 篩選。這將增加查詢延遲與成本。", + "candidateK": "擷取候選數", + "maxKeep": "保留區塊上限", + "batchSize": "批次大小", + "model": "模型(選填)", + "modelPlaceholder": "預設使用請求模型" + }, + "graph": { + "title": "圖譜擷取", + "tag": "重新索引後生效", + "extractionModel": "擷取模型", + "extractionModelHint": "用於從每個區塊擷取實體與關係的 LLM。", + "defaultModel": "使用預設模型", + "maxChunks": "擷取區塊上限", + "maxChunksHint": "限制送交擷取的區塊數量。留空則使用執行個體預設值。", + "maxChunksPlaceholder": "預設" + }, + "chunking": { + "title": "分塊", + "note": "索引時設定。變更後需重新索引來源。", + "tag": "重新索引後生效", + "strategy": "分塊策略", + "strategies": { + "classic_chunk": "經典", + "recursive": "遞迴", + "markdown": "Markdown", + "parent_child": "父/子", + "semantic": "語意" + }, + "maxTokens": "每區塊最大 Token 數", + "minTokens": "每區塊最小 Token 數", + "duplicateHeaders": "在各區塊間重複標頭" + } + }, + "configModal": { + "title": "來源設定", + "subtitle": "設定「{{name}}」的分塊與檢索方式。", + "subtitleGeneric": "設定此來源的分塊與檢索方式。", + "save": "儲存", + "saving": "儲存中…", + "readOnly": "您對此共享來源僅有檢視權限,無法變更其設定。", + "chunkingChangeHint": "您變更了分塊設定。儲存後需重新索引此來源才會生效。", + "prescreenInvalidHint": "請檢查預篩選數值:擷取候選數不得少於區塊數,且保留區塊數不得超過擷取候選數。", + "reingestRequired": "變更已儲存。您變更的分塊設定需重新索引後才會生效。要立即重新索引嗎?", + "reingestLater": "稍後", + "errors": { + "forbidden": "您沒有編輯此來源設定的權限。", + "saveFailed": "儲存設定失敗。請再試一次。" + } + } }, "apiKeys": { "label": "聊天機器人", @@ -138,7 +349,30 @@ "filterPlaceholder": "篩選", "none": "無", "positiveFeedback": "正向回饋", - "negativeFeedback": "負向回饋" + "negativeFeedback": "負向回饋", + "groupBy": "分組方式", + "groupOptions": { + "total": "總計", + "model": "依模型", + "agent": "依代理", + "source": "依來源" + }, + "includeSideChannel": "背景 Token", + "promptTokens": "提示 Token", + "generatedTokens": "生成 Token", + "scheduledRuns": "排程執行", + "toolUsage": "工具使用量", + "completed": "已完成", + "failed": "失敗", + "skipped": "已略過", + "successful": "成功", + "stats": { + "messages": "訊息", + "tokens": "Token", + "toolCalls": "工具呼叫", + "runSuccess": "執行成功率", + "feedback": "回饋" + } }, "logs": { "label": "日誌", @@ -146,7 +380,131 @@ "filterByChatbot": "按聊天機器人篩選", "selectChatbot": "選擇聊天機器人", "none": "無", - "tableHeader": "API 生成 / 聊天機器人會話" + "tableHeader": "API 生成 / 聊天機器人會話", + "searchPlaceholder": "搜尋日誌...", + "noLogs": "找不到日誌", + "levels": { + "all": "所有層級", + "info": "資訊", + "error": "錯誤", + "warning": "警告" + }, + "types": { + "all": "所有事件", + "chat": "對話", + "schedule": "排程", + "webhook": "Webhook", + "workflow": "工作流程", + "system": "系統" + }, + "detail": { + "agent": "代理", + "status": "狀態", + "trigger": "觸發方式", + "errorType": "錯誤類型", + "tokens": "Token", + "duration": "持續時間", + "conversation": "對話", + "endpoint": "端點", + "instruction": "指令", + "response": "回應", + "output": "輸出", + "error": "錯誤", + "toolCalls": "工具呼叫", + "sources": "來源", + "workflow": "工作流程", + "activity": "活動", + "steps": "步驟", + "result": "結果" + } + }, + "teams": { + "label": "團隊", + "subtitle": "管理您的團隊、成員與共享資源。", + "newTeam": "新增團隊", + "backToTeams": "返回團隊列表", + "createTeam": "建立團隊", + "createTeamDescription": "為您的團隊命名。建立後即可新增成員並分享資源。", + "editTeam": "編輯", + "teamNamePlaceholder": "團隊名稱", + "descriptionLabel": "描述", + "descriptionPlaceholder": "這個團隊的用途是什麼?(選填)", + "create": "建立", + "save": "儲存", + "createTeamError": "無法建立團隊。", + "noTeams": "尚無團隊。", + "noDescription": "無描述", + "memberCountOne": "{{count}} 位成員", + "memberCountOther": "{{count}} 位成員", + "sharedCount": "已分享 {{count}} 項", + "loadError": "載入團隊失敗。", + "roleAdmin": "管理員", + "roleMember": "成員", + "sourceManual": "手動加入", + "sourceSso": "透過 SSO", + "sourceScim": "透過 SCIM", + "teamsHeading": "您的團隊", + "selectTeamHint": "選擇團隊以管理其成員與共享資源。", + "selectTeamTitle": "未選擇團隊", + "teamSubtitle": "管理成員以及與此團隊分享的資源。", + "deleteTeam": "刪除團隊", + "deleteTeamConfirmation": "您確定要刪除「{{name}}」嗎?此操作無法復原。", + "members": "成員", + "addMember": "新增成員", + "addMemberTitle": "新增成員", + "addMemberDescription": "透過電子郵件邀請成員。對方必須至少登入過一次。", + "teamActions": "團隊操作", + "memberEmailLabel": "成員電子郵件", + "memberEmailPlaceholder": "name@example.com", + "memberRoleLabel": "角色", + "add": "新增", + "remove": "移除", + "removeMemberConfirmation": "要將此成員從團隊中移除嗎?", + "addMemberError": "新增成員失敗(僅限團隊管理員)。", + "memberNotFound": "找不到使用該電子郵件的使用者——對方必須先登入一次。", + "noMembers": "尚無成員。使用「新增成員」以電子郵件邀請。", + "roleChangeError": "無法變更角色(最後一位管理員受保護)。", + "removeMemberError": "無法移除成員。", + "deleteTeamError": "無法刪除團隊。", + "updateFailed": "更新失敗", + "openTeamError": "無法載入團隊詳細資訊。", + "sharedResources": "共享資源", + "sharedWithTeam": "已與此團隊分享({{count}})", + "nothingShared": "尚未分享任何內容。可從代理、來源、提示或工具的選單進行分享。", + "unshare": "取消分享", + "unshareError": "無法取消分享資源。", + "resourceType": { + "agent": "代理", + "source": "來源", + "prompt": "提示", + "tool": "工具" + }, + "share": { + "titleNamed": "分享「{{name}}」", + "titleGeneric": "分享此{{type}}", + "subtitle": "團隊成員可以使用;編輯者也可以修改。您仍是擁有者。", + "noTeams": "您尚未加入任何團隊。請至「設定 → 團隊」建立。", + "accessLevel": { + "viewer": "檢視者", + "editor": "編輯者" + }, + "done": "完成", + "loadError": "無法載入目前的分享設定。", + "shareError": "無法與團隊分享。", + "unshareError": "無法取消與團隊的分享。", + "addPlaceholder": "新增人員或團隊…", + "searchPlaceholder": "新增人員或團隊…", + "noMatches": "找不到人員或團隊。", + "peopleWithAccess": "具有存取權的人員", + "you": "您", + "owner": "擁有者", + "teamsGroup": "團隊", + "peopleGroup": "人員", + "teamLabel": "團隊", + "viaTeam": "透過 {{team}}", + "removeAccess": "移除存取權", + "access": "存取權" + } }, "tools": { "label": "工具", @@ -161,7 +519,14 @@ "manageTools": "前往工具", "edit": "編輯", "delete": "刪除", + "reconnect": "重新連線", + "shareWithTeam": "與團隊分享", "builtIn": "內建", + "authStatus": { + "connected": "已連線", + "needsAuth": "需要驗證", + "configured": "已設定" + }, "deleteWarning": "您確定要刪除工具 \"{{toolName}}\" 嗎?", "unsavedChanges": "您有未儲存的變更,如果不儲存就離開將會遺失。", "leaveWithoutSaving": "不儲存離開", @@ -188,6 +553,8 @@ "deleteActionWarning": "您確定要刪除操作 \"{{name}}\" 嗎?", "backToAllTools": "返回所有工具", "save": "儲存", + "saving": "儲存中...", + "saveFailed": "儲存工具設定失敗", "fieldName": "欄位名稱", "fieldType": "欄位類型", "filledByLLM": "由LLM填入", @@ -203,6 +570,8 @@ "mcp": { "addServer": "Add MCP Server", "editServer": "Edit Server", + "reconnectServer": "重新連線伺服器", + "reenterCredentials": "重新輸入您的憑證以測試並更新連線。", "serverName": "Server Name", "serverUrl": "Server URL", "headerName": "Header Name", @@ -215,6 +584,8 @@ "noAuth": "No Authentication", "oauthInProgress": "Waiting for OAuth completion...", "oauthCompleted": "OAuth completed successfully", + "oauthPopupBlocked": "彈出視窗被瀏覽器封鎖。點擊下方進行授權:", + "openAuthPage": "開啟授權頁面", "authType": "Authentication Type", "defaultServerName": "My MCP Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "裝置", + "subtitle": "配對遠端機器,讓代理能透過 shell 指令操作。", + "pair": "配對裝置", + "empty": "沒有已配對的裝置。", + "revoke": "撤銷", + "edit": "編輯", + "revokeWarning": "要撤銷 {{name}} 嗎?CLI 將會中斷連線。", + "approvalMode": "核准模式", + "approvalAsk": "詢問", + "approvalFull": "完整存取", + "online": "線上", + "offline": "離線", + "lastSeen": "上次上線:{{time}}", + "lastSeenLabel": "上次上線", + "seenAgo": "{{time}}前上線", + "nameLabel": "裝置名稱", + "descriptionLabel": "描述(代理可見)", + "descriptionPlaceholder": "這台機器的用途是什麼?", + "identity": "識別資訊", + "access": "存取權", + "connection": "連線", + "hostLabel": "主機", + "dangerZone": "危險區域", + "approvalAskDescription": "每個指令執行前都需核准。", + "approvalFullDescription": "執行所有指令而不詢問,安全拒絕清單除外。", + "fullAccessWarning": "完整存取:代理可在此機器上執行任何指令而不需詢問。安全拒絕清單仍然適用。", + "deviceIdLabel": "裝置 ID", + "offlineHint": "未連線。請在該機器上執行 `docsgpt-cli host` 使其上線。", + "dangerZoneDescription": "撤銷會停止裝置上的常駐程式並移除配對。", + "auditTitle": "近期活動", + "auditLoading": "載入中…", + "auditEmpty": "尚無活動。", + "auditDecision": "決策", + "auditExit": "結束代碼", + "auditDuration": "持續時間", + "pairing": { + "title": "配對遠端機器", + "subtitle": "在遠端機器上執行下方指令,並在提示時輸入配對碼。", + "formSubtitle": "為新裝置設定名稱、描述與核准模式。", + "nameLabel": "裝置名稱(選填)", + "namePlaceholder": "例如 edge-server", + "descriptionLabel": "描述(選填)", + "descriptionPlaceholder": "代理可見,以便選擇正確的裝置。", + "fullAccessWarning": "完整存取:代理可在此機器上執行任何指令而不需詢問。安全拒絕清單仍然適用。", + "stepOne": "1. 在您的遠端機器上執行:", + "installLink": "還沒有 docsgpt-cli?安裝它", + "stepTwo": "2. 在提示時輸入此配對碼:", + "start": "產生配對碼", + "starting": "處理中…", + "waitingForCli": "等待 CLI 兌換配對碼…", + "cancel": "取消" + } + }, "customModels": { "label": "自訂模型", "subtitle": "接入你自己的 OpenAI 相容模型。", @@ -295,6 +720,15 @@ "capabilities": { "title": "功能", "contextWindowShort": "脈絡視窗", + "apiFlavor": "API 協定", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "推理強度", + "reasoningEfforts": { + "default": "供應商預設" + }, "chips": { "tools": "工具", "structuredOutput": "結構化輸出", @@ -325,9 +759,9 @@ "selectedFiles": "已選擇的檔案", "noFilesSelected": "未選擇檔案", "file": "從檔案", - "remote": "遠端", "back": "返回", "wait": "請稍候...", + "remote": "遠端", "start": "開始對話", "name": "名稱", "choose": "選擇檔案", @@ -335,6 +769,7 @@ "uploadedFiles": "已上傳檔案", "cancel": "取消", "train": "訓練", + "create": "建立", "link": "連結", "urlLink": "URL 連結", "repoUrl": "儲存庫 URL", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "從Confluence上傳" + }, + "wiki": { + "label": "新 Wiki", + "heading": "建立動態 Wiki" } }, "connectors": { @@ -527,7 +966,8 @@ "systemTime": "目前時間 (HH:MM:SS)", "systemTimestamp": "ISO 8601 時間戳記", "systemRequestId": "唯一請求識別碼", - "systemUserId": "目前使用者 ID" + "systemUserId": "目前使用者 ID", + "artifactsLookup": "依 ID 查詢 Artifact 中繼資料" }, "learnAboutPrompts": "了解提示 →", "publicPromptEditDisabled": "公共提示無法編輯", @@ -650,14 +1090,38 @@ "remove": "刪除附件" }, "retry": "重試", - "reasoning": "推理" + "reasoning": "推理", + "wikiWrite": { + "edited": "已編輯 Wiki", + "create": "已建立 Wiki 頁面", + "str_replace": "已編輯 Wiki 頁面", + "insert": "已編輯 Wiki 頁面", + "delete": "已刪除 Wiki 頁面", + "rename": "已重新命名 Wiki 頁面", + "failed": "失敗" + } }, "agents": { "title": "代理", "edit": "編輯", "description": "探索並創建結合指令、額外知識和任意技能組合的DocsGPT自訂版本", "newAgent": "新建代理", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "與團隊分享", "backToAll": "返回所有代理", + "pageHeader": { + "crumbs": { + "agents": "代理" + }, + "tabs": { + "overview": "總覽", + "logs": "日誌", + "schedules": "排程" + }, + "fallbackName": "未命名代理", + "subnavAriaLabel": "代理子導覽" + }, "searchPlaceholder": "搜尋...", "noSearchResults": "未找到代理", "tryDifferentSearch": "請嘗試不同的搜尋詞", @@ -665,8 +1129,13 @@ "all": "全部", "byDocsGPT": "由DocsGPT提供", "byMe": "我的", + "team": "團隊", "shared": "與我共享" }, + "teamBadge": { + "editor": "編輯者", + "viewer": "檢視者" + }, "sections": { "template": { "title": "由DocsGPT提供", @@ -678,6 +1147,11 @@ "description": "您創建或發佈的代理", "emptyState": "您還沒有創建任何代理。" }, + "team": { + "title": "團隊", + "description": "團隊成員與您分享的代理", + "emptyState": "尚未有代理與您的團隊分享。" + }, "shared": { "title": "與我共享", "description": "透過公共連結匯入的代理", @@ -697,6 +1171,7 @@ "cancel": "取消", "delete": "刪除", "logs": "日誌", + "schedules": "排程", "accessDetails": "存取詳情", "add": "新增", "moreActions": "更多操作" @@ -731,7 +1206,10 @@ "toolsPopup": { "title": "選擇工具", "searchPlaceholder": "搜尋工具...", - "noOptionsMessage": "沒有可用的工具" + "noOptionsMessage": "沒有可用的工具", + "groupBuiltin": "內建", + "groupDefault": "預設", + "groupCustom": "自訂" }, "modelsPopup": { "title": "選擇模型", @@ -777,6 +1255,75 @@ "noUsageHistory": "無使用歷史", "tableHeader": "代理端點日誌" }, + "schedules": { + "title": "代理排程", + "heading": "排程", + "newRecurring": "新增排程", + "closeForm": "關閉表單", + "edit": "編輯", + "recurring": "週期性", + "oneTime": "一次性任務", + "noRecurring": "尚無週期性排程。", + "noOneTime": "尚無一次性任務。", + "pause": "暫停", + "resume": "繼續", + "runNow": "立即執行", + "delete": "刪除", + "deleteConfirm": "要刪除此排程嗎?其執行歷史也會一併刪除。此操作無法復原。", + "cancel": "取消", + "showRuns": "顯示執行記錄", + "hideRuns": "隱藏執行記錄", + "modal": { + "titleCreate": "新增排程", + "titleEdit": "編輯排程", + "namePlaceholder": "任務名稱", + "frequency": { + "once": "一次", + "daily": "每天", + "weekly": "每週", + "monthly": "每月", + "yearly": "每年" + }, + "on": "日期", + "at": "時間", + "pickDate": "選擇日期", + "timezone": "時區", + "timezonePlaceholder": "選擇時區", + "timezoneSearchPlaceholder": "搜尋時區…", + "timezoneEmpty": "找不到時區。", + "instructionsLabel": "指令", + "instructionsPlaceholder": "在此輸入提示。", + "create": "建立任務", + "save": "儲存變更", + "errors": { + "instructionRequired": "指令為必填。", + "runAtInPast": "請選擇未來的日期/時間。" + }, + "days": { + "mon": "週一", + "tue": "週二", + "wed": "週三", + "thu": "週四", + "fri": "週五", + "sat": "週六", + "sun": "週日" + }, + "months": { + "jan": "1月", + "feb": "2月", + "mar": "3月", + "apr": "4月", + "may": "5月", + "jun": "6月", + "jul": "7月", + "aug": "8月", + "sep": "9月", + "oct": "10月", + "nov": "11月", + "dec": "12月" + } + } + }, "shared": { "notFound": "未找到代理。請確保代理已共享。" }, @@ -797,11 +1344,10 @@ "move": "移動", "noFolder": "無資料夾 (根目錄)", "backToRoot": "返回", + "currentFolder": "此資料夾", "noSubfolders": "沒有子資料夾", "noFolders": "暫無資料夾" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -841,6 +1387,17 @@ "navigation": { "agents": "代理" }, + "teams": { + "switcher": { + "ariaLabel": "切換團隊", + "personal": "個人帳戶", + "personalSubtitle": "個人工作區", + "roleAdmin": "管理員", + "roleMember": "成員", + "manageTeam": "管理團隊", + "createTeam": "建立團隊" + } + }, "notification": { "ariaLabel": "通知", "closeAriaLabel": "關閉通知" diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 980d9cac..202ff839 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -12,6 +12,18 @@ "emailUs": "给我们发邮件", "documentation": "文档", "manageAgents": "管理代理", + "teamAccess": { + "editor": "编辑者", + "viewer": "查看者" + }, + "notifications": { + "memberAddedTitle": "已加入团队", + "memberAddedBody": "您已以{{role}}身份加入 {{team}}", + "sharedTitle": "与您共享", + "sharedBody": "{{type}}已共享给您的团队:{{name}}({{access}})", + "sharedBodyNoName": "已与您的团队共享一个{{type}}({{access}})", + "dismiss": "忽略" + }, "demo": [ { "header": "了解 DocsGPT", @@ -30,6 +42,15 @@ "query": "为背景写出潜在问题" } ], + "auth": { + "signInToContinue": "登录以继续使用 DocsGPT", + "signInWithSSO": "使用 SSO 登录", + "signInWith": "使用 {{provider}} 登录", + "notAuthorized": "您的账户无权访问此工作区。请使用其他账户登录或联系管理员。", + "accountDisabled": "您的账户已被禁用。请联系管理员。", + "signOut": "退出登录", + "account": "账户" + }, "settings": { "label": "设置", "general": { @@ -82,6 +103,119 @@ }, "actions": "操作", "view": "查看", + "wiki": { + "view": "查看 Wiki", + "empty": "此 Wiki 还没有页面。", + "selectPage": "选择一个页面以查看其内容。", + "edit": "编辑", + "save": "保存", + "cancel": "取消", + "saving": "保存中…", + "saveFailed": "无法保存页面,请重试。", + "forbidden": "您没有权限编辑此页面。", + "conflict": "此页面在您打开后已被修改,并已重新加载。您的编辑内容在下方——请重新应用并再次保存。", + "editPlaceholder": "使用 Markdown 编写页面…", + "livingTitle": "动态 Wiki。", + "livingExplainer": "可由代理编辑的来源:代理在工作时会读取和重写页面,并且它默认通过代理搜索工具向代理开放。", + "livingExplainerEditable": "任何使用此来源的代理都可以编辑它。", + "stamp": { + "editedBy": "由{{who}}编辑", + "agent": "代理", + "human": "人工", + "you": "您", + "unknown": "未知", + "version": "v{{version}}" + }, + "convert": { + "action": "转换为 Wiki", + "title": "转换为动态 Wiki", + "intro": "将“{{name}}”转换为由可编辑页面组成的动态 Wiki,代理可以读取和重写这些页面。", + "introGeneric": "将此来源转换为由可编辑页面组成的动态 Wiki,代理可以读取和重写这些页面。", + "costReparse": "文件将被重新解析为可编辑页面。格式会被简化,PDF 尤其明显。", + "costSkipped": "非文本文件(图片、二进制文件)将被跳过。", + "costIrreversible": "此操作不可逆——Wiki 页面将成为内容的唯一来源。", + "confirm": "转换为 Wiki", + "inProgress": "正在将文件转换为 Wiki 页面…", + "summaryPages_one": "已创建 {{count}} 个页面", + "summaryPages_other": "已创建 {{count}} 个页面", + "summarySkipped_one": "已跳过 {{count}} 个文件", + "summarySkipped_other": "已跳过 {{count}} 个文件", + "skippedHeading": "已跳过的文件", + "done": "完成", + "errors": { + "generic": "转换失败,请重试。", + "forbidden": "您没有权限转换此来源。", + "conflict": "此来源仍在索引中。请等待完成后再转换。" + } + } + }, + "graphrag": { + "badge": "GraphRAG", + "building": "正在构建图谱…", + "buildingPct": "正在构建图谱… {{pct}}%", + "enable": { + "action": "启用 GraphRAG", + "title": "启用 GraphRAG", + "intro": "在“{{name}}”上构建知识图谱,让代理能够基于关联的实体和关系进行推理。", + "introGeneric": "在此来源上构建知识图谱,让代理能够基于关联的实体和关系进行推理。", + "costExtraction": "将对此来源的文本块运行 LLM 以提取实体和关系。", + "costCost": "每个文本块会调用一次 LLM,因此会消耗令牌,来源较大时可能耗时较长。", + "estimate": "每个文本块约 1 次 LLM 调用 · 共约 {{lo}}–{{hi}} 个令牌(输入输出各约一半);输出量取决于文档中实体的密集程度。", + "confirm": "启用 GraphRAG", + "inProgress": "正在构建图谱…", + "inProgressPct": "正在构建图谱… {{pct}}%", + "summaryNodes_one": "{{count}} 个实体", + "summaryNodes_other": "{{count}} 个实体", + "summaryEdges_one": "{{count}} 个关系", + "summaryEdges_other": "{{count}} 个关系", + "summaryChunks_one": "已处理 {{count}} 个文本块", + "summaryChunks_other": "已处理 {{count}} 个文本块", + "done": "完成", + "errors": { + "generic": "启用 GraphRAG 失败,请重试。", + "forbidden": "您没有权限在此来源上启用 GraphRAG。", + "conflict": "此来源仍在索引中。请等待完成后再启用 GraphRAG。" + } + }, + "view": { + "action": "查看图谱", + "title": "知识图谱", + "explainer": "节点表示实体,边表示它们之间的关系。点击节点可查看其描述以及提取它的文本块。", + "stats": "{{nodes}} 个实体 · {{edges}} 个关系", + "empty": "暂无图谱——提取可能仍在进行中。", + "selectNode": "选择一个节点以查看其详情。", + "linkedChunks": "关联的文本块", + "noChunks": "此实体没有关联的文本块。", + "close": "关闭" + } + }, + "testRetrieval": { + "action": "测试检索", + "title": "测试检索", + "subtitle": "使用真实的检索流程,查看查询实际从“{{name}}”检索到的文本块。", + "subtitleGeneric": "使用真实的检索流程,查看查询实际检索到的文本块。", + "queryPlaceholder": "输入一个此来源应能回答的问题...", + "run": "运行", + "notSavedHint": "这些设置仅用于本次测试,不会保存到来源。如需永久生效,请在来源设置中修改。", + "resultSummary": "检索到 {{total}} 个文本块 · {{retriever}}", + "latency": "{{ms}} 毫秒", + "empty": "此查询未检索到任何文本块。", + "emptyWithThreshold": "没有文本块的得分达到分数阈值({{threshold}})。降低阈值可查看接近的结果。", + "showMore": "显示完整文本块", + "showLess": "收起", + "noScore": "无得分", + "noScoreHint": "此检索器对文本块进行排序,但不会产生可比较的单块得分。", + "scoreKinds": { + "cosine_similarity": "相似度", + "l2_distance": "距离", + "rrf": "RRF" + }, + "errors": { + "failed": "检索测试失败,请重试。" + } + }, + "editConfig": "来源设置", + "shareWithTeam": "与团队共享", "deleteWarning": "您确定要删除 \"{{name}}\" 吗?", "confirmDelete": "您确定要删除此文件吗?此操作无法撤销。", "backToAll": "返回所有来源", @@ -108,7 +242,84 @@ "uploadingFilesTitle": "正在上传文件...", "deletingTitle": "正在删除...", "deleteDirectoryWarning": "确定要删除目录 \"{{name}}\" 及其所有内容吗?此操作无法撤销。", - "searchAlt": "搜索" + "searchAlt": "搜索", + "retrievalOptions": { + "title": "高级设置", + "retrieval": { + "title": "检索", + "note": "这些更改立即生效,无需重新索引。", + "tag": "无需重新索引", + "chunks": "检索的文本块数(top-k)", + "scoreThreshold": "分数阈值", + "scoreThresholdPlaceholder": "无", + "scoreThresholdHint": "仅返回相关性得分高于此值(0–1)的文本块;数值越高越严格。", + "rephraseQuery": "搜索前改写查询", + "retriever": "检索器", + "retrievers": { + "classic": "经典", + "hybrid": "混合", + "graphrag": "GraphRAG" + }, + "retrieverHint": "混合检索结合关键词与向量搜索。GraphRAG 会在此来源上构建知识图谱,用于多跳问题。", + "exposure": "搜索接入方式", + "exposures": { + "prefetch": "预取到上下文", + "agentic_tool": "按需搜索工具" + }, + "exposureHint": "将此来源预取到提示词中,或让代理按需将其作为工具进行搜索。" + }, + "prescreen": { + "enable": "启用 LLM 预筛选", + "warning": "会获取更大的候选集并使用 LLM 进行过滤。这会增加查询时的延迟和成本。", + "candidateK": "获取的候选数量", + "maxKeep": "最多保留的文本块数", + "batchSize": "批处理大小", + "model": "模型(可选)", + "modelPlaceholder": "默认使用请求所用的模型" + }, + "graph": { + "title": "图谱提取", + "tag": "需重新索引生效", + "extractionModel": "提取模型", + "extractionModelHint": "用于从每个文本块中提取实体和关系的 LLM。", + "defaultModel": "使用默认模型", + "maxChunks": "最大提取文本块数", + "maxChunksHint": "限制发送用于提取的文本块数量。留空则使用实例默认值。", + "maxChunksPlaceholder": "默认" + }, + "chunking": { + "title": "分块", + "note": "索引时设置。更改这些设置需要重新索引来源。", + "tag": "需重新索引生效", + "strategy": "分块策略", + "strategies": { + "classic_chunk": "经典", + "recursive": "递归", + "markdown": "Markdown", + "parent_child": "父级 / 子级", + "semantic": "语义" + }, + "maxTokens": "每块最大令牌数", + "minTokens": "每块最小令牌数", + "duplicateHeaders": "跨块重复标题" + } + }, + "configModal": { + "title": "来源设置", + "subtitle": "配置“{{name}}”的分块与检索方式。", + "subtitleGeneric": "配置此来源的分块与检索方式。", + "save": "保存", + "saving": "保存中…", + "readOnly": "您对此共享来源仅有查看权限,无法更改其配置。", + "chunkingChangeHint": "您更改了分块设置。保存后需要重新索引此来源才能生效。", + "prescreenInvalidHint": "请检查预筛选参数:获取的候选数量至少应等于文本块数量,且保留的文本块数不得超过获取的候选数量。", + "reingestRequired": "您的更改已保存。修改的分块设置需重新索引后才会生效。立即重新索引?", + "reingestLater": "稍后", + "errors": { + "forbidden": "您没有权限编辑此来源的配置。", + "saveFailed": "保存配置失败,请重试。" + } + } }, "apiKeys": { "label": "聊天机器人", @@ -138,7 +349,30 @@ "filterPlaceholder": "筛选", "none": "无", "positiveFeedback": "正向反馈", - "negativeFeedback": "负向反馈" + "negativeFeedback": "负向反馈", + "groupBy": "分组方式", + "groupOptions": { + "total": "总计", + "model": "按模型", + "agent": "按代理", + "source": "按来源" + }, + "includeSideChannel": "后台令牌", + "promptTokens": "提示词令牌", + "generatedTokens": "生成令牌", + "scheduledRuns": "计划运行", + "toolUsage": "工具使用", + "completed": "已完成", + "failed": "失败", + "skipped": "已跳过", + "successful": "成功", + "stats": { + "messages": "消息", + "tokens": "令牌", + "toolCalls": "工具调用", + "runSuccess": "运行成功率", + "feedback": "反馈" + } }, "logs": { "label": "日志", @@ -146,7 +380,131 @@ "filterByChatbot": "按聊天机器人筛选", "selectChatbot": "选择聊天机器人", "none": "无", - "tableHeader": "API 生成 / 聊天机器人会话" + "tableHeader": "API 生成 / 聊天机器人会话", + "searchPlaceholder": "搜索日志...", + "noLogs": "未找到日志", + "levels": { + "all": "所有级别", + "info": "信息", + "error": "错误", + "warning": "警告" + }, + "types": { + "all": "所有事件", + "chat": "聊天", + "schedule": "计划", + "webhook": "Webhook", + "workflow": "工作流", + "system": "系统" + }, + "detail": { + "agent": "代理", + "status": "状态", + "trigger": "触发方式", + "errorType": "错误类型", + "tokens": "令牌", + "duration": "耗时", + "conversation": "对话", + "endpoint": "端点", + "instruction": "指令", + "response": "响应", + "output": "输出", + "error": "错误", + "toolCalls": "工具调用", + "sources": "来源", + "workflow": "工作流", + "activity": "活动", + "steps": "步骤", + "result": "结果" + } + }, + "teams": { + "label": "团队", + "subtitle": "管理您的团队、成员和共享资源。", + "newTeam": "新建团队", + "backToTeams": "返回团队列表", + "createTeam": "创建团队", + "createTeamDescription": "为您的团队命名。创建后即可添加成员并共享资源。", + "editTeam": "编辑", + "teamNamePlaceholder": "团队名称", + "descriptionLabel": "描述", + "descriptionPlaceholder": "这个团队的用途是什么?(可选)", + "create": "创建", + "save": "保存", + "createTeamError": "无法创建团队。", + "noTeams": "暂无团队。", + "noDescription": "无描述", + "memberCountOne": "{{count}} 名成员", + "memberCountOther": "{{count}} 名成员", + "sharedCount": "{{count}} 项共享", + "loadError": "加载团队失败。", + "roleAdmin": "管理员", + "roleMember": "成员", + "sourceManual": "手动添加", + "sourceSso": "通过 SSO", + "sourceScim": "通过 SCIM", + "teamsHeading": "您的团队", + "selectTeamHint": "选择一个团队以管理其成员和共享资源。", + "selectTeamTitle": "未选择团队", + "teamSubtitle": "管理成员以及与此团队共享的资源。", + "deleteTeam": "删除团队", + "deleteTeamConfirmation": "您确定要删除“{{name}}”吗?此操作无法撤销。", + "members": "成员", + "addMember": "添加成员", + "addMemberTitle": "添加成员", + "addMemberDescription": "通过邮箱邀请成员。对方必须至少登录过一次。", + "teamActions": "团队操作", + "memberEmailLabel": "成员邮箱", + "memberEmailPlaceholder": "name@example.com", + "memberRoleLabel": "角色", + "add": "添加", + "remove": "移除", + "removeMemberConfirmation": "将此成员从团队中移除?", + "addMemberError": "添加成员失败(仅限团队管理员)。", + "memberNotFound": "未找到使用该邮箱的用户——对方需要先登录一次。", + "noMembers": "暂无成员。使用“添加成员”通过邮箱邀请他人。", + "roleChangeError": "无法更改角色(最后一名管理员受保护)。", + "removeMemberError": "无法移除成员。", + "deleteTeamError": "无法删除团队。", + "updateFailed": "更新失败", + "openTeamError": "无法加载团队详情。", + "sharedResources": "共享资源", + "sharedWithTeam": "已与此团队共享({{count}})", + "nothingShared": "尚未共享任何内容。可从代理、来源、提示词或工具的菜单中共享。", + "unshare": "取消共享", + "unshareError": "无法取消共享该资源。", + "resourceType": { + "agent": "代理", + "source": "来源", + "prompt": "提示词", + "tool": "工具" + }, + "share": { + "titleNamed": "共享“{{name}}”", + "titleGeneric": "共享此{{type}}", + "subtitle": "团队成员可以使用它;编辑者还可以修改它。您仍是所有者。", + "noTeams": "您还不是任何团队的成员。请在 设置 → 团队 中创建一个。", + "accessLevel": { + "viewer": "查看者", + "editor": "编辑者" + }, + "done": "完成", + "loadError": "无法加载当前共享情况。", + "shareError": "无法与该团队共享。", + "unshareError": "无法取消与该团队的共享。", + "addPlaceholder": "添加人员或团队…", + "searchPlaceholder": "添加人员或团队…", + "noMatches": "未找到人员或团队。", + "peopleWithAccess": "有权访问的人员", + "you": "您", + "owner": "所有者", + "teamsGroup": "团队", + "peopleGroup": "人员", + "teamLabel": "团队", + "viaTeam": "通过 {{team}}", + "removeAccess": "移除访问权限", + "access": "访问权限" + } }, "tools": { "label": "工具", @@ -161,7 +519,14 @@ "manageTools": "前往工具", "edit": "编辑", "delete": "删除", + "reconnect": "重新连接", + "shareWithTeam": "与团队共享", "builtIn": "内置", + "authStatus": { + "connected": "已连接", + "needsAuth": "需要认证", + "configured": "已配置" + }, "deleteWarning": "您确定要删除工具 \"{{toolName}}\" 吗?", "unsavedChanges": "您有未保存的更改,如果不保存就离开将会丢失。", "leaveWithoutSaving": "不保存离开", @@ -188,6 +553,8 @@ "deleteActionWarning": "您确定要删除操作 \"{{name}}\" 吗?", "backToAllTools": "返回所有工具", "save": "保存", + "saving": "保存中...", + "saveFailed": "保存工具配置失败", "fieldName": "字段名称", "fieldType": "字段类型", "filledByLLM": "由LLM填充", @@ -203,6 +570,8 @@ "mcp": { "addServer": "Add MCP Server", "editServer": "Edit Server", + "reconnectServer": "重新连接服务器", + "reenterCredentials": "重新输入您的凭据以测试并更新连接。", "serverName": "Server Name", "serverUrl": "Server URL", "headerName": "Header Name", @@ -215,6 +584,8 @@ "noAuth": "No Authentication", "oauthInProgress": "Waiting for OAuth completion...", "oauthCompleted": "OAuth completed successfully", + "oauthPopupBlocked": "弹出窗口被浏览器拦截。点击下方进行授权:", + "openAuthPage": "打开授权页面", "authType": "Authentication Type", "defaultServerName": "My MCP Server", "authTypes": { @@ -248,6 +619,60 @@ } } }, + "devices": { + "label": "设备", + "subtitle": "配对远程机器,让代理可以通过 shell 命令操作它们。", + "pair": "配对设备", + "empty": "暂无已配对的设备。", + "revoke": "撤销", + "edit": "编辑", + "revokeWarning": "撤销 {{name}}?CLI 将被断开连接。", + "approvalMode": "审批模式", + "approvalAsk": "询问", + "approvalFull": "完全访问", + "online": "在线", + "offline": "离线", + "lastSeen": "上次在线:{{time}}", + "lastSeenLabel": "上次在线", + "seenAgo": "{{time}}前在线", + "nameLabel": "设备名称", + "descriptionLabel": "描述(对代理可见)", + "descriptionPlaceholder": "这台机器是做什么用的?", + "identity": "标识", + "access": "访问", + "connection": "连接", + "hostLabel": "主机", + "dangerZone": "危险区域", + "approvalAskDescription": "每条命令运行前都需要批准。", + "approvalFullDescription": "无需询问即可运行所有命令,安全拒绝列表除外。", + "fullAccessWarning": "完全访问:代理无需询问即可在此机器上运行任何命令。安全拒绝列表仍然有效。", + "deviceIdLabel": "设备 ID", + "offlineHint": "未连接。在该机器上运行 `docsgpt-cli host` 使其上线。", + "dangerZoneDescription": "撤销将停止设备上的守护进程并移除配对。", + "auditTitle": "近期活动", + "auditLoading": "加载中…", + "auditEmpty": "暂无活动。", + "auditDecision": "决定", + "auditExit": "退出码", + "auditDuration": "耗时", + "pairing": { + "title": "配对远程机器", + "subtitle": "在远程机器上运行以下命令,并在提示时输入代码。", + "formSubtitle": "为新设备设置名称、描述和审批模式。", + "nameLabel": "设备名称(可选)", + "namePlaceholder": "例如 edge-server", + "descriptionLabel": "描述(可选)", + "descriptionPlaceholder": "对代理可见,以便其选择正确的设备。", + "fullAccessWarning": "完全访问:代理无需询问即可在此机器上运行任何命令。安全拒绝列表仍然有效。", + "stepOne": "1. 在您的远程机器上运行:", + "installLink": "还没有 docsgpt-cli?安装它", + "stepTwo": "2. 在提示时输入此代码:", + "start": "生成代码", + "starting": "处理中…", + "waitingForCli": "等待 CLI 兑换代码…", + "cancel": "取消" + } + }, "customModels": { "label": "自定义模型", "subtitle": "接入你自己的 OpenAI 兼容模型。", @@ -295,6 +720,15 @@ "capabilities": { "title": "功能", "contextWindowShort": "上下文窗口", + "apiFlavor": "API 协议", + "apiFlavors": { + "chatCompletions": "Chat Completions", + "responses": "Responses" + }, + "reasoningEffort": "推理强度", + "reasoningEfforts": { + "default": "提供商默认" + }, "chips": { "tools": "工具", "structuredOutput": "结构化输出", @@ -335,6 +769,7 @@ "uploadedFiles": "已上传文件", "cancel": "取消", "train": "训练", + "create": "创建", "link": "链接", "urlLink": "URL 链接", "repoUrl": "存储库 URL", @@ -404,6 +839,10 @@ "confluence": { "label": "Confluence", "heading": "从Confluence上传" + }, + "wiki": { + "label": "新建 Wiki", + "heading": "创建动态 Wiki" } }, "connectors": { @@ -507,9 +946,9 @@ "promptName": "提示名称", "promptText": "提示文本", "save": "保存", + "cancel": "取消", "nameExists": "名称已存在", "deleteConfirmation": "您确定要删除提示'{{name}}'吗?", - "cancel": "取消", "placeholderText": "在此輸入提示內容...", "addExamplePlaceholder": "請總結此文本:", "variablesLabel": "變數", @@ -527,7 +966,8 @@ "systemTime": "目前時間 (HH:MM:SS)", "systemTimestamp": "ISO 8601 時間戳記", "systemRequestId": "唯一請求識別碼", - "systemUserId": "目前使用者 ID" + "systemUserId": "目前使用者 ID", + "artifactsLookup": "按 ID 查询工件元数据" }, "learnAboutPrompts": "了解提示 →", "publicPromptEditDisabled": "公共提示無法編輯", @@ -650,14 +1090,38 @@ "remove": "删除附件" }, "retry": "重试", - "reasoning": "推理" + "reasoning": "推理", + "wikiWrite": { + "edited": "已编辑 Wiki", + "create": "已创建 Wiki 页面", + "str_replace": "已编辑 Wiki 页面", + "insert": "已编辑 Wiki 页面", + "delete": "已删除 Wiki 页面", + "rename": "已重命名 Wiki 页面", + "failed": "失败" + } }, "agents": { "title": "代理", "edit": "编辑", "description": "发现并创建结合指令、额外知识和任意技能组合的DocsGPT自定义版本", "newAgent": "新建代理", + "importAgent": "Import", + "exportAgent": "Export as YAML", + "shareWithTeam": "与团队共享", "backToAll": "返回所有代理", + "pageHeader": { + "crumbs": { + "agents": "代理" + }, + "tabs": { + "overview": "概览", + "logs": "日志", + "schedules": "计划" + }, + "fallbackName": "未命名代理", + "subnavAriaLabel": "代理子导航" + }, "searchPlaceholder": "搜索...", "noSearchResults": "未找到代理", "tryDifferentSearch": "请尝试不同的搜索词", @@ -665,8 +1129,13 @@ "all": "全部", "byDocsGPT": "由DocsGPT提供", "byMe": "我的", + "team": "团队", "shared": "与我共享" }, + "teamBadge": { + "editor": "编辑者", + "viewer": "查看者" + }, "sections": { "template": { "title": "由DocsGPT提供", @@ -678,6 +1147,11 @@ "description": "您创建或发布的代理", "emptyState": "您还没有创建任何代理。" }, + "team": { + "title": "团队", + "description": "队友与您共享的代理", + "emptyState": "尚未有代理共享给您的团队。" + }, "shared": { "title": "与我共享", "description": "通过公共链接导入的代理", @@ -697,6 +1171,7 @@ "cancel": "取消", "delete": "删除", "logs": "日志", + "schedules": "计划", "accessDetails": "访问详情", "add": "添加", "moreActions": "更多操作" @@ -731,7 +1206,10 @@ "toolsPopup": { "title": "选择工具", "searchPlaceholder": "搜索工具...", - "noOptionsMessage": "没有可用的工具" + "noOptionsMessage": "没有可用的工具", + "groupBuiltin": "内置", + "groupDefault": "默认", + "groupCustom": "自定义" }, "modelsPopup": { "title": "选择模型", @@ -777,6 +1255,75 @@ "noUsageHistory": "无使用历史", "tableHeader": "代理端点日志" }, + "schedules": { + "title": "代理计划", + "heading": "计划", + "newRecurring": "新建计划", + "closeForm": "关闭表单", + "edit": "编辑", + "recurring": "定期", + "oneTime": "一次性任务", + "noRecurring": "暂无定期计划。", + "noOneTime": "暂无一次性任务。", + "pause": "暂停", + "resume": "恢复", + "runNow": "立即运行", + "delete": "删除", + "deleteConfirm": "删除此计划?其运行历史也将一并删除。此操作无法撤销。", + "cancel": "取消", + "showRuns": "显示运行记录", + "hideRuns": "隐藏运行记录", + "modal": { + "titleCreate": "新建计划", + "titleEdit": "编辑计划", + "namePlaceholder": "任务名称", + "frequency": { + "once": "一次", + "daily": "每天", + "weekly": "每周", + "monthly": "每月", + "yearly": "每年" + }, + "on": "于", + "at": "时间", + "pickDate": "选择日期", + "timezone": "时区", + "timezonePlaceholder": "选择时区", + "timezoneSearchPlaceholder": "搜索时区…", + "timezoneEmpty": "未找到时区。", + "instructionsLabel": "指令", + "instructionsPlaceholder": "在此输入提示词。", + "create": "创建任务", + "save": "保存更改", + "errors": { + "instructionRequired": "指令为必填项。", + "runAtInPast": "请选择未来的日期/时间。" + }, + "days": { + "mon": "周一", + "tue": "周二", + "wed": "周三", + "thu": "周四", + "fri": "周五", + "sat": "周六", + "sun": "周日" + }, + "months": { + "jan": "1月", + "feb": "2月", + "mar": "3月", + "apr": "4月", + "may": "5月", + "jun": "6月", + "jul": "7月", + "aug": "8月", + "sep": "9月", + "oct": "10月", + "nov": "11月", + "dec": "12月" + } + } + }, "shared": { "notFound": "未找到代理。请确保代理已共享。" }, @@ -797,11 +1344,10 @@ "move": "移动", "noFolder": "无文件夹 (根目录)", "backToRoot": "返回", + "currentFolder": "此文件夹", "noSubfolders": "没有子文件夹", "noFolders": "暂无文件夹" - }, - "importAgent": "Import", - "exportAgent": "Export as YAML" + } }, "components": { "fileUpload": { @@ -841,6 +1387,17 @@ "navigation": { "agents": "代理" }, + "teams": { + "switcher": { + "ariaLabel": "切换团队", + "personal": "个人账户", + "personalSubtitle": "个人工作区", + "roleAdmin": "管理员", + "roleMember": "成员", + "manageTeam": "管理团队", + "createTeam": "创建团队" + } + }, "notification": { "ariaLabel": "通知", "closeAriaLabel": "关闭通知" diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index e9892e24..c6847247 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -59,6 +59,7 @@ import ConvertToWikiModal from './ConvertToWikiModal'; import EnableGraphRAGModal from './EnableGraphRAGModal'; import { clearGraphBuild, selectGraphBuilds } from './graphBuildSlice'; import SourceConfigModal from './SourceConfigModal'; +import TestRetrievalModal from './TestRetrievalModal'; type SourceMenuOption = { icon: string | LucideIcon; @@ -123,6 +124,9 @@ export default function Sources({ ); const [configModalState, setConfigModalState] = useState('INACTIVE'); + const [documentToTest, setDocumentToTest] = useState(null); + const [testRetrievalState, setTestRetrievalState] = + useState('INACTIVE'); const [documentToConvert, setDocumentToConvert] = useState(null); const [convertModalState, setConvertModalState] = useState('INACTIVE'); @@ -430,6 +434,20 @@ export default function Sources({ }); } + if (document.id) { + actions.push({ + icon: SearchIcon, + label: t('settings.sources.testRetrieval.action'), + onClick: () => { + setDocumentToTest(document); + setTestRetrievalState('ACTIVE'); + }, + iconWidth: 16, + iconHeight: 16, + variant: 'default', + }); + } + if ( document.id && !isWiki && @@ -530,6 +548,21 @@ export default function Sources({ }; }, [graphRAGAvailable, token]); + // Rendered inside the open source view's own header row. + const testRetrievalAction = documentToView ? ( + + ) : null; + return documentToView ? (
{documentToView.config?.kind === 'wiki' || @@ -542,12 +575,14 @@ export default function Sources({ documentToView.team_access === 'editor' } onBackToDocuments={() => setDocumentToView(undefined)} + headerAction={testRetrievalAction} /> ) : documentToView.config?.kind === 'graphrag' ? ( setDocumentToView(undefined)} + headerAction={testRetrievalAction} /> ) : documentToView.isNested ? ( documentToView.type === 'connector:file' ? ( @@ -555,12 +590,14 @@ export default function Sources({ docId={documentToView.id || ''} sourceName={documentToView.name} onBackToDocuments={() => setDocumentToView(undefined)} + headerAction={testRetrievalAction} /> ) : ( setDocumentToView(undefined)} + headerAction={testRetrievalAction} /> ) ) : ( @@ -568,8 +605,17 @@ export default function Sources({ documentId={documentToView.id || ''} documentName={documentToView.name} handleGoBack={() => setDocumentToView(undefined)} + headerAction={testRetrievalAction} /> )} +
) : (
@@ -924,6 +970,20 @@ export default function Sources({ }} /> + { + setTestRetrievalState(state); + if (state === 'INACTIVE') { + setDocumentToTest(null); + } + }} + document={documentToTest} + hybridAvailable={hybridAvailable} + graphRAGAvailable={graphRAGAvailable} + availableModels={availableModels} + /> + { diff --git a/frontend/src/settings/TestRetrievalModal.test.tsx b/frontend/src/settings/TestRetrievalModal.test.tsx new file mode 100644 index 00000000..59383a61 --- /dev/null +++ b/frontend/src/settings/TestRetrievalModal.test.tsx @@ -0,0 +1,70 @@ +import i18n from 'i18next'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import en from '../locale/en.json'; +import { ScoreBadge, type RetrievedChunk } from './TestRetrievalModal'; + +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + resources: { en: { translation: en } }, + }); +}); + +const chunk = (over: Partial): RetrievedChunk => ({ + rank: 1, + text: 't', + title: 't', + filename: 'f.md', + source: 'f.md', + tokens: 10, + score: null, + score_kind: null, + ...over, +}); + +const render = (c: RetrievedChunk): string => + renderToStaticMarkup( + + + , + ); + +describe('ScoreBadge', () => { + // The three score kinds are NOT interchangeable — a cosine similarity is + // higher-is-better in [0,1] and is what score_threshold compares against, an + // L2 distance is lower-is-better, and an RRF score only ranks hits against + // each other. Each must be labelled as itself. + it('labels a cosine similarity as a similarity', () => { + const html = render( + chunk({ score: 0.8241, score_kind: 'cosine_similarity' }), + ); + expect(html).toContain('similarity'); + expect(html).toContain('0.824'); + expect(html).not.toContain('distance'); + }); + + it('labels a FAISS L2 score as a distance, not a similarity', () => { + const html = render(chunk({ score: 1.6515, score_kind: 'l2_distance' })); + expect(html).toContain('distance'); + expect(html).toContain('1.651'); + expect(html).not.toContain('similarity'); + }); + + it('labels a fused hybrid score as RRF', () => { + const html = render(chunk({ score: 0.0167, score_kind: 'rrf' })); + expect(html).toContain('RRF'); + expect(html).toContain('0.017'); + }); + + it('says "no score" rather than inventing one', () => { + const html = render(chunk({ score: null, score_kind: null })); + expect(html).toContain('no score'); + expect(html).not.toContain('0.000'); + }); +}); diff --git a/frontend/src/settings/TestRetrievalModal.tsx b/frontend/src/settings/TestRetrievalModal.tsx new file mode 100644 index 00000000..6cf5daf1 --- /dev/null +++ b/frontend/src/settings/TestRetrievalModal.tsx @@ -0,0 +1,315 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import userService from '../api/services/userService'; +import Spinner from '../components/Spinner'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Modal } from '../components/ui/modal'; +import { ActiveState, Doc } from '../models/misc'; +import type { Model } from '../models/types'; +import { selectToken } from '../preferences/preferenceSlice'; + +import RetrievalOptions, { + configToOptions, + isPrescreenConfigValid, + optionsToConfig, + type RetrievalOptionsValue, +} from './components/RetrievalOptions'; + +/** One retrieved chunk as returned by POST /api/sources//search. */ +export type RetrievedChunk = { + rank: number; + text: string; + title: string | null; + filename: string | null; + source: string | null; + tokens: number; + // null when the retriever/store produces no comparable score (graphrag's PPR + // ranking, stores without a score seam). + score: number | null; + score_kind: 'cosine_similarity' | 'l2_distance' | 'rrf' | null; +}; + +type RetrievalResult = { + query: string; + retriever: string; + // The config the run actually used, echoed back by the backend. + retrieval: { score_threshold: number | null }; + total: number; + latency_ms: number; + chunks: RetrievedChunk[]; +}; + +interface TestRetrievalModalProps { + modalState: ActiveState; + setModalState: (state: ActiveState) => void; + document: Doc | null; + hybridAvailable?: boolean; + graphRAGAvailable?: boolean; + availableModels?: Model[]; +} + +/** + * Renders a chunk's score with the label its kind earns. The kinds are NOT + * interchangeable: cosine similarity is higher-is-better in [0,1] and is what + * `score_threshold` is compared against; an L2 distance (FAISS) is + * lower-is-better and unbounded; an RRF score only ranks hits against each + * other. Showing a bare number would invite the user to read one as another. + */ +export function ScoreBadge({ chunk }: { chunk: RetrievedChunk }) { + const { t } = useTranslation(); + const tr = (key: string) => t(`settings.sources.testRetrieval.${key}`); + + if (chunk.score === null || chunk.score_kind === null) { + return ( + + {tr('noScore')} + + ); + } + + const label = + chunk.score_kind === 'cosine_similarity' + ? tr('scoreKinds.cosine_similarity') + : chunk.score_kind === 'l2_distance' + ? tr('scoreKinds.l2_distance') + : tr('scoreKinds.rrf'); + + return ( + + {label} + {chunk.score.toFixed(3)} + + ); +} + +export default function TestRetrievalModal({ + modalState, + setModalState, + document, + hybridAvailable = false, + graphRAGAvailable = false, + availableModels = [], +}: TestRetrievalModalProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const tr = (key: string, opts?: Record) => + t(`settings.sources.testRetrieval.${key}`, opts ?? {}); + + const [query, setQuery] = useState(''); + const [options, setOptions] = useState(() => + configToOptions(document?.config), + ); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + + useEffect(() => { + if (modalState === 'ACTIVE') { + setOptions(configToOptions(document?.config)); + setQuery(''); + setResult(null); + setError(null); + setRunning(false); + setExpanded(new Set()); + } + }, [modalState, document]); + + const closeModal = () => setModalState('INACTIVE'); + + const prescreenValid = isPrescreenConfigValid(options); + const canRun = !!document?.id && !!query.trim() && !running && prescreenValid; + + const handleRun = async () => { + if (!canRun || !document?.id) return; + setRunning(true); + setError(null); + try { + const response = await userService.testSourceRetrieval( + document.id, + { + query: query.trim(), + // Send the form's retrieval block as an ad-hoc override — the backend + // never persists it, so the source's saved config is untouched. + retrieval: optionsToConfig(options).retrieval, + }, + token, + ); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data?.success) { + setError(data?.message || tr('errors.failed')); + setResult(null); + return; + } + setResult(data as RetrievalResult); + setExpanded(new Set()); + } catch { + setError(tr('errors.failed')); + setResult(null); + } finally { + setRunning(false); + } + }; + + const toggleExpanded = (rank: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(rank)) next.delete(rank); + else next.add(rank); + return next; + }); + }; + + // A threshold that filtered everything out is the single most likely reason + // for an empty result, so say so instead of a bare "no chunks". Read the + // threshold off the completed run, not the live form — editing the field + // after a run must not rewrite that run's explanation. + const ranThreshold = result?.retrieval?.score_threshold ?? null; + const emptyMessage = ranThreshold + ? tr('emptyWithThreshold', { threshold: ranThreshold }) + : tr('empty'); + + return ( + !o && closeModal()} + hideTitle + title={tr('title')} + size="lg" + mobileVariant="sheet" + // Same width ramp and padding as PromptsModal so the two large modals + // read as one family. + className="bg-card dark:bg-card w-[95vw] max-w-[650px] rounded-2xl px-4 py-4 sm:px-6 sm:py-6 md:max-w-[860px] md:px-8 md:py-6 lg:max-w-[980px]" + contentClassName="max-h-[70vh]" + > +
+

+ {tr('title')} +

+

+ {document?.name + ? tr('subtitle', { name: document.name }) + : tr('subtitleGeneric')} +

+ +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRun(); + }} + /> + +
+ + + +

{tr('notSavedHint')}

+ + {!prescreenValid && ( +
+ {t('settings.sources.configModal.prescreenInvalidHint')} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {result && ( +
+
+ + {tr('resultSummary', { + total: result.total, + retriever: result.retriever, + })} + + {tr('latency', { ms: result.latency_ms })} +
+ + {result.chunks.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + result.chunks.map((chunk) => { + const isOpen = expanded.has(chunk.rank); + return ( +
+
+
+ + #{chunk.rank} + + + {chunk.filename || chunk.title || chunk.source} + +
+
+ + + {chunk.tokens} {t('settings.sources.tokensUnit')} + +
+
+ {/* Chunks routinely start and end with blank lines; left + in, the collapsed clamp spends its 3 lines on nothing + and the preview looks empty. */} +

+ {chunk.text.trim()} +

+ +
+ ); + }) + )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/settings/components/RetrievalOptions.tsx b/frontend/src/settings/components/RetrievalOptions.tsx index f4afb3a7..2d05dd14 100644 --- a/frontend/src/settings/components/RetrievalOptions.tsx +++ b/frontend/src/settings/components/RetrievalOptions.tsx @@ -323,6 +323,12 @@ type RetrievalOptionsProps = { graphRAGAvailable?: boolean; // Models for the graph extraction-model picker, same shape as the agent form. availableModels?: Model[]; + // Shows only the knobs that change what a query retrieves, hiding the + // ingest-time groups (chunking, graph extraction) and `exposure` (which picks + // *when* a source is searched at answer time, not what comes back). Used by + // the retrieval tester, where those knobs cannot affect the result and + // showing them would imply they do. + queryOnly?: boolean; }; /** @@ -338,6 +344,7 @@ export default function RetrievalOptions({ hybridAvailable = false, graphRAGAvailable = false, availableModels = [], + queryOnly = false, }: RetrievalOptionsProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -486,50 +493,56 @@ export default function RetrievalOptions({ )} - - - setRetrieval({ rephrase_query: checked }) - } - /> - - - - + setRetrieval({ exposure: v as RetrievalExposure }) + } > - - - - - {tr('retrieval.exposures.prefetch')} - - - {tr('retrieval.exposures.agentic_tool')} - - - - + + + + + + {tr('retrieval.exposures.prefetch')} + + + {tr('retrieval.exposures.agentic_tool')} + + + + + )} {/* Graph extraction group (graphrag only; re-ingest required to apply) */} - {isGraphRAG && ( + {isGraphRAG && !queryOnly && (
@@ -676,7 +689,7 @@ export default function RetrievalOptions({ )} {/* Chunking group (re-ingest required) */} -
+
diff --git a/tests/api/user/sources/test_retrieval_test.py b/tests/api/user/sources/test_retrieval_test.py new file mode 100644 index 00000000..21b2989d --- /dev/null +++ b/tests/api/user/sources/test_retrieval_test.py @@ -0,0 +1,484 @@ +"""Tests for application/api/user/sources/retrieval_test.py.""" + +import json +import uuid +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + + +@pytest.fixture +def app(): + return Flask(__name__) + + +@contextmanager +def _patch_db(conn): + @contextmanager + def _yield(): + yield conn + + with patch("application.api.user.sources.retrieval_test.db_readonly", _yield): + yield + + +def _grant_team_access(pg_conn, owner, member, source_id, access_level): + from application.storage.db.repositories.team_members import ( + TeamMembersRepository, + ) + from application.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, + ) + from application.storage.db.repositories.teams import TeamsRepository + + team = TeamsRepository(pg_conn).create( + "Acme", f"acme-{uuid.uuid4().hex[:8]}", owner + ) + TeamMembersRepository(pg_conn).add_member(team["id"], member, role="team_member") + TeamResourceGrantsRepository(pg_conn).grant( + team["id"], + "source", + source_id, + owner_id=owner, + granted_by=owner, + access_level=access_level, + ) + + +def _seed_source(pg_conn, user="u", name="src", config=None): + from application.storage.db.repositories.sources import SourcesRepository + + repo = SourcesRepository(pg_conn) + src = repo.create(name, user_id=user) + if config is not None: + repo.update(str(src["id"]), user, {"config": config}) + src = repo.get_any(str(src["id"]), user) + return src + + +def _post(app, source_id, body, user="u"): + from application.api.user.sources.retrieval_test import SourceSearch + + with app.test_request_context( + f"/api/sources/{source_id}/search", + method="POST", + data=json.dumps(body), + content_type="application/json", + ): + from flask import request + + request.decoded_token = {"sub": user} + return SourceSearch().post(source_id) + + +class TestSourceSearchGuards: + def test_returns_401_unauthenticated(self, app): + from application.api.user.sources.retrieval_test import SourceSearch + + with app.test_request_context( + "/api/sources/abc/search", + method="POST", + data=json.dumps({"query": "hi"}), + content_type="application/json", + ): + from flask import request + + request.decoded_token = None + response = SourceSearch().post("abc") + assert response.status_code == 401 + + def test_returns_400_without_query(self, app, pg_conn): + src = _seed_source(pg_conn, user="u-noq") + with _patch_db(pg_conn): + response = _post(app, str(src["id"]), {"query": " "}, user="u-noq") + assert response.status_code == 400 + + def test_returns_400_for_overlong_query(self, app, pg_conn): + from application.api.user.sources.retrieval_test import MAX_QUERY_LENGTH + + src = _seed_source(pg_conn, user="u-long") + with _patch_db(pg_conn): + response = _post( + app, + str(src["id"]), + {"query": "x" * (MAX_QUERY_LENGTH + 1)}, + user="u-long", + ) + assert response.status_code == 400 + + def test_returns_404_when_source_missing(self, app, pg_conn): + with _patch_db(pg_conn): + response = _post( + app, + "00000000-0000-0000-0000-000000000000", + {"query": "hi"}, + user="u", + ) + assert response.status_code == 404 + + def test_returns_400_for_invalid_retrieval_config(self, app, pg_conn): + src = _seed_source(pg_conn, user="u-bad") + with _patch_db(pg_conn): + response = _post( + app, + str(src["id"]), + # chunks must be >= 1 (RetrievalConfig._bounded_chunks) + {"query": "hi", "retrieval": {"chunks": 0}}, + user="u-bad", + ) + assert response.status_code == 400 + + def test_rejects_unknown_retrieval_field(self, app, pg_conn): + src = _seed_source(pg_conn, user="u-extra") + with _patch_db(pg_conn): + response = _post( + app, + str(src["id"]), + # RetrievalConfig forbids extras — a typo must not silently no-op + {"query": "hi", "retrieval": {"chunkz": 5}}, + user="u-extra", + ) + assert response.status_code == 400 + + +class TestSourceSearchAccess: + """Read access = owner or any team grant, matching the wiki/graph reads.""" + + def test_stranger_gets_404(self, app, pg_conn): + src = _seed_source(pg_conn, user="u-owner-x") + + fake = MagicMock() + fake.search.return_value = [{"text": "secret", "filename": "f.md"}] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post( + app, str(src["id"]), {"query": "q"}, user="u-stranger-x" + ) + + assert response.status_code == 404 + # Never even touch the vector store for a source we can't read. + dispatcher.assert_not_called() + + def test_team_viewer_can_run_a_test(self, app, pg_conn): + owner = "alice-retrieval" + viewer = "bob-retrieval-viewer" + src = _seed_source(pg_conn, user=owner) + _grant_team_access(pg_conn, owner, viewer, str(src["id"]), "viewer") + + fake = MagicMock() + fake.search.return_value = [{"text": "shared chunk", "filename": "f.md"}] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ): + response = _post(app, str(src["id"]), {"query": "q"}, user=viewer) + + assert response.status_code == 200 + assert response.json["total"] == 1 + + +class TestSourceSearchRetrieval: + def test_returns_ranked_chunks_with_scores(self, app, pg_conn): + user = "u-search" + src = _seed_source(pg_conn, user=user) + + fake = MagicMock() + fake.search.return_value = [ + { + "text": "chunk one", + "title": "T1", + "filename": "f.md", + "source": "f.md", + "score": 0.82, + "score_kind": "cosine_similarity", + }, + { + "text": "chunk two", + "title": "T2", + "filename": "f.md", + "source": "f.md", + "score": 0.71, + "score_kind": "cosine_similarity", + }, + ] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ): + response = _post(app, str(src["id"]), {"query": "what runs"}, user=user) + + assert response.status_code == 200 + data = response.json + assert data["total"] == 2 + assert [c["rank"] for c in data["chunks"]] == [1, 2] + assert data["chunks"][0]["score"] == 0.82 + assert data["chunks"][0]["score_kind"] == "cosine_similarity" + assert data["chunks"][0]["tokens"] > 0 + fake.search.assert_called_once_with("what runs") + + def test_unscored_retriever_yields_null_scores(self, app, pg_conn): + """graphrag ranks by PPR and attaches no score — it must not be faked.""" + user = "u-noscore" + src = _seed_source(pg_conn, user=user) + + fake = MagicMock() + fake.search.return_value = [{"text": "graph chunk", "filename": "g.md"}] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ): + response = _post(app, str(src["id"]), {"query": "q"}, user=user) + + assert response.status_code == 200 + assert response.json["chunks"][0]["score"] is None + assert response.json["chunks"][0]["score_kind"] is None + + def test_ad_hoc_config_is_passed_to_dispatcher_and_not_persisted( + self, app, pg_conn + ): + from application.storage.db.repositories.sources import SourcesRepository + + user = "u-adhoc" + src = _seed_source(pg_conn, user=user) + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post( + app, + str(src["id"]), + {"query": "q", "retrieval": {"chunks": 7, "score_threshold": 0.5}}, + user=user, + ) + + assert response.status_code == 200 + kwargs = dispatcher.call_args.kwargs + assert kwargs["chunks"] == 7 + assert kwargs["include_scores"] is True + assert kwargs["usage_source"] == "retrieval_test" + retrieval = kwargs["sources"][0]["retrieval"] + assert retrieval.chunks == 7 + assert retrieval.score_threshold == 0.5 + # Echoed back so the UI can show what actually ran. + assert response.json["retrieval"]["chunks"] == 7 + + # The source's stored config must be untouched by a test run. + stored = SourcesRepository(pg_conn).get_any(str(src["id"]), user) + assert (stored.get("config") or {}).get("retrieval") is None + + def test_falls_back_to_saved_config(self, app, pg_conn): + user = "u-saved" + src = _seed_source( + pg_conn, + user=user, + config={"retrieval": {"chunks": 9, "retriever": "classic"}}, + ) + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post(app, str(src["id"]), {"query": "q"}, user=user) + + assert response.status_code == 200 + assert dispatcher.call_args.kwargs["chunks"] == 9 + assert response.json["retrieval"]["chunks"] == 9 + + def test_returns_500_when_retrieval_raises(self, app, pg_conn): + user = "u-boom" + src = _seed_source(pg_conn, user=user) + + fake = MagicMock() + fake.search.side_effect = RuntimeError("vector store down") + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ): + response = _post(app, str(src["id"]), {"query": "q"}, user=user) + + assert response.status_code == 500 + + +class TestPrescreenCostCeiling: + """One request must not be able to fan out into hundreds of LLM calls.""" + + def test_rejects_a_prescreen_config_that_needs_too_many_llm_calls( + self, app, pg_conn + ): + src = _seed_source(pg_conn, user="u-costly") + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher" + ) as dispatcher: + response = _post( + app, + str(src["id"]), + { + "query": "q", + # 500 candidates screened one at a time = 500 provider calls. + "retrieval": { + "chunks": 1, + "prescreen": { + "candidate_k": 500, + "batch_size": 1, + "max_keep": 1, + }, + }, + }, + user="u-costly", + ) + + assert response.status_code == 400 + dispatcher.assert_not_called() + + def test_allows_a_prescreen_config_within_the_ceiling(self, app, pg_conn): + src = _seed_source(pg_conn, user="u-ok") + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post( + app, + str(src["id"]), + { + "query": "q", + # 40 candidates / batches of 10 = 4 calls. + "retrieval": { + "chunks": 2, + "prescreen": { + "candidate_k": 40, + "batch_size": 10, + "max_keep": 8, + }, + }, + }, + user="u-ok", + ) + + assert response.status_code == 200 + dispatcher.assert_called_once() + + def test_saved_config_is_testable_even_above_the_ceiling(self, app, pg_conn): + """The client echoes the on-screen config back, so an untouched saved + config arrives in the body. It must still be judged 'saved', or a source + configured beyond the ceiling could never be tested — which is the whole + point of the endpoint.""" + expensive = { + "chunks": 2, + "prescreen": {"candidate_k": 300, "batch_size": 10, "max_keep": 8}, + } + src = _seed_source( + pg_conn, user="u-saved-costly", config={"retrieval": expensive} + ) + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post( + app, + str(src["id"]), + # 30 batches — over the ad-hoc ceiling, but it IS the saved config. + {"query": "q", "retrieval": expensive}, + user="u-saved-costly", + ) + + assert response.status_code == 200 + dispatcher.assert_called_once() + + def test_editing_the_saved_config_upward_is_still_capped(self, app, pg_conn): + saved = { + "chunks": 2, + "prescreen": {"candidate_k": 300, "batch_size": 10, "max_keep": 8}, + } + src = _seed_source(pg_conn, user="u-edit-costly", config={"retrieval": saved}) + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.Dispatcher" + ) as dispatcher: + response = _post( + app, + str(src["id"]), + { + "query": "q", + # Same source, but the caller cranked batch_size down to 1 + # → 300 LLM calls. That is ad-hoc, and capped. + "retrieval": { + "chunks": 2, + "prescreen": { + "candidate_k": 300, + "batch_size": 1, + "max_keep": 8, + }, + }, + }, + user="u-edit-costly", + ) + + assert response.status_code == 400 + dispatcher.assert_not_called() + + +class TestModelResolution: + def test_passes_the_instance_default_model(self, app, pg_conn): + """Without a real model id the prescreen stage calls the provider with a + placeholder, fails, and silently keeps every candidate.""" + src = _seed_source(pg_conn, user="u-model") + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.get_default_model_id", + return_value="gpt-4o", + ), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post(app, str(src["id"]), {"query": "q"}, user="u-model") + + assert response.status_code == 200 + assert dispatcher.call_args.kwargs["model_id"] == "gpt-4o" + + def test_no_configured_models_leaves_the_retriever_default(self, app, pg_conn): + """get_default_model_id() is None on an instance with no models; passing + that through would override the retriever's own default with None.""" + src = _seed_source(pg_conn, user="u-nomodel") + + fake = MagicMock() + fake.search.return_value = [] + + with _patch_db(pg_conn), patch( + "application.api.user.sources.retrieval_test.get_default_model_id", + return_value=None, + ), patch( + "application.api.user.sources.retrieval_test.Dispatcher", + return_value=fake, + ) as dispatcher: + response = _post(app, str(src["id"]), {"query": "q"}, user="u-nomodel") + + assert response.status_code == 200 + assert "model_id" not in dispatcher.call_args.kwargs diff --git a/tests/retriever/test_graph_rag.py b/tests/retriever/test_graph_rag.py index 65649879..294eb524 100644 --- a/tests/retriever/test_graph_rag.py +++ b/tests/retriever/test_graph_rag.py @@ -416,3 +416,36 @@ class TestGetChunkTexts: store, cursor = self._store_with_mock_conn() assert store.get_chunk_texts("sid", []) == {} cursor.execute.assert_not_called() + + +class TestGraphRAGTopK: + """A prescreen source elsewhere in the group inflates ``chunks``; a graph + source must still contribute only its own top-k.""" + + @patch("application.retriever.graph_rag.num_tokens_from_string", return_value=10) + @patch("application.retriever.graph_rag.GraphStore") + @patch("application.retriever.graph_rag.graphrag_available", return_value=True) + def test_inflated_chunks_do_not_raise_a_graph_source_top_k( + self, _avail, mock_store_cls, _tok, _patch_llm_creator, _patch_embed + ): + nodes = [{"id": f"n{i}", "doc_freq": 1} for i in range(1, 5)] + edges = [ + {"src_node_id": "n1", "dst_node_id": "n2", "weight": 1.0}, + {"src_node_id": "n2", "dst_node_id": "n3", "weight": 1.0}, + {"src_node_id": "n3", "dst_node_id": "n4", "weight": 1.0}, + ] + node_chunks = {f"n{i}": [f"c{i}"] for i in range(1, 5)} + chunk_texts = {f"c{i}": f"text {i}" for i in range(1, 5)} + seed_rows = [{"id": "n1", "distance": 0.0}] + mock_store_cls.return_value = _store_with_graph( + nodes, edges, node_chunks, chunk_texts, seed_rows + ) + + # What the Dispatcher does when another source in the group prescreens + # at candidate_k=40: chunks inflated to 40, base_chunks left at the real 2. + rag = _make_retriever(chunks=40) + rag.base_chunks = 2 + + docs = rag._get_data() + + assert len(docs) == 2 diff --git a/tests/retriever/test_hybrid.py b/tests/retriever/test_hybrid.py index 7f5efa5f..7db1c719 100644 --- a/tests/retriever/test_hybrid.py +++ b/tests/retriever/test_hybrid.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest -from application.retriever.hybrid_rag import HybridRetriever, reciprocal_rank_fusion +from application.retriever.hybrid_rag import fuse_with_scores, HybridRetriever from application.retriever.retriever_creator import RetrieverCreator @@ -53,27 +53,27 @@ class TestReciprocalRankFusion: vector_hits = [only_vec, shared] keyword_hits = [shared, only_kw] - fused = reciprocal_rank_fusion(vector_hits, keyword_hits) + fused = [doc for doc, _ in fuse_with_scores(vector_hits, keyword_hits)] assert fused[0].page_content == "shared" assert {d.page_content for d in fused} == {"shared", "vec_only", "kw_only"} def test_empty_keyword_is_vector_only_order(self): vector_hits = [_make_doc("a", source="a"), _make_doc("b", source="b")] - fused = reciprocal_rank_fusion(vector_hits, []) + fused = [doc for doc, _ in fuse_with_scores(vector_hits, [])] assert [d.page_content for d in fused] == ["a", "b"] def test_dedupes_same_doc(self): d_vec = _make_doc("same", source="same") d_kw = _make_doc("same", source="same") - fused = reciprocal_rank_fusion([d_vec], [d_kw]) + fused = [doc for doc, _ in fuse_with_scores([d_vec], [d_kw])] assert len(fused) == 1 def test_higher_keyword_rank_can_promote(self): # Vector top is "v0"; keyword strongly favours "kw" (rank 0 vs v0's rank 1). v0 = _make_doc("v0", source="v0") kw = _make_doc("kw", source="kw") - fused = reciprocal_rank_fusion([v0, kw], [kw]) + fused = [doc for doc, _ in fuse_with_scores([v0, kw], [kw])] assert fused[0].page_content == "kw" diff --git a/tests/retriever/test_include_scores.py b/tests/retriever/test_include_scores.py new file mode 100644 index 00000000..65fea66f --- /dev/null +++ b/tests/retriever/test_include_scores.py @@ -0,0 +1,206 @@ +"""Score passthrough (``include_scores``) across the retrievers. + +The retrieval-test endpoint needs each chunk's raw store score; the answer +pipeline does not. These tests pin both halves of that: opted in, the score and +its kind ride on every doc; opted out (the default everywhere else), the doc +dicts are exactly what they were before the flag existed. +""" + +from unittest.mock import Mock, patch + +import pytest + +from application.retriever.classic_rag import ClassicRAG +from application.retriever.hybrid_rag import HybridRetriever + + +@pytest.fixture +def _patch_llm_creator(mock_llm, monkeypatch): + monkeypatch.setattr( + "application.retriever.classic_rag.LLMCreator.create_llm", + Mock(return_value=mock_llm), + ) + return mock_llm + + +def _make_doc(page_content, source="s", title="t"): + doc = Mock() + doc.page_content = page_content + doc.metadata = {"title": title, "source": source} + return doc + + +def _make_store(score_kind="cosine_similarity"): + store = Mock() + store.score_kind = score_kind + store.search.return_value = [_make_doc("hit one"), _make_doc("hit two")] + store.search_with_scores.return_value = [ + (_make_doc("hit one"), 0.82), + (_make_doc("hit two"), 0.71), + ] + store.keyword_search.return_value = [] + return store + + +def _retrieve(retriever_cls, store, **overrides): + kwargs = dict( + source={"question": "q", "active_docs": ["vs1"]}, + chat_history=None, + prompt="", + chunks=2, + doc_token_limit=50000, + model_id="test-model", + llm_name="openai", + api_key="fake", + decoded_token={"sub": "user1"}, + ) + kwargs.update(overrides) + retriever = retriever_cls(**kwargs) + with patch( + "application.retriever.classic_rag.VectorCreator.create_vectorstore", + return_value=store, + ): + return retriever.search("q") + + +@pytest.mark.unit +class TestClassicRAGScores: + def test_off_by_default_leaves_docs_untouched(self, _patch_llm_creator): + """The answer pipeline must see exactly the doc dict it saw before.""" + store = _make_store() + docs = _retrieve(ClassicRAG, store) + + assert docs + assert set(docs[0]) == {"text", "title", "source", "filename"} + store.search.assert_called_once() + store.search_with_scores.assert_not_called() + + def test_include_scores_attaches_score_and_kind(self, _patch_llm_creator): + store = _make_store() + docs = _retrieve(ClassicRAG, store, include_scores=True) + + assert [d["score"] for d in docs] == [0.82, 0.71] + assert {d["score_kind"] for d in docs} == {"cosine_similarity"} + store.search_with_scores.assert_called_once() + store.search.assert_not_called() + + def test_unscored_store_yields_null_scores(self, _patch_llm_creator): + """A store with no score seam reports None rather than a fabricated 0.""" + store = _make_store(score_kind=None) + store.search_with_scores.return_value = [ + (_make_doc("hit one"), None), + (_make_doc("hit two"), None), + ] + docs = _retrieve(ClassicRAG, store, include_scores=True) + + assert [d["score"] for d in docs] == [None, None] + assert [d["score_kind"] for d in docs] == [None, None] + + +@pytest.mark.unit +class TestTopK: + """``chunks`` is the final top-k, not just a floor on the fetch size.""" + + def test_returns_at_most_chunks_docs(self, _patch_llm_creator): + store = Mock() + store.score_kind = None + store.search.return_value = [_make_doc(f"hit {i}") for i in range(20)] + store.keyword_search.return_value = [] + + docs = _retrieve(ClassicRAG, store, chunks=2) + + assert len(docs) == 2 + assert [d["text"] for d in docs] == ["hit 0", "hit 1"] + # The over-fetch itself is intact — only the tail is dropped. + assert store.search.call_args.kwargs["k"] == 20 + + def test_token_budget_still_caps_below_top_k(self, _patch_llm_creator): + """The budget remains the harder of the two limits.""" + store = Mock() + store.score_kind = None + store.search.return_value = [_make_doc("word " * 500) for _ in range(10)] + store.keyword_search.return_value = [] + + docs = _retrieve(ClassicRAG, store, chunks=10, doc_token_limit=600) + + assert 0 < len(docs) < 10 + + def test_hybrid_respects_top_k_too(self, _patch_llm_creator): + store = Mock() + store.score_kind = None + store.search.return_value = [_make_doc(f"hit {i}") for i in range(20)] + store.keyword_search.return_value = [] + + docs = _retrieve(HybridRetriever, store, chunks=3) + + assert len(docs) == 3 + + +@pytest.mark.unit +class TestHybridScores: + def test_reports_rrf_not_the_store_kind(self, _patch_llm_creator): + """RRF fuses two rankings — the fused number is not the store's cosine + score, so it must not be labelled as one.""" + store = _make_store() + store.keyword_search.return_value = [_make_doc("hit two")] + + docs = _retrieve(HybridRetriever, store, include_scores=True) + + assert {d["score_kind"] for d in docs} == {"rrf"} + # A doc found by both searches outranks one found by vector search alone. + assert docs[0]["text"] == "hit two" + assert docs[0]["score"] > docs[1]["score"] + + def test_off_by_default_leaves_docs_untouched(self, _patch_llm_creator): + store = _make_store() + docs = _retrieve(HybridRetriever, store) + + assert docs + assert set(docs[0]) == {"text", "title", "source", "filename"} + + +@pytest.mark.unit +class TestCandidateKDoesNotLeakAcrossSources: + """A prescreen source's inflated fetch must not become its neighbour's top-k. + + The Dispatcher raises the group's ``chunks`` to the prescreen candidate_k so + the fetch is big enough to screen. A source in that same group with no + override still has to fall back to the group's *real* top-k. + """ + + def test_default_source_uses_base_chunks_not_the_inflated_fetch( + self, _patch_llm_creator + ): + store = Mock() + store.score_kind = None + store.search.return_value = [_make_doc(f"hit {i}") for i in range(40)] + store.keyword_search.return_value = [] + + # What the Dispatcher does for a group whose other source prescreens at + # candidate_k=40: chunks inflated to 40, base_chunks kept at the real 2. + retriever = ClassicRAG( + source={"question": "q", "active_docs": ["vs1", "vs2"]}, + chunks=40, + doc_token_limit=50000, + decoded_token={"sub": "u"}, + ) + retriever.base_chunks = 2 + + with patch( + "application.retriever.classic_rag.VectorCreator.create_vectorstore", + return_value=store, + ): + docs = retriever.search("q") + + # 2 sources sharing a top-k of 2 → 1 chunk each, not 20 each. + assert len(docs) == 2 + + def test_absent_base_chunks_keeps_chunks_as_the_top_k(self, _patch_llm_creator): + store = Mock() + store.score_kind = None + store.search.return_value = [_make_doc(f"hit {i}") for i in range(40)] + store.keyword_search.return_value = [] + + docs = _retrieve(ClassicRAG, store, chunks=4) + + assert len(docs) == 4 diff --git a/tests/vectorstore/test_base.py b/tests/vectorstore/test_base.py index 7e0346aa..12d3ef79 100644 --- a/tests/vectorstore/test_base.py +++ b/tests/vectorstore/test_base.py @@ -384,3 +384,34 @@ class TestBaseVectorStore: result = store._get_embeddings("some_custom_embedding") assert result is mock_emb mock_get_instance.assert_called_with("some_custom_embedding") + + +@pytest.mark.unit +class TestSearchWithScoresDefault: + def test_pairs_hits_with_none(self): + """A store that reports no score still satisfies the contract, so the + retriever never has to special-case it.""" + from application.vectorstore.base import BaseVectorStore + + class _Store(BaseVectorStore): + def search(self, question, k=2, *args, **kwargs): + return ["a", "b"] + + def add_texts(self, texts, metadatas=None, *args, **kwargs): + return [] + + store = _Store() + assert store.score_kind is None + assert store.search_with_scores("q", k=2) == [("a", None), ("b", None)] + + def test_handles_store_returning_none(self): + from application.vectorstore.base import BaseVectorStore + + class _Store(BaseVectorStore): + def search(self, question, k=2, *args, **kwargs): + return None + + def add_texts(self, texts, metadatas=None, *args, **kwargs): + return [] + + assert _Store().search_with_scores("q") == [] diff --git a/tests/vectorstore/test_faiss.py b/tests/vectorstore/test_faiss.py index 7646f329..b8840de9 100644 --- a/tests/vectorstore/test_faiss.py +++ b/tests/vectorstore/test_faiss.py @@ -562,3 +562,67 @@ class TestFaissStoreAssertEmbeddingDimensionsMatch: # Should not raise since embedding name is not the huggingface one store = FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) assert store is not None + + +@pytest.mark.unit +class TestFaissSearchWithScores: + @staticmethod + def _build(mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds): + mock_settings.EMBEDDINGS_NAME = "test_model" + mock_get_emb.return_value = Mock(dimension=3) + mock_faiss.from_documents.return_value = ds + mock_storage_creator.get_storage.return_value = Mock() + + from application.vectorstore.faiss import FaissStore + + return FaissStore(source_id="t", embeddings_key="k", docs_init=[Mock()]) + + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_reports_l2_distance_and_drops_threshold( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + doc = Mock(page_content="text1", metadata={"source": "a"}) + ds = Mock(index=Mock(d=3)) + ds.similarity_search_with_score.return_value = [(doc, 0.42)] + store = self._build( + mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds + ) + + results = store.search_with_scores("query", k=5, score_threshold=0.9) + + # LangChain's FAISS ranks by L2 distance, NOT cosine similarity — the + # label must say so, and score_threshold must be dropped (FAISS has no + # such knob and would crash on it). + assert store.score_kind == "l2_distance" + ds.similarity_search_with_score.assert_called_once_with("query", k=5) + assert results == [(doc, 0.42)] + + @patch("application.vectorstore.faiss.StorageCreator") + @patch("application.vectorstore.faiss.FAISS") + @patch.object( + __import__("application.vectorstore.base", fromlist=["BaseVectorStore"]).BaseVectorStore, + "_get_embeddings", + ) + @patch("application.vectorstore.faiss.settings") + def test_does_not_mutate_docstore_metadata( + self, mock_settings, mock_get_emb, mock_faiss, mock_storage_creator + ): + # similarity_search_with_score hands back the LIVE docstore Documents; + # writing a score into their metadata would pollute the in-memory index + # and could be persisted back to storage by a later add_chunk. + doc = Mock(page_content="text1", metadata={"source": "a"}) + ds = Mock(index=Mock(d=3)) + ds.similarity_search_with_score.return_value = [(doc, 0.42)] + store = self._build( + mock_faiss, mock_get_emb, mock_settings, mock_storage_creator, ds + ) + + store.search_with_scores("query", k=1) + + assert doc.metadata == {"source": "a"} diff --git a/tests/vectorstore/test_mongodb.py b/tests/vectorstore/test_mongodb.py index a3030edf..20291253 100644 --- a/tests/vectorstore/test_mongodb.py +++ b/tests/vectorstore/test_mongodb.py @@ -287,3 +287,40 @@ class TestMongoDBVectorStoreDeleteChunk: result = store.delete_chunk("bad_id") assert result is False + + +@pytest.mark.unit +class TestMongoDBSearchWithScores: + def test_reports_vector_search_score(self): + store, mock_collection, _ = _make_mongodb_store() + mock_collection.aggregate.return_value = iter( + [ + { + "_id": "id1", + "text": "hello", + "embedding": [0.1], + "source": "a", + "_score": 0.83, + } + ] + ) + + results = store.search_with_scores("query", k=1) + + assert store.score_kind == "cosine_similarity" + assert results[0][0].page_content == "hello" + assert results[0][1] == pytest.approx(0.83) + # The score must not leak into metadata — it rides alongside the doc. + assert "_score" not in results[0][0].metadata + + def test_score_is_added_even_without_a_threshold(self): + """The $addFields stage is unconditional, so an unfiltered search still + carries a score (the whole point of the retrieval tester).""" + store, mock_collection, _ = _make_mongodb_store() + mock_collection.aggregate.return_value = iter([]) + + store.search_with_scores("query", k=1) + + 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) diff --git a/tests/vectorstore/test_pgvector.py b/tests/vectorstore/test_pgvector.py index 78db6de2..0611653c 100644 --- a/tests/vectorstore/test_pgvector.py +++ b/tests/vectorstore/test_pgvector.py @@ -403,3 +403,38 @@ class TestPGVectorStoreConnection: store.__del__() mock_conn.close.assert_called_once() + + +@pytest.mark.unit +class TestPGVectorSearchWithScores: + def test_reports_cosine_similarity(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.fetchall.return_value = [ + ("close", {"source": "a.txt"}, 0.10), + ("far", {"source": "b.txt"}, 0.40), + ] + + results = store.search_with_scores("query", k=2) + + assert store.score_kind == "cosine_similarity" + assert [doc.page_content for doc, _ in results] == ["close", "far"] + # similarity = 1 - cosine distance, the quantity score_threshold uses. + assert results[0][1] == pytest.approx(0.90) + assert results[1][1] == pytest.approx(0.60) + + def test_honours_score_threshold(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.fetchall.return_value = [ + ("close", {}, 0.10), # sim 0.90 → kept + ("far", {}, 0.40), # sim 0.60 → dropped + ] + + results = store.search_with_scores("query", k=5, score_threshold=0.85) + + assert [doc.page_content for doc, _ in results] == ["close"] + + def test_returns_empty_on_error(self): + store, _, mock_cursor, _ = _make_store() + mock_cursor.execute.side_effect = Exception("connection lost") + + assert store.search_with_scores("query") == []