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

View file

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

View file

@ -20,8 +20,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
@ -34,6 +34,7 @@ jobs:
id: claude
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# 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
# 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 \
# NOTE: gcc/g++/make removed - uv should download pre-built wheels. Add back if build fails.
# NOTE: gcc/g++/make required for some python dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
build-essential \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
@ -35,7 +37,11 @@ COPY . /app
# Install frontend dependencies and build
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
COPY frontend/ ./
RUN npm run build
# Return to app root
@ -46,7 +52,7 @@ FROM python:3.12-slim-bookworm AS runtime
# Install only runtime system dependencies (no build tools)
# 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 \
supervisor \
curl \
@ -63,8 +69,8 @@ WORKDIR /app
# Copy the virtual environment from builder stage
COPY --from=builder /app/.venv /app/.venv
# Copy the application code
COPY --from=builder /app /app
# Copy the source code (the rest)
COPY . /app
# Ensure uv uses the existing venv without attempting network operations
ENV UV_NO_SYNC=1

View file

@ -1,51 +1,39 @@
# Build stage
FROM python:3.12-slim-bookworm AS 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
# Stage 1: Frontend Builder
FROM node:20-slim AS frontend-builder
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
# Copy the rest of the frontend source
COPY frontend/ ./
# Build the frontend
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
# 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
# Install runtime system dependencies including curl for SurrealDB installation
# Add Node.js 20.x LTS for running frontend
# Install runtime dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
ffmpeg \
supervisor \
@ -57,47 +45,34 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
# Install SurrealDB
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/
# Set the working directory in the container to /app
WORKDIR /app
# Copy the virtual environment from builder stage
COPY --from=builder /app/.venv /app/.venv
# Copy backend virtualenv and source code
COPY --from=backend-builder /app/.venv /app/.venv
COPY . /app/
# Copy the application code
COPY --from=builder /app /app
# Copy built frontend from standalone output
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
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
# Setup directories and permissions
RUN mkdir -p /app/data /mydata
# Copy and make executable the wait-for-api script
COPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh
# Ensure wait-for-api script is executable
RUN chmod +x /app/scripts/wait-for-api.sh
# Expose ports for Frontend and API
EXPOSE 8502 5055
# Copy single-container supervisord configuration
# Copy supervisord configuration
COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf
# Create log directories
RUN mkdir -p /var/log/supervisor
# Runtime API URL Configuration
# The API_URL environment variable can be set at container runtime to configure
# 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 ...
# Expose ports
EXPOSE 8502 5055
# Set startup command
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View file

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

View file

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

View file

@ -23,14 +23,20 @@ class APIClient:
timeout_value = float(timeout_str)
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
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
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
self.timeout = timeout_value
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
# Add authentication header if password is set
@ -45,7 +51,7 @@ class APIClient:
"""Make HTTP request to the API."""
url = f"{self.base_url}{endpoint}"
request_timeout = timeout if timeout is not None else self.timeout
# Merge headers
headers = kwargs.get("headers", {})
headers.update(self.headers)
@ -82,20 +88,28 @@ class APIClient:
result = self._make_request("GET", "/api/notebooks", params=params)
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."""
data = {"name": name, "description": description}
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."""
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."""
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."""
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)
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."""
data = {
"name": name,
@ -157,7 +173,9 @@ class APIClient:
}
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."""
return self._make_request("DELETE", f"/api/models/{model_id}")
@ -165,7 +183,9 @@ class APIClient:
"""Get default model assignments."""
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."""
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)
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."""
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."""
return self._make_request(
"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."""
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
@ -252,7 +278,9 @@ class APIClient:
"""Get a specific note."""
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."""
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}")
# 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."""
data = {
"item_id": item_id,
@ -276,7 +306,7 @@ class APIClient:
mode: str = "existing",
include_sources: bool = True,
include_notes: bool = True,
include_insights: bool = True
include_insights: bool = True,
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""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)
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."""
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
@ -302,7 +336,9 @@ class APIClient:
"""Get all application 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."""
return self._make_request("PUT", "/api/settings", json=settings)
@ -370,21 +406,29 @@ class APIClient:
data["transformations"] = transformations
# 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]]]:
"""Get a specific source."""
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."""
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."""
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."""
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")
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."""
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."""
return self._make_request("DELETE", f"/api/insights/{insight_id}")
@ -430,7 +478,9 @@ class APIClient:
result = self._make_request("GET", "/api/episode-profiles")
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."""
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)
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."""
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."""
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")
def get_notebook_context(
self,
notebook_id: str,
context_config: Optional[Dict] = None
self, notebook_id: str, context_config: Optional[Dict] = None
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Get context for a notebook."""
result = api_client.get_notebook_context(
notebook_id=notebook_id,
context_config=context_config
notebook_id=notebook_id, context_config=context_config
)
return result
# Global service instance
context_service = ContextService()
context_service = ContextService()

View file

