defmodule ArcanaWeb.DocumentsLive do @moduledoc """ LiveView for managing documents in Arcana. """ use Phoenix.LiveView import ArcanaWeb.DashboardComponents alias Arcana.Document import Ecto.Query @impl true def mount(_params, session, socket) do repo = get_repo_from_session(session) {:ok, socket |> assign(repo: repo) |> assign(page: 1, per_page: 10) |> assign(viewing_document: nil) |> assign(upload_error: nil) |> assign(filter_collection: nil) |> assign(graph_enabled: Arcana.Graph.enabled?()) |> assign(graph_indexing: false) |> assign(stats: nil, collections: [], documents: [], total_pages: 1, total_count: 0) |> allow_upload(:files, accept: ~w(.txt .md .markdown .pdf), max_entries: 10, max_file_size: 10_000_000 )} end @impl true def handle_params(%{"doc" => doc_id}, _uri, socket) do socket = load_data(socket) {:noreply, load_document_detail(socket, doc_id)} end def handle_params(_params, _uri, socket) do {:noreply, load_data(socket)} end defp load_data(socket) do repo = socket.assigns.repo socket |> assign(stats: load_stats(repo)) |> assign(collections: load_collections(repo)) |> load_documents() end defp load_documents(socket) do repo = socket.assigns.repo page = socket.assigns.page per_page = socket.assigns.per_page filter_collection = socket.assigns.filter_collection base_query = from(d in Document, order_by: [desc: d.inserted_at], preload: [:collection] ) filtered_query = if filter_collection do from(d in base_query, join: c in assoc(d, :collection), where: c.name == ^filter_collection ) else base_query end total_count = repo.aggregate(filtered_query, :count) total_pages = max(1, ceil(total_count / per_page)) documents = repo.all( from(d in filtered_query, offset: ^((page - 1) * per_page), limit: ^per_page ) ) assign(socket, documents: documents, total_pages: total_pages, total_count: total_count ) end defp load_document_detail(socket, doc_id) do repo = socket.assigns.repo document = repo.one( from(d in Document, where: d.id == ^doc_id, preload: [:collection] ) ) if document do chunks = repo.all( from(c in Arcana.Chunk, where: c.document_id == ^doc_id, order_by: c.chunk_index ) ) assign(socket, viewing_document: %{document: document, chunks: chunks}) else socket end end @impl true def handle_event("change_page", %{"page" => page}, socket) do page = String.to_integer(page) {:noreply, socket |> assign(page: page) |> load_documents()} end def handle_event("view_document", %{"id" => id}, socket) do {:noreply, load_document_detail(socket, id)} end def handle_event("close_detail", _params, socket) do {:noreply, assign(socket, viewing_document: nil)} end def handle_event("ingest", params, socket) do repo = socket.assigns.repo content = params["content"] || "" format = parse_format(params["format"]) collection = normalize_collection(params["collection"]) graph = params["graph"] == "true" {:ok, _doc} = Arcana.ingest(content, repo: repo, format: format, collection: collection, graph: graph) {:noreply, load_data(socket)} end def handle_event("upload_files", params, socket) do repo = socket.assigns.repo collection = normalize_collection(params["collection"]) graph = params["graph"] == "true" uploaded_files = consume_uploaded_entries(socket, :files, fn %{path: path}, entry -> dest = Path.join(System.tmp_dir!(), "arcana_#{entry.uuid}_#{entry.client_name}") File.cp!(path, dest) {:ok, dest} end) results = Enum.map(uploaded_files, fn path -> result = Arcana.ingest_file(path, repo: repo, collection: collection, graph: graph) File.rm(path) result end) errors = Enum.filter(results, &match?({:error, _}, &1)) socket = if Enum.empty?(errors) do assign(socket, upload_error: nil) else error_msg = Enum.map_join(errors, ", ", fn {:error, reason} -> inspect(reason) end) assign(socket, upload_error: "Some files failed: #{error_msg}") end {:noreply, load_data(socket)} end def handle_event("cancel_upload", %{"ref" => ref}, socket) do {:noreply, cancel_upload(socket, :files, ref)} end def handle_event("validate_upload", _params, socket) do {:noreply, socket} end def handle_event("delete", %{"id" => id}, socket) do repo = socket.assigns.repo case Arcana.delete(id, repo: repo) do :ok -> {:noreply, load_data(socket)} {:error, _reason} -> {:noreply, socket} end end def handle_event("filter_by_collection", %{"collection" => collection_name}, socket) do {:noreply, socket |> assign(filter_collection: collection_name, page: 1) |> load_documents()} end def handle_event("clear_collection_filter", _params, socket) do {:noreply, socket |> assign(filter_collection: nil, page: 1) |> load_documents()} end def handle_event("build_graph", _params, socket) do %{document: document, chunks: chunks} = socket.assigns.viewing_document collection = document.collection repo = socket.assigns.repo parent = self() socket = assign(socket, graph_indexing: true) Arcana.TaskSupervisor.start_child(fn -> result = Arcana.Graph.build_and_persist(chunks, collection, repo, []) send(parent, {:graph_complete, result}) end) {:noreply, socket} end @impl true def handle_info({:graph_complete, result}, socket) do socket = case result do {:ok, %{entity_count: entities, relationship_count: relationships}} -> socket |> assign(graph_indexing: false) |> put_flash(:info, "Graph built: #{entities} entities, #{relationships} relationships") {:error, reason} -> socket |> assign(graph_indexing: false) |> put_flash(:error, "Graph build failed: #{inspect(reason)}") end {:noreply, socket} end @impl true def render(assigns) do ~H""" <.dashboard_layout stats={@stats} current_tab={:documents}>
Upload, view, and manage documents in your knowledge base.
No documents yet. Paste some text above to get started.
<% else %>| ID | Content | Collection | Source | Chunks | Created | Actions |
|---|---|---|---|---|---|---|
<%= doc.id %> |
<%= String.slice(doc.content || "", 0, 100) %>... | <%= if doc.collection, do: doc.collection.name, else: "-" %> | <%= doc.source_id || "-" %> | <%= doc.chunk_count %> | <%= doc.inserted_at %> |
<%= @viewing.document.id %>
<%= @viewing.document.content %>
<%= chunk.text %>