Feat/localization tests docker (#371)

* feat(i18n): complete 100% internationalization and fix Next.js 15 compatibility

* feat(i18n): complete 100% internationalization coverage

* chore(test): finalize component tests and project cleanup

* test(logic): add unit tests for useModalManager hook

* fix(test): resolve timeout in AppSidebar tests by mocking TooltipProvider

* feat(i18n): comprehensive i18n audit, fixes for hardcoded strings, and complete zh-TW support

* fix(i18n): resolve TypeScript warnings and improve translation hook stability

- Remove unused useTranslation import from ConnectionGuard
- Add ref-based checking state to prevent dependency cycles
- Fix useTranslation hook to return empty string for undefined translations
- Add comment for backward compatibility on ExtractedReference interface
- Ensure .replace() string methods work safely with nested translation keys

* feat(i18n): complete internationalization implementation with Docker deployment

- Add LanguageLoadingOverlay component for smooth language transitions
- Update all translation files (en-US, zh-CN, zh-TW) with improved terminology
- Optimize Docker configuration for better performance
- Update version check and config handling for i18n support
- Fix route handling for language-specific content
- Add comprehensive task documentation

* fix(i18n): resolve localization errors, duplicates, and type issues

* chore(i18n): finalize 100% internationalization coverage

* chore(test): supplement i18n test cases and cleanup redundant files

* fix(test): resolve lint type errors and finalize delivery documents

* feat(i18n): finalize full internationalization and zh-TW localization

* fix(frontend): add missing devDependency and fix build tsconfig

* feat(ui): enhance sidebar hover effects with better visual feedback

* fix(frontend): resolve accessibility, i18n, and lint issues

- fix: add missing id, name, autocomplete attributes to dialog inputs
- fix: add aria labels and DialogDescription for accessibility
- fix: resolve uncontrolled component warning in SettingsForm
- fix: correct duplicate 'Traditional Chinese' label in zh-TW locale
- feat: add i18n support for podcast template names
- chore: fix lint errors in Dialogs

* fix: address all 21 PR feedback items from cubic-dev-ai bot

Configuration:
- Remove ignoreDuringBuilds flags from next.config.ts

Testing:
- Fix AppSidebar.test.tsx regex pattern and add missing assertion

Logic:
- Fix ConnectionGuard.tsx re-entry prevention logic

Internationalization (I18n) - Translations:
- Add missing keys: notebooks.archived, common.note/insight, accessibility keys
- Add specific keys: sources.allSourcesDescShort, transformations.selectModel
- Add singular/plural keys: podcasts.usedByCount_one/other, common.note/notes
- Add common.created/updated with {time} placeholder

Internationalization (I18n) - Usage:
- SourcesPage: use allSourcesDescShort instead of string splitting
- TransformationPlayground: use navigation.transformation and selectModel
- CommandPalette: use dedicated keys instead of string concatenation
- GeneratePodcastDialog: fix zh-TW date locale handling
- NotebookHeader: correctly interpolate {time} placeholder
- TransformationCard: use common.description instead of undefined key
- ChatPanel/SpeakerProfilesPanel: implement proper pluralization
- SystemInfo: correctly interpolate {version} placeholder
- LanguageLoadingOverlay: use t.common.loading instead of hardcoded string
- MessageActions: use specific error key cannotSaveNoteNoNotebook

Other:
- Fix SessionManager.tsx exhaustive-deps warning

* fix: remove duplicate locale keys and add missing zh-CN translations

- en-US: remove duplicate loading key (line 59) and addNew key (sources)
- zh-CN: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-CN: remove duplicate accessibility.searchNotebooks key
- zh-CN: remove duplicate sources.addNew key
- zh-CN: remove duplicate navigation.transformation key
- zh-CN: add missing usedByCount_one and usedByCount_other keys in podcasts
- zh-TW: remove duplicate common keys (loading, note, insight, newSource, newNotebook, newPodcast)
- zh-TW: remove duplicate accessibility.searchNotebooks key
- zh-TW: remove duplicate sources.addNew key

* docs: remove info.md

* fix: remove duplicate notebook keys and unused ts-expect-error

- zh-CN: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- zh-TW: remove duplicate notebooks keys (archived, archive, unarchive, deleteNotebook, deleteNotebookDesc)
- GeneratePodcastDialog: remove unused @ts-expect-error directive

* fix(a11y): fix unassociated labels in search page

- Replace <Label> with role='group' + aria-labelledby for search type section
- Replace <Label> with role='group' + aria-labelledby for search in section
- Follows WAI-ARIA best practices for labeling form field groups

* fix(a11y): fix unassociated labels across multiple components

- search/page.tsx: use role='group' + aria-labelledby for search type and search in sections
- RebuildEmbeddings.tsx: use role='group' + aria-labelledby for include checkboxes
- TransformationPlayground.tsx: replace Label with span for non-form output label

* chore: revert to npm stack and ensure i18n compatibility

* chore: polish zh-TW translations for better idiomatic usage

* fix: resolve linter errors (ruff import sort, mypy config duplicate)

* style: apply ruff formatting

* fix: finalize upstream compliance (Dockerfile.single, i18n hooks, docker-compose)

* style: polish strings, fix timeout cleanup, and improve test mocks

* fix: use relative imports in test setup to resolve IDE path errors

* perf(docker): optimize build speed by removing apt-get upgrade and build tools

- Remove apt-get upgrade from both builder and runtime stages (saves 10-15 min each)
- Remove gcc/g++/make/git from builder (uv downloads pre-built wheels)
- Add --no-install-recommends to minimize package footprint
- Keep npm mirror (npmmirror.com) for faster frontend deps
- Add npm registry config for reliable China network access

Also includes:
- fix(a11y): add missing labels and aria attributes to form fields
- fix(i18n): add 2s safety timeout to LanguageLoadingOverlay
- fix(i18n): add robustness checks to use-translation proxy

Build time reduced from 2+ hours to ~34 minutes (~70% improvement)

* fix(a11y): resolve 16 form field accessibility warnings in notebook and podcast pages

* fix(a11y): resolve 4 button and 1 select field accessibility warnings in models page

* fix(a11y): resolve redundant attributes and residual warnings in transformations and podcast forms

* fix(i18n): deep fix for language switch hang using proxy protection and safer access

* fix(a11y): add name attributes to ModelSelector, TransformationPlayground, and SourceDetailContent

* fix: add missing Label import to SourceDetailContent

* fix(i18n): use native react-i18next in LanguageLoadingOverlay to prevent hang during language switch

* fix(i18n): rewrite use-translation Proxy with strict depth limit and expanded blocked props to prevent language switch hang

* fix: add type assertion to fix TypeScript comparison error

* fix(i18n): disable useSuspense to prevent thread hang during language resource loading

* fix(i18n): add infinite loop detection circuit breaker to useTranslation hook

* fix(i18n): update traditional chinese label to native script in en-US

* feat: add new localization strings for notebook and note management.

* fix: resolve config priority, docker build deps, and ui glitches

* refactor: improve ui details and test coverage based on feedback

* refactor: improve ui details (version check/lang toggle) and test coverage

* fix: polish language matching and test cleanup

* fix(test): update mocks to resolve timeouts and proxy errors

* fix(frontend): restore tsconfig.json structure and enable IDE support for tests

* fix: address PR review findings and resolve CI OIDC failure

* fix: merge exception headers in custom handler

* fix: comprehensive PR review remediations and async performance fixes

* refactor: address all PR #371 review feedback

- Docker: consolidate SURREAL_URL to docker.env, add single-container override
- Security: restore apt-get upgrade in Dockerfile and Dockerfile.single
- Create centralized getDateLocale helper (lib/utils/date-locale.ts)
- Refactor 7 files to use getDateLocale helper
- Revert config/route.ts to origin/main version
- Move test files to co-located pattern (3 files)
- Remove local useTranslation mock from ConfirmDialog.test.tsx
- Simplify use-version-check to single useEffect pattern
- Fix test import paths after moving to co-located pattern

* fix: add jest-dom types for test files

* fix: address remaining review issues

- Add apt-get upgrade -y to Dockerfile.single backend-builder stage
- Refactor ChatColumn.test.tsx: use 'as unknown as ReturnType<typeof hook>' instead of 'as any'
- Use toBeInTheDocument() assertions instead of toBeDefined()
This commit is contained in:
MisonL 2026-01-16 00:51:05 +08:00 committed by GitHub
parent 940c56ddaf
commit 67dd85c928
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
179 changed files with 10201 additions and 2633 deletions

View file

@ -1,63 +1,35 @@
notebooks/ # Git
data/ .git
.uploads/ .gitignore
.venv/
.env
sqlite-db/
temp/
google-credentials.json
docker-compose*
.docker_data/
docs/
surreal_data/
surreal-data/
notebook_data/
temp/
*.env
.git/
.github/
# Frontend build artifacts and dependencies # Python
frontend/node_modules/ __pycache__
frontend/.next/ *.pyc
frontend/.env.local *.pyo
*.pyd
.venv
venv
ENV
env
.pytest_cache
.mypy_cache
.ruff_cache
# Cache directories (recursive patterns) # Frontend
**/__pycache__/ frontend/node_modules
**/.mypy_cache/ frontend/.next
**/.ruff_cache/ frontend/dist
**/.pytest_cache/ frontend/out
**/*.pyc frontend/.env*
**/*.pyo frontend/*.log
**/*.pyd
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
.cache/
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
# IDE and editor files # Project
.vscode/ .antigravity
.idea/ .gemini
*.swp tmp
*.swo data
*~ mydata
*.db
# OS files *.log
.DS_Store docker.env
.DS_Store? .env
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
.quarentena/
surreal_single_data/

View file

@ -22,8 +22,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: write
issues: read issues: write
id-token: write id-token: write
steps: steps:
@ -38,6 +38,7 @@ jobs:
id: claude-review id: claude-review
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins' plugins: 'code-review@claude-code-plugins'

View file

@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: write
issues: read issues: write
id-token: write id-token: write
actions: read # Required for Claude to read CI results on PRs actions: read # Required for Claude to read CI results on PRs
steps: steps:
@ -34,6 +34,7 @@ jobs:
id: claude id: claude
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs # This is an optional setting that allows Claude to read CI results on PRs

View file

@ -6,9 +6,11 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Install system dependencies required for building certain Python packages # Install system dependencies required for building certain Python packages
# Add Node.js 20.x LTS for building frontend # Add Node.js 20.x LTS for building frontend
RUN apt-get update && apt-get upgrade -y && apt-get install -y \ # NOTE: gcc/g++/make removed - uv should download pre-built wheels. Add back if build fails.
gcc g++ git make \ # NOTE: gcc/g++/make required for some python dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
build-essential \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@ -35,7 +37,11 @@ COPY . /app
# Install frontend dependencies and build # Install frontend dependencies and build
WORKDIR /app/frontend WORKDIR /app/frontend
ARG NPM_REGISTRY=https://registry.npmjs.org/
COPY frontend/package.json frontend/package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY}
RUN npm ci RUN npm ci
COPY frontend/ ./
RUN npm run build RUN npm run build
# Return to app root # Return to app root
@ -46,7 +52,7 @@ FROM python:3.12-slim-bookworm AS runtime
# Install only runtime system dependencies (no build tools) # Install only runtime system dependencies (no build tools)
# Add Node.js 20.x LTS for running frontend # Add Node.js 20.x LTS for running frontend
RUN apt-get update && apt-get upgrade -y && apt-get install -y \ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
supervisor \ supervisor \
curl \ curl \
@ -63,8 +69,8 @@ WORKDIR /app
# Copy the virtual environment from builder stage # Copy the virtual environment from builder stage
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
# Copy the application code # Copy the source code (the rest)
COPY --from=builder /app /app COPY . /app
# Ensure uv uses the existing venv without attempting network operations # Ensure uv uses the existing venv without attempting network operations
ENV UV_NO_SYNC=1 ENV UV_NO_SYNC=1

View file

@ -1,51 +1,39 @@
# Build stage # Stage 1: Frontend Builder
FROM python:3.12-slim-bookworm AS builder FROM node:20-slim AS frontend-builder
# Install uv using the official method
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Install system dependencies required for building certain Python packages
# Add Node.js 20.x LTS for building frontend
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
gcc g++ git make \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Set build optimization environment variables
ENV MAKEFLAGS="-j$(nproc)"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Set the working directory in the container to /app
WORKDIR /app
# Copy dependency files and minimal package structure first for better layer caching
COPY pyproject.toml uv.lock ./
COPY open_notebook/__init__.py ./open_notebook/__init__.py
# Install dependencies with optimizations (this layer will be cached unless dependencies change)
RUN uv sync --frozen --no-dev
# Copy the rest of the application code
COPY . /app
# Install frontend dependencies and build
WORKDIR /app/frontend WORKDIR /app/frontend
# Copy dependency files first to leverage cache
COPY frontend/package.json frontend/package-lock.json ./
ARG NPM_REGISTRY=https://registry.npmjs.org/
RUN npm config set registry ${NPM_REGISTRY}
RUN npm ci RUN npm ci
# Copy the rest of the frontend source
COPY frontend/ ./
# Build the frontend
RUN npm run build RUN npm run build
# Return to app root # Stage 2: Backend Builder
FROM python:3.12-slim-bookworm AS backend-builder
# Install build dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app WORKDIR /app
# Runtime stage # Set build optimization environment variables
ENV UV_HTTP_TIMEOUT=120
# Copy dependency files first
COPY pyproject.toml uv.lock ./
COPY open_notebook/__init__.py ./open_notebook/__init__.py
# Install dependencies
RUN uv sync --frozen --no-dev
# Stage 3: Runtime
FROM python:3.12-slim-bookworm AS runtime FROM python:3.12-slim-bookworm AS runtime
# Install runtime system dependencies including curl for SurrealDB installation # Install runtime dependencies
# Add Node.js 20.x LTS for running frontend
RUN apt-get update && apt-get upgrade -y && apt-get install -y \ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
ffmpeg \ ffmpeg \
supervisor \ supervisor \
@ -57,47 +45,34 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
# Install SurrealDB # Install SurrealDB
RUN curl --proto '=https' --tlsv1.2 -sSf https://install.surrealdb.com | sh RUN curl --proto '=https' --tlsv1.2 -sSf https://install.surrealdb.com | sh
# Install uv using the official method # Install uv (optional but helpful for some scripts)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# Set the working directory in the container to /app
WORKDIR /app WORKDIR /app
# Copy the virtual environment from builder stage # Copy backend virtualenv and source code
COPY --from=builder /app/.venv /app/.venv COPY --from=backend-builder /app/.venv /app/.venv
COPY . /app/
# Copy the application code # Copy built frontend from standalone output
COPY --from=builder /app /app COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
# Copy built frontend from builder stage # Setup directories and permissions
COPY --from=builder /app/frontend/.next/standalone /app/frontend/
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
COPY --from=builder /app/frontend/public /app/frontend/public
# Create directories for data persistence
RUN mkdir -p /app/data /mydata RUN mkdir -p /app/data /mydata
# Copy and make executable the wait-for-api script # Ensure wait-for-api script is executable
COPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh
RUN chmod +x /app/scripts/wait-for-api.sh RUN chmod +x /app/scripts/wait-for-api.sh
# Expose ports for Frontend and API # Copy supervisord configuration
EXPOSE 8502 5055
# Copy single-container supervisord configuration
COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf
# Create log directories # Create log directories
RUN mkdir -p /var/log/supervisor RUN mkdir -p /var/log/supervisor
# Runtime API URL Configuration # Expose ports
# The API_URL environment variable can be set at container runtime to configure EXPOSE 8502 5055
# where the frontend should connect to the API. This allows the same Docker image
# to work in different deployment scenarios without rebuilding.
#
# If not set, the system will auto-detect based on incoming requests.
# Set API_URL when using reverse proxies or custom domains.
#
# Example: docker run -e API_URL=https://your-domain.com/api ...
# Set startup command
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

@ -1,7 +1,7 @@
import os import os
from typing import Optional from typing import Optional
from fastapi import HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
@ -12,35 +12,41 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
Middleware to check password authentication for all API requests. Middleware to check password authentication for all API requests.
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set. Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
""" """
def __init__(self, app, excluded_paths: Optional[list] = None): def __init__(self, app, excluded_paths: Optional[list] = None):
super().__init__(app) super().__init__(app)
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD") self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
self.excluded_paths = excluded_paths or ["/", "/health", "/docs", "/openapi.json", "/redoc"] self.excluded_paths = excluded_paths or [
"/",
"/health",
"/docs",
"/openapi.json",
"/redoc",
]
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Skip authentication if no password is set # Skip authentication if no password is set
if not self.password: if not self.password:
return await call_next(request) return await call_next(request)
# Skip authentication for excluded paths # Skip authentication for excluded paths
if request.url.path in self.excluded_paths: if request.url.path in self.excluded_paths:
return await call_next(request) return await call_next(request)
# Skip authentication for CORS preflight requests (OPTIONS) # Skip authentication for CORS preflight requests (OPTIONS)
if request.method == "OPTIONS": if request.method == "OPTIONS":
return await call_next(request) return await call_next(request)
# Check authorization header # Check authorization header
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
if not auth_header: if not auth_header:
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Missing authorization header"}, content={"detail": "Missing authorization header"},
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
# Expected format: "Bearer {password}" # Expected format: "Bearer {password}"
try: try:
scheme, credentials = auth_header.split(" ", 1) scheme, credentials = auth_header.split(" ", 1)
@ -50,17 +56,17 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Invalid authorization header format"}, content={"detail": "Invalid authorization header format"},
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
# Check password # Check password
if credentials != self.password: if credentials != self.password:
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Invalid password"}, content={"detail": "Invalid password"},
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
# Password is correct, proceed with the request # Password is correct, proceed with the request
response = await call_next(request) response = await call_next(request)
return response return response
@ -70,17 +76,19 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
security = HTTPBearer(auto_error=False) security = HTTPBearer(auto_error=False)
def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = None) -> bool: def check_api_password(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> bool:
""" """
Utility function to check API password. Utility function to check API password.
Can be used as a dependency in individual routes if needed. Can be used as a dependency in individual routes if needed.
""" """
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD") password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
# No password set, allow access # No password set, allow access
if not password: if not password:
return True return True
# No credentials provided # No credentials provided
if not credentials: if not credentials:
raise HTTPException( raise HTTPException(
@ -88,7 +96,7 @@ def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = Non
detail="Missing authorization", detail="Missing authorization",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Check password # Check password
if credentials.credentials != password: if credentials.credentials != password:
raise HTTPException( raise HTTPException(
@ -96,5 +104,5 @@ def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = Non
detail="Invalid password", detail="Invalid password",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return True return True

View file

@ -2,6 +2,7 @@
Chat service for API operations. Chat service for API operations.
Provides async interface for chat functionality. Provides async interface for chat functionality.
""" """
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -11,7 +12,7 @@ from loguru import logger
class ChatService: class ChatService:
"""Service for chat-related API operations""" """Service for chat-related API operations"""
def __init__(self): def __init__(self):
self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055") self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
# Add authentication header if password is set # Add authentication header if password is set
@ -19,7 +20,7 @@ class ChatService:
password = os.getenv("OPEN_NOTEBOOK_PASSWORD") password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
if password: if password:
self.headers["Authorization"] = f"Bearer {password}" self.headers["Authorization"] = f"Bearer {password}"
async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]: async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:
"""Get all chat sessions for a notebook""" """Get all chat sessions for a notebook"""
try: try:
@ -27,14 +28,14 @@ class ChatService:
response = await client.get( response = await client.get(
f"{self.base_url}/api/chat/sessions", f"{self.base_url}/api/chat/sessions",
params={"notebook_id": notebook_id}, params={"notebook_id": notebook_id},
headers=self.headers headers=self.headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error fetching chat sessions: {str(e)}") logger.error(f"Error fetching chat sessions: {str(e)}")
raise raise
async def create_session( async def create_session(
self, self,
notebook_id: str, notebook_id: str,
@ -48,33 +49,33 @@ class ChatService:
data["title"] = title data["title"] = title
if model_override is not None: if model_override is not None:
data["model_override"] = model_override data["model_override"] = model_override
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/chat/sessions", f"{self.base_url}/api/chat/sessions",
json=data, json=data,
headers=self.headers headers=self.headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error creating chat session: {str(e)}") logger.error(f"Error creating chat session: {str(e)}")
raise raise
async def get_session(self, session_id: str) -> Dict[str, Any]: async def get_session(self, session_id: str) -> Dict[str, Any]:
"""Get a specific session with messages""" """Get a specific session with messages"""
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.get(
f"{self.base_url}/api/chat/sessions/{session_id}", f"{self.base_url}/api/chat/sessions/{session_id}",
headers=self.headers headers=self.headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error fetching session: {str(e)}") logger.error(f"Error fetching session: {str(e)}")
raise raise
async def update_session( async def update_session(
self, self,
session_id: str, session_id: str,
@ -90,34 +91,36 @@ class ChatService:
data["model_override"] = model_override data["model_override"] = model_override
if not data: if not data:
raise ValueError("At least one field must be provided to update a session") raise ValueError(
"At least one field must be provided to update a session"
)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.put( response = await client.put(
f"{self.base_url}/api/chat/sessions/{session_id}", f"{self.base_url}/api/chat/sessions/{session_id}",
json=data, json=data,
headers=self.headers headers=self.headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error updating session: {str(e)}") logger.error(f"Error updating session: {str(e)}")
raise raise
async def delete_session(self, session_id: str) -> Dict[str, Any]: async def delete_session(self, session_id: str) -> Dict[str, Any]:
"""Delete a chat session""" """Delete a chat session"""
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.delete( response = await client.delete(
f"{self.base_url}/api/chat/sessions/{session_id}", f"{self.base_url}/api/chat/sessions/{session_id}",
headers=self.headers headers=self.headers,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error deleting session: {str(e)}") logger.error(f"Error deleting session: {str(e)}")
raise raise
async def execute_chat( async def execute_chat(
self, self,
session_id: str, session_id: str,
@ -127,41 +130,32 @@ class ChatService:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a chat request""" """Execute a chat request"""
try: try:
data = { data = {"session_id": session_id, "message": message, "context": context}
"session_id": session_id,
"message": message,
"context": context
}
if model_override is not None: if model_override is not None:
data["model_override"] = model_override data["model_override"] = model_override
# Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs # Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/chat/execute", f"{self.base_url}/api/chat/execute", json=data, headers=self.headers
json=data,
headers=self.headers
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Error executing chat: {str(e)}") logger.error(f"Error executing chat: {str(e)}")
raise raise
async def build_context(self, notebook_id: str, context_config: Dict[str, Any]) -> Dict[str, Any]: async def build_context(
self, notebook_id: str, context_config: Dict[str, Any]
) -> Dict[str, Any]:
"""Build context for a notebook""" """Build context for a notebook"""
try: try:
data = { data = {"notebook_id": notebook_id, "context_config": context_config}
"notebook_id": notebook_id,
"context_config": context_config
}
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/chat/context", f"{self.base_url}/api/chat/context", json=data, headers=self.headers
json=data,
headers=self.headers
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()

View file

@ -23,14 +23,20 @@ class APIClient:
timeout_value = float(timeout_str) timeout_value = float(timeout_str)
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour) # Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
if timeout_value < 30: if timeout_value < 30:
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s") logger.warning(
f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s"
)
timeout_value = 30.0 timeout_value = 30.0
elif timeout_value > 3600: elif timeout_value > 3600:
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s") logger.warning(
f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s"
)
timeout_value = 3600.0 timeout_value = 3600.0
self.timeout = timeout_value self.timeout = timeout_value
except ValueError: except ValueError:
logger.error(f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s") logger.error(
f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s"
)
self.timeout = 300.0 self.timeout = 300.0
# Add authentication header if password is set # Add authentication header if password is set
@ -45,7 +51,7 @@ class APIClient:
"""Make HTTP request to the API.""" """Make HTTP request to the API."""
url = f"{self.base_url}{endpoint}" url = f"{self.base_url}{endpoint}"
request_timeout = timeout if timeout is not None else self.timeout request_timeout = timeout if timeout is not None else self.timeout
# Merge headers # Merge headers
headers = kwargs.get("headers", {}) headers = kwargs.get("headers", {})
headers.update(self.headers) headers.update(self.headers)
@ -82,20 +88,28 @@ class APIClient:
result = self._make_request("GET", "/api/notebooks", params=params) result = self._make_request("GET", "/api/notebooks", params=params)
return result if isinstance(result, list) else [result] return result if isinstance(result, list) else [result]
def create_notebook(self, name: str, description: str = "") -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def create_notebook(
self, name: str, description: str = ""
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new notebook.""" """Create a new notebook."""
data = {"name": name, "description": description} data = {"name": name, "description": description}
return self._make_request("POST", "/api/notebooks", json=data) return self._make_request("POST", "/api/notebooks", json=data)
def get_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_notebook(
self, notebook_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific notebook.""" """Get a specific notebook."""
return self._make_request("GET", f"/api/notebooks/{notebook_id}") return self._make_request("GET", f"/api/notebooks/{notebook_id}")
def update_notebook(self, notebook_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_notebook(
self, notebook_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a notebook.""" """Update a notebook."""
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates) return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
def delete_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_notebook(
self, notebook_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a notebook.""" """Delete a notebook."""
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}") return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
@ -148,7 +162,9 @@ class APIClient:
result = self._make_request("GET", "/api/models", params=params) result = self._make_request("GET", "/api/models", params=params)
return result if isinstance(result, list) else [result] return result if isinstance(result, list) else [result]
def create_model(self, name: str, provider: str, model_type: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def create_model(
self, name: str, provider: str, model_type: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Create a new model.""" """Create a new model."""
data = { data = {
"name": name, "name": name,
@ -157,7 +173,9 @@ class APIClient:
} }
return self._make_request("POST", "/api/models", json=data) return self._make_request("POST", "/api/models", json=data)
def delete_model(self, model_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_model(
self, model_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a model.""" """Delete a model."""
return self._make_request("DELETE", f"/api/models/{model_id}") return self._make_request("DELETE", f"/api/models/{model_id}")
@ -165,7 +183,9 @@ class APIClient:
"""Get default model assignments.""" """Get default model assignments."""
return self._make_request("GET", "/api/models/defaults") return self._make_request("GET", "/api/models/defaults")
def update_default_models(self, **defaults) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_default_models(
self, **defaults
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update default model assignments.""" """Update default model assignments."""
return self._make_request("PUT", "/api/models/defaults", json=defaults) return self._make_request("PUT", "/api/models/defaults", json=defaults)
@ -193,17 +213,23 @@ class APIClient:
} }
return self._make_request("POST", "/api/transformations", json=data) return self._make_request("POST", "/api/transformations", json=data)
def get_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_transformation(
self, transformation_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific transformation.""" """Get a specific transformation."""
return self._make_request("GET", f"/api/transformations/{transformation_id}") return self._make_request("GET", f"/api/transformations/{transformation_id}")
def update_transformation(self, transformation_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_transformation(
self, transformation_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a transformation.""" """Update a transformation."""
return self._make_request( return self._make_request(
"PUT", f"/api/transformations/{transformation_id}", json=updates "PUT", f"/api/transformations/{transformation_id}", json=updates
) )
def delete_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_transformation(
self, transformation_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a transformation.""" """Delete a transformation."""
return self._make_request("DELETE", f"/api/transformations/{transformation_id}") return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
@ -252,7 +278,9 @@ class APIClient:
"""Get a specific note.""" """Get a specific note."""
return self._make_request("GET", f"/api/notes/{note_id}") return self._make_request("GET", f"/api/notes/{note_id}")
def update_note(self, note_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_note(
self, note_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a note.""" """Update a note."""
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates) return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
@ -261,7 +289,9 @@ class APIClient:
return self._make_request("DELETE", f"/api/notes/{note_id}") return self._make_request("DELETE", f"/api/notes/{note_id}")
# Embedding API methods # Embedding API methods
def embed_content(self, item_id: str, item_type: str, async_processing: bool = False) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def embed_content(
self, item_id: str, item_type: str, async_processing: bool = False
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Embed content for vector search.""" """Embed content for vector search."""
data = { data = {
"item_id": item_id, "item_id": item_id,
@ -276,7 +306,7 @@ class APIClient:
mode: str = "existing", mode: str = "existing",
include_sources: bool = True, include_sources: bool = True,
include_notes: bool = True, include_notes: bool = True,
include_insights: bool = True include_insights: bool = True,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Rebuild embeddings in bulk. """Rebuild embeddings in bulk.
@ -291,9 +321,13 @@ class APIClient:
} }
# Use double the configured timeout for bulk rebuild operations (or configured value if already high) # Use double the configured timeout for bulk rebuild operations (or configured value if already high)
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0)) rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
return self._make_request("POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout) return self._make_request(
"POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout
)
def get_rebuild_status(self, command_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_rebuild_status(
self, command_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get status of a rebuild operation.""" """Get status of a rebuild operation."""
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status") return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
@ -302,7 +336,9 @@ class APIClient:
"""Get all application settings.""" """Get all application settings."""
return self._make_request("GET", "/api/settings") return self._make_request("GET", "/api/settings")
def update_settings(self, **settings) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_settings(
self, **settings
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update application settings.""" """Update application settings."""
return self._make_request("PUT", "/api/settings", json=settings) return self._make_request("PUT", "/api/settings", json=settings)
@ -370,21 +406,29 @@ class APIClient:
data["transformations"] = transformations data["transformations"] = transformations
# Use configured timeout for source creation (especially PDF processing with OCR) # Use configured timeout for source creation (especially PDF processing with OCR)
return self._make_request("POST", "/api/sources/json", json=data, timeout=self.timeout) return self._make_request(
"POST", "/api/sources/json", json=data, timeout=self.timeout
)
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific source.""" """Get a specific source."""
return self._make_request("GET", f"/api/sources/{source_id}") return self._make_request("GET", f"/api/sources/{source_id}")
def get_source_status(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_source_status(
self, source_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get processing status for a source.""" """Get processing status for a source."""
return self._make_request("GET", f"/api/sources/{source_id}/status") return self._make_request("GET", f"/api/sources/{source_id}/status")
def update_source(self, source_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_source(
self, source_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update a source.""" """Update a source."""
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates) return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
def delete_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_source(
self, source_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a source.""" """Delete a source."""
return self._make_request("DELETE", f"/api/sources/{source_id}") return self._make_request("DELETE", f"/api/sources/{source_id}")
@ -394,11 +438,15 @@ class APIClient:
result = self._make_request("GET", f"/api/sources/{source_id}/insights") result = self._make_request("GET", f"/api/sources/{source_id}/insights")
return result if isinstance(result, list) else [result] return result if isinstance(result, list) else [result]
def get_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_insight(
self, insight_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific insight.""" """Get a specific insight."""
return self._make_request("GET", f"/api/insights/{insight_id}") return self._make_request("GET", f"/api/insights/{insight_id}")
def delete_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_insight(
self, insight_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete a specific insight.""" """Delete a specific insight."""
return self._make_request("DELETE", f"/api/insights/{insight_id}") return self._make_request("DELETE", f"/api/insights/{insight_id}")
@ -430,7 +478,9 @@ class APIClient:
result = self._make_request("GET", "/api/episode-profiles") result = self._make_request("GET", "/api/episode-profiles")
return result if isinstance(result, list) else [result] return result if isinstance(result, list) else [result]
def get_episode_profile(self, profile_name: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def get_episode_profile(
self, profile_name: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get a specific episode profile by name.""" """Get a specific episode profile by name."""
return self._make_request("GET", f"/api/episode-profiles/{profile_name}") return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
@ -460,11 +510,17 @@ class APIClient:
} }
return self._make_request("POST", "/api/episode-profiles", json=data) return self._make_request("POST", "/api/episode-profiles", json=data)
def update_episode_profile(self, profile_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def update_episode_profile(
self, profile_id: str, **updates
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Update an episode profile.""" """Update an episode profile."""
return self._make_request("PUT", f"/api/episode-profiles/{profile_id}", json=updates) return self._make_request(
"PUT", f"/api/episode-profiles/{profile_id}", json=updates
)
def delete_episode_profile(self, profile_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def delete_episode_profile(
self, profile_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Delete an episode profile.""" """Delete an episode profile."""
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}") return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")

View file

@ -16,17 +16,14 @@ class ContextService:
logger.info("Using API for context operations") logger.info("Using API for context operations")
def get_notebook_context( def get_notebook_context(
self, self, notebook_id: str, context_config: Optional[Dict] = None
notebook_id: str,
context_config: Optional[Dict] = None
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get context for a notebook.""" """Get context for a notebook."""
result = api_client.get_notebook_context( result = api_client.get_notebook_context(
notebook_id=notebook_id, notebook_id=notebook_id, context_config=context_config
context_config=context_config
) )
return result return result
# Global service instance # Global service instance
context_service = ContextService() context_service = ContextService()

View file

@ -15,11 +15,13 @@ class EmbeddingService:
def __init__(self): def __init__(self):
logger.info("Using API for embedding operations") logger.info("Using API for embedding operations")
def embed_content(self, item_id: str, item_type: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: def embed_content(
self, item_id: str, item_type: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Embed content for vector search.""" """Embed content for vector search."""
result = api_client.embed_content(item_id=item_id, item_type=item_type) result = api_client.embed_content(item_id=item_id, item_type=item_type)
return result return result
# Global service instance # Global service instance
embedding_service = EmbeddingService() embedding_service = EmbeddingService()

View file

@ -12,10 +12,10 @@ from open_notebook.podcasts.models import EpisodeProfile
class EpisodeProfilesService: class EpisodeProfilesService:
"""Service layer for episode profiles operations using API.""" """Service layer for episode profiles operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for episode profiles operations") logger.info("Using API for episode profiles operations")
def get_all_episode_profiles(self) -> List[EpisodeProfile]: def get_all_episode_profiles(self) -> List[EpisodeProfile]:
"""Get all episode profiles.""" """Get all episode profiles."""
profiles_data = api_client.get_episode_profiles() profiles_data = api_client.get_episode_profiles()
@ -31,16 +31,20 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"], transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"], transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"], default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"] num_segments=profile_data["num_segments"],
) )
profile.id = profile_data["id"] profile.id = profile_data["id"]
profiles.append(profile) profiles.append(profile)
return profiles return profiles
def get_episode_profile(self, profile_name: str) -> EpisodeProfile: def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
"""Get a specific episode profile by name.""" """Get a specific episode profile by name."""
profile_response = api_client.get_episode_profile(profile_name) profile_response = api_client.get_episode_profile(profile_name)
profile_data = profile_response if isinstance(profile_response, dict) else profile_response[0] profile_data = (
profile_response
if isinstance(profile_response, dict)
else profile_response[0]
)
profile = EpisodeProfile( profile = EpisodeProfile(
name=profile_data["name"], name=profile_data["name"],
description=profile_data.get("description", ""), description=profile_data.get("description", ""),
@ -50,11 +54,11 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"], transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"], transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"], default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"] num_segments=profile_data["num_segments"],
) )
profile.id = profile_data["id"] profile.id = profile_data["id"]
return profile return profile
def create_episode_profile( def create_episode_profile(
self, self,
name: str, name: str,
@ -79,7 +83,11 @@ class EpisodeProfilesService:
default_briefing=default_briefing, default_briefing=default_briefing,
num_segments=num_segments, num_segments=num_segments,
) )
profile_data = profile_response if isinstance(profile_response, dict) else profile_response[0] profile_data = (
profile_response
if isinstance(profile_response, dict)
else profile_response[0]
)
profile = EpisodeProfile( profile = EpisodeProfile(
name=profile_data["name"], name=profile_data["name"],
description=profile_data.get("description", ""), description=profile_data.get("description", ""),
@ -89,11 +97,11 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"], transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"], transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"], default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"] num_segments=profile_data["num_segments"],
) )
profile.id = profile_data["id"] profile.id = profile_data["id"]
return profile return profile
def delete_episode_profile(self, profile_id: str) -> bool: def delete_episode_profile(self, profile_id: str) -> bool:
"""Delete an episode profile.""" """Delete an episode profile."""
api_client.delete_episode_profile(profile_id) api_client.delete_episode_profile(profile_id)
@ -101,4 +109,4 @@ class EpisodeProfilesService:
# Global service instance # Global service instance
episode_profiles_service = EpisodeProfilesService() episode_profiles_service = EpisodeProfilesService()

View file

@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Note, SourceInsight
class InsightsService: class InsightsService:
"""Service layer for insights operations using API.""" """Service layer for insights operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for insights operations") logger.info("Using API for insights operations")
def get_source_insights(self, source_id: str) -> List[SourceInsight]: def get_source_insights(self, source_id: str) -> List[SourceInsight]:
"""Get all insights for a specific source.""" """Get all insights for a specific source."""
insights_data = api_client.get_source_insights(source_id) insights_data = api_client.get_source_insights(source_id)
@ -31,11 +31,15 @@ class InsightsService:
insight.updated = insight_data["updated"] insight.updated = insight_data["updated"]
insights.append(insight) insights.append(insight)
return insights return insights
def get_insight(self, insight_id: str) -> SourceInsight: def get_insight(self, insight_id: str) -> SourceInsight:
"""Get a specific insight.""" """Get a specific insight."""
insight_response = api_client.get_insight(insight_id) insight_response = api_client.get_insight(insight_id)
insight_data = insight_response if isinstance(insight_response, dict) else insight_response[0] insight_data = (
insight_response
if isinstance(insight_response, dict)
else insight_response[0]
)
insight = SourceInsight( insight = SourceInsight(
insight_type=insight_data["insight_type"], insight_type=insight_data["insight_type"],
content=insight_data["content"], content=insight_data["content"],
@ -45,16 +49,20 @@ class InsightsService:
insight.updated = insight_data["updated"] insight.updated = insight_data["updated"]
# Note: source_id from API response is not stored; use await insight.get_source() if needed # Note: source_id from API response is not stored; use await insight.get_source() if needed
return insight return insight
def delete_insight(self, insight_id: str) -> bool: def delete_insight(self, insight_id: str) -> bool:
"""Delete a specific insight.""" """Delete a specific insight."""
api_client.delete_insight(insight_id) api_client.delete_insight(insight_id)
return True return True
def save_insight_as_note(self, insight_id: str, notebook_id: Optional[str] = None) -> Note: def save_insight_as_note(
self, insight_id: str, notebook_id: Optional[str] = None
) -> Note:
"""Convert an insight to a note.""" """Convert an insight to a note."""
note_response = api_client.save_insight_as_note(insight_id, notebook_id) note_response = api_client.save_insight_as_note(insight_id, notebook_id)
note_data = note_response if isinstance(note_response, dict) else note_response[0] note_data = (
note_response if isinstance(note_response, dict) else note_response[0]
)
note = Note( note = Note(
title=note_data["title"], title=note_data["title"],
content=note_data["content"], content=note_data["content"],
@ -64,11 +72,19 @@ class InsightsService:
note.created = note_data["created"] note.created = note_data["created"]
note.updated = note_data["updated"] note.updated = note_data["updated"]
return note return note
def create_source_insight(self, source_id: str, transformation_id: str, model_id: Optional[str] = None) -> SourceInsight: def create_source_insight(
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
) -> SourceInsight:
"""Create a new insight for a source by running a transformation.""" """Create a new insight for a source by running a transformation."""
insight_response = api_client.create_source_insight(source_id, transformation_id, model_id) insight_response = api_client.create_source_insight(
insight_data = insight_response if isinstance(insight_response, dict) else insight_response[0] source_id, transformation_id, model_id
)
insight_data = (
insight_response
if isinstance(insight_response, dict)
else insight_response[0]
)
insight = SourceInsight( insight = SourceInsight(
insight_type=insight_data["insight_type"], insight_type=insight_data["insight_type"],
content=insight_data["content"], content=insight_data["content"],
@ -81,4 +97,4 @@ class InsightsService:
# Global service instance # Global service instance
insights_service = InsightsService() insights_service = InsightsService()

View file

@ -37,7 +37,6 @@ from open_notebook.database.async_migrate import AsyncMigrationManager
# Import commands to register them in the API process # Import commands to register them in the API process
try: try:
logger.info("Commands imported in API process") logger.info("Commands imported in API process")
except Exception as e: except Exception as e:
logger.error(f"Failed to import commands in API process: {e}") logger.error(f"Failed to import commands in API process: {e}")
@ -61,9 +60,13 @@ async def lifespan(app: FastAPI):
logger.warning("Database migrations are pending. Running migrations...") logger.warning("Database migrations are pending. Running migrations...")
await migration_manager.run_migration_up() await migration_manager.run_migration_up()
new_version = await migration_manager.get_current_version() new_version = await migration_manager.get_current_version()
logger.success(f"Migrations completed successfully. Database is now at version {new_version}") logger.success(
f"Migrations completed successfully. Database is now at version {new_version}"
)
else: else:
logger.info("Database is already at the latest version. No migrations needed.") logger.info(
"Database is already at the latest version. No migrations needed."
)
except Exception as e: except Exception as e:
logger.error(f"CRITICAL: Database migration failed: {str(e)}") logger.error(f"CRITICAL: Database migration failed: {str(e)}")
logger.exception(e) logger.exception(e)
@ -88,7 +91,18 @@ app = FastAPI(
# Add password authentication middleware first # Add password authentication middleware first
# Exclude /api/auth/status and /api/config from authentication # Exclude /api/auth/status and /api/config from authentication
app.add_middleware(PasswordAuthMiddleware, excluded_paths=["/", "/health", "/docs", "/openapi.json", "/redoc", "/api/auth/status", "/api/config"]) app.add_middleware(
PasswordAuthMiddleware,
excluded_paths=[
"/",
"/health",
"/docs",
"/openapi.json",
"/redoc",
"/api/auth/status",
"/api/config",
],
)
# Add CORS middleware last (so it processes first) # Add CORS middleware last (so it processes first)
app.add_middleware( app.add_middleware(
@ -119,7 +133,7 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
status_code=exc.status_code, status_code=exc.status_code,
content={"detail": exc.detail}, content={"detail": exc.detail},
headers={ headers={
"Access-Control-Allow-Origin": origin, **(exc.headers or {}), "Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*", "Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*", "Access-Control-Allow-Headers": "*",
@ -136,7 +150,9 @@ app.include_router(models.router, prefix="/api", tags=["models"])
app.include_router(transformations.router, prefix="/api", tags=["transformations"]) app.include_router(transformations.router, prefix="/api", tags=["transformations"])
app.include_router(notes.router, prefix="/api", tags=["notes"]) app.include_router(notes.router, prefix="/api", tags=["notes"])
app.include_router(embedding.router, prefix="/api", tags=["embedding"]) app.include_router(embedding.router, prefix="/api", tags=["embedding"])
app.include_router(embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]) app.include_router(
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
)
app.include_router(settings.router, prefix="/api", tags=["settings"]) app.include_router(settings.router, prefix="/api", tags=["settings"])
app.include_router(context.router, prefix="/api", tags=["context"]) app.include_router(context.router, prefix="/api", tags=["context"])
app.include_router(sources.router, prefix="/api", tags=["sources"]) app.include_router(sources.router, prefix="/api", tags=["sources"])

View file

@ -12,10 +12,10 @@ from open_notebook.ai.models import DefaultModels, Model
class ModelsService: class ModelsService:
"""Service layer for models operations using API.""" """Service layer for models operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for models operations") logger.info("Using API for models operations")
def get_all_models(self, model_type: Optional[str] = None) -> List[Model]: def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
"""Get all models with optional type filtering.""" """Get all models with optional type filtering."""
models_data = api_client.get_models(model_type=model_type) models_data = api_client.get_models(model_type=model_type)
@ -32,7 +32,7 @@ class ModelsService:
model.updated = model_data["updated"] model.updated = model_data["updated"]
models.append(model) models.append(model)
return models return models
def create_model(self, name: str, provider: str, model_type: str) -> Model: def create_model(self, name: str, provider: str, model_type: str) -> Model:
"""Create a new model.""" """Create a new model."""
response = api_client.create_model(name, provider, model_type) response = api_client.create_model(name, provider, model_type)
@ -46,12 +46,12 @@ class ModelsService:
model.created = model_data["created"] model.created = model_data["created"]
model.updated = model_data["updated"] model.updated = model_data["updated"]
return model return model
def delete_model(self, model_id: str) -> bool: def delete_model(self, model_id: str) -> bool:
"""Delete a model.""" """Delete a model."""
api_client.delete_model(model_id) api_client.delete_model(model_id)
return True return True
def get_default_models(self) -> DefaultModels: def get_default_models(self) -> DefaultModels:
"""Get default model assignments.""" """Get default model assignments."""
response = api_client.get_default_models() response = api_client.get_default_models()
@ -60,15 +60,21 @@ class ModelsService:
# Set the values from API response # Set the values from API response
defaults.default_chat_model = defaults_data.get("default_chat_model") defaults.default_chat_model = defaults_data.get("default_chat_model")
defaults.default_transformation_model = defaults_data.get("default_transformation_model") defaults.default_transformation_model = defaults_data.get(
"default_transformation_model"
)
defaults.large_context_model = defaults_data.get("large_context_model") defaults.large_context_model = defaults_data.get("large_context_model")
defaults.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model") defaults.default_text_to_speech_model = defaults_data.get(
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model") "default_text_to_speech_model"
)
defaults.default_speech_to_text_model = defaults_data.get(
"default_speech_to_text_model"
)
defaults.default_embedding_model = defaults_data.get("default_embedding_model") defaults.default_embedding_model = defaults_data.get("default_embedding_model")
defaults.default_tools_model = defaults_data.get("default_tools_model") defaults.default_tools_model = defaults_data.get("default_tools_model")
return defaults return defaults
def update_default_models(self, defaults: DefaultModels) -> DefaultModels: def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
"""Update default model assignments.""" """Update default model assignments."""
updates = { updates = {
@ -86,10 +92,16 @@ class ModelsService:
# Update the defaults object with the response # Update the defaults object with the response
defaults.default_chat_model = defaults_data.get("default_chat_model") defaults.default_chat_model = defaults_data.get("default_chat_model")
defaults.default_transformation_model = defaults_data.get("default_transformation_model") defaults.default_transformation_model = defaults_data.get(
"default_transformation_model"
)
defaults.large_context_model = defaults_data.get("large_context_model") defaults.large_context_model = defaults_data.get("large_context_model")
defaults.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model") defaults.default_text_to_speech_model = defaults_data.get(
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model") "default_text_to_speech_model"
)
defaults.default_speech_to_text_model = defaults_data.get(
"default_speech_to_text_model"
)
defaults.default_embedding_model = defaults_data.get("default_embedding_model") defaults.default_embedding_model = defaults_data.get("default_embedding_model")
defaults.default_tools_model = defaults_data.get("default_tools_model") defaults.default_tools_model = defaults_data.get("default_tools_model")
@ -97,4 +109,4 @@ class ModelsService:
# Global service instance # Global service instance
models_service = ModelsService() models_service = ModelsService()

View file

@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Notebook
class NotebookService: class NotebookService:
"""Service layer for notebook operations using API.""" """Service layer for notebook operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for notebook operations") logger.info("Using API for notebook operations")
def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]: def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]:
"""Get all notebooks.""" """Get all notebooks."""
notebooks_data = api_client.get_notebooks(order_by=order_by) notebooks_data = api_client.get_notebooks(order_by=order_by)
@ -32,7 +32,7 @@ class NotebookService:
nb.updated = nb_data["updated"] nb.updated = nb_data["updated"]
notebooks.append(nb) notebooks.append(nb)
return notebooks return notebooks
def get_notebook(self, notebook_id: str) -> Optional[Notebook]: def get_notebook(self, notebook_id: str) -> Optional[Notebook]:
"""Get a specific notebook.""" """Get a specific notebook."""
response = api_client.get_notebook(notebook_id) response = api_client.get_notebook(notebook_id)
@ -60,7 +60,7 @@ class NotebookService:
nb.created = nb_data["created"] nb.created = nb_data["created"]
nb.updated = nb_data["updated"] nb.updated = nb_data["updated"]
return nb return nb
def update_notebook(self, notebook: Notebook) -> Notebook: def update_notebook(self, notebook: Notebook) -> Notebook:
"""Update a notebook.""" """Update a notebook."""
updates = { updates = {
@ -76,7 +76,7 @@ class NotebookService:
notebook.archived = nb_data["archived"] notebook.archived = nb_data["archived"]
notebook.updated = nb_data["updated"] notebook.updated = nb_data["updated"]
return notebook return notebook
def delete_notebook(self, notebook: Notebook) -> bool: def delete_notebook(self, notebook: Notebook) -> bool:
"""Delete a notebook.""" """Delete a notebook."""
api_client.delete_notebook(notebook.id or "") api_client.delete_notebook(notebook.id or "")
@ -84,4 +84,4 @@ class NotebookService:
# Global service instance # Global service instance
notebook_service = NotebookService() notebook_service = NotebookService()

View file

@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Note
class NotesService: class NotesService:
"""Service layer for notes operations using API.""" """Service layer for notes operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for notes operations") logger.info("Using API for notes operations")
def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]: def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:
"""Get all notes with optional notebook filtering.""" """Get all notes with optional notebook filtering."""
notes_data = api_client.get_notes(notebook_id=notebook_id) notes_data = api_client.get_notes(notebook_id=notebook_id)
@ -32,11 +32,13 @@ class NotesService:
note.updated = note_data["updated"] note.updated = note_data["updated"]
notes.append(note) notes.append(note)
return notes return notes
def get_note(self, note_id: str) -> Note: def get_note(self, note_id: str) -> Note:
"""Get a specific note.""" """Get a specific note."""
note_response = api_client.get_note(note_id) note_response = api_client.get_note(note_id)
note_data = note_response if isinstance(note_response, dict) else note_response[0] note_data = (
note_response if isinstance(note_response, dict) else note_response[0]
)
note = Note( note = Note(
title=note_data["title"], title=note_data["title"],
content=note_data["content"], content=note_data["content"],
@ -46,22 +48,21 @@ class NotesService:
note.created = note_data["created"] note.created = note_data["created"]
note.updated = note_data["updated"] note.updated = note_data["updated"]
return note return note
def create_note( def create_note(
self, self,
content: str, content: str,
title: Optional[str] = None, title: Optional[str] = None,
note_type: str = "human", note_type: str = "human",
notebook_id: Optional[str] = None notebook_id: Optional[str] = None,
) -> Note: ) -> Note:
"""Create a new note.""" """Create a new note."""
note_response = api_client.create_note( note_response = api_client.create_note(
content=content, content=content, title=title, note_type=note_type, notebook_id=notebook_id
title=title, )
note_type=note_type, note_data = (
notebook_id=notebook_id note_response if isinstance(note_response, dict) else note_response[0]
) )
note_data = note_response if isinstance(note_response, dict) else note_response[0]
note = Note( note = Note(
title=note_data["title"], title=note_data["title"],
content=note_data["content"], content=note_data["content"],
@ -71,7 +72,7 @@ class NotesService:
note.created = note_data["created"] note.created = note_data["created"]
note.updated = note_data["updated"] note.updated = note_data["updated"]
return note return note
def update_note(self, note: Note) -> Note: def update_note(self, note: Note) -> Note:
"""Update a note.""" """Update a note."""
updates = { updates = {
@ -80,7 +81,9 @@ class NotesService:
"note_type": note.note_type, "note_type": note.note_type,
} }
note_response = api_client.update_note(note.id or "", **updates) note_response = api_client.update_note(note.id or "", **updates)
note_data = note_response if isinstance(note_response, dict) else note_response[0] note_data = (
note_response if isinstance(note_response, dict) else note_response[0]
)
# Update the note object with the response # Update the note object with the response
note.title = note_data["title"] note.title = note_data["title"]
@ -89,7 +92,7 @@ class NotesService:
note.updated = note_data["updated"] note.updated = note_data["updated"]
return note return note
def delete_note(self, note_id: str) -> bool: def delete_note(self, note_id: str) -> bool:
"""Delete a note.""" """Delete a note."""
api_client.delete_note(note_id) api_client.delete_note(note_id)
@ -97,4 +100,4 @@ class NotesService:
# Global service instance # Global service instance
notes_service = NotesService() notes_service = NotesService()

View file

@ -20,5 +20,7 @@ async def get_auth_status():
return { return {
"auth_enabled": auth_enabled, "auth_enabled": auth_enabled,
"message": "Authentication is required" if auth_enabled else "Authentication is disabled" "message": "Authentication is required"
if auth_enabled
else "Authentication is disabled",
} }

View file

@ -15,6 +15,7 @@ from open_notebook.graphs.chat import graph as chat_graph
router = APIRouter() router = APIRouter()
# Request/Response models # Request/Response models
class CreateSessionRequest(BaseModel): class CreateSessionRequest(BaseModel):
notebook_id: str = Field(..., description="Notebook ID to create session for") notebook_id: str = Field(..., description="Notebook ID to create session for")
@ -134,7 +135,8 @@ async def create_session(request: CreateSessionRequest):
# Create new session # Create new session
session = ChatSession( session = ChatSession(
title=request.title or f"Chat Session {asyncio.get_event_loop().time():.0f}", title=request.title
or f"Chat Session {asyncio.get_event_loop().time():.0f}",
model_override=request.model_override, model_override=request.model_override,
) )
await session.save() await session.save()
@ -334,9 +336,7 @@ async def execute_chat(request: ExecuteChatRequest):
# Get current state # Get current state
current_state = chat_graph.get_state( current_state = chat_graph.get_state(
config=RunnableConfig( config=RunnableConfig(configurable={"thread_id": request.session_id})
configurable={"thread_id": request.session_id}
)
) )
# Prepare state for execution # Prepare state for execution

View file

@ -9,16 +9,21 @@ from api.command_service import CommandService
router = APIRouter() router = APIRouter()
class CommandExecutionRequest(BaseModel): class CommandExecutionRequest(BaseModel):
command: str = Field(..., description="Command function name (e.g., 'process_text')") command: str = Field(
..., description="Command function name (e.g., 'process_text')"
)
app: str = Field(..., description="Application name (e.g., 'open_notebook')") app: str = Field(..., description="Application name (e.g., 'open_notebook')")
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command") input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
class CommandJobResponse(BaseModel): class CommandJobResponse(BaseModel):
job_id: str job_id: str
status: str status: str
message: str message: str
class CommandJobStatusResponse(BaseModel): class CommandJobStatusResponse(BaseModel):
job_id: str job_id: str
status: str status: str
@ -28,19 +33,20 @@ class CommandJobStatusResponse(BaseModel):
updated: Optional[str] = None updated: Optional[str] = None
progress: Optional[Dict[str, Any]] = None progress: Optional[Dict[str, Any]] = None
@router.post("/commands/jobs", response_model=CommandJobResponse) @router.post("/commands/jobs", response_model=CommandJobResponse)
async def execute_command(request: CommandExecutionRequest): async def execute_command(request: CommandExecutionRequest):
""" """
Submit a command for background processing. Submit a command for background processing.
Returns immediately with job ID for status tracking. Returns immediately with job ID for status tracking.
Example request: Example request:
{ {
"command": "process_text", "command": "process_text",
"app": "open_notebook", "app": "open_notebook",
"input": { "input": {
"text": "Hello world", "text": "Hello world",
"operation": "uppercase" "operation": "uppercase"
} }
} }
""" """
@ -49,91 +55,91 @@ async def execute_command(request: CommandExecutionRequest):
job_id = await CommandService.submit_command_job( job_id = await CommandService.submit_command_job(
module_name=request.app, # This should be "open_notebook" module_name=request.app, # This should be "open_notebook"
command_name=request.command, command_name=request.command,
command_args=request.input command_args=request.input,
) )
return CommandJobResponse( return CommandJobResponse(
job_id=job_id, job_id=job_id,
status="submitted", status="submitted",
message=f"Command '{request.command}' submitted successfully" message=f"Command '{request.command}' submitted successfully",
) )
except Exception as e: except Exception as e:
logger.error(f"Error submitting command: {str(e)}") logger.error(f"Error submitting command: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to submit command"
detail=f"Failed to submit command: {str(e)}"
) )
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse) @router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
async def get_command_job_status(job_id: str): async def get_command_job_status(job_id: str):
"""Get the status of a specific command job""" """Get the status of a specific command job"""
try: try:
status_data = await CommandService.get_command_status(job_id) status_data = await CommandService.get_command_status(job_id)
return CommandJobStatusResponse(**status_data) return CommandJobStatusResponse(**status_data)
except Exception as e: except Exception as e:
logger.error(f"Error fetching job status: {str(e)}") logger.error(f"Error fetching job status: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to fetch job status"
detail=f"Failed to fetch job status: {str(e)}"
) )
@router.get("/commands/jobs", response_model=List[Dict[str, Any]]) @router.get("/commands/jobs", response_model=List[Dict[str, Any]])
async def list_command_jobs( async def list_command_jobs(
command_filter: Optional[str] = Query(None, description="Filter by command name"), command_filter: Optional[str] = Query(None, description="Filter by command name"),
status_filter: Optional[str] = Query(None, description="Filter by status"), status_filter: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, description="Maximum number of jobs to return") limit: int = Query(50, description="Maximum number of jobs to return"),
): ):
"""List command jobs with optional filtering""" """List command jobs with optional filtering"""
try: try:
jobs = await CommandService.list_command_jobs( jobs = await CommandService.list_command_jobs(
command_filter=command_filter, command_filter=command_filter, status_filter=status_filter, limit=limit
status_filter=status_filter,
limit=limit
) )
return jobs return jobs
except Exception as e: except Exception as e:
logger.error(f"Error listing command jobs: {str(e)}") logger.error(f"Error listing command jobs: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to list command jobs"
detail=f"Failed to list command jobs: {str(e)}"
) )
@router.delete("/commands/jobs/{job_id}") @router.delete("/commands/jobs/{job_id}")
async def cancel_command_job(job_id: str): async def cancel_command_job(job_id: str):
"""Cancel a running command job""" """Cancel a running command job"""
try: try:
success = await CommandService.cancel_command_job(job_id) success = await CommandService.cancel_command_job(job_id)
return {"job_id": job_id, "cancelled": success} return {"job_id": job_id, "cancelled": success}
except Exception as e: except Exception as e:
logger.error(f"Error cancelling command job: {str(e)}") logger.error(f"Error cancelling command job: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to cancel command job"
detail=f"Failed to cancel command job: {str(e)}"
) )
@router.get("/commands/registry/debug") @router.get("/commands/registry/debug")
async def debug_registry(): async def debug_registry():
"""Debug endpoint to see what commands are registered""" """Debug endpoint to see what commands are registered"""
try: try:
# Get all registered commands # Get all registered commands
all_items = registry.get_all_commands() all_items = registry.get_all_commands()
# Create JSON-serializable data # Create JSON-serializable data
command_items = [] command_items = []
for item in all_items: for item in all_items:
try: try:
command_items.append({ command_items.append(
"app_id": item.app_id, {
"name": item.name, "app_id": item.app_id,
"full_id": f"{item.app_id}.{item.name}" "name": item.name,
}) "full_id": f"{item.app_id}.{item.name}",
}
)
except Exception as item_error: except Exception as item_error:
logger.error(f"Error processing item: {item_error}") logger.error(f"Error processing item: {item_error}")
# Get the basic command structure # Get the basic command structure
try: try:
commands_dict: dict[str, list[str]] = {} commands_dict: dict[str, list[str]] = {}
@ -143,18 +149,18 @@ async def debug_registry():
commands_dict[item.app_id].append(item.name) commands_dict[item.app_id].append(item.name)
except Exception: except Exception:
commands_dict = {} commands_dict = {}
return { return {
"total_commands": len(all_items), "total_commands": len(all_items),
"commands_by_app": commands_dict, "commands_by_app": commands_dict,
"command_items": command_items "command_items": command_items,
} }
except Exception as e: except Exception as e:
logger.error(f"Error debugging registry: {str(e)}") logger.error(f"Error debugging registry: {str(e)}")
return { return {
"error": str(e), "error": str(e),
"total_commands": 0, "total_commands": 0,
"commands_by_app": {}, "commands_by_app": {},
"command_items": [] "command_items": [],
} }

View file

@ -11,7 +11,7 @@ from loguru import logger
from open_notebook.database.repository import repo_query from open_notebook.database.repository import repo_query
from open_notebook.utils.version_utils import ( from open_notebook.utils.version_utils import (
compare_versions, compare_versions,
get_version_from_github, get_version_from_github_async,
) )
router = APIRouter() router = APIRouter()
@ -40,7 +40,7 @@ def get_version() -> str:
return "unknown" return "unknown"
def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]: async def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
""" """
Check for the latest version from GitHub with caching. Check for the latest version from GitHub with caching.
@ -66,12 +66,13 @@ def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool
logger.info("Checking for latest version from GitHub...") logger.info("Checking for latest version from GitHub...")
# Fetch latest version from GitHub with 10-second timeout # Fetch latest version from GitHub with 10-second timeout
latest_version = get_version_from_github( latest_version = await get_version_from_github_async(
"https://github.com/lfnovo/open-notebook", "https://github.com/lfnovo/open-notebook", "main"
"main"
) )
logger.info(f"Latest version from GitHub: {latest_version}, Current version: {current_version}") logger.info(
f"Latest version from GitHub: {latest_version}, Current version: {current_version}"
)
# Compare versions # Compare versions
has_update = compare_versions(current_version, latest_version) < 0 has_update = compare_versions(current_version, latest_version) < 0
@ -107,10 +108,7 @@ async def check_database_health() -> dict:
""" """
try: try:
# 2-second timeout for database health check # 2-second timeout for database health check
result = await asyncio.wait_for( result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
repo_query("RETURN 1"),
timeout=2.0
)
if result: if result:
return {"status": "online"} return {"status": "online"}
return {"status": "offline", "error": "Empty result"} return {"status": "offline", "error": "Empty result"}
@ -142,7 +140,7 @@ async def get_config(request: Request):
has_update = False has_update = False
try: try:
latest_version, has_update = get_latest_version_cached(current_version) latest_version, has_update = await get_latest_version_cached(current_version)
except Exception as e: except Exception as e:
# Extra safety: ensure version check never breaks the config endpoint # Extra safety: ensure version check never breaks the config endpoint
logger.error(f"Unexpected error during version check: {e}") logger.error(f"Unexpected error during version check: {e}")

View file

@ -1,4 +1,3 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger

View file

@ -88,7 +88,11 @@ async def embed_content(embed_request: EmbedRequest):
message = "Note embedded successfully" message = "Note embedded successfully"
return EmbedResponse( return EmbedResponse(
success=True, message=message, item_id=item_id, item_type=item_type, command_id=command_id success=True,
message=message,
item_id=item_id,
item_type=item_type,
command_id=command_id,
) )
except HTTPException: except HTTPException:

View file

@ -173,10 +173,12 @@ async def get_rebuild_status(command_id: str):
response.completed_at = str(status.updated) response.completed_at = str(status.updated)
# Add error message if failed # Add error message if failed
if status.status == "failed" and status.result and isinstance(status.result, dict): if (
response.error_message = status.result.get( status.status == "failed"
"error_message", "Unknown error" and status.result
) and isinstance(status.result, dict)
):
response.error_message = status.result.get("error_message", "Unknown error")
return response return response

View file

@ -27,7 +27,7 @@ async def list_episode_profiles():
"""List all available episode profiles""" """List all available episode profiles"""
try: try:
profiles = await EpisodeProfile.get_all(order_by="name asc") profiles = await EpisodeProfile.get_all(order_by="name asc")
return [ return [
EpisodeProfileResponse( EpisodeProfileResponse(
id=str(profile.id), id=str(profile.id),
@ -39,16 +39,15 @@ async def list_episode_profiles():
transcript_provider=profile.transcript_provider, transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model, transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing, default_briefing=profile.default_briefing,
num_segments=profile.num_segments num_segments=profile.num_segments,
) )
for profile in profiles for profile in profiles
] ]
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch episode profiles: {e}") logger.error(f"Failed to fetch episode profiles: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to fetch episode profiles"
detail=f"Failed to fetch episode profiles: {str(e)}"
) )
@ -57,13 +56,12 @@ async def get_episode_profile(profile_name: str):
"""Get a specific episode profile by name""" """Get a specific episode profile by name"""
try: try:
profile = await EpisodeProfile.get_by_name(profile_name) profile = await EpisodeProfile.get_by_name(profile_name)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Episode profile '{profile_name}' not found"
detail=f"Episode profile '{profile_name}' not found"
) )
return EpisodeProfileResponse( return EpisodeProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
@ -74,16 +72,15 @@ async def get_episode_profile(profile_name: str):
transcript_provider=profile.transcript_provider, transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model, transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing, default_briefing=profile.default_briefing,
num_segments=profile.num_segments num_segments=profile.num_segments,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch episode profile '{profile_name}': {e}") logger.error(f"Failed to fetch episode profile '{profile_name}': {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to fetch episode profile"
detail=f"Failed to fetch episode profile: {str(e)}"
) )
@ -93,7 +90,9 @@ class EpisodeProfileCreate(BaseModel):
speaker_config: str = Field(..., description="Reference to speaker profile name") speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation") outline_provider: str = Field(..., description="AI provider for outline generation")
outline_model: str = Field(..., description="AI model for outline generation") outline_model: str = Field(..., description="AI model for outline generation")
transcript_provider: str = Field(..., description="AI provider for transcript generation") transcript_provider: str = Field(
..., description="AI provider for transcript generation"
)
transcript_model: str = Field(..., description="AI model for transcript generation") transcript_model: str = Field(..., description="AI model for transcript generation")
default_briefing: str = Field(..., description="Default briefing template") default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments") num_segments: int = Field(default=5, description="Number of podcast segments")
@ -112,11 +111,11 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
transcript_provider=profile_data.transcript_provider, transcript_provider=profile_data.transcript_provider,
transcript_model=profile_data.transcript_model, transcript_model=profile_data.transcript_model,
default_briefing=profile_data.default_briefing, default_briefing=profile_data.default_briefing,
num_segments=profile_data.num_segments num_segments=profile_data.num_segments,
) )
await profile.save() await profile.save()
return EpisodeProfileResponse( return EpisodeProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
@ -127,14 +126,13 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
transcript_provider=profile.transcript_provider, transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model, transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing, default_briefing=profile.default_briefing,
num_segments=profile.num_segments num_segments=profile.num_segments,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to create episode profile: {e}") logger.error(f"Failed to create episode profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to create episode profile"
detail=f"Failed to create episode profile: {str(e)}"
) )
@ -143,13 +141,12 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
"""Update an existing episode profile""" """Update an existing episode profile"""
try: try:
profile = await EpisodeProfile.get(profile_id) profile = await EpisodeProfile.get(profile_id)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Episode profile '{profile_id}' not found"
detail=f"Episode profile '{profile_id}' not found"
) )
# Update fields # Update fields
profile.name = profile_data.name profile.name = profile_data.name
profile.description = profile_data.description profile.description = profile_data.description
@ -160,9 +157,9 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
profile.transcript_model = profile_data.transcript_model profile.transcript_model = profile_data.transcript_model
profile.default_briefing = profile_data.default_briefing profile.default_briefing = profile_data.default_briefing
profile.num_segments = profile_data.num_segments profile.num_segments = profile_data.num_segments
await profile.save() await profile.save()
return EpisodeProfileResponse( return EpisodeProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
@ -173,16 +170,15 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
transcript_provider=profile.transcript_provider, transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model, transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing, default_briefing=profile.default_briefing,
num_segments=profile.num_segments num_segments=profile.num_segments,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to update episode profile: {e}") logger.error(f"Failed to update episode profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to update episode profile"
detail=f"Failed to update episode profile: {str(e)}"
) )
@ -191,39 +187,38 @@ async def delete_episode_profile(profile_id: str):
"""Delete an episode profile""" """Delete an episode profile"""
try: try:
profile = await EpisodeProfile.get(profile_id) profile = await EpisodeProfile.get(profile_id)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Episode profile '{profile_id}' not found"
detail=f"Episode profile '{profile_id}' not found"
) )
await profile.delete() await profile.delete()
return {"message": "Episode profile deleted successfully"} return {"message": "Episode profile deleted successfully"}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to delete episode profile: {e}") logger.error(f"Failed to delete episode profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to delete episode profile"
detail=f"Failed to delete episode profile: {str(e)}"
) )
@router.post("/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse) @router.post(
"/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse
)
async def duplicate_episode_profile(profile_id: str): async def duplicate_episode_profile(profile_id: str):
"""Duplicate an episode profile""" """Duplicate an episode profile"""
try: try:
original = await EpisodeProfile.get(profile_id) original = await EpisodeProfile.get(profile_id)
if not original: if not original:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Episode profile '{profile_id}' not found"
detail=f"Episode profile '{profile_id}' not found"
) )
# Create duplicate with modified name # Create duplicate with modified name
duplicate = EpisodeProfile( duplicate = EpisodeProfile(
name=f"{original.name} - Copy", name=f"{original.name} - Copy",
@ -234,11 +229,11 @@ async def duplicate_episode_profile(profile_id: str):
transcript_provider=original.transcript_provider, transcript_provider=original.transcript_provider,
transcript_model=original.transcript_model, transcript_model=original.transcript_model,
default_briefing=original.default_briefing, default_briefing=original.default_briefing,
num_segments=original.num_segments num_segments=original.num_segments,
) )
await duplicate.save() await duplicate.save()
return EpisodeProfileResponse( return EpisodeProfileResponse(
id=str(duplicate.id), id=str(duplicate.id),
name=duplicate.name, name=duplicate.name,
@ -249,14 +244,13 @@ async def duplicate_episode_profile(profile_id: str):
transcript_provider=duplicate.transcript_provider, transcript_provider=duplicate.transcript_provider,
transcript_model=duplicate.transcript_model, transcript_model=duplicate.transcript_model,
default_briefing=duplicate.default_briefing, default_briefing=duplicate.default_briefing,
num_segments=duplicate.num_segments num_segments=duplicate.num_segments,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to duplicate episode profile: {e}") logger.error(f"Failed to duplicate episode profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to duplicate episode profile"
detail=f"Failed to duplicate episode profile: {str(e)}" )
)

View file

@ -1,4 +1,3 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger
@ -16,10 +15,10 @@ async def get_insight(insight_id: str):
insight = await SourceInsight.get(insight_id) insight = await SourceInsight.get(insight_id)
if not insight: if not insight:
raise HTTPException(status_code=404, detail="Insight not found") raise HTTPException(status_code=404, detail="Insight not found")
# Get source ID from the insight relationship # Get source ID from the insight relationship
source = await insight.get_source() source = await insight.get_source()
return SourceInsightResponse( return SourceInsightResponse(
id=insight.id or "", id=insight.id or "",
source_id=source.id or "", source_id=source.id or "",
@ -32,7 +31,7 @@ async def get_insight(insight_id: str):
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error fetching insight {insight_id}: {str(e)}") logger.error(f"Error fetching insight {insight_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching insight: {str(e)}") raise HTTPException(status_code=500, detail="Error fetching insight")
@router.delete("/insights/{insight_id}") @router.delete("/insights/{insight_id}")
@ -42,15 +41,15 @@ async def delete_insight(insight_id: str):
insight = await SourceInsight.get(insight_id) insight = await SourceInsight.get(insight_id)
if not insight: if not insight:
raise HTTPException(status_code=404, detail="Insight not found") raise HTTPException(status_code=404, detail="Insight not found")
await insight.delete() await insight.delete()
return {"message": "Insight deleted successfully"} return {"message": "Insight deleted successfully"}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error deleting insight {insight_id}: {str(e)}") logger.error(f"Error deleting insight {insight_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting insight: {str(e)}") raise HTTPException(status_code=500, detail="Error deleting insight")
@router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse) @router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse)
@ -60,10 +59,10 @@ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
insight = await SourceInsight.get(insight_id) insight = await SourceInsight.get(insight_id)
if not insight: if not insight:
raise HTTPException(status_code=404, detail="Insight not found") raise HTTPException(status_code=404, detail="Insight not found")
# Use the existing save_as_note method from the domain model # Use the existing save_as_note method from the domain model
note = await insight.save_as_note(request.notebook_id) note = await insight.save_as_note(request.notebook_id)
return NoteResponse( return NoteResponse(
id=note.id or "", id=note.id or "",
title=note.title, title=note.title,
@ -78,4 +77,6 @@ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error saving insight {insight_id} as note: {str(e)}") logger.error(f"Error saving insight {insight_id} as note: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error saving insight as note: {str(e)}") raise HTTPException(
status_code=500, detail="Error saving insight as note"
)

View file

@ -61,7 +61,7 @@ def _check_azure_support(mode: str) -> bool:
@router.get("/models", response_model=List[ModelResponse]) @router.get("/models", response_model=List[ModelResponse])
async def get_models( async def get_models(
type: Optional[str] = Query(None, description="Filter by model type") type: Optional[str] = Query(None, description="Filter by model type"),
): ):
"""Get all configured models with optional type filtering.""" """Get all configured models with optional type filtering."""
try: try:
@ -69,7 +69,7 @@ async def get_models(
models = await Model.get_models_by_type(type) models = await Model.get_models_by_type(type)
else: else:
models = await Model.get_all() models = await Model.get_all()
return [ return [
ModelResponse( ModelResponse(
id=model.id, id=model.id,
@ -95,19 +95,24 @@ async def create_model(model_data: ModelCreate):
if model_data.type not in valid_types: if model_data.type not in valid_types:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid model type. Must be one of: {valid_types}" detail=f"Invalid model type. Must be one of: {valid_types}",
) )
# Check for duplicate model name under the same provider and type (case-insensitive) # Check for duplicate model name under the same provider and type (case-insensitive)
from open_notebook.database.repository import repo_query from open_notebook.database.repository import repo_query
existing = await repo_query( existing = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1", "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1",
{"provider": model_data.provider.lower(), "name": model_data.name.lower(), "type": model_data.type.lower()} {
"provider": model_data.provider.lower(),
"name": model_data.name.lower(),
"type": model_data.type.lower(),
},
) )
if existing: if existing:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'" detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'",
) )
new_model = Model( new_model = Model(
@ -141,9 +146,9 @@ async def delete_model(model_id: str):
model = await Model.get(model_id) model = await Model.get(model_id)
if not model: if not model:
raise HTTPException(status_code=404, detail="Model not found") raise HTTPException(status_code=404, detail="Model not found")
await model.delete() await model.delete()
return {"message": "Model deleted successfully"} return {"message": "Model deleted successfully"}
except HTTPException: except HTTPException:
raise raise
@ -169,7 +174,9 @@ async def get_default_models():
) )
except Exception as e: except Exception as e:
logger.error(f"Error fetching default models: {str(e)}") logger.error(f"Error fetching default models: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching default models: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error fetching default models: {str(e)}"
)
@router.put("/models/defaults", response_model=DefaultModelsResponse) @router.put("/models/defaults", response_model=DefaultModelsResponse)
@ -177,23 +184,29 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
"""Update default model assignments.""" """Update default model assignments."""
try: try:
defaults = await DefaultModels.get_instance() defaults = await DefaultModels.get_instance()
# Update only provided fields # Update only provided fields
if defaults_data.default_chat_model is not None: if defaults_data.default_chat_model is not None:
defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined] defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined]
if defaults_data.default_transformation_model is not None: if defaults_data.default_transformation_model is not None:
defaults.default_transformation_model = defaults_data.default_transformation_model # type: ignore[attr-defined] defaults.default_transformation_model = (
defaults_data.default_transformation_model
) # type: ignore[attr-defined]
if defaults_data.large_context_model is not None: if defaults_data.large_context_model is not None:
defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined] defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined]
if defaults_data.default_text_to_speech_model is not None: if defaults_data.default_text_to_speech_model is not None:
defaults.default_text_to_speech_model = defaults_data.default_text_to_speech_model # type: ignore[attr-defined] defaults.default_text_to_speech_model = (
defaults_data.default_text_to_speech_model
) # type: ignore[attr-defined]
if defaults_data.default_speech_to_text_model is not None: if defaults_data.default_speech_to_text_model is not None:
defaults.default_speech_to_text_model = defaults_data.default_speech_to_text_model # type: ignore[attr-defined] defaults.default_speech_to_text_model = (
defaults_data.default_speech_to_text_model
) # type: ignore[attr-defined]
if defaults_data.default_embedding_model is not None: if defaults_data.default_embedding_model is not None:
defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined] defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined]
if defaults_data.default_tools_model is not None: if defaults_data.default_tools_model is not None:
defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined] defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined]
await defaults.update() await defaults.update()
# No cache refresh needed - next access will fetch fresh data from DB # No cache refresh needed - next access will fetch fresh data from DB
@ -211,7 +224,9 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error updating default models: {str(e)}") logger.error(f"Error updating default models: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error updating default models: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error updating default models: {str(e)}"
)
@router.get("/models/providers", response_model=ProviderAvailabilityResponse) @router.get("/models/providers", response_model=ProviderAvailabilityResponse)
@ -252,7 +267,7 @@ async def get_provider_availability():
or _check_openai_compatible_support("TTS") or _check_openai_compatible_support("TTS")
), ),
} }
available_providers = [k for k, v in provider_status.items() if v] available_providers = [k for k, v in provider_status.items() if v]
unavailable_providers = [k for k, v in provider_status.items() if not v] unavailable_providers = [k for k, v in provider_status.items() if not v]
@ -275,13 +290,19 @@ async def get_provider_availability():
# Special handling for openai-compatible to check mode-specific availability # Special handling for openai-compatible to check mode-specific availability
if provider == "openai-compatible": if provider == "openai-compatible":
for model_type, mode in mode_mapping.items(): for model_type, mode in mode_mapping.items():
if model_type in esperanto_available and provider in esperanto_available[model_type]: if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
if _check_openai_compatible_support(mode): if _check_openai_compatible_support(mode):
supported_types[provider].append(model_type) supported_types[provider].append(model_type)
# Special handling for azure to check mode-specific availability # Special handling for azure to check mode-specific availability
elif provider == "azure": elif provider == "azure":
for model_type, mode in mode_mapping.items(): for model_type, mode in mode_mapping.items():
if model_type in esperanto_available and provider in esperanto_available[model_type]: if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
if _check_azure_support(mode): if _check_azure_support(mode):
supported_types[provider].append(model_type) supported_types[provider].append(model_type)
else: else:
@ -289,12 +310,14 @@ async def get_provider_availability():
for model_type, providers in esperanto_available.items(): for model_type, providers in esperanto_available.items():
if provider in providers: if provider in providers:
supported_types[provider].append(model_type) supported_types[provider].append(model_type)
return ProviderAvailabilityResponse( return ProviderAvailabilityResponse(
available=available_providers, available=available_providers,
unavailable=unavailable_providers, unavailable=unavailable_providers,
supported_types=supported_types supported_types=supported_types,
) )
except Exception as e: except Exception as e:
logger.error(f"Error checking provider availability: {str(e)}") logger.error(f"Error checking provider availability: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error checking provider availability: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error checking provider availability: {str(e)}"
)

View file

@ -12,13 +12,14 @@ router = APIRouter()
@router.get("/notes", response_model=List[NoteResponse]) @router.get("/notes", response_model=List[NoteResponse])
async def get_notes( async def get_notes(
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID") notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
): ):
"""Get all notes with optional notebook filtering.""" """Get all notes with optional notebook filtering."""
try: try:
if notebook_id: if notebook_id:
# Get notes for a specific notebook # Get notes for a specific notebook
from open_notebook.domain.notebook import Notebook from open_notebook.domain.notebook import Notebook
notebook = await Notebook.get(notebook_id) notebook = await Notebook.get(notebook_id)
if not notebook: if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found") raise HTTPException(status_code=404, detail="Notebook not found")
@ -26,7 +27,7 @@ async def get_notes(
else: else:
# Get all notes # Get all notes
notes = await Note.get_all(order_by="updated desc") notes = await Note.get_all(order_by="updated desc")
return [ return [
NoteResponse( NoteResponse(
id=note.id or "", id=note.id or "",
@ -53,21 +54,24 @@ async def create_note(note_data: NoteCreate):
title = note_data.title title = note_data.title
if not title and note_data.note_type == "ai" and note_data.content: if not title and note_data.note_type == "ai" and note_data.content:
from open_notebook.graphs.prompt import graph as prompt_graph from open_notebook.graphs.prompt import graph as prompt_graph
prompt = "Based on the Note below, please provide a Title for this content, with max 15 words" prompt = "Based on the Note below, please provide a Title for this content, with max 15 words"
result = await prompt_graph.ainvoke( result = await prompt_graph.ainvoke(
{ # type: ignore[arg-type] { # type: ignore[arg-type]
"input_text": note_data.content, "input_text": note_data.content,
"prompt": prompt "prompt": prompt,
} }
) )
title = result.get("output", "Untitled Note") title = result.get("output", "Untitled Note")
# Validate note_type # Validate note_type
note_type: Optional[Literal["human", "ai"]] = None note_type: Optional[Literal["human", "ai"]] = None
if note_data.note_type in ("human", "ai"): if note_data.note_type in ("human", "ai"):
note_type = note_data.note_type # type: ignore[assignment] note_type = note_data.note_type # type: ignore[assignment]
elif note_data.note_type is not None: elif note_data.note_type is not None:
raise HTTPException(status_code=400, detail="note_type must be 'human' or 'ai'") raise HTTPException(
status_code=400, detail="note_type must be 'human' or 'ai'"
)
new_note = Note( new_note = Note(
title=title, title=title,
@ -75,15 +79,16 @@ async def create_note(note_data: NoteCreate):
note_type=note_type, note_type=note_type,
) )
await new_note.save() await new_note.save()
# Add to notebook if specified # Add to notebook if specified
if note_data.notebook_id: if note_data.notebook_id:
from open_notebook.domain.notebook import Notebook from open_notebook.domain.notebook import Notebook
notebook = await Notebook.get(note_data.notebook_id) notebook = await Notebook.get(note_data.notebook_id)
if not notebook: if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found") raise HTTPException(status_code=404, detail="Notebook not found")
await new_note.add_to_notebook(note_data.notebook_id) await new_note.add_to_notebook(note_data.notebook_id)
return NoteResponse( return NoteResponse(
id=new_note.id or "", id=new_note.id or "",
title=new_note.title, title=new_note.title,
@ -108,7 +113,7 @@ async def get_note(note_id: str):
note = await Note.get(note_id) note = await Note.get(note_id)
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
return NoteResponse( return NoteResponse(
id=note.id or "", id=note.id or "",
title=note.title, title=note.title,
@ -131,7 +136,7 @@ async def update_note(note_id: str, note_update: NoteUpdate):
note = await Note.get(note_id) note = await Note.get(note_id)
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
# Update only provided fields # Update only provided fields
if note_update.title is not None: if note_update.title is not None:
note.title = note_update.title note.title = note_update.title
@ -141,7 +146,9 @@ async def update_note(note_id: str, note_update: NoteUpdate):
if note_update.note_type in ("human", "ai"): if note_update.note_type in ("human", "ai"):
note.note_type = note_update.note_type # type: ignore[assignment] note.note_type = note_update.note_type # type: ignore[assignment]
else: else:
raise HTTPException(status_code=400, detail="note_type must be 'human' or 'ai'") raise HTTPException(
status_code=400, detail="note_type must be 'human' or 'ai'"
)
await note.save() await note.save()
@ -169,12 +176,12 @@ async def delete_note(note_id: str):
note = await Note.get(note_id) note = await Note.get(note_id)
if not note: if not note:
raise HTTPException(status_code=404, detail="Note not found") raise HTTPException(status_code=404, detail="Note not found")
await note.delete() await note.delete()
return {"message": "Note deleted successfully"} return {"message": "Note deleted successfully"}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error deleting note {note_id}: {str(e)}") logger.error(f"Error deleting note {note_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}") raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}")

View file

@ -64,7 +64,7 @@ async def generate_podcast(request: PodcastGenerationRequest):
except Exception as e: except Exception as e:
logger.error(f"Error generating podcast: {str(e)}") logger.error(f"Error generating podcast: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to generate podcast: {str(e)}" status_code=500, detail="Failed to generate podcast"
) )
@ -78,7 +78,7 @@ async def get_podcast_job_status(job_id: str):
except Exception as e: except Exception as e:
logger.error(f"Error fetching podcast job status: {str(e)}") logger.error(f"Error fetching podcast job status: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to fetch job status: {str(e)}" status_code=500, detail="Failed to fetch job status"
) )
@ -93,7 +93,7 @@ async def list_podcast_episodes():
# Skip incomplete episodes without command or audio # Skip incomplete episodes without command or audio
if not episode.command and not episode.audio_file: if not episode.command and not episode.audio_file:
continue continue
# Get job status if available # Get job status if available
job_status = None job_status = None
if episode.command: if episode.command:
@ -132,7 +132,7 @@ async def list_podcast_episodes():
except Exception as e: except Exception as e:
logger.error(f"Error listing podcast episodes: {str(e)}") logger.error(f"Error listing podcast episodes: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to list podcast episodes: {str(e)}" status_code=500, detail="Failed to list podcast episodes"
) )
@ -175,7 +175,7 @@ async def get_podcast_episode(episode_id: str):
except Exception as e: except Exception as e:
logger.error(f"Error fetching podcast episode: {str(e)}") logger.error(f"Error fetching podcast episode: {str(e)}")
raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}") raise HTTPException(status_code=404, detail="Episode not found")
@router.get("/podcasts/episodes/{episode_id}/audio") @router.get("/podcasts/episodes/{episode_id}/audio")
@ -187,7 +187,7 @@ async def stream_podcast_episode_audio(episode_id: str):
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error fetching podcast episode for audio: {str(e)}") logger.error(f"Error fetching podcast episode for audio: {str(e)}")
raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}") raise HTTPException(status_code=404, detail="Episode not found")
if not episode.audio_file: if not episode.audio_file:
raise HTTPException(status_code=404, detail="Episode has no audio file") raise HTTPException(status_code=404, detail="Episode has no audio file")
@ -209,7 +209,7 @@ async def delete_podcast_episode(episode_id: str):
try: try:
# Get the episode first to check if it exists and get the audio file path # Get the episode first to check if it exists and get the audio file path
episode = await PodcastService.get_episode(episode_id) episode = await PodcastService.get_episode(episode_id)
# Delete the physical audio file if it exists # Delete the physical audio file if it exists
if episode.audio_file: if episode.audio_file:
audio_path = _resolve_audio_path(episode.audio_file) audio_path = _resolve_audio_path(episode.audio_file)
@ -219,13 +219,15 @@ async def delete_podcast_episode(episode_id: str):
logger.info(f"Deleted audio file: {audio_path}") logger.info(f"Deleted audio file: {audio_path}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete audio file {audio_path}: {e}") logger.warning(f"Failed to delete audio file {audio_path}: {e}")
# Delete the episode from the database # Delete the episode from the database
await episode.delete() await episode.delete()
logger.info(f"Deleted podcast episode: {episode_id}") logger.info(f"Deleted podcast episode: {episode_id}")
return {"message": "Episode deleted successfully", "episode_id": episode_id} return {"message": "Episode deleted successfully", "episode_id": episode_id}
except Exception as e: except Exception as e:
logger.error(f"Error deleting podcast episode: {str(e)}") logger.error(f"Error deleting podcast episode: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}") raise HTTPException(
status_code=500, detail="Failed to delete episode"
)

View file

@ -23,7 +23,9 @@ async def get_settings():
) )
except Exception as e: except Exception as e:
logger.error(f"Error fetching settings: {str(e)}") logger.error(f"Error fetching settings: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching settings: {str(e)}") raise HTTPException(
status_code=500, detail="Error fetching settings"
)
@router.put("/settings", response_model=SettingsResponse) @router.put("/settings", response_model=SettingsResponse)
@ -36,30 +38,35 @@ async def update_settings(settings_update: SettingsUpdate):
if settings_update.default_content_processing_engine_doc is not None: if settings_update.default_content_processing_engine_doc is not None:
# Cast to proper literal type # Cast to proper literal type
from typing import Literal, cast from typing import Literal, cast
settings.default_content_processing_engine_doc = cast( settings.default_content_processing_engine_doc = cast(
Literal["auto", "docling", "simple"], Literal["auto", "docling", "simple"],
settings_update.default_content_processing_engine_doc settings_update.default_content_processing_engine_doc,
) )
if settings_update.default_content_processing_engine_url is not None: if settings_update.default_content_processing_engine_url is not None:
from typing import Literal, cast from typing import Literal, cast
settings.default_content_processing_engine_url = cast( settings.default_content_processing_engine_url = cast(
Literal["auto", "firecrawl", "jina", "simple"], Literal["auto", "firecrawl", "jina", "simple"],
settings_update.default_content_processing_engine_url settings_update.default_content_processing_engine_url,
) )
if settings_update.default_embedding_option is not None: if settings_update.default_embedding_option is not None:
from typing import Literal, cast from typing import Literal, cast
settings.default_embedding_option = cast( settings.default_embedding_option = cast(
Literal["ask", "always", "never"], Literal["ask", "always", "never"],
settings_update.default_embedding_option settings_update.default_embedding_option,
) )
if settings_update.auto_delete_files is not None: if settings_update.auto_delete_files is not None:
from typing import Literal, cast from typing import Literal, cast
settings.auto_delete_files = cast( settings.auto_delete_files = cast(
Literal["yes", "no"], Literal["yes", "no"], settings_update.auto_delete_files
settings_update.auto_delete_files
) )
if settings_update.youtube_preferred_languages is not None: if settings_update.youtube_preferred_languages is not None:
settings.youtube_preferred_languages = settings_update.youtube_preferred_languages settings.youtube_preferred_languages = (
settings_update.youtube_preferred_languages
)
await settings.update() await settings.update()
@ -76,4 +83,6 @@ async def update_settings(settings_update: SettingsUpdate):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"Error updating settings: {str(e)}") logger.error(f"Error updating settings: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error updating settings: {str(e)}") raise HTTPException(
status_code=500, detail="Error updating settings"
)

View file

@ -18,15 +18,22 @@ from open_notebook.graphs.source_chat import source_chat_graph as source_chat_gr
router = APIRouter() router = APIRouter()
# Request/Response models # Request/Response models
class CreateSourceChatSessionRequest(BaseModel): class CreateSourceChatSessionRequest(BaseModel):
source_id: str = Field(..., description="Source ID to create chat session for") source_id: str = Field(..., description="Source ID to create chat session for")
title: Optional[str] = Field(None, description="Optional session title") title: Optional[str] = Field(None, description="Optional session title")
model_override: Optional[str] = Field(None, description="Optional model override for this session") model_override: Optional[str] = Field(
None, description="Optional model override for this session"
)
class UpdateSourceChatSessionRequest(BaseModel): class UpdateSourceChatSessionRequest(BaseModel):
title: Optional[str] = Field(None, description="New session title") title: Optional[str] = Field(None, description="New session title")
model_override: Optional[str] = Field(None, description="Model override for this session") model_override: Optional[str] = Field(
None, description="Model override for this session"
)
class ChatMessage(BaseModel): class ChatMessage(BaseModel):
id: str = Field(..., description="Message ID") id: str = Field(..., description="Message ID")
@ -34,56 +41,81 @@ class ChatMessage(BaseModel):
content: str = Field(..., description="Message content") content: str = Field(..., description="Message content")
timestamp: Optional[str] = Field(None, description="Message timestamp") timestamp: Optional[str] = Field(None, description="Message timestamp")
class ContextIndicator(BaseModel): class ContextIndicator(BaseModel):
sources: List[str] = Field(default_factory=list, description="Source IDs used in context") sources: List[str] = Field(
insights: List[str] = Field(default_factory=list, description="Insight IDs used in context") default_factory=list, description="Source IDs used in context"
notes: List[str] = Field(default_factory=list, description="Note IDs used in context") )
insights: List[str] = Field(
default_factory=list, description="Insight IDs used in context"
)
notes: List[str] = Field(
default_factory=list, description="Note IDs used in context"
)
class SourceChatSessionResponse(BaseModel): class SourceChatSessionResponse(BaseModel):
id: str = Field(..., description="Session ID") id: str = Field(..., description="Session ID")
title: str = Field(..., description="Session title") title: str = Field(..., description="Session title")
source_id: str = Field(..., description="Source ID") source_id: str = Field(..., description="Source ID")
model_override: Optional[str] = Field(None, description="Model override for this session") model_override: Optional[str] = Field(
None, description="Model override for this session"
)
created: str = Field(..., description="Creation timestamp") created: str = Field(..., description="Creation timestamp")
updated: str = Field(..., description="Last update timestamp") updated: str = Field(..., description="Last update timestamp")
message_count: Optional[int] = Field(None, description="Number of messages in session") message_count: Optional[int] = Field(
None, description="Number of messages in session"
)
class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse): class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):
messages: List[ChatMessage] = Field(default_factory=list, description="Session messages") messages: List[ChatMessage] = Field(
context_indicators: Optional[ContextIndicator] = Field(None, description="Context indicators from last response") default_factory=list, description="Session messages"
)
context_indicators: Optional[ContextIndicator] = Field(
None, description="Context indicators from last response"
)
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
message: str = Field(..., description="User message content") message: str = Field(..., description="User message content")
model_override: Optional[str] = Field(None, description="Optional model override for this message") model_override: Optional[str] = Field(
None, description="Optional model override for this message"
)
class SuccessResponse(BaseModel): class SuccessResponse(BaseModel):
success: bool = Field(True, description="Operation success status") success: bool = Field(True, description="Operation success status")
message: str = Field(..., description="Success message") message: str = Field(..., description="Success message")
@router.post("/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse) @router.post(
"/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse
)
async def create_source_chat_session( async def create_source_chat_session(
request: CreateSourceChatSessionRequest, request: CreateSourceChatSessionRequest,
source_id: str = Path(..., description="Source ID") source_id: str = Path(..., description="Source ID"),
): ):
"""Create a new chat session for a source.""" """Create a new chat session for a source."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Create new session with model_override support # Create new session with model_override support
session = ChatSession( session = ChatSession(
title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}", title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}",
model_override=request.model_override model_override=request.model_override,
) )
await session.save() await session.save()
# Relate session to source using "refers_to" relation # Relate session to source using "refers_to" relation
await session.relate("refers_to", full_source_id) await session.relate("refers_to", full_source_id)
return SourceChatSessionResponse( return SourceChatSessionResponse(
id=session.id or "", id=session.id or "",
title=session.title or "Untitled Session", title=session.title or "Untitled Session",
@ -91,33 +123,37 @@ async def create_source_chat_session(
model_override=session.model_override, model_override=session.model_override,
created=str(session.created), created=str(session.created),
updated=str(session.updated), updated=str(session.updated),
message_count=0 message_count=0,
) )
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
except Exception as e: except Exception as e:
logger.error(f"Error creating source chat session: {str(e)}") logger.error(f"Error creating source chat session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creating source chat session: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error creating source chat session: {str(e)}"
)
@router.get("/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]) @router.get(
async def get_source_chat_sessions( "/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]
source_id: str = Path(..., description="Source ID") )
): async def get_source_chat_sessions(source_id: str = Path(..., description="Source ID")):
"""Get all chat sessions for a source.""" """Get all chat sessions for a source."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Get sessions that refer to this source - first get relations, then sessions # Get sessions that refer to this source - first get relations, then sessions
relations = await repo_query( relations = await repo_query(
"SELECT in FROM refers_to WHERE out = $source_id", "SELECT in FROM refers_to WHERE out = $source_id",
{"source_id": ensure_record_id(full_source_id)} {"source_id": ensure_record_id(full_source_id)},
) )
sessions = [] sessions = []
for relation in relations: for relation in relations:
session_id = relation.get("in") session_id = relation.get("in")
@ -125,16 +161,18 @@ async def get_source_chat_sessions(
session_result = await repo_query(f"SELECT * FROM {session_id}") session_result = await repo_query(f"SELECT * FROM {session_id}")
if session_result and len(session_result) > 0: if session_result and len(session_result) > 0:
session_data = session_result[0] session_data = session_result[0]
sessions.append(SourceChatSessionResponse( sessions.append(
id=session_data.get("id") or "", SourceChatSessionResponse(
title=session_data.get("title") or "Untitled Session", id=session_data.get("id") or "",
source_id=source_id, title=session_data.get("title") or "Untitled Session",
model_override=session_data.get("model_override"), source_id=source_id,
created=str(session_data.get("created")), model_override=session_data.get("model_override"),
updated=str(session_data.get("updated")), created=str(session_data.get("created")),
message_count=0 # TODO: Add message count if needed updated=str(session_data.get("updated")),
)) message_count=0, # TODO: Add message count if needed
)
)
# Sort sessions by created date (newest first) # Sort sessions by created date (newest first)
sessions.sort(key=lambda x: x.created, reverse=True) sessions.sort(key=lambda x: x.created, reverse=True)
return sessions return sessions
@ -142,183 +180,232 @@ async def get_source_chat_sessions(
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
except Exception as e: except Exception as e:
logger.error(f"Error fetching source chat sessions: {str(e)}") logger.error(f"Error fetching source chat sessions: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching source chat sessions: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error fetching source chat sessions: {str(e)}"
)
@router.get("/sources/{source_id}/chat/sessions/{session_id}", response_model=SourceChatSessionWithMessagesResponse) @router.get(
"/sources/{source_id}/chat/sessions/{session_id}",
response_model=SourceChatSessionWithMessagesResponse,
)
async def get_source_chat_session( async def get_source_chat_session(
source_id: str = Path(..., description="Source ID"), source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID") session_id: str = Path(..., description="Session ID"),
): ):
"""Get a specific source chat session with its messages.""" """Get a specific source chat session with its messages."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Get session # Get session
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}" full_session_id = (
session_id
if session_id.startswith("chat_session:")
else f"chat_session:{session_id}"
)
session = await ChatSession.get(full_session_id) session = await ChatSession.get(full_session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source # Verify session is related to this source
relation_query = await repo_query( relation_query = await repo_query(
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id", "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)} {
"session_id": ensure_record_id(full_session_id),
"source_id": ensure_record_id(full_source_id),
},
) )
if not relation_query: if not relation_query:
raise HTTPException(status_code=404, detail="Session not found for this source") raise HTTPException(
status_code=404, detail="Session not found for this source"
)
# Get session state from LangGraph to retrieve messages # Get session state from LangGraph to retrieve messages
thread_state = source_chat_graph.get_state( thread_state = source_chat_graph.get_state(
config=RunnableConfig(configurable={"thread_id": session_id}) config=RunnableConfig(configurable={"thread_id": session_id})
) )
# Extract messages from state # Extract messages from state
messages: list[ChatMessage] = [] messages: list[ChatMessage] = []
context_indicators = None context_indicators = None
if thread_state and thread_state.values: if thread_state and thread_state.values:
# Extract messages # Extract messages
if "messages" in thread_state.values: if "messages" in thread_state.values:
for msg in thread_state.values["messages"]: for msg in thread_state.values["messages"]:
messages.append(ChatMessage( messages.append(
id=getattr(msg, 'id', f"msg_{len(messages)}"), ChatMessage(
type=msg.type if hasattr(msg, 'type') else 'unknown', id=getattr(msg, "id", f"msg_{len(messages)}"),
content=msg.content if hasattr(msg, 'content') else str(msg), type=msg.type if hasattr(msg, "type") else "unknown",
timestamp=None # LangChain messages don't have timestamps by default content=msg.content
)) if hasattr(msg, "content")
else str(msg),
timestamp=None, # LangChain messages don't have timestamps by default
)
)
# Extract context indicators from the last state # Extract context indicators from the last state
if "context_indicators" in thread_state.values: if "context_indicators" in thread_state.values:
context_data = thread_state.values["context_indicators"] context_data = thread_state.values["context_indicators"]
context_indicators = ContextIndicator( context_indicators = ContextIndicator(
sources=context_data.get("sources", []), sources=context_data.get("sources", []),
insights=context_data.get("insights", []), insights=context_data.get("insights", []),
notes=context_data.get("notes", []) notes=context_data.get("notes", []),
) )
return SourceChatSessionWithMessagesResponse( return SourceChatSessionWithMessagesResponse(
id=session.id or "", id=session.id or "",
title=session.title or "Untitled Session", title=session.title or "Untitled Session",
source_id=source_id, source_id=source_id,
model_override=getattr(session, 'model_override', None), model_override=getattr(session, "model_override", None),
created=str(session.created), created=str(session.created),
updated=str(session.updated), updated=str(session.updated),
message_count=len(messages), message_count=len(messages),
messages=messages, messages=messages,
context_indicators=context_indicators context_indicators=context_indicators,
) )
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found") raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as e: except Exception as e:
logger.error(f"Error fetching source chat session: {str(e)}") logger.error(f"Error fetching source chat session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error fetching source chat session: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error fetching source chat session: {str(e)}"
)
@router.put("/sources/{source_id}/chat/sessions/{session_id}", response_model=SourceChatSessionResponse) @router.put(
"/sources/{source_id}/chat/sessions/{session_id}",
response_model=SourceChatSessionResponse,
)
async def update_source_chat_session( async def update_source_chat_session(
request: UpdateSourceChatSessionRequest, request: UpdateSourceChatSessionRequest,
source_id: str = Path(..., description="Source ID"), source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID") session_id: str = Path(..., description="Session ID"),
): ):
"""Update source chat session title and/or model override.""" """Update source chat session title and/or model override."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Get session # Get session
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}" full_session_id = (
session_id
if session_id.startswith("chat_session:")
else f"chat_session:{session_id}"
)
session = await ChatSession.get(full_session_id) session = await ChatSession.get(full_session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source # Verify session is related to this source
relation_query = await repo_query( relation_query = await repo_query(
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id", "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)} {
"session_id": ensure_record_id(full_session_id),
"source_id": ensure_record_id(full_source_id),
},
) )
if not relation_query: if not relation_query:
raise HTTPException(status_code=404, detail="Session not found for this source") raise HTTPException(
status_code=404, detail="Session not found for this source"
)
# Update session fields # Update session fields
if request.title is not None: if request.title is not None:
session.title = request.title session.title = request.title
if request.model_override is not None: if request.model_override is not None:
session.model_override = request.model_override session.model_override = request.model_override
await session.save() await session.save()
return SourceChatSessionResponse( return SourceChatSessionResponse(
id=session.id or "", id=session.id or "",
title=session.title or "Untitled Session", title=session.title or "Untitled Session",
source_id=source_id, source_id=source_id,
model_override=getattr(session, 'model_override', None), model_override=getattr(session, "model_override", None),
created=str(session.created), created=str(session.created),
updated=str(session.updated), updated=str(session.updated),
message_count=0 message_count=0,
) )
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found") raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as e: except Exception as e:
logger.error(f"Error updating source chat session: {str(e)}") logger.error(f"Error updating source chat session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error updating source chat session: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error updating source chat session: {str(e)}"
)
@router.delete("/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse) @router.delete(
"/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse
)
async def delete_source_chat_session( async def delete_source_chat_session(
source_id: str = Path(..., description="Source ID"), source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID") session_id: str = Path(..., description="Session ID"),
): ):
"""Delete a source chat session.""" """Delete a source chat session."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Get session # Get session
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}" full_session_id = (
session_id
if session_id.startswith("chat_session:")
else f"chat_session:{session_id}"
)
session = await ChatSession.get(full_session_id) session = await ChatSession.get(full_session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source # Verify session is related to this source
relation_query = await repo_query( relation_query = await repo_query(
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id", "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)} {
"session_id": ensure_record_id(full_session_id),
"source_id": ensure_record_id(full_source_id),
},
) )
if not relation_query: if not relation_query:
raise HTTPException(status_code=404, detail="Session not found for this source") raise HTTPException(
status_code=404, detail="Session not found for this source"
)
await session.delete() await session.delete()
return SuccessResponse( return SuccessResponse(
success=True, success=True, message="Source chat session deleted successfully"
message="Source chat session deleted successfully"
) )
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found") raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as e: except Exception as e:
logger.error(f"Error deleting source chat session: {str(e)}") logger.error(f"Error deleting source chat session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting source chat session: {str(e)}") raise HTTPException(
status_code=500, detail=f"Error deleting source chat session: {str(e)}"
)
async def stream_source_chat_response( async def stream_source_chat_response(
session_id: str, session_id: str, source_id: str, message: str, model_override: Optional[str] = None
source_id: str,
message: str,
model_override: Optional[str] = None
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Stream the source chat response as Server-Sent Events.""" """Stream the source chat response as Server-Sent Events."""
try: try:
@ -326,59 +413,52 @@ async def stream_source_chat_response(
current_state = source_chat_graph.get_state( current_state = source_chat_graph.get_state(
config=RunnableConfig(configurable={"thread_id": session_id}) config=RunnableConfig(configurable={"thread_id": session_id})
) )
# Prepare state for execution # Prepare state for execution
state_values = current_state.values if current_state else {} state_values = current_state.values if current_state else {}
state_values["messages"] = state_values.get("messages", []) state_values["messages"] = state_values.get("messages", [])
state_values["source_id"] = source_id state_values["source_id"] = source_id
state_values["model_override"] = model_override state_values["model_override"] = model_override
# Add user message to state # Add user message to state
user_message = HumanMessage(content=message) user_message = HumanMessage(content=message)
state_values["messages"].append(user_message) state_values["messages"].append(user_message)
# Send user message event # Send user message event
user_event = { user_event = {"type": "user_message", "content": message, "timestamp": None}
"type": "user_message",
"content": message,
"timestamp": None
}
yield f"data: {json.dumps(user_event)}\n\n" yield f"data: {json.dumps(user_event)}\n\n"
# Execute source chat graph synchronously (like notebook chat does) # Execute source chat graph synchronously (like notebook chat does)
result = source_chat_graph.invoke( result = source_chat_graph.invoke(
input=state_values, # type: ignore[arg-type] input=state_values, # type: ignore[arg-type]
config=RunnableConfig( config=RunnableConfig(
configurable={ configurable={"thread_id": session_id, "model_id": model_override}
"thread_id": session_id, ),
"model_id": model_override
}
)
) )
# Stream the complete AI response # Stream the complete AI response
if "messages" in result: if "messages" in result:
for msg in result["messages"]: for msg in result["messages"]:
if hasattr(msg, 'type') and msg.type == 'ai': if hasattr(msg, "type") and msg.type == "ai":
ai_event = { ai_event = {
"type": "ai_message", "type": "ai_message",
"content": msg.content if hasattr(msg, 'content') else str(msg), "content": msg.content if hasattr(msg, "content") else str(msg),
"timestamp": None "timestamp": None,
} }
yield f"data: {json.dumps(ai_event)}\n\n" yield f"data: {json.dumps(ai_event)}\n\n"
# Stream context indicators # Stream context indicators
if "context_indicators" in result: if "context_indicators" in result:
context_event = { context_event = {
"type": "context_indicators", "type": "context_indicators",
"data": result["context_indicators"] "data": result["context_indicators"],
} }
yield f"data: {json.dumps(context_event)}\n\n" yield f"data: {json.dumps(context_event)}\n\n"
# Send completion signal # Send completion signal
completion_event = {"type": "complete"} completion_event = {"type": "complete"}
yield f"data: {json.dumps(completion_event)}\n\n" yield f"data: {json.dumps(completion_event)}\n\n"
except Exception as e: except Exception as e:
logger.error(f"Error in source chat streaming: {str(e)}") logger.error(f"Error in source chat streaming: {str(e)}")
error_event = {"type": "error", "message": str(e)} error_event = {"type": "error", "message": str(e)}
@ -389,58 +469,71 @@ async def stream_source_chat_response(
async def send_message_to_source_chat( async def send_message_to_source_chat(
request: SendMessageRequest, request: SendMessageRequest,
source_id: str = Path(..., description="Source ID"), source_id: str = Path(..., description="Source ID"),
session_id: str = Path(..., description="Session ID") session_id: str = Path(..., description="Session ID"),
): ):
"""Send a message to source chat session with SSE streaming response.""" """Send a message to source chat session with SSE streaming response."""
try: try:
# Verify source exists # Verify source exists
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}" full_source_id = (
source_id if source_id.startswith("source:") else f"source:{source_id}"
)
source = await Source.get(full_source_id) source = await Source.get(full_source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="Source not found") raise HTTPException(status_code=404, detail="Source not found")
# Verify session exists and is related to source # Verify session exists and is related to source
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}" full_session_id = (
session_id
if session_id.startswith("chat_session:")
else f"chat_session:{session_id}"
)
session = await ChatSession.get(full_session_id) session = await ChatSession.get(full_session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source # Verify session is related to this source
relation_query = await repo_query( relation_query = await repo_query(
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id", "SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)} {
"session_id": ensure_record_id(full_session_id),
"source_id": ensure_record_id(full_source_id),
},
) )
if not relation_query: if not relation_query:
raise HTTPException(status_code=404, detail="Session not found for this source") raise HTTPException(
status_code=404, detail="Session not found for this source"
)
if not request.message: if not request.message:
raise HTTPException(status_code=400, detail="Message content is required") raise HTTPException(status_code=400, detail="Message content is required")
# Determine model override (request override takes precedence over session override) # Determine model override (request override takes precedence over session override)
model_override = request.model_override or getattr(session, 'model_override', None) model_override = request.model_override or getattr(
session, "model_override", None
)
# Update session timestamp # Update session timestamp
await session.save() await session.save()
# Return streaming response # Return streaming response
return StreamingResponse( return StreamingResponse(
stream_source_chat_response( stream_source_chat_response(
session_id=session_id, session_id=session_id,
source_id=full_source_id, source_id=full_source_id,
message=request.message, message=request.message,
model_override=model_override model_override=model_override,
), ),
media_type="text/plain", media_type="text/plain",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"Content-Type": "text/plain; charset=utf-8" "Content-Type": "text/plain; charset=utf-8",
} },
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error sending message to source chat: {str(e)}") logger.error(f"Error sending message to source chat: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}") raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}")

View file

@ -121,9 +121,7 @@ def parse_source_form_data(
try: try:
transformations_list = json.loads(transformations) transformations_list = json.loads(transformations)
except json.JSONDecodeError: except json.JSONDecodeError:
logger.error( logger.error(f"Invalid JSON in transformations field: {transformations}")
f"Invalid JSON in transformations field: {transformations}"
)
raise ValueError("Invalid JSON in transformations field") raise ValueError("Invalid JSON in transformations field")
# Create SourceCreate instance # Create SourceCreate instance
@ -152,18 +150,26 @@ def parse_source_form_data(
@router.get("/sources", response_model=List[SourceListResponse]) @router.get("/sources", response_model=List[SourceListResponse])
async def get_sources( async def get_sources(
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"), notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
limit: int = Query(50, ge=1, le=100, description="Number of sources to return (1-100)"), limit: int = Query(
50, ge=1, le=100, description="Number of sources to return (1-100)"
),
offset: int = Query(0, ge=0, description="Number of sources to skip"), offset: int = Query(0, ge=0, description="Number of sources to skip"),
sort_by: str = Query("updated", description="Field to sort by (created or updated)"), sort_by: str = Query(
"updated", description="Field to sort by (created or updated)"
),
sort_order: str = Query("desc", description="Sort order (asc or desc)"), sort_order: str = Query("desc", description="Sort order (asc or desc)"),
): ):
"""Get sources with pagination and sorting support.""" """Get sources with pagination and sorting support."""
try: try:
# Validate sort parameters # Validate sort parameters
if sort_by not in ["created", "updated"]: if sort_by not in ["created", "updated"]:
raise HTTPException(status_code=400, detail="sort_by must be 'created' or 'updated'") raise HTTPException(
status_code=400, detail="sort_by must be 'created' or 'updated'"
)
if sort_order.lower() not in ["asc", "desc"]: if sort_order.lower() not in ["asc", "desc"]:
raise HTTPException(status_code=400, detail="sort_order must be 'asc' or 'desc'") raise HTTPException(
status_code=400, detail="sort_order must be 'asc' or 'desc'"
)
# Build ORDER BY clause # Build ORDER BY clause
order_clause = f"ORDER BY {sort_by} {sort_order.upper()}" order_clause = f"ORDER BY {sort_by} {sort_order.upper()}"
@ -185,11 +191,12 @@ async def get_sources(
LIMIT $limit START $offset LIMIT $limit START $offset
""" """
result = await repo_query( result = await repo_query(
query, { query,
{
"notebook_id": ensure_record_id(notebook_id), "notebook_id": ensure_record_id(notebook_id),
"limit": limit, "limit": limit,
"offset": offset "offset": offset,
} },
) )
else: else:
# Query all sources - include command field # Query all sources - include command field
@ -272,8 +279,14 @@ async def get_sources(
if status_obj: if status_obj:
status = status_obj.status status = status_obj.status
# Extract execution metadata from nested result structure # Extract execution metadata from nested result structure
result_data: dict[str, Any] | None = getattr(status_obj, "result", None) result_data: dict[str, Any] | None = getattr(
execution_metadata: dict[str, Any] = result_data.get("execution_metadata", {}) if isinstance(result_data, dict) else {} status_obj, "result", None
)
execution_metadata: dict[str, Any] = (
result_data.get("execution_metadata", {})
if isinstance(result_data, dict)
else {}
)
processing_info = { processing_info = {
"started_at": execution_metadata.get("started_at"), "started_at": execution_metadata.get("started_at"),
"completed_at": execution_metadata.get("completed_at"), "completed_at": execution_metadata.get("completed_at"),
@ -327,7 +340,7 @@ async def create_source(
try: try:
# Verify all specified notebooks exist (backward compatibility support) # Verify all specified notebooks exist (backward compatibility support)
for notebook_id in (source_data.notebooks or []): for notebook_id in source_data.notebooks or []:
notebook = await Notebook.get(notebook_id) notebook = await Notebook.get(notebook_id)
if not notebook: if not notebook:
raise HTTPException( raise HTTPException(
@ -399,7 +412,7 @@ async def create_source(
# Add source to notebooks immediately so it appears in the UI # Add source to notebooks immediately so it appears in the UI
# The source_graph will skip adding duplicates # The source_graph will skip adding duplicates
for notebook_id in (source_data.notebooks or []): for notebook_id in source_data.notebooks or []:
await source.add_to_notebook(notebook_id) await source.add_to_notebook(notebook_id)
try: try:
@ -478,7 +491,7 @@ async def create_source(
# Add source to notebooks immediately so it appears in the UI # Add source to notebooks immediately so it appears in the UI
# The source_graph will skip adding duplicates # The source_graph will skip adding duplicates
for notebook_id in (source_data.notebooks or []): for notebook_id in source_data.notebooks or []:
await source.add_to_notebook(notebook_id) await source.add_to_notebook(notebook_id)
# Execute command synchronously # Execute command synchronously
@ -517,9 +530,7 @@ async def create_source(
# Get the processed source # Get the processed source
if not source.id: if not source.id:
raise HTTPException( raise HTTPException(status_code=500, detail="Source ID is missing")
status_code=500, detail="Source ID is missing"
)
processed_source = await Source.get(source.id) processed_source = await Source.get(source.id)
if not processed_source: if not processed_source:
raise HTTPException( raise HTTPException(
@ -657,9 +668,11 @@ async def get_source(source_id: str):
# Get associated notebooks # Get associated notebooks
notebooks_query = await repo_query( notebooks_query = await repo_query(
"SELECT VALUE out FROM reference WHERE in = $source_id", "SELECT VALUE out FROM reference WHERE in = $source_id",
{"source_id": ensure_record_id(source.id or source_id)} {"source_id": ensure_record_id(source.id or source_id)},
)
notebook_ids = (
[str(nb_id) for nb_id in notebooks_query] if notebooks_query else []
) )
notebook_ids = [str(nb_id) for nb_id in notebooks_query] if notebooks_query else []
return SourceResponse( return SourceResponse(
id=source.id or "", id=source.id or "",

View file

@ -23,7 +23,7 @@ async def list_speaker_profiles():
"""List all available speaker profiles""" """List all available speaker profiles"""
try: try:
profiles = await SpeakerProfile.get_all(order_by="name asc") profiles = await SpeakerProfile.get_all(order_by="name asc")
return [ return [
SpeakerProfileResponse( SpeakerProfileResponse(
id=str(profile.id), id=str(profile.id),
@ -31,16 +31,15 @@ async def list_speaker_profiles():
description=profile.description or "", description=profile.description or "",
tts_provider=profile.tts_provider, tts_provider=profile.tts_provider,
tts_model=profile.tts_model, tts_model=profile.tts_model,
speakers=profile.speakers speakers=profile.speakers,
) )
for profile in profiles for profile in profiles
] ]
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch speaker profiles: {e}") logger.error(f"Failed to fetch speaker profiles: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to fetch speaker profiles"
detail=f"Failed to fetch speaker profiles: {str(e)}"
) )
@ -49,29 +48,27 @@ async def get_speaker_profile(profile_name: str):
"""Get a specific speaker profile by name""" """Get a specific speaker profile by name"""
try: try:
profile = await SpeakerProfile.get_by_name(profile_name) profile = await SpeakerProfile.get_by_name(profile_name)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Speaker profile '{profile_name}' not found"
detail=f"Speaker profile '{profile_name}' not found"
) )
return SpeakerProfileResponse( return SpeakerProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
description=profile.description or "", description=profile.description or "",
tts_provider=profile.tts_provider, tts_provider=profile.tts_provider,
tts_model=profile.tts_model, tts_model=profile.tts_model,
speakers=profile.speakers speakers=profile.speakers,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}") logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to fetch speaker profile"
detail=f"Failed to fetch speaker profile: {str(e)}"
) )
@ -80,7 +77,9 @@ class SpeakerProfileCreate(BaseModel):
description: str = Field("", description="Profile description") description: str = Field("", description="Profile description")
tts_provider: str = Field(..., description="TTS provider") tts_provider: str = Field(..., description="TTS provider")
tts_model: str = Field(..., description="TTS model name") tts_model: str = Field(..., description="TTS model name")
speakers: List[Dict[str, Any]] = Field(..., description="Array of speaker configurations") speakers: List[Dict[str, Any]] = Field(
..., description="Array of speaker configurations"
)
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse) @router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
@ -92,25 +91,24 @@ async def create_speaker_profile(profile_data: SpeakerProfileCreate):
description=profile_data.description, description=profile_data.description,
tts_provider=profile_data.tts_provider, tts_provider=profile_data.tts_provider,
tts_model=profile_data.tts_model, tts_model=profile_data.tts_model,
speakers=profile_data.speakers speakers=profile_data.speakers,
) )
await profile.save() await profile.save()
return SpeakerProfileResponse( return SpeakerProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
description=profile.description or "", description=profile.description or "",
tts_provider=profile.tts_provider, tts_provider=profile.tts_provider,
tts_model=profile.tts_model, tts_model=profile.tts_model,
speakers=profile.speakers speakers=profile.speakers,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to create speaker profile: {e}") logger.error(f"Failed to create speaker profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to create speaker profile"
detail=f"Failed to create speaker profile: {str(e)}"
) )
@ -119,38 +117,36 @@ async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCr
"""Update an existing speaker profile""" """Update an existing speaker profile"""
try: try:
profile = await SpeakerProfile.get(profile_id) profile = await SpeakerProfile.get(profile_id)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Speaker profile '{profile_id}' not found"
detail=f"Speaker profile '{profile_id}' not found"
) )
# Update fields # Update fields
profile.name = profile_data.name profile.name = profile_data.name
profile.description = profile_data.description profile.description = profile_data.description
profile.tts_provider = profile_data.tts_provider profile.tts_provider = profile_data.tts_provider
profile.tts_model = profile_data.tts_model profile.tts_model = profile_data.tts_model
profile.speakers = profile_data.speakers profile.speakers = profile_data.speakers
await profile.save() await profile.save()
return SpeakerProfileResponse( return SpeakerProfileResponse(
id=str(profile.id), id=str(profile.id),
name=profile.name, name=profile.name,
description=profile.description or "", description=profile.description or "",
tts_provider=profile.tts_provider, tts_provider=profile.tts_provider,
tts_model=profile.tts_model, tts_model=profile.tts_model,
speakers=profile.speakers speakers=profile.speakers,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to update speaker profile: {e}") logger.error(f"Failed to update speaker profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to update speaker profile"
detail=f"Failed to update speaker profile: {str(e)}"
) )
@ -159,64 +155,62 @@ async def delete_speaker_profile(profile_id: str):
"""Delete a speaker profile""" """Delete a speaker profile"""
try: try:
profile = await SpeakerProfile.get(profile_id) profile = await SpeakerProfile.get(profile_id)
if not profile: if not profile:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Speaker profile '{profile_id}' not found"
detail=f"Speaker profile '{profile_id}' not found"
) )
await profile.delete() await profile.delete()
return {"message": "Speaker profile deleted successfully"} return {"message": "Speaker profile deleted successfully"}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to delete speaker profile: {e}") logger.error(f"Failed to delete speaker profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to delete speaker profile"
detail=f"Failed to delete speaker profile: {str(e)}"
) )
@router.post("/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse) @router.post(
"/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse
)
async def duplicate_speaker_profile(profile_id: str): async def duplicate_speaker_profile(profile_id: str):
"""Duplicate a speaker profile""" """Duplicate a speaker profile"""
try: try:
original = await SpeakerProfile.get(profile_id) original = await SpeakerProfile.get(profile_id)
if not original: if not original:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404, detail=f"Speaker profile '{profile_id}' not found"
detail=f"Speaker profile '{profile_id}' not found"
) )
# Create duplicate with modified name # Create duplicate with modified name
duplicate = SpeakerProfile( duplicate = SpeakerProfile(
name=f"{original.name} - Copy", name=f"{original.name} - Copy",
description=original.description, description=original.description,
tts_provider=original.tts_provider, tts_provider=original.tts_provider,
tts_model=original.tts_model, tts_model=original.tts_model,
speakers=original.speakers speakers=original.speakers,
) )
await duplicate.save() await duplicate.save()
return SpeakerProfileResponse( return SpeakerProfileResponse(
id=str(duplicate.id), id=str(duplicate.id),
name=duplicate.name, name=duplicate.name,
description=duplicate.description or "", description=duplicate.description or "",
tts_provider=duplicate.tts_provider, tts_provider=duplicate.tts_provider,
tts_model=duplicate.tts_model, tts_model=duplicate.tts_model,
speakers=duplicate.speakers speakers=duplicate.speakers,
) )
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to duplicate speaker profile: {e}") logger.error(f"Failed to duplicate speaker profile: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail="Failed to duplicate speaker profile"
detail=f"Failed to duplicate speaker profile: {str(e)}" )
)

View file

@ -123,7 +123,8 @@ async def get_default_prompt():
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment] default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
return DefaultPromptResponse( return DefaultPromptResponse(
transformation_instructions=default_prompts.transformation_instructions or "" transformation_instructions=default_prompts.transformation_instructions
or ""
) )
except Exception as e: except Exception as e:
logger.error(f"Error fetching default prompt: {str(e)}") logger.error(f"Error fetching default prompt: {str(e)}")
@ -138,7 +139,9 @@ async def update_default_prompt(prompt_update: DefaultPromptUpdate):
try: try:
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment] default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
default_prompts.transformation_instructions = prompt_update.transformation_instructions default_prompts.transformation_instructions = (
prompt_update.transformation_instructions
)
await default_prompts.update() await default_prompts.update()
return DefaultPromptResponse( return DefaultPromptResponse(

View file

@ -22,7 +22,7 @@ class SearchService:
limit: int = 100, limit: int = 100,
search_sources: bool = True, search_sources: bool = True,
search_notes: bool = True, search_notes: bool = True,
minimum_score: float = 0.2 minimum_score: float = 0.2,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Search the knowledge base.""" """Search the knowledge base."""
response = api_client.search( response = api_client.search(
@ -31,7 +31,7 @@ class SearchService:
limit=limit, limit=limit,
search_sources=search_sources, search_sources=search_sources,
search_notes=search_notes, search_notes=search_notes,
minimum_score=minimum_score minimum_score=minimum_score,
) )
if isinstance(response, dict): if isinstance(response, dict):
return response.get("results", []) return response.get("results", [])
@ -42,17 +42,17 @@ class SearchService:
question: str, question: str,
strategy_model: str, strategy_model: str,
answer_model: str, answer_model: str,
final_answer_model: str final_answer_model: str,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Ask the knowledge base a question.""" """Ask the knowledge base a question."""
response = api_client.ask_simple( response = api_client.ask_simple(
question=question, question=question,
strategy_model=strategy_model, strategy_model=strategy_model,
answer_model=answer_model, answer_model=answer_model,
final_answer_model=final_answer_model final_answer_model=final_answer_model,
) )
return response return response
# Global service instance # Global service instance
search_service = SearchService() search_service = SearchService()

View file

@ -2,7 +2,6 @@
Settings service layer using API. Settings service layer using API.
""" """
from loguru import logger from loguru import logger
from api.client import api_client from api.client import api_client
@ -11,26 +10,36 @@ from open_notebook.domain.content_settings import ContentSettings
class SettingsService: class SettingsService:
"""Service layer for settings operations using API.""" """Service layer for settings operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for settings operations") logger.info("Using API for settings operations")
def get_settings(self) -> ContentSettings: def get_settings(self) -> ContentSettings:
"""Get application settings.""" """Get application settings."""
settings_response = api_client.get_settings() settings_response = api_client.get_settings()
settings_data = settings_response if isinstance(settings_response, dict) else settings_response[0] settings_data = (
settings_response
if isinstance(settings_response, dict)
else settings_response[0]
)
# Create ContentSettings object from API response # Create ContentSettings object from API response
settings = ContentSettings( settings = ContentSettings(
default_content_processing_engine_doc=settings_data.get("default_content_processing_engine_doc"), default_content_processing_engine_doc=settings_data.get(
default_content_processing_engine_url=settings_data.get("default_content_processing_engine_url"), "default_content_processing_engine_doc"
),
default_content_processing_engine_url=settings_data.get(
"default_content_processing_engine_url"
),
default_embedding_option=settings_data.get("default_embedding_option"), default_embedding_option=settings_data.get("default_embedding_option"),
auto_delete_files=settings_data.get("auto_delete_files"), auto_delete_files=settings_data.get("auto_delete_files"),
youtube_preferred_languages=settings_data.get("youtube_preferred_languages"), youtube_preferred_languages=settings_data.get(
"youtube_preferred_languages"
),
) )
return settings return settings
def update_settings(self, settings: ContentSettings) -> ContentSettings: def update_settings(self, settings: ContentSettings) -> ContentSettings:
"""Update application settings.""" """Update application settings."""
updates = { updates = {
@ -42,17 +51,29 @@ class SettingsService:
} }
settings_response = api_client.update_settings(**updates) settings_response = api_client.update_settings(**updates)
settings_data = settings_response if isinstance(settings_response, dict) else settings_response[0] settings_data = (
settings_response
if isinstance(settings_response, dict)
else settings_response[0]
)
# Update the settings object with the response # Update the settings object with the response
settings.default_content_processing_engine_doc = settings_data.get("default_content_processing_engine_doc") settings.default_content_processing_engine_doc = settings_data.get(
settings.default_content_processing_engine_url = settings_data.get("default_content_processing_engine_url") "default_content_processing_engine_doc"
settings.default_embedding_option = settings_data.get("default_embedding_option") )
settings.default_content_processing_engine_url = settings_data.get(
"default_content_processing_engine_url"
)
settings.default_embedding_option = settings_data.get(
"default_embedding_option"
)
settings.auto_delete_files = settings_data.get("auto_delete_files") settings.auto_delete_files = settings_data.get("auto_delete_files")
settings.youtube_preferred_languages = settings_data.get("youtube_preferred_languages") settings.youtube_preferred_languages = settings_data.get(
"youtube_preferred_languages"
)
return settings return settings
# Global service instance # Global service instance
settings_service = SettingsService() settings_service = SettingsService()

View file

@ -14,6 +14,7 @@ from open_notebook.domain.notebook import Asset, Source
@dataclass @dataclass
class SourceProcessingResult: class SourceProcessingResult:
"""Result of source creation with optional async processing info.""" """Result of source creation with optional async processing info."""
source: Source source: Source
is_async: bool = False is_async: bool = False
command_id: Optional[str] = None command_id: Optional[str] = None
@ -24,38 +25,39 @@ class SourceProcessingResult:
@dataclass @dataclass
class SourceWithMetadata: class SourceWithMetadata:
"""Source object with additional metadata from API.""" """Source object with additional metadata from API."""
source: Source source: Source
embedded_chunks: int embedded_chunks: int
# Expose common source properties for easy access # Expose common source properties for easy access
@property @property
def id(self): def id(self):
return self.source.id return self.source.id
@property @property
def title(self): def title(self):
return self.source.title return self.source.title
@title.setter @title.setter
def title(self, value): def title(self, value):
self.source.title = value self.source.title = value
@property @property
def topics(self): def topics(self):
return self.source.topics return self.source.topics
@property @property
def asset(self): def asset(self):
return self.source.asset return self.source.asset
@property @property
def full_text(self): def full_text(self):
return self.source.full_text return self.source.full_text
@property @property
def created(self): def created(self):
return self.source.created return self.source.created
@property @property
def updated(self): def updated(self):
return self.source.updated return self.source.updated
@ -67,7 +69,9 @@ class SourcesService:
def __init__(self): def __init__(self):
logger.info("Using API for sources operations") logger.info("Using API for sources operations")
def get_all_sources(self, notebook_id: Optional[str] = None) -> List[SourceWithMetadata]: def get_all_sources(
self, notebook_id: Optional[str] = None
) -> List[SourceWithMetadata]:
"""Get all sources with optional notebook filtering.""" """Get all sources with optional notebook filtering."""
sources_data = api_client.get_sources(notebook_id=notebook_id) sources_data = api_client.get_sources(notebook_id=notebook_id)
# Convert API response to SourceWithMetadata objects # Convert API response to SourceWithMetadata objects
@ -88,11 +92,10 @@ class SourcesService:
source.id = source_data["id"] source.id = source_data["id"]
source.created = source_data["created"] source.created = source_data["created"]
source.updated = source_data["updated"] source.updated = source_data["updated"]
# Wrap in SourceWithMetadata # Wrap in SourceWithMetadata
source_with_metadata = SourceWithMetadata( source_with_metadata = SourceWithMetadata(
source=source, source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
embedded_chunks=source_data.get("embedded_chunks", 0)
) )
sources.append(source_with_metadata) sources.append(source_with_metadata)
return sources return sources
@ -119,8 +122,7 @@ class SourcesService:
source.updated = source_data["updated"] source.updated = source_data["updated"]
return SourceWithMetadata( return SourceWithMetadata(
source=source, source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
embedded_chunks=source_data.get("embedded_chunks", 0)
) )
def create_source( def create_source(
@ -139,7 +141,7 @@ class SourcesService:
) -> Union[Source, SourceProcessingResult]: ) -> Union[Source, SourceProcessingResult]:
""" """
Create a new source with support for async processing. Create a new source with support for async processing.
Args: Args:
notebook_id: Single notebook ID (deprecated, use notebooks parameter) notebook_id: Single notebook ID (deprecated, use notebooks parameter)
source_type: Type of source (link, upload, text) source_type: Type of source (link, upload, text)
@ -152,7 +154,7 @@ class SourcesService:
delete_source: Whether to delete uploaded file after processing delete_source: Whether to delete uploaded file after processing
notebooks: List of notebook IDs to add source to (preferred over notebook_id) notebooks: List of notebook IDs to add source to (preferred over notebook_id)
async_processing: Whether to process source asynchronously async_processing: Whether to process source asynchronously
Returns: Returns:
Source object for sync processing (backward compatibility) Source object for sync processing (backward compatibility)
SourceProcessingResult for async processing (contains additional metadata) SourceProcessingResult for async processing (contains additional metadata)
@ -193,9 +195,15 @@ class SourcesService:
source.updated = response_data["updated"] source.updated = response_data["updated"]
# Check if this is an async processing response # Check if this is an async processing response
if response_data.get("command_id") or response_data.get("status") or response_data.get("processing_info"): if (
response_data.get("command_id")
or response_data.get("status")
or response_data.get("processing_info")
):
# Ensure source_data is a dict for accessing attributes # Ensure source_data is a dict for accessing attributes
source_data_dict = source_data if isinstance(source_data, dict) else source_data[0] source_data_dict = (
source_data if isinstance(source_data, dict) else source_data[0]
)
# Return enhanced result for async processing # Return enhanced result for async processing
return SourceProcessingResult( return SourceProcessingResult(
source=source, source=source,
@ -228,7 +236,7 @@ class SourcesService:
) -> SourceProcessingResult: ) -> SourceProcessingResult:
""" """
Create a new source with async processing enabled. Create a new source with async processing enabled.
This is a convenience method that always uses async processing. This is a convenience method that always uses async processing.
Returns a SourceProcessingResult with processing status information. Returns a SourceProcessingResult with processing status information.
""" """
@ -245,7 +253,7 @@ class SourcesService:
delete_source=delete_source, delete_source=delete_source,
async_processing=True, async_processing=True,
) )
# Since we forced async_processing=True, this should always be a SourceProcessingResult # Since we forced async_processing=True, this should always be a SourceProcessingResult
if isinstance(result, SourceProcessingResult): if isinstance(result, SourceProcessingResult):
return result return result
@ -259,14 +267,18 @@ class SourcesService:
def is_source_processing_complete(self, source_id: str) -> bool: def is_source_processing_complete(self, source_id: str) -> bool:
""" """
Check if a source's async processing is complete. Check if a source's async processing is complete.
Returns True if processing is complete (success or failure), Returns True if processing is complete (success or failure),
False if still processing or queued. False if still processing or queued.
""" """
try: try:
status_data = self.get_source_status(source_id) status_data = self.get_source_status(source_id)
status = status_data.get("status") status = status_data.get("status")
return status in ["completed", "failed", None] # None indicates legacy/sync source return status in [
"completed",
"failed",
None,
] # None indicates legacy/sync source
except Exception as e: except Exception as e:
logger.error(f"Error checking source processing status: {e}") logger.error(f"Error checking source processing status: {e}")
return True # Assume complete on error return True # Assume complete on error
@ -275,7 +287,7 @@ class SourcesService:
"""Update a source.""" """Update a source."""
if not source.id: if not source.id:
raise ValueError("Source ID is required for update") raise ValueError("Source ID is required for update")
updates = { updates = {
"title": source.title, "title": source.title,
"topics": source.topics, "topics": source.topics,
@ -283,7 +295,9 @@ class SourcesService:
source_data = api_client.update_source(source.id, **updates) source_data = api_client.update_source(source.id, **updates)
# Ensure source_data is a dict # Ensure source_data is a dict
source_data_dict = source_data if isinstance(source_data, dict) else source_data[0] source_data_dict = (
source_data if isinstance(source_data, dict) else source_data[0]
)
# Update the source object with the response # Update the source object with the response
source.title = source_data_dict["title"] source.title = source_data_dict["title"]
@ -302,4 +316,9 @@ class SourcesService:
sources_service = SourcesService() sources_service = SourcesService()
# Export important classes for easy importing # Export important classes for easy importing
__all__ = ["SourcesService", "SourceWithMetadata", "SourceProcessingResult", "sources_service"] __all__ = [
"SourcesService",
"SourceWithMetadata",
"SourceProcessingResult",
"sources_service",
]

View file

@ -13,10 +13,10 @@ from open_notebook.domain.transformation import Transformation
class TransformationsService: class TransformationsService:
"""Service layer for transformations operations using API.""" """Service layer for transformations operations using API."""
def __init__(self): def __init__(self):
logger.info("Using API for transformations operations") logger.info("Using API for transformations operations")
def get_all_transformations(self) -> List[Transformation]: def get_all_transformations(self) -> List[Transformation]:
"""Get all transformations.""" """Get all transformations."""
transformations_data = api_client.get_transformations() transformations_data = api_client.get_transformations()
@ -31,11 +31,15 @@ class TransformationsService:
apply_default=trans_data["apply_default"], apply_default=trans_data["apply_default"],
) )
transformation.id = trans_data["id"] transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00')) transformation.created = datetime.fromisoformat(
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00')) trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
transformations.append(transformation) transformations.append(transformation)
return transformations return transformations
def get_transformation(self, transformation_id: str) -> Transformation: def get_transformation(self, transformation_id: str) -> Transformation:
"""Get a specific transformation.""" """Get a specific transformation."""
response = api_client.get_transformation(transformation_id) response = api_client.get_transformation(transformation_id)
@ -48,17 +52,21 @@ class TransformationsService:
apply_default=trans_data["apply_default"], apply_default=trans_data["apply_default"],
) )
transformation.id = trans_data["id"] transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00')) transformation.created = datetime.fromisoformat(
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00')) trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
return transformation return transformation
def create_transformation( def create_transformation(
self, self,
name: str, name: str,
title: str, title: str,
description: str, description: str,
prompt: str, prompt: str,
apply_default: bool = False apply_default: bool = False,
) -> Transformation: ) -> Transformation:
"""Create a new transformation.""" """Create a new transformation."""
response = api_client.create_transformation( response = api_client.create_transformation(
@ -66,7 +74,7 @@ class TransformationsService:
title=title, title=title,
description=description, description=description,
prompt=prompt, prompt=prompt,
apply_default=apply_default apply_default=apply_default,
) )
trans_data = response if isinstance(response, dict) else response[0] trans_data = response if isinstance(response, dict) else response[0]
transformation = Transformation( transformation = Transformation(
@ -77,10 +85,14 @@ class TransformationsService:
apply_default=trans_data["apply_default"], apply_default=trans_data["apply_default"],
) )
transformation.id = trans_data["id"] transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00')) transformation.created = datetime.fromisoformat(
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00')) trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
return transformation return transformation
def update_transformation(self, transformation: Transformation) -> Transformation: def update_transformation(self, transformation: Transformation) -> Transformation:
"""Update a transformation.""" """Update a transformation."""
if not transformation.id: if not transformation.id:
@ -102,29 +114,28 @@ class TransformationsService:
transformation.description = trans_data["description"] transformation.description = trans_data["description"]
transformation.prompt = trans_data["prompt"] transformation.prompt = trans_data["prompt"]
transformation.apply_default = trans_data["apply_default"] transformation.apply_default = trans_data["apply_default"]
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00')) transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
return transformation return transformation
def delete_transformation(self, transformation_id: str) -> bool: def delete_transformation(self, transformation_id: str) -> bool:
"""Delete a transformation.""" """Delete a transformation."""
api_client.delete_transformation(transformation_id) api_client.delete_transformation(transformation_id)
return True return True
def execute_transformation( def execute_transformation(
self, self, transformation_id: str, input_text: str, model_id: str
transformation_id: str,
input_text: str,
model_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]: ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Execute a transformation on input text.""" """Execute a transformation on input text."""
result = api_client.execute_transformation( result = api_client.execute_transformation(
transformation_id=transformation_id, transformation_id=transformation_id,
input_text=input_text, input_text=input_text,
model_id=model_id model_id=model_id,
) )
return result return result
# Global service instance # Global service instance
transformations_service = TransformationsService() transformations_service = TransformationsService()

View file

@ -174,7 +174,9 @@ async def embed_single_item_command(
except Exception as e: except Exception as e:
processing_time = time.time() - start_time processing_time = time.time() - start_time
logger.error(f"Embedding failed for {input_data.item_type} {input_data.item_id}: {e}") logger.error(
f"Embedding failed for {input_data.item_type} {input_data.item_id}: {e}"
)
logger.exception(e) logger.exception(e)
return EmbedSingleItemOutput( return EmbedSingleItemOutput(
@ -317,7 +319,9 @@ async def vectorize_source_command(
start_time = time.time() start_time = time.time()
try: try:
logger.info(f"Starting vectorization orchestration for source {input_data.source_id}") logger.info(
f"Starting vectorization orchestration for source {input_data.source_id}"
)
# 1. Load source # 1. Load source
source = await Source.get(input_data.source_id) source = await Source.get(input_data.source_id)
@ -331,7 +335,7 @@ async def vectorize_source_command(
logger.info(f"Deleting existing embeddings for source {input_data.source_id}") logger.info(f"Deleting existing embeddings for source {input_data.source_id}")
delete_result = await repo_query( delete_result = await repo_query(
"DELETE source_embedding WHERE source = $source_id", "DELETE source_embedding WHERE source = $source_id",
{"source_id": ensure_record_id(input_data.source_id)} {"source_id": ensure_record_id(input_data.source_id)},
) )
deleted_count = len(delete_result) if delete_result else 0 deleted_count = len(delete_result) if delete_result else 0
if deleted_count > 0: if deleted_count > 0:
@ -354,12 +358,12 @@ async def vectorize_source_command(
try: try:
job_id = submit_command( job_id = submit_command(
"open_notebook", # app name "open_notebook", # app name
"embed_chunk", # command name "embed_chunk", # command name
{ {
"source_id": input_data.source_id, "source_id": input_data.source_id,
"chunk_index": idx, "chunk_index": idx,
"chunk_text": chunk_text, "chunk_text": chunk_text,
} },
) )
jobs_submitted += 1 jobs_submitted += 1
@ -387,7 +391,9 @@ async def vectorize_source_command(
except Exception as e: except Exception as e:
processing_time = time.time() - start_time processing_time = time.time() - start_time
logger.error(f"Vectorization orchestration failed for source {input_data.source_id}: {e}") logger.error(
f"Vectorization orchestration failed for source {input_data.source_id}: {e}"
)
logger.exception(e) logger.exception(e)
return VectorizeSourceOutput( return VectorizeSourceOutput(
@ -484,7 +490,9 @@ async def rebuild_embeddings_command(
try: try:
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"Starting embedding rebuild with mode={input_data.mode}") logger.info(f"Starting embedding rebuild with mode={input_data.mode}")
logger.info(f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}") logger.info(
f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}"
)
logger.info("=" * 60) logger.info("=" * 60)
# Check embedding model availability # Check embedding model availability
@ -561,7 +569,9 @@ async def rebuild_embeddings_command(
notes_processed += 1 notes_processed += 1
if idx % 10 == 0 or idx == len(items["notes"]): if idx % 10 == 0 or idx == len(items["notes"]):
logger.info(f" Progress: {idx}/{len(items['notes'])} notes processed") logger.info(
f" Progress: {idx}/{len(items['notes'])} notes processed"
)
except Exception as e: except Exception as e:
logger.error(f"Failed to re-embed note {note_id}: {e}") logger.error(f"Failed to re-embed note {note_id}: {e}")

View file

@ -12,6 +12,7 @@ class TextProcessingInput(BaseModel):
operation: str = "uppercase" # uppercase, lowercase, word_count, reverse operation: str = "uppercase" # uppercase, lowercase, word_count, reverse
delay_seconds: Optional[int] = None # For testing async behavior delay_seconds: Optional[int] = None # For testing async behavior
class TextProcessingOutput(BaseModel): class TextProcessingOutput(BaseModel):
success: bool success: bool
original_text: str original_text: str
@ -20,11 +21,13 @@ class TextProcessingOutput(BaseModel):
processing_time: float processing_time: float
error_message: Optional[str] = None error_message: Optional[str] = None
class DataAnalysisInput(BaseModel): class DataAnalysisInput(BaseModel):
numbers: List[float] numbers: List[float]
analysis_type: str = "basic" # basic, detailed analysis_type: str = "basic" # basic, detailed
delay_seconds: Optional[int] = None delay_seconds: Optional[int] = None
class DataAnalysisOutput(BaseModel): class DataAnalysisOutput(BaseModel):
success: bool success: bool
analysis_type: str analysis_type: str
@ -36,6 +39,7 @@ class DataAnalysisOutput(BaseModel):
processing_time: float processing_time: float
error_message: Optional[str] = None error_message: Optional[str] = None
@command("process_text", app="open_notebook") @command("process_text", app="open_notebook")
async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput: async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:
""" """
@ -43,17 +47,17 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
and demonstrates different processing types. and demonstrates different processing types.
""" """
start_time = time.time() start_time = time.time()
try: try:
logger.info(f"Processing text with operation: {input_data.operation}") logger.info(f"Processing text with operation: {input_data.operation}")
# Simulate processing delay if specified # Simulate processing delay if specified
if input_data.delay_seconds: if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds) await asyncio.sleep(input_data.delay_seconds)
processed_text = None processed_text = None
word_count = None word_count = None
if input_data.operation == "uppercase": if input_data.operation == "uppercase":
processed_text = input_data.text.upper() processed_text = input_data.text.upper()
elif input_data.operation == "lowercase": elif input_data.operation == "lowercase":
@ -65,17 +69,17 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
processed_text = f"Word count: {word_count}" processed_text = f"Word count: {word_count}"
else: else:
raise ValueError(f"Unknown operation: {input_data.operation}") raise ValueError(f"Unknown operation: {input_data.operation}")
processing_time = time.time() - start_time processing_time = time.time() - start_time
return TextProcessingOutput( return TextProcessingOutput(
success=True, success=True,
original_text=input_data.text, original_text=input_data.text,
processed_text=processed_text, processed_text=processed_text,
word_count=word_count, word_count=word_count,
processing_time=processing_time processing_time=processing_time,
) )
except Exception as e: except Exception as e:
processing_time = time.time() - start_time processing_time = time.time() - start_time
logger.error(f"Text processing failed: {e}") logger.error(f"Text processing failed: {e}")
@ -83,9 +87,10 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
success=False, success=False,
original_text=input_data.text, original_text=input_data.text,
processing_time=processing_time, processing_time=processing_time,
error_message=str(e) error_message=str(e),
) )
@command("analyze_data", app="open_notebook") @command("analyze_data", app="open_notebook")
async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput: async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:
""" """
@ -93,25 +98,27 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
and demonstrates error handling. and demonstrates error handling.
""" """
start_time = time.time() start_time = time.time()
try: try:
logger.info(f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis") logger.info(
f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis"
)
# Simulate processing delay if specified # Simulate processing delay if specified
if input_data.delay_seconds: if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds) await asyncio.sleep(input_data.delay_seconds)
if not input_data.numbers: if not input_data.numbers:
raise ValueError("No numbers provided for analysis") raise ValueError("No numbers provided for analysis")
count = len(input_data.numbers) count = len(input_data.numbers)
sum_value = sum(input_data.numbers) sum_value = sum(input_data.numbers)
average = sum_value / count average = sum_value / count
min_value = min(input_data.numbers) min_value = min(input_data.numbers)
max_value = max(input_data.numbers) max_value = max(input_data.numbers)
processing_time = time.time() - start_time processing_time = time.time() - start_time
return DataAnalysisOutput( return DataAnalysisOutput(
success=True, success=True,
analysis_type=input_data.analysis_type, analysis_type=input_data.analysis_type,
@ -120,9 +127,9 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
average=average, average=average,
min_value=min_value, min_value=min_value,
max_value=max_value, max_value=max_value,
processing_time=processing_time processing_time=processing_time,
) )
except Exception as e: except Exception as e:
processing_time = time.time() - start_time processing_time = time.time() - start_time
logger.error(f"Data analysis failed: {e}") logger.error(f"Data analysis failed: {e}")
@ -131,5 +138,5 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
analysis_type=input_data.analysis_type, analysis_type=input_data.analysis_type,
count=0, count=0,
processing_time=processing_time, processing_time=processing_time,
error_message=str(e) error_message=str(e),
) )

View file

@ -13,6 +13,9 @@ services:
restart: always restart: always
open_notebook: open_notebook:
image: lfnovo/open_notebook:v1-latest image: lfnovo/open_notebook:v1-latest
# build:
# context: .
# dockerfile: Dockerfile
ports: ports:
- "8502:8502" - "8502:8502"
- "5055:5055" - "5055:5055"

View file

@ -9,6 +9,9 @@ services:
- "5055:5055" # REST API - "5055:5055" # REST API
env_file: env_file:
- ./docker.env - ./docker.env
environment:
# Override for single-container mode: SurrealDB runs on localhost inside the same container
- SURREAL_URL=ws://localhost:8000/rpc
volumes: volumes:
- ./notebook_data:/app/data # Application data - ./notebook_data:/app/data # Application data
- ./surreal_single_data:/mydata # SurrealDB data - ./surreal_single_data:/mydata # SurrealDB data

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,10 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "node start-server.js", "start": "node start-server.js",
"lint": "next lint" "lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
@ -35,12 +38,15 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "^16.1.1", "next": "^16.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.6", "sonner": "^2.0.6",
@ -57,8 +63,14 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.2", "eslint-config-next": "15.4.2",
"jsdom": "^26.0.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "^5" "typescript": "^5",
"vitest": "^3.0.0",
"@vitest/ui": "^3.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@testing-library/react": "^16.2.0",
"@testing-library/jest-dom": "^6.6.3"
} }
} }

View file

@ -18,8 +18,10 @@ import {
} from '@/components/ui/accordion' } from '@/components/ui/accordion'
import { embeddingApi } from '@/lib/api/embedding' import { embeddingApi } from '@/lib/api/embedding'
import type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding' import type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding'
import { useTranslation } from '@/lib/hooks/use-translation'
export function RebuildEmbeddings() { export function RebuildEmbeddings() {
const { t } = useTranslation()
const [mode, setMode] = useState<'existing' | 'all'>('existing') const [mode, setMode] = useState<'existing' | 'all'>('existing')
const [includeSources, setIncludeSources] = useState(true) const [includeSources, setIncludeSources] = useState(true)
const [includeNotes, setIncludeNotes] = useState(true) const [includeNotes, setIncludeNotes] = useState(true)
@ -121,10 +123,10 @@ export function RebuildEmbeddings() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
🔄 Rebuild Embeddings {t.advanced.rebuildEmbeddings}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Rebuild vector embeddings for your content. Use this when switching embedding models or fixing corrupted embeddings. {t.advanced.rebuildEmbeddingsDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -132,25 +134,25 @@ export function RebuildEmbeddings() {
{!isRebuildActive && ( {!isRebuildActive && (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="mode">Rebuild Mode</Label> <Label htmlFor="mode">{t.advanced.rebuild.mode}</Label>
<Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}> <Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}>
<SelectTrigger id="mode"> <SelectTrigger id="mode">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="existing">Existing</SelectItem> <SelectItem value="existing">{t.advanced.rebuild.existing}</SelectItem>
<SelectItem value="all">All</SelectItem> <SelectItem value="all">{t.advanced.rebuild.all}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{mode === 'existing' {mode === 'existing'
? 'Re-embed only items that already have embeddings (faster, for model switching)' ? t.advanced.rebuild.existingDesc
: 'Re-embed existing items + create embeddings for items without any (slower, comprehensive)'} : t.advanced.rebuild.allDesc}
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3" role="group" aria-labelledby="include-label">
<Label>Include in Rebuild</Label> <span id="include-label" className="text-sm font-medium leading-none">{t.advanced.rebuild.include}</span>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@ -159,7 +161,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeSources(checked === true)} onCheckedChange={(checked) => setIncludeSources(checked === true)}
/> />
<Label htmlFor="sources" className="font-normal cursor-pointer"> <Label htmlFor="sources" className="font-normal cursor-pointer">
Sources {t.navigation.sources}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -169,7 +171,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeNotes(checked === true)} onCheckedChange={(checked) => setIncludeNotes(checked === true)}
/> />
<Label htmlFor="notes" className="font-normal cursor-pointer"> <Label htmlFor="notes" className="font-normal cursor-pointer">
Notes {t.common.notes}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -179,7 +181,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeInsights(checked === true)} onCheckedChange={(checked) => setIncludeInsights(checked === true)}
/> />
<Label htmlFor="insights" className="font-normal cursor-pointer"> <Label htmlFor="insights" className="font-normal cursor-pointer">
Insights {t.common.insights}
</Label> </Label>
</div> </div>
</div> </div>
@ -187,7 +189,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Please select at least one item type to rebuild {t.advanced.rebuild.selectOneError}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -201,10 +203,10 @@ export function RebuildEmbeddings() {
{rebuildMutation.isPending ? ( {rebuildMutation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting Rebuild... {t.advanced.rebuild.starting}
</> </>
) : ( ) : (
'🚀 Start Rebuild' t.advanced.rebuild.startBtn
)} )}
</Button> </Button>
@ -212,7 +214,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Failed to start rebuild: {(rebuildMutation.error as Error)?.message || 'Unknown error'} {t.advanced.rebuild.failed}: {(rebuildMutation.error as Error)?.message || t.common.error}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -230,21 +232,21 @@ export function RebuildEmbeddings() {
{status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />} {status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
{status.status === 'queued' && 'Queued'} {status.status === 'queued' && t.advanced.rebuild.queued}
{status.status === 'running' && 'Running...'} {status.status === 'running' && t.advanced.rebuild.running}
{status.status === 'completed' && 'Completed!'} {status.status === 'completed' && t.advanced.rebuild.completed}
{status.status === 'failed' && 'Failed'} {status.status === 'failed' && t.advanced.rebuild.failed}
</span> </span>
{status.status === 'running' && ( {status.status === 'running' && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
You can leave this page as this will run in the background {t.advanced.rebuild.leavePageHint}
</span> </span>
)} )}
</div> </div>
</div> </div>
{(status.status === 'completed' || status.status === 'failed') && ( {(status.status === 'completed' || status.status === 'failed') && (
<Button variant="outline" size="sm" onClick={handleReset}> <Button variant="outline" size="sm" onClick={handleReset}>
Start New Rebuild {t.advanced.rebuild.startNew}
</Button> </Button>
)} )}
</div> </div>
@ -252,36 +254,39 @@ export function RebuildEmbeddings() {
{progressData && ( {progressData && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span>Progress</span> <span>{t.common.progress}</span>
<span className="font-medium"> <span className="font-medium">
{processedItems}/{totalItems} items ({progressPercent.toFixed(1)}%) {t.advanced.rebuild.itemsProcessed
.replace('{processed}', processedItems.toString())
.replace('{total}', totalItems.toString())
.replace('{percent}', progressPercent.toFixed(1))}
</span> </span>
</div> </div>
<Progress value={progressPercent} className="h-2" /> <Progress value={progressPercent} className="h-2" />
{failedItems > 0 && ( {failedItems > 0 && (
<p className="text-sm text-yellow-600"> <p className="text-sm text-yellow-600">
{failedItems} items failed to process {t.advanced.rebuild.failedItems.replace('{count}', failedItems.toString())}
</p> </p>
)} )}
</div> </div>
)} )}
{stats && ( {stats && (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Sources</p> <p className="text-sm text-muted-foreground">{t.navigation.sources}</p>
<p className="text-2xl font-bold">{sourcesProcessed}</p> <p className="text-2xl font-bold">{sourcesProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Notes</p> <p className="text-sm text-muted-foreground">{t.common.notes}</p>
<p className="text-2xl font-bold">{notesProcessed}</p> <p className="text-2xl font-bold">{notesProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Insights</p> <p className="text-sm text-muted-foreground">{t.common.insights}</p>
<p className="text-2xl font-bold">{insightsProcessed}</p> <p className="text-2xl font-bold">{insightsProcessed}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-muted-foreground">Time</p> <p className="text-sm text-muted-foreground">{t.advanced.rebuild.time}</p>
<p className="text-2xl font-bold"> <p className="text-2xl font-bold">
{processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'} {processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}
</p> </p>
@ -298,9 +303,9 @@ export function RebuildEmbeddings() {
{status.started_at && ( {status.started_at && (
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
<p>Started: {new Date(status.started_at).toLocaleString()}</p> <p>{t.common.created.replace('{time}', new Date(status.started_at).toLocaleString())}</p>
{status.completed_at && ( {status.completed_at && (
<p>Completed: {new Date(status.completed_at).toLocaleString()}</p> <p>{t.notebooks.updated}: {new Date(status.completed_at).toLocaleString()}</p>
)} )}
</div> </div>
)} )}
@ -308,51 +313,25 @@ export function RebuildEmbeddings() {
)} )}
{/* Help Section */} {/* Help Section */}
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="when"> <AccordionItem value="when">
<AccordionTrigger>When should I rebuild embeddings?</AccordionTrigger> <AccordionTrigger>{t.advanced.rebuild.whenToRebuild}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p><strong>You should rebuild embeddings when:</strong></p> <p>{t.advanced.rebuild.whenToRebuildAns}</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Switching embedding models:</strong> If you change from one embedding model to another, you need to rebuild all embeddings to ensure consistency.</li>
<li><strong>Upgrading model versions:</strong> When updating to a newer version of your embedding model, rebuild to take advantage of improvements.</li>
<li><strong>Fixing corrupted embeddings:</strong> If you suspect some embeddings are corrupted or missing, rebuilding can restore them.</li>
<li><strong>After bulk imports:</strong> If you imported content without embeddings, use &quot;All&quot; mode to embed everything.</li>
</ul>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="time"> <AccordionItem value="time">
<AccordionTrigger>How long does rebuilding take?</AccordionTrigger> <AccordionTrigger>{t.advanced.rebuild.howLong}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p><strong>Processing time depends on:</strong></p> <p>{t.advanced.rebuild.howLongAns}</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Number of items to process</li>
<li>Embedding model speed</li>
<li>API rate limits (for cloud providers)</li>
<li>System resources</li>
</ul>
<p className="mt-2"><strong>Typical rates:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Local models</strong> (Ollama): Very fast, limited only by hardware</li>
<li><strong>Cloud APIs</strong> (OpenAI, Google): Moderate speed, may hit rate limits with large datasets</li>
<li><strong>Sources:</strong> Slower than notes/insights (creates multiple chunks per source)</li>
</ul>
<p className="mt-2"><em>Example: Rebuilding 200 items might take 2-5 minutes with cloud APIs, or under 1 minute with local models.</em></p>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="safe"> <AccordionItem value="safe">
<AccordionTrigger>Is it safe to rebuild while using the app?</AccordionTrigger> <AccordionTrigger>{t.advanced.rebuild.isSafe}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm"> <AccordionContent className="space-y-2 text-sm">
<p><strong>Yes, rebuilding is safe!</strong> The rebuild process:</p> <p>{t.advanced.rebuild.isSafeAns}</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li> <strong>Is idempotent:</strong> Running multiple times produces the same result</li>
<li> <strong>Doesn&apos;t delete content:</strong> Only replaces embeddings</li>
<li> <strong>Can be run anytime:</strong> No need to stop other operations</li>
<li> <strong>Handles errors gracefully:</strong> Failed items are logged and skipped</li>
</ul>
<p className="mt-2"> <strong>However:</strong> Very large rebuilds (1000s of items) may temporarily slow down searches while processing.</p>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>

View file

@ -4,8 +4,10 @@ import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { getConfig } from '@/lib/config' import { getConfig } from '@/lib/config'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useTranslation } from '@/lib/hooks/use-translation'
export function SystemInfo() { export function SystemInfo() {
const { t } = useTranslation()
const [config, setConfig] = useState<{ const [config, setConfig] = useState<{
version: string version: string
latestVersion?: string | null latestVersion?: string | null
@ -32,8 +34,8 @@ export function SystemInfo() {
return ( return (
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">System Information</h2> <h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2>
<div className="text-sm text-muted-foreground">Loading...</div> <div className="text-sm text-muted-foreground">{t.common.loading}</div>
</div> </div>
</Card> </Card>
) )
@ -42,37 +44,37 @@ export function SystemInfo() {
return ( return (
<Card className="p-6"> <Card className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">System Information</h2> <h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2>
<div className="space-y-3"> <div className="space-y-3">
{/* Current Version */} {/* Current Version */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Current Version</span> <span className="text-sm font-medium">{t.advanced.currentVersion}</span>
<Badge variant="outline">{config?.version || 'Unknown'}</Badge> <Badge variant="outline">{config?.version || t.advanced.unknown}</Badge>
</div> </div>
{/* Latest Version */} {/* Latest Version */}
{config?.latestVersion && ( {config?.latestVersion && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Latest Version</span> <span className="text-sm font-medium">{t.advanced.latestVersion}</span>
<Badge variant="outline">{config.latestVersion}</Badge> <Badge variant="outline">{config.latestVersion}</Badge>
</div> </div>
)} )}
{/* Update Status */} {/* Update Status */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span> <span className="text-sm font-medium">{t.advanced.status}</span>
{config?.hasUpdate ? ( {config?.hasUpdate ? (
<Badge variant="destructive"> <Badge variant="destructive">
Update Available {t.advanced.updateAvailable.replace('{version}', config.latestVersion || '')}
</Badge> </Badge>
) : config?.latestVersion ? ( ) : config?.latestVersion ? (
<Badge variant="outline" className="text-green-600 border-green-600"> <Badge variant="outline" className="text-green-600 border-green-600">
Up to Date {t.advanced.upToDate}
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-muted-foreground"> <Badge variant="outline" className="text-muted-foreground">
Unknown {t.advanced.unknown}
</Badge> </Badge>
)} )}
</div> </div>
@ -86,7 +88,7 @@ export function SystemInfo() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1" className="text-sm text-primary hover:underline inline-flex items-center gap-1"
> >
View on GitHub {t.advanced.viewOnGithub}
<svg <svg
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
@ -107,7 +109,7 @@ export function SystemInfo() {
{/* Version Check Failed Message */} {/* Version Check Failed Message */}
{!config?.latestVersion && config?.version && ( {!config?.latestVersion && config?.version && (
<div className="pt-2 text-xs text-muted-foreground"> <div className="pt-2 text-xs text-muted-foreground">
Unable to check for updates. GitHub may be unreachable. {t.advanced.updateCheckFailed}
</div> </div>
)} )}
</div> </div>

View file

@ -3,17 +3,19 @@
import { AppShell } from '@/components/layout/AppShell' import { AppShell } from '@/components/layout/AppShell'
import { RebuildEmbeddings } from './components/RebuildEmbeddings' import { RebuildEmbeddings } from './components/RebuildEmbeddings'
import { SystemInfo } from './components/SystemInfo' import { SystemInfo } from './components/SystemInfo'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function AdvancedPage() { export default function AdvancedPage() {
const { t } = useTranslation()
return ( return (
<AppShell> <AppShell>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="p-6"> <div className="p-6">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<div> <div>
<h1 className="text-3xl font-bold">Advanced</h1> <h1 className="text-3xl font-bold">{t.advanced.title}</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Advanced tools and utilities for power users {t.advanced.desc}
</p> </p>
</div> </div>

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useId, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models' import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useCreateModel } from '@/lib/hooks/use-models' import { useCreateModel } from '@/lib/hooks/use-models'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface AddModelFormProps { interface AddModelFormProps {
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
@ -17,6 +18,9 @@ interface AddModelFormProps {
} }
export function AddModelForm({ modelType, providers }: AddModelFormProps) { export function AddModelForm({ modelType, providers }: AddModelFormProps) {
const { t } = useTranslation()
const providerSelectId = useId()
const modelNameInputId = useId()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const createModel = useCreateModel() const createModel = useCreateModel()
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({ const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({
@ -37,7 +41,7 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
} }
const getModelTypeName = () => { const getModelTypeName = () => {
return modelType.replace(/_/g, ' ') return (t.models as Record<string, string>)[modelType] || modelType.replace(/_/g, ' ')
} }
const getModelPlaceholder = () => { const getModelPlaceholder = () => {
@ -51,14 +55,14 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
case 'speech_to_text': case 'speech_to_text':
return 'e.g., whisper-1' return 'e.g., whisper-1'
default: default:
return 'Enter model name' return t.models.enterModelName
} }
} }
if (availableProviders.length === 0) { if (availableProviders.length === 0) {
return ( return (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
No providers available for {getModelTypeName()} models {t.models.noProvidersForType.replace('{type}', getModelTypeName())}
</div> </div>
) )
} }
@ -73,24 +77,34 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button
id={`add-model-${modelType}`}
name={`add-model-${modelType}`}
size="sm"
>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Model {t.models.addModel}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add {getModelTypeName()} Model</DialogTitle> <DialogTitle>
{t.models.addSpecificModel.replace('{type}', getModelTypeName())}
</DialogTitle>
<DialogDescription> <DialogDescription>
Configure a new {getModelTypeName()} model from available providers. {t.models.addSpecificModelDesc.replace('{type}', getModelTypeName())}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div> <div>
<Label htmlFor="provider">Provider</Label> <Label htmlFor={providerSelectId}>{t.models.provider}</Label>
<Select onValueChange={(value) => setValue('provider', value)} required> <Select
<SelectTrigger> name="provider"
<SelectValue placeholder="Select a provider" /> onValueChange={(value) => setValue('provider', value)}
required
>
<SelectTrigger id={providerSelectId}>
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableProviders.map((provider) => ( {availableProviders.map((provider) => (
@ -101,32 +115,33 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
</SelectContent> </SelectContent>
</Select> </Select>
{errors.provider && ( {errors.provider && (
<p className="text-sm text-destructive mt-1">Provider is required</p> <p className="text-sm text-destructive mt-1">{t.models.providerRequired}</p>
)} )}
</div> </div>
<div> <div>
<Label htmlFor="name">Model Name</Label> <Label htmlFor={modelNameInputId}>{t.models.modelName}</Label>
<Input <Input
id="name" id={modelNameInputId}
{...register('name', { required: 'Model name is required' })} {...register('name', { required: t.models.modelNameRequired })}
placeholder={getModelPlaceholder()} placeholder={getModelPlaceholder()}
autoComplete="off"
/> />
{errors.name && ( {errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p> <p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)} )}
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{modelType === 'language' && watch('provider') === 'azure' && {modelType === 'language' && watch('provider') === 'azure' &&
'For Azure, use the deployment name as the model name'} t.models.azureHint}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}> <Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={createModel.isPending}> <Button type="submit" disabled={createModel.isPending}>
{createModel.isPending ? 'Adding...' : 'Add Model'} {createModel.isPending ? t.models.adding : t.models.addModel}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, useId } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -11,74 +11,86 @@ import { ModelDefaults, Model } from '@/lib/types/models'
import { useUpdateModelDefaults } from '@/lib/hooks/use-models' import { useUpdateModelDefaults } from '@/lib/hooks/use-models'
import { AlertCircle, X } from 'lucide-react' import { AlertCircle, X } from 'lucide-react'
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog' import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
import { useTranslation } from '@/lib/hooks/use-translation'
interface DefaultModelsSectionProps { interface DefaultModelsSectionProps {
models: Model[] models: Model[]
defaults: ModelDefaults defaults: ModelDefaults
} }
interface DefaultConfig {
key: keyof ModelDefaults
label: string
description: string
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
required?: boolean
}
const defaultConfigs: DefaultConfig[] = [
{
key: 'default_chat_model',
label: 'Chat Model',
description: 'Used for chat conversations',
modelType: 'language',
required: true
},
{
key: 'default_transformation_model',
label: 'Transformation Model',
description: 'Used for summaries, insights, and transformations',
modelType: 'language',
required: true
},
{
key: 'default_tools_model',
label: 'Tools Model',
description: 'Used for function calling - OpenAI or Anthropic recommended',
modelType: 'language'
},
{
key: 'large_context_model',
label: 'Large Context Model',
description: 'Used for processing large documents - Gemini recommended',
modelType: 'language'
},
{
key: 'default_embedding_model',
label: 'Embedding Model',
description: 'Used for semantic search and vector embeddings',
modelType: 'embedding',
required: true
},
{
key: 'default_text_to_speech_model',
label: 'Text-to-Speech Model',
description: 'Used for podcast generation',
modelType: 'text_to_speech'
},
{
key: 'default_speech_to_text_model',
label: 'Speech-to-Text Model',
description: 'Used for audio transcription',
modelType: 'speech_to_text'
}
]
export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) { export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) {
const { t } = useTranslation()
const updateDefaults = useUpdateModelDefaults() const updateDefaults = useUpdateModelDefaults()
const { setValue, watch } = useForm<ModelDefaults>({ const { setValue, watch } = useForm<ModelDefaults>({
defaultValues: defaults defaultValues: defaults
}) })
interface DefaultConfig {
key: keyof ModelDefaults
label: string
description: string
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
required?: boolean
id: string
}
const generatedId = useId()
const defaultConfigs: DefaultConfig[] = [
{
key: 'default_chat_model',
label: t.models.chatModelLabel,
description: t.models.chatModelDesc,
modelType: 'language',
required: true,
id: `${generatedId}-chat`,
},
{
key: 'default_transformation_model',
label: t.models.transformationModelLabel,
description: t.models.transformationModelDesc,
modelType: 'language',
required: true,
id: `${generatedId}-transformation`,
},
{
key: 'default_tools_model',
label: t.models.toolsModelLabel,
description: t.models.toolsModelDesc,
modelType: 'language',
id: `${generatedId}-tools`,
},
{
key: 'large_context_model',
label: t.models.largeContextModelLabel,
description: t.models.largeContextModelDesc,
modelType: 'language',
id: `${generatedId}-large-context`,
},
{
key: 'default_embedding_model',
label: t.models.embeddingModelLabel,
description: t.models.embeddingModelDesc,
modelType: 'embedding',
required: true,
id: `${generatedId}-embedding`,
},
{
key: 'default_text_to_speech_model',
label: t.models.ttsModelLabel,
description: t.models.ttsModelDesc,
modelType: 'text_to_speech',
id: `${generatedId}-tts`,
},
{
key: 'default_speech_to_text_model',
label: t.models.sttModelLabel,
description: t.models.sttModelDesc,
modelType: 'speech_to_text',
id: `${generatedId}-stt`,
},
]
// State for embedding model change dialog // State for embedding model change dialog
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false) const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{ const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
@ -153,9 +165,9 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Default Model Assignments</CardTitle> <CardTitle>{t.models.defaultAssignments}</CardTitle>
<CardDescription> <CardDescription>
Configure which models to use for different purposes across Open Notebook {t.models.defaultAssignmentsDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -163,8 +175,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Missing required models: {missingRequired.join(', ')}. {t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}
Open Notebook may not function properly without these.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -179,7 +190,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
return ( return (
<div key={config.key} className="space-y-2"> <div key={config.key} className="space-y-2">
<Label> <Label htmlFor={config.id}>
{config.label} {config.label}
{config.required && <span className="text-destructive ml-1">*</span>} {config.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
@ -188,15 +199,18 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
value={currentValue || ""} value={currentValue || ""}
onValueChange={(value) => handleChange(config.key, value)} onValueChange={(value) => handleChange(config.key, value)}
> >
<SelectTrigger className={ <SelectTrigger
config.required && !isValidModel && availableModels.length > 0 id={config.id}
? 'border-destructive' className={
: '' config.required && !isValidModel && availableModels.length > 0
}> ? 'border-destructive'
: ''
}
>
<SelectValue placeholder={ <SelectValue placeholder={
config.required && !isValidModel && availableModels.length > 0 config.required && !isValidModel && availableModels.length > 0
? "⚠️ Required - Select a model" ? t.models.requiredModelPlaceholder
: "Select a model" : t.models.selectModelPlaceholder
} /> } />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -236,7 +250,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
Which model should I choose? {t.models.whichModelToChoose}
</a> </a>
</div> </div>
</CardContent> </CardContent>

View file

@ -14,6 +14,7 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle, ExternalLink } from 'lucide-react' import { AlertTriangle, ExternalLink } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface EmbeddingModelChangeDialogProps { interface EmbeddingModelChangeDialogProps {
open: boolean open: boolean
@ -30,6 +31,7 @@ export function EmbeddingModelChangeDialog({
oldModelName, oldModelName,
newModelName newModelName
}: EmbeddingModelChangeDialogProps) { }: EmbeddingModelChangeDialogProps) {
const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const [isConfirming, setIsConfirming] = useState(false) const [isConfirming, setIsConfirming] = useState(false)
@ -55,54 +57,49 @@ export function EmbeddingModelChangeDialog({
<AlertDialogHeader> <AlertDialogHeader>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" /> <AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>Embedding Model Change</AlertDialogTitle> <AlertDialogTitle>{t.models.embeddingChangeTitle}</AlertDialogTitle>
</div> </div>
<AlertDialogDescription asChild> <AlertDialogDescription asChild>
<div className="space-y-3 text-base text-muted-foreground"> <div className="space-y-3 text-base text-muted-foreground">
<p> <p>
You are about to change your embedding model{' '} {t.models.embeddingChangeConfirm
{oldModelName && newModelName && ( .replace('{from}', oldModelName || '...')
<> .replace('{to}', newModelName || '...')}
from <strong>{oldModelName}</strong> to <strong>{newModelName}</strong>
</>
)}
.
</p> </p>
<div className="bg-muted p-4 rounded-md space-y-2"> <div className="bg-muted p-4 rounded-md space-y-2">
<p className="font-semibold text-foreground"> Important: Rebuild Required</p> <p className="font-semibold text-foreground"> {t.models.rebuildRequired}</p>
<p className="text-sm"> <p className="text-sm">
Changing your embedding model requires rebuilding all existing embeddings to maintain consistency. {t.models.rebuildReason}
Without rebuilding, your searches may return incorrect or incomplete results.
</p> </p>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<p className="font-medium text-foreground">What happens next:</p> <p className="font-medium text-foreground">{t.models.whatHappensNext}</p>
<ul className="list-disc list-inside space-y-1 ml-2"> <ul className="list-disc list-inside space-y-1 ml-2">
<li>Your default embedding model will be updated</li> <li>{t.models.step1}</li>
<li>Existing embeddings will remain unchanged until rebuild</li> <li>{t.models.step2}</li>
<li>New content will use the new embedding model</li> <li>{t.models.step3}</li>
<li>You should rebuild embeddings as soon as possible</li> <li>{t.models.step4}</li>
</ul> </ul>
</div> </div>
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Would you like to proceed to the Advanced page to start the rebuild now? {t.models.proceedToRebuildPrompt}
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2"> <AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel disabled={isConfirming}> <AlertDialogCancel disabled={isConfirming}>
Cancel {t.common.cancel}
</AlertDialogCancel> </AlertDialogCancel>
<Button <Button
variant="outline" variant="outline"
onClick={handleConfirmOnly} onClick={handleConfirmOnly}
disabled={isConfirming} disabled={isConfirming}
> >
Change Model Only {t.models.changeModelOnly}
</Button> </Button>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmAndRebuild} onClick={handleConfirmAndRebuild}
@ -110,7 +107,7 @@ export function EmbeddingModelChangeDialog({
className="bg-primary" className="bg-primary"
> >
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
Change & Go to Rebuild {t.models.changeAndRebuild}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useDeleteModel } from '@/lib/hooks/use-models' import { useDeleteModel } from '@/lib/hooks/use-models'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ModelTypeSectionProps { interface ModelTypeSectionProps {
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
@ -21,6 +22,7 @@ interface ModelTypeSectionProps {
const COLLAPSED_ITEM_COUNT = 5 const COLLAPSED_ITEM_COUNT = 5
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) { export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
const { t } = useTranslation()
const [deleteModel, setDeleteModel] = useState<Model | null>(null) const [deleteModel, setDeleteModel] = useState<Model | null>(null)
const [selectedProvider, setSelectedProvider] = useState<string | null>(null) const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
@ -30,32 +32,32 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
switch (type) { switch (type) {
case 'language': case 'language':
return { return {
title: 'Language Models', title: t.models.language,
description: 'Chat, transformations, and text generation', description: t.models.languageDesc,
icon: Bot, icon: Bot,
iconColor: 'text-blue-500', iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20' bgColor: 'bg-blue-50 dark:bg-blue-950/20'
} }
case 'embedding': case 'embedding':
return { return {
title: 'Embedding Models', title: t.models.embedding,
description: 'Semantic search and vector embeddings', description: t.models.embeddingDesc,
icon: Search, icon: Search,
iconColor: 'text-green-500', iconColor: 'text-green-500',
bgColor: 'bg-green-50 dark:bg-green-950/20' bgColor: 'bg-green-50 dark:bg-green-950/20'
} }
case 'text_to_speech': case 'text_to_speech':
return { return {
title: 'Text-to-Speech', title: t.models.tts,
description: 'Generate audio from text', description: t.models.ttsDesc,
icon: Volume2, icon: Volume2,
iconColor: 'text-purple-500', iconColor: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-950/20' bgColor: 'bg-purple-50 dark:bg-purple-950/20'
} }
case 'speech_to_text': case 'speech_to_text':
return { return {
title: 'Speech-to-Text', title: t.models.stt,
description: 'Transcribe audio to text', description: t.models.sttDesc,
icon: Mic, icon: Mic,
iconColor: 'text-orange-500', iconColor: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-950/20' bgColor: 'bg-orange-50 dark:bg-orange-950/20'
@ -118,7 +120,7 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
className="cursor-pointer text-xs" className="cursor-pointer text-xs"
onClick={() => setSelectedProvider(null)} onClick={() => setSelectedProvider(null)}
> >
All {t.models.all}
</Badge> </Badge>
{modelProviders.map(provider => ( {modelProviders.map(provider => (
<Badge <Badge
@ -143,8 +145,8 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
) : filteredModels.length === 0 ? ( ) : filteredModels.length === 0 ? (
<div className="text-center py-6 text-sm text-muted-foreground"> <div className="text-center py-6 text-sm text-muted-foreground">
{selectedProvider {selectedProvider
? `No ${selectedProvider} models configured` ? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider)
: 'No models configured' : t.models.noModelsConfigured
} }
</div> </div>
) : ( ) : (
@ -182,12 +184,12 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
{isExpanded ? ( {isExpanded ? (
<> <>
<ChevronUp className="h-4 w-4 mr-2" /> <ChevronUp className="h-4 w-4 mr-2" />
Show less {t.models.seeLess}
</> </>
) : ( ) : (
<> <>
<ChevronDown className="h-4 w-4 mr-2" /> <ChevronDown className="h-4 w-4 mr-2" />
Show {filteredModels.length - COLLAPSED_ITEM_COUNT} more {t.models.showMore.replace('{count}', (filteredModels.length - COLLAPSED_ITEM_COUNT).toString())}
</> </>
)} )}
</Button> </Button>
@ -200,9 +202,9 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
<ConfirmDialog <ConfirmDialog
open={!!deleteModel} open={!!deleteModel}
onOpenChange={(open) => !open && setDeleteModel(null)} onOpenChange={(open) => !open && setDeleteModel(null)}
title="Delete Model" title={t.models.deleteModel}
description={`Are you sure you want to delete "${deleteModel?.name}"? This action cannot be undone.`} description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')}
confirmText="Delete" confirmText={t.common.delete}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDelete} onConfirm={handleDelete}
/> />

View file

@ -6,12 +6,14 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Check, X } from 'lucide-react' import { Check, X } from 'lucide-react'
import { ProviderAvailability } from '@/lib/types/models' import { ProviderAvailability } from '@/lib/types/models'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ProviderStatusProps { interface ProviderStatusProps {
providers: ProviderAvailability providers: ProviderAvailability
} }
export function ProviderStatus({ providers }: ProviderStatusProps) { export function ProviderStatus({ providers }: ProviderStatusProps) {
const { t } = useTranslation()
// Combine all providers, with available ones first // Combine all providers, with available ones first
const allProviders = useMemo( const allProviders = useMemo(
() => [ () => [
@ -33,11 +35,13 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>AI Providers</CardTitle> <CardTitle>{t.models.aiProviders}</CardTitle>
<CardDescription> <CardDescription>
Configure providers through environment variables to enable their models. {t.models.providerConfigDesc}
<span className="ml-1"> <span className="ml-1">
{providers.available.length} of {allProviders.length} configured {t.models.configuredCount
.replace('{count}', providers.available.length.toString())
.replace('{total}', allProviders.length.toString())}
</span> </span>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -74,21 +78,21 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
{provider.name} {provider.name}
</span> </span>
{provider.available ? ( {provider.available ? (
<div className="flex flex-wrap items-center justify-end gap-1"> <div className="flex flex-wrap items-center justify-end gap-1">
{supportedTypes.length > 0 ? ( {supportedTypes.length > 0 ? (
supportedTypes.map((type) => ( supportedTypes.map((type) => (
<Badge key={type} variant="secondary" className="text-xs font-medium"> <Badge key={type} variant="secondary" className="text-xs font-medium">
{type.replace('_', ' ')} {(t.models as Record<string, string>)[type] || type.replace('_', ' ')}
</Badge> </Badge>
)) ))
) : ( ) : (
<Badge variant="outline" className="text-xs">No models</Badge> <Badge variant="outline" className="text-xs">{t.models.noModels}</Badge>
)} )}
</div> </div>
) : ( ) : (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed"> <Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
Not configured {t.models.notConfigured}
</Badge> </Badge>
)} )}
</div> </div>
@ -97,26 +101,28 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
})} })}
</div> </div>
{allProviders.length > 6 ? ( {allProviders.length > 6 ? (
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<button <button
type="button" type="button"
onClick={() => setExpanded((prev) => !prev)} onClick={() => setExpanded((prev) => !prev)}
className="text-sm font-medium text-primary hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
{expanded ? 'See less' : `See all ${allProviders.length} providers`} {expanded
? t.models.seeLess
: t.models.seeAll.replace('{count}', allProviders.length.toString())}
</button> </button>
</div> </div>
) : null} ) : null}
<div className="mt-6 pt-4 border-t"> <div className="mt-6 pt-4 border-t">
<a <a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md" href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
Learn how to configure providers {t.models.learnMore}
</a> </a>
</div> </div>
</CardContent> </CardContent>

View file

@ -8,8 +8,10 @@ import { useModels, useModelDefaults, useProviders } from '@/lib/hooks/use-model
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function ModelsPage() { export default function ModelsPage() {
const { t } = useTranslation()
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels() const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults() const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders() const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
@ -35,7 +37,7 @@ export default function ModelsPage() {
<AppShell> <AppShell>
<div className="p-6"> <div className="p-6">
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-muted-foreground">Failed to load models data</p> <p className="text-muted-foreground">{t.models.failedToLoad}</p>
</div> </div>
</div> </div>
</AppShell> </AppShell>
@ -48,9 +50,9 @@ export default function ModelsPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Model Management</h1> <h1 className="text-2xl font-bold">{t.models.title}</h1>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Configure AI models for different purposes across Open Notebook {t.models.desc}
</p> </p>
</div> </div>
<Button variant="outline" size="sm" onClick={handleRefresh}> <Button variant="outline" size="sm" onClick={handleRefresh}>

View file

@ -13,6 +13,7 @@ import { useNotes } from '@/lib/hooks/use-notes'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store' import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
import { useIsDesktop } from '@/lib/hooks/use-media-query' import { useIsDesktop } from '@/lib/hooks/use-media-query'
import { useTranslation } from '@/lib/hooks/use-translation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FileText, StickyNote, MessageSquare } from 'lucide-react' import { FileText, StickyNote, MessageSquare } from 'lucide-react'
@ -25,10 +26,11 @@ export interface ContextSelections {
} }
export default function NotebookPage() { export default function NotebookPage() {
const { t } = useTranslation()
const params = useParams() const params = useParams()
// Ensure the notebook ID is properly decoded from URL // Ensure the notebook ID is properly decoded from URL
const notebookId = decodeURIComponent(params.id as string) const notebookId = params?.id ? decodeURIComponent(params.id as string) : ''
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId) const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
const { const {
@ -112,8 +114,8 @@ export default function NotebookPage() {
return ( return (
<AppShell> <AppShell>
<div className="p-6"> <div className="p-6">
<h1 className="text-2xl font-bold mb-4">Notebook Not Found</h1> <h1 className="text-2xl font-bold mb-4">{t.notebooks.notFound}</h1>
<p className="text-muted-foreground">The requested notebook could not be found.</p> <p className="text-muted-foreground">{t.notebooks.notFoundDesc}</p>
</div> </div>
</AppShell> </AppShell>
) )
@ -135,15 +137,15 @@ export default function NotebookPage() {
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sources" className="gap-2"> <TabsTrigger value="sources" className="gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Sources {t.navigation.sources}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="notes" className="gap-2"> <TabsTrigger value="notes" className="gap-2">
<StickyNote className="h-4 w-4" /> <StickyNote className="h-4 w-4" />
Notes {t.common.notes}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="chat" className="gap-2"> <TabsTrigger value="chat" className="gap-2">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
Chat {t.common.chat}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View file

@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { ChatColumn } from './ChatColumn'
import { useSources } from '@/lib/hooks/use-sources'
import { useNotes } from '@/lib/hooks/use-notes'
import { useNotebookChat } from '@/lib/hooks/useNotebookChat'
// Mock the hooks
vi.mock('@/lib/hooks/use-sources')
vi.mock('@/lib/hooks/use-notes')
vi.mock('@/lib/hooks/useNotebookChat')
vi.mock('@/components/source/ChatPanel', () => ({
ChatPanel: () => <div data-testid="chat-panel" />
}))
// Type-safe mock factory for useSources hook
function createSourcesMock(overrides: { isLoading?: boolean } = {}) {
return {
data: [],
isLoading: overrides.isLoading ?? false,
} as unknown as ReturnType<typeof useSources>
}
// Type-safe mock factory for useNotes hook
function createNotesMock(overrides: { isLoading?: boolean } = {}) {
return {
data: [],
isLoading: overrides.isLoading ?? false,
} as unknown as ReturnType<typeof useNotes>
}
// Type-safe mock factory for useNotebookChat hook
function createChatMock() {
return {
messages: [],
isSending: false,
tokenCount: 0,
charCount: 0,
sessions: [],
currentSessionId: null,
} as unknown as ReturnType<typeof useNotebookChat>
}
describe('ChatColumn', () => {
const mockProps = {
notebookId: 'test-notebook',
contextSelections: {
sources: {},
notes: {}
}
}
it('shows loading spinner when fetching data', () => {
vi.mocked(useSources).mockReturnValue(createSourcesMock({ isLoading: true }))
vi.mocked(useNotes).mockReturnValue(createNotesMock({ isLoading: true }))
vi.mocked(useNotebookChat).mockReturnValue(createChatMock())
render(<ChatColumn {...mockProps} />)
// Should show loading spinner
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
it('renders chat panel when data is loaded', () => {
vi.mocked(useSources).mockReturnValue(createSourcesMock({ isLoading: false }))
vi.mocked(useNotes).mockReturnValue(createNotesMock({ isLoading: false }))
vi.mocked(useNotebookChat).mockReturnValue(createChatMock())
render(<ChatColumn {...mockProps} />)
// Should show chat panel
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
})
})

View file

@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
import { ContextSelections } from '../[id]/page' import { ContextSelections } from '../[id]/page'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ChatColumnProps { interface ChatColumnProps {
notebookId: string notebookId: string
@ -16,6 +17,8 @@ interface ChatColumnProps {
} }
export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) { export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
const { t } = useTranslation()
// Fetch sources and notes for this notebook // Fetch sources and notes for this notebook
const { data: sources = [], isLoading: sourcesLoading } = useSources(notebookId) const { data: sources = [], isLoading: sourcesLoading } = useSources(notebookId)
const { data: notes = [], isLoading: notesLoading } = useNotes(notebookId) const { data: notes = [], isLoading: notesLoading } = useNotes(notebookId)
@ -79,8 +82,8 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
<CardContent className="flex-1 flex items-center justify-center"> <CardContent className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" /> <AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">Unable to load chat</p> <p className="text-sm">{t.chat.unableToLoadChat}</p>
<p className="text-xs mt-2">Please try refreshing the page</p> <p className="text-xs mt-2">{t.common.refreshPage || 'Please try refreshing the page'}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -89,7 +92,7 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
return ( return (
<ChatPanel <ChatPanel
title="Chat with Notebook" title={t.chat.chatWithNotebook}
contextType="notebook" contextType="notebook"
messages={chat.messages} messages={chat.messages}
isStreaming={chat.isSending} isStreaming={chat.isSending}

View file

@ -12,6 +12,7 @@ import { QUERY_KEYS } from '@/lib/api/query-client'
import { MarkdownEditor } from '@/components/ui/markdown-editor' import { MarkdownEditor } from '@/components/ui/markdown-editor'
import { InlineEdit } from '@/components/common/InlineEdit' import { InlineEdit } from '@/components/common/InlineEdit'
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from '@/lib/hooks/use-translation'
const createNoteSchema = z.object({ const createNoteSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
@ -28,6 +29,7 @@ interface NoteEditorDialogProps {
} }
export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) { export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) {
const { t } = useTranslation()
const createNote = useCreateNote() const createNote = useCreateNote()
const updateNote = useUpdateNote() const updateNote = useUpdateNote()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -122,21 +124,23 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen" isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen"
)}> )}>
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{isEditing ? 'Edit note' : 'Create note'} {isEditing ? t.sources.editNote : t.sources.createNote}
</DialogTitle> </DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
{isEditing && noteLoading ? ( {isEditing && noteLoading ? (
<div className="flex-1 flex items-center justify-center py-10"> <div className="flex-1 flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">Loading note</span> <span className="text-sm text-muted-foreground">{t.common.loading}</span>
</div> </div>
) : ( ) : (
<> <>
<div className="border-b px-6 py-4"> <div className="border-b px-6 py-4">
<InlineEdit <InlineEdit
id="note-title"
name="title"
value={watchTitle ?? ''} value={watchTitle ?? ''}
onSave={(value) => setValue('title', value || '')} onSave={(value) => setValue('title', value || '')}
placeholder="Add a title..." placeholder={t.sources.addTitle}
emptyText="Untitled Note" emptyText={t.sources.untitledNote}
className="text-xl font-semibold" className="text-xl font-semibold"
inputClassName="text-xl font-semibold" inputClassName="text-xl font-semibold"
/> />
@ -152,10 +156,11 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
render={({ field }) => ( render={({ field }) => (
<MarkdownEditor <MarkdownEditor
key={note?.id ?? 'new'} key={note?.id ?? 'new'}
textareaId="note-content"
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
height={420} height={420}
placeholder="Write your note content here..." placeholder={t.sources.writeNotePlaceholder}
className={cn( className={cn(
"w-full h-full min-h-[420px] [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full", "w-full h-full min-h-[420px] [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full",
!isEditorFullscreen && "rounded-md border" !isEditorFullscreen && "rounded-md border"
@ -172,17 +177,17 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
<div className="border-t px-6 py-4 flex justify-end gap-2"> <div className="border-t px-6 py-4 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" variant="outline" onClick={handleClose}>
Cancel {t.common.cancel}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isSaving || (isEditing && noteLoading)} disabled={isSaving || (isEditing && noteLoading)}
> >
{isSaving {isSaving
? isEditing ? 'Saving...' : 'Creating...' ? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
: isEditing : isEditing
? 'Save Note' ? t.sources.saveNote
: 'Create Note'} : t.sources.createNoteBtn}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -16,12 +16,14 @@ import {
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks' import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getDateLocale } from '@/lib/utils/date-locale'
interface NotebookCardProps { interface NotebookCardProps {
notebook: NotebookResponse notebook: NotebookResponse
} }
export function NotebookCard({ notebook }: NotebookCardProps) { export function NotebookCard({ notebook }: NotebookCardProps) {
const { t, language } = useTranslation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const router = useRouter() const router = useRouter()
const updateNotebook = useUpdateNotebook() const updateNotebook = useUpdateNotebook()
@ -59,7 +61,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
</CardTitle> </CardTitle>
{notebook.archived && ( {notebook.archived && (
<Badge variant="secondary" className="mt-1"> <Badge variant="secondary" className="mt-1">
Archived {t.notebooks.archived}
</Badge> </Badge>
)} )}
</div> </div>
@ -80,12 +82,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
{notebook.archived ? ( {notebook.archived ? (
<> <>
<ArchiveRestore className="h-4 w-4 mr-2" /> <ArchiveRestore className="h-4 w-4 mr-2" />
Unarchive {t.notebooks.unarchive}
</> </>
) : ( ) : (
<> <>
<Archive className="h-4 w-4 mr-2" /> <Archive className="h-4 w-4 mr-2" />
Archive {t.notebooks.archive}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@ -97,7 +99,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
className="text-red-600" className="text-red-600"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete {t.common.delete}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -106,11 +108,14 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
<CardContent> <CardContent>
<CardDescription className="line-clamp-2 text-sm"> <CardDescription className="line-clamp-2 text-sm">
{notebook.description || 'No description'} {notebook.description || t.chat.noDescription}
</CardDescription> </CardDescription>
<div className="mt-3 text-xs text-muted-foreground"> <div className="mt-3 text-xs text-muted-foreground">
Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })} {t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), {
addSuffix: true,
locale: getDateLocale(language)
}))}
</div> </div>
{/* Item counts footer */} {/* Item counts footer */}
@ -130,9 +135,9 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
<ConfirmDialog <ConfirmDialog
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
title="Delete Notebook" title={t.notebooks.deleteNotebook}
description={`Are you sure you want to delete "${notebook.name}"? This action cannot be undone and will delete all sources, notes, and chat sessions.`} description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
confirmText="Delete" confirmText={t.common.delete}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDelete} onConfirm={handleDelete}
/> />

View file

@ -8,13 +8,17 @@ import { Archive, ArchiveRestore, Trash2 } from 'lucide-react'
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks' import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { getDateLocale } from '@/lib/utils/date-locale'
import { InlineEdit } from '@/components/common/InlineEdit' import { InlineEdit } from '@/components/common/InlineEdit'
import { useTranslation } from '@/lib/hooks/use-translation'
interface NotebookHeaderProps { interface NotebookHeaderProps {
notebook: NotebookResponse notebook: NotebookResponse
} }
export function NotebookHeader({ notebook }: NotebookHeaderProps) { export function NotebookHeader({ notebook }: NotebookHeaderProps) {
const { t, language } = useTranslation()
const dfLocale = getDateLocale(language)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const updateNotebook = useUpdateNotebook() const updateNotebook = useUpdateNotebook()
@ -57,14 +61,16 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
<InlineEdit <InlineEdit
id="notebook-name"
name="notebook-name"
value={notebook.name} value={notebook.name}
onSave={handleUpdateName} onSave={handleUpdateName}
className="text-2xl font-bold" className="text-2xl font-bold"
inputClassName="text-2xl font-bold" inputClassName="text-2xl font-bold"
placeholder="Notebook name" placeholder={t.notebooks.namePlaceholder}
/> />
{notebook.archived && ( {notebook.archived && (
<Badge variant="secondary">Archived</Badge> <Badge variant="secondary">{t.notebooks.archived}</Badge>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -76,12 +82,12 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
{notebook.archived ? ( {notebook.archived ? (
<> <>
<ArchiveRestore className="h-4 w-4 mr-2" /> <ArchiveRestore className="h-4 w-4 mr-2" />
Unarchive {t.notebooks.unarchive}
</> </>
) : ( ) : (
<> <>
<Archive className="h-4 w-4 mr-2" /> <Archive className="h-4 w-4 mr-2" />
Archive {t.notebooks.archive}
</> </>
)} )}
</Button> </Button>
@ -92,24 +98,26 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
className="text-red-600 hover:text-red-700" className="text-red-600 hover:text-red-700"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete {t.common.delete}
</Button> </Button>
</div> </div>
</div> </div>
<InlineEdit <InlineEdit
id="notebook-description"
name="notebook-description"
value={notebook.description || ''} value={notebook.description || ''}
onSave={handleUpdateDescription} onSave={handleUpdateDescription}
className="text-muted-foreground" className="text-muted-foreground"
inputClassName="text-muted-foreground" inputClassName="text-muted-foreground"
placeholder="Add a description..." placeholder={t.notebooks.addDescription}
multiline multiline
emptyText="Add a description..." emptyText={t.notebooks.addDescription}
/> />
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Created {formatDistanceToNow(new Date(notebook.created), { addSuffix: true })} {t.common.created.replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))}
Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })} {t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))}
</div> </div>
</div> </div>
</div> </div>
@ -117,9 +125,9 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
<ConfirmDialog <ConfirmDialog
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
title="Delete Notebook" title={t.notebooks.deleteNotebook}
description={`Are you sure you want to delete "${notebook.name}"? This action cannot be undone and will delete all sources, notes, and chat sessions.`} description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
confirmText="Delete Forever" confirmText={t.common.deleteForever}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDelete} onConfirm={handleDelete}
/> />

View file

@ -7,6 +7,7 @@ import { EmptyState } from '@/components/common/EmptyState'
import { Book, ChevronDown, ChevronRight, Plus } from 'lucide-react' import { Book, ChevronDown, ChevronRight, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface NotebookListProps { interface NotebookListProps {
notebooks?: NotebookResponse[] notebooks?: NotebookResponse[]
@ -29,6 +30,7 @@ export function NotebookList({
onAction, onAction,
actionLabel, actionLabel,
}: NotebookListProps) { }: NotebookListProps) {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(!collapsible) const [isExpanded, setIsExpanded] = useState(!collapsible)
if (isLoading) { if (isLoading) {
@ -43,8 +45,8 @@ export function NotebookList({
return ( return (
<EmptyState <EmptyState
icon={Book} icon={Book}
title={emptyTitle ?? `No ${title.toLowerCase()}`} title={emptyTitle ?? t.common.noResults}
description={emptyDescription ?? 'Start by creating your first notebook to organize your research.'} description={emptyDescription ?? t.chat.startByCreating}
action={onAction && actionLabel ? ( action={onAction && actionLabel ? (
<Button onClick={onAction} variant="outline" className="mt-4"> <Button onClick={onAction} variant="outline" className="mt-4">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />

View file

@ -15,6 +15,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { NoteEditorDialog } from './NoteEditorDialog' import { NoteEditorDialog } from './NoteEditorDialog'
import { getDateLocale } from '@/lib/utils/date-locale'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { ContextToggle } from '@/components/common/ContextToggle' import { ContextToggle } from '@/components/common/ContextToggle'
import { ContextMode } from '../[id]/page' import { ContextMode } from '../[id]/page'
@ -22,6 +23,7 @@ import { useDeleteNote } from '@/lib/hooks/use-notes'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn' import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store' import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
import { useTranslation } from '@/lib/hooks/use-translation'
interface NotesColumnProps { interface NotesColumnProps {
notes?: NoteResponse[] notes?: NoteResponse[]
@ -38,6 +40,7 @@ export function NotesColumn({
contextSelections, contextSelections,
onContextModeChange onContextModeChange
}: NotesColumnProps) { }: NotesColumnProps) {
const { t, language } = useTranslation()
const [showAddDialog, setShowAddDialog] = useState(false) const [showAddDialog, setShowAddDialog] = useState(false)
const [editingNote, setEditingNote] = useState<NoteResponse | null>(null) const [editingNote, setEditingNote] = useState<NoteResponse | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
@ -48,8 +51,8 @@ export function NotesColumn({
// Collapsible column state // Collapsible column state
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore() const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
const collapseButton = useMemo( const collapseButton = useMemo(
() => createCollapseButton(toggleNotes, 'Notes'), () => createCollapseButton(toggleNotes, t.common.notes),
[toggleNotes] [toggleNotes, t.common.notes]
) )
const handleDeleteClick = (noteId: string) => { const handleDeleteClick = (noteId: string) => {
@ -75,12 +78,12 @@ export function NotesColumn({
isCollapsed={notesCollapsed} isCollapsed={notesCollapsed}
onToggle={toggleNotes} onToggle={toggleNotes}
collapsedIcon={StickyNote} collapsedIcon={StickyNote}
collapsedLabel="Notes" collapsedLabel={t.common.notes}
> >
<Card className="h-full flex flex-col flex-1 overflow-hidden"> <Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0"> <CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">Notes</CardTitle> <CardTitle className="text-lg">{t.common.notes}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
@ -90,7 +93,7 @@ export function NotesColumn({
}} }}
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Write Note {t.common.writeNote}
</Button> </Button>
{collapseButton} {collapseButton}
</div> </div>
@ -105,8 +108,8 @@ export function NotesColumn({
) : !notes || notes.length === 0 ? ( ) : !notes || notes.length === 0 ? (
<EmptyState <EmptyState
icon={StickyNote} icon={StickyNote}
title="No notes yet" title={t.notebooks.noNotesYet}
description="Create your first note to capture insights and observations." description={t.sources.createFirstNote}
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -124,13 +127,16 @@ export function NotesColumn({
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
)} )}
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{note.note_type === 'ai' ? 'AI Generated' : 'Human'} {note.note_type === 'ai' ? t.common.aiGenerated : t.common.human}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })} {formatDistanceToNow(new Date(note.updated), {
addSuffix: true,
locale: getDateLocale(language)
})}
</span> </span>
{/* Context toggle - only show if handler provided */} {/* Context toggle - only show if handler provided */}
@ -165,7 +171,7 @@ export function NotesColumn({
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete Note {t.notebooks.deleteNote}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -206,9 +212,9 @@ export function NotesColumn({
<ConfirmDialog <ConfirmDialog
open={deleteDialogOpen} open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
title="Delete Note" title={t.notebooks.deleteNote}
description="Are you sure you want to delete this note? This action cannot be undone." description={t.notebooks.deleteNoteConfirm}
confirmText="Delete" confirmText={t.common.delete}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
isLoading={deleteNote.isPending} isLoading={deleteNote.isPending}
confirmVariant="destructive" confirmVariant="destructive"

View file

@ -22,6 +22,7 @@ import { useModalManager } from '@/lib/hooks/use-modal-manager'
import { ContextMode } from '../[id]/page' import { ContextMode } from '../[id]/page'
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn' import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store' import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
import { useTranslation } from '@/lib/hooks/use-translation'
interface SourcesColumnProps { interface SourcesColumnProps {
sources?: SourceListResponse[] sources?: SourceListResponse[]
@ -48,6 +49,7 @@ export function SourcesColumn({
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
}: SourcesColumnProps) { }: SourcesColumnProps) {
const { t } = useTranslation()
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false)
const [addDialogOpen, setAddDialogOpen] = useState(false) const [addDialogOpen, setAddDialogOpen] = useState(false)
const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false) const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false)
@ -64,8 +66,8 @@ export function SourcesColumn({
// Collapsible column state // Collapsible column state
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore() const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
const collapseButton = useMemo( const collapseButton = useMemo(
() => createCollapseButton(toggleSources, 'Sources'), () => createCollapseButton(toggleSources, t.navigation.sources),
[toggleSources] [toggleSources, t.navigation.sources]
) )
// Scroll container ref for infinite scroll // Scroll container ref for infinite scroll
@ -149,29 +151,29 @@ export function SourcesColumn({
isCollapsed={sourcesCollapsed} isCollapsed={sourcesCollapsed}
onToggle={toggleSources} onToggle={toggleSources}
collapsedIcon={FileText} collapsedIcon={FileText}
collapsedLabel="Sources" collapsedLabel={t.navigation.sources}
> >
<Card className="h-full flex flex-col flex-1 overflow-hidden"> <Card className="h-full flex flex-col flex-1 overflow-hidden">
<CardHeader className="pb-3 flex-shrink-0"> <CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-lg">Sources</CardTitle> <CardTitle className="text-lg">{t.navigation.sources}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Source {t.sources.addSource}
<ChevronDown className="h-4 w-4 ml-2" /> <ChevronDown className="h-4 w-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}> <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add New Source {t.sources.addSource}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}> <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
<Link2 className="h-4 w-4 mr-2" /> <Link2 className="h-4 w-4 mr-2" />
Add Existing Source {t.sources.addExistingTitle}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -188,8 +190,8 @@ export function SourcesColumn({
) : !sources || sources.length === 0 ? ( ) : !sources || sources.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title="No sources yet" title={t.sources.noSourcesYet}
description="Add your first source to start building your knowledge base." description={t.sources.createFirstSource}
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -238,9 +240,9 @@ export function SourcesColumn({
<ConfirmDialog <ConfirmDialog
open={deleteDialogOpen} open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
title="Delete Source" title={t.sources.delete}
description="Are you sure you want to delete this source? This action cannot be undone." description={t.sources.deleteConfirm}
confirmText="Delete" confirmText={t.common.delete}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
isLoading={deleteSource.isPending} isLoading={deleteSource.isPending}
confirmVariant="destructive" confirmVariant="destructive"
@ -249,9 +251,9 @@ export function SourcesColumn({
<ConfirmDialog <ConfirmDialog
open={removeDialogOpen} open={removeDialogOpen}
onOpenChange={setRemoveDialogOpen} onOpenChange={setRemoveDialogOpen}
title="Remove Source from Notebook" title={t.sources.removeFromNotebook}
description="Are you sure you want to remove this source from the notebook? The source itself will not be deleted." description={t.sources.removeConfirm}
confirmText="Remove" confirmText={t.common.remove}
onConfirm={handleRemoveConfirm} onConfirm={handleRemoveConfirm}
isLoading={removeFromNotebook.isPending} isLoading={removeFromNotebook.isPending}
confirmVariant="default" confirmVariant="default"

View file

@ -9,8 +9,10 @@ import { Plus, RefreshCw } from 'lucide-react'
import { useNotebooks } from '@/lib/hooks/use-notebooks' import { useNotebooks } from '@/lib/hooks/use-notebooks'
import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog' import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function NotebooksPage() { export default function NotebooksPage() {
const { t } = useTranslation()
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const { data: notebooks, isLoading, refetch } = useNotebooks(false) const { data: notebooks, isLoading, refetch } = useNotebooks(false)
@ -51,21 +53,25 @@ export default function NotebooksPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Notebooks</h1> <h1 className="text-2xl font-bold">{t.notebooks.title}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<Input <Input
id="notebook-search"
name="notebook-search"
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search notebooks..." placeholder={t.notebooks.searchPlaceholder}
autoComplete="off"
aria-label={t.common.accessibility?.searchNotebooks || "Search notebooks"}
className="w-full sm:w-64" className="w-full sm:w-64"
/> />
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
New Notebook {t.notebooks.newNotebook}
</Button> </Button>
</div> </div>
</div> </div>
@ -74,21 +80,21 @@ export default function NotebooksPage() {
<NotebookList <NotebookList
notebooks={filteredActive} notebooks={filteredActive}
isLoading={isLoading} isLoading={isLoading}
title="Active Notebooks" title={t.notebooks.activeNotebooks}
emptyTitle={isSearching ? 'No notebooks match your search' : undefined} emptyTitle={isSearching ? t.common.noMatches : undefined}
emptyDescription={isSearching ? 'Try using a different notebook name.' : undefined} emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}
onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined} onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined}
actionLabel={!isSearching ? "Create Notebook" : undefined} actionLabel={!isSearching ? t.notebooks.newNotebook : undefined}
/> />
{hasArchived && ( {hasArchived && (
<NotebookList <NotebookList
notebooks={filteredArchived} notebooks={filteredArchived}
isLoading={false} isLoading={false}
title="Archived Notebooks" title={t.notebooks.archivedNotebooks}
collapsible collapsible
emptyTitle={isSearching ? 'No archived notebooks match your search' : undefined} emptyTitle={isSearching ? t.common.noMatches : undefined}
emptyDescription={isSearching ? 'Modify your search to find archived notebooks.' : undefined} emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}
/> />
)} )}
</div> </div>

View file

@ -7,8 +7,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { EpisodesTab } from '@/components/podcasts/EpisodesTab' import { EpisodesTab } from '@/components/podcasts/EpisodesTab'
import { TemplatesTab } from '@/components/podcasts/TemplatesTab' import { TemplatesTab } from '@/components/podcasts/TemplatesTab'
import { Mic, LayoutTemplate } from 'lucide-react' import { Mic, LayoutTemplate } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function PodcastsPage() { export default function PodcastsPage() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes') const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')
return ( return (
@ -16,9 +18,9 @@ export default function PodcastsPage() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="px-6 py-6 space-y-6"> <div className="px-6 py-6 space-y-6">
<header className="space-y-1"> <header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Podcasts</h1> <h1 className="text-2xl font-semibold tracking-tight">{t.podcasts.listTitle}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Keep track of generated episodes and manage reusable templates. {t.podcasts.listDesc}
</p> </p>
</header> </header>
@ -28,15 +30,15 @@ export default function PodcastsPage() {
className="space-y-6" className="space-y-6"
> >
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a view</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.podcasts.chooseAView}</p>
<TabsList aria-label="Podcast views" className="w-full max-w-md"> <TabsList aria-label={t.common.accessibility.podcastViews} className="w-full max-w-md">
<TabsTrigger value="episodes"> <TabsTrigger value="episodes">
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
Episodes {t.podcasts.episodesTab}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="templates"> <TabsTrigger value="templates">
<LayoutTemplate className="h-4 w-4" /> <LayoutTemplate className="h-4 w-4" />
Templates {t.podcasts.templatesTab}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useTranslation } from '@/lib/hooks/use-translation'
import { AppShell } from '@/components/layout/AppShell' import { AppShell } from '@/components/layout/AppShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@ -24,10 +25,11 @@ import { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog' import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
export default function SearchPage() { export default function SearchPage() {
const { t } = useTranslation()
// URL params // URL params
const searchParams = useSearchParams() const searchParams = useSearchParams()
const urlQuery = searchParams.get('q') || '' const urlQuery = searchParams?.get('q') || ''
const rawMode = searchParams.get('mode') const rawMode = searchParams?.get('mode')
const urlMode = rawMode === 'search' ? 'search' : 'ask' const urlMode = rawMode === 'search' ? 'search' : 'ask'
// Tab state (controlled) // Tab state (controlled)
@ -70,7 +72,7 @@ export default function SearchPage() {
}, [availableModels]) }, [availableModels])
const resolveModelName = (id?: string | null) => { const resolveModelName = (id?: string | null) => {
if (!id) return 'Not set' if (!id) return t.searchPage.notSet
return modelNameById.get(id) ?? id return modelNameById.get(id) ?? id
} }
@ -130,8 +132,8 @@ export default function SearchPage() {
// Handle URL param changes while on page (e.g., from command palette again) // Handle URL param changes while on page (e.g., from command palette again)
useEffect(() => { useEffect(() => {
const currentQ = searchParams.get('q') || '' const currentQ = searchParams?.get('q') || ''
const rawCurrentMode = searchParams.get('mode') const rawCurrentMode = searchParams?.get('mode')
const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask' const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask'
// Check if URL params have changed // Check if URL params have changed
@ -157,19 +159,19 @@ export default function SearchPage() {
return ( return (
<AppShell> <AppShell>
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">Ask and Search</h1> <h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">{t.searchPage.askAndSearch}</h1>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6"> <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a mode</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.searchPage.chooseAMode}</p>
<TabsList aria-label="Ask or search your knowledge base" className="w-full max-w-xl"> <TabsList aria-label={t.common.accessibility.searchKB} className="w-full max-w-xl">
<TabsTrigger value="ask"> <TabsTrigger value="ask">
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
Ask (beta) {t.searchPage.askBeta}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="search"> <TabsTrigger value="search">
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
Search {t.searchPage.search}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@ -177,18 +179,19 @@ export default function SearchPage() {
<TabsContent value="ask" className="mt-6"> <TabsContent value="ask" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Ask Your Knowledge Base (beta)</CardTitle> <CardTitle className="text-lg">{t.searchPage.askYourKb}</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The LLM will answer your query based on the documents in your knowledge base. {t.searchPage.askYourKbDesc}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Question Input */} {/* Question Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ask-question">Question</Label> <Label htmlFor="ask-question">{t.searchPage.question}</Label>
<Textarea <Textarea
id="ask-question" id="ask-question"
placeholder="Enter your question..." name="ask-question"
placeholder={t.searchPage.enterQuestionPlaceholder}
value={askQuestion} value={askQuestion}
onChange={(e) => setAskQuestion(e.target.value)} onChange={(e) => setAskQuestion(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -200,23 +203,23 @@ export default function SearchPage() {
}} }}
disabled={ask.isStreaming} disabled={ask.isStreaming}
rows={3} rows={3}
aria-label="Enter your question to ask the knowledge base" aria-label={t.common.accessibility.enterQuestion}
/> />
<p className="text-xs text-muted-foreground">Press Cmd/Ctrl+Enter to submit</p> <p className="text-xs text-muted-foreground">{t.searchPage.pressToSubmit}</p>
</div> </div>
{/* Models Display */} {/* Models Display */}
{!hasEmbeddingModel ? ( {!hasEmbeddingModel ? (
<div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md"> <div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span>You can&apos;t use this feature because you have no embedding model selected. Please set one up in the Models page.</span> <span>{t.searchPage.noEmbeddingModel}</span>
</div> </div>
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground"> <Label className="text-xs text-muted-foreground">
{customModels ? 'Using Custom Models' : 'Using Default Models'} {customModels ? t.searchPage.usingCustomModels : t.searchPage.usingDefaultModels}
</Label> </Label>
<Button <Button
variant="ghost" variant="ghost"
@ -226,18 +229,18 @@ export default function SearchPage() {
className="h-auto py-1 px-2" className="h-auto py-1 px-2"
> >
<Settings className="h-3 w-3 mr-1" /> <Settings className="h-3 w-3 mr-1" />
Advanced {t.searchPage.advanced}
</Button> </Button>
</div> </div>
<div className="flex gap-2 text-xs flex-wrap"> <div className="flex gap-2 text-xs flex-wrap">
<Badge variant="secondary"> <Badge variant="secondary">
Strategy: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)} {t.searchPage.strategy}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
</Badge> </Badge>
<Badge variant="secondary"> <Badge variant="secondary">
Answer: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)} {t.searchPage.answer}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
</Badge> </Badge>
<Badge variant="secondary"> <Badge variant="secondary">
Final: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)} {t.searchPage.final}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -251,10 +254,10 @@ export default function SearchPage() {
{ask.isStreaming ? ( {ask.isStreaming ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
Processing... {t.searchPage.processing}
</> </>
) : ( ) : (
'Ask' t.searchPage.ask
)} )}
</Button> </Button>
@ -265,7 +268,7 @@ export default function SearchPage() {
className="w-full" className="w-full"
> >
<Save className="h-4 w-4 mr-2" /> <Save className="h-4 w-4 mr-2" />
Save to Notebooks {t.searchPage.saveToNotebooks}
</Button> </Button>
)} )}
</div> </div>
@ -308,29 +311,34 @@ export default function SearchPage() {
<TabsContent value="search" className="mt-6"> <TabsContent value="search" className="mt-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Search</CardTitle> <CardTitle className="text-lg">{t.searchPage.search}</CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Search your knowledge base for specific keywords or concepts {t.searchPage.searchDesc}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Search Input */} {/* Search Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-query" className="sr-only">
{t.searchPage.search}
</Label>
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<Input <Input
id="search-query" id="search-query"
placeholder="Enter search query..." name="search-query"
placeholder={t.searchPage.enterSearchPlaceholder}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
className="flex-1" className="flex-1"
aria-label="Enter search query" aria-label={t.common.accessibility.enterSearch}
autoComplete="off"
/> />
<Button <Button
onClick={handleSearch} onClick={handleSearch}
disabled={searchMutation.isPending || !searchQuery.trim()} disabled={searchMutation.isPending || !searchQuery.trim()}
aria-label="Search knowledge base" aria-label={t.common.accessibility.searchKBBtn}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{searchMutation.isPending ? ( {searchMutation.isPending ? (
@ -338,24 +346,25 @@ export default function SearchPage() {
) : ( ) : (
<Search className="h-4 w-4 mr-2" /> <Search className="h-4 w-4 mr-2" />
)} )}
Search {t.searchPage.search}
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Press Enter to search</p> <p className="text-xs text-muted-foreground">{t.searchPage.pressToSearch}</p>
</div> </div>
{/* Search Options */} {/* Search Options */}
<div className="space-y-4"> <div className="space-y-4">
{/* Search Type */} {/* Search Type */}
<div className="space-y-2"> <div className="space-y-2" role="group" aria-labelledby="search-type-label">
<Label>Search Type</Label> <span id="search-type-label" className="text-sm font-medium leading-none">{t.searchPage.searchType}</span>
{!hasEmbeddingModel && ( {!hasEmbeddingModel && (
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500"> <div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<span>Vector search requires an embedding model. Only text search is available.</span> <span>{t.searchPage.vectorSearchWarning}</span>
</div> </div>
)} )}
<RadioGroup <RadioGroup
name="search-type"
value={searchType} value={searchType}
onValueChange={(value: 'text' | 'vector') => setSearchType(value)} onValueChange={(value: 'text' | 'vector') => setSearchType(value)}
disabled={modelsLoading || searchMutation.isPending} disabled={modelsLoading || searchMutation.isPending}
@ -363,7 +372,7 @@ export default function SearchPage() {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" /> <RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="font-normal cursor-pointer"> <Label htmlFor="text" className="font-normal cursor-pointer">
Text Search {t.searchPage.textSearch}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -376,36 +385,38 @@ export default function SearchPage() {
htmlFor="vector" htmlFor="vector"
className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`} className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}
> >
Vector Search {t.searchPage.vectorSearch}
</Label> </Label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </div>
{/* Search Locations */} {/* Search Locations */}
<div className="space-y-2"> <div className="space-y-2" role="group" aria-labelledby="search-in-label">
<Label>Search In</Label> <span id="search-in-label" className="text-sm font-medium leading-none">{t.searchPage.searchIn}</span>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="sources" id="sources"
name="sources"
checked={searchSources} checked={searchSources}
onCheckedChange={(checked) => setSearchSources(checked as boolean)} onCheckedChange={(checked) => setSearchSources(checked as boolean)}
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
/> />
<Label htmlFor="sources" className="font-normal cursor-pointer"> <Label htmlFor="sources" className="font-normal cursor-pointer">
Search Sources {t.searchPage.searchSources}
</Label> </Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="notes" id="notes"
name="notes"
checked={searchNotes} checked={searchNotes}
onCheckedChange={(checked) => setSearchNotes(checked as boolean)} onCheckedChange={(checked) => setSearchNotes(checked as boolean)}
disabled={searchMutation.isPending} disabled={searchMutation.isPending}
/> />
<Label htmlFor="notes" className="font-normal cursor-pointer"> <Label htmlFor="notes" className="font-normal cursor-pointer">
Search Notes {t.searchPage.searchNotes}
</Label> </Label>
</div> </div>
</div> </div>
@ -417,15 +428,15 @@ export default function SearchPage() {
<div className="mt-6 space-y-3"> <div className="mt-6 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> <h3 className="text-sm font-medium">
{searchMutation.data.total_count} result{searchMutation.data.total_count !== 1 ? 's' : ''} found {t.searchPage.resultsFound.replace('{count}', searchMutation.data.total_count.toString())}
</h3> </h3>
<Badge variant="outline">{searchMutation.data.search_type} search</Badge> <Badge variant="outline">{searchMutation.data.search_type === 'text' ? t.searchPage.textSearch : t.searchPage.vectorSearch}</Badge>
</div> </div>
{searchMutation.data.results.length === 0 ? ( {searchMutation.data.results.length === 0 ? (
<Card> <Card>
<CardContent className="pt-6 text-center text-muted-foreground"> <CardContent className="pt-6 text-center text-muted-foreground">
No results found for &ldquo;{searchQuery}&rdquo; {t.searchPage.noResultsFor.replace('{query}', searchQuery)}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -456,7 +467,7 @@ export default function SearchPage() {
<Collapsible className="mt-3"> <Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
Matches ({result.matches.length}) {t.searchPage.matches.replace('{count}', result.matches.length.toString())}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-1"> <CollapsibleContent className="mt-2 space-y-1">
{result.matches.map((match, i) => ( {result.matches.map((match, i) => (

View file

@ -13,6 +13,7 @@ import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { useSettings, useUpdateSettings } from '@/lib/hooks/use-settings' import { useSettings, useUpdateSettings } from '@/lib/hooks/use-settings'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ChevronDownIcon } from 'lucide-react' import { ChevronDownIcon } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
const settingsSchema = z.object({ const settingsSchema = z.object({
default_content_processing_engine_doc: z.enum(['auto', 'docling', 'simple']).optional(), default_content_processing_engine_doc: z.enum(['auto', 'docling', 'simple']).optional(),
@ -24,9 +25,15 @@ const settingsSchema = z.object({
type SettingsFormData = z.infer<typeof settingsSchema> type SettingsFormData = z.infer<typeof settingsSchema>
export function SettingsForm() { export function SettingsForm() {
const { t } = useTranslation()
const { data: settings, isLoading, error } = useSettings() const { data: settings, isLoading, error } = useSettings()
const updateSettings = useUpdateSettings() const updateSettings = useUpdateSettings()
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({}) const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
doc: false,
url: false,
embedding: false,
files: false
})
const [hasResetForm, setHasResetForm] = useState(false) const [hasResetForm, setHasResetForm] = useState(false)
@ -78,9 +85,9 @@ export function SettingsForm() {
if (error) { if (error) {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Failed to load settings</AlertTitle> <AlertTitle>{t.settings.loadFailed}</AlertTitle>
<AlertDescription> <AlertDescription>
{error instanceof Error ? error.message : 'An unexpected error occurred.'} {error instanceof Error ? error.message : t.common.error}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) )
@ -90,31 +97,32 @@ export function SettingsForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Content Processing</CardTitle> <CardTitle>{t.settings.contentProcessing}</CardTitle>
<CardDescription> <CardDescription>
Configure how documents and URLs are processed {t.settings.contentProcessingDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="doc_engine">Document Processing Engine</Label> <Label htmlFor="doc_engine">{t.settings.docEngine}</Label>
<Controller <Controller
name="default_content_processing_engine_doc" name="default_content_processing_engine_doc"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
key={field.value} key={field.value}
value={field.value || ''} name={field.name}
onValueChange={field.onChange} value={field.value || ''}
disabled={field.disabled || isLoading} onValueChange={field.onChange}
> disabled={field.disabled || isLoading}
<SelectTrigger className="w-full"> >
<SelectValue placeholder="Select document processing engine" /> <SelectTrigger id="doc_engine" className="w-full">
</SelectTrigger> <SelectValue placeholder={t.settings.docEnginePlaceholder} />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto (Recommended)</SelectItem> <SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
<SelectItem value="docling">Docling</SelectItem> <SelectItem value="docling">{t.settings.docling}</SelectItem>
<SelectItem value="simple">Simple</SelectItem> <SelectItem value="simple">{t.settings.simple}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -122,143 +130,135 @@ export function SettingsForm() {
<Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}> <Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} />
Help me choose {t.settings.helpMeChoose}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p> <strong>Docling</strong> is a little slower but more accurate, specially if the documents contain tables and images.</p> <p>{t.settings.docHelp}</p>
<p> <strong>Simple</strong> will extract any content from the document without formatting it. It&apos;s ok for simple documents, but will lose quality in complex ones.</p>
<p> <strong>Auto (recommended)</strong> will try to process through docling and default to simple.</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="url_engine">URL Processing Engine</Label> <Label htmlFor="url_engine">{t.settings.urlEngine}</Label>
<Controller <Controller
name="default_content_processing_engine_url" name="default_content_processing_engine_url"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
key={field.value} key={field.value}
name={field.name}
value={field.value || ''} value={field.value || ''}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="url_engine" className="w-full">
<SelectValue placeholder="Select URL processing engine" /> <SelectValue placeholder={t.settings.urlEnginePlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="auto">Auto (Recommended)</SelectItem> <SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
<SelectItem value="firecrawl">Firecrawl</SelectItem> <SelectItem value="firecrawl">{t.settings.firecrawl}</SelectItem>
<SelectItem value="jina">Jina</SelectItem> <SelectItem value="jina">{t.settings.jina}</SelectItem>
<SelectItem value="simple">Simple</SelectItem> <SelectItem value="simple">{t.settings.simple}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
<Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}> <Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />
Help me choose {t.settings.helpMeChoose}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p> <strong>Firecrawl</strong> is a paid service (with a free tier), and very powerful.</p> <p>{t.settings.urlHelp}</p>
<p> <strong>Jina</strong> is a good option as well and also has a free tier.</p>
<p> <strong>Simple</strong> will use basic HTTP extraction and will miss content on javascript-based websites.</p>
<p> <strong>Auto (recommended)</strong> will try to use firecrawl (if API Key is present). Then, it will use Jina until reaches the limit (or will keep using Jina if you setup the API Key). It will fallback to simple, when none of the previous options is possible.</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Embedding and Search</CardTitle> <CardTitle>{t.settings.embeddingAndSearch}</CardTitle>
<CardDescription> <CardDescription>
Configure search and embedding options {t.settings.embeddingAndSearchDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="embedding">Default Embedding Option</Label> <Label htmlFor="embedding">{t.settings.defaultEmbeddingOption}</Label>
<Controller <Controller
name="default_embedding_option" name="default_embedding_option"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
key={field.value} key={field.value}
name={field.name}
value={field.value || ''} value={field.value || ''}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="embedding" className="w-full">
<SelectValue placeholder="Select embedding option" /> <SelectValue placeholder={t.settings.embeddingOptionPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ask">Ask</SelectItem> <SelectItem value="ask">{t.settings.ask}</SelectItem>
<SelectItem value="always">Always</SelectItem> <SelectItem value="always">{t.settings.always}</SelectItem>
<SelectItem value="never">Never</SelectItem> <SelectItem value="never">{t.settings.never}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
<Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}> <Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />
Help me choose {t.settings.helpMeChoose}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>Embedding the content will make it easier to find by you and by your AI agents. If you are running a local embedding model (Ollama, for example), you shouldn&apos;t worry about cost and just embed everything. For online providers, you might want to be careful only if you process a lot of content (like 100s of documents at a day).</p> <p>{t.settings.embeddingHelp}</p>
<p> Choose <strong>always</strong> if you are running a local embedding model or if your content volume is not that big</p>
<p> Choose <strong>ask</strong> if you want to decide every time</p>
<p> Choose <strong>never</strong> if you don&apos;t care about vector search or do not have an embedding provider.</p>
<p>As a reference, OpenAI&apos;s text-embedding-3-small costs about 0.02 for 1 million tokens -- which is about 30 times the Wikipedia page for Earth. With Gemini API, Text Embedding 004 is free with a rate limit of 1500 requests per minute.</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>File Management</CardTitle> <CardTitle>{t.settings.fileManagement}</CardTitle>
<CardDescription> <CardDescription>
Configure file handling and storage options {t.settings.fileManagementDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="auto_delete">Auto Delete Files</Label> <Label htmlFor="auto_delete">{t.settings.autoDeleteFiles}</Label>
<Controller <Controller
name="auto_delete_files" name="auto_delete_files"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<Select <Select
key={field.value} key={field.value}
name={field.name}
value={field.value || ''} value={field.value || ''}
onValueChange={field.onChange} onValueChange={field.onChange}
disabled={field.disabled || isLoading} disabled={field.disabled || isLoading}
> >
<SelectTrigger className="w-full"> <SelectTrigger id="auto_delete" className="w-full">
<SelectValue placeholder="Select auto delete option" /> <SelectValue placeholder={t.settings.autoDeletePlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="yes">Yes</SelectItem> <SelectItem value="yes">{t.common.yes}</SelectItem>
<SelectItem value="no">No</SelectItem> <SelectItem value="no">{t.common.no}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
<Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}> <Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"> <CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} /> <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />
Help me choose {t.settings.helpMeChoose}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2"> <CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
<p>Once your files are uploaded and processed, they are not required anymore. Most users should allow Open Notebook to delete uploaded files from the upload folder automatically. Choose <strong>no</strong>, ONLY if you are using Notebook as the primary storage location for those files (which you shouldn&apos;t be at all). This option will soon be deprecated in favor of always downloading the files.</p> <p>{t.settings.filesHelp}</p>
<p> Choose <strong>yes</strong> (recommended) to automatically delete uploaded files after processing</p>
<p> Choose <strong>no</strong> only if you need to keep the original files in the upload folder</p>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </div>
@ -266,11 +266,11 @@ export function SettingsForm() {
</Card> </Card>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
disabled={!isDirty || updateSettings.isPending} disabled={!isDirty || updateSettings.isPending}
> >
{updateSettings.isPending ? 'Saving...' : 'Save Settings'} {updateSettings.isPending ? t.common.saving : t.navigation.settings}
</Button> </Button>
</div> </div>
</form> </form>

View file

@ -5,8 +5,10 @@ import { SettingsForm } from './components/SettingsForm'
import { useSettings } from '@/lib/hooks/use-settings' import { useSettings } from '@/lib/hooks/use-settings'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function SettingsPage() { export default function SettingsPage() {
const { t } = useTranslation()
const { refetch } = useSettings() const { refetch } = useSettings()
return ( return (
@ -15,7 +17,7 @@ export default function SettingsPage() {
<div className="p-6"> <div className="p-6">
<div className="max-w-4xl"> <div className="max-w-4xl">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">{t.navigation.settings}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>

View file

@ -12,7 +12,7 @@ import { SourceDetailContent } from '@/components/source/SourceDetailContent'
export default function SourceDetailPage() { export default function SourceDetailPage() {
const router = useRouter() const router = useRouter()
const params = useParams() const params = useParams()
const sourceId = decodeURIComponent(params.id as string) const sourceId = params?.id ? decodeURIComponent(params.id as string) : ''
const navigation = useNavigation() const navigation = useNavigation()
// Initialize source chat // Initialize source chat

View file

@ -12,10 +12,14 @@ import { FileText, Link as LinkIcon, Upload, AlignLeft, Trash2, ArrowUpDown } fr
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getDateLocale } from '@/lib/utils/date-locale'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getApiErrorKey } from '@/lib/utils/error-handler'
export default function SourcesPage() { export default function SourcesPage() {
const { t, language } = useTranslation()
const [sources, setSources] = useState<SourceListResponse[]>([]) const [sources, setSources] = useState<SourceListResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
@ -71,14 +75,14 @@ export default function SourcesPage() {
offsetRef.current += data.length offsetRef.current += data.length
} catch (err) { } catch (err) {
console.error('Failed to fetch sources:', err) console.error('Failed to fetch sources:', err)
setError('Failed to load sources') setError(t.sources.failedToLoad)
toast.error('Failed to load sources') toast.error(t.sources.failedToLoad)
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingMore(false) setLoadingMore(false)
loadingMoreRef.current = false loadingMoreRef.current = false
} }
}, [sortBy, sortOrder]) }, [sortBy, sortOrder, t.sources.failedToLoad])
// Initial load and when sort changes // Initial load and when sort changes
useEffect(() => { useEffect(() => {
@ -216,9 +220,9 @@ export default function SourcesPage() {
} }
const getSourceType = (source: SourceListResponse) => { const getSourceType = (source: SourceListResponse) => {
if (source.asset?.url) return 'Link' if (source.asset?.url) return t.sources.type.link
if (source.asset?.file_path) return 'File' if (source.asset?.file_path) return t.sources.type.file
return 'Text' return t.sources.type.text
} }
const handleRowClick = useCallback((index: number, sourceId: string) => { const handleRowClick = useCallback((index: number, sourceId: string) => {
@ -236,13 +240,14 @@ export default function SourcesPage() {
try { try {
await sourcesApi.delete(deleteDialog.source.id) await sourcesApi.delete(deleteDialog.source.id)
toast.success('Source deleted successfully') toast.success(t.sources.deleteSuccess)
// Remove the deleted source from the list // Remove the deleted source from the list
setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id)) setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))
setDeleteDialog({ open: false, source: null }) setDeleteDialog({ open: false, source: null })
} catch (err) { } catch (err: unknown) {
console.error('Failed to delete source:', err) const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error('Failed to delete source') console.error('Failed to delete source:', error)
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message)))
} }
} }
@ -271,8 +276,8 @@ export default function SourcesPage() {
<AppShell> <AppShell>
<EmptyState <EmptyState
icon={FileText} icon={FileText}
title="No sources yet" title={t.sources.noSourcesYet}
description="Sources from all notebooks will appear here" description={t.sources.allSourcesDescShort}
/> />
</AppShell> </AppShell>
) )
@ -282,9 +287,9 @@ export default function SourcesPage() {
<AppShell> <AppShell>
<div className="flex flex-col h-full w-full max-w-none px-6 py-6"> <div className="flex flex-col h-full w-full max-w-none px-6 py-6">
<div className="mb-6 flex-shrink-0"> <div className="mb-6 flex-shrink-0">
<h1 className="text-3xl font-bold">All Sources</h1> <h1 className="text-3xl font-bold">{t.sources.allSources}</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Browse all sources across your notebooks. Use arrow keys to navigate and Enter to open. {t.sources.allSourcesDesc}
</p> </p>
</div> </div>
@ -305,10 +310,10 @@ export default function SourcesPage() {
<thead className="sticky top-0 bg-background z-10"> <thead className="sticky top-0 bg-background z-10">
<tr className="border-b bg-muted/50"> <tr className="border-b bg-muted/50">
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Type {t.common.type}
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title {t.common.title}
</th> </th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell"> <th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell">
<Button <Button
@ -317,7 +322,7 @@ export default function SourcesPage() {
onClick={() => toggleSort('created')} onClick={() => toggleSort('created')}
className="h-8 px-2 hover:bg-muted" className="h-8 px-2 hover:bg-muted"
> >
Created {t.common.created_label}
<ArrowUpDown className={cn( <ArrowUpDown className={cn(
"ml-2 h-3 w-3", "ml-2 h-3 w-3",
sortBy === 'created' ? 'opacity-100' : 'opacity-30' sortBy === 'created' ? 'opacity-100' : 'opacity-30'
@ -330,13 +335,13 @@ export default function SourcesPage() {
</Button> </Button>
</th> </th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell">
Insights {t.sources.insights}
</th> </th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell"> <th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell">
Embedded {t.sources.embedded}
</th> </th>
<th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground"> <th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground">
Actions {t.common.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@ -364,7 +369,7 @@ export default function SourcesPage() {
<td className="h-12 px-4"> <td className="h-12 px-4">
<div className="flex flex-col overflow-hidden"> <div className="flex flex-col overflow-hidden">
<span className="font-medium truncate"> <span className="font-medium truncate">
{source.title || 'Untitled Source'} {source.title || t.sources.untitledSource}
</span> </span>
{source.asset?.url && ( {source.asset?.url && (
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
@ -374,14 +379,17 @@ export default function SourcesPage() {
</div> </div>
</td> </td>
<td className="h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell"> <td className="h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell">
{formatDistanceToNow(new Date(source.created), { addSuffix: true })} {formatDistanceToNow(new Date(source.created), {
addSuffix: true,
locale: getDateLocale(language)
})}
</td> </td>
<td className="h-12 px-4 text-center hidden md:table-cell"> <td className="h-12 px-4 text-center hidden md:table-cell">
<span className="text-sm font-medium">{source.insights_count || 0}</span> <span className="text-sm font-medium">{source.insights_count || 0}</span>
</td> </td>
<td className="h-12 px-4 text-center hidden lg:table-cell"> <td className="h-12 px-4 text-center hidden lg:table-cell">
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs"> <Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
{source.embedded ? "Yes" : "No"} {source.embedded ? t.sources.yes : t.sources.no}
</Badge> </Badge>
</td> </td>
<td className="h-12 px-4 text-right"> <td className="h-12 px-4 text-right">
@ -401,7 +409,7 @@ export default function SourcesPage() {
<td colSpan={6} className="h-16 text-center"> <td colSpan={6} className="h-16 text-center">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
<span className="ml-2 text-muted-foreground">Loading more sources...</span> <span className="ml-2 text-muted-foreground">{t.sources.loadingMore}</span>
</div> </div>
</td> </td>
</tr> </tr>
@ -414,9 +422,9 @@ export default function SourcesPage() {
<ConfirmDialog <ConfirmDialog
open={deleteDialog.open} open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })} onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
title="Delete Source" title={t.sources.delete}
description={`Are you sure you want to delete "${deleteDialog.source?.title || 'this source'}"? This action cannot be undone.`} description={t.sources.deleteConfirmWithTitle.replace('{title}', deleteDialog.source?.title || t.sources.untitledSource)}
confirmText="Delete" confirmText={t.common.delete}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
/> />

View file

@ -1,18 +1,22 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useId } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronRight, Settings } from 'lucide-react' import { ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { useDefaultPrompt, useUpdateDefaultPrompt } from '@/lib/hooks/use-transformations' import { useDefaultPrompt, useUpdateDefaultPrompt } from '@/lib/hooks/use-transformations'
import { useTranslation } from '@/lib/hooks/use-translation'
export function DefaultPromptEditor() { export function DefaultPromptEditor() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
const { data: defaultPrompt, isLoading } = useDefaultPrompt() const { data: defaultPrompt, isLoading } = useDefaultPrompt()
const updateDefaultPrompt = useUpdateDefaultPrompt() const updateDefaultPrompt = useUpdateDefaultPrompt()
const { t } = useTranslation()
const textareaId = useId()
useEffect(() => { useEffect(() => {
if (defaultPrompt) { if (defaultPrompt) {
@ -33,9 +37,9 @@ export function DefaultPromptEditor() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5" />
<div className="text-left"> <div className="text-left">
<CardTitle className="text-lg">Default Transformation Prompt</CardTitle> <CardTitle className="text-lg">{t.transformations.defaultPrompt}</CardTitle>
<CardDescription> <CardDescription>
This will be added to all your transformation prompts {t.transformations.defaultPromptDesc}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@ -49,19 +53,26 @@ export function DefaultPromptEditor() {
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Textarea <div className="space-y-2">
value={prompt} <Label htmlFor={textareaId} className="sr-only">
onChange={(e) => setPrompt(e.target.value)} {t.transformations.defaultPrompt}
placeholder="Enter your default transformation instructions..." </Label>
className="min-h-[200px] font-mono text-sm" <Textarea
disabled={isLoading} id={textareaId}
/> name="default-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t.transformations.defaultPromptPlaceholder}
className="min-h-[200px] font-mono text-sm"
disabled={isLoading}
/>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={isLoading || updateDefaultPrompt.isPending} disabled={isLoading || updateDefaultPrompt.isPending}
> >
Save {t.common.save}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View file

@ -9,6 +9,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
import { ChevronDown, ChevronRight, Trash2, Wand2, Edit } from 'lucide-react' import { ChevronDown, ChevronRight, Trash2, Wand2, Edit } from 'lucide-react'
import { Transformation } from '@/lib/types/transformations' import { Transformation } from '@/lib/types/transformations'
import { useDeleteTransformation } from '@/lib/hooks/use-transformations' import { useDeleteTransformation } from '@/lib/hooks/use-transformations'
import { useTranslation } from '@/lib/hooks/use-translation'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface TransformationCardProps { interface TransformationCardProps {
@ -18,6 +19,7 @@ interface TransformationCardProps {
} }
export function TransformationCard({ transformation, onPlayground, onEdit }: TransformationCardProps) { export function TransformationCard({ transformation, onPlayground, onEdit }: TransformationCardProps) {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const deleteTransformation = useDeleteTransformation() const deleteTransformation = useDeleteTransformation()
@ -47,7 +49,7 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
)} )}
</div> </div>
{transformation.apply_default && ( {transformation.apply_default && (
<Badge variant="secondary">default</Badge> <Badge variant="secondary">{t.common.default}</Badge>
)} )}
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
@ -56,13 +58,13 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
{onPlayground && ( {onPlayground && (
<Button variant="outline" size="sm" onClick={onPlayground}> <Button variant="outline" size="sm" onClick={onPlayground}>
<Wand2 className="h-4 w-4 mr-2" /> <Wand2 className="h-4 w-4 mr-2" />
Playground {t.transformations.playground}
</Button> </Button>
)} )}
{onEdit && ( {onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}> <Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
Edit {t.common.edit}
</Button> </Button>
)} )}
<Button <Button
@ -80,19 +82,19 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<p className="text-sm text-muted-foreground">Title</p> <p className="text-sm text-muted-foreground">{t.common.title}</p>
<p className="text-sm font-medium">{transformation.title || 'Untitled'}</p> <p className="text-sm font-medium">{transformation.title || t.sources.untitledSource}</p>
</div> </div>
{transformation.description && ( {transformation.description && (
<div> <div>
<p className="text-sm text-muted-foreground">Description</p> <p className="text-sm text-muted-foreground">{t.common.description}</p>
<p className="text-sm leading-6">{transformation.description}</p> <p className="text-sm leading-6">{transformation.description}</p>
</div> </div>
)} )}
<div> <div>
<p className="text-sm text-muted-foreground">Prompt</p> <p className="text-sm text-muted-foreground">{t.transformations.systemPrompt}</p>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono"> <pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono">
{transformation.prompt} {transformation.prompt}
</pre> </pre>
@ -105,9 +107,9 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
<ConfirmDialog <ConfirmDialog
open={showDeleteDialog} open={showDeleteDialog}
onOpenChange={setShowDeleteDialog} onOpenChange={setShowDeleteDialog}
title="Delete Transformation" title={t.sources.delete}
description={`Are you sure you want to delete "${transformation.name}"? This action cannot be undone.`} description={t.transformations.deleteConfirm}
confirmText="Delete" confirmText={t.common.delete}
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={handleDelete} onConfirm={handleDelete}
isLoading={deleteTransformation.isPending} isLoading={deleteTransformation.isPending}

View file

@ -1,10 +1,10 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect, useId } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@ -15,12 +15,13 @@ import { useCreateTransformation, useUpdateTransformation, useTransformation } f
import { Transformation } from '@/lib/types/transformations' import { Transformation } from '@/lib/types/transformations'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { TRANSFORMATION_QUERY_KEYS } from '@/lib/hooks/use-transformations' import { TRANSFORMATION_QUERY_KEYS } from '@/lib/hooks/use-transformations'
import { useTranslation } from '@/lib/hooks/use-translation'
const transformationSchema = z.object({ const transformationSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1),
title: z.string().optional(), title: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
prompt: z.string().min(1, 'Prompt is required'), prompt: z.string().min(1),
apply_default: z.boolean().optional(), apply_default: z.boolean().optional(),
}) })
@ -33,6 +34,12 @@ interface TransformationEditorDialogProps {
} }
export function TransformationEditorDialog({ open, onOpenChange, transformation }: TransformationEditorDialogProps) { export function TransformationEditorDialog({ open, onOpenChange, transformation }: TransformationEditorDialogProps) {
const { t } = useTranslation()
const nameId = useId()
const titleId = useId()
const defaultId = useId()
const descriptionId = useId()
const promptId = useId()
const isEditing = Boolean(transformation) const isEditing = Boolean(transformation)
const { data: fetchedTransformation, isLoading } = useTransformation(transformation?.id ?? '', { const { data: fetchedTransformation, isLoading } = useTransformation(transformation?.id ?? '', {
enabled: open && Boolean(transformation?.id), enabled: open && Boolean(transformation?.id),
@ -111,28 +118,32 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0"> <DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0">
<DialogTitle className="sr-only"> <DialogTitle className="sr-only">
{isEditing ? 'Edit transformation' : 'Create transformation'} {isEditing ? t.common.edit : t.transformations.createNew}
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only">
{isEditing ? t.common.editTransformation : t.transformations.createNew}
</DialogDescription>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col"> <form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
{isEditing && isLoading ? ( {isEditing && isLoading ? (
<div className="flex-1 flex items-center justify-center py-10"> <div className="flex-1 flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">Loading transformation</span> <span className="text-sm text-muted-foreground">{t.common.loading}</span>
</div> </div>
) : ( ) : (
<> <>
<div className="border-b px-6 py-4 space-y-4"> <div className="border-b px-6 py-4 space-y-4">
<div> <div>
<Label htmlFor="transformation-name" className="text-sm font-medium"> <Label htmlFor={nameId} className="text-sm font-medium">
Name {t.transformations.name}
</Label> </Label>
<Controller <Controller
control={control} control={control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<Input <Input
id="transformation-name" id={nameId}
{...field} {...field}
placeholder="Unique identifier, e.g. key_topics" placeholder={t.transformations.namePlaceholder}
autoComplete="off"
/> />
)} )}
/> />
@ -143,18 +154,19 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="transformation-title" className="text-sm font-medium"> <Label htmlFor={titleId} className="text-sm font-medium">
Title {t.common.title}
</Label> </Label>
<Controller <Controller
control={control} control={control}
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<Input <Input
id="transformation-title" id={titleId}
{...field} {...field}
placeholder="Displayed title, defaults to name" placeholder={t.transformations.titlePlaceholder}
/> autoComplete="off"
/>
)} )}
/> />
</div> </div>
@ -164,31 +176,32 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
name="apply_default" name="apply_default"
render={({ field }) => ( render={({ field }) => (
<Checkbox <Checkbox
id="transformation-default" id={defaultId}
checked={field.value} checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))} onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/> />
)} )}
/> />
<Label htmlFor="transformation-default" className="text-sm"> <Label htmlFor={defaultId} className="text-sm">
Suggest by default on new sources {t.transformations.suggestDefault}
</Label> </Label>
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor="transformation-description" className="text-sm font-medium"> <Label htmlFor={descriptionId} className="text-sm font-medium">
Description {t.notebooks.addDescription.replace('...', '')}
</Label> </Label>
<Controller <Controller
control={control} control={control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<Textarea <Textarea
id="transformation-description" id={descriptionId}
{...field} {...field}
placeholder="Describe what this transformation does." placeholder={t.transformations.descriptionPlaceholder}
rows={2} rows={2}
autoComplete="off"
/> />
)} )}
/> />
@ -196,7 +209,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4"> <div className="flex-1 overflow-y-auto px-6 py-4">
<Label className="text-sm font-medium">Prompt</Label> <Label htmlFor={promptId} className="text-sm font-medium">{t.transformations.systemPrompt}</Label>
<Controller <Controller
control={control} control={control}
name="prompt" name="prompt"
@ -206,33 +219,34 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
height={420} height={420}
placeholder="Write the prompt that will power this transformation..." placeholder={t.transformations.promptPlaceholder}
className="rounded-md border" className="rounded-md border"
textareaId={promptId}
name={field.name}
/> />
)} )}
/> />
{errors.prompt && ( {errors.prompt && (
<p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p> <p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p>
)} )}
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
Prompts should be written with the source content in mind. You can ask the model to {t.transformations.promptHint}
summarise, extract insights, or produce structured outputs such as tables. </p>
</p>
</div> </div>
</> </>
)} )}
<div className="border-t px-6 py-4 flex justify-end gap-2"> <div className="border-t px-6 py-4 flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" variant="outline" onClick={handleClose}>
Cancel {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={isSaving || (isEditing && isLoading)}> <Button type="submit" disabled={isSaving || (isEditing && isLoading)}>
{isSaving {isSaving
? isEditing ? 'Saving…' : 'Creating…' ? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
: isEditing : isEditing
? 'Save Transformation' ? t.common.editTransformation
: 'Create Transformation'} : t.transformations.createNew}
</Button> </Button>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>

View file

@ -11,6 +11,7 @@ import { Play, Loader2 } from 'lucide-react'
import { Transformation } from '@/lib/types/transformations' import { Transformation } from '@/lib/types/transformations'
import { useExecuteTransformation } from '@/lib/hooks/use-transformations' import { useExecuteTransformation } from '@/lib/hooks/use-transformations'
import { ModelSelector } from '@/components/common/ModelSelector' import { ModelSelector } from '@/components/common/ModelSelector'
import { useTranslation } from '@/lib/hooks/use-translation'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@ -20,6 +21,7 @@ interface TransformationPlaygroundProps {
} }
export function TransformationPlayground({ transformations, selectedTransformation }: TransformationPlaygroundProps) { export function TransformationPlayground({ transformations, selectedTransformation }: TransformationPlaygroundProps) {
const { t } = useTranslation()
const [selectedId, setSelectedId] = useState(selectedTransformation?.id || '') const [selectedId, setSelectedId] = useState(selectedTransformation?.id || '')
const [inputText, setInputText] = useState('') const [inputText, setInputText] = useState('')
const [modelId, setModelId] = useState('') const [modelId, setModelId] = useState('')
@ -47,18 +49,18 @@ export function TransformationPlayground({ transformations, selectedTransformati
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Playground</CardTitle> <CardTitle>{t.transformations.playground}</CardTitle>
<CardDescription> <CardDescription>
Test your transformations on sample text before applying them to your sources {t.transformations.desc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="transformation">Transformation</Label> <Label htmlFor="transformation">{t.navigation.transformation}</Label>
<Select value={selectedId} onValueChange={setSelectedId}> <Select name="transformation" value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger id="transformation"> <SelectTrigger id="transformation">
<SelectValue placeholder="Select a transformation" /> <SelectValue placeholder={t.transformations.selectToStart} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{transformations?.map((transformation) => ( {transformations?.map((transformation) => (
@ -72,22 +74,24 @@ export function TransformationPlayground({ transformations, selectedTransformati
<div> <div>
<ModelSelector <ModelSelector
label="Model" label={t.transformations.model}
name="model"
modelType="language" modelType="language"
value={modelId} value={modelId}
onChange={setModelId} onChange={setModelId}
placeholder="Select a model" placeholder={t.transformations.selectModel}
/> />
</div> </div>
</div> </div>
<div> <div>
<Label htmlFor="input">Input Text</Label> <Label htmlFor="input">{t.transformations.inputLabel}</Label>
<Textarea <Textarea
id="input" id="input"
name="input"
value={inputText} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={(e) => setInputText(e.target.value)}
placeholder="Enter some text to transform..." placeholder={t.transformations.inputPlaceholder}
rows={8} rows={8}
className="font-mono text-sm" className="font-mono text-sm"
/> />
@ -102,12 +106,12 @@ export function TransformationPlayground({ transformations, selectedTransformati
{executeTransformation.isPending ? ( {executeTransformation.isPending ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
Running... {t.transformations.running}
</> </>
) : ( ) : (
<> <>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
Run Transformation {t.transformations.runTest}
</> </>
)} )}
</Button> </Button>
@ -115,7 +119,7 @@ export function TransformationPlayground({ transformations, selectedTransformati
{output && ( {output && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Output</Label> <span className="text-sm font-medium leading-none">{t.transformations.outputLabel}</span>
<Card> <Card>
<ScrollArea className="h-[400px]"> <ScrollArea className="h-[400px]">
<CardContent className="pt-6"> <CardContent className="pt-6">

View file

@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Wand2 } from 'lucide-react' import { Wand2 } from 'lucide-react'
import { Transformation } from '@/lib/types/transformations' import { Transformation } from '@/lib/types/transformations'
import { TransformationEditorDialog } from './TransformationEditorDialog' import { TransformationEditorDialog } from './TransformationEditorDialog'
import { useTranslation } from '@/lib/hooks/use-translation'
interface TransformationsListProps { interface TransformationsListProps {
transformations: Transformation[] | undefined transformations: Transformation[] | undefined
@ -17,6 +18,7 @@ interface TransformationsListProps {
} }
export function TransformationsList({ transformations, isLoading, onPlayground }: TransformationsListProps) { export function TransformationsList({ transformations, isLoading, onPlayground }: TransformationsListProps) {
const { t } = useTranslation()
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [editingTransformation, setEditingTransformation] = useState<Transformation | undefined>() const [editingTransformation, setEditingTransformation] = useState<Transformation | undefined>()
@ -37,12 +39,12 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
return ( return (
<EmptyState <EmptyState
icon={Wand2} icon={Wand2}
title="No transformations yet" title={t.transformations.noTransformations}
description="Create your first transformation to process and extract insights from your content." description={t.transformations.createOne}
action={ action={
<Button onClick={() => handleOpenEditor()}> <Button onClick={() => handleOpenEditor()}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create New Transformation {t.transformations.createNew}
</Button> </Button>
} }
/> />
@ -53,10 +55,10 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
<> <>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">Your Transformations</h2> <h2 className="text-lg font-semibold">{t.transformations.listTitle}</h2>
<Button onClick={() => handleOpenEditor()}> <Button onClick={() => handleOpenEditor()}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create New Transformation {t.transformations.createNew}
</Button> </Button>
</div> </div>

View file

@ -10,8 +10,10 @@ import { TransformationPlayground } from './components/TransformationPlayground'
import { useTransformations } from '@/lib/hooks/use-transformations' import { useTransformations } from '@/lib/hooks/use-transformations'
import { Transformation } from '@/lib/types/transformations' import { Transformation } from '@/lib/types/transformations'
import { Wand2, Play, RefreshCw } from 'lucide-react' import { Wand2, Play, RefreshCw } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function TransformationsPage() { export default function TransformationsPage() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('transformations') const [activeTab, setActiveTab] = useState('transformations')
const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>() const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>()
const { data: transformations, isLoading, refetch } = useTransformations() const { data: transformations, isLoading, refetch } = useTransformations()
@ -27,7 +29,7 @@ export default function TransformationsPage() {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Transformations</h1> <h1 className="text-2xl font-bold">{t.transformations.title}</h1>
<Button variant="outline" size="sm" onClick={() => refetch()}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@ -36,21 +38,21 @@ export default function TransformationsPage() {
<div className="max-w-5xl"> <div className="max-w-5xl">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Transformations are prompts that will be used by the LLM to process a source and extract insights, summaries, etc. {t.transformations.desc}
</p> </p>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a workspace</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.transformations.workspace}</p>
<TabsList aria-label="Transformation views" className="w-full max-w-xl"> <TabsList aria-label={t.common.accessibility.transformationViews} className="w-full max-w-xl">
<TabsTrigger value="transformations" className="flex items-center gap-2"> <TabsTrigger value="transformations" className="flex items-center gap-2">
<Wand2 className="h-4 w-4" /> <Wand2 className="h-4 w-4" />
Transformations {t.transformations.title}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="playground" className="flex items-center gap-2"> <TabsTrigger value="playground" className="flex items-center gap-2">
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
Playground {t.transformations.playground}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View file

@ -70,7 +70,7 @@
--sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815); --sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604); --sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent: oklch(0.92 0.01 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815); --sidebar-ring: oklch(0.623 0.214 259.815);
@ -104,7 +104,7 @@
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881); --sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent: oklch(0.35 0.01 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376); --sidebar-ring: oklch(0.488 0.243 264.376);
@ -114,63 +114,78 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html { html {
@apply antialiased; @apply antialiased;
} }
body { body {
@apply bg-background text-foreground transition-colors; @apply bg-background text-foreground transition-colors;
} }
/* Ensure proper theme inheritance for popovers and dropdowns */ /* Ensure proper theme inheritance for popovers and dropdowns */
.dark { .dark {
color-scheme: dark; color-scheme: dark;
} }
:root { :root {
color-scheme: light; color-scheme: light;
} }
/* Ensure Radix UI components inherit theme properly */ /* Ensure Radix UI components inherit theme properly */
[data-radix-popper-content-wrapper] { [data-radix-popper-content-wrapper] {
@apply z-50; @apply z-50;
} }
/* Force theme inheritance for portaled content */ /* Force theme inheritance for portaled content */
.dark [data-radix-popper-content-wrapper], .dark [data-radix-popper-content-wrapper],
.dark [data-overlay-container] { .dark [data-overlay-container] {
color-scheme: dark; color-scheme: dark;
} }
/* Ensure sidebar gets proper theme */ /* Ensure sidebar gets proper theme */
.app-sidebar { .app-sidebar {
background-color: var(--sidebar); background-color: var(--sidebar);
color: var(--sidebar-foreground); color: var(--sidebar-foreground);
border-color: var(--sidebar-border); border-color: var(--sidebar-border);
} }
/* Enhanced sidebar menu item hover effects */
.sidebar-menu-item {
@apply transition-all duration-200 ease-out;
}
.sidebar-menu-item:hover {
@apply scale-[1.02];
background-color: var(--sidebar-accent) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.dark .sidebar-menu-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
/* Enhanced hover effects for cards */ /* Enhanced hover effects for cards */
.card-hover { .card-hover {
@apply transition-all duration-200 cursor-pointer; @apply transition-all duration-200 cursor-pointer;
} }
.card-hover:hover { .card-hover:hover {
background-color: var(--muted) !important; background-color: var(--muted) !important;
border-color: var(--border); border-color: var(--border);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
} }
.dark .card-hover:hover { .dark .card-hover:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
/* Ensure clickable cards show pointer cursor */ /* Ensure clickable cards show pointer cursor */
.clickable-card { .clickable-card {
cursor: pointer !important; cursor: pointer !important;
} }
.clickable-card * { .clickable-card * {
cursor: pointer !important; cursor: pointer !important;
} }

View file

@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/providers/ThemeProvider";
import { ErrorBoundary } from "@/components/common/ErrorBoundary"; import { ErrorBoundary } from "@/components/common/ErrorBoundary";
import { ConnectionGuard } from "@/components/common/ConnectionGuard"; import { ConnectionGuard } from "@/components/common/ConnectionGuard";
import { themeScript } from "@/lib/theme-script"; import { themeScript } from "@/lib/theme-script";
import { I18nProvider } from "@/components/providers/I18nProvider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@ -29,10 +30,12 @@ export default function RootLayout({
<ErrorBoundary> <ErrorBoundary>
<ThemeProvider> <ThemeProvider>
<QueryProvider> <QueryProvider>
<ConnectionGuard> <I18nProvider>
{children} <ConnectionGuard>
<Toaster /> {children}
</ConnectionGuard> <Toaster />
</ConnectionGuard>
</I18nProvider>
</QueryProvider> </QueryProvider>
</ThemeProvider> </ThemeProvider>
</ErrorBoundary> </ErrorBoundary>

View file

@ -10,8 +10,10 @@ import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useTranslation } from '@/lib/hooks/use-translation'
export function LoginForm() { export function LoginForm() {
const { t, language } = useTranslation()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const { login, isLoading, error } = useAuth() const { login, isLoading, error } = useAuth()
const { authRequired, checkAuthRequired, hasHydrated, isAuthenticated } = useAuthStore() const { authRequired, checkAuthRequired, hasHydrated, isAuthenticated } = useAuthStore()
@ -81,9 +83,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4"> <div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle>Connection Error</CardTitle> <CardTitle>{t.common.connectionError}</CardTitle>
<CardDescription> <CardDescription>
Unable to connect to the API server {t.common.unableToConnect}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -91,21 +93,21 @@ export function LoginForm() {
<div className="flex items-start gap-2 text-red-600 text-sm"> <div className="flex items-start gap-2 text-red-600 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" /> <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div className="flex-1"> <div className="flex-1">
{error || 'Unable to connect to server. Please check if the API is running.'} {error || t.auth.connectErrorHint}
</div> </div>
</div> </div>
{configInfo && ( {configInfo && (
<div className="space-y-2 text-xs text-muted-foreground border-t pt-3"> <div className="space-y-2 text-xs text-muted-foreground border-t pt-3">
<div className="font-medium">Diagnostic Information:</div> <div className="font-medium">{t.common.diagnosticInfo}:</div>
<div className="space-y-1 font-mono"> <div className="space-y-1 font-mono">
<div>Version: {configInfo.version}</div> <div>{t.common.version}: {configInfo.version}</div>
<div>Built: {new Date(configInfo.buildTime).toLocaleString()}</div> <div>{t.common.built}: {new Date(configInfo.buildTime).toLocaleString(language === 'zh-CN' ? 'zh-CN' : language === 'zh-TW' ? 'zh-TW' : 'en-US')}</div>
<div className="break-all">API URL: {configInfo.apiUrl}</div> <div className="break-all">{t.common.apiUrl}: {configInfo.apiUrl}</div>
<div className="break-all">Frontend: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div> <div className="break-all">{t.common.frontendUrl}: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div>
</div> </div>
<div className="text-xs pt-2"> <div className="text-xs pt-2">
Check browser console for detailed logs (look for 🔧 [Config] messages) {t.common.checkConsoleLogs}
</div> </div>
</div> </div>
)} )}
@ -114,7 +116,7 @@ export function LoginForm() {
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="w-full" className="w-full"
> >
Retry Connection {t.common.retryConnection}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -139,9 +141,9 @@ export function LoginForm() {
<div className="min-h-screen flex items-center justify-center bg-background p-4"> <div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle>Open Notebook</CardTitle> <CardTitle>{t.auth.loginTitle}</CardTitle>
<CardDescription> <CardDescription>
Enter your password to access the application {t.auth.loginDesc}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -149,7 +151,7 @@ export function LoginForm() {
<div> <div>
<Input <Input
type="password" type="password"
placeholder="Password" placeholder={t.auth.passwordPlaceholder}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
@ -168,12 +170,12 @@ export function LoginForm() {
className="w-full" className="w-full"
disabled={isLoading || !password.trim()} disabled={isLoading || !password.trim()}
> >
{isLoading ? 'Signing in...' : 'Sign In'} {isLoading ? t.auth.signingIn : t.auth.signIn}
</Button> </Button>
{configInfo && ( {configInfo && (
<div className="text-xs text-center text-muted-foreground pt-2 border-t"> <div className="text-xs text-center text-muted-foreground pt-2 border-t">
<div>Version {configInfo.version}</div> <div>{t.common.version} {configInfo.version}</div>
<div className="font-mono text-[10px]">{configInfo.apiUrl}</div> <div className="font-mono text-[10px]">{configInfo.apiUrl}</div>
</div> </div>
)} )}

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useCallback, useMemo } from 'react' import { useEffect, useState, useCallback, useMemo, useId } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCreateDialogs } from '@/lib/hooks/use-create-dialogs' import { useCreateDialogs } from '@/lib/hooks/use-create-dialogs'
import { useNotebooks } from '@/lib/hooks/use-notebooks' import { useNotebooks } from '@/lib/hooks/use-notebooks'
@ -29,31 +29,39 @@ import {
Monitor, Monitor,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales'
const navigationItems = [ const getNavigationItems = (t: TranslationKeys) => [
{ name: 'Sources', href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] }, { name: t.navigation.sources, href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },
{ name: 'Notebooks', href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] }, { name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
{ name: 'Ask and Search', href: '/search', icon: Search, keywords: ['find', 'query'] }, { name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] },
{ name: 'Podcasts', href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] }, { name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
{ name: 'Models', href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] }, { name: t.navigation.models, href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
{ name: 'Transformations', href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] }, { name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
{ name: 'Settings', href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] }, { name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
{ name: 'Advanced', href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] }, { name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
] ]
const createItems = [ const getCreateItems = (t: TranslationKeys) => [
{ name: 'Create Source', action: 'source', icon: FileText }, { name: t.common.newSource, action: 'source', icon: FileText },
{ name: 'Create Notebook', action: 'notebook', icon: Book }, { name: t.common.newNotebook, action: 'notebook', icon: Book },
{ name: 'Create Podcast', action: 'podcast', icon: Mic }, { name: t.common.newPodcast, action: 'podcast', icon: Mic },
] ]
const themeItems = [ const getThemeItems = (t: TranslationKeys) => [
{ name: 'Light Theme', value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] }, { name: t.common.light, value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] },
{ name: 'Dark Theme', value: 'dark' as const, icon: Moon, keywords: ['night'] }, { name: t.common.dark, value: 'dark' as const, icon: Moon, keywords: ['night'] },
{ name: 'System Theme', value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] }, { name: t.common.system, value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] },
] ]
export function CommandPalette() { export function CommandPalette() {
const { t } = useTranslation()
const commandInputId = useId()
const navigationItems = useMemo(() => getNavigationItems(t), [t])
const createItems = useMemo(() => getCreateItems(t), [t])
const themeItems = useMemo(() => getThemeItems(t), [t])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const router = useRouter() const router = useRouter()
@ -147,7 +155,7 @@ export function CommandPalette() {
(nb.description && nb.description.toLowerCase().includes(queryLower)) (nb.description && nb.description.toLowerCase().includes(queryLower))
) ?? false) ) ?? false)
) )
}, [queryLower, notebooks]) }, [queryLower, notebooks, navigationItems, createItems, themeItems])
// Determine if we should show the Search/Ask section at the top // Determine if we should show the Search/Ask section at the top
const showSearchFirst = query.trim() && !hasCommandMatch const showSearchFirst = query.trim() && !hasCommandMatch
@ -156,26 +164,30 @@ export function CommandPalette() {
<CommandDialog <CommandDialog
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
title="Command Palette" title={t.common.quickActions}
description="Navigate, search, or ask your knowledge base" description={t.common.quickActionsDesc}
className="sm:max-w-lg" className="sm:max-w-lg"
> >
<CommandInput <CommandInput
placeholder="Type a command or search..." id={commandInputId}
name="command-search"
placeholder={t.searchPage.enterSearchPlaceholder}
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
aria-label={t.common.search}
autoComplete="off"
/> />
<CommandList> <CommandList>
{/* Search/Ask - show FIRST when there's a query with no command match */} {/* Search/Ask - show FIRST when there's a query with no command match */}
{showSearchFirst && ( {showSearchFirst && (
<CommandGroup heading="Search & Ask" forceMount> <CommandGroup heading={t.searchPage.searchAndAsk} forceMount>
<CommandItem <CommandItem
value={`__search__ ${query}`} value={`__search__ ${query}`}
onSelect={handleSearch} onSelect={handleSearch}
forceMount forceMount
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span>Search for &ldquo;{query}&rdquo;</span> <span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value={`__ask__ ${query}`} value={`__ask__ ${query}`}
@ -183,13 +195,13 @@ export function CommandPalette() {
forceMount forceMount
> >
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
<span>Ask about &ldquo;{query}&rdquo;</span> <span>{t.searchPage.askAbout.replace('{query}', query)}</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
)} )}
{/* Navigation */} {/* Navigation */}
<CommandGroup heading="Navigation"> <CommandGroup heading={t.navigation.nav}>
{navigationItems.map((item) => ( {navigationItems.map((item) => (
<CommandItem <CommandItem
key={item.href} key={item.href}
@ -203,11 +215,11 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Notebooks */} {/* Notebooks */}
<CommandGroup heading="Notebooks"> <CommandGroup heading={t.notebooks.title}>
{notebooksLoading ? ( {notebooksLoading ? (
<CommandItem disabled> <CommandItem disabled>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span>Loading notebooks...</span> <span>{t.common.loading}</span>
</CommandItem> </CommandItem>
) : notebooks && notebooks.length > 0 ? ( ) : notebooks && notebooks.length > 0 ? (
notebooks.map((notebook) => ( notebooks.map((notebook) => (
@ -224,7 +236,7 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Create */} {/* Create */}
<CommandGroup heading="Create"> <CommandGroup heading={t.navigation.create}>
{createItems.map((item) => ( {createItems.map((item) => (
<CommandItem <CommandItem
key={item.action} key={item.action}
@ -238,7 +250,7 @@ export function CommandPalette() {
</CommandGroup> </CommandGroup>
{/* Theme */} {/* Theme */}
<CommandGroup heading="Theme"> <CommandGroup heading={t.navigation.theme}>
{themeItems.map((item) => ( {themeItems.map((item) => (
<CommandItem <CommandItem
key={item.value} key={item.value}
@ -255,14 +267,14 @@ export function CommandPalette() {
{query.trim() && hasCommandMatch && ( {query.trim() && hasCommandMatch && (
<> <>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading="Or search your knowledge base" forceMount> <CommandGroup heading={t.searchPage.orSearchKb} forceMount>
<CommandItem <CommandItem
value={`__search__ ${query}`} value={`__search__ ${query}`}
onSelect={handleSearch} onSelect={handleSearch}
forceMount forceMount
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span>Search for &ldquo;{query}&rdquo;</span> <span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value={`__ask__ ${query}`} value={`__ask__ ${query}`}
@ -270,7 +282,7 @@ export function CommandPalette() {
forceMount forceMount
> >
<MessageCircleQuestion className="h-4 w-4" /> <MessageCircleQuestion className="h-4 w-4" />
<span>Ask about &ldquo;{query}&rdquo;</span> <span>{t.searchPage.askAbout.replace('{query}', query)}</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</> </>

View file

@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConfirmDialog } from './ConfirmDialog'
// useTranslation is mocked globally in setup.ts
describe('ConfirmDialog', () => {
const onConfirmMock = vi.fn()
const onOpenChangeMock = vi.fn()
const defaultProps = {
open: true,
onOpenChange: onOpenChangeMock,
title: 'Test Title',
description: 'Test Description',
onConfirm: onConfirmMock,
}
it('should render correct titles and descriptions', () => {
render(<ConfirmDialog {...defaultProps} />)
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.getByText('Test Description')).toBeInTheDocument()
// Localized text from our setup.ts mock should be visible
expect(screen.getByText('Confirm')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
it('should call onConfirm when confirm button is clicked', () => {
render(<ConfirmDialog {...defaultProps} />)
const confirmBtn = screen.getByText('Confirm')
fireEvent.click(confirmBtn)
expect(onConfirmMock).toHaveBeenCalledTimes(1)
})
it('should show custom confirm text if provided', () => {
render(<ConfirmDialog {...defaultProps} confirmText="Delete Now" />)
expect(screen.getByText('Delete Now')).toBeInTheDocument()
})
it('should show loading state and disable buttons', () => {
render(<ConfirmDialog {...defaultProps} isLoading={true} />)
const confirmBtn = screen.getByText('Confirm').closest('button')
const cancelBtn = screen.getByText('Cancel').closest('button')
expect(confirmBtn).toBeDisabled()
expect(cancelBtn).toBeDisabled()
})
})

View file

@ -10,6 +10,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { useTranslation } from '@/lib/hooks/use-translation'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
interface ConfirmDialogProps { interface ConfirmDialogProps {
@ -28,11 +29,14 @@ export function ConfirmDialog({
onOpenChange, onOpenChange,
title, title,
description, description,
confirmText = 'Confirm', confirmText,
confirmVariant = 'default', confirmVariant = 'default',
onConfirm, onConfirm,
isLoading = false, isLoading = false,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { t } = useTranslation()
const finalConfirmText = confirmText || t.common.confirm
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
@ -41,7 +45,7 @@ export function ConfirmDialog({
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isLoading}>{t.common.cancel}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={onConfirm} onClick={onConfirm}
disabled={isLoading} disabled={isLoading}
@ -50,10 +54,10 @@ export function ConfirmDialog({
{isLoading ? ( {isLoading ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
{confirmText} {finalConfirmText}
</> </>
) : ( ) : (
confirmText finalConfirmText
)} )}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { ConnectionError } from '@/lib/types/config' import { ConnectionError } from '@/lib/types/config'
import { ConnectionErrorOverlay } from '@/components/errors/ConnectionErrorOverlay' import { ConnectionErrorOverlay } from '@/components/errors/ConnectionErrorOverlay'
import { getConfig, resetConfig } from '@/lib/config' import { getConfig, resetConfig } from '@/lib/config'
@ -12,9 +12,18 @@ interface ConnectionGuardProps {
export function ConnectionGuard({ children }: ConnectionGuardProps) { export function ConnectionGuard({ children }: ConnectionGuardProps) {
const [error, setError] = useState<ConnectionError | null>(null) const [error, setError] = useState<ConnectionError | null>(null)
const [isChecking, setIsChecking] = useState(true) const [isChecking, setIsChecking] = useState(true)
// Use a ref to track checking status to avoid dependency cycles
const isCheckingRef = useRef(false)
const checkConnection = useCallback(async () => { const checkConnection = useCallback(async () => {
// Prevent re-entry if already checking
if (isCheckingRef.current) {
return
}
isCheckingRef.current = true
setIsChecking(true) setIsChecking(true)
setError(null) setError(null)
// Reset config cache to force a fresh fetch // Reset config cache to force a fresh fetch
@ -25,41 +34,46 @@ export function ConnectionGuard({ children }: ConnectionGuardProps) {
// Check if database is offline // Check if database is offline
if (config.dbStatus === 'offline') { if (config.dbStatus === 'offline') {
setError({ const dbError: ConnectionError = {
type: 'database-offline', type: 'database-offline',
details: { details: {
message: 'The API server is running, but the database is not accessible', message: 'Database is offline', // Fallback message, UI will translate
attemptedUrl: config.apiUrl, attemptedUrl: config.apiUrl,
}, },
}) }
setError(dbError)
isCheckingRef.current = false
setIsChecking(false) setIsChecking(false)
return return
} }
// If we got here, connection is good // If we got here, connection is good
setError(null) setError(null)
isCheckingRef.current = false
setIsChecking(false) setIsChecking(false)
} catch (err) { } catch (err) {
// API is unreachable // API is unreachable
const errorMessage = const errorMessage = err instanceof Error ? err.message : 'Unknown error'
err instanceof Error ? err.message : 'Unknown error occurred'
const attemptedUrl = const attemptedUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? `${window.location.origin}/api/config` ? `${window.location.origin}/api/config`
: undefined : undefined
setError({ const apiError: ConnectionError = {
type: 'api-unreachable', type: 'api-unreachable',
details: { details: {
message: 'The Open Notebook API server could not be reached', message: 'Unable to connect to API', // Fallback message
technicalMessage: errorMessage, technicalMessage: errorMessage,
stack: err instanceof Error ? err.stack : undefined, stack: err instanceof Error ? err.stack : undefined,
attemptedUrl, attemptedUrl,
}, },
}) }
setError(apiError)
isCheckingRef.current = false
setIsChecking(false) setIsChecking(false)
} }
}, []) }, []) // Empty dependency array - stable callback
// Check connection on mount // Check connection on mount
useEffect(() => { useEffect(() => {

View file

@ -10,6 +10,7 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page' import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ContextToggleProps { interface ContextToggleProps {
mode: ContextMode mode: ContextMode
@ -18,28 +19,29 @@ interface ContextToggleProps {
className?: string className?: string
} }
const MODE_CONFIG = {
off: {
icon: EyeOff,
label: 'Not included in chat',
color: 'text-muted-foreground',
bgColor: 'hover:bg-muted'
},
insights: {
icon: Lightbulb,
label: 'Insights only',
color: 'text-amber-600',
bgColor: 'hover:bg-amber-50'
},
full: {
icon: FileText,
label: 'Full content',
color: 'text-primary',
bgColor: 'hover:bg-primary/10'
}
} as const
export function ContextToggle({ mode, hasInsights = false, onChange, className }: ContextToggleProps) { export function ContextToggle({ mode, hasInsights = false, onChange, className }: ContextToggleProps) {
const { t } = useTranslation()
const MODE_CONFIG = {
off: {
icon: EyeOff,
label: t.common.contextModes.off,
color: 'text-muted-foreground',
bgColor: 'hover:bg-muted'
},
insights: {
icon: Lightbulb,
label: t.common.contextModes.insights,
color: 'text-amber-600',
bgColor: 'hover:bg-amber-50'
},
full: {
icon: FileText,
label: t.common.contextModes.full,
color: 'text-primary',
bgColor: 'hover:bg-primary/10'
}
} as const
const config = MODE_CONFIG[mode] const config = MODE_CONFIG[mode]
const Icon = config.icon const Icon = config.icon
@ -77,7 +79,7 @@ export function ContextToggle({ mode, hasInsights = false, onChange, className }
<TooltipContent> <TooltipContent>
<p className="text-xs">{config.label}</p> <p className="text-xs">{config.label}</p>
<p className="text-[10px] text-muted-foreground mt-1"> <p className="text-[10px] text-muted-foreground mt-1">
Click to cycle {t.common.contextModes.clickToCycle}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

@ -4,6 +4,10 @@ import React from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle, RefreshCw } from 'lucide-react' import { AlertTriangle, RefreshCw } from 'lucide-react'
import { enUS } from '@/lib/locales/en-US'
// Use English as fallback for ErrorBoundary (class component cannot use hooks)
const t = enUS
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean hasError: boolean
@ -55,15 +59,15 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4"> <div className="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" /> <AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div> </div>
<CardTitle className="text-red-900 dark:text-red-100">Something went wrong</CardTitle> <CardTitle className="text-red-900 dark:text-red-100">{t?.common?.error || 'Something went wrong'}</CardTitle>
<CardDescription> <CardDescription>
An unexpected error occurred. Please try refreshing the page. {t?.common?.refreshPage || 'An unexpected error occurred. Please try refreshing the page.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{process.env.NODE_ENV === 'development' && this.state.error && ( {process.env.NODE_ENV === 'development' && this.state.error && (
<details className="text-xs bg-muted p-3 rounded border"> <details className="text-xs bg-muted p-3 rounded border">
<summary className="cursor-pointer font-medium">Error Details</summary> <summary className="cursor-pointer font-medium">{t?.common?.errorDetails || 'Error Details'}</summary>
<pre className="mt-2 whitespace-pre-wrap break-all"> <pre className="mt-2 whitespace-pre-wrap break-all">
{this.state.error.toString()} {this.state.error.toString()}
</pre> </pre>
@ -75,13 +79,13 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
variant="outline" variant="outline"
> >
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Try Again {t?.common?.retry || 'Try Again'}
</Button> </Button>
<Button <Button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="w-full" className="w-full"
> >
Refresh Page {t?.common?.refresh || 'Refresh Page'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useRef, useEffect, type RefObject } from 'react' import { useState, useRef, useEffect, useId, type RefObject } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/hooks/use-translation'
interface InlineEditProps { interface InlineEditProps {
value: string value: string
@ -11,6 +12,9 @@ interface InlineEditProps {
placeholder?: string placeholder?: string
multiline?: boolean multiline?: boolean
emptyText?: string emptyText?: string
id?: string
name?: string
autocomplete?: string
} }
export function InlineEdit({ export function InlineEdit({
@ -20,8 +24,15 @@ export function InlineEdit({
inputClassName, inputClassName,
placeholder, placeholder,
multiline = false, multiline = false,
emptyText = 'Click to edit' emptyText,
id: providedId,
name,
autocomplete = 'off'
}: InlineEditProps) { }: InlineEditProps) {
const generatedId = useId()
const id = providedId || generatedId
const { t } = useTranslation()
const defaultEmptyText = emptyText || t.common.clickToEdit
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value) const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
@ -85,7 +96,7 @@ export function InlineEdit({
setIsEditing(true) setIsEditing(true)
}} }}
> >
{value || <span className="text-muted-foreground">{emptyText}</span>} {value || <span className="text-muted-foreground">{defaultEmptyText}</span>}
</button> </button>
) )
} }
@ -111,6 +122,9 @@ export function InlineEdit({
)} )}
placeholder={placeholder} placeholder={placeholder}
disabled={isSaving} disabled={isSaving}
id={id}
name={name}
autoComplete={autocomplete}
/> />
) )
} }
@ -134,6 +148,9 @@ export function InlineEdit({
)} )}
placeholder={placeholder} placeholder={placeholder}
disabled={isSaving} disabled={isSaving}
id={id}
name={name}
autoComplete={autocomplete}
/> />
) )
} }

View file

@ -0,0 +1,91 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useTranslation as useI18nTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import {
i18nEvents,
I18N_LANGUAGE_CHANGE_END,
I18N_LANGUAGE_CHANGE_START,
} from '@/lib/i18n-events'
/**
* LanguageLoadingOverlay - Shows a brief loading overlay during language switches
* to provide a smoother UX and hide the flash caused by re-rendering.
*
* IMPORTANT: This component intentionally uses react-i18next directly instead of
* our custom useTranslation hook to avoid Proxy-related issues during the
* language change transition period.
*/
export function LanguageLoadingOverlay() {
const { t } = useI18nTranslation()
const [isChanging, setIsChanging] = useState(false)
const isChangingRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleLanguageChanging = useCallback(() => {
if (!isChangingRef.current) {
isChangingRef.current = true
setIsChanging(true)
}
// Safety timeout: ensure we don't get stuck forever.
if (!timerRef.current) {
timerRef.current = setTimeout(() => {
isChangingRef.current = false
setIsChanging(false)
timerRef.current = null
}, 1500)
}
}, [])
const handleLanguageChanged = useCallback(() => {
// Immediately hide the overlay on language change success
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
if (isChangingRef.current) {
isChangingRef.current = false
setIsChanging(false)
}
}, [])
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
useEffect(() => {
const onChangeStart = () => handleLanguageChanging()
const onChangeEnd = () => handleLanguageChanged()
i18nEvents.addEventListener(I18N_LANGUAGE_CHANGE_START, onChangeStart)
i18nEvents.addEventListener(I18N_LANGUAGE_CHANGE_END, onChangeEnd)
return () => {
i18nEvents.removeEventListener(I18N_LANGUAGE_CHANGE_START, onChangeStart)
i18nEvents.removeEventListener(I18N_LANGUAGE_CHANGE_END, onChangeEnd)
}
}, [handleLanguageChanging, handleLanguageChanged])
if (!isChanging) return null
// Use react-i18next's t() directly - this is safe during language transitions
// because react-i18next handles the loading state internally
const loadingText = t('common.loading', { defaultValue: '加载中...' })
return (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200"
style={{ opacity: isChanging ? 1 : 0 }}
>
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">{loadingText}</span>
</div>
</div>
)
}

View file

@ -0,0 +1,58 @@
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Languages } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface LanguageToggleProps {
iconOnly?: boolean
}
export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
const { language, setLanguage, t } = useTranslation()
// Keep the actual language code for proper comparison
const currentLang = language || 'en-US'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={iconOnly ? "ghost" : "outline"}
size={iconOnly ? "icon" : "default"}
className={iconOnly ? "h-9 w-full sidebar-menu-item" : "w-full justify-start gap-2 sidebar-menu-item"}
>
<Languages className="h-[1.2rem] w-[1.2rem]" />
{!iconOnly && <span>{t.common.language}</span>}
<span className="sr-only">{t.navigation.language}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setLanguage('en-US')}
className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''}
>
<span>{t.common.english}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-CN')}
className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''}
>
<span>{t.common.chinese}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('zh-TW')}
className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''}
>
<span>{t.common.traditionalChinese}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -14,6 +14,9 @@ export function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps)
} }
return ( return (
<Loader2 className={cn('animate-spin', sizeClasses[size], className)} /> <Loader2
data-testid="loading-spinner"
className={cn('animate-spin', sizeClasses[size], className)}
/>
) )
} }

View file

@ -1,11 +1,13 @@
'use client' import { useId } from 'react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useModels } from '@/lib/hooks/use-models' import { useModels } from '@/lib/hooks/use-models'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ModelSelectorProps { interface ModelSelectorProps {
id?: string
name?: string
label?: string label?: string
modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech' modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech'
value: string value: string
@ -14,24 +16,29 @@ interface ModelSelectorProps {
disabled?: boolean disabled?: boolean
} }
export function ModelSelector({ export function ModelSelector({
label, id,
modelType, name,
value, label,
onChange, modelType,
placeholder = 'Select a model', value,
disabled = false onChange,
placeholder,
disabled = false
}: ModelSelectorProps) { }: ModelSelectorProps) {
const { t } = useTranslation()
const { data: models, isLoading } = useModels() const { data: models, isLoading } = useModels()
const derivedId = useId()
const selectId = id || derivedId
// Filter models by type // Filter models by type
const filteredModels = models?.filter(model => model.type === modelType) || [] const filteredModels = models?.filter(model => model.type === modelType) || []
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{label && <Label>{label}</Label>} {label && <Label htmlFor={selectId}>{label}</Label>}
<Select value={value} onValueChange={onChange} disabled={disabled || isLoading}> <Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger> <SelectTrigger id={selectId}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{isLoading ? ( {isLoading ? (
@ -40,7 +47,7 @@ export function ModelSelector({
</div> </div>
) : filteredModels.length === 0 ? ( ) : filteredModels.length === 0 ? (
<div className="text-sm text-muted-foreground py-2 px-2"> <div className="text-sm text-muted-foreground py-2 px-2">
No {modelType.replace('_', ' ')} models available {t.common.noResults}
</div> </div>
) : ( ) : (
filteredModels.map((model) => ( filteredModels.map((model) => (

View file

@ -9,6 +9,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Sun, Moon, Monitor } from 'lucide-react' import { Sun, Moon, Monitor } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ThemeToggleProps { interface ThemeToggleProps {
iconOnly?: boolean iconOnly?: boolean
@ -16,6 +17,7 @@ interface ThemeToggleProps {
export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) { export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { t } = useTranslation()
return ( return (
<DropdownMenu> <DropdownMenu>
@ -23,14 +25,14 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
<Button <Button
variant={iconOnly ? "ghost" : "outline"} variant={iconOnly ? "ghost" : "outline"}
size={iconOnly ? "icon" : "default"} size={iconOnly ? "icon" : "default"}
className={iconOnly ? "h-9 w-full" : "w-full justify-start gap-2"} className={iconOnly ? "h-9 w-full sidebar-menu-item" : "w-full justify-start gap-2 sidebar-menu-item"}
> >
<div className="relative h-[1.2rem] w-[1.2rem]"> <div className="relative h-[1.2rem] w-[1.2rem]">
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</div> </div>
{!iconOnly && <span>Theme</span>} {!iconOnly && <span>{t.common.theme}</span>}
<span className="sr-only">Toggle theme</span> <span className="sr-only">{t.navigation.theme}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -39,21 +41,21 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
className={theme === 'light' ? 'bg-accent' : ''} className={theme === 'light' ? 'bg-accent' : ''}
> >
<Sun className="mr-2 h-4 w-4" /> <Sun className="mr-2 h-4 w-4" />
<span>Light</span> <span>{t.common.light}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setTheme('dark')} onClick={() => setTheme('dark')}
className={theme === 'dark' ? 'bg-accent' : ''} className={theme === 'dark' ? 'bg-accent' : ''}
> >
<Moon className="mr-2 h-4 w-4" /> <Moon className="mr-2 h-4 w-4" />
<span>Dark</span> <span>{t.common.dark}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setTheme('system')} onClick={() => setTheme('system')}
className={theme === 'system' ? 'bg-accent' : ''} className={theme === 'system' ? 'bg-accent' : ''}
> >
<Monitor className="mr-2 h-4 w-4" /> <Monitor className="mr-2 h-4 w-4" />
<span>System</span> <span>{t.common.system}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View file

@ -10,6 +10,7 @@ import {
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import { Database, Server, ChevronDown, ExternalLink } from 'lucide-react' import { Database, Server, ChevronDown, ExternalLink } from 'lucide-react'
import { ConnectionError } from '@/lib/types/config' import { ConnectionError } from '@/lib/types/config'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ConnectionErrorOverlayProps { interface ConnectionErrorOverlayProps {
error: ConnectionError error: ConnectionError
@ -20,6 +21,7 @@ export function ConnectionErrorOverlay({
error, error,
onRetry, onRetry,
}: ConnectionErrorOverlayProps) { }: ConnectionErrorOverlayProps) {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
const isApiError = error.type === 'api-unreachable' const isApiError = error.type === 'api-unreachable'
@ -41,56 +43,56 @@ export function ConnectionErrorOverlay({
<div> <div>
<h1 className="text-2xl font-bold" id="error-title"> <h1 className="text-2xl font-bold" id="error-title">
{isApiError {isApiError
? 'Unable to Connect to API Server' ? t.connectionErrors.apiTitle
: 'Database Connection Failed'} : t.connectionErrors.dbTitle}
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{isApiError {isApiError
? 'The Open Notebook API server could not be reached' ? t.connectionErrors.apiDesc
: 'The API server is running, but the database is not accessible'} : t.connectionErrors.dbDesc}
</p> </p>
</div> </div>
</div> </div>
{/* Troubleshooting instructions */} {/* Troubleshooting instructions */}
<div className="space-y-4 border-l-4 border-primary pl-4"> <div className="space-y-4 border-l-4 border-primary pl-4">
<h2 className="font-semibold">This usually means:</h2> <h2 className="font-semibold">{t.connectionErrors.troubleshooting}</h2>
<ul className="list-disc list-inside space-y-2 text-sm"> <ul className="list-disc list-inside space-y-2 text-sm">
{isApiError ? ( {isApiError ? (
<> <>
<li>The API server is not running</li> <li>{t.connectionErrors.apiUnreachable1}</li>
<li>The API server is running on a different address</li> <li>{t.connectionErrors.apiUnreachable2}</li>
<li>Network connectivity issues</li> <li>{t.connectionErrors.apiUnreachable3}</li>
</> </>
) : ( ) : (
<> <>
<li>SurrealDB is not running</li> <li>{t.connectionErrors.dbFailed1}</li>
<li>Database connection settings are incorrect</li> <li>{t.connectionErrors.dbFailed2}</li>
<li>Network issues between API and database</li> <li>{t.connectionErrors.dbFailed3}</li>
</> </>
)} )}
</ul> </ul>
<h2 className="font-semibold mt-4">Quick fixes:</h2> <h2 className="font-semibold mt-4">{t.connectionErrors.quickFixes}</h2>
{isApiError ? ( {isApiError ? (
<div className="space-y-2 text-sm bg-muted p-4 rounded"> <div className="space-y-2 text-sm bg-muted p-4 rounded">
<p className="font-medium">Set the API_URL environment variable:</p> <p className="font-medium">{t.connectionErrors.setApiUrl}</p>
<code className="block bg-background p-2 rounded text-xs"> <code className="block bg-background p-2 rounded text-xs">
# For Docker: # {t.connectionErrors.dockerLabel}:
<br /> <br />
docker run -e API_URL=http://your-host:5055 ... docker run -e API_URL=http://your-host:5055 ...
<br /> <br />
<br /> <br />
# For local development (.env file): # {t.connectionErrors.localDevLabel}:
<br /> <br />
API_URL=http://localhost:5055 API_URL=http://localhost:5055
</code> </code>
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm bg-muted p-4 rounded"> <div className="space-y-2 text-sm bg-muted p-4 rounded">
<p className="font-medium">Check if SurrealDB is running:</p> <p className="font-medium">{t.connectionErrors.checkSurreal}</p>
<code className="block bg-background p-2 rounded text-xs"> <code className="block bg-background p-2 rounded text-xs">
# For Docker: # {t.connectionErrors.dockerLabel}:
<br /> <br />
docker compose ps | grep surrealdb docker compose ps | grep surrealdb
<br /> <br />
@ -102,14 +104,14 @@ export function ConnectionErrorOverlay({
{/* Documentation link */} {/* Documentation link */}
<div className="text-sm"> <div className="text-sm">
<p>For detailed setup instructions, see:</p> <p>{t.connectionErrors.seeDocumentation}</p>
<a <a
href="https://github.com/lfnovo/open-notebook" href="https://github.com/lfnovo/open-notebook"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
> >
Open Notebook Documentation {t.connectionErrors.docLink}
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
</div> </div>
@ -119,7 +121,7 @@ export function ConnectionErrorOverlay({
<Collapsible open={showDetails} onOpenChange={setShowDetails}> <Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between"> <Button variant="ghost" size="sm" className="w-full justify-between">
<span>Show Technical Details</span> <span>{t.connectionErrors.showTechnical}</span>
<ChevronDown <ChevronDown
className={`w-4 h-4 transition-transform ${ className={`w-4 h-4 transition-transform ${
showDetails ? 'rotate-180' : '' showDetails ? 'rotate-180' : ''
@ -131,23 +133,23 @@ export function ConnectionErrorOverlay({
<div className="space-y-2 text-sm bg-muted p-4 rounded font-mono"> <div className="space-y-2 text-sm bg-muted p-4 rounded font-mono">
{error.details.attemptedUrl && ( {error.details.attemptedUrl && (
<div> <div>
<strong>Attempted URL:</strong> {error.details.attemptedUrl} <strong>{t.connectionErrors.attemptedUrl}:</strong> {error.details.attemptedUrl}
</div> </div>
)} )}
{error.details.message && ( {error.details.message && (
<div> <div>
<strong>Message:</strong> {error.details.message} <strong>{t.connectionErrors.message}:</strong> {error.details.message}
</div> </div>
)} )}
{error.details.technicalMessage && ( {error.details.technicalMessage && (
<div> <div>
<strong>Technical Details:</strong>{' '} <strong>{t.connectionErrors.technicalDetails}:</strong>{' '}
{error.details.technicalMessage} {error.details.technicalMessage}
</div> </div>
)} )}
{error.details.stack && ( {error.details.stack && (
<div> <div>
<strong>Stack Trace:</strong> <strong>{t.connectionErrors.stackTrace}:</strong>
<pre className="mt-2 overflow-x-auto text-xs"> <pre className="mt-2 overflow-x-auto text-xs">
{error.details.stack} {error.details.stack}
</pre> </pre>
@ -161,10 +163,10 @@ export function ConnectionErrorOverlay({
{/* Retry button */} {/* Retry button */}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<Button onClick={onRetry} className="w-full" size="lg"> <Button onClick={onRetry} className="w-full" size="lg">
Retry Connection {t.connectionErrors.retryLabel}
</Button> </Button>
<p className="text-xs text-muted-foreground text-center mt-2"> <p className="text-xs text-muted-foreground text-center mt-2">
Press R or click the button to retry {t.connectionErrors.retryHint}
</p> </p>
</div> </div>
</Card> </Card>

View file

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { AppSidebar } from './AppSidebar'
import { useSidebarStore } from '@/lib/stores/sidebar-store'
// Mock Tooltip components to avoid Radix UI async issues in tests
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// But setup.ts has some basic mocks, let's see.
describe('AppSidebar', () => {
it('renders correctly when expanded', () => {
render(<AppSidebar />)
// Check for logo or app name (using actual locale value)
expect(screen.getByText(/Open Notebook/i)).toBeDefined()
// Check for navigation items (using actual locale values)
expect(screen.getByText(/Sources/i)).toBeDefined()
expect(screen.getByText(/Notebooks/i)).toBeDefined()
})
it('toggles collapse state when clicking handle', () => {
const toggleCollapse = vi.fn()
vi.mocked(useSidebarStore).mockReturnValue({
isCollapsed: false,
toggleCollapse,
} as any)
render(<AppSidebar />)
// The collapse button has ChevronLeft icon when expanded
// The collapse button has ChevronLeft icon when expanded
// const toggleButton = screen.getAllByRole('button')[0]
// Let's use more specific selector if possible, but AppSidebar has many buttons
// Actually, line 147 has the button
// Use data-testid for reliable selection
fireEvent.click(screen.getByTestId('sidebar-toggle'))
expect(toggleCollapse).toHaveBeenCalled()
})
it('shows collapsed view when isCollapsed is true', () => {
vi.mocked(useSidebarStore).mockReturnValue({
isCollapsed: true,
toggleCollapse: vi.fn(),
} as any)
render(<AppSidebar />)
// In collapsed mode, app name shouldn't be visible (as text)
expect(screen.queryByText(/Open Notebook/i)).toBeNull()
})
})

View file

@ -23,6 +23,9 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ThemeToggle } from '@/components/common/ThemeToggle' import { ThemeToggle } from '@/components/common/ThemeToggle'
import { LanguageToggle } from '@/components/common/LanguageToggle'
import { TranslationKeys } from '@/lib/locales'
import { useTranslation } from '@/lib/hooks/use-translation'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { import {
Book, Book,
@ -40,33 +43,33 @@ import {
Command, Command,
} from 'lucide-react' } from 'lucide-react'
const navigation = [ const getNavigation = (t: TranslationKeys) => [
{ {
title: 'Collect', title: t.navigation.collect,
items: [ items: [
{ name: 'Sources', href: '/sources', icon: FileText }, { name: t.navigation.sources, href: '/sources', icon: FileText },
], ],
}, },
{ {
title: 'Process', title: t.navigation.process,
items: [ items: [
{ name: 'Notebooks', href: '/notebooks', icon: Book }, { name: t.navigation.notebooks, href: '/notebooks', icon: Book },
{ name: 'Ask and Search', href: '/search', icon: Search }, { name: t.navigation.askAndSearch, href: '/search', icon: Search },
], ],
}, },
{ {
title: 'Create', title: t.navigation.create,
items: [ items: [
{ name: 'Podcasts', href: '/podcasts', icon: Mic }, { name: t.navigation.podcasts, href: '/podcasts', icon: Mic },
], ],
}, },
{ {
title: 'Manage', title: t.navigation.manage,
items: [ items: [
{ name: 'Models', href: '/models', icon: Bot }, { name: t.navigation.models, href: '/models', icon: Bot },
{ name: 'Transformations', href: '/transformations', icon: Shuffle }, { name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
{ name: 'Settings', href: '/settings', icon: Settings }, { name: t.navigation.settings, href: '/settings', icon: Settings },
{ name: 'Advanced', href: '/advanced', icon: Wrench }, { name: t.navigation.advanced, href: '/advanced', icon: Wrench },
], ],
}, },
] as const ] as const
@ -74,6 +77,8 @@ const navigation = [
type CreateTarget = 'source' | 'notebook' | 'podcast' type CreateTarget = 'source' | 'notebook' | 'podcast'
export function AppSidebar() { export function AppSidebar() {
const { t } = useTranslation()
const navigation = getNavigation(t)
const pathname = usePathname() const pathname = usePathname()
const { logout } = useAuth() const { logout } = useAuth()
const { isCollapsed, toggleCollapse } = useSidebarStore() const { isCollapsed, toggleCollapse } = useSidebarStore()
@ -134,9 +139,9 @@ export function AppSidebar() {
) : ( ) : (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image src="/logo.svg" alt="Open Notebook" width={32} height={32} /> <Image src="/logo.svg" alt={t.common.appName} width={32} height={32} />
<span className="text-base font-medium text-sidebar-foreground"> <span className="text-base font-medium text-sidebar-foreground">
Open Notebook {t.common.appName}
</span> </span>
</div> </div>
<Button <Button
@ -144,6 +149,7 @@ export function AppSidebar() {
size="sm" size="sm"
onClick={toggleCollapse} onClick={toggleCollapse}
className="text-sidebar-foreground hover:bg-sidebar-accent" className="text-sidebar-foreground hover:bg-sidebar-accent"
data-testid="sidebar-toggle"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@ -173,13 +179,13 @@ export function AppSidebar() {
variant="default" variant="default"
size="sm" size="sm"
className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0" className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0"
aria-label="Create" aria-label={t.common.create}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">Create</TooltipContent> <TooltipContent side="right">{t.common.create}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -188,9 +194,9 @@ export function AppSidebar() {
variant="default" variant="default"
size="sm" size="sm"
className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0" className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Create {t.common.create}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
)} )}
@ -207,8 +213,8 @@ export function AppSidebar() {
}} }}
className="gap-2" className="gap-2"
> >
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Source {t.common.source}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
@ -217,8 +223,8 @@ export function AppSidebar() {
}} }}
className="gap-2" className="gap-2"
> >
<Book className="h-4 w-4" /> <Book className="h-4 w-4" />
Notebook {t.common.notebook}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(event) => { onSelect={(event) => {
@ -227,8 +233,8 @@ export function AppSidebar() {
}} }}
className="gap-2" className="gap-2"
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
Podcast {t.common.podcast}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -247,12 +253,12 @@ export function AppSidebar() {
)} )}
{section.items.map((item) => { {section.items.map((item) => {
const isActive = pathname.startsWith(item.href) const isActive = pathname?.startsWith(item.href) || false
const button = ( const button = (
<Button <Button
variant={isActive ? 'secondary' : 'ghost'} variant={isActive ? 'secondary' : 'ghost'}
className={cn( className={cn(
'w-full gap-3 text-sidebar-foreground', 'w-full gap-3 text-sidebar-foreground sidebar-menu-item',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground', isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
isCollapsed ? 'justify-center px-2' : 'justify-start' isCollapsed ? 'justify-center px-2' : 'justify-start'
)} )}
@ -296,37 +302,50 @@ export function AppSidebar() {
{!isCollapsed && ( {!isCollapsed && (
<div className="px-3 py-1.5 text-xs text-sidebar-foreground/60"> <div className="px-3 py-1.5 text-xs text-sidebar-foreground/60">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Command className="h-3 w-3" /> <Command className="h-3 w-3" />
Quick actions {t.common.quickActions}
</span> </span>
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground"> <kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
{isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K {isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K
</kbd> </kbd>
</div> </div>
<p className="mt-1 text-[10px] text-sidebar-foreground/40"> <p className="mt-1 text-[10px] text-sidebar-foreground/40">
Navigation, search, ask, theme {t.common.quickActionsDesc}
</p> </p>
</div> </div>
)} )}
<div <div
className={cn( className={cn(
'flex', 'flex flex-col gap-2',
isCollapsed ? 'justify-center' : 'justify-start' isCollapsed ? 'items-center' : 'items-stretch'
)} )}
> >
{isCollapsed ? ( {isCollapsed ? (
<Tooltip> <>
<TooltipTrigger asChild> <Tooltip>
<div> <TooltipTrigger asChild>
<ThemeToggle iconOnly /> <div>
</div> <ThemeToggle iconOnly />
</TooltipTrigger> </div>
<TooltipContent side="right">Theme</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent side="right">{t.common.theme}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<LanguageToggle iconOnly />
</div>
</TooltipTrigger>
<TooltipContent side="right">{t.common.language}</TooltipContent>
</Tooltip>
</>
) : ( ) : (
<ThemeToggle /> <>
<ThemeToggle />
<LanguageToggle />
</>
)} )}
</div> </div>
@ -335,22 +354,24 @@ export function AppSidebar() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-center" className="w-full justify-center sidebar-menu-item"
onClick={logout} onClick={logout}
aria-label={t.common.signOut}
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">Sign Out</TooltipContent> <TooltipContent side="right">{t.common.signOut}</TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-3" className="w-full justify-start gap-3 sidebar-menu-item"
onClick={logout} onClick={logout}
> aria-label={t.common.signOut}
>
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Sign Out {t.common.signOut}
</Button> </Button>
)} )}
</div> </div>

View file

@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useCreateNotebook } from '@/lib/hooks/use-notebooks' import { useCreateNotebook } from '@/lib/hooks/use-notebooks'
import { useTranslation } from '@/lib/hooks/use-translation'
const createNotebookSchema = z.object({ const createNotebookSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@ -32,6 +33,7 @@ interface CreateNotebookDialogProps {
} }
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) { export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
const { t } = useTranslation()
const createNotebook = useCreateNotebook() const createNotebook = useCreateNotebook()
const { const {
register, register,
@ -65,20 +67,20 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]"> <DialogContent className="sm:max-w-[480px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Notebook</DialogTitle> <DialogTitle>{t.notebooks.createNew}</DialogTitle>
<DialogDescription> <DialogDescription>
Start organizing your research with a dedicated space for related sources and notes. {t.notebooks.createNewDesc}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notebook-name">Name *</Label> <Label htmlFor="notebook-name">{t.common.name || 'Name'} *</Label>
<Input <Input
id="notebook-name" id="notebook-name"
{...register('name')} {...register('name')}
placeholder="Enter notebook name" placeholder={t.notebooks.namePlaceholder}
autoFocus autoComplete="off"
/> />
{errors.name && ( {errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p> <p className="text-sm text-destructive">{errors.name.message}</p>
@ -86,21 +88,21 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="notebook-description">Description</Label> <Label htmlFor="notebook-description">{t.common.description}</Label>
<Textarea <Textarea
id="notebook-description" id="notebook-description"
{...register('description')} {...register('description')}
placeholder="Describe the purpose and scope of this notebook..." placeholder={t.notebooks.descPlaceholder}
rows={4} rows={4}
/> />
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={closeDialog}> <Button type="button" variant="outline" onClick={closeDialog}>
Cancel {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={!isValid || createNotebook.isPending}> <Button type="submit" disabled={!isValid || createNotebook.isPending}>
{createNotebook.isPending ? 'Creating…' : 'Create Notebook'} {createNotebook.isPending ? t.common.creating : t.notebooks.createNew}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View file

@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { getDateLocale } from '@/lib/utils/date-locale'
import { InfoIcon, Trash2 } from 'lucide-react' import { InfoIcon, Trash2 } from 'lucide-react'
import { resolvePodcastAssetUrl } from '@/lib/api/podcasts' import { resolvePodcastAssetUrl } from '@/lib/api/podcasts'
@ -31,6 +32,8 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales'
interface EpisodeCardProps { interface EpisodeCardProps {
episode: PodcastEpisode episode: PodcastEpisode
@ -38,51 +41,52 @@ interface EpisodeCardProps {
deleting?: boolean deleting?: boolean
} }
const STATUS_META: Record< const getSTATUS_META = (t: TranslationKeys): Record<
EpisodeStatus | 'unknown', EpisodeStatus | 'unknown',
{ label: string; className: string } { label: string; className: string }
> = { > => ({
running: { running: {
label: 'Processing', label: t.podcasts.processingLabel,
className: 'bg-amber-100 text-amber-800 border-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200',
}, },
processing: { processing: {
label: 'Processing', label: t.podcasts.processingLabel,
className: 'bg-amber-100 text-amber-800 border-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200',
}, },
completed: { completed: {
label: 'Completed', label: t.podcasts.completedLabel,
className: 'bg-emerald-100 text-emerald-800 border-emerald-200', className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
}, },
failed: { failed: {
label: 'Failed', label: t.podcasts.failedLabel,
className: 'bg-red-100 text-red-800 border-red-200', className: 'bg-red-100 text-red-800 border-red-200',
}, },
error: { error: {
label: 'Failed', label: t.podcasts.failedLabel,
className: 'bg-red-100 text-red-800 border-red-200', className: 'bg-red-100 text-red-800 border-red-200',
}, },
pending: { pending: {
label: 'Pending', label: t.podcasts.pendingLabel,
className: 'bg-sky-100 text-sky-800 border-sky-200', className: 'bg-sky-100 text-sky-800 border-sky-200',
}, },
submitted: { submitted: {
label: 'Pending', label: t.podcasts.pendingLabel,
className: 'bg-sky-100 text-sky-800 border-sky-200', className: 'bg-sky-100 text-sky-800 border-sky-200',
}, },
unknown: { unknown: {
label: 'Unknown', label: t.common.unknown,
className: 'bg-muted text-muted-foreground border-transparent', className: 'bg-muted text-muted-foreground border-transparent',
}, },
} })
function StatusBadge({ status }: { status?: EpisodeStatus | null }) { function StatusBadge({ status }: { status?: EpisodeStatus | null }) {
const { t } = useTranslation()
// Don't show badge for completed episodes // Don't show badge for completed episodes
if (status === 'completed') { if (status === 'completed') {
return null return null
} }
const meta = STATUS_META[status ?? 'unknown'] const meta = getSTATUS_META(t)[status ?? 'unknown']
return ( return (
<Badge <Badge
variant="outline" variant="outline"
@ -133,6 +137,7 @@ function extractTranscriptEntries(transcript: unknown): TranscriptEntry[] {
} }
export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) { export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
const { t, language } = useTranslation()
const [audioSrc, setAudioSrc] = useState<string | undefined>() const [audioSrc, setAudioSrc] = useState<string | undefined>()
const [audioError, setAudioError] = useState<string | null>(null) const [audioError, setAudioError] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false)
@ -183,7 +188,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
setAudioSrc(revokeUrl) setAudioSrc(revokeUrl)
} catch (error) { } catch (error) {
console.error('Unable to load podcast audio', error) console.error('Unable to load podcast audio', error)
setAudioError('Audio unavailable') setAudioError(t.podcasts.audioUnavailable)
setAudioSrc(undefined) setAudioSrc(undefined)
} }
} }
@ -195,14 +200,19 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
URL.revokeObjectURL(revokeUrl) URL.revokeObjectURL(revokeUrl)
} }
} }
}, [episode.audio_url, episode.audio_file]) }, [episode.audio_url, episode.audio_file, t])
const createdLabel = episode.created const distance = episode.created
? formatDistanceToNow(new Date(episode.created), { ? formatDistanceToNow(new Date(episode.created), {
addSuffix: true, addSuffix: true,
locale: getDateLocale(language),
}) })
: null : null
const createdLabel = distance
? t.podcasts.created.replace('{time}', distance)
: null
const handleDelete = () => { const handleDelete = () => {
void onDelete(episode.id) void onDelete(episode.id)
} }
@ -219,23 +229,23 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
<StatusBadge status={episode.job_status} /> <StatusBadge status={episode.job_status} />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Profile: {episode.episode_profile?.name ?? 'Unknown'} {t.podcasts.profile}: {episode.episode_profile?.name || t.common.unknown}
{createdLabel ? `Created ${createdLabel}` : ''} {createdLabel ? `${createdLabel}` : ''}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}> <Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<InfoIcon className="mr-2 h-4 w-4" /> Details <InfoIcon className="mr-2 h-4 w-4" /> {t.podcasts.details}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden"> <DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{episode.name}</DialogTitle> <DialogTitle>{episode.name}</DialogTitle>
<DialogDescription> <DialogDescription>
{episode.episode_profile?.name ?? 'Unknown profile'} {episode.episode_profile?.name || t.common.unknown}
{createdLabel ? `Created ${createdLabel}` : ''} {createdLabel ? `${createdLabel}` : ''}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 overflow-hidden"> <div className="space-y-4 overflow-hidden">
@ -247,19 +257,19 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
<Tabs defaultValue="summary" className="h-[60vh] flex flex-col"> <Tabs defaultValue="summary" className="h-[60vh] flex flex-col">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="summary">Summary</TabsTrigger> <TabsTrigger value="summary">{t.podcasts.summaryTab}</TabsTrigger>
<TabsTrigger value="outline">Outline</TabsTrigger> <TabsTrigger value="outline">{t.podcasts.outlineTab}</TabsTrigger>
<TabsTrigger value="transcript">Transcript</TabsTrigger> <TabsTrigger value="transcript">{t.podcasts.transcriptTab}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="summary" className="flex-1 overflow-hidden"> <TabsContent value="summary" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4"> <ScrollArea className="h-full pr-4">
<div className="space-y-6"> <div className="space-y-6">
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">Episode Profile</h4> <h4 className="text-sm font-semibold text-foreground">{t.podcasts.episodeProfile}</h4>
<div className="grid gap-2 text-sm md:grid-cols-2"> <div className="grid gap-2 text-sm md:grid-cols-2">
<div> <div>
<p className="text-muted-foreground">Outline Model</p> <p className="text-muted-foreground">{t.podcasts.outlineModel}</p>
<p> <p>
{episode.episode_profile?.outline_provider ?? '—'} / {episode.episode_profile?.outline_provider ?? '—'} /
{' '} {' '}
@ -267,7 +277,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">Transcript Model</p> <p className="text-muted-foreground">{t.podcasts.transcriptModel}</p>
<p> <p>
{episode.episode_profile?.transcript_provider ?? '—'} / {episode.episode_profile?.transcript_provider ?? '—'} /
{' '} {' '}
@ -275,7 +285,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">Segments</p> <p className="text-muted-foreground">{t.podcasts.segments}</p>
<p>{episode.episode_profile?.num_segments ?? '—'}</p> <p>{episode.episode_profile?.num_segments ?? '—'}</p>
</div> </div>
</div> </div>
@ -287,7 +297,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">Speaker Profile</h4> <h4 className="text-sm font-semibold text-foreground">{t.podcasts.speakerProfile}</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{episode.speaker_profile?.tts_provider ?? '—'} /{' '} {episode.speaker_profile?.tts_provider ?? '—'} /{' '}
{episode.speaker_profile?.tts_model ?? '—'} {episode.speaker_profile?.tts_model ?? '—'}
@ -298,12 +308,12 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
className="rounded-md border bg-muted/20 p-3 text-xs" className="rounded-md border bg-muted/20 p-3 text-xs"
> >
<p className="font-semibold text-foreground">{speaker.name}</p> <p className="font-semibold text-foreground">{speaker.name}</p>
<p className="text-muted-foreground">Voice ID: {speaker.voice_id}</p> <p className="text-muted-foreground">{t.podcasts.voiceId}: {speaker.voice_id}</p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground"> <p className="mt-2 whitespace-pre-wrap text-muted-foreground">
<span className="font-semibold">Backstory:</span> {speaker.backstory} <span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
</p> </p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground"> <p className="mt-2 whitespace-pre-wrap text-muted-foreground">
<span className="font-semibold">Personality:</span> {speaker.personality} <span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality}
</p> </p>
</div> </div>
))} ))}
@ -311,7 +321,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
{episode.briefing ? ( {episode.briefing ? (
<section className="space-y-2"> <section className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">Briefing</h4> <h4 className="text-sm font-semibold text-foreground">{t.podcasts.briefing}</h4>
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap"> <div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
{episode.briefing} {episode.briefing}
</div> </div>
@ -328,17 +338,17 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
{outlineSegments.map((segment, index) => ( {outlineSegments.map((segment, index) => (
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1"> <div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">{segment.name ?? `Segment ${index + 1}`}</p> <p className="font-semibold text-foreground">{segment.name ?? `${t.podcasts.segment} ${index + 1}`}</p>
{segment.size ? ( {segment.size ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge> <Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge>
) : null} ) : null}
</div> </div>
<p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? 'No description provided.'}</p> <p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? t.podcasts.noDescription}</p>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground">No outline available.</p> <p className="text-xs text-muted-foreground">{t.podcasts.noOutline}</p>
)} )}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@ -348,12 +358,12 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
{transcriptEntries.length > 0 ? ( {transcriptEntries.length > 0 ? (
transcriptEntries.map((entry, index) => ( transcriptEntries.map((entry, index) => (
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1"> <div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
<p className="font-semibold text-foreground">{entry.speaker ?? 'Speaker'}</p> <p className="font-semibold text-foreground">{entry.speaker ?? t.podcasts.speaker}</p>
<p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p> <p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p>
</div> </div>
)) ))
) : ( ) : (
<p className="text-xs text-muted-foreground">No transcript available.</p> <p className="text-xs text-muted-foreground">{t.podcasts.noTranscript}</p>
)} )}
</ScrollArea> </ScrollArea>
</TabsContent> </TabsContent>
@ -365,20 +375,20 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive"> <Button variant="ghost" size="sm" className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete {t.podcasts.delete}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete episode?</AlertDialogTitle> <AlertDialogTitle>{t.podcasts.deleteEpisodeTitle}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will remove {episode.name} and its audio file permanently. {t.podcasts.deleteEpisodeDesc.replace('{name}', episode.name)}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}> <AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'} {deleting ? t.podcasts.deleting : t.podcasts.delete}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -36,6 +36,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/lib/hooks/use-translation'
interface EpisodeProfilesPanelProps { interface EpisodeProfilesPanelProps {
episodeProfiles: EpisodeProfile[] episodeProfiles: EpisodeProfile[]
@ -55,6 +56,7 @@ export function EpisodeProfilesPanel({
speakerProfiles, speakerProfiles,
modelOptions, modelOptions,
}: EpisodeProfilesPanelProps) { }: EpisodeProfilesPanelProps) {
const { t } = useTranslation()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editProfile, setEditProfile] = useState<EpisodeProfile | null>(null) const [editProfile, setEditProfile] = useState<EpisodeProfile | null>(null)
@ -63,7 +65,7 @@ export function EpisodeProfilesPanel({
const sortedProfiles = useMemo( const sortedProfiles = useMemo(
() => () =>
[...episodeProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')), [...episodeProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
[episodeProfiles] [episodeProfiles]
) )
@ -73,25 +75,25 @@ export function EpisodeProfilesPanel({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold">Episode profiles</h2> <h2 className="text-lg font-semibold">{t.podcasts.episodeProfilesTitle}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Define reusable generation settings for your shows. {t.podcasts.episodeProfilesDesc}
</p> </p>
</div> </div>
<Button onClick={() => setCreateOpen(true)} disabled={disableCreate}> <Button onClick={() => setCreateOpen(true)} disabled={disableCreate}>
Create profile {t.podcasts.createProfile}
</Button> </Button>
</div> </div>
{disableCreate ? ( {disableCreate ? (
<p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900"> <p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900">
Create a speaker profile before adding an episode profile. {t.podcasts.createSpeakerFirst}
</p> </p>
) : null} ) : null}
{sortedProfiles.length === 0 ? ( {sortedProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
No episode profiles yet. Create one to kickstart podcast generation. {t.podcasts.noEpisodeProfiles}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@ -109,7 +111,7 @@ export function EpisodeProfilesPanel({
{profile.name} {profile.name}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || 'No description provided.'} {profile.description || t.podcasts.noDescription}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -118,7 +120,7 @@ export function EpisodeProfilesPanel({
size="sm" size="sm"
onClick={() => setEditProfile(profile)} onClick={() => setEditProfile(profile)}
> >
<Edit3 className="mr-2 h-4 w-4" /> Edit <Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit}
</Button> </Button>
<AlertDialog> <AlertDialog>
<DropdownMenu> <DropdownMenu>
@ -142,32 +144,31 @@ export function EpisodeProfilesPanel({
disabled={duplicateProfile.isPending} disabled={duplicateProfile.isPending}
> >
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Duplicate {t.podcasts.duplicate}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem className="text-destructive focus:text-destructive"> <DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete {t.podcasts.delete}
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete profile?</AlertDialogTitle> <AlertDialogTitle>{t.podcasts.deleteProfileTitle}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will remove {profile.name}. Existing episodes keep their {t.podcasts.deleteProfileDesc.replace('{name}', profile.name)}
data, but new ones will no longer use this configuration.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteProfile.mutate(profile.id)} onClick={() => deleteProfile.mutate(profile.id)}
disabled={deleteProfile.isPending} disabled={deleteProfile.isPending}
> >
{deleteProfile.isPending ? 'Deleting…' : 'Delete'} {deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -179,7 +180,7 @@ export function EpisodeProfilesPanel({
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Outline model {t.podcasts.outlineModel}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.outline_provider} / {profile.outline_model} {profile.outline_provider} / {profile.outline_model}
@ -187,7 +188,7 @@ export function EpisodeProfilesPanel({
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Transcript model {t.podcasts.transcriptModel}
</p> </p>
<p className="text-foreground"> <p className="text-foreground">
{profile.transcript_provider} / {profile.transcript_model} {profile.transcript_provider} / {profile.transcript_model}
@ -195,13 +196,13 @@ export function EpisodeProfilesPanel({
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Segments {t.podcasts.segments}
</p> </p>
<p className="text-foreground">{profile.num_segments}</p> <p className="text-foreground">{profile.num_segments}</p>
</div> </div>
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Speaker profile {t.podcasts.speakerProfile}
</p> </p>
<div className="flex items-center gap-2 text-foreground"> <div className="flex items-center gap-2 text-foreground">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
@ -218,7 +219,7 @@ export function EpisodeProfilesPanel({
{profile.default_briefing ? ( {profile.default_briefing ? (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Default briefing {t.podcasts.defaultBriefingTitle}
</p> </p>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground"> <p className="mt-1 whitespace-pre-wrap text-muted-foreground">
{profile.default_briefing} {profile.default_briefing}

View file

@ -10,31 +10,33 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog' import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
import { useTranslation } from '@/lib/hooks/use-translation'
import { TranslationKeys } from '@/lib/locales'
const STATUS_ORDER: Array<{ const getSTATUS_ORDER = (t: TranslationKeys): Array<{
key: 'running' | 'completed' | 'failed' | 'pending' key: 'running' | 'completed' | 'failed' | 'pending'
title: string title: string
description?: string description?: string
}> = [ }> => [
{ {
key: 'running', key: 'running',
title: 'Currently Processing', title: t.podcasts.statusRunningTitle,
description: 'Episodes that are actively generating assets.', description: t.podcasts.statusRunningDesc,
}, },
{ {
key: 'pending', key: 'pending',
title: 'Queued / Pending', title: t.podcasts.statusPendingTitle,
description: 'Submitted episodes waiting to start processing.', description: t.podcasts.statusPendingDesc,
}, },
{ {
key: 'completed', key: 'completed',
title: 'Completed Episodes', title: t.podcasts.statusCompletedTitle,
description: 'Ready to review, download, or publish.', description: t.podcasts.statusCompletedDesc,
}, },
{ {
key: 'failed', key: 'failed',
title: 'Failed Episodes', title: t.podcasts.statusFailedTitle,
description: 'Episodes that encountered issues during generation.', description: t.podcasts.statusFailedDesc,
}, },
] ]
@ -48,6 +50,7 @@ function SummaryBadge({ label, value }: { label: string; value: number }) {
} }
export function EpisodesTab() { export function EpisodesTab() {
const { t } = useTranslation()
const [showGenerateDialog, setShowGenerateDialog] = useState(false) const [showGenerateDialog, setShowGenerateDialog] = useState(false)
const { const {
episodes, episodes,
@ -75,14 +78,14 @@ export function EpisodesTab() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-semibold">Episodes overview</h2> <h2 className="text-xl font-semibold">{t.podcasts.overviewTitle}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Monitor podcast generation jobs and review the final artefacts. {t.podcasts.overviewDesc}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button onClick={() => setShowGenerateDialog(true)}> <Button onClick={() => setShowGenerateDialog(true)}>
Generate Podcast {t.podcasts.generateBtn}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@ -95,25 +98,25 @@ export function EpisodesTab() {
) : ( ) : (
<RefreshCcw className="mr-2 h-4 w-4" /> <RefreshCcw className="mr-2 h-4 w-4" />
)} )}
Refresh {t.common.refresh}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<SummaryBadge label="Total" value={statusCounts.total} /> <SummaryBadge label={t.podcasts.total} value={statusCounts.total} />
<SummaryBadge label="Processing" value={statusCounts.running} /> <SummaryBadge label={t.podcasts.processingLabel} value={statusCounts.running} />
<SummaryBadge label="Completed" value={statusCounts.completed} /> <SummaryBadge label={t.podcasts.completedLabel} value={statusCounts.completed} />
<SummaryBadge label="Failed" value={statusCounts.failed} /> <SummaryBadge label={t.podcasts.failedLabel} value={statusCounts.failed} />
<SummaryBadge label="Pending" value={statusCounts.pending} /> <SummaryBadge label={t.podcasts.pendingLabel} value={statusCounts.pending} />
</div> </div>
{isError ? ( {isError ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load episodes</AlertTitle> <AlertTitle>{t.podcasts.loadErrorTitle}</AlertTitle>
<AlertDescription> <AlertDescription>
We could not fetch the latest podcast episodes. Try again shortly. {t.podcasts.loadErrorDesc}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -121,20 +124,19 @@ export function EpisodesTab() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading episodes {t.podcasts.loadingEpisodes}
</div> </div>
) : null} ) : null}
{emptyState ? ( {emptyState ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center"> <div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No podcast episodes yet. Generate your first one from the notebook or source {t.podcasts.noEpisodesYet}
chat interfaces.
</p> </p>
</div> </div>
) : null} ) : null}
{STATUS_ORDER.map(({ key, title, description }) => { {getSTATUS_ORDER(t).map(({ key, title, description }) => {
const data = statusGroups[key] const data = statusGroups[key]
if (!data || data.length === 0) { if (!data || data.length === 0) {
return null return null

View file

@ -13,6 +13,7 @@ import { BuildContextRequest, NoteResponse, SourceListResponse } from '@/lib/typ
import { PodcastGenerationRequest } from '@/lib/types/podcasts' import { PodcastGenerationRequest } from '@/lib/types/podcasts'
import { QUERY_KEYS } from '@/lib/api/query-client' import { QUERY_KEYS } from '@/lib/api/query-client'
import { useToast } from '@/lib/hooks/use-toast' import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -30,10 +31,11 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { TranslationKeys } from '@/lib/locales'
const SOURCE_MODES = [ const getSourceModes = (t: TranslationKeys) => [
{ value: 'insights', label: 'Summary' }, { value: 'insights', label: t.podcasts.summary },
{ value: 'full', label: 'Full content' }, { value: 'full', label: t.podcasts.fullContent },
] as const ] as const
type SourceMode = 'off' | 'insights' | 'full' type SourceMode = 'off' | 'insights' | 'full'
@ -74,6 +76,7 @@ interface GeneratePodcastDialogProps {
} }
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) { export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
const { t, language } = useTranslation()
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([]) const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
@ -415,22 +418,22 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
const response = await chatApi.buildContext(task.payload) const response = await chatApi.buildContext(task.payload)
const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId
const contextString = JSON.stringify(response.context, null, 2) const contextString = JSON.stringify(response.context, null, 2)
const snippet = `Notebook: ${notebookName}\n${contextString}` const snippet = `${t.common.notebookLabel.replace('{name}', notebookName)}\n${contextString}`
parts.push(snippet) parts.push(snippet)
} catch (error) { } catch (error) {
console.error('Failed to build context for notebook', task.notebookId, error) console.error('Failed to build context for notebook', task.notebookId, error)
throw new Error('Failed to build context. Please review your selections.') throw new Error(t.podcasts.buildContextFailed)
} }
} }
return parts.join('\n\n') return parts.join('\n\n')
}, [notebooks, selections]) }, [notebooks, selections, t])
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
if (!selectedEpisodeProfile) { if (!selectedEpisodeProfile) {
toast({ toast({
title: 'Episode profile required', title: t.podcasts.profileRequired,
description: 'Select an episode profile before generating a podcast.', description: t.podcasts.profileRequiredDesc,
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -438,8 +441,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
if (!episodeName.trim()) { if (!episodeName.trim()) {
toast({ toast({
title: 'Episode name required', title: t.podcasts.nameRequired,
description: 'Provide a name for the episode.', description: t.podcasts.nameRequiredDesc,
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -450,8 +453,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
const content = await buildContentFromSelections() const content = await buildContentFromSelections()
if (!content.trim()) { if (!content.trim()) {
toast({ toast({
title: 'Add context', title: t.podcasts.addContext,
description: 'Select at least one source or note to include in the episode.', description: t.podcasts.addContextDesc,
variant: 'destructive', variant: 'destructive',
}) })
return return
@ -467,6 +470,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
await generatePodcast.mutateAsync(payload) await generatePodcast.mutateAsync(payload)
toast({
title: t.common.success,
description: t.podcasts.podcastTaskStarted,
})
// Delay closing dialog slightly to ensure refetch completes // Delay closing dialog slightly to ensure refetch completes
setTimeout(() => { setTimeout(() => {
onOpenChange(false) onOpenChange(false)
@ -475,8 +483,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
} catch (error) { } catch (error) {
console.error('Failed to generate podcast', error) console.error('Failed to generate podcast', error)
toast({ toast({
title: 'Podcast generation failed', title: t.podcasts.generationFailed,
description: error instanceof Error ? error.message : 'Please try again later.', description: error instanceof Error ? error.message : t.common.refreshPage,
variant: 'destructive', variant: 'destructive',
}) })
} finally { } finally {
@ -491,6 +499,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
resetState, resetState,
selectedEpisodeProfile, selectedEpisodeProfile,
toast, toast,
t,
]) ])
const isSubmitting = generatePodcast.isPending || isBuildingContext const isSubmitting = generatePodcast.isPending || isBuildingContext
@ -504,9 +513,9 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
}}> }}>
<DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden"> <DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>Generate Podcast Episode</DialogTitle> <DialogTitle>{t.podcasts.generateEpisode}</DialogTitle>
<DialogDescription> <DialogDescription>
Select the content to include and configure the episode details before generating a new podcast episode. {t.podcasts.generateEpisodeDesc}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -515,25 +524,27 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Content {t.podcasts.content}
</h3> </h3>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pick notebooks, sources, and notes to include in this episode. {t.podcasts.contentDesc}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
{selectedNotebookSummaries.reduce( {t.podcasts.itemsSelected.replace(
(acc, summary) => acc + summary.sources + summary.notes, '{count}',
0 selectedNotebookSummaries.reduce(
)}{' '} (acc, summary) => acc + summary.sources + summary.notes,
items selected 0
).toString()
)}
</Badge> </Badge>
{(tokenCount > 0 || charCount > 0) && ( {(tokenCount > 0 || charCount > 0) && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{tokenCount > 0 && `${formatNumber(tokenCount)} tokens`} {tokenCount > 0 && t.podcasts.tokens.replace('{count}', formatNumber(tokenCount))}
{tokenCount > 0 && charCount > 0 && ' / '} {tokenCount > 0 && charCount > 0 && ' / '}
{charCount > 0 && `${formatNumber(charCount)} chars`} {charCount > 0 && t.podcasts.chars.replace('{count}', formatNumber(charCount))}
</span> </span>
)} )}
</div> </div>
@ -542,11 +553,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="rounded-lg border bg-muted/30"> <div className="rounded-lg border bg-muted/30">
{notebooksQuery.isLoading ? ( {notebooksQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground"> <div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading notebooks <Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t.podcasts.loadingNotebooks}
</div> </div>
) : notebooks.length === 0 ? ( ) : notebooks.length === 0 ? (
<div className="p-6 text-sm text-muted-foreground"> <div className="p-6 text-sm text-muted-foreground">
No notebooks found. Create a notebook and add content before generating a podcast. {t.podcasts.noNotebooksFoundInPodcasts}
</div> </div>
) : ( ) : (
<ScrollArea className="h-[60vh]"> <ScrollArea className="h-[60vh]">
@ -572,6 +583,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<AccordionItem key={notebook.id} value={notebook.id}> <AccordionItem key={notebook.id} value={notebook.id}>
<div className="flex items-start gap-3 px-4 pt-3"> <div className="flex items-start gap-3 px-4 pt-3">
<Checkbox <Checkbox
id={`notebook-toggle-${notebook.id}`}
checked={isIndeterminate ? 'indeterminate' : notebookChecked} checked={isIndeterminate ? 'indeterminate' : notebookChecked}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleNotebookToggle(notebook.id, checked) handleNotebookToggle(notebook.id, checked)
@ -587,21 +599,24 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
/> />
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline"> <AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
<div className="flex w-full items-center justify-between gap-3"> <Label
htmlFor={`notebook-toggle-${notebook.id}`}
className="flex w-full items-center justify-between gap-3 pointer-events-none"
>
<div className="text-left"> <div className="text-left">
<p className="font-medium text-sm text-foreground"> <p className="font-medium text-sm text-foreground">
{notebook.name} {notebook.name}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{summary.sources + summary.notes > 0 {summary.sources + summary.notes > 0
? `${summary.sources} sources, ${summary.notes} notes` ? `${summary.sources} ${t.podcasts.sources}, ${summary.notes} ${t.podcasts.notes}`
: 'No content selected'} : t.podcasts.noContentSelected}
</p> </p>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{sources.length} sources · {notes.length} notes {sources.length} {t.podcasts.sources} · {notes.length} {t.podcasts.notes}
</Badge> </Badge>
</div> </Label>
</AccordionTrigger> </AccordionTrigger>
</div> </div>
<AccordionContent> <AccordionContent>
@ -609,7 +624,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sources {t.podcasts.sources}
</h4> </h4>
{sourcesQueries[index]?.isFetching && ( {sourcesQueries[index]?.isFetching && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
@ -617,7 +632,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
</div> </div>
{sources.length === 0 ? ( {sources.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No sources available in this notebook. {t.podcasts.noSources}
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -629,6 +644,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
className="flex items-center gap-3 rounded border bg-background px-3 py-2" className="flex items-center gap-3 rounded border bg-background px-3 py-2"
> >
<Checkbox <Checkbox
id={`source-selection-${source.id}`}
checked={mode !== 'off'} checked={mode !== 'off'}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleSourceModeChange( handleSourceModeChange(
@ -638,16 +654,19 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
) )
} }
/> />
<div className="flex flex-1 flex-col gap-1"> <Label
htmlFor={`source-selection-${source.id}`}
className="flex flex-1 flex-col gap-1 cursor-pointer"
>
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{source.title || 'Untitled source'} {source.title || t.podcasts.untitledSource}
</span> </span>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{source.asset?.url ? 'Link' : 'File'}</span> <span>{source.asset?.url ? t.podcasts.link : t.podcasts.file}</span>
<span></span> <span></span>
<span>{source.embedded ? 'Embedded' : 'Not embedded'}</span> <span>{source.embedded ? t.podcasts.embedded : t.podcasts.notEmbedded}</span>
</div> </div>
</div> </Label>
<Select <Select
value={mode === 'off' ? 'off' : mode} value={mode === 'off' ? 'off' : mode}
onValueChange={(value) => onValueChange={(value) =>
@ -660,10 +679,10 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
disabled={mode === 'off'} disabled={mode === 'off'}
> >
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[140px]">
<SelectValue placeholder="Select mode" /> <SelectValue placeholder={t.podcasts.selectMode} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{SOURCE_MODES.map((option) => ( {getSourceModes(t).map((option) => (
<SelectItem <SelectItem
key={option.value} key={option.value}
value={option.value} value={option.value}
@ -688,11 +707,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notes {t.podcasts.notes}
</h4> </h4>
{notes.length === 0 ? ( {notes.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No notes available in this notebook. {t.podcasts.noNotes}
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -704,6 +723,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
className="flex items-center gap-3 rounded border bg-background px-3 py-2" className="flex items-center gap-3 rounded border bg-background px-3 py-2"
> >
<Checkbox <Checkbox
id={`note-selection-${note.id}`}
checked={mode !== 'off'} checked={mode !== 'off'}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
handleNoteToggle( handleNoteToggle(
@ -713,14 +733,20 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
) )
} }
/> />
<div className="flex flex-1 flex-col"> <Label
htmlFor={`note-selection-${note.id}`}
className="flex flex-1 flex-col cursor-pointer"
>
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{note.title || 'Untitled note'} {note.title || t.podcasts.untitledNote}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Updated {new Date(note.updated).toLocaleString()} {t.common.updated}{' '}
{new Date(note.updated).toLocaleString(
language.startsWith('zh') ? language : 'en-US'
)}
</span> </span>
</div> </Label>
</div> </div>
) )
})} })}
@ -741,88 +767,89 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Episode Settings {t.podcasts.episodeSettings}
</h3> </h3>
{episodeProfilesQuery.isLoading ? ( {episodeProfilesQuery.isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading episode profiles <Loader2 className="h-4 w-4 animate-spin" /> {t.podcasts.loadingProfiles}
</div> </div>
) : episodeProfiles.length === 0 ? ( ) : episodeProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
No episode profiles found. Create an episode profile before generating a podcast. {t.podcasts.noProfilesFound}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="episode_profile">Episode profile</Label> <Label htmlFor="episode_profile">{t.podcasts.episodeProfile}</Label>
<Select <Select
value={episodeProfileId} value={episodeProfileId}
onValueChange={setEpisodeProfileId} onValueChange={setEpisodeProfileId}
disabled={episodeProfiles.length === 0} disabled={episodeProfiles.length === 0}
> >
<SelectTrigger id="episode_profile"> <SelectTrigger id="episode_profile">
<SelectValue placeholder="Select an episode profile" /> <SelectValue placeholder={t.podcasts.episodeProfilePlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{episodeProfiles.map((profile) => ( {episodeProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}> <SelectItem key={profile.id} value={profile.id}>
{profile.name} {t.podcasts.podcastProfiles[profile.name] ?? profile.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{selectedEpisodeProfile && ( {selectedEpisodeProfile && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Uses speaker profile <strong>{selectedEpisodeProfile.speaker_config}</strong> {t.podcasts.usesSpeakerProfile}{' '}
<strong>{selectedEpisodeProfile.speaker_config}</strong>
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="episode_name">Episode name</Label> <Label htmlFor="episode_name">{t.podcasts.episodeName}</Label>
<Input <Input
id="episode_name" id="episode_name"
name="episode_name"
value={episodeName} value={episodeName}
onChange={(event) => setEpisodeName(event.target.value)} onChange={(event) => setEpisodeName(event.target.value)}
placeholder="e.g., AI and the Future of Work" placeholder={t.podcasts.episodeNamePlaceholder}
autoComplete="off"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="instructions">Additional instructions</Label> <Label htmlFor="instructions">{t.podcasts.additionalInstructions}</Label>
<Textarea <Textarea
id="instructions" id="instructions"
name="instructions"
placeholder={t.podcasts.instructionsPlaceholder}
value={instructions} value={instructions}
onChange={(event) => setInstructions(event.target.value)} onChange={(event) => setInstructions(event.target.value)}
placeholder="Any supplemental guidance to append to the episode briefing..." className="min-h-[100px] text-xs"
rows={6} autoComplete="off"
/> />
<p className="text-xs text-muted-foreground">
These instructions will be appended to the episode profile&apos;s default briefing.
</p>
</div> </div>
</div> </div>
)} )}
</div> </div>
<Separator />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting || episodeProfiles.length === 0} disabled={isSubmitting}
className="w-full"
> >
{isSubmitting ? ( {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<> {isSubmitting ? t.podcasts.generating : t.podcasts.generate}
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating episode... </Button>
</> <Button
) : ( variant="outline"
'Generate Podcast' onClick={() => onOpenChange(false)}
)} disabled={isSubmitting}
className="w-full"
>
{t.common.cancel}
</Button> </Button>
<p className="text-xs text-muted-foreground">
The episode will appear in the Episodes list once generation starts. Refresh the list to monitor progress.
</p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -36,6 +36,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/lib/hooks/use-translation'
interface SpeakerProfilesPanelProps { interface SpeakerProfilesPanelProps {
speakerProfiles: SpeakerProfile[] speakerProfiles: SpeakerProfile[]
@ -48,6 +49,7 @@ export function SpeakerProfilesPanel({
modelOptions, modelOptions,
usage, usage,
}: SpeakerProfilesPanelProps) { }: SpeakerProfilesPanelProps) {
const { t } = useTranslation()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editProfile, setEditProfile] = useState<SpeakerProfile | null>(null) const [editProfile, setEditProfile] = useState<SpeakerProfile | null>(null)
@ -64,17 +66,17 @@ export function SpeakerProfilesPanel({
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold">Speaker profiles</h2> <h2 className="text-lg font-semibold">{t.podcasts.speakerProfilesTitle}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Configure voices and personalities for generated episodes. {t.podcasts.speakerProfilesDesc}
</p> </p>
</div> </div>
<Button onClick={() => setCreateOpen(true)}>Create speaker</Button> <Button onClick={() => setCreateOpen(true)}>{t.podcasts.createSpeaker}</Button>
</div> </div>
{sortedProfiles.length === 0 ? ( {sortedProfiles.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
No speaker profiles yet. Create one to make episode templates available. {t.podcasts.noSpeakerProfiles}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@ -91,7 +93,7 @@ export function SpeakerProfilesPanel({
{profile.name} {profile.name}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className="text-sm text-muted-foreground">
{profile.description || 'No description provided.'} {profile.description || t.podcasts.noDescription}
</CardDescription> </CardDescription>
</div> </div>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
@ -104,8 +106,8 @@ export function SpeakerProfilesPanel({
className="text-xs" className="text-xs"
> >
{usageCount > 0 {usageCount > 0
? `Used by ${usageCount} episode${usageCount === 1 ? '' : 's'}` ? (usageCount === 1 ? t.podcasts.usedByCount_one : t.podcasts.usedByCount_other.replace('{count}', usageCount.toString()))
: 'Unused'} : t.podcasts.unused}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@ -125,14 +127,14 @@ export function SpeakerProfilesPanel({
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Voice ID: {speaker.voice_id} {t.podcasts.voiceId}: {speaker.voice_id}
</span> </span>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap"> <p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">Backstory:</span> {speaker.backstory} <span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
</p> </p>
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap"> <p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
<span className="font-semibold">Personality:</span> {speaker.personality} <span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality}
</p> </p>
</div> </div>
))} ))}
@ -144,7 +146,7 @@ export function SpeakerProfilesPanel({
size="sm" size="sm"
onClick={() => setEditProfile(profile)} onClick={() => setEditProfile(profile)}
> >
<Edit3 className="mr-2 h-4 w-4" /> Edit <Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit}
</Button> </Button>
<AlertDialog> <AlertDialog>
<DropdownMenu> <DropdownMenu>
@ -168,7 +170,7 @@ export function SpeakerProfilesPanel({
disabled={duplicateProfile.isPending} disabled={duplicateProfile.isPending}
> >
<Copy className="h-4 w-4 mr-2" /> <Copy className="h-4 w-4 mr-2" />
Duplicate {t.podcasts.duplicate}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@ -177,30 +179,30 @@ export function SpeakerProfilesPanel({
disabled={deleteDisabled} disabled={deleteDisabled}
> >
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
Delete {t.podcasts.delete}
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete speaker profile?</AlertDialogTitle> <AlertDialogTitle>{t.podcasts.deleteSpeakerProfileTitle}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Deleting {profile.name} cannot be undone. {t.podcasts.deleteSpeakerProfileDesc.replace('{name}', profile.name)}
</AlertDialogDescription> </AlertDialogDescription>
{deleteDisabled ? ( {deleteDisabled ? (
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Remove this speaker from episode profiles before deleting it. {t.podcasts.deleteSpeakerDisabledHint}
</p> </p>
) : null} ) : null}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => deleteProfile.mutate(profile.id)} onClick={() => deleteProfile.mutate(profile.id)}
disabled={deleteDisabled || deleteProfile.isPending} disabled={deleteDisabled || deleteProfile.isPending}
> >
{deleteProfile.isPending ? 'Deleting…' : 'Delete'} {deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View file

@ -10,6 +10,7 @@ import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts
import { useModels } from '@/lib/hooks/use-models' import { useModels } from '@/lib/hooks/use-models'
import { Model } from '@/lib/types/models' import { Model } from '@/lib/types/models'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { useTranslation } from '@/lib/hooks/use-translation'
function modelsByProvider(models: Model[], type: Model['type']) { function modelsByProvider(models: Model[], type: Model['type']) {
return models return models
@ -24,6 +25,7 @@ function modelsByProvider(models: Model[], type: Model['type']) {
} }
export function TemplatesTab() { export function TemplatesTab() {
const { t } = useTranslation()
const { const {
episodeProfiles, episodeProfiles,
isLoading: loadingEpisodeProfiles, isLoading: loadingEpisodeProfiles,
@ -58,9 +60,9 @@ export function TemplatesTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-semibold">Templates workspace</h2> <h2 className="text-xl font-semibold">{t.podcasts.templatesWorkspaceTitle}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Build reusable episode and speaker configurations for fast podcast production. {t.podcasts.templatesWorkspaceDesc}
</p> </p>
</div> </div>
@ -72,44 +74,42 @@ export function TemplatesTab() {
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold"> <AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-primary" /> <Lightbulb className="h-4 w-4 text-primary" />
How templates power podcast generation {t.podcasts.howTemplatesPowerTitle}
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-sm text-muted-foreground"> <AccordionContent className="text-sm text-muted-foreground">
<div className="space-y-4"> <div className="space-y-4">
<p className="text-muted-foreground/90"> <p className="text-muted-foreground/90">
Templates split the podcast workflow into two reusable building blocks. Mix and match {t.podcasts.howTemplatesPowerDesc}
them whenever you generate a new episode.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">Episode profiles set the format</h4> <h4 className="font-medium text-foreground">{t.podcasts.episodeProfilesSetFormat}</h4>
<ul className="list-disc space-y-1 pl-5"> <ul className="list-disc space-y-1 pl-5">
<li>Outline the number of segments and how the story flows</li> <li>{t.podcasts.episodeProfilesList1}</li>
<li>Pick the language models used for briefing, outlining, and script writing</li> <li>{t.podcasts.episodeProfilesList2}</li>
<li>Store default briefings so every episode starts with a consistent tone</li> <li>{t.podcasts.episodeProfilesList3}</li>
</ul> </ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">Speaker profiles bring voices to life</h4> <h4 className="font-medium text-foreground">{t.podcasts.speakerProfilesBringVoices}</h4>
<ul className="list-disc space-y-1 pl-5"> <ul className="list-disc space-y-1 pl-5">
<li>Choose the text-to-speech provider and model</li> <li>{t.podcasts.speakerProfilesList1}</li>
<li>Capture personality, backstory, and pronunciation notes per speaker</li> <li>{t.podcasts.speakerProfilesList2}</li>
<li>Reuse the same host or guest voices across different episode formats</li> <li>{t.podcasts.speakerProfilesList3}</li>
</ul> </ul>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-foreground">Recommended workflow</h4> <h4 className="font-medium text-foreground">{t.podcasts.recommendedWorkflow}</h4>
<ol className="list-decimal space-y-1 pl-5"> <ol className="list-decimal space-y-1 pl-5">
<li>Create speaker profiles for each voice you need</li> <li>{t.podcasts.workflowStep1}</li>
<li>Build episode profiles that reference those speakers by name</li> <li>{t.podcasts.workflowStep2}</li>
<li>Generate podcasts by selecting the episode profile that fits the story</li> <li>{t.podcasts.workflowStep3}</li>
</ol> </ol>
<p className="text-xs text-muted-foreground/80"> <p className="text-xs text-muted-foreground/80">
Episode profiles reference speaker profiles by name, so starting with speakers avoids {t.podcasts.workflowHint}
missing voice assignments later.
</p> </p>
</div> </div>
</div> </div>
@ -120,9 +120,9 @@ export function TemplatesTab() {
{hasError ? ( {hasError ? (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load templates data</AlertTitle> <AlertTitle>{t.podcasts.failedToLoadTemplates}</AlertTitle>
<AlertDescription> <AlertDescription>
Ensure the API is running and try again. Some sections may be incomplete. {t.podcasts.failedToLoadTemplatesDesc}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -130,7 +130,7 @@ export function TemplatesTab() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading templates {t.podcasts.loadingTemplates}
</div> </div>
) : ( ) : (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">

View file

@ -10,6 +10,7 @@ import {
useCreateEpisodeProfile, useCreateEpisodeProfile,
useUpdateEpisodeProfile, useUpdateEpisodeProfile,
} from '@/lib/hooks/use-podcasts' } from '@/lib/hooks/use-podcasts'
import { useTranslation } from '@/lib/hooks/use-translation'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -30,23 +31,24 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { TranslationKeys } from '@/lib/locales'
const episodeProfileSchema = z.object({ const episodeProfileSchema = (t: TranslationKeys) => z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),
description: z.string().optional(), description: z.string().optional(),
speaker_config: z.string().min(1, 'Speaker profile is required'), speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),
outline_provider: z.string().min(1, 'Outline provider is required'), outline_provider: z.string().min(1, t.podcasts.outlineProviderRequired || 'Outline provider is required'),
outline_model: z.string().min(1, 'Outline model is required'), outline_model: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
transcript_provider: z.string().min(1, 'Transcript provider is required'), transcript_provider: z.string().min(1, t.podcasts.transcriptProviderRequired || 'Transcript provider is required'),
transcript_model: z.string().min(1, 'Transcript model is required'), transcript_model: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
default_briefing: z.string().min(1, 'Default briefing is required'), default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),
num_segments: z.number() num_segments: z.number()
.int('Must be an integer') .int(t.podcasts.segmentsInteger || 'Must be an integer')
.min(3, 'At least 3 segments') .min(3, t.podcasts.segmentsMin || 'At least 3 segments')
.max(20, 'Maximum 20 segments'), .max(20, t.podcasts.segmentsMax || 'Maximum 20 segments'),
}) })
export type EpisodeProfileFormValues = z.infer<typeof episodeProfileSchema> export type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>>
interface EpisodeProfileFormDialogProps { interface EpisodeProfileFormDialogProps {
mode: 'create' | 'edit' mode: 'create' | 'edit'
@ -65,6 +67,7 @@ export function EpisodeProfileFormDialog({
modelOptions, modelOptions,
initialData, initialData,
}: EpisodeProfileFormDialogProps) { }: EpisodeProfileFormDialogProps) {
const { t } = useTranslation()
const createProfile = useCreateEpisodeProfile() const createProfile = useCreateEpisodeProfile()
const updateProfile = useUpdateEpisodeProfile() const updateProfile = useUpdateEpisodeProfile()
@ -111,7 +114,7 @@ export function EpisodeProfileFormDialog({
watch, watch,
formState: { errors }, formState: { errors },
} = useForm<EpisodeProfileFormValues>({ } = useForm<EpisodeProfileFormValues>({
resolver: zodResolver(episodeProfileSchema), resolver: zodResolver(episodeProfileSchema(t)),
defaultValues: getDefaults(), defaultValues: getDefaults(),
}) })
@ -185,29 +188,27 @@ export function EpisodeProfileFormDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEdit ? 'Edit Episode Profile' : 'Create Episode Profile'} {isEdit ? t.podcasts.editEpisodeProfile : t.podcasts.createEpisodeProfile}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Define how episodes should be generated and which speaker configuration {t.podcasts.episodeProfileFormDesc}
they use by default.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{speakerProfiles.length === 0 ? ( {speakerProfiles.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900"> <Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>No speaker profiles available</AlertTitle> <AlertTitle>{t.podcasts.noSpeakerProfilesAvailable}</AlertTitle>
<AlertDescription> <AlertDescription>
Create a speaker profile before configuring an episode profile. {t.podcasts.noSpeakerProfilesDesc}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
{providers.length === 0 ? ( {providers.length === 0 ? (
<Alert className="bg-amber-50 text-amber-900"> <Alert className="bg-amber-50 text-amber-900 border-amber-200">
<AlertTitle>No language models available</AlertTitle> <AlertTitle>{t.podcasts.noLanguageModelsAvailable}</AlertTitle>
<AlertDescription> <AlertDescription>
Add language models in the Models section to configure outline and transcript {t.podcasts.noLanguageModelsDesc}
generation.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
@ -215,21 +216,22 @@ export function EpisodeProfileFormDialog({
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Profile name *</Label> <Label htmlFor="name">{t.podcasts.profileName} *</Label>
<Input id="name" placeholder="Tech discussion" {...register('name')} /> <Input id="name" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} />
{errors.name ? ( {errors.name ? (
<p className="text-xs text-red-600">{errors.name.message}</p> <p className="text-xs text-red-600">{errors.name.message}</p>
) : null} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="num_segments">Segments *</Label> <Label htmlFor="num_segments">{t.podcasts.segments} *</Label>
<Input <Input
id="num_segments" id="num_segments"
type="number" type="number"
min={3} min={3}
max={20} max={20}
{...register('num_segments', { valueAsNumber: true })} {...register('num_segments', { valueAsNumber: true })}
autoComplete="off"
/> />
{errors.num_segments ? ( {errors.num_segments ? (
<p className="text-xs text-red-600">{errors.num_segments.message}</p> <p className="text-xs text-red-600">{errors.num_segments.message}</p>
@ -237,12 +239,13 @@ export function EpisodeProfileFormDialog({
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t.common.description}</Label>
<Textarea <Textarea
id="description" id="description"
rows={3} rows={3}
placeholder="Short summary of when to use this profile" placeholder={t.podcasts.descriptionPlaceholder}
{...register('description')} {...register('description')}
autoComplete="off"
/> />
</div> </div>
</div> </div>
@ -250,7 +253,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Speaker configuration {t.podcasts.speakerConfig}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -259,12 +262,12 @@ export function EpisodeProfileFormDialog({
name="speaker_config" name="speaker_config"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label>Speaker profile *</Label> <Label htmlFor="speaker_config">{t.podcasts.speakerProfile} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger id="speaker_config">
<SelectValue placeholder="Select a speaker profile" /> <SelectValue placeholder={t.podcasts.selectSpeakerProfile} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent title={t.podcasts.speakerProfile}>
{speakerProfiles.map((profile) => ( {speakerProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.name}> <SelectItem key={profile.id} value={profile.name}>
{profile.name} {profile.name}
@ -285,7 +288,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Outline generation {t.podcasts.outlineGeneration}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -295,12 +298,12 @@ export function EpisodeProfileFormDialog({
name="outline_provider" name="outline_provider"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label>Provider *</Label> <Label htmlFor="outline_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger id="outline_provider">
<SelectValue placeholder="Select provider" /> <SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent title={t.models.provider}>
{providers.map((provider) => ( {providers.map((provider) => (
<SelectItem key={provider} value={provider}> <SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span> <span className="capitalize">{provider}</span>
@ -322,12 +325,12 @@ export function EpisodeProfileFormDialog({
name="outline_model" name="outline_model"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label>Model *</Label> <Label htmlFor="outline_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger id="outline_model">
<SelectValue placeholder="Select model" /> <SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent title={t.common.model}>
{availableOutlineModels.map((model) => ( {availableOutlineModels.map((model) => (
<SelectItem key={model} value={model}> <SelectItem key={model} value={model}>
{model} {model}
@ -349,7 +352,7 @@ export function EpisodeProfileFormDialog({
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Transcript generation {t.podcasts.transcriptGeneration}
</h3> </h3>
<Separator className="mt-2" /> <Separator className="mt-2" />
</div> </div>
@ -359,12 +362,12 @@ export function EpisodeProfileFormDialog({
name="transcript_provider" name="transcript_provider"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label>Provider *</Label> <Label htmlFor="transcript_provider">{t.models.provider} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger id="transcript_provider">
<SelectValue placeholder="Select provider" /> <SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent title={t.models.provider}>
{providers.map((provider) => ( {providers.map((provider) => (
<SelectItem key={provider} value={provider}> <SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span> <span className="capitalize">{provider}</span>
@ -386,12 +389,12 @@ export function EpisodeProfileFormDialog({
name="transcript_model" name="transcript_model"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-2"> <div className="space-y-2">
<Label>Model *</Label> <Label htmlFor="transcript_model">{t.common.model} *</Label>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger> <SelectTrigger id="transcript_model">
<SelectValue placeholder="Select model" /> <SelectValue placeholder={t.models.selectModelPlaceholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent title={t.common.model}>
{availableTranscriptModels.map((model) => ( {availableTranscriptModels.map((model) => (
<SelectItem key={model} value={model}> <SelectItem key={model} value={model}>
{model} {model}
@ -411,11 +414,11 @@ export function EpisodeProfileFormDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="default_briefing">Default briefing *</Label> <Label htmlFor="default_briefing">{t.podcasts.defaultBriefingTitle} *</Label>
<Textarea <Textarea
id="default_briefing" id="default_briefing"
rows={6} rows={6}
placeholder="Outline the structure, tone, and goals for this episode format" placeholder={t.podcasts.defaultBriefingPlaceholder}
{...register('default_briefing')} {...register('default_briefing')}
/> />
{errors.default_briefing ? ( {errors.default_briefing ? (
@ -431,16 +434,14 @@ export function EpisodeProfileFormDialog({
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Cancel {t.common.cancel}
</Button> </Button>
<Button type="submit" disabled={disableSubmit}> <Button type="submit" disabled={disableSubmit}>
{isSubmitting {isSubmitting
? isEdit ? t.common.saving
? 'Saving…'
: 'Creating…'
: isEdit : isEdit
? 'Save changes' ? t.common.saveChanges
: 'Create profile'} : t.podcasts.createProfile}
</Button> </Button>
</div> </div>
</form> </form>

Some files were not shown because too many files have changed in this diff Show more