@ -15,11 +15,13 @@ class EmbeddingService:
def __init__(self):
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."""
result = api_client.embed_content(item_id=item_id, item_type=item_type)
return result
# Global service instance
embedding_service = EmbeddingService()
embedding_service = EmbeddingService()

View file

@ -12,10 +12,10 @@ from open_notebook.podcasts.models import EpisodeProfile
class EpisodeProfilesService:
"""Service layer for episode profiles operations using API."""
def __init__(self):
logger.info("Using API for episode profiles operations")
def get_all_episode_profiles(self) -> List[EpisodeProfile]:
"""Get all episode profiles."""
profiles_data = api_client.get_episode_profiles()
@ -31,16 +31,20 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"]
num_segments=profile_data["num_segments"],
)
profile.id = profile_data["id"]
profiles.append(profile)
return profiles
def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
"""Get a specific episode profile by 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(
name=profile_data["name"],
description=profile_data.get("description", ""),
@ -50,11 +54,11 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"]
num_segments=profile_data["num_segments"],
)
profile.id = profile_data["id"]
return profile
def create_episode_profile(
self,
name: str,
@ -79,7 +83,11 @@ class EpisodeProfilesService:
default_briefing=default_briefing,
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(
name=profile_data["name"],
description=profile_data.get("description", ""),
@ -89,11 +97,11 @@ class EpisodeProfilesService:
transcript_provider=profile_data["transcript_provider"],
transcript_model=profile_data["transcript_model"],
default_briefing=profile_data["default_briefing"],
num_segments=profile_data["num_segments"]
num_segments=profile_data["num_segments"],
)
profile.id = profile_data["id"]
return profile
def delete_episode_profile(self, profile_id: str) -> bool:
"""Delete an episode profile."""
api_client.delete_episode_profile(profile_id)
@ -101,4 +109,4 @@ class EpisodeProfilesService:
# 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:
"""Service layer for insights operations using API."""
def __init__(self):
logger.info("Using API for insights operations")
def get_source_insights(self, source_id: str) -> List[SourceInsight]:
"""Get all insights for a specific source."""
insights_data = api_client.get_source_insights(source_id)
@ -31,11 +31,15 @@ class InsightsService:
insight.updated = insight_data["updated"]
insights.append(insight)
return insights
def get_insight(self, insight_id: str) -> SourceInsight:
"""Get a specific insight."""
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_type=insight_data["insight_type"],
content=insight_data["content"],
@ -45,16 +49,20 @@ class InsightsService:
insight.updated = insight_data["updated"]
# Note: source_id from API response is not stored; use await insight.get_source() if needed
return insight
def delete_insight(self, insight_id: str) -> bool:
"""Delete a specific insight."""
api_client.delete_insight(insight_id)
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."""
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(
title=note_data["title"],
content=note_data["content"],
@ -64,11 +72,19 @@ class InsightsService:
note.created = note_data["created"]
note.updated = note_data["updated"]
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."""
insight_response = api_client.create_source_insight(source_id, transformation_id, model_id)
insight_data = insight_response if isinstance(insight_response, dict) else insight_response[0]
insight_response = api_client.create_source_insight(
source_id, transformation_id, model_id
)
insight_data = (
insight_response
if isinstance(insight_response, dict)
else insight_response[0]
)
insight = SourceInsight(
insight_type=insight_data["insight_type"],
content=insight_data["content"],
@ -81,4 +97,4 @@ class InsightsService:
# 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
try:
logger.info("Commands imported in API process")
except Exception as 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...")
await migration_manager.run_migration_up()
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:
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:
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
logger.exception(e)
@ -88,7 +91,18 @@ app = FastAPI(
# Add password authentication middleware first
# 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)
app.add_middleware(
@ -119,7 +133,7 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
status_code=exc.status_code,
content={"detail": exc.detail},
headers={
"Access-Control-Allow-Origin": origin,
**(exc.headers or {}), "Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*",
"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(notes.router, prefix="/api", tags=["notes"])
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(context.router, prefix="/api", tags=["context"])
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:
"""Service layer for models operations using API."""
def __init__(self):
logger.info("Using API for models operations")
def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
"""Get all models with optional type filtering."""
models_data = api_client.get_models(model_type=model_type)
@ -32,7 +32,7 @@ class ModelsService:
model.updated = model_data["updated"]
models.append(model)
return models
def create_model(self, name: str, provider: str, model_type: str) -> Model:
"""Create a new model."""
response = api_client.create_model(name, provider, model_type)
@ -46,12 +46,12 @@ class ModelsService:
model.created = model_data["created"]
model.updated = model_data["updated"]
return model
def delete_model(self, model_id: str) -> bool:
"""Delete a model."""
api_client.delete_model(model_id)
return True
def get_default_models(self) -> DefaultModels:
"""Get default model assignments."""
response = api_client.get_default_models()
@ -60,15 +60,21 @@ class ModelsService:
# Set the values from API response
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.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model")
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model")
defaults.default_text_to_speech_model = defaults_data.get(
"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_tools_model = defaults_data.get("default_tools_model")
return defaults
def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
"""Update default model assignments."""
updates = {
@ -86,10 +92,16 @@ class ModelsService:
# Update the defaults object with the response
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.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model")
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model")
defaults.default_text_to_speech_model = defaults_data.get(
"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_tools_model = defaults_data.get("default_tools_model")
@ -97,4 +109,4 @@ class ModelsService:
# Global service instance
models_service = ModelsService()
models_service = ModelsService()

View file

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

View file

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

View file

@ -20,5 +20,7 @@ async def get_auth_status():
return {
"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()
# Request/Response models
class CreateSessionRequest(BaseModel):
notebook_id: str = Field(..., description="Notebook ID to create session for")
@ -134,7 +135,8 @@ async def create_session(request: CreateSessionRequest):
# Create new session
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,
)
await session.save()
@ -334,9 +336,7 @@ async def execute_chat(request: ExecuteChatRequest):
# Get current state
current_state = chat_graph.get_state(
config=RunnableConfig(
configurable={"thread_id": request.session_id}
)
config=RunnableConfig(configurable={"thread_id": request.session_id})
)
# Prepare state for execution

View file

@ -9,16 +9,21 @@ from api.command_service import CommandService
router = APIRouter()
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')")
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
class CommandJobResponse(BaseModel):
job_id: str
status: str
message: str
class CommandJobStatusResponse(BaseModel):
job_id: str
status: str
@ -28,19 +33,20 @@ class CommandJobStatusResponse(BaseModel):
updated: Optional[str] = None
progress: Optional[Dict[str, Any]] = None
@router.post("/commands/jobs", response_model=CommandJobResponse)
async def execute_command(request: CommandExecutionRequest):
"""
Submit a command for background processing.
Returns immediately with job ID for status tracking.
Example request:
{
"command": "process_text",
"app": "open_notebook",
"input": {
"text": "Hello world",
"operation": "uppercase"
"app": "open_notebook",
"input": {
"text": "Hello world",
"operation": "uppercase"
}
}
"""
@ -49,91 +55,91 @@ async def execute_command(request: CommandExecutionRequest):
job_id = await CommandService.submit_command_job(
module_name=request.app, # This should be "open_notebook"
command_name=request.command,
command_args=request.input
command_args=request.input,
)
return CommandJobResponse(
job_id=job_id,
status="submitted",
message=f"Command '{request.command}' submitted successfully"
message=f"Command '{request.command}' submitted successfully",
)
except Exception as e:
logger.error(f"Error submitting command: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to submit command: {str(e)}"
status_code=500, detail="Failed to submit command"
)
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
async def get_command_job_status(job_id: str):
"""Get the status of a specific command job"""
try:
status_data = await CommandService.get_command_status(job_id)
return CommandJobStatusResponse(**status_data)
except Exception as e:
logger.error(f"Error fetching job status: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch job status: {str(e)}"
status_code=500, detail="Failed to fetch job status"
)
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
async def list_command_jobs(
command_filter: Optional[str] = Query(None, description="Filter by command name"),
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"""
try:
jobs = await CommandService.list_command_jobs(
command_filter=command_filter,
status_filter=status_filter,
limit=limit
command_filter=command_filter, status_filter=status_filter, limit=limit
)
return jobs
except Exception as e:
logger.error(f"Error listing command jobs: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to list command jobs: {str(e)}"
status_code=500, detail="Failed to list command jobs"
)
@router.delete("/commands/jobs/{job_id}")
async def cancel_command_job(job_id: str):
"""Cancel a running command job"""
try:
success = await CommandService.cancel_command_job(job_id)
return {"job_id": job_id, "cancelled": success}
except Exception as e:
logger.error(f"Error cancelling command job: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to cancel command job: {str(e)}"
status_code=500, detail="Failed to cancel command job"
)
@router.get("/commands/registry/debug")
async def debug_registry():
"""Debug endpoint to see what commands are registered"""
try:
# Get all registered commands
all_items = registry.get_all_commands()
# Create JSON-serializable data
command_items = []
for item in all_items:
try:
command_items.append({
"app_id": item.app_id,
"name": item.name,
"full_id": f"{item.app_id}.{item.name}"
})
command_items.append(
{
"app_id": item.app_id,
"name": item.name,
"full_id": f"{item.app_id}.{item.name}",
}
)
except Exception as item_error:
logger.error(f"Error processing item: {item_error}")
# Get the basic command structure
try:
commands_dict: dict[str, list[str]] = {}
@ -143,18 +149,18 @@ async def debug_registry():
commands_dict[item.app_id].append(item.name)
except Exception:
commands_dict = {}
return {
"total_commands": len(all_items),
"commands_by_app": commands_dict,
"command_items": command_items
"command_items": command_items,
}
except Exception as e:
logger.error(f"Error debugging registry: {str(e)}")
return {
"error": str(e),
"total_commands": 0,
"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.utils.version_utils import (
compare_versions,
get_version_from_github,
get_version_from_github_async,
)
router = APIRouter()
@ -40,7 +40,7 @@ def get_version() -> str:
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.
@ -66,12 +66,13 @@ def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool
logger.info("Checking for latest version from GitHub...")
# Fetch latest version from GitHub with 10-second timeout
latest_version = get_version_from_github(
"https://github.com/lfnovo/open-notebook",
"main"
latest_version = await get_version_from_github_async(
"https://github.com/lfnovo/open-notebook", "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
has_update = compare_versions(current_version, latest_version) < 0
@ -107,10 +108,7 @@ async def check_database_health() -> dict:
"""
try:
# 2-second timeout for database health check
result = await asyncio.wait_for(
repo_query("RETURN 1"),
timeout=2.0
)
result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
if result:
return {"status": "online"}
return {"status": "offline", "error": "Empty result"}
@ -142,7 +140,7 @@ async def get_config(request: Request):
has_update = False
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:
# Extra safety: ensure version check never breaks the config endpoint
logger.error(f"Unexpected error during version check: {e}")

View file

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

View file

@ -88,7 +88,11 @@ async def embed_content(embed_request: EmbedRequest):
message = "Note embedded successfully"
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:

View file

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

View file

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

View file

@ -1,4 +1,3 @@
from fastapi import APIRouter, HTTPException
from loguru import logger
@ -16,10 +15,10 @@ async def get_insight(insight_id: str):
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
# Get source ID from the insight relationship
source = await insight.get_source()
return SourceInsightResponse(
id=insight.id or "",
source_id=source.id or "",
@ -32,7 +31,7 @@ async def get_insight(insight_id: str):
raise
except Exception as 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}")
@ -42,15 +41,15 @@ async def delete_insight(insight_id: str):
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
await insight.delete()
return {"message": "Insight deleted successfully"}
except HTTPException:
raise
except Exception as 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)
@ -60,10 +59,10 @@ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
insight = await SourceInsight.get(insight_id)
if not insight:
raise HTTPException(status_code=404, detail="Insight not found")
# Use the existing save_as_note method from the domain model
note = await insight.save_as_note(request.notebook_id)
return NoteResponse(
id=note.id or "",
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))
except Exception as 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])
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."""
try:
@ -69,7 +69,7 @@ async def get_models(
models = await Model.get_models_by_type(type)
else:
models = await Model.get_all()
return [
ModelResponse(
id=model.id,
@ -95,19 +95,24 @@ async def create_model(model_data: ModelCreate):
if model_data.type not in valid_types:
raise HTTPException(
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)
from open_notebook.database.repository import 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",
{"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:
raise HTTPException(
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(
@ -141,9 +146,9 @@ async def delete_model(model_id: str):
model = await Model.get(model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
await model.delete()
return {"message": "Model deleted successfully"}
except HTTPException:
raise
@ -169,7 +174,9 @@ async def get_default_models():
)
except Exception as 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)
@ -177,23 +184,29 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
"""Update default model assignments."""
try:
defaults = await DefaultModels.get_instance()
# Update only provided fields
if defaults_data.default_chat_model is not None:
defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined]
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:
defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined]
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:
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:
defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined]
if defaults_data.default_tools_model is not None:
defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined]
await defaults.update()
# 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
except Exception as 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)
@ -252,7 +267,7 @@ async def get_provider_availability():
or _check_openai_compatible_support("TTS")
),
}
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]
@ -275,13 +290,19 @@ async def get_provider_availability():
# Special handling for openai-compatible to check mode-specific availability
if provider == "openai-compatible":
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):
supported_types[provider].append(model_type)
# Special handling for azure to check mode-specific availability
elif provider == "azure":
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):
supported_types[provider].append(model_type)
else:
@ -289,12 +310,14 @@ async def get_provider_availability():
for model_type, providers in esperanto_available.items():
if provider in providers:
supported_types[provider].append(model_type)
return ProviderAvailabilityResponse(
available=available_providers,
unavailable=unavailable_providers,
supported_types=supported_types
supported_types=supported_types,
)
except Exception as 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])
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."""
try:
if notebook_id:
# Get notes for a specific notebook
from open_notebook.domain.notebook import Notebook
notebook = await Notebook.get(notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
@ -26,7 +27,7 @@ async def get_notes(
else:
# Get all notes
notes = await Note.get_all(order_by="updated desc")
return [
NoteResponse(
id=note.id or "",
@ -53,21 +54,24 @@ async def create_note(note_data: NoteCreate):
title = note_data.title
if not title and note_data.note_type == "ai" and note_data.content:
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"
result = await prompt_graph.ainvoke(
{ # type: ignore[arg-type]
"input_text": note_data.content,
"prompt": prompt
"prompt": prompt,
}
)
title = result.get("output", "Untitled Note")
# Validate note_type
note_type: Optional[Literal["human", "ai"]] = None
if note_data.note_type in ("human", "ai"):
note_type = note_data.note_type # type: ignore[assignment]
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(
title=title,
@ -75,15 +79,16 @@ async def create_note(note_data: NoteCreate):
note_type=note_type,
)
await new_note.save()
# Add to notebook if specified
if note_data.notebook_id:
from open_notebook.domain.notebook import Notebook
notebook = await Notebook.get(note_data.notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
await new_note.add_to_notebook(note_data.notebook_id)
return NoteResponse(
id=new_note.id or "",
title=new_note.title,
@ -108,7 +113,7 @@ async def get_note(note_id: str):
note = await Note.get(note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return NoteResponse(
id=note.id or "",
title=note.title,
@ -131,7 +136,7 @@ async def update_note(note_id: str, note_update: NoteUpdate):
note = await Note.get(note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
# Update only provided fields
if note_update.title is not None:
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"):
note.note_type = note_update.note_type # type: ignore[assignment]
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()
@ -169,12 +176,12 @@ async def delete_note(note_id: str):
note = await Note.get(note_id)
if not note:
raise HTTPException(status_code=404, detail="Note not found")
await note.delete()
return {"message": "Note deleted successfully"}
except HTTPException:
raise
except Exception as 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:
logger.error(f"Error generating podcast: {str(e)}")
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:
logger.error(f"Error fetching podcast job status: {str(e)}")
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
if not episode.command and not episode.audio_file:
continue
# Get job status if available
job_status = None
if episode.command:
@ -132,7 +132,7 @@ async def list_podcast_episodes():
except Exception as e:
logger.error(f"Error listing podcast episodes: {str(e)}")
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:
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")
@ -187,7 +187,7 @@ async def stream_podcast_episode_audio(episode_id: str):
raise
except Exception as 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:
raise HTTPException(status_code=404, detail="Episode has no audio file")
@ -209,7 +209,7 @@ async def delete_podcast_episode(episode_id: str):
try:
# Get the episode first to check if it exists and get the audio file path
episode = await PodcastService.get_episode(episode_id)
# Delete the physical audio file if it exists
if 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}")
except Exception as e:
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
# Delete the episode from the database
await episode.delete()
logger.info(f"Deleted podcast episode: {episode_id}")
return {"message": "Episode deleted successfully", "episode_id": episode_id}
except Exception as 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:
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)
@ -36,30 +38,35 @@ async def update_settings(settings_update: SettingsUpdate):
if settings_update.default_content_processing_engine_doc is not None:
# Cast to proper literal type
from typing import Literal, cast
settings.default_content_processing_engine_doc = cast(
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:
from typing import Literal, cast
settings.default_content_processing_engine_url = cast(
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:
from typing import Literal, cast
settings.default_embedding_option = cast(
Literal["ask", "always", "never"],
settings_update.default_embedding_option
settings_update.default_embedding_option,
)
if settings_update.auto_delete_files is not None:
from typing import Literal, cast
settings.auto_delete_files = cast(
Literal["yes", "no"],
settings_update.auto_delete_files
Literal["yes", "no"], settings_update.auto_delete_files
)
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()
@ -76,4 +83,6 @@ async def update_settings(settings_update: SettingsUpdate):
raise HTTPException(status_code=400, detail=str(e))
except Exception as 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()
# Request/Response models
class CreateSourceChatSessionRequest(BaseModel):
source_id: str = Field(..., description="Source ID to create chat session for")
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):
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):
id: str = Field(..., description="Message ID")
@ -34,56 +41,81 @@ class ChatMessage(BaseModel):
content: str = Field(..., description="Message content")
timestamp: Optional[str] = Field(None, description="Message timestamp")
class ContextIndicator(BaseModel):
sources: List[str] = Field(default_factory=list, description="Source 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")
sources: List[str] = Field(
default_factory=list, description="Source 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):
id: str = Field(..., description="Session ID")
title: str = Field(..., description="Session title")
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")
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):
messages: List[ChatMessage] = Field(default_factory=list, description="Session messages")
context_indicators: Optional[ContextIndicator] = Field(None, description="Context indicators from last response")
messages: List[ChatMessage] = Field(
default_factory=list, description="Session messages"
)
context_indicators: Optional[ContextIndicator] = Field(
None, description="Context indicators from last response"
)
class SendMessageRequest(BaseModel):
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):
success: bool = Field(True, description="Operation success status")
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(
request: CreateSourceChatSessionRequest,
source_id: str = Path(..., description="Source ID")
source_id: str = Path(..., description="Source ID"),
):
"""Create a new chat session for a source."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# Create new session with model_override support
session = ChatSession(
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()
# Relate session to source using "refers_to" relation
await session.relate("refers_to", full_source_id)
return SourceChatSessionResponse(
id=session.id or "",
title=session.title or "Untitled Session",
@ -91,33 +123,37 @@ async def create_source_chat_session(
model_override=session.model_override,
created=str(session.created),
updated=str(session.updated),
message_count=0
message_count=0,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source not found")
except Exception as 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])
async def get_source_chat_sessions(
source_id: str = Path(..., description="Source ID")
):
@router.get(
"/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]
)
async def get_source_chat_sessions(source_id: str = Path(..., description="Source ID")):
"""Get all chat sessions for a source."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# Get sessions that refer to this source - first get relations, then sessions
relations = await repo_query(
"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 = []
for relation in relations:
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}")
if session_result and len(session_result) > 0:
session_data = session_result[0]
sessions.append(SourceChatSessionResponse(
id=session_data.get("id") or "",
title=session_data.get("title") or "Untitled Session",
source_id=source_id,
model_override=session_data.get("model_override"),
created=str(session_data.get("created")),
updated=str(session_data.get("updated")),
message_count=0 # TODO: Add message count if needed
))
sessions.append(
SourceChatSessionResponse(
id=session_data.get("id") or "",
title=session_data.get("title") or "Untitled Session",
source_id=source_id,
model_override=session_data.get("model_override"),
created=str(session_data.get("created")),
updated=str(session_data.get("updated")),
message_count=0, # TODO: Add message count if needed
)
)
# Sort sessions by created date (newest first)
sessions.sort(key=lambda x: x.created, reverse=True)
return sessions
@ -142,183 +180,232 @@ async def get_source_chat_sessions(
raise HTTPException(status_code=404, detail="Source not found")
except Exception as 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(
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."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# 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)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source
relation_query = await repo_query(
"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:
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
thread_state = source_chat_graph.get_state(
config=RunnableConfig(configurable={"thread_id": session_id})
)
# Extract messages from state
messages: list[ChatMessage] = []
context_indicators = None
if thread_state and thread_state.values:
# Extract messages
if "messages" in thread_state.values:
for msg in thread_state.values["messages"]:
messages.append(ChatMessage(
id=getattr(msg, 'id', f"msg_{len(messages)}"),
type=msg.type if hasattr(msg, 'type') else 'unknown',
content=msg.content if hasattr(msg, 'content') else str(msg),
timestamp=None # LangChain messages don't have timestamps by default
))
messages.append(
ChatMessage(
id=getattr(msg, "id", f"msg_{len(messages)}"),
type=msg.type if hasattr(msg, "type") else "unknown",
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
if "context_indicators" in thread_state.values:
context_data = thread_state.values["context_indicators"]
context_indicators = ContextIndicator(
sources=context_data.get("sources", []),
insights=context_data.get("insights", []),
notes=context_data.get("notes", [])
notes=context_data.get("notes", []),
)
return SourceChatSessionWithMessagesResponse(
id=session.id or "",
title=session.title or "Untitled Session",
source_id=source_id,
model_override=getattr(session, 'model_override', None),
model_override=getattr(session, "model_override", None),
created=str(session.created),
updated=str(session.updated),
message_count=len(messages),
messages=messages,
context_indicators=context_indicators
context_indicators=context_indicators,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as 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(
request: UpdateSourceChatSessionRequest,
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."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# 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)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source
relation_query = await repo_query(
"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:
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
if request.title is not None:
session.title = request.title
if request.model_override is not None:
session.model_override = request.model_override
await session.save()
return SourceChatSessionResponse(
id=session.id or "",
title=session.title or "Untitled Session",
source_id=source_id,
model_override=getattr(session, 'model_override', None),
model_override=getattr(session, "model_override", None),
created=str(session.created),
updated=str(session.updated),
message_count=0
message_count=0,
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as 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(
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."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# 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)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source
relation_query = await repo_query(
"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:
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()
return SuccessResponse(
success=True,
message="Source chat session deleted successfully"
success=True, message="Source chat session deleted successfully"
)
except NotFoundError:
raise HTTPException(status_code=404, detail="Source or session not found")
except Exception as 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(
session_id: str,
source_id: str,
message: str,
model_override: Optional[str] = None
session_id: str, source_id: str, message: str, model_override: Optional[str] = None
) -> AsyncGenerator[str, None]:
"""Stream the source chat response as Server-Sent Events."""
try:
@ -326,59 +413,52 @@ async def stream_source_chat_response(
current_state = source_chat_graph.get_state(
config=RunnableConfig(configurable={"thread_id": session_id})
)
# Prepare state for execution
state_values = current_state.values if current_state else {}
state_values["messages"] = state_values.get("messages", [])
state_values["source_id"] = source_id
state_values["model_override"] = model_override
# Add user message to state
user_message = HumanMessage(content=message)
state_values["messages"].append(user_message)
# Send user message event
user_event = {
"type": "user_message",
"content": message,
"timestamp": None
}
user_event = {"type": "user_message", "content": message, "timestamp": None}
yield f"data: {json.dumps(user_event)}\n\n"
# Execute source chat graph synchronously (like notebook chat does)
result = source_chat_graph.invoke(
input=state_values, # type: ignore[arg-type]
config=RunnableConfig(
configurable={
"thread_id": session_id,
"model_id": model_override
}
)
configurable={"thread_id": session_id, "model_id": model_override}
),
)
# Stream the complete AI response
if "messages" in result:
for msg in result["messages"]:
if hasattr(msg, 'type') and msg.type == 'ai':
if hasattr(msg, "type") and msg.type == "ai":
ai_event = {
"type": "ai_message",
"content": msg.content if hasattr(msg, 'content') else str(msg),
"timestamp": None
"type": "ai_message",
"content": msg.content if hasattr(msg, "content") else str(msg),
"timestamp": None,
}
yield f"data: {json.dumps(ai_event)}\n\n"
# Stream context indicators
if "context_indicators" in result:
context_event = {
"type": "context_indicators",
"data": result["context_indicators"]
"data": result["context_indicators"],
}
yield f"data: {json.dumps(context_event)}\n\n"
# Send completion signal
completion_event = {"type": "complete"}
yield f"data: {json.dumps(completion_event)}\n\n"
except Exception as e:
logger.error(f"Error in source chat streaming: {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(
request: SendMessageRequest,
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."""
try:
# 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)
if not source:
raise HTTPException(status_code=404, detail="Source not found")
# 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)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session is related to this source
relation_query = await repo_query(
"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:
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:
raise HTTPException(status_code=400, detail="Message content is required")
# 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
await session.save()
# Return streaming response
return StreamingResponse(
stream_source_chat_response(
session_id=session_id,
source_id=full_source_id,
message=request.message,
model_override=model_override
model_override=model_override,
),
media_type="text/plain",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "text/plain; charset=utf-8"
}
"Content-Type": "text/plain; charset=utf-8",
},
)
except HTTPException:
raise
except Exception as 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:
transformations_list = json.loads(transformations)
except json.JSONDecodeError:
logger.error(
f"Invalid JSON in transformations field: {transformations}"
)
logger.error(f"Invalid JSON in transformations field: {transformations}")
raise ValueError("Invalid JSON in transformations field")
# Create SourceCreate instance
@ -152,18 +150,26 @@ def parse_source_form_data(
@router.get("/sources", response_model=List[SourceListResponse])
async def get_sources(
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"),
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)"),
):
"""Get sources with pagination and sorting support."""
try:
# Validate sort parameters
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"]:
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
order_clause = f"ORDER BY {sort_by} {sort_order.upper()}"
@ -185,11 +191,12 @@ async def get_sources(
LIMIT $limit START $offset
"""
result = await repo_query(
query, {
query,
{
"notebook_id": ensure_record_id(notebook_id),
"limit": limit,
"offset": offset
}
"offset": offset,
},
)
else:
# Query all sources - include command field
@ -272,8 +279,14 @@ async def get_sources(
if status_obj:
status = status_obj.status
# Extract execution metadata from nested result structure
result_data: dict[str, Any] | None = getattr(status_obj, "result", None)
execution_metadata: dict[str, Any] = result_data.get("execution_metadata", {}) if isinstance(result_data, dict) else {}
result_data: dict[str, Any] | None = getattr(
status_obj, "result", None
)
execution_metadata: dict[str, Any] = (
result_data.get("execution_metadata", {})
if isinstance(result_data, dict)
else {}
)
processing_info = {
"started_at": execution_metadata.get("started_at"),
"completed_at": execution_metadata.get("completed_at"),
@ -327,7 +340,7 @@ async def create_source(
try:
# 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)
if not notebook:
raise HTTPException(
@ -399,7 +412,7 @@ async def create_source(
# Add source to notebooks immediately so it appears in the UI
# 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)
try:
@ -478,7 +491,7 @@ async def create_source(
# Add source to notebooks immediately so it appears in the UI
# 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)
# Execute command synchronously
@ -517,9 +530,7 @@ async def create_source(
# Get the processed source
if not source.id:
raise HTTPException(
status_code=500, detail="Source ID is missing"
)
raise HTTPException(status_code=500, detail="Source ID is missing")
processed_source = await Source.get(source.id)
if not processed_source:
raise HTTPException(
@ -657,9 +668,11 @@ async def get_source(source_id: str):
# Get associated notebooks
notebooks_query = await repo_query(
"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(
id=source.id or "",

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@
Settings service layer using API.
"""
from loguru import logger
from api.client import api_client
@ -11,26 +10,36 @@ from open_notebook.domain.content_settings import ContentSettings
class SettingsService:
"""Service layer for settings operations using API."""
def __init__(self):
logger.info("Using API for settings operations")
def get_settings(self) -> ContentSettings:
"""Get application 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
settings = ContentSettings(
default_content_processing_engine_doc=settings_data.get("default_content_processing_engine_doc"),
default_content_processing_engine_url=settings_data.get("default_content_processing_engine_url"),
default_content_processing_engine_doc=settings_data.get(
"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"),
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
def update_settings(self, settings: ContentSettings) -> ContentSettings:
"""Update application settings."""
updates = {
@ -42,17 +51,29 @@ class SettingsService:
}
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
settings.default_content_processing_engine_doc = settings_data.get("default_content_processing_engine_doc")
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.default_content_processing_engine_doc = settings_data.get(
"default_content_processing_engine_doc"
)
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.youtube_preferred_languages = settings_data.get("youtube_preferred_languages")
settings.youtube_preferred_languages = settings_data.get(
"youtube_preferred_languages"
)
return settings
# Global service instance
settings_service = SettingsService()
settings_service = SettingsService()

View file

@ -14,6 +14,7 @@ from open_notebook.domain.notebook import Asset, Source
@dataclass
class SourceProcessingResult:
"""Result of source creation with optional async processing info."""
source: Source
is_async: bool = False
command_id: Optional[str] = None
@ -24,38 +25,39 @@ class SourceProcessingResult:
@dataclass
class SourceWithMetadata:
"""Source object with additional metadata from API."""
source: Source
embedded_chunks: int
# Expose common source properties for easy access
@property
def id(self):
return self.source.id
@property
@property
def title(self):
return self.source.title
@title.setter
def title(self, value):
self.source.title = value
@property
def topics(self):
return self.source.topics
@property
def asset(self):
return self.source.asset
@property
def full_text(self):
return self.source.full_text
@property
def created(self):
return self.source.created
@property
def updated(self):
return self.source.updated
@ -67,7 +69,9 @@ class SourcesService:
def __init__(self):
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."""
sources_data = api_client.get_sources(notebook_id=notebook_id)
# Convert API response to SourceWithMetadata objects
@ -88,11 +92,10 @@ class SourcesService:
source.id = source_data["id"]
source.created = source_data["created"]
source.updated = source_data["updated"]
# Wrap in SourceWithMetadata
source_with_metadata = SourceWithMetadata(
source=source,
embedded_chunks=source_data.get("embedded_chunks", 0)
source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
)
sources.append(source_with_metadata)
return sources
@ -119,8 +122,7 @@ class SourcesService:
source.updated = source_data["updated"]
return SourceWithMetadata(
source=source,
embedded_chunks=source_data.get("embedded_chunks", 0)
source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
)
def create_source(
@ -139,7 +141,7 @@ class SourcesService:
) -> Union[Source, SourceProcessingResult]:
"""
Create a new source with support for async processing.
Args:
notebook_id: Single notebook ID (deprecated, use notebooks parameter)
source_type: Type of source (link, upload, text)
@ -152,7 +154,7 @@ class SourcesService:
delete_source: Whether to delete uploaded file after processing
notebooks: List of notebook IDs to add source to (preferred over notebook_id)
async_processing: Whether to process source asynchronously
Returns:
Source object for sync processing (backward compatibility)
SourceProcessingResult for async processing (contains additional metadata)
@ -193,9 +195,15 @@ class SourcesService:
source.updated = response_data["updated"]
# 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
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 SourceProcessingResult(
source=source,
@ -228,7 +236,7 @@ class SourcesService:
) -> SourceProcessingResult:
"""
Create a new source with async processing enabled.
This is a convenience method that always uses async processing.
Returns a SourceProcessingResult with processing status information.
"""
@ -245,7 +253,7 @@ class SourcesService:
delete_source=delete_source,
async_processing=True,
)
# Since we forced async_processing=True, this should always be a SourceProcessingResult
if isinstance(result, SourceProcessingResult):
return result
@ -259,14 +267,18 @@ class SourcesService:
def is_source_processing_complete(self, source_id: str) -> bool:
"""
Check if a source's async processing is complete.
Returns True if processing is complete (success or failure),
False if still processing or queued.
"""
try:
status_data = self.get_source_status(source_id)
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:
logger.error(f"Error checking source processing status: {e}")
return True # Assume complete on error
@ -275,7 +287,7 @@ class SourcesService:
"""Update a source."""
if not source.id:
raise ValueError("Source ID is required for update")
updates = {
"title": source.title,
"topics": source.topics,
@ -283,7 +295,9 @@ class SourcesService:
source_data = api_client.update_source(source.id, **updates)
# 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
source.title = source_data_dict["title"]
@ -302,4 +316,9 @@ class SourcesService:
sources_service = SourcesService()
# 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:
"""Service layer for transformations operations using API."""
def __init__(self):
logger.info("Using API for transformations operations")
def get_all_transformations(self) -> List[Transformation]:
"""Get all transformations."""
transformations_data = api_client.get_transformations()
@ -31,11 +31,15 @@ class TransformationsService:
apply_default=trans_data["apply_default"],
)
transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
transformation.created = datetime.fromisoformat(
trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
transformations.append(transformation)
return transformations
def get_transformation(self, transformation_id: str) -> Transformation:
"""Get a specific transformation."""
response = api_client.get_transformation(transformation_id)
@ -48,17 +52,21 @@ class TransformationsService:
apply_default=trans_data["apply_default"],
)
transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
transformation.created = datetime.fromisoformat(
trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
return transformation
def create_transformation(
self,
name: str,
title: str,
description: str,
prompt: str,
apply_default: bool = False
apply_default: bool = False,
) -> Transformation:
"""Create a new transformation."""
response = api_client.create_transformation(
@ -66,7 +74,7 @@ class TransformationsService:
title=title,
description=description,
prompt=prompt,
apply_default=apply_default
apply_default=apply_default,
)
trans_data = response if isinstance(response, dict) else response[0]
transformation = Transformation(
@ -77,10 +85,14 @@ class TransformationsService:
apply_default=trans_data["apply_default"],
)
transformation.id = trans_data["id"]
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
transformation.created = datetime.fromisoformat(
trans_data["created"].replace("Z", "+00:00")
)
transformation.updated = datetime.fromisoformat(
trans_data["updated"].replace("Z", "+00:00")
)
return transformation
def update_transformation(self, transformation: Transformation) -> Transformation:
"""Update a transformation."""
if not transformation.id:
@ -102,29 +114,28 @@ class TransformationsService:
transformation.description = trans_data["description"]
transformation.prompt = trans_data["prompt"]
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
def delete_transformation(self, transformation_id: str) -> bool:
"""Delete a transformation."""
api_client.delete_transformation(transformation_id)
return True
def execute_transformation(
self,
transformation_id: str,
input_text: str,
model_id: str
self, transformation_id: str, input_text: str, model_id: str
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
"""Execute a transformation on input text."""
result = api_client.execute_transformation(
transformation_id=transformation_id,
input_text=input_text,
model_id=model_id
model_id=model_id,
)
return result
# 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:
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)
return EmbedSingleItemOutput(
@ -317,7 +319,9 @@ async def vectorize_source_command(
start_time = time.time()
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
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}")
delete_result = await repo_query(
"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
if deleted_count > 0:
@ -354,12 +358,12 @@ async def vectorize_source_command(
try:
job_id = submit_command(
"open_notebook", # app name
"embed_chunk", # command name
"embed_chunk", # command name
{
"source_id": input_data.source_id,
"chunk_index": idx,
"chunk_text": chunk_text,
}
},
)
jobs_submitted += 1
@ -387,7 +391,9 @@ async def vectorize_source_command(
except Exception as e:
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)
return VectorizeSourceOutput(
@ -484,7 +490,9 @@ async def rebuild_embeddings_command(
try:
logger.info("=" * 60)
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)
# Check embedding model availability
@ -561,7 +569,9 @@ async def rebuild_embeddings_command(
notes_processed += 1
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:
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
delay_seconds: Optional[int] = None # For testing async behavior
class TextProcessingOutput(BaseModel):
success: bool
original_text: str
@ -20,11 +21,13 @@ class TextProcessingOutput(BaseModel):
processing_time: float
error_message: Optional[str] = None
class DataAnalysisInput(BaseModel):
numbers: List[float]
analysis_type: str = "basic" # basic, detailed
delay_seconds: Optional[int] = None
class DataAnalysisOutput(BaseModel):
success: bool
analysis_type: str
@ -36,6 +39,7 @@ class DataAnalysisOutput(BaseModel):
processing_time: float
error_message: Optional[str] = None
@command("process_text", app="open_notebook")
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.
"""
start_time = time.time()
try:
logger.info(f"Processing text with operation: {input_data.operation}")
# Simulate processing delay if specified
if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds)
processed_text = None
word_count = None
if input_data.operation == "uppercase":
processed_text = input_data.text.upper()
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}"
else:
raise ValueError(f"Unknown operation: {input_data.operation}")
processing_time = time.time() - start_time
return TextProcessingOutput(
success=True,
original_text=input_data.text,
processed_text=processed_text,
word_count=word_count,
processing_time=processing_time
processing_time=processing_time,
)
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Text processing failed: {e}")
@ -83,9 +87,10 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
success=False,
original_text=input_data.text,
processing_time=processing_time,
error_message=str(e)
error_message=str(e),
)
@command("analyze_data", app="open_notebook")
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.
"""
start_time = time.time()
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
if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds)
if not input_data.numbers:
raise ValueError("No numbers provided for analysis")
count = len(input_data.numbers)
sum_value = sum(input_data.numbers)
average = sum_value / count
min_value = min(input_data.numbers)
max_value = max(input_data.numbers)
processing_time = time.time() - start_time
return DataAnalysisOutput(
success=True,
analysis_type=input_data.analysis_type,
@ -120,9 +127,9 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
average=average,
min_value=min_value,
max_value=max_value,
processing_time=processing_time
processing_time=processing_time,
)
except Exception as e:
processing_time = time.time() - start_time
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,
count=0,
processing_time=processing_time,
error_message=str(e)
)
error_message=str(e),
)

View file

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

View file

@ -9,6 +9,9 @@ services:
- "5055:5055" # REST API
env_file:
- ./docker.env
environment:
# Override for single-container mode: SurrealDB runs on localhost inside the same container
- SURREAL_URL=ws://localhost:8000/rpc
volumes:
- ./notebook_data:/app/data # Application 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",
"build": "next build",
"start": "node start-server.js",
"lint": "next lint"
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
@ -35,12 +38,15 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.525.0",
"next": "^16.1.1",
"next-themes": "^0.4.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.60.0",
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.6",
@ -57,8 +63,14 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.4.2",
"jsdom": "^26.0.0",
"tailwindcss": "^4",
"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'
import { embeddingApi } from '@/lib/api/embedding'
import type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding'
import { useTranslation } from '@/lib/hooks/use-translation'
export function RebuildEmbeddings() {
const { t } = useTranslation()
const [mode, setMode] = useState<'existing' | 'all'>('existing')
const [includeSources, setIncludeSources] = useState(true)
const [includeNotes, setIncludeNotes] = useState(true)
@ -121,10 +123,10 @@ export function RebuildEmbeddings() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
🔄 Rebuild Embeddings
{t.advanced.rebuildEmbeddings}
</CardTitle>
<CardDescription>
Rebuild vector embeddings for your content. Use this when switching embedding models or fixing corrupted embeddings.
{t.advanced.rebuildEmbeddingsDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -132,25 +134,25 @@ export function RebuildEmbeddings() {
{!isRebuildActive && (
<div className="space-y-6">
<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')}>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="existing">Existing</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="existing">{t.advanced.rebuild.existing}</SelectItem>
<SelectItem value="all">{t.advanced.rebuild.all}</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{mode === 'existing'
? 'Re-embed only items that already have embeddings (faster, for model switching)'
: 'Re-embed existing items + create embeddings for items without any (slower, comprehensive)'}
? t.advanced.rebuild.existingDesc
: t.advanced.rebuild.allDesc}
</p>
</div>
<div className="space-y-3">
<Label>Include in Rebuild</Label>
<div className="space-y-3" role="group" aria-labelledby="include-label">
<span id="include-label" className="text-sm font-medium leading-none">{t.advanced.rebuild.include}</span>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
@ -159,7 +161,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeSources(checked === true)}
/>
<Label htmlFor="sources" className="font-normal cursor-pointer">
Sources
{t.navigation.sources}
</Label>
</div>
<div className="flex items-center space-x-2">
@ -169,7 +171,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeNotes(checked === true)}
/>
<Label htmlFor="notes" className="font-normal cursor-pointer">
Notes
{t.common.notes}
</Label>
</div>
<div className="flex items-center space-x-2">
@ -179,7 +181,7 @@ export function RebuildEmbeddings() {
onCheckedChange={(checked) => setIncludeInsights(checked === true)}
/>
<Label htmlFor="insights" className="font-normal cursor-pointer">
Insights
{t.common.insights}
</Label>
</div>
</div>
@ -187,7 +189,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Please select at least one item type to rebuild
{t.advanced.rebuild.selectOneError}
</AlertDescription>
</Alert>
)}
@ -201,10 +203,10 @@ export function RebuildEmbeddings() {
{rebuildMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting Rebuild...
{t.advanced.rebuild.starting}
</>
) : (
'🚀 Start Rebuild'
t.advanced.rebuild.startBtn
)}
</Button>
@ -212,7 +214,7 @@ export function RebuildEmbeddings() {
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<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>
</Alert>
)}
@ -230,21 +232,21 @@ export function RebuildEmbeddings() {
{status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />}
<div className="flex flex-col">
<span className="font-medium">
{status.status === 'queued' && 'Queued'}
{status.status === 'running' && 'Running...'}
{status.status === 'completed' && 'Completed!'}
{status.status === 'failed' && 'Failed'}
{status.status === 'queued' && t.advanced.rebuild.queued}
{status.status === 'running' && t.advanced.rebuild.running}
{status.status === 'completed' && t.advanced.rebuild.completed}
{status.status === 'failed' && t.advanced.rebuild.failed}
</span>
{status.status === 'running' && (
<span className="text-sm text-muted-foreground">
You can leave this page as this will run in the background
{t.advanced.rebuild.leavePageHint}
</span>
)}
</div>
</div>
{(status.status === 'completed' || status.status === 'failed') && (
<Button variant="outline" size="sm" onClick={handleReset}>
Start New Rebuild
{t.advanced.rebuild.startNew}
</Button>
)}
</div>
@ -252,36 +254,39 @@ export function RebuildEmbeddings() {
{progressData && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span>{t.common.progress}</span>
<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>
</div>
<Progress value={progressPercent} className="h-2" />
{failedItems > 0 && (
<p className="text-sm text-yellow-600">
{failedItems} items failed to process
{t.advanced.rebuild.failedItems.replace('{count}', failedItems.toString())}
</p>
)}
</div>
)}
{stats && (
{stats && (
<div className="grid grid-cols-4 gap-4">
<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>
</div>
<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>
</div>
<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>
</div>
<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">
{processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}
</p>
@ -298,9 +303,9 @@ export function RebuildEmbeddings() {
{status.started_at && (
<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 && (
<p>Completed: {new Date(status.completed_at).toLocaleString()}</p>
<p>{t.notebooks.updated}: {new Date(status.completed_at).toLocaleString()}</p>
)}
</div>
)}
@ -308,51 +313,25 @@ export function RebuildEmbeddings() {
)}
{/* Help Section */}
<Accordion type="single" collapsible className="w-full">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="when">
<AccordionTrigger>When should I rebuild embeddings?</AccordionTrigger>
<AccordionTrigger>{t.advanced.rebuild.whenToRebuild}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm">
<p><strong>You should rebuild embeddings when:</strong></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>
<p>{t.advanced.rebuild.whenToRebuildAns}</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="time">
<AccordionTrigger>How long does rebuilding take?</AccordionTrigger>
<AccordionTrigger>{t.advanced.rebuild.howLong}</AccordionTrigger>
<AccordionContent className="space-y-2 text-sm">
<p><strong>Processing time depends on:</strong></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>
<p>{t.advanced.rebuild.howLongAns}</p>
</AccordionContent>
</AccordionItem>
<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">
<p><strong>Yes, rebuilding is safe!</strong> The rebuild process:</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>
<p>{t.advanced.rebuild.isSafeAns}</p>
</AccordionContent>
</AccordionItem>
</Accordion>

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useState, useId } from 'react'
import { useForm } from 'react-hook-form'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 { AlertCircle, X } from 'lucide-react'
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
import { useTranslation } from '@/lib/hooks/use-translation'
interface DefaultModelsSectionProps {
models: Model[]
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) {
const { t } = useTranslation()
const updateDefaults = useUpdateModelDefaults()
const { setValue, watch } = useForm<ModelDefaults>({
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
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
@ -153,9 +165,9 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
return (
<Card>
<CardHeader>
<CardTitle>Default Model Assignments</CardTitle>
<CardTitle>{t.models.defaultAssignments}</CardTitle>
<CardDescription>
Configure which models to use for different purposes across Open Notebook
{t.models.defaultAssignmentsDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@ -163,8 +175,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Missing required models: {missingRequired.join(', ')}.
Open Notebook may not function properly without these.
{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}
</AlertDescription>
</Alert>
)}
@ -179,7 +190,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
return (
<div key={config.key} className="space-y-2">
<Label>
<Label htmlFor={config.id}>
{config.label}
{config.required && <span className="text-destructive ml-1">*</span>}
</Label>
@ -188,15 +199,18 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
value={currentValue || ""}
onValueChange={(value) => handleChange(config.key, value)}
>
<SelectTrigger className={
config.required && !isValidModel && availableModels.length > 0
? 'border-destructive'
: ''
}>
<SelectTrigger
id={config.id}
className={
config.required && !isValidModel && availableModels.length > 0
? 'border-destructive'
: ''
}
>
<SelectValue placeholder={
config.required && !isValidModel && availableModels.length > 0
? "⚠️ Required - Select a model"
: "Select a model"
? t.models.requiredModelPlaceholder
: t.models.selectModelPlaceholder
} />
</SelectTrigger>
<SelectContent>
@ -236,7 +250,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
Which model should I choose?
{t.models.whichModelToChoose}
</a>
</div>
</CardContent>

View file

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

View file

@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useDeleteModel } from '@/lib/hooks/use-models'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useState, useMemo } from 'react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ModelTypeSectionProps {
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
@ -21,6 +22,7 @@ interface ModelTypeSectionProps {
const COLLAPSED_ITEM_COUNT = 5
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
const { t } = useTranslation()
const [deleteModel, setDeleteModel] = useState<Model | null>(null)
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
const [isExpanded, setIsExpanded] = useState(false)
@ -30,32 +32,32 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
switch (type) {
case 'language':
return {
title: 'Language Models',
description: 'Chat, transformations, and text generation',
title: t.models.language,
description: t.models.languageDesc,
icon: Bot,
iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20'
}
case 'embedding':
return {
title: 'Embedding Models',
description: 'Semantic search and vector embeddings',
title: t.models.embedding,
description: t.models.embeddingDesc,
icon: Search,
iconColor: 'text-green-500',
bgColor: 'bg-green-50 dark:bg-green-950/20'
}
case 'text_to_speech':
return {
title: 'Text-to-Speech',
description: 'Generate audio from text',
title: t.models.tts,
description: t.models.ttsDesc,
icon: Volume2,
iconColor: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-950/20'
}
case 'speech_to_text':
return {
title: 'Speech-to-Text',
description: 'Transcribe audio to text',
title: t.models.stt,
description: t.models.sttDesc,
icon: Mic,
iconColor: 'text-orange-500',
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"
onClick={() => setSelectedProvider(null)}
>
All
{t.models.all}
</Badge>
{modelProviders.map(provider => (
<Badge
@ -143,8 +145,8 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
) : filteredModels.length === 0 ? (
<div className="text-center py-6 text-sm text-muted-foreground">
{selectedProvider
? `No ${selectedProvider} models configured`
: 'No models configured'
? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider)
: t.models.noModelsConfigured
}
</div>
) : (
@ -182,12 +184,12 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-2" />
Show less
{t.models.seeLess}
</>
) : (
<>
<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>
@ -200,9 +202,9 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
<ConfirmDialog
open={!!deleteModel}
onOpenChange={(open) => !open && setDeleteModel(null)}
title="Delete Model"
description={`Are you sure you want to delete "${deleteModel?.name}"? This action cannot be undone.`}
confirmText="Delete"
title={t.models.deleteModel}
description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')}
confirmText={t.common.delete}
confirmVariant="destructive"
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 { Check, X } from 'lucide-react'
import { ProviderAvailability } from '@/lib/types/models'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ProviderStatusProps {
providers: ProviderAvailability
}
export function ProviderStatus({ providers }: ProviderStatusProps) {
const { t } = useTranslation()
// Combine all providers, with available ones first
const allProviders = useMemo(
() => [
@ -33,11 +35,13 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
return (
<Card>
<CardHeader>
<CardTitle>AI Providers</CardTitle>
<CardTitle>{t.models.aiProviders}</CardTitle>
<CardDescription>
Configure providers through environment variables to enable their models.
{t.models.providerConfigDesc}
<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>
</CardDescription>
</CardHeader>
@ -74,21 +78,21 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
{provider.name}
</span>
{provider.available ? (
{provider.available ? (
<div className="flex flex-wrap items-center justify-end gap-1">
{supportedTypes.length > 0 ? (
supportedTypes.map((type) => (
<Badge key={type} variant="secondary" className="text-xs font-medium">
{type.replace('_', ' ')}
{(t.models as Record<string, string>)[type] || type.replace('_', ' ')}
</Badge>
))
) : (
<Badge variant="outline" className="text-xs">No models</Badge>
<Badge variant="outline" className="text-xs">{t.models.noModels}</Badge>
)}
</div>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
Not configured
{t.models.notConfigured}
</Badge>
)}
</div>
@ -97,26 +101,28 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
})}
</div>
{allProviders.length > 6 ? (
{allProviders.length > 6 ? (
<div className="mt-4 flex justify-center">
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
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>
</div>
) : null}
<div className="mt-6 pt-4 border-t">
<div className="mt-6 pt-4 border-t">
<a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
Learn how to configure providers
{t.models.learnMore}
</a>
</div>
</CardContent>

View file

@ -8,8 +8,10 @@ import { useModels, useModelDefaults, useProviders } from '@/lib/hooks/use-model
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function ModelsPage() {
const { t } = useTranslation()
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
@ -35,7 +37,7 @@ export default function ModelsPage() {
<AppShell>
<div className="p-6">
<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>
</AppShell>
@ -48,9 +50,9 @@ export default function ModelsPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<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">
Configure AI models for different purposes across Open Notebook
{t.models.desc}
</p>
</div>
<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 { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
import { useIsDesktop } from '@/lib/hooks/use-media-query'
import { useTranslation } from '@/lib/hooks/use-translation'
import { cn } from '@/lib/utils'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FileText, StickyNote, MessageSquare } from 'lucide-react'
@ -25,10 +26,11 @@ export interface ContextSelections {
}
export default function NotebookPage() {
const { t } = useTranslation()
const params = useParams()
// 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 {
@ -112,8 +114,8 @@ export default function NotebookPage() {
return (
<AppShell>
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Notebook Not Found</h1>
<p className="text-muted-foreground">The requested notebook could not be found.</p>
<h1 className="text-2xl font-bold mb-4">{t.notebooks.notFound}</h1>
<p className="text-muted-foreground">{t.notebooks.notFoundDesc}</p>
</div>
</AppShell>
)
@ -135,15 +137,15 @@ export default function NotebookPage() {
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="sources" className="gap-2">
<FileText className="h-4 w-4" />
Sources
{t.navigation.sources}
</TabsTrigger>
<TabsTrigger value="notes" className="gap-2">
<StickyNote className="h-4 w-4" />
Notes
{t.common.notes}
</TabsTrigger>
<TabsTrigger value="chat" className="gap-2">
<MessageSquare className="h-4 w-4" />
Chat
{t.common.chat}
</TabsTrigger>
</TabsList>
</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 { AlertCircle } from 'lucide-react'
import { ContextSelections } from '../[id]/page'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ChatColumnProps {
notebookId: string
@ -16,6 +17,8 @@ interface ChatColumnProps {
}
export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
const { t } = useTranslation()
// Fetch sources and notes for this notebook
const { data: sources = [], isLoading: sourcesLoading } = useSources(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">
<div className="text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">Unable to load chat</p>
<p className="text-xs mt-2">Please try refreshing the page</p>
<p className="text-sm">{t.chat.unableToLoadChat}</p>
<p className="text-xs mt-2">{t.common.refreshPage || 'Please try refreshing the page'}</p>
</div>
</CardContent>
</Card>
@ -89,7 +92,7 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
return (
<ChatPanel
title="Chat with Notebook"
title={t.chat.chatWithNotebook}
contextType="notebook"
messages={chat.messages}
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 { InlineEdit } from '@/components/common/InlineEdit'
import { cn } from "@/lib/utils";
import { useTranslation } from '@/lib/hooks/use-translation'
const createNoteSchema = z.object({
title: z.string().optional(),
@ -28,6 +29,7 @@ interface NoteEditorDialogProps {
}
export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) {
const { t } = useTranslation()
const createNote = useCreateNote()
const updateNote = useUpdateNote()
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"
)}>
<DialogTitle className="sr-only">
{isEditing ? 'Edit note' : 'Create note'}
{isEditing ? t.sources.editNote : t.sources.createNote}
</DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
{isEditing && noteLoading ? (
<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 className="border-b px-6 py-4">
<InlineEdit
id="note-title"
name="title"
value={watchTitle ?? ''}
onSave={(value) => setValue('title', value || '')}
placeholder="Add a title..."
emptyText="Untitled Note"
placeholder={t.sources.addTitle}
emptyText={t.sources.untitledNote}
className="text-xl font-semibold"
inputClassName="text-xl font-semibold"
/>
@ -152,10 +156,11 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
render={({ field }) => (
<MarkdownEditor
key={note?.id ?? 'new'}
textareaId="note-content"
value={field.value}
onChange={field.onChange}
height={420}
placeholder="Write your note content here..."
placeholder={t.sources.writeNotePlaceholder}
className={cn(
"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"
@ -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">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
{t.common.cancel}
</Button>
<Button
type="submit"
disabled={isSaving || (isEditing && noteLoading)}
>
{isSaving
? isEditing ? 'Saving...' : 'Creating...'
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
: isEditing
? 'Save Note'
: 'Create Note'}
? t.sources.saveNote
: t.sources.createNoteBtn}
</Button>
</div>
</form>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslation } from '@/lib/hooks/use-translation'
import { AppShell } from '@/components/layout/AppShell'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
@ -24,10 +25,11 @@ import { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
export default function SearchPage() {
const { t } = useTranslation()
// URL params
const searchParams = useSearchParams()
const urlQuery = searchParams.get('q') || ''
const rawMode = searchParams.get('mode')
const urlQuery = searchParams?.get('q') || ''
const rawMode = searchParams?.get('mode')
const urlMode = rawMode === 'search' ? 'search' : 'ask'
// Tab state (controlled)
@ -70,7 +72,7 @@ export default function SearchPage() {
}, [availableModels])
const resolveModelName = (id?: string | null) => {
if (!id) return 'Not set'
if (!id) return t.searchPage.notSet
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)
useEffect(() => {
const currentQ = searchParams.get('q') || ''
const rawCurrentMode = searchParams.get('mode')
const currentQ = searchParams?.get('q') || ''
const rawCurrentMode = searchParams?.get('mode')
const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask'
// Check if URL params have changed
@ -157,19 +159,19 @@ export default function SearchPage() {
return (
<AppShell>
<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">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a mode</p>
<TabsList aria-label="Ask or search your knowledge base" className="w-full max-w-xl">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.searchPage.chooseAMode}</p>
<TabsList aria-label={t.common.accessibility.searchKB} className="w-full max-w-xl">
<TabsTrigger value="ask">
<MessageCircleQuestion className="h-4 w-4" />
Ask (beta)
{t.searchPage.askBeta}
</TabsTrigger>
<TabsTrigger value="search">
<Search className="h-4 w-4" />
Search
{t.searchPage.search}
</TabsTrigger>
</TabsList>
</div>
@ -177,18 +179,19 @@ export default function SearchPage() {
<TabsContent value="ask" className="mt-6">
<Card>
<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">
The LLM will answer your query based on the documents in your knowledge base.
{t.searchPage.askYourKbDesc}
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Question Input */}
<div className="space-y-2">
<Label htmlFor="ask-question">Question</Label>
<Label htmlFor="ask-question">{t.searchPage.question}</Label>
<Textarea
id="ask-question"
placeholder="Enter your question..."
name="ask-question"
placeholder={t.searchPage.enterQuestionPlaceholder}
value={askQuestion}
onChange={(e) => setAskQuestion(e.target.value)}
onKeyDown={(e) => {
@ -200,23 +203,23 @@ export default function SearchPage() {
}}
disabled={ask.isStreaming}
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>
{/* Models Display */}
{!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">
<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 className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{customModels ? 'Using Custom Models' : 'Using Default Models'}
{customModels ? t.searchPage.usingCustomModels : t.searchPage.usingDefaultModels}
</Label>
<Button
variant="ghost"
@ -226,18 +229,18 @@ export default function SearchPage() {
className="h-auto py-1 px-2"
>
<Settings className="h-3 w-3 mr-1" />
Advanced
{t.searchPage.advanced}
</Button>
</div>
<div className="flex gap-2 text-xs flex-wrap">
<Badge variant="secondary">
Strategy: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
{t.searchPage.strategy}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
</Badge>
<Badge variant="secondary">
Answer: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
{t.searchPage.answer}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
</Badge>
<Badge variant="secondary">
Final: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
{t.searchPage.final}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
</Badge>
</div>
</div>
@ -251,10 +254,10 @@ export default function SearchPage() {
{ask.isStreaming ? (
<>
<LoadingSpinner size="sm" className="mr-2" />
Processing...
{t.searchPage.processing}
</>
) : (
'Ask'
t.searchPage.ask
)}
</Button>
@ -265,7 +268,7 @@ export default function SearchPage() {
className="w-full"
>
<Save className="h-4 w-4 mr-2" />
Save to Notebooks
{t.searchPage.saveToNotebooks}
</Button>
)}
</div>
@ -308,29 +311,34 @@ export default function SearchPage() {
<TabsContent value="search" className="mt-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Search</CardTitle>
<CardTitle className="text-lg">{t.searchPage.search}</CardTitle>
<p className="text-sm text-muted-foreground">
Search your knowledge base for specific keywords or concepts
{t.searchPage.searchDesc}
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Search Input */}
<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">
<Input
id="search-query"
placeholder="Enter search query..."
name="search-query"
placeholder={t.searchPage.enterSearchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={handleKeyPress}
disabled={searchMutation.isPending}
className="flex-1"
aria-label="Enter search query"
aria-label={t.common.accessibility.enterSearch}
autoComplete="off"
/>
<Button
onClick={handleSearch}
disabled={searchMutation.isPending || !searchQuery.trim()}
aria-label="Search knowledge base"
aria-label={t.common.accessibility.searchKBBtn}
className="w-full sm:w-auto"
>
{searchMutation.isPending ? (
@ -338,24 +346,25 @@ export default function SearchPage() {
) : (
<Search className="h-4 w-4 mr-2" />
)}
Search
{t.searchPage.search}
</Button>
</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>
{/* Search Options */}
<div className="space-y-4">
{/* Search Type */}
<div className="space-y-2">
<Label>Search Type</Label>
<div className="space-y-2" role="group" aria-labelledby="search-type-label">
<span id="search-type-label" className="text-sm font-medium leading-none">{t.searchPage.searchType}</span>
{!hasEmbeddingModel && (
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
<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>
)}
<RadioGroup
name="search-type"
value={searchType}
onValueChange={(value: 'text' | 'vector') => setSearchType(value)}
disabled={modelsLoading || searchMutation.isPending}
@ -363,7 +372,7 @@ export default function SearchPage() {
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="font-normal cursor-pointer">
Text Search
{t.searchPage.textSearch}
</Label>
</div>
<div className="flex items-center space-x-2">
@ -376,36 +385,38 @@ export default function SearchPage() {
htmlFor="vector"
className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}
>
Vector Search
{t.searchPage.vectorSearch}
</Label>
</div>
</RadioGroup>
</div>
{/* Search Locations */}
<div className="space-y-2">
<Label>Search In</Label>
<div className="space-y-2" role="group" aria-labelledby="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="flex items-center space-x-2">
<Checkbox
id="sources"
name="sources"
checked={searchSources}
onCheckedChange={(checked) => setSearchSources(checked as boolean)}
disabled={searchMutation.isPending}
/>
<Label htmlFor="sources" className="font-normal cursor-pointer">
Search Sources
{t.searchPage.searchSources}
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="notes"
name="notes"
checked={searchNotes}
onCheckedChange={(checked) => setSearchNotes(checked as boolean)}
disabled={searchMutation.isPending}
/>
<Label htmlFor="notes" className="font-normal cursor-pointer">
Search Notes
{t.searchPage.searchNotes}
</Label>
</div>
</div>
@ -417,15 +428,15 @@ export default function SearchPage() {
<div className="mt-6 space-y-3">
<div className="flex items-center justify-between">
<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>
<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>
{searchMutation.data.results.length === 0 ? (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
No results found for &ldquo;{searchQuery}&rdquo;
{t.searchPage.noResultsFor.replace('{query}', searchQuery)}
</CardContent>
</Card>
) : (
@ -456,7 +467,7 @@ export default function SearchPage() {
<Collapsible className="mt-3">
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="h-4 w-4" />
Matches ({result.matches.length})
{t.searchPage.matches.replace('{count}', result.matches.length.toString())}
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-1">
{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 { useEffect, useState } from 'react'
import { ChevronDownIcon } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
const settingsSchema = z.object({
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>
export function SettingsForm() {
const { t } = useTranslation()
const { data: settings, isLoading, error } = useSettings()
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)
@ -78,9 +85,9 @@ export function SettingsForm() {
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Failed to load settings</AlertTitle>
<AlertTitle>{t.settings.loadFailed}</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
{error instanceof Error ? error.message : t.common.error}
</AlertDescription>
</Alert>
)
@ -90,31 +97,32 @@ export function SettingsForm() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Content Processing</CardTitle>
<CardTitle>{t.settings.contentProcessing}</CardTitle>
<CardDescription>
Configure how documents and URLs are processed
{t.settings.contentProcessingDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="doc_engine">Document Processing Engine</Label>
<Label htmlFor="doc_engine">{t.settings.docEngine}</Label>
<Controller
name="default_content_processing_engine_doc"
control={control}
render={({ field }) => (
<Select
key={field.value}
value={field.value || ''}
onValueChange={field.onChange}
disabled={field.disabled || isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select document processing engine" />
</SelectTrigger>
<Select
key={field.value}
name={field.name}
value={field.value || ''}
onValueChange={field.onChange}
disabled={field.disabled || isLoading}
>
<SelectTrigger id="doc_engine" className="w-full">
<SelectValue placeholder={t.settings.docEnginePlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (Recommended)</SelectItem>
<SelectItem value="docling">Docling</SelectItem>
<SelectItem value="simple">Simple</SelectItem>
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
<SelectItem value="docling">{t.settings.docling}</SelectItem>
<SelectItem value="simple">{t.settings.simple}</SelectItem>
</SelectContent>
</Select>
)}
@ -122,143 +130,135 @@ export function SettingsForm() {
<Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>
<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' : ''}`} />
Help me choose
{t.settings.helpMeChoose}
</CollapsibleTrigger>
<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> <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>
<p>{t.settings.docHelp}</p>
</CollapsibleContent>
</Collapsible>
</div>
<div className="space-y-3">
<Label htmlFor="url_engine">URL Processing Engine</Label>
<Label htmlFor="url_engine">{t.settings.urlEngine}</Label>
<Controller
name="default_content_processing_engine_url"
control={control}
render={({ field }) => (
<Select
key={field.value}
name={field.name}
value={field.value || ''}
onValueChange={field.onChange}
disabled={field.disabled || isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select URL processing engine" />
<SelectTrigger id="url_engine" className="w-full">
<SelectValue placeholder={t.settings.urlEnginePlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto (Recommended)</SelectItem>
<SelectItem value="firecrawl">Firecrawl</SelectItem>
<SelectItem value="jina">Jina</SelectItem>
<SelectItem value="simple">Simple</SelectItem>
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
<SelectItem value="firecrawl">{t.settings.firecrawl}</SelectItem>
<SelectItem value="jina">{t.settings.jina}</SelectItem>
<SelectItem value="simple">{t.settings.simple}</SelectItem>
</SelectContent>
</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">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />
Help me choose
{t.settings.helpMeChoose}
</CollapsibleTrigger>
<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> <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>
<p>{t.settings.urlHelp}</p>
</CollapsibleContent>
</Collapsible>
</div>
</CardContent>
</Card>
<Card>
<Card>
<CardHeader>
<CardTitle>Embedding and Search</CardTitle>
<CardTitle>{t.settings.embeddingAndSearch}</CardTitle>
<CardDescription>
Configure search and embedding options
{t.settings.embeddingAndSearchDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="embedding">Default Embedding Option</Label>
<div className="space-y-3">
<Label htmlFor="embedding">{t.settings.defaultEmbeddingOption}</Label>
<Controller
name="default_embedding_option"
control={control}
render={({ field }) => (
<Select
key={field.value}
name={field.name}
value={field.value || ''}
onValueChange={field.onChange}
disabled={field.disabled || isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select embedding option" />
<SelectTrigger id="embedding" className="w-full">
<SelectValue placeholder={t.settings.embeddingOptionPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask">Ask</SelectItem>
<SelectItem value="always">Always</SelectItem>
<SelectItem value="never">Never</SelectItem>
<SelectItem value="ask">{t.settings.ask}</SelectItem>
<SelectItem value="always">{t.settings.always}</SelectItem>
<SelectItem value="never">{t.settings.never}</SelectItem>
</SelectContent>
</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">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />
Help me choose
{t.settings.helpMeChoose}
</CollapsibleTrigger>
<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> 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>
<p>{t.settings.embeddingHelp}</p>
</CollapsibleContent>
</Collapsible>
</div>
</CardContent>
</Card>
<Card>
<Card>
<CardHeader>
<CardTitle>File Management</CardTitle>
<CardTitle>{t.settings.fileManagement}</CardTitle>
<CardDescription>
Configure file handling and storage options
{t.settings.fileManagementDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="auto_delete">Auto Delete Files</Label>
<div className="space-y-3">
<Label htmlFor="auto_delete">{t.settings.autoDeleteFiles}</Label>
<Controller
name="auto_delete_files"
control={control}
render={({ field }) => (
<Select
key={field.value}
name={field.name}
value={field.value || ''}
onValueChange={field.onChange}
disabled={field.disabled || isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select auto delete option" />
<SelectTrigger id="auto_delete" className="w-full">
<SelectValue placeholder={t.settings.autoDeletePlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
<SelectContent>
<SelectItem value="yes">{t.common.yes}</SelectItem>
<SelectItem value="no">{t.common.no}</SelectItem>
</SelectContent>
</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">
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />
Help me choose
{t.settings.helpMeChoose}
</CollapsibleTrigger>
<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> 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>
<p>{t.settings.filesHelp}</p>
</CollapsibleContent>
</Collapsible>
</div>
@ -266,11 +266,11 @@ export function SettingsForm() {
</Card>
<div className="flex justify-end">
<Button
<Button
type="submit"
disabled={!isDirty || updateSettings.isPending}
>
{updateSettings.isPending ? 'Saving...' : 'Save Settings'}
{updateSettings.isPending ? t.common.saving : t.navigation.settings}
</Button>
</div>
</form>

View file

@ -5,8 +5,10 @@ import { SettingsForm } from './components/SettingsForm'
import { useSettings } from '@/lib/hooks/use-settings'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function SettingsPage() {
const { t } = useTranslation()
const { refetch } = useSettings()
return (
@ -15,7 +17,7 @@ export default function SettingsPage() {
<div className="p-6">
<div className="max-w-4xl">
<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()}>
<RefreshCw className="h-4 w-4" />
</Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,8 +10,10 @@ import { TransformationPlayground } from './components/TransformationPlayground'
import { useTransformations } from '@/lib/hooks/use-transformations'
import { Transformation } from '@/lib/types/transformations'
import { Wand2, Play, RefreshCw } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function TransformationsPage() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('transformations')
const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>()
const { data: transformations, isLoading, refetch } = useTransformations()
@ -27,7 +29,7 @@ export default function TransformationsPage() {
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<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()}>
<RefreshCw className="h-4 w-4" />
</Button>
@ -36,21 +38,21 @@ export default function TransformationsPage() {
<div className="max-w-5xl">
<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>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a workspace</p>
<TabsList aria-label="Transformation views" className="w-full max-w-xl">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.transformations.workspace}</p>
<TabsList aria-label={t.common.accessibility.transformationViews} className="w-full max-w-xl">
<TabsTrigger value="transformations" className="flex items-center gap-2">
<Wand2 className="h-4 w-4" />
Transformations
{t.transformations.title}
</TabsTrigger>
<TabsTrigger value="playground" className="flex items-center gap-2">
<Play className="h-4 w-4" />
Playground
{t.transformations.playground}
</TabsTrigger>
</TabsList>
</div>

View file

@ -70,7 +70,7 @@
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815);
--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-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
@ -104,7 +104,7 @@
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--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-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
@ -114,63 +114,78 @@
* {
@apply border-border outline-ring/50;
}
html {
@apply antialiased;
}
body {
@apply bg-background text-foreground transition-colors;
}
/* Ensure proper theme inheritance for popovers and dropdowns */
.dark {
color-scheme: dark;
}
:root {
color-scheme: light;
}
/* Ensure Radix UI components inherit theme properly */
[data-radix-popper-content-wrapper] {
@apply z-50;
}
/* Force theme inheritance for portaled content */
.dark [data-radix-popper-content-wrapper],
.dark [data-overlay-container] {
color-scheme: dark;
}
/* Ensure sidebar gets proper theme */
.app-sidebar {
background-color: var(--sidebar);
color: var(--sidebar-foreground);
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 */
.card-hover {
@apply transition-all duration-200 cursor-pointer;
}
.card-hover:hover {
background-color: var(--muted) !important;
border-color: var(--border);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .card-hover:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Ensure clickable cards show pointer cursor */
.clickable-card {
cursor: pointer !important;
}
.clickable-card * {
cursor: pointer !important;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import {
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ContextToggleProps {
mode: ContextMode
@ -18,28 +19,29 @@ interface ContextToggleProps {
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) {
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 Icon = config.icon
@ -77,7 +79,7 @@ export function ContextToggle({ mode, hasInsights = false, onChange, className }
<TooltipContent>
<p className="text-xs">{config.label}</p>
<p className="text-[10px] text-muted-foreground mt-1">
Click to cycle
{t.common.contextModes.clickToCycle}
</p>
</TooltipContent>
</Tooltip>

View file

@ -4,6 +4,10 @@ import React from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
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 {
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">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</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>
An unexpected error occurred. Please try refreshing the page.
{t?.common?.refreshPage || 'An unexpected error occurred. Please try refreshing the page.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{process.env.NODE_ENV === 'development' && this.state.error && (
<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">
{this.state.error.toString()}
</pre>
@ -75,13 +79,13 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
variant="outline"
>
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
{t?.common?.retry || 'Try Again'}
</Button>
<Button
onClick={() => window.location.reload()}
className="w-full"
>
Refresh Page
{t?.common?.refresh || 'Refresh Page'}
</Button>
</CardContent>
</Card>

View file

@ -1,7 +1,8 @@
'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 { useTranslation } from '@/lib/hooks/use-translation'
interface InlineEditProps {
value: string
@ -11,6 +12,9 @@ interface InlineEditProps {
placeholder?: string
multiline?: boolean
emptyText?: string
id?: string
name?: string
autocomplete?: string
}
export function InlineEdit({
@ -20,8 +24,15 @@ export function InlineEdit({
inputClassName,
placeholder,
multiline = false,
emptyText = 'Click to edit'
emptyText,
id: providedId,
name,
autocomplete = 'off'
}: InlineEditProps) {
const generatedId = useId()
const id = providedId || generatedId
const { t } = useTranslation()
const defaultEmptyText = emptyText || t.common.clickToEdit
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(value)
const [isSaving, setIsSaving] = useState(false)
@ -85,7 +96,7 @@ export function InlineEdit({
setIsEditing(true)
}}
>
{value || <span className="text-muted-foreground">{emptyText}</span>}
{value || <span className="text-muted-foreground">{defaultEmptyText}</span>}
</button>
)
}
@ -111,6 +122,9 @@ export function InlineEdit({
)}
placeholder={placeholder}
disabled={isSaving}
id={id}
name={name}
autoComplete={autocomplete}
/>
)
}
@ -134,6 +148,9 @@ export function InlineEdit({
)}
placeholder={placeholder}
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 (
<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 { Label } from '@/components/ui/label'
import { useModels } from '@/lib/hooks/use-models'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ModelSelectorProps {
id?: string
name?: string
label?: string
modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech'
value: string
@ -14,24 +16,29 @@ interface ModelSelectorProps {
disabled?: boolean
}
export function ModelSelector({
label,
modelType,
value,
onChange,
placeholder = 'Select a model',
disabled = false
export function ModelSelector({
id,
name,
label,
modelType,
value,
onChange,
placeholder,
disabled = false
}: ModelSelectorProps) {
const { t } = useTranslation()
const { data: models, isLoading } = useModels()
const derivedId = useId()
const selectId = id || derivedId
// Filter models by type
const filteredModels = models?.filter(model => model.type === modelType) || []
return (
<div className="space-y-2">
{label && <Label>{label}</Label>}
<Select value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
{label && <Label htmlFor={selectId}>{label}</Label>}
<Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>
<SelectTrigger id={selectId}>
<SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} />
</SelectTrigger>
<SelectContent>
{isLoading ? (
@ -40,7 +47,7 @@ export function ModelSelector({
</div>
) : filteredModels.length === 0 ? (
<div className="text-sm text-muted-foreground py-2 px-2">
No {modelType.replace('_', ' ')} models available
{t.common.noResults}
</div>
) : (
filteredModels.map((model) => (

View file

@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Sun, Moon, Monitor } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ThemeToggleProps {
iconOnly?: boolean
@ -16,6 +17,7 @@ interface ThemeToggleProps {
export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
const { theme, setTheme } = useTheme()
const { t } = useTranslation()
return (
<DropdownMenu>
@ -23,14 +25,14 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
<Button
variant={iconOnly ? "ghost" : "outline"}
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]">
<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" />
</div>
{!iconOnly && <span>Theme</span>}
<span className="sr-only">Toggle theme</span>
{!iconOnly && <span>{t.common.theme}</span>}
<span className="sr-only">{t.navigation.theme}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -39,21 +41,21 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
className={theme === 'light' ? 'bg-accent' : ''}
>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
<span>{t.common.light}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('dark')}
className={theme === 'dark' ? 'bg-accent' : ''}
>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
<span>{t.common.dark}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme('system')}
className={theme === 'system' ? 'bg-accent' : ''}
>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
<span>{t.common.system}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -10,6 +10,7 @@ import {
} from '@/components/ui/collapsible'
import { Database, Server, ChevronDown, ExternalLink } from 'lucide-react'
import { ConnectionError } from '@/lib/types/config'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ConnectionErrorOverlayProps {
error: ConnectionError
@ -20,6 +21,7 @@ export function ConnectionErrorOverlay({
error,
onRetry,
}: ConnectionErrorOverlayProps) {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState(false)
const isApiError = error.type === 'api-unreachable'
@ -41,56 +43,56 @@ export function ConnectionErrorOverlay({
<div>
<h1 className="text-2xl font-bold" id="error-title">
{isApiError
? 'Unable to Connect to API Server'
: 'Database Connection Failed'}
? t.connectionErrors.apiTitle
: t.connectionErrors.dbTitle}
</h1>
<p className="text-muted-foreground">
{isApiError
? 'The Open Notebook API server could not be reached'
: 'The API server is running, but the database is not accessible'}
? t.connectionErrors.apiDesc
: t.connectionErrors.dbDesc}
</p>
</div>
</div>
{/* Troubleshooting instructions */}
<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">
{isApiError ? (
<>
<li>The API server is not running</li>
<li>The API server is running on a different address</li>
<li>Network connectivity issues</li>
<li>{t.connectionErrors.apiUnreachable1}</li>
<li>{t.connectionErrors.apiUnreachable2}</li>
<li>{t.connectionErrors.apiUnreachable3}</li>
</>
) : (
<>
<li>SurrealDB is not running</li>
<li>Database connection settings are incorrect</li>
<li>Network issues between API and database</li>
<li>{t.connectionErrors.dbFailed1}</li>
<li>{t.connectionErrors.dbFailed2}</li>
<li>{t.connectionErrors.dbFailed3}</li>
</>
)}
</ul>
<h2 className="font-semibold mt-4">Quick fixes:</h2>
<h2 className="font-semibold mt-4">{t.connectionErrors.quickFixes}</h2>
{isApiError ? (
<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">
# For Docker:
# {t.connectionErrors.dockerLabel}:
<br />
docker run -e API_URL=http://your-host:5055 ...
<br />
<br />
# For local development (.env file):
# {t.connectionErrors.localDevLabel}:
<br />
API_URL=http://localhost:5055
</code>
</div>
) : (
<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">
# For Docker:
# {t.connectionErrors.dockerLabel}:
<br />
docker compose ps | grep surrealdb
<br />
@ -102,14 +104,14 @@ export function ConnectionErrorOverlay({
{/* Documentation link */}
<div className="text-sm">
<p>For detailed setup instructions, see:</p>
<p>{t.connectionErrors.seeDocumentation}</p>
<a
href="https://github.com/lfnovo/open-notebook"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
Open Notebook Documentation
{t.connectionErrors.docLink}
<ExternalLink className="w-4 h-4" />
</a>
</div>
@ -119,7 +121,7 @@ export function ConnectionErrorOverlay({
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span>Show Technical Details</span>
<span>{t.connectionErrors.showTechnical}</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${
showDetails ? 'rotate-180' : ''
@ -131,23 +133,23 @@ export function ConnectionErrorOverlay({
<div className="space-y-2 text-sm bg-muted p-4 rounded font-mono">
{error.details.attemptedUrl && (
<div>
<strong>Attempted URL:</strong> {error.details.attemptedUrl}
<strong>{t.connectionErrors.attemptedUrl}:</strong> {error.details.attemptedUrl}
</div>
)}
{error.details.message && (
<div>
<strong>Message:</strong> {error.details.message}
<strong>{t.connectionErrors.message}:</strong> {error.details.message}
</div>
)}
{error.details.technicalMessage && (
<div>
<strong>Technical Details:</strong>{' '}
<strong>{t.connectionErrors.technicalDetails}:</strong>{' '}
{error.details.technicalMessage}
</div>
)}
{error.details.stack && (
<div>
<strong>Stack Trace:</strong>
<strong>{t.connectionErrors.stackTrace}:</strong>
<pre className="mt-2 overflow-x-auto text-xs">
{error.details.stack}
</pre>
@ -161,10 +163,10 @@ export function ConnectionErrorOverlay({
{/* Retry button */}
<div className="pt-4 border-t">
<Button onClick={onRetry} className="w-full" size="lg">
Retry Connection
{t.connectionErrors.retryLabel}
</Button>
<p className="text-xs text-muted-foreground text-center mt-2">
Press R or click the button to retry
{t.connectionErrors.retryHint}
</p>
</div>
</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,
} from '@/components/ui/dropdown-menu'
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 {
Book,
@ -40,33 +43,33 @@ import {
Command,
} from 'lucide-react'
const navigation = [
const getNavigation = (t: TranslationKeys) => [
{
title: 'Collect',
title: t.navigation.collect,
items: [
{ name: 'Sources', href: '/sources', icon: FileText },
{ name: t.navigation.sources, href: '/sources', icon: FileText },
],
},
{
title: 'Process',
title: t.navigation.process,
items: [
{ name: 'Notebooks', href: '/notebooks', icon: Book },
{ name: 'Ask and Search', href: '/search', icon: Search },
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book },
{ name: t.navigation.askAndSearch, href: '/search', icon: Search },
],
},
{
title: 'Create',
title: t.navigation.create,
items: [
{ name: 'Podcasts', href: '/podcasts', icon: Mic },
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic },
],
},
{
title: 'Manage',
title: t.navigation.manage,
items: [
{ name: 'Models', href: '/models', icon: Bot },
{ name: 'Transformations', href: '/transformations', icon: Shuffle },
{ name: 'Settings', href: '/settings', icon: Settings },
{ name: 'Advanced', href: '/advanced', icon: Wrench },
{ name: t.navigation.models, href: '/models', icon: Bot },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
{ name: t.navigation.settings, href: '/settings', icon: Settings },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench },
],
},
] as const
@ -74,6 +77,8 @@ const navigation = [
type CreateTarget = 'source' | 'notebook' | 'podcast'
export function AppSidebar() {
const { t } = useTranslation()
const navigation = getNavigation(t)
const pathname = usePathname()
const { logout } = useAuth()
const { isCollapsed, toggleCollapse } = useSidebarStore()
@ -134,9 +139,9 @@ export function AppSidebar() {
) : (
<>
<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">
Open Notebook
{t.common.appName}
</span>
</div>
<Button
@ -144,6 +149,7 @@ export function AppSidebar() {
size="sm"
onClick={toggleCollapse}
className="text-sidebar-foreground hover:bg-sidebar-accent"
data-testid="sidebar-toggle"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@ -173,13 +179,13 @@ export function AppSidebar() {
variant="default"
size="sm"
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" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">Create</TooltipContent>
<TooltipContent side="right">{t.common.create}</TooltipContent>
</Tooltip>
) : (
<DropdownMenuTrigger asChild>
@ -188,9 +194,9 @@ export function AppSidebar() {
variant="default"
size="sm"
className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0"
>
>
<Plus className="h-4 w-4 mr-2" />
Create
{t.common.create}
</Button>
</DropdownMenuTrigger>
)}
@ -207,8 +213,8 @@ export function AppSidebar() {
}}
className="gap-2"
>
<FileText className="h-4 w-4" />
Source
<FileText className="h-4 w-4" />
{t.common.source}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
@ -217,8 +223,8 @@ export function AppSidebar() {
}}
className="gap-2"
>
<Book className="h-4 w-4" />
Notebook
<Book className="h-4 w-4" />
{t.common.notebook}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
@ -227,8 +233,8 @@ export function AppSidebar() {
}}
className="gap-2"
>
<Mic className="h-4 w-4" />
Podcast
<Mic className="h-4 w-4" />
{t.common.podcast}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -247,12 +253,12 @@ export function AppSidebar() {
)}
{section.items.map((item) => {
const isActive = pathname.startsWith(item.href)
const isActive = pathname?.startsWith(item.href) || false
const button = (
<Button
variant={isActive ? 'secondary' : 'ghost'}
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',
isCollapsed ? 'justify-center px-2' : 'justify-start'
)}
@ -296,37 +302,50 @@ export function AppSidebar() {
{!isCollapsed && (
<div className="px-3 py-1.5 text-xs text-sidebar-foreground/60">
<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" />
Quick actions
{t.common.quickActions}
</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">
{isMac ? <span className="text-xs"></span> : <span>Ctrl+</span>}K
</kbd>
</div>
<p className="mt-1 text-[10px] text-sidebar-foreground/40">
Navigation, search, ask, theme
<p className="mt-1 text-[10px] text-sidebar-foreground/40">
{t.common.quickActionsDesc}
</p>
</div>
)}
<div
<div
className={cn(
'flex',
isCollapsed ? 'justify-center' : 'justify-start'
'flex flex-col gap-2',
isCollapsed ? 'items-center' : 'items-stretch'
)}
>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ThemeToggle iconOnly />
</div>
</TooltipTrigger>
<TooltipContent side="right">Theme</TooltipContent>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger asChild>
<div>
<ThemeToggle iconOnly />
</div>
</TooltipTrigger>
<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>
@ -335,22 +354,24 @@ export function AppSidebar() {
<TooltipTrigger asChild>
<Button
variant="outline"
className="w-full justify-center"
className="w-full justify-center sidebar-menu-item"
onClick={logout}
aria-label={t.common.signOut}
>
<LogOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Sign Out</TooltipContent>
<TooltipContent side="right">{t.common.signOut}</TooltipContent>
</Tooltip>
) : (
<Button
variant="outline"
className="w-full justify-start gap-3"
className="w-full justify-start gap-3 sidebar-menu-item"
onClick={logout}
>
aria-label={t.common.signOut}
>
<LogOut className="h-4 w-4" />
Sign Out
{t.common.signOut}
</Button>
)}
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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