Complete Python CLI for OpenWebUI with: - Auth commands (login, logout, whoami, token, refresh) - Chat send with streaming support - RAG files/collections management - Models list/info - Admin stats (minimal v1.0) - Config management with profiles and keyring Stack: typer, httpx, rich, pydantic, keyring Based on RFC v1.2 with 22-step implementation checklist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
256 lines
8.3 KiB
Python
256 lines
8.3 KiB
Python
"""RAG (Retrieval-Augmented Generation) commands."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from ..http import create_client, handle_response, handle_request_error
|
|
|
|
app = typer.Typer(no_args_is_help=True)
|
|
console = Console()
|
|
|
|
# Sub-apps
|
|
files_app = typer.Typer(help="File operations")
|
|
collections_app = typer.Typer(help="Collection operations")
|
|
|
|
app.add_typer(files_app, name="files")
|
|
app.add_typer(collections_app, name="collections")
|
|
|
|
|
|
# Files commands
|
|
@files_app.command("list")
|
|
def list_files(ctx: typer.Context) -> None:
|
|
"""List uploaded files."""
|
|
obj = ctx.obj or {}
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.get("/api/v1/files/")
|
|
data = handle_response(response)
|
|
|
|
files = data if isinstance(data, list) else data.get("files", [])
|
|
|
|
if obj.get("format") == "json":
|
|
console.print(json.dumps(files, indent=2))
|
|
else:
|
|
table = Table(title="Uploaded Files")
|
|
table.add_column("ID", style="cyan")
|
|
table.add_column("Filename", style="green")
|
|
table.add_column("Size", style="yellow")
|
|
|
|
for f in files:
|
|
file_id = f.get("id", "-")
|
|
filename = f.get("filename", f.get("name", "-"))
|
|
size = f.get("size", "-")
|
|
if isinstance(size, int):
|
|
size = f"{size / 1024:.1f} KB"
|
|
table.add_row(file_id, filename, str(size))
|
|
|
|
console.print(table)
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
@files_app.command()
|
|
def upload(
|
|
ctx: typer.Context,
|
|
paths: list[Path] = typer.Argument(..., help="File path(s) to upload"),
|
|
collection: str | None = typer.Option(None, "--collection", "-c", help="Add to collection"),
|
|
) -> None:
|
|
"""Upload file(s) for RAG."""
|
|
obj = ctx.obj or {}
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
timeout=300, # Longer timeout for uploads
|
|
) as client:
|
|
for path in paths:
|
|
if not path.exists():
|
|
console.print(f"[red]File not found: {path}[/red]")
|
|
continue
|
|
|
|
with open(path, "rb") as f:
|
|
files = {"file": (path.name, f)}
|
|
response = client.post("/api/v1/files/", files=files)
|
|
|
|
data = handle_response(response)
|
|
file_id = data.get("id", "unknown")
|
|
console.print(f"[green]Uploaded:[/green] {path.name} (id: {file_id})")
|
|
|
|
# Add to collection if specified
|
|
if collection and file_id != "unknown":
|
|
try:
|
|
client.post(
|
|
f"/api/v1/knowledge/{collection}/file/add",
|
|
json={"file_id": file_id},
|
|
)
|
|
console.print(f" Added to collection: {collection}")
|
|
except Exception as e:
|
|
console.print(f" [yellow]Warning: Could not add to collection: {e}[/yellow]")
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
@files_app.command()
|
|
def delete(
|
|
ctx: typer.Context,
|
|
file_id: str = typer.Argument(..., help="File ID to delete"),
|
|
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
) -> None:
|
|
"""Delete an uploaded file."""
|
|
obj = ctx.obj or {}
|
|
|
|
if not force:
|
|
confirm = typer.confirm(f"Delete file {file_id}?")
|
|
if not confirm:
|
|
raise typer.Abort()
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.delete(f"/api/v1/files/{file_id}")
|
|
handle_response(response)
|
|
console.print(f"[green]Deleted file: {file_id}[/green]")
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
# Collections commands
|
|
@collections_app.command("list")
|
|
def list_collections(ctx: typer.Context) -> None:
|
|
"""List knowledge collections."""
|
|
obj = ctx.obj or {}
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.get("/api/v1/knowledge/")
|
|
data = handle_response(response)
|
|
|
|
collections = data if isinstance(data, list) else data.get("collections", [])
|
|
|
|
if obj.get("format") == "json":
|
|
console.print(json.dumps(collections, indent=2))
|
|
else:
|
|
table = Table(title="Knowledge Collections")
|
|
table.add_column("ID", style="cyan")
|
|
table.add_column("Name", style="green")
|
|
table.add_column("Description", style="yellow")
|
|
|
|
for c in collections:
|
|
coll_id = c.get("id", "-")
|
|
name = c.get("name", "-")
|
|
desc = c.get("description", "-")[:50]
|
|
table.add_row(coll_id, name, desc)
|
|
|
|
console.print(table)
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
@collections_app.command()
|
|
def create(
|
|
ctx: typer.Context,
|
|
name: str = typer.Argument(..., help="Collection name"),
|
|
description: str = typer.Option("", "--description", "-d", help="Collection description"),
|
|
) -> None:
|
|
"""Create a knowledge collection."""
|
|
obj = ctx.obj or {}
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.post(
|
|
"/api/v1/knowledge/",
|
|
json={"name": name, "description": description},
|
|
)
|
|
data = handle_response(response)
|
|
coll_id = data.get("id", "unknown")
|
|
console.print(f"[green]Created collection:[/green] {name} (id: {coll_id})")
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
@collections_app.command()
|
|
def delete(
|
|
ctx: typer.Context,
|
|
collection_id: str = typer.Argument(..., help="Collection ID to delete"),
|
|
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
) -> None:
|
|
"""Delete a knowledge collection."""
|
|
obj = ctx.obj or {}
|
|
|
|
if not force:
|
|
confirm = typer.confirm(f"Delete collection {collection_id}?")
|
|
if not confirm:
|
|
raise typer.Abort()
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.delete(f"/api/v1/knowledge/{collection_id}")
|
|
handle_response(response)
|
|
console.print(f"[green]Deleted collection: {collection_id}[/green]")
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|
|
|
|
|
|
# Search command
|
|
@app.command()
|
|
def search(
|
|
ctx: typer.Context,
|
|
query: str = typer.Argument(..., help="Search query"),
|
|
collection: str = typer.Option(..., "--collection", "-c", help="Collection ID to search"),
|
|
top_k: int = typer.Option(5, "--top-k", "-k", help="Number of results"),
|
|
) -> None:
|
|
"""Search within a collection (vector search)."""
|
|
obj = ctx.obj or {}
|
|
|
|
try:
|
|
with create_client(
|
|
profile=obj.get("profile"),
|
|
uri=obj.get("uri"),
|
|
) as client:
|
|
response = client.post(
|
|
f"/api/v1/knowledge/{collection}/query",
|
|
json={"query": query, "k": top_k},
|
|
)
|
|
data = handle_response(response)
|
|
|
|
results = data.get("results", data.get("documents", []))
|
|
|
|
if obj.get("format") == "json":
|
|
console.print(json.dumps(results, indent=2))
|
|
else:
|
|
console.print(f"[bold]Search results for:[/bold] {query}\n")
|
|
for i, result in enumerate(results, 1):
|
|
content = result.get("content", result.get("text", str(result)))[:200]
|
|
score = result.get("score", result.get("distance", "-"))
|
|
console.print(f"[cyan]{i}.[/cyan] (score: {score})")
|
|
console.print(f" {content}...")
|
|
console.print()
|
|
|
|
except Exception as e:
|
|
handle_request_error(e)
|