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:
parent
940c56ddaf
commit
67dd85c928
179 changed files with 10201 additions and 2633 deletions
|
|
@ -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
|
||||
5
.github/workflows/claude-code-review.yml
vendored
5
.github/workflows/claude-code-review.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
5
.github/workflows/claude.yml
vendored
5
.github/workflows/claude.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
Dockerfile
16
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
48
api/auth.py
48
api/auth.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
116
api/client.py
116
api/client.py
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
28
api/main.py
28
api/main.py
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ services:
|
|||
restart: always
|
||||
open_notebook:
|
||||
image: lfnovo/open_notebook:v1-latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8502:8502"
|
||||
- "5055:5055"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2902
frontend/package-lock.json
generated
2902
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "All" 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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 “{searchQuery}”
|
||||
{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) => (
|
||||
|
|
|
|||
|
|
@ -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'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'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't care about vector search or do not have an embedding provider.</p>
|
||||
<p>As a reference, OpenAI'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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 “{query}”</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 “{query}”</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 “{query}”</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 “{query}”</span>
|
||||
<span>{t.searchPage.askAbout.replace('{query}', query)}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
|
|
|
|||
52
frontend/src/components/common/ConfirmDialog.test.tsx
Normal file
52
frontend/src/components/common/ConfirmDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
91
frontend/src/components/common/LanguageLoadingOverlay.tsx
Normal file
91
frontend/src/components/common/LanguageLoadingOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/common/LanguageToggle.tsx
Normal file
58
frontend/src/components/common/LanguageToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
60
frontend/src/components/layout/AppSidebar.test.tsx
Normal file
60
frontend/src/components/layout/AppSidebar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue