feat(reasoning): live docling-agent runner + UX polish

Backend — live runner
- New `POST /api/documents/:id/rag` endpoint. Loads `document_json` from
  SQLite, reconstructs the DoclingDocument, wraps the model id in
  `ModelIdentifier(ollama_name=...)`, and calls `agent._rag_loop`
  off-thread (blocking sync call). Returns a `RAGResult` in the shape
  the existing v1 import path already consumes, so the frontend overlay
  is fully reused.
- `_rag_loop` is private upstream; we call it because `run()` wraps the
  answer in a synthetic DoclingDocument and drops the iteration trace.
- Settings: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`. Router mounts
  unconditionally; handler 503s when the flag is off or deps aren't
  installed. `rag_available` surfaced in `/api/health`.
- Maps known docling-agent bugs to readable HTTP errors: 502 with
  "the model couldn't produce a parseable answer" when `_rag_loop`
  raises `IndexError` from `find_json_dicts([])[0]` after 3 + 3
  rejection-sampling retries (model-dependent).
- Tests: 11 cases (flag off, query empty, no analysis, happy path,
  model_id wrap, Ollama env, IndexError → 502, other errors → 500,
  deps missing → 503).

Backend — bug fix
- Default `BATCH_PAGE_SIZE` flipped from `10` to `0` to match the
  dataclass default. The old default silently dropped `document_json`
  (see `domain/services.merge_results`) for any doc > 10 pages, which
  broke the reasoning tunnel. Set `BATCH_PAGE_SIZE>0` explicitly on
  memory-constrained deploys if batching is wanted.

Frontend — runner UX
- `features/reasoning/api.ts:runReasoning()` — POST wrapper.
- `RunReasoningDialog.vue` — query textarea + optional model_id
  override. Blocks close while running, 20-40s loading state,
  synthesises a sidecar-shaped envelope so the panel surfaces query +
  model the same way an imported trace would.
- `ReasoningWorkspace.vue` — primary "Run reasoning" button; "Import
  trace" relegated to ghost secondary.
- Store: `runDialogOpen`, `running`, `setRunning`.

Frontend — answer polish
- Answer rendered through `marked` + DOMPurify (models emit markdown
  lists; `pre-wrap` rendered them as plain "1. …" strings).
- Dedicated answer block with orange border, "ANSWER" label, "Copy"
  button (clipboard + "Copied ✓" feedback).
- IterationCard: drop the duplicate `response` block (the main answer
  is authoritative); style reasons equal to `"fallback"` (docling-agent
  `select_from_failure` placeholder) as italic muted "— no structured
  rationale".

Frontend — node details contents
- Clicking a SectionHeader (or any node with compound children) lists
  its contained elements in `NodeDetailsPanel` under a new "Contents"
  block. Children come from the same `parentMap` used for Cytoscape
  compound parenting (explicit PARENT_OF + synthetic section scope),
  inverted once and cached as a computed.
- Click a child row → pan the viewport to it + swap the selection.

Housekeeping
- `cytoscape-navigator` removed from `package-lock.json` (follow-up
  from the minimap removal in the previous commit).
This commit is contained in:
Pier-Jean Malandrino 2026-04-21 17:11:54 +02:00
parent 8103460e9c
commit 5b7700df83
16 changed files with 1213 additions and 44 deletions

View file

@ -0,0 +1,148 @@
"""Reasoning API — live `docling-agent` runner (R&D).
`POST /api/documents/:id/rag` invokes `docling-agent`'s Chunkless RAG loop
against the stored `DoclingDocument` and returns a `RAGResult` in the same
shape the v1 import dialog already consumes so the frontend overlay code
is fully reused.
Constraints (docling-agent v0.1.0):
- Backend is hard-wired to Ollama (`setup_local_session` in
`docling_agent/agent_models.py`). Set `OLLAMA_HOST` + `RAG_MODEL_ID` in the
environment. No OpenAI/WatsonX path without forking upstream.
- We call the private `_rag_loop` because `DoclingRAGAgent.run()` wraps the
answer in a synthetic `DoclingDocument` and never returns the iteration
trace. This is brittle track upstream for a public hook.
- Sync blocking call offloaded to a thread so we don't stall the event loop.
No streaming at this step (see design doc §7 for v2 SSE plan).
"""
from __future__ import annotations
import asyncio
import logging
import os
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from infra.settings import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["reasoning"])
class RagRunRequest(BaseModel):
query: str
# Optional per-run override; falls back to settings.rag_model_id.
model_id: str | None = None
class RagIterationResponse(BaseModel):
iteration: int
section_ref: str
reason: str
section_text_length: int
can_answer: bool
response: str
class RagResultResponse(BaseModel):
answer: str
iterations: list[RagIterationResponse]
converged: bool
@router.post("/{doc_id}/rag", response_model=RagResultResponse)
async def run_rag(doc_id: str, body: RagRunRequest, request: Request) -> RagResultResponse:
if not settings.rag_enabled:
raise HTTPException(status_code=503, detail="Live reasoning disabled (RAG_ENABLED=false)")
if not body.query.strip():
raise HTTPException(status_code=400, detail="Query must not be empty")
analysis_repo = getattr(request.app.state, "analysis_repo", None)
if analysis_repo is None:
raise HTTPException(status_code=500, detail="AnalysisRepository not wired")
latest = await analysis_repo.find_latest_completed_by_document(doc_id)
if latest is None or not latest.document_json:
raise HTTPException(
status_code=404,
detail=f"No completed analysis with document_json for {doc_id}",
)
# Lazy-import docling-agent so the backend boots even if the dep isn't
# installed (R&D group). If missing, return 503 with a clear install hint.
try:
from docling_agent.agents import DoclingRAGAgent
from docling_core.types.doc.document import DoclingDocument
from mellea.backends.model_ids import ModelIdentifier
except ImportError as e:
raise HTTPException(
status_code=503,
detail=f"docling-agent not installed: {e}. `pip install docling-agent mellea`.",
) from e
# Ollama client reads OLLAMA_HOST at request time; set it per-call so the
# configured host takes effect without needing to restart the server.
os.environ["OLLAMA_HOST"] = settings.ollama_host
raw_model_id = body.model_id or settings.rag_model_id
# `DoclingRAGAgent` (pydantic) validates `model_id` strictly against the
# `ModelIdentifier` dataclass from Mellea. A raw string like "gpt-oss:20b"
# is rejected even though the Ollama backend itself would accept one.
# Wrap on the Ollama axis; add other axes here if we ever fork upstream to
# support non-Ollama backends.
model_id = ModelIdentifier(ollama_name=raw_model_id)
try:
doc = DoclingDocument.model_validate_json(latest.document_json)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to parse document_json: {e}") from e
agent = DoclingRAGAgent(model_id=model_id, tools=[])
logger.info(
"RAG run: doc_id=%s model_id=%s ollama_host=%s query=%r",
doc_id,
model_id,
settings.ollama_host,
body.query[:120],
)
try:
# `_rag_loop` is a synchronous LLM-heavy call (N * model latency). Run
# it in a worker thread so concurrent requests don't block the loop.
result = await asyncio.to_thread(agent._rag_loop, query=body.query, doc=doc)
except IndexError as e:
# Known docling-agent bug: `_attempt_answer` / `_select_section` call
# `find_json_dicts(answer.value)[0]` without checking for an empty
# list. When the model can't produce a parseable JSON after 3
# rejection-sampling retries + 3 `select_from_failure` retries, the
# list is empty and the `[0]` crashes. It's model-dependent (some
# questions + some models trip it, others don't).
#
# Report as 502 Bad Gateway — the upstream LLM couldn't produce a
# usable response, not our fault — with a message the UI can show
# to the user so they pick another model or rephrase.
logger.warning(
"docling-agent produced no parseable JSON for doc=%s model=%s query=%r",
doc_id,
raw_model_id,
body.query[:120],
)
raise HTTPException(
status_code=502,
detail=(
f"The model '{raw_model_id}' couldn't produce a parseable "
"answer after retries. Try a different model (e.g. mistral-small3.2) "
"or rephrase the question."
),
) from e
except Exception as e:
logger.exception("RAG loop failed for doc %s", doc_id)
raise HTTPException(status_code=500, detail=f"RAG loop failed: {e}") from e
return RagResultResponse(
answer=result.answer,
iterations=[RagIterationResponse(**it.model_dump()) for it in result.iterations],
converged=result.converged,
)

View file

@ -35,6 +35,10 @@ class HealthResponse(_CamelModel):
max_page_count: int | None = None
max_file_size_mb: int | None = None
ingestion_available: bool = False
# True when the live-reasoning runner (docling-agent + Ollama) is
# available: RAG_ENABLED=true AND deps importable. Doesn't imply Ollama
# itself is reachable — that's checked per-call.
rag_available: bool = False
class DocumentResponse(_CamelModel):

View file

@ -28,6 +28,12 @@ class Settings:
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
neo4j_user: str = "neo4j"
neo4j_password: str = "changeme"
# Live reasoning via docling-agent — off by default (heavy deps, needs an
# Ollama host reachable from the backend). Toggle RAG_ENABLED=true + point
# OLLAMA_HOST at a running instance (default http://localhost:11434).
rag_enabled: bool = False
ollama_host: str = "http://localhost:11434"
rag_model_id: str = "gpt-oss:20b" # matches docling-agent's example_05
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
upload_dir: str = "./uploads"
@ -102,12 +108,20 @@ class Settings:
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "10")),
# 0 = batching disabled (matches dataclass default). Batching
# preserves memory on very large docs but `merge_results` drops
# `document_json`, which breaks the reasoning tunnel. Enable
# explicitly (e.g. 50+) for memory-bound deploys.
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
embedding_url=os.environ.get("EMBEDDING_URL", ""),
neo4j_uri=os.environ.get("NEO4J_URI", ""),
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
rag_enabled=os.environ.get("RAG_ENABLED", "false").lower()
in ("1", "true", "yes", "on"),
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
rag_model_id=os.environ.get("RAG_MODEL_ID", "gpt-oss:20b"),
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),

View file

@ -221,6 +221,13 @@ from api.graph import router as graph_router # noqa: E402
app.include_router(graph_router)
# Live reasoning (docling-agent runner). Router is mounted unconditionally so
# the route is introspectable in OpenAPI; the handler itself 503s when
# `RAG_ENABLED` is off or the deps aren't installed.
from api.reasoning import router as reasoning_router # noqa: E402
app.include_router(reasoning_router)
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:
@ -243,4 +250,18 @@ async def health() -> HealthResponse:
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
ingestion_available=getattr(app.state, "ingestion_service", None) is not None,
# True when the live-reasoning runner is wired (flag on + deps present).
# The actual Ollama reachability is checked lazily at call-time to avoid
# blocking health checks on the LLM host.
rag_available=settings.rag_enabled and _rag_deps_present(),
)
def _rag_deps_present() -> bool:
"""Import-check only — does not hit Ollama."""
try:
import docling_agent.agents # noqa: F401
import mellea # noqa: F401
except ImportError:
return False
return True

View file

@ -9,3 +9,7 @@ httpx>=0.27.0,<1.0.0
pypdfium2>=4.0.0,<5.0.0
opensearch-py[async]>=2.6.0,<3.0.0
neo4j>=5.15.0,<6.0.0
# R&D reasoning-trace live runner — calls docling-agent's `_rag_loop` over
# an Ollama backend. Gated server-side by `RAG_ENABLED`; pulls ~60MB of deps.
docling-agent==0.1.0
mellea==0.4.2

View file

@ -0,0 +1,261 @@
"""Tests for `api.reasoning` — the live `docling-agent` RAG runner endpoint.
docling-agent + mellea are NOT installed in the CI test env (heavy deps).
The endpoint does a lazy import inside the handler; we stub the modules via
`sys.modules` injection so the tests cover the real code path without
bringing in Ollama, mellea, or LLM clients.
"""
from __future__ import annotations
import sys
import types
from dataclasses import replace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import reasoning as reasoning_module
from api.reasoning import router
from domain.models import AnalysisJob
def _patched_settings(monkeypatch, **overrides):
"""Replace `api.reasoning.settings` with a frozen dataclass copy carrying
the given overrides. `Settings` is frozen, so attribute-level monkeypatch
doesn't work — we swap the whole instance on the module.
"""
new_settings = replace(reasoning_module.settings, **overrides)
monkeypatch.setattr(reasoning_module, "settings", new_settings)
return new_settings
def _job_with_doc_json() -> AnalysisJob:
job = AnalysisJob(document_id="doc-1")
job.document_filename = "hello.pdf"
job.mark_running()
job.mark_completed(
markdown="# Hello",
html="<h1>Hello</h1>",
pages_json="[]",
# Minimal placeholder — the test stubs `DoclingDocument.model_validate_json`
# so the content doesn't need to be a real DoclingDocument.
document_json='{"stub": true}',
chunks_json="[]",
)
return job
@pytest.fixture
def mock_analysis_repo() -> AsyncMock:
repo = AsyncMock()
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
return repo
@pytest.fixture
def stub_docling_agent(monkeypatch):
"""Inject fake `docling_agent.agents` + `docling_core.types.doc.document`
modules so the endpoint's lazy imports resolve to our stubs.
Returns the `DoclingRAGAgent` stub class so tests can assert on its calls
/ configure its `_rag_loop` return value.
"""
fake_result = MagicMock()
fake_result.answer = "stub answer"
fake_result.converged = True
fake_result.iterations = [
MagicMock(
model_dump=lambda: {
"iteration": 1,
"section_ref": "#/texts/0",
"reason": "looks relevant",
"section_text_length": 42,
"can_answer": True,
"response": "stub answer",
}
)
]
agent_instance = MagicMock()
agent_instance._rag_loop.return_value = fake_result
agent_class = MagicMock(return_value=agent_instance)
fake_agents_mod = types.ModuleType("docling_agent.agents")
fake_agents_mod.DoclingRAGAgent = agent_class
fake_root_mod = types.ModuleType("docling_agent")
fake_root_mod.agents = fake_agents_mod
fake_doc_class = MagicMock()
fake_doc_class.model_validate_json = MagicMock(return_value="fake-doc-instance")
fake_doc_mod = types.ModuleType("docling_core.types.doc.document")
fake_doc_mod.DoclingDocument = fake_doc_class
# Stub `mellea.backends.model_ids.ModelIdentifier` — the endpoint wraps
# the string model_id in this dataclass before handing to DoclingRAGAgent.
# Identity-like: stores the kwargs so tests can assert on `ollama_name`.
def fake_model_identifier(**kwargs):
m = MagicMock()
m.ollama_name = kwargs.get("ollama_name")
m.openai_name = kwargs.get("openai_name")
return m
fake_model_ids_mod = types.ModuleType("mellea.backends.model_ids")
fake_model_ids_mod.ModelIdentifier = fake_model_identifier
fake_backends_mod = types.ModuleType("mellea.backends")
fake_backends_mod.model_ids = fake_model_ids_mod
fake_mellea_mod = types.ModuleType("mellea")
fake_mellea_mod.backends = fake_backends_mod
monkeypatch.setitem(sys.modules, "docling_agent", fake_root_mod)
monkeypatch.setitem(sys.modules, "docling_agent.agents", fake_agents_mod)
monkeypatch.setitem(sys.modules, "docling_core.types.doc.document", fake_doc_mod)
monkeypatch.setitem(sys.modules, "mellea", fake_mellea_mod)
monkeypatch.setitem(sys.modules, "mellea.backends", fake_backends_mod)
monkeypatch.setitem(sys.modules, "mellea.backends.model_ids", fake_model_ids_mod)
return agent_class, agent_instance, fake_result
@pytest.fixture
def client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
return TestClient(app)
class TestRagDisabled:
def test_503_when_flag_off(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=False)
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 503
assert "RAG_ENABLED" in resp.json()["detail"]
class TestRagValidation:
def test_400_when_query_empty(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
resp = client.post("/api/documents/doc-1/rag", json={"query": " "})
assert resp.status_code == 400
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 404
class TestRagSuccess:
def test_returns_rag_result_shape(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, _agent_instance, _fake_result = stub_docling_agent
resp = client.post("/api/documents/doc-1/rag", json={"query": "What is this?"})
assert resp.status_code == 200
data = resp.json()
assert data["answer"] == "stub answer"
assert data["converged"] is True
assert len(data["iterations"]) == 1
it = data["iterations"][0]
assert it["iteration"] == 1
assert it["section_ref"] == "#/texts/0"
assert it["can_answer"] is True
def test_calls_rag_loop_with_query_and_doc(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, agent_instance, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Hello?"})
agent_instance._rag_loop.assert_called_once()
kwargs = agent_instance._rag_loop.call_args.kwargs
assert kwargs["query"] == "Hello?"
# The stub returns the string "fake-doc-instance" from model_validate_json
# and we pass it straight through to `doc=`.
assert kwargs["doc"] == "fake-doc-instance"
def test_uses_default_model_id_when_not_overridden(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="custom-model:7b")
agent_class, _, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
agent_class.assert_called_once()
# model_id is wrapped in a ModelIdentifier(ollama_name=...) dataclass
# before reaching the agent — the stub exposes the field for assertion.
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "custom-model:7b"
def test_per_request_model_id_override_wins(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="default:7b")
agent_class, _, _ = stub_docling_agent
client.post("/api/documents/doc-1/rag", json={"query": "Q", "model_id": "override:13b"})
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "override:13b"
def test_sets_ollama_host_env_from_settings(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
import os
_patched_settings(monkeypatch, rag_enabled=True, ollama_host="http://ollama:11434")
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert os.environ["OLLAMA_HOST"] == "http://ollama:11434"
class TestRagDepsMissing:
def test_503_when_docling_agent_not_installed(self, client: TestClient, monkeypatch) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
# Simulate the import failing: remove any stub and ensure the name
# resolves to a module that raises on attribute access.
monkeypatch.setitem(sys.modules, "docling_agent.agents", None)
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 503
assert "docling-agent" in resp.json()["detail"]
class TestRagUpstreamFailure:
def test_502_when_docling_agent_raises_indexerror(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
"""Known docling-agent bug: `find_json_dicts(answer.value)[0]` raises
`IndexError` when the model fails to produce parseable JSON after
retries. Our endpoint must surface a 502 with a human-readable
message, not a 500 stack trace."""
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="granite4:micro-h")
_agent_class, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = IndexError("list index out of range")
resp = client.post("/api/documents/doc-1/rag", json={"query": "Quelle tarification ?"})
assert resp.status_code == 502
detail = resp.json()["detail"]
assert "granite4:micro-h" in detail
assert "parseable" in detail or "rephrase" in detail
def test_500_for_other_unexpected_errors(
self, client: TestClient, stub_docling_agent, monkeypatch
) -> None:
_patched_settings(monkeypatch, rag_enabled=True)
_agent_class, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = RuntimeError("Ollama unreachable")
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
assert resp.status_code == 500
assert "Ollama unreachable" in resp.json()["detail"]

View file

@ -11,7 +11,6 @@
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"cytoscape-navigator": "^2.0.2",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -1876,14 +1875,6 @@
"cytoscape": "^3.3.0"
}
},
"node_modules/cytoscape-navigator": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/cytoscape-navigator/-/cytoscape-navigator-2.0.2.tgz",
"integrity": "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw==",
"peerDependencies": {
"cytoscape": "^2.6.0 || ^3.0.0"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",

View file

@ -49,7 +49,12 @@
{{ tooltip.text }}
</div>
</div>
<NodeDetailsPanel :node="selectedNode" @close="closeDetails" />
<NodeDetailsPanel
:node="selectedNode"
:contents="selectedNodeContents"
@close="closeDetails"
@navigate="navigateToNode"
/>
</div>
</template>
</div>
@ -57,7 +62,7 @@
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
@ -98,6 +103,32 @@ const hiddenChips = ref<Set<string>>(new Set())
// Click details panel. Null = panel hidden.
const selectedNode = ref<GraphNode | null>(null)
// Compound parenting map (childId parentId), kept in sync with the
// Cytoscape render so the details panel can show "this section contains ".
// Updated at the end of `renderGraph` before that it's empty.
const parentMap = ref<Map<string, string>>(new Map())
// Inverse index of parentMap: parentId childId[]. Enables the
// NodeDetailsPanel "contents" section (click a section see what's in it).
const childrenByParent = computed<Map<string, GraphNode[]>>(() => {
const out = new Map<string, GraphNode[]>()
const byId = new Map<string, GraphNode>()
for (const n of payload.value?.nodes ?? []) byId.set(n.id, n)
for (const [childId, parentId] of parentMap.value) {
const child = byId.get(childId)
if (!child) continue
if (!out.has(parentId)) out.set(parentId, [])
out.get(parentId)!.push(child)
}
return out
})
const selectedNodeContents = computed<GraphNode[]>(() => {
const id = selectedNode.value?.id
if (!id) return []
return childrenByParent.value.get(id) ?? []
})
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
@ -182,11 +213,13 @@ async function renderGraph(): Promise<void> {
// Compute compound parenting: merge docling-native PARENT_OF with the
// synthetic section-scope parents so every non-root element sits inside
// its section visually (enables per-section collapse via the legend chips
// and the expand-collapse plugin).
const parentMap = mergeParentMaps(
// and the expand-collapse plugin). Also persisted on `parentMap` so the
// NodeDetailsPanel can list what a given section contains.
const computedParentMap = mergeParentMaps(
explicitParentMap(payload.value.edges),
computeSectionParents(payload.value.nodes, payload.value.edges),
)
parentMap.value = computedParentMap
const elements = [
...payload.value.nodes.map((n) => ({
@ -202,7 +235,7 @@ async function renderGraph(): Promise<void> {
// Compound-node parent: used by the expand-collapse plugin to
// fold/unfold a section's scope. `undefined` = this node is a root
// of the compound hierarchy (Documents, unparented sections, etc.).
parent: parentMap.get(n.id),
parent: computedParentMap.get(n.id),
raw: n,
},
})),
@ -399,6 +432,22 @@ function closeDetails(): void {
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
}
/**
* Triggered when the user clicks a child row inside the NodeDetailsPanel
* (e.g. the "Contents" list of a section). Switch the selection, center the
* viewport on the target, and flash the node briefly so the eye can catch it.
*/
function navigateToNode(target: GraphNode): void {
selectedNode.value = target
if (!cy.value) return
cy.value.nodes('.nd-selected').removeClass('nd-selected')
const el = cy.value.getElementById(target.id)
if (el && el.length > 0) {
el.addClass('nd-selected')
cy.value.animate({ center: { eles: el }, duration: 250 })
}
}
function disposeGraph(): void {
if (cy.value) {
cy.value.destroy()

View file

@ -57,6 +57,31 @@
</li>
</ul>
</section>
<section v-if="contents && contents.length > 0" class="nd-contents-block">
<h4 class="nd-section-title">
{{ t('graph.contains').replace('{n}', String(contents.length)) }}
</h4>
<ul class="nd-contents">
<li v-for="child in contents" :key="child.id">
<button
type="button"
class="nd-child"
:data-e2e="`node-details-child-${child.id}`"
@click="$emit('navigate', child)"
>
<span
class="nd-child-chip"
:style="{ background: kindColorFor(child) }"
:title="child.label ?? child.group"
>
{{ kindLabelFor(child) }}
</span>
<span class="nd-child-text">{{ previewText(child) }}</span>
</button>
</li>
</ul>
</section>
</aside>
</template>
@ -66,8 +91,20 @@ import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n'
import type { GraphNode, GraphProvenance } from '../graphApi'
const props = defineProps<{ node: GraphNode | null }>()
defineEmits<{ close: [] }>()
const props = defineProps<{
node: GraphNode | null
/**
* Nodes whose compound parent (PARENT_OF or synthetic section scope) is the
* currently-selected node. Computed upstream in GraphView so we don't have
* to re-walk the whole edge list here. Empty or null for leaf nodes.
*/
contents?: readonly GraphNode[] | null
}>()
defineEmits<{
close: []
/** User clicked a child row — GraphView pans + swaps selection. */
navigate: [node: GraphNode]
}>()
const { t } = useI18n()
@ -133,6 +170,35 @@ function fmt(n: number | null | undefined): string {
if (n == null) return '—'
return n.toFixed(1)
}
// Label + color helpers factored so they work for children too, not just the
// currently-selected node. Keep them consistent with the chips above.
function kindLabelFor(n: GraphNode): string {
if (n.group === 'document') return 'Document'
if (n.group === 'page') return 'Page'
if (n.group === 'chunk') return 'Chunk'
return n.label ?? 'Element'
}
function kindColorFor(n: GraphNode): string {
if (n.group === 'document') return KIND_COLORS.document
if (n.group === 'page') return KIND_COLORS.Page
if (n.group === 'chunk') return KIND_COLORS.Chunk
return KIND_COLORS[n.label ?? ''] || KIND_COLORS.TextElement
}
/**
* Short label for a child row. Prefer the node's own text (truncated), fall
* back to its self_ref so users can still recognise / debug missing text.
*/
function previewText(n: GraphNode): string {
const raw = (n.text as string | undefined) ?? ''
const clean = raw.replace(/\s+/g, ' ').trim()
if (clean) return clean.length > 80 ? clean.slice(0, 80) + '…' : clean
if (n.group === 'page') return `p.${n.page_no ?? '?'}`
if (n.group === 'chunk') return `chunk #${n.chunk_index ?? '?'}`
return n.self_ref ?? n.id
}
</script>
<style scoped>
@ -275,4 +341,69 @@ function fmt(n: number | null | undefined): string {
font-size: 9px;
text-transform: lowercase;
}
.nd-contents-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-contents {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
/* Cap the list height so a section with hundreds of paragraphs doesn't
* blow the panel out. Scroll internally above that. */
max-height: 340px;
overflow-y: auto;
}
.nd-child {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 5px 8px;
cursor: pointer;
font: inherit;
color: inherit;
transition: all var(--transition);
}
.nd-child:hover {
background: var(--border-light);
border-color: var(--border);
}
.nd-child-chip {
flex: 0 0 auto;
display: inline-block;
padding: 1px 7px;
border-radius: 8px;
color: #f8fafc;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
}
.nd-child-text {
flex: 1 1 auto;
font-size: 12px;
line-height: 1.4;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View file

@ -1,5 +1,6 @@
import { apiFetch } from '../../shared/api/http'
import type { GraphPayload } from '../analysis/graphApi'
import type { RAGResult } from './types'
/**
* Fetch the reasoning-trace graph for a document built on the backend from
@ -13,3 +14,28 @@ import type { GraphPayload } from '../analysis/graphApi'
export function fetchReasoningGraph(docId: string): Promise<GraphPayload> {
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/reasoning-graph`)
}
/**
* Kick off a `docling-agent` RAG run against a document and wait for the
* `RAGResult` (no streaming yet the backend blocks on `_rag_loop` and
* returns once the loop converges or hits `max_iterations`).
*
* Runs typically take 2040s depending on the model + Ollama latency. The
* caller should show a loading state.
*
* Errors:
* - 503 if `RAG_ENABLED=false` server-side or docling-agent isn't installed
* - 404 if no completed analysis exists for the doc
* - 500 if the loop itself raises (Ollama unreachable, model missing, )
*/
export function runReasoning(docId: string, query: string, modelId?: string): Promise<RAGResult> {
return apiFetch<RAGResult>(`/api/documents/${encodeURIComponent(docId)}/rag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
// Backend accepts snake_case; don't camelCase here.
model_id: modelId || undefined,
}),
})
}

View file

@ -39,6 +39,11 @@ function isRAGResult(x: RAGResult | undefined): boolean {
export const useReasoningStore = defineStore('reasoning', () => {
const importDialogOpen = ref(false)
// Separate modal for the live runner (POST /api/documents/:id/rag), so it
// can coexist with the import dialog conceptually even if only one is ever
// open at a time.
const runDialogOpen = ref(false)
const running = ref(false)
const rawResult = ref<RAGResult | null>(null)
const envelope = ref<SidecarEnvelope | null>(null)
const overlay = ref<OverlayResult | null>(null)
@ -62,6 +67,18 @@ export const useReasoningStore = defineStore('reasoning', () => {
importDialogOpen.value = false
}
function openRunDialog(): void {
runDialogOpen.value = true
}
function closeRunDialog(): void {
runDialogOpen.value = false
}
function setRunning(v: boolean): void {
running.value = v
}
/**
* Called by `ImportTraceDialog` once the user has supplied a JSON file.
* Does NOT touch Cytoscape the `ReasoningPanel` watches `rawResult` and
@ -98,12 +115,16 @@ export const useReasoningStore = defineStore('reasoning', () => {
activeIteration.value = null
error.value = null
importDialogOpen.value = false
runDialogOpen.value = false
running.value = false
focusMode.value = true
}
return {
// state
importDialogOpen,
runDialogOpen,
running,
rawResult,
envelope,
overlay,
@ -118,6 +139,9 @@ export const useReasoningStore = defineStore('reasoning', () => {
// actions
openImportDialog,
closeImportDialog,
openRunDialog,
closeRunDialog,
setRunning,
setResult,
setOverlay,
setActiveIteration,

View file

@ -20,9 +20,8 @@
{{ statusLabel }}
</span>
</div>
<p v-if="iteration.reason" class="it-reason">{{ iteration.reason }}</p>
<p v-if="iteration.response && iteration.canAnswer" class="it-response">
{{ iteration.response }}
<p v-if="iteration.reason" class="it-reason" :class="{ placeholder: isPlaceholderReason }">
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
</p>
<div class="it-meta">
<span v-if="iteration.sectionTextLength">
@ -52,6 +51,15 @@ const statusLabel = computed(() => {
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
return t('reasoning.statusMore')
})
// docling-agent emits the literal string "fallback" for `reason` when its
// `select_from_failure` branch runs (the model's structured output didn't
// parse N times in a row). Don't show that noise render a dash-style
// placeholder the user can visually skip.
const isPlaceholderReason = computed(() => {
const r = (props.iteration.reason || '').trim().toLowerCase()
return r === '' || r === 'fallback'
})
</script>
<style scoped>
@ -151,16 +159,9 @@ const statusLabel = computed(() => {
color: var(--text);
}
.it-response {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
.it-reason.placeholder {
color: var(--text-muted);
font-style: italic;
padding: 6px 8px;
border-left: 2px solid #ea580c;
background: rgba(234, 88, 12, 0.04);
border-radius: 2px;
}
.it-meta {

View file

@ -40,14 +40,28 @@
<section v-if="result" class="rp-answer">
<div class="rp-answer-header">
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
<span class="rp-answer-label">{{ t('reasoning.answerLabel') }}</span>
<span class="rp-answer-actions">
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
</span>
<button
class="rp-copy-btn"
:title="t('reasoning.copyAnswer')"
data-e2e="reasoning-copy-answer"
@click="copyAnswer"
>
{{ copied ? t('reasoning.copied') : t('reasoning.copy') }}
</button>
</span>
</div>
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
<div class="rp-answer-body markdown-body" v-html="renderedAnswer" />
<div class="rp-answer-footer">
<span class="rp-stats">
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
</span>
</div>
<p class="rp-answer-text">{{ result.answer }}</p>
</section>
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
@ -76,7 +90,9 @@
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { computed, watch } from 'vue'
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { computed, ref, watch } from 'vue'
import { useI18n } from '../../../shared/i18n'
import {
@ -104,6 +120,34 @@ const { t } = useI18n()
const result = computed(() => store.rawResult)
const envelope = computed(() => store.envelope)
// Render the answer as markdown so numbered lists, bold, etc. render properly.
// Models tend to produce markdown-formatted answers (numbered lists especially),
// and plain-text `pre-wrap` made them near-unreadable.
const renderedAnswer = computed(() => {
const raw = result.value?.answer ?? ''
if (!raw.trim()) return ''
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
})
const copied = ref(false)
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
async function copyAnswer(): Promise<void> {
const text = result.value?.answer
if (!text) return
try {
await navigator.clipboard.writeText(text)
copied.value = true
if (copyResetTimer) clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
copied.value = false
}, 1800)
} catch (e) {
console.warn('Copy failed', e)
}
}
const missingWarning = computed(() => {
// Full miss + no cy the graph simply isn't loaded. Different message
// than "N sections are actually missing from the graph".
@ -273,11 +317,12 @@ function onClear(): void {
.rp-answer {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: var(--accent-muted, rgba(234, 88, 12, 0.04));
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
gap: 10px;
padding: 14px 16px;
background: var(--bg);
border: 1px solid #ea580c;
border-radius: var(--radius);
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
}
.rp-answer-header {
@ -287,6 +332,20 @@ function onClear(): void {
gap: 8px;
}
.rp-answer-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: #ea580c;
}
.rp-answer-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rp-converged {
font-size: 10px;
font-weight: 600;
@ -306,18 +365,97 @@ function onClear(): void {
color: #a16207;
}
.rp-copy-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 2px 8px;
font-size: 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.rp-copy-btn:hover {
background: var(--border-light);
color: var(--text);
}
.rp-stats {
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
.rp-answer-text {
margin: 0;
font-size: 13px;
line-height: 1.5;
.rp-answer-footer {
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-light);
padding-top: 6px;
}
/* Markdown-rendered answer body. Mirrors a subset of MarkdownViewer styles,
* tuned for a narrow right-rail context (tighter sizes than the full viewer). */
.rp-answer-body {
font-size: 13.5px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
}
.rp-answer-body :deep(p) {
margin: 0 0 8px;
}
.rp-answer-body :deep(p:last-child) {
margin-bottom: 0;
}
.rp-answer-body :deep(ol),
.rp-answer-body :deep(ul) {
margin: 4px 0 8px;
padding-left: 22px;
}
.rp-answer-body :deep(li) {
margin: 2px 0;
}
.rp-answer-body :deep(strong) {
color: var(--text);
font-weight: 600;
}
.rp-answer-body :deep(code) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 1px 5px;
border-radius: 3px;
}
.rp-answer-body :deep(pre) {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: var(--border-light);
padding: 8px 10px;
border-radius: var(--radius-sm);
overflow-x: auto;
margin: 6px 0;
}
.rp-answer-body :deep(h1),
.rp-answer-body :deep(h2),
.rp-answer-body :deep(h3),
.rp-answer-body :deep(h4) {
margin: 10px 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.rp-answer-body :deep(a) {
color: #ea580c;
text-decoration: underline;
}
.rp-warn {

View file

@ -8,18 +8,27 @@
{{ docFilename ?? docId }}
</div>
<button
class="rw-action-btn"
class="rw-action-btn rw-action-ghost"
data-e2e="reasoning-workspace-import"
@click="reasoningStore.openImportDialog()"
>
{{ t('reasoning.importBtn') }}
</button>
<button
class="rw-action-btn"
data-e2e="reasoning-workspace-run"
@click="reasoningStore.openRunDialog()"
>
{{ t('reasoning.runBtn') }}
</button>
</header>
<div class="rw-body">
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
<ReasoningPanel :cy="graphCy" />
</div>
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
</div>
</template>
@ -31,6 +40,7 @@ import { useI18n } from '../../../shared/i18n'
import { fetchReasoningGraph } from '../api'
import { useReasoningStore } from '../store'
import ReasoningPanel from './ReasoningPanel.vue'
import RunReasoningDialog from './RunReasoningDialog.vue'
const props = defineProps<{
docId: string
@ -115,6 +125,19 @@ onBeforeUnmount(() => reasoningStore.reset())
filter: brightness(0.95);
}
/* Secondary action next to the primary Run button — import is a rarer path. */
.rw-action-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.rw-action-ghost:hover {
background: var(--border-light);
color: var(--text);
filter: none;
}
.rw-body {
flex: 1 1 auto;
min-height: 0;

View file

@ -0,0 +1,296 @@
<template>
<div
v-if="store.runDialogOpen"
class="run-modal-backdrop"
data-e2e="reasoning-run-modal"
@click.self="close"
>
<div class="run-modal" role="dialog" aria-modal="true" :aria-label="t('reasoning.runTitle')">
<div class="run-modal-header">
<h3>{{ t('reasoning.runTitle') }}</h3>
<button
class="run-modal-close"
:aria-label="t('reasoning.close')"
:disabled="store.running"
@click="close"
>
</button>
</div>
<p class="run-modal-hint">{{ t('reasoning.runHint') }}</p>
<label class="run-field">
<span class="run-field-label">{{ t('reasoning.runQueryLabel') }}</span>
<textarea
v-model="query"
class="run-field-input"
rows="3"
:placeholder="t('reasoning.runQueryPlaceholder')"
:disabled="store.running"
data-e2e="reasoning-run-query"
/>
</label>
<label class="run-field">
<span class="run-field-label">{{ t('reasoning.runModelLabel') }}</span>
<input
v-model="modelId"
type="text"
class="run-field-input"
:placeholder="t('reasoning.runModelPlaceholder')"
:disabled="store.running"
data-e2e="reasoning-run-model"
/>
<span class="run-field-sub">{{ t('reasoning.runModelSub') }}</span>
</label>
<div v-if="store.running" class="run-loading" data-e2e="reasoning-run-loading">
<div class="spinner" />
<span>{{ t('reasoning.running') }}</span>
</div>
<div v-if="errorMsg" class="run-modal-error" data-e2e="reasoning-run-error">
{{ errorMsg }}
</div>
<div class="run-modal-actions">
<button class="run-ghost" :disabled="store.running" @click="close">
{{ t('reasoning.cancel') }}
</button>
<button
class="run-primary"
:disabled="!query.trim() || store.running"
data-e2e="reasoning-run-submit"
@click="submit"
>
{{ t('reasoning.runSubmit') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from '../../../shared/i18n'
import { runReasoning } from '../api'
import { useReasoningStore } from '../store'
import type { SidecarEnvelope } from '../types'
const props = defineProps<{ docId: string; docFilename?: string | null }>()
const store = useReasoningStore()
const { t } = useI18n()
const query = ref('')
const modelId = ref('')
const errorMsg = ref<string | null>(null)
function close(): void {
if (store.running) return // don't let the user close mid-run
store.closeRunDialog()
errorMsg.value = null
}
async function submit(): Promise<void> {
const q = query.value.trim()
if (!q) return
errorMsg.value = null
store.setRunning(true)
try {
const result = await runReasoning(props.docId, q, modelId.value.trim() || undefined)
// Synthesize a sidecar-like envelope so the panel can show what was asked
// and which model answered, same as an imported trace.
const envelope: SidecarEnvelope = {
filename: props.docFilename ?? undefined,
query: q,
model: modelId.value.trim()
? { ollama_name: modelId.value.trim(), hf_model_name: null }
: undefined,
result,
}
store.setResult(result, envelope)
// Keep the query for the user's reference but close the dialog.
store.closeRunDialog()
} catch (e) {
errorMsg.value = (e as Error).message || t('reasoning.runErrUnknown')
} finally {
store.setRunning(false)
}
}
</script>
<style scoped>
.run-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.run-modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
width: min(560px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 12px 48px rgba(15, 23, 42, 0.25);
display: flex;
flex-direction: column;
gap: 14px;
}
.run-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.run-modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.run-modal-close {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.run-modal-close:hover:not(:disabled) {
background: var(--border-light);
color: var(--text);
}
.run-modal-close:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.run-modal-hint {
font-size: 13px;
color: var(--text-muted);
margin: 0;
}
.run-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.run-field-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.run-field-input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
resize: vertical;
}
.run-field-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.run-field-sub {
font-size: 11px;
color: var(--text-muted);
}
.run-loading {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-secondary);
padding: 8px 12px;
background: var(--accent-muted, rgba(234, 88, 12, 0.08));
border-radius: var(--radius-sm);
}
.run-modal-error {
padding: 8px 12px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
color: var(--error, #dc2626);
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
word-break: break-word;
}
.run-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.run-primary {
background: var(--accent);
color: white;
border: 0;
padding: 7px 16px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
}
.run-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.run-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 7px 14px;
font-size: 13px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.run-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -91,6 +91,7 @@ const messages: Messages = {
'graph.page': 'Page',
'graph.text': 'Texte',
'graph.provenances': 'Provenances ({n})',
'graph.contains': 'Contenu ({n})',
'results.retry': 'Réessayer',
'results.pageOf': 'Page {current} sur {total}',
'results.noElements': 'Aucun élément détecté sur cette page',
@ -147,6 +148,11 @@ const messages: Messages = {
'reasoning.converged': 'Convergé',
'reasoning.notConverged': 'Itérations max atteintes',
'reasoning.resolved': 'sections résolues',
'reasoning.answerLabel': 'Réponse',
'reasoning.copy': 'Copier',
'reasoning.copied': 'Copié ✓',
'reasoning.copyAnswer': 'Copier la réponse dans le presse-papier',
'reasoning.reasonPlaceholder': '— pas de justification structurée',
'reasoning.missingWarn':
'{n} section(s) introuvable(s) dans le graphe. Le document a peut-être été re-analysé — relance « Maintenir » ou régénère la trace.',
'reasoning.graphNotLoadedWarn':
@ -172,6 +178,19 @@ const messages: Messages = {
'reasoning.analyzing': 'Analyse du document...',
'reasoning.analyzingHint':
'Docling analyse le PDF avec la configuration par défaut. Cela peut prendre 1 à 3 minutes selon la taille.',
'reasoning.runBtn': 'Lancer le reasoning',
'reasoning.runTitle': 'Lancer docling-agent',
'reasoning.runHint':
'Pose une question au document. Le backend appelle docling-agent via Ollama et renvoie la trace dès que la boucle converge (20-40s).',
'reasoning.runQueryLabel': 'Question',
'reasoning.runQueryPlaceholder': 'Ex : Quelles sont les obligations du fournisseur ?',
'reasoning.runModelLabel': 'Modèle (optionnel)',
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
'reasoning.runModelSub':
'Nom du modèle Ollama. Laisser vide pour utiliser le défaut serveur (RAG_MODEL_ID).',
'reasoning.runSubmit': 'Lancer',
'reasoning.running': 'docling-agent tourne... (20-40s)',
'reasoning.runErrUnknown': 'Erreur inconnue lors de l\u2019appel à docling-agent.',
'reasoning.cancel': 'Annuler',
'reasoning.retry': 'Réessayer',
'reasoning.pickAnother': 'Choisir un autre document',
@ -332,6 +351,7 @@ const messages: Messages = {
'graph.page': 'Page',
'graph.text': 'Text',
'graph.provenances': 'Provenances ({n})',
'graph.contains': 'Contents ({n})',
'results.retry': 'Retry',
'results.pageOf': 'Page {current} of {total}',
'results.noElements': 'No elements detected on this page',
@ -383,6 +403,11 @@ const messages: Messages = {
'reasoning.converged': 'Converged',
'reasoning.notConverged': 'Max iterations',
'reasoning.resolved': 'sections resolved',
'reasoning.answerLabel': 'Answer',
'reasoning.copy': 'Copy',
'reasoning.copied': 'Copied ✓',
'reasoning.copyAnswer': 'Copy answer to clipboard',
'reasoning.reasonPlaceholder': '— no structured rationale',
'reasoning.missingWarn':
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
'reasoning.graphNotLoadedWarn':
@ -408,6 +433,19 @@ const messages: Messages = {
'reasoning.analyzing': 'Analyzing document...',
'reasoning.analyzingHint':
'Docling is parsing the PDF with default settings. May take 13 minutes depending on size.',
'reasoning.runBtn': 'Run reasoning',
'reasoning.runTitle': 'Run docling-agent',
'reasoning.runHint':
'Ask a question against this document. The backend calls docling-agent over Ollama and returns the trace once the loop converges (2040s).',
'reasoning.runQueryLabel': 'Question',
'reasoning.runQueryPlaceholder': 'e.g. What are the supplier obligations?',
'reasoning.runModelLabel': 'Model (optional)',
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
'reasoning.runModelSub':
'Ollama model name. Leave empty to use the server default (RAG_MODEL_ID).',
'reasoning.runSubmit': 'Run',
'reasoning.running': 'docling-agent is thinking… (2040s)',
'reasoning.runErrUnknown': 'Unknown error while calling docling-agent.',
'reasoning.cancel': 'Cancel',
'reasoning.retry': 'Retry',
'reasoning.pickAnother': 'Pick another document',