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/
|
# Git
|
||||||
data/
|
.git
|
||||||
.uploads/
|
.gitignore
|
||||||
.venv/
|
|
||||||
.env
|
|
||||||
sqlite-db/
|
|
||||||
temp/
|
|
||||||
google-credentials.json
|
|
||||||
docker-compose*
|
|
||||||
.docker_data/
|
|
||||||
docs/
|
|
||||||
surreal_data/
|
|
||||||
surreal-data/
|
|
||||||
notebook_data/
|
|
||||||
temp/
|
|
||||||
*.env
|
|
||||||
.git/
|
|
||||||
.github/
|
|
||||||
|
|
||||||
# Frontend build artifacts and dependencies
|
# Python
|
||||||
frontend/node_modules/
|
__pycache__
|
||||||
frontend/.next/
|
*.pyc
|
||||||
frontend/.env.local
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
ENV
|
||||||
|
env
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
|
||||||
# Cache directories (recursive patterns)
|
# Frontend
|
||||||
**/__pycache__/
|
frontend/node_modules
|
||||||
**/.mypy_cache/
|
frontend/.next
|
||||||
**/.ruff_cache/
|
frontend/dist
|
||||||
**/.pytest_cache/
|
frontend/out
|
||||||
**/*.pyc
|
frontend/.env*
|
||||||
**/*.pyo
|
frontend/*.log
|
||||||
**/*.pyd
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.cache/
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
|
|
||||||
# IDE and editor files
|
# Project
|
||||||
.vscode/
|
.antigravity
|
||||||
.idea/
|
.gemini
|
||||||
*.swp
|
tmp
|
||||||
*.swo
|
data
|
||||||
*~
|
mydata
|
||||||
|
*.db
|
||||||
# OS files
|
*.log
|
||||||
.DS_Store
|
docker.env
|
||||||
.DS_Store?
|
.env
|
||||||
._*
|
|
||||||
.Spotlight-V100
|
|
||||||
.Trashes
|
|
||||||
ehthumbs.db
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
|
|
||||||
.quarentena/
|
|
||||||
surreal_single_data/
|
|
||||||
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
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
issues: read
|
issues: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -38,6 +38,7 @@ jobs:
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
plugins: 'code-review@claude-code-plugins'
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
|
|
||||||
5
.github/workflows/claude.yml
vendored
5
.github/workflows/claude.yml
vendored
|
|
@ -20,8 +20,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
issues: read
|
issues: write
|
||||||
id-token: write
|
id-token: write
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -34,6 +34,7 @@ jobs:
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
|
|
||||||
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
|
# Install system dependencies required for building certain Python packages
|
||||||
# Add Node.js 20.x LTS for building frontend
|
# Add Node.js 20.x LTS for building frontend
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
# NOTE: gcc/g++/make removed - uv should download pre-built wheels. Add back if build fails.
|
||||||
gcc g++ git make \
|
# NOTE: gcc/g++/make required for some python dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
|
build-essential \
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y nodejs \
|
&& apt-get install -y nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
@ -35,7 +37,11 @@ COPY . /app
|
||||||
|
|
||||||
# Install frontend dependencies and build
|
# Install frontend dependencies and build
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm config set registry ${NPM_REGISTRY}
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Return to app root
|
# Return to app root
|
||||||
|
|
@ -46,7 +52,7 @@ FROM python:3.12-slim-bookworm AS runtime
|
||||||
|
|
||||||
# Install only runtime system dependencies (no build tools)
|
# Install only runtime system dependencies (no build tools)
|
||||||
# Add Node.js 20.x LTS for running frontend
|
# Add Node.js 20.x LTS for running frontend
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
supervisor \
|
supervisor \
|
||||||
curl \
|
curl \
|
||||||
|
|
@ -63,8 +69,8 @@ WORKDIR /app
|
||||||
# Copy the virtual environment from builder stage
|
# Copy the virtual environment from builder stage
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
# Copy the application code
|
# Copy the source code (the rest)
|
||||||
COPY --from=builder /app /app
|
COPY . /app
|
||||||
|
|
||||||
# Ensure uv uses the existing venv without attempting network operations
|
# Ensure uv uses the existing venv without attempting network operations
|
||||||
ENV UV_NO_SYNC=1
|
ENV UV_NO_SYNC=1
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,39 @@
|
||||||
# Build stage
|
# Stage 1: Frontend Builder
|
||||||
FROM python:3.12-slim-bookworm AS builder
|
FROM node:20-slim AS frontend-builder
|
||||||
|
|
||||||
# Install uv using the official method
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
||||||
|
|
||||||
# Install system dependencies required for building certain Python packages
|
|
||||||
# Add Node.js 20.x LTS for building frontend
|
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
|
||||||
gcc g++ git make \
|
|
||||||
curl \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set build optimization environment variables
|
|
||||||
ENV MAKEFLAGS="-j$(nproc)"
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
|
||||||
ENV UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
# Set the working directory in the container to /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy dependency files and minimal package structure first for better layer caching
|
|
||||||
COPY pyproject.toml uv.lock ./
|
|
||||||
COPY open_notebook/__init__.py ./open_notebook/__init__.py
|
|
||||||
|
|
||||||
# Install dependencies with optimizations (this layer will be cached unless dependencies change)
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
# Copy the rest of the application code
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
# Install frontend dependencies and build
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
# Copy dependency files first to leverage cache
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||||
|
RUN npm config set registry ${NPM_REGISTRY}
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy the rest of the frontend source
|
||||||
|
COPY frontend/ ./
|
||||||
|
# Build the frontend
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Return to app root
|
# Stage 2: Backend Builder
|
||||||
|
FROM python:3.12-slim-bookworm AS backend-builder
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Runtime stage
|
# Set build optimization environment variables
|
||||||
|
ENV UV_HTTP_TIMEOUT=120
|
||||||
|
|
||||||
|
# Copy dependency files first
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY open_notebook/__init__.py ./open_notebook/__init__.py
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
# Stage 3: Runtime
|
||||||
FROM python:3.12-slim-bookworm AS runtime
|
FROM python:3.12-slim-bookworm AS runtime
|
||||||
|
|
||||||
# Install runtime system dependencies including curl for SurrealDB installation
|
# Install runtime dependencies
|
||||||
# Add Node.js 20.x LTS for running frontend
|
|
||||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
supervisor \
|
supervisor \
|
||||||
|
|
@ -57,47 +45,34 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
||||||
# Install SurrealDB
|
# Install SurrealDB
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://install.surrealdb.com | sh
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://install.surrealdb.com | sh
|
||||||
|
|
||||||
# Install uv using the official method
|
# Install uv (optional but helpful for some scripts)
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
# Set the working directory in the container to /app
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the virtual environment from builder stage
|
# Copy backend virtualenv and source code
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=backend-builder /app/.venv /app/.venv
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
# Copy the application code
|
# Copy built frontend from standalone output
|
||||||
COPY --from=builder /app /app
|
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
|
||||||
|
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
|
||||||
|
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
|
||||||
|
|
||||||
# Copy built frontend from builder stage
|
# Setup directories and permissions
|
||||||
COPY --from=builder /app/frontend/.next/standalone /app/frontend/
|
|
||||||
COPY --from=builder /app/frontend/.next/static /app/frontend/.next/static
|
|
||||||
COPY --from=builder /app/frontend/public /app/frontend/public
|
|
||||||
|
|
||||||
# Create directories for data persistence
|
|
||||||
RUN mkdir -p /app/data /mydata
|
RUN mkdir -p /app/data /mydata
|
||||||
|
|
||||||
# Copy and make executable the wait-for-api script
|
# Ensure wait-for-api script is executable
|
||||||
COPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh
|
|
||||||
RUN chmod +x /app/scripts/wait-for-api.sh
|
RUN chmod +x /app/scripts/wait-for-api.sh
|
||||||
|
|
||||||
# Expose ports for Frontend and API
|
# Copy supervisord configuration
|
||||||
EXPOSE 8502 5055
|
|
||||||
|
|
||||||
# Copy single-container supervisord configuration
|
|
||||||
COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
# Create log directories
|
# Create log directories
|
||||||
RUN mkdir -p /var/log/supervisor
|
RUN mkdir -p /var/log/supervisor
|
||||||
|
|
||||||
# Runtime API URL Configuration
|
# Expose ports
|
||||||
# The API_URL environment variable can be set at container runtime to configure
|
EXPOSE 8502 5055
|
||||||
# where the frontend should connect to the API. This allows the same Docker image
|
|
||||||
# to work in different deployment scenarios without rebuilding.
|
|
||||||
#
|
|
||||||
# If not set, the system will auto-detect based on incoming requests.
|
|
||||||
# Set API_URL when using reverse proxies or custom domains.
|
|
||||||
#
|
|
||||||
# Example: docker run -e API_URL=https://your-domain.com/api ...
|
|
||||||
|
|
||||||
|
# Set startup command
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
48
api/auth.py
48
api/auth.py
|
|
@ -1,7 +1,7 @@
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
@ -12,35 +12,41 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
|
||||||
Middleware to check password authentication for all API requests.
|
Middleware to check password authentication for all API requests.
|
||||||
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
|
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, app, excluded_paths: Optional[list] = None):
|
def __init__(self, app, excluded_paths: Optional[list] = None):
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
|
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
|
||||||
self.excluded_paths = excluded_paths or ["/", "/health", "/docs", "/openapi.json", "/redoc"]
|
self.excluded_paths = excluded_paths or [
|
||||||
|
"/",
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
]
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Skip authentication if no password is set
|
# Skip authentication if no password is set
|
||||||
if not self.password:
|
if not self.password:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Skip authentication for excluded paths
|
# Skip authentication for excluded paths
|
||||||
if request.url.path in self.excluded_paths:
|
if request.url.path in self.excluded_paths:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Skip authentication for CORS preflight requests (OPTIONS)
|
# Skip authentication for CORS preflight requests (OPTIONS)
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# Check authorization header
|
# Check authorization header
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "Missing authorization header"},
|
content={"detail": "Missing authorization header"},
|
||||||
headers={"WWW-Authenticate": "Bearer"}
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Expected format: "Bearer {password}"
|
# Expected format: "Bearer {password}"
|
||||||
try:
|
try:
|
||||||
scheme, credentials = auth_header.split(" ", 1)
|
scheme, credentials = auth_header.split(" ", 1)
|
||||||
|
|
@ -50,17 +56,17 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "Invalid authorization header format"},
|
content={"detail": "Invalid authorization header format"},
|
||||||
headers={"WWW-Authenticate": "Bearer"}
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check password
|
# Check password
|
||||||
if credentials != self.password:
|
if credentials != self.password:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "Invalid password"},
|
content={"detail": "Invalid password"},
|
||||||
headers={"WWW-Authenticate": "Bearer"}
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Password is correct, proceed with the request
|
# Password is correct, proceed with the request
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|
@ -70,17 +76,19 @@ class PasswordAuthMiddleware(BaseHTTPMiddleware):
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = None) -> bool:
|
def check_api_password(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Utility function to check API password.
|
Utility function to check API password.
|
||||||
Can be used as a dependency in individual routes if needed.
|
Can be used as a dependency in individual routes if needed.
|
||||||
"""
|
"""
|
||||||
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
|
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
|
||||||
|
|
||||||
# No password set, allow access
|
# No password set, allow access
|
||||||
if not password:
|
if not password:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# No credentials provided
|
# No credentials provided
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -88,7 +96,7 @@ def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = Non
|
||||||
detail="Missing authorization",
|
detail="Missing authorization",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check password
|
# Check password
|
||||||
if credentials.credentials != password:
|
if credentials.credentials != password:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -96,5 +104,5 @@ def check_api_password(credentials: Optional[HTTPAuthorizationCredentials] = Non
|
||||||
detail="Invalid password",
|
detail="Invalid password",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
Chat service for API operations.
|
Chat service for API operations.
|
||||||
Provides async interface for chat functionality.
|
Provides async interface for chat functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
@ -11,7 +12,7 @@ from loguru import logger
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
"""Service for chat-related API operations"""
|
"""Service for chat-related API operations"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
|
self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
|
||||||
# Add authentication header if password is set
|
# Add authentication header if password is set
|
||||||
|
|
@ -19,7 +20,7 @@ class ChatService:
|
||||||
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
|
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
|
||||||
if password:
|
if password:
|
||||||
self.headers["Authorization"] = f"Bearer {password}"
|
self.headers["Authorization"] = f"Bearer {password}"
|
||||||
|
|
||||||
async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:
|
async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:
|
||||||
"""Get all chat sessions for a notebook"""
|
"""Get all chat sessions for a notebook"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -27,14 +28,14 @@ class ChatService:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{self.base_url}/api/chat/sessions",
|
f"{self.base_url}/api/chat/sessions",
|
||||||
params={"notebook_id": notebook_id},
|
params={"notebook_id": notebook_id},
|
||||||
headers=self.headers
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching chat sessions: {str(e)}")
|
logger.error(f"Error fetching chat sessions: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
self,
|
self,
|
||||||
notebook_id: str,
|
notebook_id: str,
|
||||||
|
|
@ -48,33 +49,33 @@ class ChatService:
|
||||||
data["title"] = title
|
data["title"] = title
|
||||||
if model_override is not None:
|
if model_override is not None:
|
||||||
data["model_override"] = model_override
|
data["model_override"] = model_override
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat/sessions",
|
f"{self.base_url}/api/chat/sessions",
|
||||||
json=data,
|
json=data,
|
||||||
headers=self.headers
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating chat session: {str(e)}")
|
logger.error(f"Error creating chat session: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def get_session(self, session_id: str) -> Dict[str, Any]:
|
async def get_session(self, session_id: str) -> Dict[str, Any]:
|
||||||
"""Get a specific session with messages"""
|
"""Get a specific session with messages"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{self.base_url}/api/chat/sessions/{session_id}",
|
f"{self.base_url}/api/chat/sessions/{session_id}",
|
||||||
headers=self.headers
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching session: {str(e)}")
|
logger.error(f"Error fetching session: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def update_session(
|
async def update_session(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
@ -90,34 +91,36 @@ class ChatService:
|
||||||
data["model_override"] = model_override
|
data["model_override"] = model_override
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
raise ValueError("At least one field must be provided to update a session")
|
raise ValueError(
|
||||||
|
"At least one field must be provided to update a session"
|
||||||
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
f"{self.base_url}/api/chat/sessions/{session_id}",
|
f"{self.base_url}/api/chat/sessions/{session_id}",
|
||||||
json=data,
|
json=data,
|
||||||
headers=self.headers
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating session: {str(e)}")
|
logger.error(f"Error updating session: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def delete_session(self, session_id: str) -> Dict[str, Any]:
|
async def delete_session(self, session_id: str) -> Dict[str, Any]:
|
||||||
"""Delete a chat session"""
|
"""Delete a chat session"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
f"{self.base_url}/api/chat/sessions/{session_id}",
|
f"{self.base_url}/api/chat/sessions/{session_id}",
|
||||||
headers=self.headers
|
headers=self.headers,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting session: {str(e)}")
|
logger.error(f"Error deleting session: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def execute_chat(
|
async def execute_chat(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
@ -127,41 +130,32 @@ class ChatService:
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a chat request"""
|
"""Execute a chat request"""
|
||||||
try:
|
try:
|
||||||
data = {
|
data = {"session_id": session_id, "message": message, "context": context}
|
||||||
"session_id": session_id,
|
|
||||||
"message": message,
|
|
||||||
"context": context
|
|
||||||
}
|
|
||||||
if model_override is not None:
|
if model_override is not None:
|
||||||
data["model_override"] = model_override
|
data["model_override"] = model_override
|
||||||
|
|
||||||
# Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
|
# Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
|
||||||
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
|
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat/execute",
|
f"{self.base_url}/api/chat/execute", json=data, headers=self.headers
|
||||||
json=data,
|
|
||||||
headers=self.headers
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing chat: {str(e)}")
|
logger.error(f"Error executing chat: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def build_context(self, notebook_id: str, context_config: Dict[str, Any]) -> Dict[str, Any]:
|
async def build_context(
|
||||||
|
self, notebook_id: str, context_config: Dict[str, Any]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Build context for a notebook"""
|
"""Build context for a notebook"""
|
||||||
try:
|
try:
|
||||||
data = {
|
data = {"notebook_id": notebook_id, "context_config": context_config}
|
||||||
"notebook_id": notebook_id,
|
|
||||||
"context_config": context_config
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.base_url}/api/chat/context",
|
f"{self.base_url}/api/chat/context", json=data, headers=self.headers
|
||||||
json=data,
|
|
||||||
headers=self.headers
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
|
||||||
116
api/client.py
116
api/client.py
|
|
@ -23,14 +23,20 @@ class APIClient:
|
||||||
timeout_value = float(timeout_str)
|
timeout_value = float(timeout_str)
|
||||||
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
|
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
|
||||||
if timeout_value < 30:
|
if timeout_value < 30:
|
||||||
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s")
|
logger.warning(
|
||||||
|
f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s"
|
||||||
|
)
|
||||||
timeout_value = 30.0
|
timeout_value = 30.0
|
||||||
elif timeout_value > 3600:
|
elif timeout_value > 3600:
|
||||||
logger.warning(f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s")
|
logger.warning(
|
||||||
|
f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s"
|
||||||
|
)
|
||||||
timeout_value = 3600.0
|
timeout_value = 3600.0
|
||||||
self.timeout = timeout_value
|
self.timeout = timeout_value
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error(f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s")
|
logger.error(
|
||||||
|
f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s"
|
||||||
|
)
|
||||||
self.timeout = 300.0
|
self.timeout = 300.0
|
||||||
|
|
||||||
# Add authentication header if password is set
|
# Add authentication header if password is set
|
||||||
|
|
@ -45,7 +51,7 @@ class APIClient:
|
||||||
"""Make HTTP request to the API."""
|
"""Make HTTP request to the API."""
|
||||||
url = f"{self.base_url}{endpoint}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
request_timeout = timeout if timeout is not None else self.timeout
|
request_timeout = timeout if timeout is not None else self.timeout
|
||||||
|
|
||||||
# Merge headers
|
# Merge headers
|
||||||
headers = kwargs.get("headers", {})
|
headers = kwargs.get("headers", {})
|
||||||
headers.update(self.headers)
|
headers.update(self.headers)
|
||||||
|
|
@ -82,20 +88,28 @@ class APIClient:
|
||||||
result = self._make_request("GET", "/api/notebooks", params=params)
|
result = self._make_request("GET", "/api/notebooks", params=params)
|
||||||
return result if isinstance(result, list) else [result]
|
return result if isinstance(result, list) else [result]
|
||||||
|
|
||||||
def create_notebook(self, name: str, description: str = "") -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def create_notebook(
|
||||||
|
self, name: str, description: str = ""
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Create a new notebook."""
|
"""Create a new notebook."""
|
||||||
data = {"name": name, "description": description}
|
data = {"name": name, "description": description}
|
||||||
return self._make_request("POST", "/api/notebooks", json=data)
|
return self._make_request("POST", "/api/notebooks", json=data)
|
||||||
|
|
||||||
def get_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_notebook(
|
||||||
|
self, notebook_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get a specific notebook."""
|
"""Get a specific notebook."""
|
||||||
return self._make_request("GET", f"/api/notebooks/{notebook_id}")
|
return self._make_request("GET", f"/api/notebooks/{notebook_id}")
|
||||||
|
|
||||||
def update_notebook(self, notebook_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_notebook(
|
||||||
|
self, notebook_id: str, **updates
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update a notebook."""
|
"""Update a notebook."""
|
||||||
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
|
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
|
||||||
|
|
||||||
def delete_notebook(self, notebook_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_notebook(
|
||||||
|
self, notebook_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete a notebook."""
|
"""Delete a notebook."""
|
||||||
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
|
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
|
||||||
|
|
||||||
|
|
@ -148,7 +162,9 @@ class APIClient:
|
||||||
result = self._make_request("GET", "/api/models", params=params)
|
result = self._make_request("GET", "/api/models", params=params)
|
||||||
return result if isinstance(result, list) else [result]
|
return result if isinstance(result, list) else [result]
|
||||||
|
|
||||||
def create_model(self, name: str, provider: str, model_type: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def create_model(
|
||||||
|
self, name: str, provider: str, model_type: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Create a new model."""
|
"""Create a new model."""
|
||||||
data = {
|
data = {
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|
@ -157,7 +173,9 @@ class APIClient:
|
||||||
}
|
}
|
||||||
return self._make_request("POST", "/api/models", json=data)
|
return self._make_request("POST", "/api/models", json=data)
|
||||||
|
|
||||||
def delete_model(self, model_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_model(
|
||||||
|
self, model_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete a model."""
|
"""Delete a model."""
|
||||||
return self._make_request("DELETE", f"/api/models/{model_id}")
|
return self._make_request("DELETE", f"/api/models/{model_id}")
|
||||||
|
|
||||||
|
|
@ -165,7 +183,9 @@ class APIClient:
|
||||||
"""Get default model assignments."""
|
"""Get default model assignments."""
|
||||||
return self._make_request("GET", "/api/models/defaults")
|
return self._make_request("GET", "/api/models/defaults")
|
||||||
|
|
||||||
def update_default_models(self, **defaults) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_default_models(
|
||||||
|
self, **defaults
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update default model assignments."""
|
"""Update default model assignments."""
|
||||||
return self._make_request("PUT", "/api/models/defaults", json=defaults)
|
return self._make_request("PUT", "/api/models/defaults", json=defaults)
|
||||||
|
|
||||||
|
|
@ -193,17 +213,23 @@ class APIClient:
|
||||||
}
|
}
|
||||||
return self._make_request("POST", "/api/transformations", json=data)
|
return self._make_request("POST", "/api/transformations", json=data)
|
||||||
|
|
||||||
def get_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_transformation(
|
||||||
|
self, transformation_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get a specific transformation."""
|
"""Get a specific transformation."""
|
||||||
return self._make_request("GET", f"/api/transformations/{transformation_id}")
|
return self._make_request("GET", f"/api/transformations/{transformation_id}")
|
||||||
|
|
||||||
def update_transformation(self, transformation_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_transformation(
|
||||||
|
self, transformation_id: str, **updates
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update a transformation."""
|
"""Update a transformation."""
|
||||||
return self._make_request(
|
return self._make_request(
|
||||||
"PUT", f"/api/transformations/{transformation_id}", json=updates
|
"PUT", f"/api/transformations/{transformation_id}", json=updates
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_transformation(self, transformation_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_transformation(
|
||||||
|
self, transformation_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete a transformation."""
|
"""Delete a transformation."""
|
||||||
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
|
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
|
||||||
|
|
||||||
|
|
@ -252,7 +278,9 @@ class APIClient:
|
||||||
"""Get a specific note."""
|
"""Get a specific note."""
|
||||||
return self._make_request("GET", f"/api/notes/{note_id}")
|
return self._make_request("GET", f"/api/notes/{note_id}")
|
||||||
|
|
||||||
def update_note(self, note_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_note(
|
||||||
|
self, note_id: str, **updates
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update a note."""
|
"""Update a note."""
|
||||||
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
|
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
|
||||||
|
|
||||||
|
|
@ -261,7 +289,9 @@ class APIClient:
|
||||||
return self._make_request("DELETE", f"/api/notes/{note_id}")
|
return self._make_request("DELETE", f"/api/notes/{note_id}")
|
||||||
|
|
||||||
# Embedding API methods
|
# Embedding API methods
|
||||||
def embed_content(self, item_id: str, item_type: str, async_processing: bool = False) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def embed_content(
|
||||||
|
self, item_id: str, item_type: str, async_processing: bool = False
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Embed content for vector search."""
|
"""Embed content for vector search."""
|
||||||
data = {
|
data = {
|
||||||
"item_id": item_id,
|
"item_id": item_id,
|
||||||
|
|
@ -276,7 +306,7 @@ class APIClient:
|
||||||
mode: str = "existing",
|
mode: str = "existing",
|
||||||
include_sources: bool = True,
|
include_sources: bool = True,
|
||||||
include_notes: bool = True,
|
include_notes: bool = True,
|
||||||
include_insights: bool = True
|
include_insights: bool = True,
|
||||||
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Rebuild embeddings in bulk.
|
"""Rebuild embeddings in bulk.
|
||||||
|
|
||||||
|
|
@ -291,9 +321,13 @@ class APIClient:
|
||||||
}
|
}
|
||||||
# Use double the configured timeout for bulk rebuild operations (or configured value if already high)
|
# Use double the configured timeout for bulk rebuild operations (or configured value if already high)
|
||||||
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
|
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
|
||||||
return self._make_request("POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout)
|
return self._make_request(
|
||||||
|
"POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout
|
||||||
|
)
|
||||||
|
|
||||||
def get_rebuild_status(self, command_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_rebuild_status(
|
||||||
|
self, command_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get status of a rebuild operation."""
|
"""Get status of a rebuild operation."""
|
||||||
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
|
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
|
||||||
|
|
||||||
|
|
@ -302,7 +336,9 @@ class APIClient:
|
||||||
"""Get all application settings."""
|
"""Get all application settings."""
|
||||||
return self._make_request("GET", "/api/settings")
|
return self._make_request("GET", "/api/settings")
|
||||||
|
|
||||||
def update_settings(self, **settings) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_settings(
|
||||||
|
self, **settings
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update application settings."""
|
"""Update application settings."""
|
||||||
return self._make_request("PUT", "/api/settings", json=settings)
|
return self._make_request("PUT", "/api/settings", json=settings)
|
||||||
|
|
||||||
|
|
@ -370,21 +406,29 @@ class APIClient:
|
||||||
data["transformations"] = transformations
|
data["transformations"] = transformations
|
||||||
|
|
||||||
# Use configured timeout for source creation (especially PDF processing with OCR)
|
# Use configured timeout for source creation (especially PDF processing with OCR)
|
||||||
return self._make_request("POST", "/api/sources/json", json=data, timeout=self.timeout)
|
return self._make_request(
|
||||||
|
"POST", "/api/sources/json", json=data, timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get a specific source."""
|
"""Get a specific source."""
|
||||||
return self._make_request("GET", f"/api/sources/{source_id}")
|
return self._make_request("GET", f"/api/sources/{source_id}")
|
||||||
|
|
||||||
def get_source_status(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_source_status(
|
||||||
|
self, source_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get processing status for a source."""
|
"""Get processing status for a source."""
|
||||||
return self._make_request("GET", f"/api/sources/{source_id}/status")
|
return self._make_request("GET", f"/api/sources/{source_id}/status")
|
||||||
|
|
||||||
def update_source(self, source_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_source(
|
||||||
|
self, source_id: str, **updates
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update a source."""
|
"""Update a source."""
|
||||||
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
|
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
|
||||||
|
|
||||||
def delete_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_source(
|
||||||
|
self, source_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete a source."""
|
"""Delete a source."""
|
||||||
return self._make_request("DELETE", f"/api/sources/{source_id}")
|
return self._make_request("DELETE", f"/api/sources/{source_id}")
|
||||||
|
|
||||||
|
|
@ -394,11 +438,15 @@ class APIClient:
|
||||||
result = self._make_request("GET", f"/api/sources/{source_id}/insights")
|
result = self._make_request("GET", f"/api/sources/{source_id}/insights")
|
||||||
return result if isinstance(result, list) else [result]
|
return result if isinstance(result, list) else [result]
|
||||||
|
|
||||||
def get_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_insight(
|
||||||
|
self, insight_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get a specific insight."""
|
"""Get a specific insight."""
|
||||||
return self._make_request("GET", f"/api/insights/{insight_id}")
|
return self._make_request("GET", f"/api/insights/{insight_id}")
|
||||||
|
|
||||||
def delete_insight(self, insight_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_insight(
|
||||||
|
self, insight_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete a specific insight."""
|
"""Delete a specific insight."""
|
||||||
return self._make_request("DELETE", f"/api/insights/{insight_id}")
|
return self._make_request("DELETE", f"/api/insights/{insight_id}")
|
||||||
|
|
||||||
|
|
@ -430,7 +478,9 @@ class APIClient:
|
||||||
result = self._make_request("GET", "/api/episode-profiles")
|
result = self._make_request("GET", "/api/episode-profiles")
|
||||||
return result if isinstance(result, list) else [result]
|
return result if isinstance(result, list) else [result]
|
||||||
|
|
||||||
def get_episode_profile(self, profile_name: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def get_episode_profile(
|
||||||
|
self, profile_name: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get a specific episode profile by name."""
|
"""Get a specific episode profile by name."""
|
||||||
return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
|
return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
|
||||||
|
|
||||||
|
|
@ -460,11 +510,17 @@ class APIClient:
|
||||||
}
|
}
|
||||||
return self._make_request("POST", "/api/episode-profiles", json=data)
|
return self._make_request("POST", "/api/episode-profiles", json=data)
|
||||||
|
|
||||||
def update_episode_profile(self, profile_id: str, **updates) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def update_episode_profile(
|
||||||
|
self, profile_id: str, **updates
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Update an episode profile."""
|
"""Update an episode profile."""
|
||||||
return self._make_request("PUT", f"/api/episode-profiles/{profile_id}", json=updates)
|
return self._make_request(
|
||||||
|
"PUT", f"/api/episode-profiles/{profile_id}", json=updates
|
||||||
|
)
|
||||||
|
|
||||||
def delete_episode_profile(self, profile_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def delete_episode_profile(
|
||||||
|
self, profile_id: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Delete an episode profile."""
|
"""Delete an episode profile."""
|
||||||
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
|
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,14 @@ class ContextService:
|
||||||
logger.info("Using API for context operations")
|
logger.info("Using API for context operations")
|
||||||
|
|
||||||
def get_notebook_context(
|
def get_notebook_context(
|
||||||
self,
|
self, notebook_id: str, context_config: Optional[Dict] = None
|
||||||
notebook_id: str,
|
|
||||||
context_config: Optional[Dict] = None
|
|
||||||
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Get context for a notebook."""
|
"""Get context for a notebook."""
|
||||||
result = api_client.get_notebook_context(
|
result = api_client.get_notebook_context(
|
||||||
notebook_id=notebook_id,
|
notebook_id=notebook_id, context_config=context_config
|
||||||
context_config=context_config
|
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
context_service = ContextService()
|
context_service = ContextService()
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,13 @@ class EmbeddingService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for embedding operations")
|
logger.info("Using API for embedding operations")
|
||||||
|
|
||||||
def embed_content(self, item_id: str, item_type: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
def embed_content(
|
||||||
|
self, item_id: str, item_type: str
|
||||||
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Embed content for vector search."""
|
"""Embed content for vector search."""
|
||||||
result = api_client.embed_content(item_id=item_id, item_type=item_type)
|
result = api_client.embed_content(item_id=item_id, item_type=item_type)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
embedding_service = EmbeddingService()
|
embedding_service = EmbeddingService()
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ from open_notebook.podcasts.models import EpisodeProfile
|
||||||
|
|
||||||
class EpisodeProfilesService:
|
class EpisodeProfilesService:
|
||||||
"""Service layer for episode profiles operations using API."""
|
"""Service layer for episode profiles operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for episode profiles operations")
|
logger.info("Using API for episode profiles operations")
|
||||||
|
|
||||||
def get_all_episode_profiles(self) -> List[EpisodeProfile]:
|
def get_all_episode_profiles(self) -> List[EpisodeProfile]:
|
||||||
"""Get all episode profiles."""
|
"""Get all episode profiles."""
|
||||||
profiles_data = api_client.get_episode_profiles()
|
profiles_data = api_client.get_episode_profiles()
|
||||||
|
|
@ -31,16 +31,20 @@ class EpisodeProfilesService:
|
||||||
transcript_provider=profile_data["transcript_provider"],
|
transcript_provider=profile_data["transcript_provider"],
|
||||||
transcript_model=profile_data["transcript_model"],
|
transcript_model=profile_data["transcript_model"],
|
||||||
default_briefing=profile_data["default_briefing"],
|
default_briefing=profile_data["default_briefing"],
|
||||||
num_segments=profile_data["num_segments"]
|
num_segments=profile_data["num_segments"],
|
||||||
)
|
)
|
||||||
profile.id = profile_data["id"]
|
profile.id = profile_data["id"]
|
||||||
profiles.append(profile)
|
profiles.append(profile)
|
||||||
return profiles
|
return profiles
|
||||||
|
|
||||||
def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
|
def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
|
||||||
"""Get a specific episode profile by name."""
|
"""Get a specific episode profile by name."""
|
||||||
profile_response = api_client.get_episode_profile(profile_name)
|
profile_response = api_client.get_episode_profile(profile_name)
|
||||||
profile_data = profile_response if isinstance(profile_response, dict) else profile_response[0]
|
profile_data = (
|
||||||
|
profile_response
|
||||||
|
if isinstance(profile_response, dict)
|
||||||
|
else profile_response[0]
|
||||||
|
)
|
||||||
profile = EpisodeProfile(
|
profile = EpisodeProfile(
|
||||||
name=profile_data["name"],
|
name=profile_data["name"],
|
||||||
description=profile_data.get("description", ""),
|
description=profile_data.get("description", ""),
|
||||||
|
|
@ -50,11 +54,11 @@ class EpisodeProfilesService:
|
||||||
transcript_provider=profile_data["transcript_provider"],
|
transcript_provider=profile_data["transcript_provider"],
|
||||||
transcript_model=profile_data["transcript_model"],
|
transcript_model=profile_data["transcript_model"],
|
||||||
default_briefing=profile_data["default_briefing"],
|
default_briefing=profile_data["default_briefing"],
|
||||||
num_segments=profile_data["num_segments"]
|
num_segments=profile_data["num_segments"],
|
||||||
)
|
)
|
||||||
profile.id = profile_data["id"]
|
profile.id = profile_data["id"]
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
def create_episode_profile(
|
def create_episode_profile(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
@ -79,7 +83,11 @@ class EpisodeProfilesService:
|
||||||
default_briefing=default_briefing,
|
default_briefing=default_briefing,
|
||||||
num_segments=num_segments,
|
num_segments=num_segments,
|
||||||
)
|
)
|
||||||
profile_data = profile_response if isinstance(profile_response, dict) else profile_response[0]
|
profile_data = (
|
||||||
|
profile_response
|
||||||
|
if isinstance(profile_response, dict)
|
||||||
|
else profile_response[0]
|
||||||
|
)
|
||||||
profile = EpisodeProfile(
|
profile = EpisodeProfile(
|
||||||
name=profile_data["name"],
|
name=profile_data["name"],
|
||||||
description=profile_data.get("description", ""),
|
description=profile_data.get("description", ""),
|
||||||
|
|
@ -89,11 +97,11 @@ class EpisodeProfilesService:
|
||||||
transcript_provider=profile_data["transcript_provider"],
|
transcript_provider=profile_data["transcript_provider"],
|
||||||
transcript_model=profile_data["transcript_model"],
|
transcript_model=profile_data["transcript_model"],
|
||||||
default_briefing=profile_data["default_briefing"],
|
default_briefing=profile_data["default_briefing"],
|
||||||
num_segments=profile_data["num_segments"]
|
num_segments=profile_data["num_segments"],
|
||||||
)
|
)
|
||||||
profile.id = profile_data["id"]
|
profile.id = profile_data["id"]
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
def delete_episode_profile(self, profile_id: str) -> bool:
|
def delete_episode_profile(self, profile_id: str) -> bool:
|
||||||
"""Delete an episode profile."""
|
"""Delete an episode profile."""
|
||||||
api_client.delete_episode_profile(profile_id)
|
api_client.delete_episode_profile(profile_id)
|
||||||
|
|
@ -101,4 +109,4 @@ class EpisodeProfilesService:
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
episode_profiles_service = EpisodeProfilesService()
|
episode_profiles_service = EpisodeProfilesService()
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Note, SourceInsight
|
||||||
|
|
||||||
class InsightsService:
|
class InsightsService:
|
||||||
"""Service layer for insights operations using API."""
|
"""Service layer for insights operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for insights operations")
|
logger.info("Using API for insights operations")
|
||||||
|
|
||||||
def get_source_insights(self, source_id: str) -> List[SourceInsight]:
|
def get_source_insights(self, source_id: str) -> List[SourceInsight]:
|
||||||
"""Get all insights for a specific source."""
|
"""Get all insights for a specific source."""
|
||||||
insights_data = api_client.get_source_insights(source_id)
|
insights_data = api_client.get_source_insights(source_id)
|
||||||
|
|
@ -31,11 +31,15 @@ class InsightsService:
|
||||||
insight.updated = insight_data["updated"]
|
insight.updated = insight_data["updated"]
|
||||||
insights.append(insight)
|
insights.append(insight)
|
||||||
return insights
|
return insights
|
||||||
|
|
||||||
def get_insight(self, insight_id: str) -> SourceInsight:
|
def get_insight(self, insight_id: str) -> SourceInsight:
|
||||||
"""Get a specific insight."""
|
"""Get a specific insight."""
|
||||||
insight_response = api_client.get_insight(insight_id)
|
insight_response = api_client.get_insight(insight_id)
|
||||||
insight_data = insight_response if isinstance(insight_response, dict) else insight_response[0]
|
insight_data = (
|
||||||
|
insight_response
|
||||||
|
if isinstance(insight_response, dict)
|
||||||
|
else insight_response[0]
|
||||||
|
)
|
||||||
insight = SourceInsight(
|
insight = SourceInsight(
|
||||||
insight_type=insight_data["insight_type"],
|
insight_type=insight_data["insight_type"],
|
||||||
content=insight_data["content"],
|
content=insight_data["content"],
|
||||||
|
|
@ -45,16 +49,20 @@ class InsightsService:
|
||||||
insight.updated = insight_data["updated"]
|
insight.updated = insight_data["updated"]
|
||||||
# Note: source_id from API response is not stored; use await insight.get_source() if needed
|
# Note: source_id from API response is not stored; use await insight.get_source() if needed
|
||||||
return insight
|
return insight
|
||||||
|
|
||||||
def delete_insight(self, insight_id: str) -> bool:
|
def delete_insight(self, insight_id: str) -> bool:
|
||||||
"""Delete a specific insight."""
|
"""Delete a specific insight."""
|
||||||
api_client.delete_insight(insight_id)
|
api_client.delete_insight(insight_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def save_insight_as_note(self, insight_id: str, notebook_id: Optional[str] = None) -> Note:
|
def save_insight_as_note(
|
||||||
|
self, insight_id: str, notebook_id: Optional[str] = None
|
||||||
|
) -> Note:
|
||||||
"""Convert an insight to a note."""
|
"""Convert an insight to a note."""
|
||||||
note_response = api_client.save_insight_as_note(insight_id, notebook_id)
|
note_response = api_client.save_insight_as_note(insight_id, notebook_id)
|
||||||
note_data = note_response if isinstance(note_response, dict) else note_response[0]
|
note_data = (
|
||||||
|
note_response if isinstance(note_response, dict) else note_response[0]
|
||||||
|
)
|
||||||
note = Note(
|
note = Note(
|
||||||
title=note_data["title"],
|
title=note_data["title"],
|
||||||
content=note_data["content"],
|
content=note_data["content"],
|
||||||
|
|
@ -64,11 +72,19 @@ class InsightsService:
|
||||||
note.created = note_data["created"]
|
note.created = note_data["created"]
|
||||||
note.updated = note_data["updated"]
|
note.updated = note_data["updated"]
|
||||||
return note
|
return note
|
||||||
|
|
||||||
def create_source_insight(self, source_id: str, transformation_id: str, model_id: Optional[str] = None) -> SourceInsight:
|
def create_source_insight(
|
||||||
|
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
|
||||||
|
) -> SourceInsight:
|
||||||
"""Create a new insight for a source by running a transformation."""
|
"""Create a new insight for a source by running a transformation."""
|
||||||
insight_response = api_client.create_source_insight(source_id, transformation_id, model_id)
|
insight_response = api_client.create_source_insight(
|
||||||
insight_data = insight_response if isinstance(insight_response, dict) else insight_response[0]
|
source_id, transformation_id, model_id
|
||||||
|
)
|
||||||
|
insight_data = (
|
||||||
|
insight_response
|
||||||
|
if isinstance(insight_response, dict)
|
||||||
|
else insight_response[0]
|
||||||
|
)
|
||||||
insight = SourceInsight(
|
insight = SourceInsight(
|
||||||
insight_type=insight_data["insight_type"],
|
insight_type=insight_data["insight_type"],
|
||||||
content=insight_data["content"],
|
content=insight_data["content"],
|
||||||
|
|
@ -81,4 +97,4 @@ class InsightsService:
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
insights_service = InsightsService()
|
insights_service = InsightsService()
|
||||||
|
|
|
||||||
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
|
# Import commands to register them in the API process
|
||||||
try:
|
try:
|
||||||
|
|
||||||
logger.info("Commands imported in API process")
|
logger.info("Commands imported in API process")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to import commands in API process: {e}")
|
logger.error(f"Failed to import commands in API process: {e}")
|
||||||
|
|
@ -61,9 +60,13 @@ async def lifespan(app: FastAPI):
|
||||||
logger.warning("Database migrations are pending. Running migrations...")
|
logger.warning("Database migrations are pending. Running migrations...")
|
||||||
await migration_manager.run_migration_up()
|
await migration_manager.run_migration_up()
|
||||||
new_version = await migration_manager.get_current_version()
|
new_version = await migration_manager.get_current_version()
|
||||||
logger.success(f"Migrations completed successfully. Database is now at version {new_version}")
|
logger.success(
|
||||||
|
f"Migrations completed successfully. Database is now at version {new_version}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Database is already at the latest version. No migrations needed.")
|
logger.info(
|
||||||
|
"Database is already at the latest version. No migrations needed."
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
|
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
@ -88,7 +91,18 @@ app = FastAPI(
|
||||||
|
|
||||||
# Add password authentication middleware first
|
# Add password authentication middleware first
|
||||||
# Exclude /api/auth/status and /api/config from authentication
|
# Exclude /api/auth/status and /api/config from authentication
|
||||||
app.add_middleware(PasswordAuthMiddleware, excluded_paths=["/", "/health", "/docs", "/openapi.json", "/redoc", "/api/auth/status", "/api/config"])
|
app.add_middleware(
|
||||||
|
PasswordAuthMiddleware,
|
||||||
|
excluded_paths=[
|
||||||
|
"/",
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/openapi.json",
|
||||||
|
"/redoc",
|
||||||
|
"/api/auth/status",
|
||||||
|
"/api/config",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
# Add CORS middleware last (so it processes first)
|
# Add CORS middleware last (so it processes first)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -119,7 +133,7 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content={"detail": exc.detail},
|
content={"detail": exc.detail},
|
||||||
headers={
|
headers={
|
||||||
"Access-Control-Allow-Origin": origin,
|
**(exc.headers or {}), "Access-Control-Allow-Origin": origin,
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"Access-Control-Allow-Credentials": "true",
|
||||||
"Access-Control-Allow-Methods": "*",
|
"Access-Control-Allow-Methods": "*",
|
||||||
"Access-Control-Allow-Headers": "*",
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
|
@ -136,7 +150,9 @@ app.include_router(models.router, prefix="/api", tags=["models"])
|
||||||
app.include_router(transformations.router, prefix="/api", tags=["transformations"])
|
app.include_router(transformations.router, prefix="/api", tags=["transformations"])
|
||||||
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
||||||
app.include_router(embedding.router, prefix="/api", tags=["embedding"])
|
app.include_router(embedding.router, prefix="/api", tags=["embedding"])
|
||||||
app.include_router(embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"])
|
app.include_router(
|
||||||
|
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
|
||||||
|
)
|
||||||
app.include_router(settings.router, prefix="/api", tags=["settings"])
|
app.include_router(settings.router, prefix="/api", tags=["settings"])
|
||||||
app.include_router(context.router, prefix="/api", tags=["context"])
|
app.include_router(context.router, prefix="/api", tags=["context"])
|
||||||
app.include_router(sources.router, prefix="/api", tags=["sources"])
|
app.include_router(sources.router, prefix="/api", tags=["sources"])
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ from open_notebook.ai.models import DefaultModels, Model
|
||||||
|
|
||||||
class ModelsService:
|
class ModelsService:
|
||||||
"""Service layer for models operations using API."""
|
"""Service layer for models operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for models operations")
|
logger.info("Using API for models operations")
|
||||||
|
|
||||||
def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
|
def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
|
||||||
"""Get all models with optional type filtering."""
|
"""Get all models with optional type filtering."""
|
||||||
models_data = api_client.get_models(model_type=model_type)
|
models_data = api_client.get_models(model_type=model_type)
|
||||||
|
|
@ -32,7 +32,7 @@ class ModelsService:
|
||||||
model.updated = model_data["updated"]
|
model.updated = model_data["updated"]
|
||||||
models.append(model)
|
models.append(model)
|
||||||
return models
|
return models
|
||||||
|
|
||||||
def create_model(self, name: str, provider: str, model_type: str) -> Model:
|
def create_model(self, name: str, provider: str, model_type: str) -> Model:
|
||||||
"""Create a new model."""
|
"""Create a new model."""
|
||||||
response = api_client.create_model(name, provider, model_type)
|
response = api_client.create_model(name, provider, model_type)
|
||||||
|
|
@ -46,12 +46,12 @@ class ModelsService:
|
||||||
model.created = model_data["created"]
|
model.created = model_data["created"]
|
||||||
model.updated = model_data["updated"]
|
model.updated = model_data["updated"]
|
||||||
return model
|
return model
|
||||||
|
|
||||||
def delete_model(self, model_id: str) -> bool:
|
def delete_model(self, model_id: str) -> bool:
|
||||||
"""Delete a model."""
|
"""Delete a model."""
|
||||||
api_client.delete_model(model_id)
|
api_client.delete_model(model_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_default_models(self) -> DefaultModels:
|
def get_default_models(self) -> DefaultModels:
|
||||||
"""Get default model assignments."""
|
"""Get default model assignments."""
|
||||||
response = api_client.get_default_models()
|
response = api_client.get_default_models()
|
||||||
|
|
@ -60,15 +60,21 @@ class ModelsService:
|
||||||
|
|
||||||
# Set the values from API response
|
# Set the values from API response
|
||||||
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
||||||
defaults.default_transformation_model = defaults_data.get("default_transformation_model")
|
defaults.default_transformation_model = defaults_data.get(
|
||||||
|
"default_transformation_model"
|
||||||
|
)
|
||||||
defaults.large_context_model = defaults_data.get("large_context_model")
|
defaults.large_context_model = defaults_data.get("large_context_model")
|
||||||
defaults.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model")
|
defaults.default_text_to_speech_model = defaults_data.get(
|
||||||
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model")
|
"default_text_to_speech_model"
|
||||||
|
)
|
||||||
|
defaults.default_speech_to_text_model = defaults_data.get(
|
||||||
|
"default_speech_to_text_model"
|
||||||
|
)
|
||||||
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
||||||
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
||||||
|
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
|
def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
|
||||||
"""Update default model assignments."""
|
"""Update default model assignments."""
|
||||||
updates = {
|
updates = {
|
||||||
|
|
@ -86,10 +92,16 @@ class ModelsService:
|
||||||
|
|
||||||
# Update the defaults object with the response
|
# Update the defaults object with the response
|
||||||
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
||||||
defaults.default_transformation_model = defaults_data.get("default_transformation_model")
|
defaults.default_transformation_model = defaults_data.get(
|
||||||
|
"default_transformation_model"
|
||||||
|
)
|
||||||
defaults.large_context_model = defaults_data.get("large_context_model")
|
defaults.large_context_model = defaults_data.get("large_context_model")
|
||||||
defaults.default_text_to_speech_model = defaults_data.get("default_text_to_speech_model")
|
defaults.default_text_to_speech_model = defaults_data.get(
|
||||||
defaults.default_speech_to_text_model = defaults_data.get("default_speech_to_text_model")
|
"default_text_to_speech_model"
|
||||||
|
)
|
||||||
|
defaults.default_speech_to_text_model = defaults_data.get(
|
||||||
|
"default_speech_to_text_model"
|
||||||
|
)
|
||||||
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
||||||
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
||||||
|
|
||||||
|
|
@ -97,4 +109,4 @@ class ModelsService:
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
models_service = ModelsService()
|
models_service = ModelsService()
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Notebook
|
||||||
|
|
||||||
class NotebookService:
|
class NotebookService:
|
||||||
"""Service layer for notebook operations using API."""
|
"""Service layer for notebook operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for notebook operations")
|
logger.info("Using API for notebook operations")
|
||||||
|
|
||||||
def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]:
|
def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]:
|
||||||
"""Get all notebooks."""
|
"""Get all notebooks."""
|
||||||
notebooks_data = api_client.get_notebooks(order_by=order_by)
|
notebooks_data = api_client.get_notebooks(order_by=order_by)
|
||||||
|
|
@ -32,7 +32,7 @@ class NotebookService:
|
||||||
nb.updated = nb_data["updated"]
|
nb.updated = nb_data["updated"]
|
||||||
notebooks.append(nb)
|
notebooks.append(nb)
|
||||||
return notebooks
|
return notebooks
|
||||||
|
|
||||||
def get_notebook(self, notebook_id: str) -> Optional[Notebook]:
|
def get_notebook(self, notebook_id: str) -> Optional[Notebook]:
|
||||||
"""Get a specific notebook."""
|
"""Get a specific notebook."""
|
||||||
response = api_client.get_notebook(notebook_id)
|
response = api_client.get_notebook(notebook_id)
|
||||||
|
|
@ -60,7 +60,7 @@ class NotebookService:
|
||||||
nb.created = nb_data["created"]
|
nb.created = nb_data["created"]
|
||||||
nb.updated = nb_data["updated"]
|
nb.updated = nb_data["updated"]
|
||||||
return nb
|
return nb
|
||||||
|
|
||||||
def update_notebook(self, notebook: Notebook) -> Notebook:
|
def update_notebook(self, notebook: Notebook) -> Notebook:
|
||||||
"""Update a notebook."""
|
"""Update a notebook."""
|
||||||
updates = {
|
updates = {
|
||||||
|
|
@ -76,7 +76,7 @@ class NotebookService:
|
||||||
notebook.archived = nb_data["archived"]
|
notebook.archived = nb_data["archived"]
|
||||||
notebook.updated = nb_data["updated"]
|
notebook.updated = nb_data["updated"]
|
||||||
return notebook
|
return notebook
|
||||||
|
|
||||||
def delete_notebook(self, notebook: Notebook) -> bool:
|
def delete_notebook(self, notebook: Notebook) -> bool:
|
||||||
"""Delete a notebook."""
|
"""Delete a notebook."""
|
||||||
api_client.delete_notebook(notebook.id or "")
|
api_client.delete_notebook(notebook.id or "")
|
||||||
|
|
@ -84,4 +84,4 @@ class NotebookService:
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
notebook_service = NotebookService()
|
notebook_service = NotebookService()
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ from open_notebook.domain.notebook import Note
|
||||||
|
|
||||||
class NotesService:
|
class NotesService:
|
||||||
"""Service layer for notes operations using API."""
|
"""Service layer for notes operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for notes operations")
|
logger.info("Using API for notes operations")
|
||||||
|
|
||||||
def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:
|
def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:
|
||||||
"""Get all notes with optional notebook filtering."""
|
"""Get all notes with optional notebook filtering."""
|
||||||
notes_data = api_client.get_notes(notebook_id=notebook_id)
|
notes_data = api_client.get_notes(notebook_id=notebook_id)
|
||||||
|
|
@ -32,11 +32,13 @@ class NotesService:
|
||||||
note.updated = note_data["updated"]
|
note.updated = note_data["updated"]
|
||||||
notes.append(note)
|
notes.append(note)
|
||||||
return notes
|
return notes
|
||||||
|
|
||||||
def get_note(self, note_id: str) -> Note:
|
def get_note(self, note_id: str) -> Note:
|
||||||
"""Get a specific note."""
|
"""Get a specific note."""
|
||||||
note_response = api_client.get_note(note_id)
|
note_response = api_client.get_note(note_id)
|
||||||
note_data = note_response if isinstance(note_response, dict) else note_response[0]
|
note_data = (
|
||||||
|
note_response if isinstance(note_response, dict) else note_response[0]
|
||||||
|
)
|
||||||
note = Note(
|
note = Note(
|
||||||
title=note_data["title"],
|
title=note_data["title"],
|
||||||
content=note_data["content"],
|
content=note_data["content"],
|
||||||
|
|
@ -46,22 +48,21 @@ class NotesService:
|
||||||
note.created = note_data["created"]
|
note.created = note_data["created"]
|
||||||
note.updated = note_data["updated"]
|
note.updated = note_data["updated"]
|
||||||
return note
|
return note
|
||||||
|
|
||||||
def create_note(
|
def create_note(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
note_type: str = "human",
|
note_type: str = "human",
|
||||||
notebook_id: Optional[str] = None
|
notebook_id: Optional[str] = None,
|
||||||
) -> Note:
|
) -> Note:
|
||||||
"""Create a new note."""
|
"""Create a new note."""
|
||||||
note_response = api_client.create_note(
|
note_response = api_client.create_note(
|
||||||
content=content,
|
content=content, title=title, note_type=note_type, notebook_id=notebook_id
|
||||||
title=title,
|
)
|
||||||
note_type=note_type,
|
note_data = (
|
||||||
notebook_id=notebook_id
|
note_response if isinstance(note_response, dict) else note_response[0]
|
||||||
)
|
)
|
||||||
note_data = note_response if isinstance(note_response, dict) else note_response[0]
|
|
||||||
note = Note(
|
note = Note(
|
||||||
title=note_data["title"],
|
title=note_data["title"],
|
||||||
content=note_data["content"],
|
content=note_data["content"],
|
||||||
|
|
@ -71,7 +72,7 @@ class NotesService:
|
||||||
note.created = note_data["created"]
|
note.created = note_data["created"]
|
||||||
note.updated = note_data["updated"]
|
note.updated = note_data["updated"]
|
||||||
return note
|
return note
|
||||||
|
|
||||||
def update_note(self, note: Note) -> Note:
|
def update_note(self, note: Note) -> Note:
|
||||||
"""Update a note."""
|
"""Update a note."""
|
||||||
updates = {
|
updates = {
|
||||||
|
|
@ -80,7 +81,9 @@ class NotesService:
|
||||||
"note_type": note.note_type,
|
"note_type": note.note_type,
|
||||||
}
|
}
|
||||||
note_response = api_client.update_note(note.id or "", **updates)
|
note_response = api_client.update_note(note.id or "", **updates)
|
||||||
note_data = note_response if isinstance(note_response, dict) else note_response[0]
|
note_data = (
|
||||||
|
note_response if isinstance(note_response, dict) else note_response[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Update the note object with the response
|
# Update the note object with the response
|
||||||
note.title = note_data["title"]
|
note.title = note_data["title"]
|
||||||
|
|
@ -89,7 +92,7 @@ class NotesService:
|
||||||
note.updated = note_data["updated"]
|
note.updated = note_data["updated"]
|
||||||
|
|
||||||
return note
|
return note
|
||||||
|
|
||||||
def delete_note(self, note_id: str) -> bool:
|
def delete_note(self, note_id: str) -> bool:
|
||||||
"""Delete a note."""
|
"""Delete a note."""
|
||||||
api_client.delete_note(note_id)
|
api_client.delete_note(note_id)
|
||||||
|
|
@ -97,4 +100,4 @@ class NotesService:
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
notes_service = NotesService()
|
notes_service = NotesService()
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,7 @@ async def get_auth_status():
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"auth_enabled": auth_enabled,
|
"auth_enabled": auth_enabled,
|
||||||
"message": "Authentication is required" if auth_enabled else "Authentication is disabled"
|
"message": "Authentication is required"
|
||||||
|
if auth_enabled
|
||||||
|
else "Authentication is disabled",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from open_notebook.graphs.chat import graph as chat_graph
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# Request/Response models
|
# Request/Response models
|
||||||
class CreateSessionRequest(BaseModel):
|
class CreateSessionRequest(BaseModel):
|
||||||
notebook_id: str = Field(..., description="Notebook ID to create session for")
|
notebook_id: str = Field(..., description="Notebook ID to create session for")
|
||||||
|
|
@ -134,7 +135,8 @@ async def create_session(request: CreateSessionRequest):
|
||||||
|
|
||||||
# Create new session
|
# Create new session
|
||||||
session = ChatSession(
|
session = ChatSession(
|
||||||
title=request.title or f"Chat Session {asyncio.get_event_loop().time():.0f}",
|
title=request.title
|
||||||
|
or f"Chat Session {asyncio.get_event_loop().time():.0f}",
|
||||||
model_override=request.model_override,
|
model_override=request.model_override,
|
||||||
)
|
)
|
||||||
await session.save()
|
await session.save()
|
||||||
|
|
@ -334,9 +336,7 @@ async def execute_chat(request: ExecuteChatRequest):
|
||||||
|
|
||||||
# Get current state
|
# Get current state
|
||||||
current_state = chat_graph.get_state(
|
current_state = chat_graph.get_state(
|
||||||
config=RunnableConfig(
|
config=RunnableConfig(configurable={"thread_id": request.session_id})
|
||||||
configurable={"thread_id": request.session_id}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare state for execution
|
# Prepare state for execution
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,21 @@ from api.command_service import CommandService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class CommandExecutionRequest(BaseModel):
|
class CommandExecutionRequest(BaseModel):
|
||||||
command: str = Field(..., description="Command function name (e.g., 'process_text')")
|
command: str = Field(
|
||||||
|
..., description="Command function name (e.g., 'process_text')"
|
||||||
|
)
|
||||||
app: str = Field(..., description="Application name (e.g., 'open_notebook')")
|
app: str = Field(..., description="Application name (e.g., 'open_notebook')")
|
||||||
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
|
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
|
||||||
|
|
||||||
|
|
||||||
class CommandJobResponse(BaseModel):
|
class CommandJobResponse(BaseModel):
|
||||||
job_id: str
|
job_id: str
|
||||||
status: str
|
status: str
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
class CommandJobStatusResponse(BaseModel):
|
class CommandJobStatusResponse(BaseModel):
|
||||||
job_id: str
|
job_id: str
|
||||||
status: str
|
status: str
|
||||||
|
|
@ -28,19 +33,20 @@ class CommandJobStatusResponse(BaseModel):
|
||||||
updated: Optional[str] = None
|
updated: Optional[str] = None
|
||||||
progress: Optional[Dict[str, Any]] = None
|
progress: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/commands/jobs", response_model=CommandJobResponse)
|
@router.post("/commands/jobs", response_model=CommandJobResponse)
|
||||||
async def execute_command(request: CommandExecutionRequest):
|
async def execute_command(request: CommandExecutionRequest):
|
||||||
"""
|
"""
|
||||||
Submit a command for background processing.
|
Submit a command for background processing.
|
||||||
Returns immediately with job ID for status tracking.
|
Returns immediately with job ID for status tracking.
|
||||||
|
|
||||||
Example request:
|
Example request:
|
||||||
{
|
{
|
||||||
"command": "process_text",
|
"command": "process_text",
|
||||||
"app": "open_notebook",
|
"app": "open_notebook",
|
||||||
"input": {
|
"input": {
|
||||||
"text": "Hello world",
|
"text": "Hello world",
|
||||||
"operation": "uppercase"
|
"operation": "uppercase"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
@ -49,91 +55,91 @@ async def execute_command(request: CommandExecutionRequest):
|
||||||
job_id = await CommandService.submit_command_job(
|
job_id = await CommandService.submit_command_job(
|
||||||
module_name=request.app, # This should be "open_notebook"
|
module_name=request.app, # This should be "open_notebook"
|
||||||
command_name=request.command,
|
command_name=request.command,
|
||||||
command_args=request.input
|
command_args=request.input,
|
||||||
)
|
)
|
||||||
|
|
||||||
return CommandJobResponse(
|
return CommandJobResponse(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
status="submitted",
|
status="submitted",
|
||||||
message=f"Command '{request.command}' submitted successfully"
|
message=f"Command '{request.command}' submitted successfully",
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error submitting command: {str(e)}")
|
logger.error(f"Error submitting command: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to submit command"
|
||||||
detail=f"Failed to submit command: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
|
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
|
||||||
async def get_command_job_status(job_id: str):
|
async def get_command_job_status(job_id: str):
|
||||||
"""Get the status of a specific command job"""
|
"""Get the status of a specific command job"""
|
||||||
try:
|
try:
|
||||||
status_data = await CommandService.get_command_status(job_id)
|
status_data = await CommandService.get_command_status(job_id)
|
||||||
return CommandJobStatusResponse(**status_data)
|
return CommandJobStatusResponse(**status_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching job status: {str(e)}")
|
logger.error(f"Error fetching job status: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to fetch job status"
|
||||||
detail=f"Failed to fetch job status: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
|
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
|
||||||
async def list_command_jobs(
|
async def list_command_jobs(
|
||||||
command_filter: Optional[str] = Query(None, description="Filter by command name"),
|
command_filter: Optional[str] = Query(None, description="Filter by command name"),
|
||||||
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
||||||
limit: int = Query(50, description="Maximum number of jobs to return")
|
limit: int = Query(50, description="Maximum number of jobs to return"),
|
||||||
):
|
):
|
||||||
"""List command jobs with optional filtering"""
|
"""List command jobs with optional filtering"""
|
||||||
try:
|
try:
|
||||||
jobs = await CommandService.list_command_jobs(
|
jobs = await CommandService.list_command_jobs(
|
||||||
command_filter=command_filter,
|
command_filter=command_filter, status_filter=status_filter, limit=limit
|
||||||
status_filter=status_filter,
|
|
||||||
limit=limit
|
|
||||||
)
|
)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing command jobs: {str(e)}")
|
logger.error(f"Error listing command jobs: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to list command jobs"
|
||||||
detail=f"Failed to list command jobs: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/commands/jobs/{job_id}")
|
@router.delete("/commands/jobs/{job_id}")
|
||||||
async def cancel_command_job(job_id: str):
|
async def cancel_command_job(job_id: str):
|
||||||
"""Cancel a running command job"""
|
"""Cancel a running command job"""
|
||||||
try:
|
try:
|
||||||
success = await CommandService.cancel_command_job(job_id)
|
success = await CommandService.cancel_command_job(job_id)
|
||||||
return {"job_id": job_id, "cancelled": success}
|
return {"job_id": job_id, "cancelled": success}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cancelling command job: {str(e)}")
|
logger.error(f"Error cancelling command job: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to cancel command job"
|
||||||
detail=f"Failed to cancel command job: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/commands/registry/debug")
|
@router.get("/commands/registry/debug")
|
||||||
async def debug_registry():
|
async def debug_registry():
|
||||||
"""Debug endpoint to see what commands are registered"""
|
"""Debug endpoint to see what commands are registered"""
|
||||||
try:
|
try:
|
||||||
# Get all registered commands
|
# Get all registered commands
|
||||||
all_items = registry.get_all_commands()
|
all_items = registry.get_all_commands()
|
||||||
|
|
||||||
# Create JSON-serializable data
|
# Create JSON-serializable data
|
||||||
command_items = []
|
command_items = []
|
||||||
for item in all_items:
|
for item in all_items:
|
||||||
try:
|
try:
|
||||||
command_items.append({
|
command_items.append(
|
||||||
"app_id": item.app_id,
|
{
|
||||||
"name": item.name,
|
"app_id": item.app_id,
|
||||||
"full_id": f"{item.app_id}.{item.name}"
|
"name": item.name,
|
||||||
})
|
"full_id": f"{item.app_id}.{item.name}",
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as item_error:
|
except Exception as item_error:
|
||||||
logger.error(f"Error processing item: {item_error}")
|
logger.error(f"Error processing item: {item_error}")
|
||||||
|
|
||||||
# Get the basic command structure
|
# Get the basic command structure
|
||||||
try:
|
try:
|
||||||
commands_dict: dict[str, list[str]] = {}
|
commands_dict: dict[str, list[str]] = {}
|
||||||
|
|
@ -143,18 +149,18 @@ async def debug_registry():
|
||||||
commands_dict[item.app_id].append(item.name)
|
commands_dict[item.app_id].append(item.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
commands_dict = {}
|
commands_dict = {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_commands": len(all_items),
|
"total_commands": len(all_items),
|
||||||
"commands_by_app": commands_dict,
|
"commands_by_app": commands_dict,
|
||||||
"command_items": command_items
|
"command_items": command_items,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error debugging registry: {str(e)}")
|
logger.error(f"Error debugging registry: {str(e)}")
|
||||||
return {
|
return {
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
"total_commands": 0,
|
"total_commands": 0,
|
||||||
"commands_by_app": {},
|
"commands_by_app": {},
|
||||||
"command_items": []
|
"command_items": [],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from loguru import logger
|
||||||
from open_notebook.database.repository import repo_query
|
from open_notebook.database.repository import repo_query
|
||||||
from open_notebook.utils.version_utils import (
|
from open_notebook.utils.version_utils import (
|
||||||
compare_versions,
|
compare_versions,
|
||||||
get_version_from_github,
|
get_version_from_github_async,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -40,7 +40,7 @@ def get_version() -> str:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
|
async def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
|
||||||
"""
|
"""
|
||||||
Check for the latest version from GitHub with caching.
|
Check for the latest version from GitHub with caching.
|
||||||
|
|
||||||
|
|
@ -66,12 +66,13 @@ def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool
|
||||||
logger.info("Checking for latest version from GitHub...")
|
logger.info("Checking for latest version from GitHub...")
|
||||||
|
|
||||||
# Fetch latest version from GitHub with 10-second timeout
|
# Fetch latest version from GitHub with 10-second timeout
|
||||||
latest_version = get_version_from_github(
|
latest_version = await get_version_from_github_async(
|
||||||
"https://github.com/lfnovo/open-notebook",
|
"https://github.com/lfnovo/open-notebook", "main"
|
||||||
"main"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Latest version from GitHub: {latest_version}, Current version: {current_version}")
|
logger.info(
|
||||||
|
f"Latest version from GitHub: {latest_version}, Current version: {current_version}"
|
||||||
|
)
|
||||||
|
|
||||||
# Compare versions
|
# Compare versions
|
||||||
has_update = compare_versions(current_version, latest_version) < 0
|
has_update = compare_versions(current_version, latest_version) < 0
|
||||||
|
|
@ -107,10 +108,7 @@ async def check_database_health() -> dict:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 2-second timeout for database health check
|
# 2-second timeout for database health check
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
|
||||||
repo_query("RETURN 1"),
|
|
||||||
timeout=2.0
|
|
||||||
)
|
|
||||||
if result:
|
if result:
|
||||||
return {"status": "online"}
|
return {"status": "online"}
|
||||||
return {"status": "offline", "error": "Empty result"}
|
return {"status": "offline", "error": "Empty result"}
|
||||||
|
|
@ -142,7 +140,7 @@ async def get_config(request: Request):
|
||||||
has_update = False
|
has_update = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
latest_version, has_update = get_latest_version_cached(current_version)
|
latest_version, has_update = await get_latest_version_cached(current_version)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Extra safety: ensure version check never breaks the config endpoint
|
# Extra safety: ensure version check never breaks the config endpoint
|
||||||
logger.error(f"Unexpected error during version check: {e}")
|
logger.error(f"Unexpected error during version check: {e}")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,11 @@ async def embed_content(embed_request: EmbedRequest):
|
||||||
message = "Note embedded successfully"
|
message = "Note embedded successfully"
|
||||||
|
|
||||||
return EmbedResponse(
|
return EmbedResponse(
|
||||||
success=True, message=message, item_id=item_id, item_type=item_type, command_id=command_id
|
success=True,
|
||||||
|
message=message,
|
||||||
|
item_id=item_id,
|
||||||
|
item_type=item_type,
|
||||||
|
command_id=command_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -173,10 +173,12 @@ async def get_rebuild_status(command_id: str):
|
||||||
response.completed_at = str(status.updated)
|
response.completed_at = str(status.updated)
|
||||||
|
|
||||||
# Add error message if failed
|
# Add error message if failed
|
||||||
if status.status == "failed" and status.result and isinstance(status.result, dict):
|
if (
|
||||||
response.error_message = status.result.get(
|
status.status == "failed"
|
||||||
"error_message", "Unknown error"
|
and status.result
|
||||||
)
|
and isinstance(status.result, dict)
|
||||||
|
):
|
||||||
|
response.error_message = status.result.get("error_message", "Unknown error")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ async def list_episode_profiles():
|
||||||
"""List all available episode profiles"""
|
"""List all available episode profiles"""
|
||||||
try:
|
try:
|
||||||
profiles = await EpisodeProfile.get_all(order_by="name asc")
|
profiles = await EpisodeProfile.get_all(order_by="name asc")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
EpisodeProfileResponse(
|
EpisodeProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
|
|
@ -39,16 +39,15 @@ async def list_episode_profiles():
|
||||||
transcript_provider=profile.transcript_provider,
|
transcript_provider=profile.transcript_provider,
|
||||||
transcript_model=profile.transcript_model,
|
transcript_model=profile.transcript_model,
|
||||||
default_briefing=profile.default_briefing,
|
default_briefing=profile.default_briefing,
|
||||||
num_segments=profile.num_segments
|
num_segments=profile.num_segments,
|
||||||
)
|
)
|
||||||
for profile in profiles
|
for profile in profiles
|
||||||
]
|
]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch episode profiles: {e}")
|
logger.error(f"Failed to fetch episode profiles: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to fetch episode profiles"
|
||||||
detail=f"Failed to fetch episode profiles: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,13 +56,12 @@ async def get_episode_profile(profile_name: str):
|
||||||
"""Get a specific episode profile by name"""
|
"""Get a specific episode profile by name"""
|
||||||
try:
|
try:
|
||||||
profile = await EpisodeProfile.get_by_name(profile_name)
|
profile = await EpisodeProfile.get_by_name(profile_name)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Episode profile '{profile_name}' not found"
|
||||||
detail=f"Episode profile '{profile_name}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return EpisodeProfileResponse(
|
return EpisodeProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
|
|
@ -74,16 +72,15 @@ async def get_episode_profile(profile_name: str):
|
||||||
transcript_provider=profile.transcript_provider,
|
transcript_provider=profile.transcript_provider,
|
||||||
transcript_model=profile.transcript_model,
|
transcript_model=profile.transcript_model,
|
||||||
default_briefing=profile.default_briefing,
|
default_briefing=profile.default_briefing,
|
||||||
num_segments=profile.num_segments
|
num_segments=profile.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch episode profile '{profile_name}': {e}")
|
logger.error(f"Failed to fetch episode profile '{profile_name}': {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to fetch episode profile"
|
||||||
detail=f"Failed to fetch episode profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,7 +90,9 @@ class EpisodeProfileCreate(BaseModel):
|
||||||
speaker_config: str = Field(..., description="Reference to speaker profile name")
|
speaker_config: str = Field(..., description="Reference to speaker profile name")
|
||||||
outline_provider: str = Field(..., description="AI provider for outline generation")
|
outline_provider: str = Field(..., description="AI provider for outline generation")
|
||||||
outline_model: str = Field(..., description="AI model for outline generation")
|
outline_model: str = Field(..., description="AI model for outline generation")
|
||||||
transcript_provider: str = Field(..., description="AI provider for transcript generation")
|
transcript_provider: str = Field(
|
||||||
|
..., description="AI provider for transcript generation"
|
||||||
|
)
|
||||||
transcript_model: str = Field(..., description="AI model for transcript generation")
|
transcript_model: str = Field(..., description="AI model for transcript generation")
|
||||||
default_briefing: str = Field(..., description="Default briefing template")
|
default_briefing: str = Field(..., description="Default briefing template")
|
||||||
num_segments: int = Field(default=5, description="Number of podcast segments")
|
num_segments: int = Field(default=5, description="Number of podcast segments")
|
||||||
|
|
@ -112,11 +111,11 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
|
||||||
transcript_provider=profile_data.transcript_provider,
|
transcript_provider=profile_data.transcript_provider,
|
||||||
transcript_model=profile_data.transcript_model,
|
transcript_model=profile_data.transcript_model,
|
||||||
default_briefing=profile_data.default_briefing,
|
default_briefing=profile_data.default_briefing,
|
||||||
num_segments=profile_data.num_segments
|
num_segments=profile_data.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
await profile.save()
|
await profile.save()
|
||||||
|
|
||||||
return EpisodeProfileResponse(
|
return EpisodeProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
|
|
@ -127,14 +126,13 @@ async def create_episode_profile(profile_data: EpisodeProfileCreate):
|
||||||
transcript_provider=profile.transcript_provider,
|
transcript_provider=profile.transcript_provider,
|
||||||
transcript_model=profile.transcript_model,
|
transcript_model=profile.transcript_model,
|
||||||
default_briefing=profile.default_briefing,
|
default_briefing=profile.default_briefing,
|
||||||
num_segments=profile.num_segments
|
num_segments=profile.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create episode profile: {e}")
|
logger.error(f"Failed to create episode profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to create episode profile"
|
||||||
detail=f"Failed to create episode profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -143,13 +141,12 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
|
||||||
"""Update an existing episode profile"""
|
"""Update an existing episode profile"""
|
||||||
try:
|
try:
|
||||||
profile = await EpisodeProfile.get(profile_id)
|
profile = await EpisodeProfile.get(profile_id)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Episode profile '{profile_id}' not found"
|
||||||
detail=f"Episode profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
profile.name = profile_data.name
|
profile.name = profile_data.name
|
||||||
profile.description = profile_data.description
|
profile.description = profile_data.description
|
||||||
|
|
@ -160,9 +157,9 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
|
||||||
profile.transcript_model = profile_data.transcript_model
|
profile.transcript_model = profile_data.transcript_model
|
||||||
profile.default_briefing = profile_data.default_briefing
|
profile.default_briefing = profile_data.default_briefing
|
||||||
profile.num_segments = profile_data.num_segments
|
profile.num_segments = profile_data.num_segments
|
||||||
|
|
||||||
await profile.save()
|
await profile.save()
|
||||||
|
|
||||||
return EpisodeProfileResponse(
|
return EpisodeProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
|
|
@ -173,16 +170,15 @@ async def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCr
|
||||||
transcript_provider=profile.transcript_provider,
|
transcript_provider=profile.transcript_provider,
|
||||||
transcript_model=profile.transcript_model,
|
transcript_model=profile.transcript_model,
|
||||||
default_briefing=profile.default_briefing,
|
default_briefing=profile.default_briefing,
|
||||||
num_segments=profile.num_segments
|
num_segments=profile.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update episode profile: {e}")
|
logger.error(f"Failed to update episode profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to update episode profile"
|
||||||
detail=f"Failed to update episode profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -191,39 +187,38 @@ async def delete_episode_profile(profile_id: str):
|
||||||
"""Delete an episode profile"""
|
"""Delete an episode profile"""
|
||||||
try:
|
try:
|
||||||
profile = await EpisodeProfile.get(profile_id)
|
profile = await EpisodeProfile.get(profile_id)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Episode profile '{profile_id}' not found"
|
||||||
detail=f"Episode profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await profile.delete()
|
await profile.delete()
|
||||||
|
|
||||||
return {"message": "Episode profile deleted successfully"}
|
return {"message": "Episode profile deleted successfully"}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete episode profile: {e}")
|
logger.error(f"Failed to delete episode profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to delete episode profile"
|
||||||
detail=f"Failed to delete episode profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse)
|
@router.post(
|
||||||
|
"/episode-profiles/{profile_id}/duplicate", response_model=EpisodeProfileResponse
|
||||||
|
)
|
||||||
async def duplicate_episode_profile(profile_id: str):
|
async def duplicate_episode_profile(profile_id: str):
|
||||||
"""Duplicate an episode profile"""
|
"""Duplicate an episode profile"""
|
||||||
try:
|
try:
|
||||||
original = await EpisodeProfile.get(profile_id)
|
original = await EpisodeProfile.get(profile_id)
|
||||||
|
|
||||||
if not original:
|
if not original:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Episode profile '{profile_id}' not found"
|
||||||
detail=f"Episode profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create duplicate with modified name
|
# Create duplicate with modified name
|
||||||
duplicate = EpisodeProfile(
|
duplicate = EpisodeProfile(
|
||||||
name=f"{original.name} - Copy",
|
name=f"{original.name} - Copy",
|
||||||
|
|
@ -234,11 +229,11 @@ async def duplicate_episode_profile(profile_id: str):
|
||||||
transcript_provider=original.transcript_provider,
|
transcript_provider=original.transcript_provider,
|
||||||
transcript_model=original.transcript_model,
|
transcript_model=original.transcript_model,
|
||||||
default_briefing=original.default_briefing,
|
default_briefing=original.default_briefing,
|
||||||
num_segments=original.num_segments
|
num_segments=original.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
await duplicate.save()
|
await duplicate.save()
|
||||||
|
|
||||||
return EpisodeProfileResponse(
|
return EpisodeProfileResponse(
|
||||||
id=str(duplicate.id),
|
id=str(duplicate.id),
|
||||||
name=duplicate.name,
|
name=duplicate.name,
|
||||||
|
|
@ -249,14 +244,13 @@ async def duplicate_episode_profile(profile_id: str):
|
||||||
transcript_provider=duplicate.transcript_provider,
|
transcript_provider=duplicate.transcript_provider,
|
||||||
transcript_model=duplicate.transcript_model,
|
transcript_model=duplicate.transcript_model,
|
||||||
default_briefing=duplicate.default_briefing,
|
default_briefing=duplicate.default_briefing,
|
||||||
num_segments=duplicate.num_segments
|
num_segments=duplicate.num_segments,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to duplicate episode profile: {e}")
|
logger.error(f"Failed to duplicate episode profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to duplicate episode profile"
|
||||||
detail=f"Failed to duplicate episode profile: {str(e)}"
|
)
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
@ -16,10 +15,10 @@ async def get_insight(insight_id: str):
|
||||||
insight = await SourceInsight.get(insight_id)
|
insight = await SourceInsight.get(insight_id)
|
||||||
if not insight:
|
if not insight:
|
||||||
raise HTTPException(status_code=404, detail="Insight not found")
|
raise HTTPException(status_code=404, detail="Insight not found")
|
||||||
|
|
||||||
# Get source ID from the insight relationship
|
# Get source ID from the insight relationship
|
||||||
source = await insight.get_source()
|
source = await insight.get_source()
|
||||||
|
|
||||||
return SourceInsightResponse(
|
return SourceInsightResponse(
|
||||||
id=insight.id or "",
|
id=insight.id or "",
|
||||||
source_id=source.id or "",
|
source_id=source.id or "",
|
||||||
|
|
@ -32,7 +31,7 @@ async def get_insight(insight_id: str):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching insight {insight_id}: {str(e)}")
|
logger.error(f"Error fetching insight {insight_id}: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching insight: {str(e)}")
|
raise HTTPException(status_code=500, detail="Error fetching insight")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/insights/{insight_id}")
|
@router.delete("/insights/{insight_id}")
|
||||||
|
|
@ -42,15 +41,15 @@ async def delete_insight(insight_id: str):
|
||||||
insight = await SourceInsight.get(insight_id)
|
insight = await SourceInsight.get(insight_id)
|
||||||
if not insight:
|
if not insight:
|
||||||
raise HTTPException(status_code=404, detail="Insight not found")
|
raise HTTPException(status_code=404, detail="Insight not found")
|
||||||
|
|
||||||
await insight.delete()
|
await insight.delete()
|
||||||
|
|
||||||
return {"message": "Insight deleted successfully"}
|
return {"message": "Insight deleted successfully"}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting insight {insight_id}: {str(e)}")
|
logger.error(f"Error deleting insight {insight_id}: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error deleting insight: {str(e)}")
|
raise HTTPException(status_code=500, detail="Error deleting insight")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse)
|
@router.post("/insights/{insight_id}/save-as-note", response_model=NoteResponse)
|
||||||
|
|
@ -60,10 +59,10 @@ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
|
||||||
insight = await SourceInsight.get(insight_id)
|
insight = await SourceInsight.get(insight_id)
|
||||||
if not insight:
|
if not insight:
|
||||||
raise HTTPException(status_code=404, detail="Insight not found")
|
raise HTTPException(status_code=404, detail="Insight not found")
|
||||||
|
|
||||||
# Use the existing save_as_note method from the domain model
|
# Use the existing save_as_note method from the domain model
|
||||||
note = await insight.save_as_note(request.notebook_id)
|
note = await insight.save_as_note(request.notebook_id)
|
||||||
|
|
||||||
return NoteResponse(
|
return NoteResponse(
|
||||||
id=note.id or "",
|
id=note.id or "",
|
||||||
title=note.title,
|
title=note.title,
|
||||||
|
|
@ -78,4 +77,6 @@ async def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving insight {insight_id} as note: {str(e)}")
|
logger.error(f"Error saving insight {insight_id} as note: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error saving insight as note: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Error saving insight as note"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def _check_azure_support(mode: str) -> bool:
|
||||||
|
|
||||||
@router.get("/models", response_model=List[ModelResponse])
|
@router.get("/models", response_model=List[ModelResponse])
|
||||||
async def get_models(
|
async def get_models(
|
||||||
type: Optional[str] = Query(None, description="Filter by model type")
|
type: Optional[str] = Query(None, description="Filter by model type"),
|
||||||
):
|
):
|
||||||
"""Get all configured models with optional type filtering."""
|
"""Get all configured models with optional type filtering."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -69,7 +69,7 @@ async def get_models(
|
||||||
models = await Model.get_models_by_type(type)
|
models = await Model.get_models_by_type(type)
|
||||||
else:
|
else:
|
||||||
models = await Model.get_all()
|
models = await Model.get_all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ModelResponse(
|
ModelResponse(
|
||||||
id=model.id,
|
id=model.id,
|
||||||
|
|
@ -95,19 +95,24 @@ async def create_model(model_data: ModelCreate):
|
||||||
if model_data.type not in valid_types:
|
if model_data.type not in valid_types:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid model type. Must be one of: {valid_types}"
|
detail=f"Invalid model type. Must be one of: {valid_types}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for duplicate model name under the same provider and type (case-insensitive)
|
# Check for duplicate model name under the same provider and type (case-insensitive)
|
||||||
from open_notebook.database.repository import repo_query
|
from open_notebook.database.repository import repo_query
|
||||||
|
|
||||||
existing = await repo_query(
|
existing = await repo_query(
|
||||||
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1",
|
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1",
|
||||||
{"provider": model_data.provider.lower(), "name": model_data.name.lower(), "type": model_data.type.lower()}
|
{
|
||||||
|
"provider": model_data.provider.lower(),
|
||||||
|
"name": model_data.name.lower(),
|
||||||
|
"type": model_data.type.lower(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'"
|
detail=f"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
new_model = Model(
|
new_model = Model(
|
||||||
|
|
@ -141,9 +146,9 @@ async def delete_model(model_id: str):
|
||||||
model = await Model.get(model_id)
|
model = await Model.get(model_id)
|
||||||
if not model:
|
if not model:
|
||||||
raise HTTPException(status_code=404, detail="Model not found")
|
raise HTTPException(status_code=404, detail="Model not found")
|
||||||
|
|
||||||
await model.delete()
|
await model.delete()
|
||||||
|
|
||||||
return {"message": "Model deleted successfully"}
|
return {"message": "Model deleted successfully"}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -169,7 +174,9 @@ async def get_default_models():
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching default models: {str(e)}")
|
logger.error(f"Error fetching default models: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching default models: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error fetching default models: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/models/defaults", response_model=DefaultModelsResponse)
|
@router.put("/models/defaults", response_model=DefaultModelsResponse)
|
||||||
|
|
@ -177,23 +184,29 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
|
||||||
"""Update default model assignments."""
|
"""Update default model assignments."""
|
||||||
try:
|
try:
|
||||||
defaults = await DefaultModels.get_instance()
|
defaults = await DefaultModels.get_instance()
|
||||||
|
|
||||||
# Update only provided fields
|
# Update only provided fields
|
||||||
if defaults_data.default_chat_model is not None:
|
if defaults_data.default_chat_model is not None:
|
||||||
defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined]
|
defaults.default_chat_model = defaults_data.default_chat_model # type: ignore[attr-defined]
|
||||||
if defaults_data.default_transformation_model is not None:
|
if defaults_data.default_transformation_model is not None:
|
||||||
defaults.default_transformation_model = defaults_data.default_transformation_model # type: ignore[attr-defined]
|
defaults.default_transformation_model = (
|
||||||
|
defaults_data.default_transformation_model
|
||||||
|
) # type: ignore[attr-defined]
|
||||||
if defaults_data.large_context_model is not None:
|
if defaults_data.large_context_model is not None:
|
||||||
defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined]
|
defaults.large_context_model = defaults_data.large_context_model # type: ignore[attr-defined]
|
||||||
if defaults_data.default_text_to_speech_model is not None:
|
if defaults_data.default_text_to_speech_model is not None:
|
||||||
defaults.default_text_to_speech_model = defaults_data.default_text_to_speech_model # type: ignore[attr-defined]
|
defaults.default_text_to_speech_model = (
|
||||||
|
defaults_data.default_text_to_speech_model
|
||||||
|
) # type: ignore[attr-defined]
|
||||||
if defaults_data.default_speech_to_text_model is not None:
|
if defaults_data.default_speech_to_text_model is not None:
|
||||||
defaults.default_speech_to_text_model = defaults_data.default_speech_to_text_model # type: ignore[attr-defined]
|
defaults.default_speech_to_text_model = (
|
||||||
|
defaults_data.default_speech_to_text_model
|
||||||
|
) # type: ignore[attr-defined]
|
||||||
if defaults_data.default_embedding_model is not None:
|
if defaults_data.default_embedding_model is not None:
|
||||||
defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined]
|
defaults.default_embedding_model = defaults_data.default_embedding_model # type: ignore[attr-defined]
|
||||||
if defaults_data.default_tools_model is not None:
|
if defaults_data.default_tools_model is not None:
|
||||||
defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined]
|
defaults.default_tools_model = defaults_data.default_tools_model # type: ignore[attr-defined]
|
||||||
|
|
||||||
await defaults.update()
|
await defaults.update()
|
||||||
|
|
||||||
# No cache refresh needed - next access will fetch fresh data from DB
|
# No cache refresh needed - next access will fetch fresh data from DB
|
||||||
|
|
@ -211,7 +224,9 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating default models: {str(e)}")
|
logger.error(f"Error updating default models: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error updating default models: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error updating default models: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models/providers", response_model=ProviderAvailabilityResponse)
|
@router.get("/models/providers", response_model=ProviderAvailabilityResponse)
|
||||||
|
|
@ -252,7 +267,7 @@ async def get_provider_availability():
|
||||||
or _check_openai_compatible_support("TTS")
|
or _check_openai_compatible_support("TTS")
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
available_providers = [k for k, v in provider_status.items() if v]
|
available_providers = [k for k, v in provider_status.items() if v]
|
||||||
unavailable_providers = [k for k, v in provider_status.items() if not v]
|
unavailable_providers = [k for k, v in provider_status.items() if not v]
|
||||||
|
|
||||||
|
|
@ -275,13 +290,19 @@ async def get_provider_availability():
|
||||||
# Special handling for openai-compatible to check mode-specific availability
|
# Special handling for openai-compatible to check mode-specific availability
|
||||||
if provider == "openai-compatible":
|
if provider == "openai-compatible":
|
||||||
for model_type, mode in mode_mapping.items():
|
for model_type, mode in mode_mapping.items():
|
||||||
if model_type in esperanto_available and provider in esperanto_available[model_type]:
|
if (
|
||||||
|
model_type in esperanto_available
|
||||||
|
and provider in esperanto_available[model_type]
|
||||||
|
):
|
||||||
if _check_openai_compatible_support(mode):
|
if _check_openai_compatible_support(mode):
|
||||||
supported_types[provider].append(model_type)
|
supported_types[provider].append(model_type)
|
||||||
# Special handling for azure to check mode-specific availability
|
# Special handling for azure to check mode-specific availability
|
||||||
elif provider == "azure":
|
elif provider == "azure":
|
||||||
for model_type, mode in mode_mapping.items():
|
for model_type, mode in mode_mapping.items():
|
||||||
if model_type in esperanto_available and provider in esperanto_available[model_type]:
|
if (
|
||||||
|
model_type in esperanto_available
|
||||||
|
and provider in esperanto_available[model_type]
|
||||||
|
):
|
||||||
if _check_azure_support(mode):
|
if _check_azure_support(mode):
|
||||||
supported_types[provider].append(model_type)
|
supported_types[provider].append(model_type)
|
||||||
else:
|
else:
|
||||||
|
|
@ -289,12 +310,14 @@ async def get_provider_availability():
|
||||||
for model_type, providers in esperanto_available.items():
|
for model_type, providers in esperanto_available.items():
|
||||||
if provider in providers:
|
if provider in providers:
|
||||||
supported_types[provider].append(model_type)
|
supported_types[provider].append(model_type)
|
||||||
|
|
||||||
return ProviderAvailabilityResponse(
|
return ProviderAvailabilityResponse(
|
||||||
available=available_providers,
|
available=available_providers,
|
||||||
unavailable=unavailable_providers,
|
unavailable=unavailable_providers,
|
||||||
supported_types=supported_types
|
supported_types=supported_types,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking provider availability: {str(e)}")
|
logger.error(f"Error checking provider availability: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error checking provider availability: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error checking provider availability: {str(e)}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/notes", response_model=List[NoteResponse])
|
@router.get("/notes", response_model=List[NoteResponse])
|
||||||
async def get_notes(
|
async def get_notes(
|
||||||
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID")
|
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
|
||||||
):
|
):
|
||||||
"""Get all notes with optional notebook filtering."""
|
"""Get all notes with optional notebook filtering."""
|
||||||
try:
|
try:
|
||||||
if notebook_id:
|
if notebook_id:
|
||||||
# Get notes for a specific notebook
|
# Get notes for a specific notebook
|
||||||
from open_notebook.domain.notebook import Notebook
|
from open_notebook.domain.notebook import Notebook
|
||||||
|
|
||||||
notebook = await Notebook.get(notebook_id)
|
notebook = await Notebook.get(notebook_id)
|
||||||
if not notebook:
|
if not notebook:
|
||||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||||
|
|
@ -26,7 +27,7 @@ async def get_notes(
|
||||||
else:
|
else:
|
||||||
# Get all notes
|
# Get all notes
|
||||||
notes = await Note.get_all(order_by="updated desc")
|
notes = await Note.get_all(order_by="updated desc")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
NoteResponse(
|
NoteResponse(
|
||||||
id=note.id or "",
|
id=note.id or "",
|
||||||
|
|
@ -53,21 +54,24 @@ async def create_note(note_data: NoteCreate):
|
||||||
title = note_data.title
|
title = note_data.title
|
||||||
if not title and note_data.note_type == "ai" and note_data.content:
|
if not title and note_data.note_type == "ai" and note_data.content:
|
||||||
from open_notebook.graphs.prompt import graph as prompt_graph
|
from open_notebook.graphs.prompt import graph as prompt_graph
|
||||||
|
|
||||||
prompt = "Based on the Note below, please provide a Title for this content, with max 15 words"
|
prompt = "Based on the Note below, please provide a Title for this content, with max 15 words"
|
||||||
result = await prompt_graph.ainvoke(
|
result = await prompt_graph.ainvoke(
|
||||||
{ # type: ignore[arg-type]
|
{ # type: ignore[arg-type]
|
||||||
"input_text": note_data.content,
|
"input_text": note_data.content,
|
||||||
"prompt": prompt
|
"prompt": prompt,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
title = result.get("output", "Untitled Note")
|
title = result.get("output", "Untitled Note")
|
||||||
|
|
||||||
# Validate note_type
|
# Validate note_type
|
||||||
note_type: Optional[Literal["human", "ai"]] = None
|
note_type: Optional[Literal["human", "ai"]] = None
|
||||||
if note_data.note_type in ("human", "ai"):
|
if note_data.note_type in ("human", "ai"):
|
||||||
note_type = note_data.note_type # type: ignore[assignment]
|
note_type = note_data.note_type # type: ignore[assignment]
|
||||||
elif note_data.note_type is not None:
|
elif note_data.note_type is not None:
|
||||||
raise HTTPException(status_code=400, detail="note_type must be 'human' or 'ai'")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="note_type must be 'human' or 'ai'"
|
||||||
|
)
|
||||||
|
|
||||||
new_note = Note(
|
new_note = Note(
|
||||||
title=title,
|
title=title,
|
||||||
|
|
@ -75,15 +79,16 @@ async def create_note(note_data: NoteCreate):
|
||||||
note_type=note_type,
|
note_type=note_type,
|
||||||
)
|
)
|
||||||
await new_note.save()
|
await new_note.save()
|
||||||
|
|
||||||
# Add to notebook if specified
|
# Add to notebook if specified
|
||||||
if note_data.notebook_id:
|
if note_data.notebook_id:
|
||||||
from open_notebook.domain.notebook import Notebook
|
from open_notebook.domain.notebook import Notebook
|
||||||
|
|
||||||
notebook = await Notebook.get(note_data.notebook_id)
|
notebook = await Notebook.get(note_data.notebook_id)
|
||||||
if not notebook:
|
if not notebook:
|
||||||
raise HTTPException(status_code=404, detail="Notebook not found")
|
raise HTTPException(status_code=404, detail="Notebook not found")
|
||||||
await new_note.add_to_notebook(note_data.notebook_id)
|
await new_note.add_to_notebook(note_data.notebook_id)
|
||||||
|
|
||||||
return NoteResponse(
|
return NoteResponse(
|
||||||
id=new_note.id or "",
|
id=new_note.id or "",
|
||||||
title=new_note.title,
|
title=new_note.title,
|
||||||
|
|
@ -108,7 +113,7 @@ async def get_note(note_id: str):
|
||||||
note = await Note.get(note_id)
|
note = await Note.get(note_id)
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
|
||||||
return NoteResponse(
|
return NoteResponse(
|
||||||
id=note.id or "",
|
id=note.id or "",
|
||||||
title=note.title,
|
title=note.title,
|
||||||
|
|
@ -131,7 +136,7 @@ async def update_note(note_id: str, note_update: NoteUpdate):
|
||||||
note = await Note.get(note_id)
|
note = await Note.get(note_id)
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
|
||||||
# Update only provided fields
|
# Update only provided fields
|
||||||
if note_update.title is not None:
|
if note_update.title is not None:
|
||||||
note.title = note_update.title
|
note.title = note_update.title
|
||||||
|
|
@ -141,7 +146,9 @@ async def update_note(note_id: str, note_update: NoteUpdate):
|
||||||
if note_update.note_type in ("human", "ai"):
|
if note_update.note_type in ("human", "ai"):
|
||||||
note.note_type = note_update.note_type # type: ignore[assignment]
|
note.note_type = note_update.note_type # type: ignore[assignment]
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="note_type must be 'human' or 'ai'")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="note_type must be 'human' or 'ai'"
|
||||||
|
)
|
||||||
|
|
||||||
await note.save()
|
await note.save()
|
||||||
|
|
||||||
|
|
@ -169,12 +176,12 @@ async def delete_note(note_id: str):
|
||||||
note = await Note.get(note_id)
|
note = await Note.get(note_id)
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
|
||||||
await note.delete()
|
await note.delete()
|
||||||
|
|
||||||
return {"message": "Note deleted successfully"}
|
return {"message": "Note deleted successfully"}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting note {note_id}: {str(e)}")
|
logger.error(f"Error deleting note {note_id}: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Error deleting note: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ async def generate_podcast(request: PodcastGenerationRequest):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating podcast: {str(e)}")
|
logger.error(f"Error generating podcast: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to generate podcast: {str(e)}"
|
status_code=500, detail="Failed to generate podcast"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def get_podcast_job_status(job_id: str):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching podcast job status: {str(e)}")
|
logger.error(f"Error fetching podcast job status: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch job status: {str(e)}"
|
status_code=500, detail="Failed to fetch job status"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ async def list_podcast_episodes():
|
||||||
# Skip incomplete episodes without command or audio
|
# Skip incomplete episodes without command or audio
|
||||||
if not episode.command and not episode.audio_file:
|
if not episode.command and not episode.audio_file:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get job status if available
|
# Get job status if available
|
||||||
job_status = None
|
job_status = None
|
||||||
if episode.command:
|
if episode.command:
|
||||||
|
|
@ -132,7 +132,7 @@ async def list_podcast_episodes():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing podcast episodes: {str(e)}")
|
logger.error(f"Error listing podcast episodes: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to list podcast episodes: {str(e)}"
|
status_code=500, detail="Failed to list podcast episodes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -175,7 +175,7 @@ async def get_podcast_episode(episode_id: str):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching podcast episode: {str(e)}")
|
logger.error(f"Error fetching podcast episode: {str(e)}")
|
||||||
raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}")
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/podcasts/episodes/{episode_id}/audio")
|
@router.get("/podcasts/episodes/{episode_id}/audio")
|
||||||
|
|
@ -187,7 +187,7 @@ async def stream_podcast_episode_audio(episode_id: str):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching podcast episode for audio: {str(e)}")
|
logger.error(f"Error fetching podcast episode for audio: {str(e)}")
|
||||||
raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}")
|
raise HTTPException(status_code=404, detail="Episode not found")
|
||||||
|
|
||||||
if not episode.audio_file:
|
if not episode.audio_file:
|
||||||
raise HTTPException(status_code=404, detail="Episode has no audio file")
|
raise HTTPException(status_code=404, detail="Episode has no audio file")
|
||||||
|
|
@ -209,7 +209,7 @@ async def delete_podcast_episode(episode_id: str):
|
||||||
try:
|
try:
|
||||||
# Get the episode first to check if it exists and get the audio file path
|
# Get the episode first to check if it exists and get the audio file path
|
||||||
episode = await PodcastService.get_episode(episode_id)
|
episode = await PodcastService.get_episode(episode_id)
|
||||||
|
|
||||||
# Delete the physical audio file if it exists
|
# Delete the physical audio file if it exists
|
||||||
if episode.audio_file:
|
if episode.audio_file:
|
||||||
audio_path = _resolve_audio_path(episode.audio_file)
|
audio_path = _resolve_audio_path(episode.audio_file)
|
||||||
|
|
@ -219,13 +219,15 @@ async def delete_podcast_episode(episode_id: str):
|
||||||
logger.info(f"Deleted audio file: {audio_path}")
|
logger.info(f"Deleted audio file: {audio_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
|
logger.warning(f"Failed to delete audio file {audio_path}: {e}")
|
||||||
|
|
||||||
# Delete the episode from the database
|
# Delete the episode from the database
|
||||||
await episode.delete()
|
await episode.delete()
|
||||||
|
|
||||||
logger.info(f"Deleted podcast episode: {episode_id}")
|
logger.info(f"Deleted podcast episode: {episode_id}")
|
||||||
return {"message": "Episode deleted successfully", "episode_id": episode_id}
|
return {"message": "Episode deleted successfully", "episode_id": episode_id}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting podcast episode: {str(e)}")
|
logger.error(f"Error deleting podcast episode: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete episode: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to delete episode"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ async def get_settings():
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching settings: {str(e)}")
|
logger.error(f"Error fetching settings: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching settings: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Error fetching settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/settings", response_model=SettingsResponse)
|
@router.put("/settings", response_model=SettingsResponse)
|
||||||
|
|
@ -36,30 +38,35 @@ async def update_settings(settings_update: SettingsUpdate):
|
||||||
if settings_update.default_content_processing_engine_doc is not None:
|
if settings_update.default_content_processing_engine_doc is not None:
|
||||||
# Cast to proper literal type
|
# Cast to proper literal type
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
settings.default_content_processing_engine_doc = cast(
|
settings.default_content_processing_engine_doc = cast(
|
||||||
Literal["auto", "docling", "simple"],
|
Literal["auto", "docling", "simple"],
|
||||||
settings_update.default_content_processing_engine_doc
|
settings_update.default_content_processing_engine_doc,
|
||||||
)
|
)
|
||||||
if settings_update.default_content_processing_engine_url is not None:
|
if settings_update.default_content_processing_engine_url is not None:
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
settings.default_content_processing_engine_url = cast(
|
settings.default_content_processing_engine_url = cast(
|
||||||
Literal["auto", "firecrawl", "jina", "simple"],
|
Literal["auto", "firecrawl", "jina", "simple"],
|
||||||
settings_update.default_content_processing_engine_url
|
settings_update.default_content_processing_engine_url,
|
||||||
)
|
)
|
||||||
if settings_update.default_embedding_option is not None:
|
if settings_update.default_embedding_option is not None:
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
settings.default_embedding_option = cast(
|
settings.default_embedding_option = cast(
|
||||||
Literal["ask", "always", "never"],
|
Literal["ask", "always", "never"],
|
||||||
settings_update.default_embedding_option
|
settings_update.default_embedding_option,
|
||||||
)
|
)
|
||||||
if settings_update.auto_delete_files is not None:
|
if settings_update.auto_delete_files is not None:
|
||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
settings.auto_delete_files = cast(
|
settings.auto_delete_files = cast(
|
||||||
Literal["yes", "no"],
|
Literal["yes", "no"], settings_update.auto_delete_files
|
||||||
settings_update.auto_delete_files
|
|
||||||
)
|
)
|
||||||
if settings_update.youtube_preferred_languages is not None:
|
if settings_update.youtube_preferred_languages is not None:
|
||||||
settings.youtube_preferred_languages = settings_update.youtube_preferred_languages
|
settings.youtube_preferred_languages = (
|
||||||
|
settings_update.youtube_preferred_languages
|
||||||
|
)
|
||||||
|
|
||||||
await settings.update()
|
await settings.update()
|
||||||
|
|
||||||
|
|
@ -76,4 +83,6 @@ async def update_settings(settings_update: SettingsUpdate):
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating settings: {str(e)}")
|
logger.error(f"Error updating settings: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error updating settings: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Error updating settings"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,22 @@ from open_notebook.graphs.source_chat import source_chat_graph as source_chat_gr
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# Request/Response models
|
# Request/Response models
|
||||||
class CreateSourceChatSessionRequest(BaseModel):
|
class CreateSourceChatSessionRequest(BaseModel):
|
||||||
source_id: str = Field(..., description="Source ID to create chat session for")
|
source_id: str = Field(..., description="Source ID to create chat session for")
|
||||||
title: Optional[str] = Field(None, description="Optional session title")
|
title: Optional[str] = Field(None, description="Optional session title")
|
||||||
model_override: Optional[str] = Field(None, description="Optional model override for this session")
|
model_override: Optional[str] = Field(
|
||||||
|
None, description="Optional model override for this session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UpdateSourceChatSessionRequest(BaseModel):
|
class UpdateSourceChatSessionRequest(BaseModel):
|
||||||
title: Optional[str] = Field(None, description="New session title")
|
title: Optional[str] = Field(None, description="New session title")
|
||||||
model_override: Optional[str] = Field(None, description="Model override for this session")
|
model_override: Optional[str] = Field(
|
||||||
|
None, description="Model override for this session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
id: str = Field(..., description="Message ID")
|
id: str = Field(..., description="Message ID")
|
||||||
|
|
@ -34,56 +41,81 @@ class ChatMessage(BaseModel):
|
||||||
content: str = Field(..., description="Message content")
|
content: str = Field(..., description="Message content")
|
||||||
timestamp: Optional[str] = Field(None, description="Message timestamp")
|
timestamp: Optional[str] = Field(None, description="Message timestamp")
|
||||||
|
|
||||||
|
|
||||||
class ContextIndicator(BaseModel):
|
class ContextIndicator(BaseModel):
|
||||||
sources: List[str] = Field(default_factory=list, description="Source IDs used in context")
|
sources: List[str] = Field(
|
||||||
insights: List[str] = Field(default_factory=list, description="Insight IDs used in context")
|
default_factory=list, description="Source IDs used in context"
|
||||||
notes: List[str] = Field(default_factory=list, description="Note IDs used in context")
|
)
|
||||||
|
insights: List[str] = Field(
|
||||||
|
default_factory=list, description="Insight IDs used in context"
|
||||||
|
)
|
||||||
|
notes: List[str] = Field(
|
||||||
|
default_factory=list, description="Note IDs used in context"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SourceChatSessionResponse(BaseModel):
|
class SourceChatSessionResponse(BaseModel):
|
||||||
id: str = Field(..., description="Session ID")
|
id: str = Field(..., description="Session ID")
|
||||||
title: str = Field(..., description="Session title")
|
title: str = Field(..., description="Session title")
|
||||||
source_id: str = Field(..., description="Source ID")
|
source_id: str = Field(..., description="Source ID")
|
||||||
model_override: Optional[str] = Field(None, description="Model override for this session")
|
model_override: Optional[str] = Field(
|
||||||
|
None, description="Model override for this session"
|
||||||
|
)
|
||||||
created: str = Field(..., description="Creation timestamp")
|
created: str = Field(..., description="Creation timestamp")
|
||||||
updated: str = Field(..., description="Last update timestamp")
|
updated: str = Field(..., description="Last update timestamp")
|
||||||
message_count: Optional[int] = Field(None, description="Number of messages in session")
|
message_count: Optional[int] = Field(
|
||||||
|
None, description="Number of messages in session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):
|
class SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):
|
||||||
messages: List[ChatMessage] = Field(default_factory=list, description="Session messages")
|
messages: List[ChatMessage] = Field(
|
||||||
context_indicators: Optional[ContextIndicator] = Field(None, description="Context indicators from last response")
|
default_factory=list, description="Session messages"
|
||||||
|
)
|
||||||
|
context_indicators: Optional[ContextIndicator] = Field(
|
||||||
|
None, description="Context indicators from last response"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
message: str = Field(..., description="User message content")
|
message: str = Field(..., description="User message content")
|
||||||
model_override: Optional[str] = Field(None, description="Optional model override for this message")
|
model_override: Optional[str] = Field(
|
||||||
|
None, description="Optional model override for this message"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SuccessResponse(BaseModel):
|
class SuccessResponse(BaseModel):
|
||||||
success: bool = Field(True, description="Operation success status")
|
success: bool = Field(True, description="Operation success status")
|
||||||
message: str = Field(..., description="Success message")
|
message: str = Field(..., description="Success message")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse)
|
@router.post(
|
||||||
|
"/sources/{source_id}/chat/sessions", response_model=SourceChatSessionResponse
|
||||||
|
)
|
||||||
async def create_source_chat_session(
|
async def create_source_chat_session(
|
||||||
request: CreateSourceChatSessionRequest,
|
request: CreateSourceChatSessionRequest,
|
||||||
source_id: str = Path(..., description="Source ID")
|
source_id: str = Path(..., description="Source ID"),
|
||||||
):
|
):
|
||||||
"""Create a new chat session for a source."""
|
"""Create a new chat session for a source."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Create new session with model_override support
|
# Create new session with model_override support
|
||||||
session = ChatSession(
|
session = ChatSession(
|
||||||
title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}",
|
title=request.title or f"Source Chat {asyncio.get_event_loop().time():.0f}",
|
||||||
model_override=request.model_override
|
model_override=request.model_override,
|
||||||
)
|
)
|
||||||
await session.save()
|
await session.save()
|
||||||
|
|
||||||
# Relate session to source using "refers_to" relation
|
# Relate session to source using "refers_to" relation
|
||||||
await session.relate("refers_to", full_source_id)
|
await session.relate("refers_to", full_source_id)
|
||||||
|
|
||||||
return SourceChatSessionResponse(
|
return SourceChatSessionResponse(
|
||||||
id=session.id or "",
|
id=session.id or "",
|
||||||
title=session.title or "Untitled Session",
|
title=session.title or "Untitled Session",
|
||||||
|
|
@ -91,33 +123,37 @@ async def create_source_chat_session(
|
||||||
model_override=session.model_override,
|
model_override=session.model_override,
|
||||||
created=str(session.created),
|
created=str(session.created),
|
||||||
updated=str(session.updated),
|
updated=str(session.updated),
|
||||||
message_count=0
|
message_count=0,
|
||||||
)
|
)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating source chat session: {str(e)}")
|
logger.error(f"Error creating source chat session: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error creating source chat session: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error creating source chat session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse])
|
@router.get(
|
||||||
async def get_source_chat_sessions(
|
"/sources/{source_id}/chat/sessions", response_model=List[SourceChatSessionResponse]
|
||||||
source_id: str = Path(..., description="Source ID")
|
)
|
||||||
):
|
async def get_source_chat_sessions(source_id: str = Path(..., description="Source ID")):
|
||||||
"""Get all chat sessions for a source."""
|
"""Get all chat sessions for a source."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Get sessions that refer to this source - first get relations, then sessions
|
# Get sessions that refer to this source - first get relations, then sessions
|
||||||
relations = await repo_query(
|
relations = await repo_query(
|
||||||
"SELECT in FROM refers_to WHERE out = $source_id",
|
"SELECT in FROM refers_to WHERE out = $source_id",
|
||||||
{"source_id": ensure_record_id(full_source_id)}
|
{"source_id": ensure_record_id(full_source_id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
sessions = []
|
sessions = []
|
||||||
for relation in relations:
|
for relation in relations:
|
||||||
session_id = relation.get("in")
|
session_id = relation.get("in")
|
||||||
|
|
@ -125,16 +161,18 @@ async def get_source_chat_sessions(
|
||||||
session_result = await repo_query(f"SELECT * FROM {session_id}")
|
session_result = await repo_query(f"SELECT * FROM {session_id}")
|
||||||
if session_result and len(session_result) > 0:
|
if session_result and len(session_result) > 0:
|
||||||
session_data = session_result[0]
|
session_data = session_result[0]
|
||||||
sessions.append(SourceChatSessionResponse(
|
sessions.append(
|
||||||
id=session_data.get("id") or "",
|
SourceChatSessionResponse(
|
||||||
title=session_data.get("title") or "Untitled Session",
|
id=session_data.get("id") or "",
|
||||||
source_id=source_id,
|
title=session_data.get("title") or "Untitled Session",
|
||||||
model_override=session_data.get("model_override"),
|
source_id=source_id,
|
||||||
created=str(session_data.get("created")),
|
model_override=session_data.get("model_override"),
|
||||||
updated=str(session_data.get("updated")),
|
created=str(session_data.get("created")),
|
||||||
message_count=0 # TODO: Add message count if needed
|
updated=str(session_data.get("updated")),
|
||||||
))
|
message_count=0, # TODO: Add message count if needed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Sort sessions by created date (newest first)
|
# Sort sessions by created date (newest first)
|
||||||
sessions.sort(key=lambda x: x.created, reverse=True)
|
sessions.sort(key=lambda x: x.created, reverse=True)
|
||||||
return sessions
|
return sessions
|
||||||
|
|
@ -142,183 +180,232 @@ async def get_source_chat_sessions(
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching source chat sessions: {str(e)}")
|
logger.error(f"Error fetching source chat sessions: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching source chat sessions: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error fetching source chat sessions: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources/{source_id}/chat/sessions/{session_id}", response_model=SourceChatSessionWithMessagesResponse)
|
@router.get(
|
||||||
|
"/sources/{source_id}/chat/sessions/{session_id}",
|
||||||
|
response_model=SourceChatSessionWithMessagesResponse,
|
||||||
|
)
|
||||||
async def get_source_chat_session(
|
async def get_source_chat_session(
|
||||||
source_id: str = Path(..., description="Source ID"),
|
source_id: str = Path(..., description="Source ID"),
|
||||||
session_id: str = Path(..., description="Session ID")
|
session_id: str = Path(..., description="Session ID"),
|
||||||
):
|
):
|
||||||
"""Get a specific source chat session with its messages."""
|
"""Get a specific source chat session with its messages."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Get session
|
# Get session
|
||||||
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}"
|
full_session_id = (
|
||||||
|
session_id
|
||||||
|
if session_id.startswith("chat_session:")
|
||||||
|
else f"chat_session:{session_id}"
|
||||||
|
)
|
||||||
session = await ChatSession.get(full_session_id)
|
session = await ChatSession.get(full_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
# Verify session is related to this source
|
# Verify session is related to this source
|
||||||
relation_query = await repo_query(
|
relation_query = await repo_query(
|
||||||
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
||||||
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)}
|
{
|
||||||
|
"session_id": ensure_record_id(full_session_id),
|
||||||
|
"source_id": ensure_record_id(full_source_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not relation_query:
|
if not relation_query:
|
||||||
raise HTTPException(status_code=404, detail="Session not found for this source")
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found for this source"
|
||||||
|
)
|
||||||
|
|
||||||
# Get session state from LangGraph to retrieve messages
|
# Get session state from LangGraph to retrieve messages
|
||||||
thread_state = source_chat_graph.get_state(
|
thread_state = source_chat_graph.get_state(
|
||||||
config=RunnableConfig(configurable={"thread_id": session_id})
|
config=RunnableConfig(configurable={"thread_id": session_id})
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract messages from state
|
# Extract messages from state
|
||||||
messages: list[ChatMessage] = []
|
messages: list[ChatMessage] = []
|
||||||
context_indicators = None
|
context_indicators = None
|
||||||
|
|
||||||
if thread_state and thread_state.values:
|
if thread_state and thread_state.values:
|
||||||
# Extract messages
|
# Extract messages
|
||||||
if "messages" in thread_state.values:
|
if "messages" in thread_state.values:
|
||||||
for msg in thread_state.values["messages"]:
|
for msg in thread_state.values["messages"]:
|
||||||
messages.append(ChatMessage(
|
messages.append(
|
||||||
id=getattr(msg, 'id', f"msg_{len(messages)}"),
|
ChatMessage(
|
||||||
type=msg.type if hasattr(msg, 'type') else 'unknown',
|
id=getattr(msg, "id", f"msg_{len(messages)}"),
|
||||||
content=msg.content if hasattr(msg, 'content') else str(msg),
|
type=msg.type if hasattr(msg, "type") else "unknown",
|
||||||
timestamp=None # LangChain messages don't have timestamps by default
|
content=msg.content
|
||||||
))
|
if hasattr(msg, "content")
|
||||||
|
else str(msg),
|
||||||
|
timestamp=None, # LangChain messages don't have timestamps by default
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Extract context indicators from the last state
|
# Extract context indicators from the last state
|
||||||
if "context_indicators" in thread_state.values:
|
if "context_indicators" in thread_state.values:
|
||||||
context_data = thread_state.values["context_indicators"]
|
context_data = thread_state.values["context_indicators"]
|
||||||
context_indicators = ContextIndicator(
|
context_indicators = ContextIndicator(
|
||||||
sources=context_data.get("sources", []),
|
sources=context_data.get("sources", []),
|
||||||
insights=context_data.get("insights", []),
|
insights=context_data.get("insights", []),
|
||||||
notes=context_data.get("notes", [])
|
notes=context_data.get("notes", []),
|
||||||
)
|
)
|
||||||
|
|
||||||
return SourceChatSessionWithMessagesResponse(
|
return SourceChatSessionWithMessagesResponse(
|
||||||
id=session.id or "",
|
id=session.id or "",
|
||||||
title=session.title or "Untitled Session",
|
title=session.title or "Untitled Session",
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
model_override=getattr(session, 'model_override', None),
|
model_override=getattr(session, "model_override", None),
|
||||||
created=str(session.created),
|
created=str(session.created),
|
||||||
updated=str(session.updated),
|
updated=str(session.updated),
|
||||||
message_count=len(messages),
|
message_count=len(messages),
|
||||||
messages=messages,
|
messages=messages,
|
||||||
context_indicators=context_indicators
|
context_indicators=context_indicators,
|
||||||
)
|
)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Source or session not found")
|
raise HTTPException(status_code=404, detail="Source or session not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching source chat session: {str(e)}")
|
logger.error(f"Error fetching source chat session: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error fetching source chat session: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error fetching source chat session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/sources/{source_id}/chat/sessions/{session_id}", response_model=SourceChatSessionResponse)
|
@router.put(
|
||||||
|
"/sources/{source_id}/chat/sessions/{session_id}",
|
||||||
|
response_model=SourceChatSessionResponse,
|
||||||
|
)
|
||||||
async def update_source_chat_session(
|
async def update_source_chat_session(
|
||||||
request: UpdateSourceChatSessionRequest,
|
request: UpdateSourceChatSessionRequest,
|
||||||
source_id: str = Path(..., description="Source ID"),
|
source_id: str = Path(..., description="Source ID"),
|
||||||
session_id: str = Path(..., description="Session ID")
|
session_id: str = Path(..., description="Session ID"),
|
||||||
):
|
):
|
||||||
"""Update source chat session title and/or model override."""
|
"""Update source chat session title and/or model override."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Get session
|
# Get session
|
||||||
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}"
|
full_session_id = (
|
||||||
|
session_id
|
||||||
|
if session_id.startswith("chat_session:")
|
||||||
|
else f"chat_session:{session_id}"
|
||||||
|
)
|
||||||
session = await ChatSession.get(full_session_id)
|
session = await ChatSession.get(full_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
# Verify session is related to this source
|
# Verify session is related to this source
|
||||||
relation_query = await repo_query(
|
relation_query = await repo_query(
|
||||||
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
||||||
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)}
|
{
|
||||||
|
"session_id": ensure_record_id(full_session_id),
|
||||||
|
"source_id": ensure_record_id(full_source_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not relation_query:
|
if not relation_query:
|
||||||
raise HTTPException(status_code=404, detail="Session not found for this source")
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found for this source"
|
||||||
|
)
|
||||||
|
|
||||||
# Update session fields
|
# Update session fields
|
||||||
if request.title is not None:
|
if request.title is not None:
|
||||||
session.title = request.title
|
session.title = request.title
|
||||||
if request.model_override is not None:
|
if request.model_override is not None:
|
||||||
session.model_override = request.model_override
|
session.model_override = request.model_override
|
||||||
|
|
||||||
await session.save()
|
await session.save()
|
||||||
|
|
||||||
return SourceChatSessionResponse(
|
return SourceChatSessionResponse(
|
||||||
id=session.id or "",
|
id=session.id or "",
|
||||||
title=session.title or "Untitled Session",
|
title=session.title or "Untitled Session",
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
model_override=getattr(session, 'model_override', None),
|
model_override=getattr(session, "model_override", None),
|
||||||
created=str(session.created),
|
created=str(session.created),
|
||||||
updated=str(session.updated),
|
updated=str(session.updated),
|
||||||
message_count=0
|
message_count=0,
|
||||||
)
|
)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Source or session not found")
|
raise HTTPException(status_code=404, detail="Source or session not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating source chat session: {str(e)}")
|
logger.error(f"Error updating source chat session: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error updating source chat session: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error updating source chat session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse)
|
@router.delete(
|
||||||
|
"/sources/{source_id}/chat/sessions/{session_id}", response_model=SuccessResponse
|
||||||
|
)
|
||||||
async def delete_source_chat_session(
|
async def delete_source_chat_session(
|
||||||
source_id: str = Path(..., description="Source ID"),
|
source_id: str = Path(..., description="Source ID"),
|
||||||
session_id: str = Path(..., description="Session ID")
|
session_id: str = Path(..., description="Session ID"),
|
||||||
):
|
):
|
||||||
"""Delete a source chat session."""
|
"""Delete a source chat session."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Get session
|
# Get session
|
||||||
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}"
|
full_session_id = (
|
||||||
|
session_id
|
||||||
|
if session_id.startswith("chat_session:")
|
||||||
|
else f"chat_session:{session_id}"
|
||||||
|
)
|
||||||
session = await ChatSession.get(full_session_id)
|
session = await ChatSession.get(full_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
# Verify session is related to this source
|
# Verify session is related to this source
|
||||||
relation_query = await repo_query(
|
relation_query = await repo_query(
|
||||||
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
||||||
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)}
|
{
|
||||||
|
"session_id": ensure_record_id(full_session_id),
|
||||||
|
"source_id": ensure_record_id(full_source_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not relation_query:
|
if not relation_query:
|
||||||
raise HTTPException(status_code=404, detail="Session not found for this source")
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found for this source"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete()
|
await session.delete()
|
||||||
|
|
||||||
return SuccessResponse(
|
return SuccessResponse(
|
||||||
success=True,
|
success=True, message="Source chat session deleted successfully"
|
||||||
message="Source chat session deleted successfully"
|
|
||||||
)
|
)
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="Source or session not found")
|
raise HTTPException(status_code=404, detail="Source or session not found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting source chat session: {str(e)}")
|
logger.error(f"Error deleting source chat session: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error deleting source chat session: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error deleting source chat session: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def stream_source_chat_response(
|
async def stream_source_chat_response(
|
||||||
session_id: str,
|
session_id: str, source_id: str, message: str, model_override: Optional[str] = None
|
||||||
source_id: str,
|
|
||||||
message: str,
|
|
||||||
model_override: Optional[str] = None
|
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Stream the source chat response as Server-Sent Events."""
|
"""Stream the source chat response as Server-Sent Events."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -326,59 +413,52 @@ async def stream_source_chat_response(
|
||||||
current_state = source_chat_graph.get_state(
|
current_state = source_chat_graph.get_state(
|
||||||
config=RunnableConfig(configurable={"thread_id": session_id})
|
config=RunnableConfig(configurable={"thread_id": session_id})
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare state for execution
|
# Prepare state for execution
|
||||||
state_values = current_state.values if current_state else {}
|
state_values = current_state.values if current_state else {}
|
||||||
state_values["messages"] = state_values.get("messages", [])
|
state_values["messages"] = state_values.get("messages", [])
|
||||||
state_values["source_id"] = source_id
|
state_values["source_id"] = source_id
|
||||||
state_values["model_override"] = model_override
|
state_values["model_override"] = model_override
|
||||||
|
|
||||||
# Add user message to state
|
# Add user message to state
|
||||||
user_message = HumanMessage(content=message)
|
user_message = HumanMessage(content=message)
|
||||||
state_values["messages"].append(user_message)
|
state_values["messages"].append(user_message)
|
||||||
|
|
||||||
# Send user message event
|
# Send user message event
|
||||||
user_event = {
|
user_event = {"type": "user_message", "content": message, "timestamp": None}
|
||||||
"type": "user_message",
|
|
||||||
"content": message,
|
|
||||||
"timestamp": None
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(user_event)}\n\n"
|
yield f"data: {json.dumps(user_event)}\n\n"
|
||||||
|
|
||||||
# Execute source chat graph synchronously (like notebook chat does)
|
# Execute source chat graph synchronously (like notebook chat does)
|
||||||
result = source_chat_graph.invoke(
|
result = source_chat_graph.invoke(
|
||||||
input=state_values, # type: ignore[arg-type]
|
input=state_values, # type: ignore[arg-type]
|
||||||
config=RunnableConfig(
|
config=RunnableConfig(
|
||||||
configurable={
|
configurable={"thread_id": session_id, "model_id": model_override}
|
||||||
"thread_id": session_id,
|
),
|
||||||
"model_id": model_override
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream the complete AI response
|
# Stream the complete AI response
|
||||||
if "messages" in result:
|
if "messages" in result:
|
||||||
for msg in result["messages"]:
|
for msg in result["messages"]:
|
||||||
if hasattr(msg, 'type') and msg.type == 'ai':
|
if hasattr(msg, "type") and msg.type == "ai":
|
||||||
ai_event = {
|
ai_event = {
|
||||||
"type": "ai_message",
|
"type": "ai_message",
|
||||||
"content": msg.content if hasattr(msg, 'content') else str(msg),
|
"content": msg.content if hasattr(msg, "content") else str(msg),
|
||||||
"timestamp": None
|
"timestamp": None,
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(ai_event)}\n\n"
|
yield f"data: {json.dumps(ai_event)}\n\n"
|
||||||
|
|
||||||
# Stream context indicators
|
# Stream context indicators
|
||||||
if "context_indicators" in result:
|
if "context_indicators" in result:
|
||||||
context_event = {
|
context_event = {
|
||||||
"type": "context_indicators",
|
"type": "context_indicators",
|
||||||
"data": result["context_indicators"]
|
"data": result["context_indicators"],
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(context_event)}\n\n"
|
yield f"data: {json.dumps(context_event)}\n\n"
|
||||||
|
|
||||||
# Send completion signal
|
# Send completion signal
|
||||||
completion_event = {"type": "complete"}
|
completion_event = {"type": "complete"}
|
||||||
yield f"data: {json.dumps(completion_event)}\n\n"
|
yield f"data: {json.dumps(completion_event)}\n\n"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in source chat streaming: {str(e)}")
|
logger.error(f"Error in source chat streaming: {str(e)}")
|
||||||
error_event = {"type": "error", "message": str(e)}
|
error_event = {"type": "error", "message": str(e)}
|
||||||
|
|
@ -389,58 +469,71 @@ async def stream_source_chat_response(
|
||||||
async def send_message_to_source_chat(
|
async def send_message_to_source_chat(
|
||||||
request: SendMessageRequest,
|
request: SendMessageRequest,
|
||||||
source_id: str = Path(..., description="Source ID"),
|
source_id: str = Path(..., description="Source ID"),
|
||||||
session_id: str = Path(..., description="Session ID")
|
session_id: str = Path(..., description="Session ID"),
|
||||||
):
|
):
|
||||||
"""Send a message to source chat session with SSE streaming response."""
|
"""Send a message to source chat session with SSE streaming response."""
|
||||||
try:
|
try:
|
||||||
# Verify source exists
|
# Verify source exists
|
||||||
full_source_id = source_id if source_id.startswith("source:") else f"source:{source_id}"
|
full_source_id = (
|
||||||
|
source_id if source_id.startswith("source:") else f"source:{source_id}"
|
||||||
|
)
|
||||||
source = await Source.get(full_source_id)
|
source = await Source.get(full_source_id)
|
||||||
if not source:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source not found")
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
|
||||||
# Verify session exists and is related to source
|
# Verify session exists and is related to source
|
||||||
full_session_id = session_id if session_id.startswith("chat_session:") else f"chat_session:{session_id}"
|
full_session_id = (
|
||||||
|
session_id
|
||||||
|
if session_id.startswith("chat_session:")
|
||||||
|
else f"chat_session:{session_id}"
|
||||||
|
)
|
||||||
session = await ChatSession.get(full_session_id)
|
session = await ChatSession.get(full_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
|
|
||||||
# Verify session is related to this source
|
# Verify session is related to this source
|
||||||
relation_query = await repo_query(
|
relation_query = await repo_query(
|
||||||
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id",
|
||||||
{"session_id": ensure_record_id(full_session_id), "source_id": ensure_record_id(full_source_id)}
|
{
|
||||||
|
"session_id": ensure_record_id(full_session_id),
|
||||||
|
"source_id": ensure_record_id(full_source_id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not relation_query:
|
if not relation_query:
|
||||||
raise HTTPException(status_code=404, detail="Session not found for this source")
|
raise HTTPException(
|
||||||
|
status_code=404, detail="Session not found for this source"
|
||||||
|
)
|
||||||
|
|
||||||
if not request.message:
|
if not request.message:
|
||||||
raise HTTPException(status_code=400, detail="Message content is required")
|
raise HTTPException(status_code=400, detail="Message content is required")
|
||||||
|
|
||||||
# Determine model override (request override takes precedence over session override)
|
# Determine model override (request override takes precedence over session override)
|
||||||
model_override = request.model_override or getattr(session, 'model_override', None)
|
model_override = request.model_override or getattr(
|
||||||
|
session, "model_override", None
|
||||||
|
)
|
||||||
|
|
||||||
# Update session timestamp
|
# Update session timestamp
|
||||||
await session.save()
|
await session.save()
|
||||||
|
|
||||||
# Return streaming response
|
# Return streaming response
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_source_chat_response(
|
stream_source_chat_response(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
source_id=full_source_id,
|
source_id=full_source_id,
|
||||||
message=request.message,
|
message=request.message,
|
||||||
model_override=model_override
|
model_override=model_override,
|
||||||
),
|
),
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Content-Type": "text/plain; charset=utf-8"
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending message to source chat: {str(e)}")
|
logger.error(f"Error sending message to source chat: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Error sending message: {str(e)}")
|
||||||
|
|
|
||||||
|
|
@ -121,9 +121,7 @@ def parse_source_form_data(
|
||||||
try:
|
try:
|
||||||
transformations_list = json.loads(transformations)
|
transformations_list = json.loads(transformations)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.error(
|
logger.error(f"Invalid JSON in transformations field: {transformations}")
|
||||||
f"Invalid JSON in transformations field: {transformations}"
|
|
||||||
)
|
|
||||||
raise ValueError("Invalid JSON in transformations field")
|
raise ValueError("Invalid JSON in transformations field")
|
||||||
|
|
||||||
# Create SourceCreate instance
|
# Create SourceCreate instance
|
||||||
|
|
@ -152,18 +150,26 @@ def parse_source_form_data(
|
||||||
@router.get("/sources", response_model=List[SourceListResponse])
|
@router.get("/sources", response_model=List[SourceListResponse])
|
||||||
async def get_sources(
|
async def get_sources(
|
||||||
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
|
notebook_id: Optional[str] = Query(None, description="Filter by notebook ID"),
|
||||||
limit: int = Query(50, ge=1, le=100, description="Number of sources to return (1-100)"),
|
limit: int = Query(
|
||||||
|
50, ge=1, le=100, description="Number of sources to return (1-100)"
|
||||||
|
),
|
||||||
offset: int = Query(0, ge=0, description="Number of sources to skip"),
|
offset: int = Query(0, ge=0, description="Number of sources to skip"),
|
||||||
sort_by: str = Query("updated", description="Field to sort by (created or updated)"),
|
sort_by: str = Query(
|
||||||
|
"updated", description="Field to sort by (created or updated)"
|
||||||
|
),
|
||||||
sort_order: str = Query("desc", description="Sort order (asc or desc)"),
|
sort_order: str = Query("desc", description="Sort order (asc or desc)"),
|
||||||
):
|
):
|
||||||
"""Get sources with pagination and sorting support."""
|
"""Get sources with pagination and sorting support."""
|
||||||
try:
|
try:
|
||||||
# Validate sort parameters
|
# Validate sort parameters
|
||||||
if sort_by not in ["created", "updated"]:
|
if sort_by not in ["created", "updated"]:
|
||||||
raise HTTPException(status_code=400, detail="sort_by must be 'created' or 'updated'")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="sort_by must be 'created' or 'updated'"
|
||||||
|
)
|
||||||
if sort_order.lower() not in ["asc", "desc"]:
|
if sort_order.lower() not in ["asc", "desc"]:
|
||||||
raise HTTPException(status_code=400, detail="sort_order must be 'asc' or 'desc'")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="sort_order must be 'asc' or 'desc'"
|
||||||
|
)
|
||||||
|
|
||||||
# Build ORDER BY clause
|
# Build ORDER BY clause
|
||||||
order_clause = f"ORDER BY {sort_by} {sort_order.upper()}"
|
order_clause = f"ORDER BY {sort_by} {sort_order.upper()}"
|
||||||
|
|
@ -185,11 +191,12 @@ async def get_sources(
|
||||||
LIMIT $limit START $offset
|
LIMIT $limit START $offset
|
||||||
"""
|
"""
|
||||||
result = await repo_query(
|
result = await repo_query(
|
||||||
query, {
|
query,
|
||||||
|
{
|
||||||
"notebook_id": ensure_record_id(notebook_id),
|
"notebook_id": ensure_record_id(notebook_id),
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset
|
"offset": offset,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Query all sources - include command field
|
# Query all sources - include command field
|
||||||
|
|
@ -272,8 +279,14 @@ async def get_sources(
|
||||||
if status_obj:
|
if status_obj:
|
||||||
status = status_obj.status
|
status = status_obj.status
|
||||||
# Extract execution metadata from nested result structure
|
# Extract execution metadata from nested result structure
|
||||||
result_data: dict[str, Any] | None = getattr(status_obj, "result", None)
|
result_data: dict[str, Any] | None = getattr(
|
||||||
execution_metadata: dict[str, Any] = result_data.get("execution_metadata", {}) if isinstance(result_data, dict) else {}
|
status_obj, "result", None
|
||||||
|
)
|
||||||
|
execution_metadata: dict[str, Any] = (
|
||||||
|
result_data.get("execution_metadata", {})
|
||||||
|
if isinstance(result_data, dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
processing_info = {
|
processing_info = {
|
||||||
"started_at": execution_metadata.get("started_at"),
|
"started_at": execution_metadata.get("started_at"),
|
||||||
"completed_at": execution_metadata.get("completed_at"),
|
"completed_at": execution_metadata.get("completed_at"),
|
||||||
|
|
@ -327,7 +340,7 @@ async def create_source(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Verify all specified notebooks exist (backward compatibility support)
|
# Verify all specified notebooks exist (backward compatibility support)
|
||||||
for notebook_id in (source_data.notebooks or []):
|
for notebook_id in source_data.notebooks or []:
|
||||||
notebook = await Notebook.get(notebook_id)
|
notebook = await Notebook.get(notebook_id)
|
||||||
if not notebook:
|
if not notebook:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -399,7 +412,7 @@ async def create_source(
|
||||||
|
|
||||||
# Add source to notebooks immediately so it appears in the UI
|
# Add source to notebooks immediately so it appears in the UI
|
||||||
# The source_graph will skip adding duplicates
|
# The source_graph will skip adding duplicates
|
||||||
for notebook_id in (source_data.notebooks or []):
|
for notebook_id in source_data.notebooks or []:
|
||||||
await source.add_to_notebook(notebook_id)
|
await source.add_to_notebook(notebook_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -478,7 +491,7 @@ async def create_source(
|
||||||
|
|
||||||
# Add source to notebooks immediately so it appears in the UI
|
# Add source to notebooks immediately so it appears in the UI
|
||||||
# The source_graph will skip adding duplicates
|
# The source_graph will skip adding duplicates
|
||||||
for notebook_id in (source_data.notebooks or []):
|
for notebook_id in source_data.notebooks or []:
|
||||||
await source.add_to_notebook(notebook_id)
|
await source.add_to_notebook(notebook_id)
|
||||||
|
|
||||||
# Execute command synchronously
|
# Execute command synchronously
|
||||||
|
|
@ -517,9 +530,7 @@ async def create_source(
|
||||||
|
|
||||||
# Get the processed source
|
# Get the processed source
|
||||||
if not source.id:
|
if not source.id:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail="Source ID is missing")
|
||||||
status_code=500, detail="Source ID is missing"
|
|
||||||
)
|
|
||||||
processed_source = await Source.get(source.id)
|
processed_source = await Source.get(source.id)
|
||||||
if not processed_source:
|
if not processed_source:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -657,9 +668,11 @@ async def get_source(source_id: str):
|
||||||
# Get associated notebooks
|
# Get associated notebooks
|
||||||
notebooks_query = await repo_query(
|
notebooks_query = await repo_query(
|
||||||
"SELECT VALUE out FROM reference WHERE in = $source_id",
|
"SELECT VALUE out FROM reference WHERE in = $source_id",
|
||||||
{"source_id": ensure_record_id(source.id or source_id)}
|
{"source_id": ensure_record_id(source.id or source_id)},
|
||||||
|
)
|
||||||
|
notebook_ids = (
|
||||||
|
[str(nb_id) for nb_id in notebooks_query] if notebooks_query else []
|
||||||
)
|
)
|
||||||
notebook_ids = [str(nb_id) for nb_id in notebooks_query] if notebooks_query else []
|
|
||||||
|
|
||||||
return SourceResponse(
|
return SourceResponse(
|
||||||
id=source.id or "",
|
id=source.id or "",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ async def list_speaker_profiles():
|
||||||
"""List all available speaker profiles"""
|
"""List all available speaker profiles"""
|
||||||
try:
|
try:
|
||||||
profiles = await SpeakerProfile.get_all(order_by="name asc")
|
profiles = await SpeakerProfile.get_all(order_by="name asc")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SpeakerProfileResponse(
|
SpeakerProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
|
|
@ -31,16 +31,15 @@ async def list_speaker_profiles():
|
||||||
description=profile.description or "",
|
description=profile.description or "",
|
||||||
tts_provider=profile.tts_provider,
|
tts_provider=profile.tts_provider,
|
||||||
tts_model=profile.tts_model,
|
tts_model=profile.tts_model,
|
||||||
speakers=profile.speakers
|
speakers=profile.speakers,
|
||||||
)
|
)
|
||||||
for profile in profiles
|
for profile in profiles
|
||||||
]
|
]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch speaker profiles: {e}")
|
logger.error(f"Failed to fetch speaker profiles: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to fetch speaker profiles"
|
||||||
detail=f"Failed to fetch speaker profiles: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,29 +48,27 @@ async def get_speaker_profile(profile_name: str):
|
||||||
"""Get a specific speaker profile by name"""
|
"""Get a specific speaker profile by name"""
|
||||||
try:
|
try:
|
||||||
profile = await SpeakerProfile.get_by_name(profile_name)
|
profile = await SpeakerProfile.get_by_name(profile_name)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Speaker profile '{profile_name}' not found"
|
||||||
detail=f"Speaker profile '{profile_name}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return SpeakerProfileResponse(
|
return SpeakerProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
description=profile.description or "",
|
description=profile.description or "",
|
||||||
tts_provider=profile.tts_provider,
|
tts_provider=profile.tts_provider,
|
||||||
tts_model=profile.tts_model,
|
tts_model=profile.tts_model,
|
||||||
speakers=profile.speakers
|
speakers=profile.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}")
|
logger.error(f"Failed to fetch speaker profile '{profile_name}': {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to fetch speaker profile"
|
||||||
detail=f"Failed to fetch speaker profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,7 +77,9 @@ class SpeakerProfileCreate(BaseModel):
|
||||||
description: str = Field("", description="Profile description")
|
description: str = Field("", description="Profile description")
|
||||||
tts_provider: str = Field(..., description="TTS provider")
|
tts_provider: str = Field(..., description="TTS provider")
|
||||||
tts_model: str = Field(..., description="TTS model name")
|
tts_model: str = Field(..., description="TTS model name")
|
||||||
speakers: List[Dict[str, Any]] = Field(..., description="Array of speaker configurations")
|
speakers: List[Dict[str, Any]] = Field(
|
||||||
|
..., description="Array of speaker configurations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
|
@router.post("/speaker-profiles", response_model=SpeakerProfileResponse)
|
||||||
|
|
@ -92,25 +91,24 @@ async def create_speaker_profile(profile_data: SpeakerProfileCreate):
|
||||||
description=profile_data.description,
|
description=profile_data.description,
|
||||||
tts_provider=profile_data.tts_provider,
|
tts_provider=profile_data.tts_provider,
|
||||||
tts_model=profile_data.tts_model,
|
tts_model=profile_data.tts_model,
|
||||||
speakers=profile_data.speakers
|
speakers=profile_data.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
await profile.save()
|
await profile.save()
|
||||||
|
|
||||||
return SpeakerProfileResponse(
|
return SpeakerProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
description=profile.description or "",
|
description=profile.description or "",
|
||||||
tts_provider=profile.tts_provider,
|
tts_provider=profile.tts_provider,
|
||||||
tts_model=profile.tts_model,
|
tts_model=profile.tts_model,
|
||||||
speakers=profile.speakers
|
speakers=profile.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create speaker profile: {e}")
|
logger.error(f"Failed to create speaker profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to create speaker profile"
|
||||||
detail=f"Failed to create speaker profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,38 +117,36 @@ async def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCr
|
||||||
"""Update an existing speaker profile"""
|
"""Update an existing speaker profile"""
|
||||||
try:
|
try:
|
||||||
profile = await SpeakerProfile.get(profile_id)
|
profile = await SpeakerProfile.get(profile_id)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
|
||||||
detail=f"Speaker profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
profile.name = profile_data.name
|
profile.name = profile_data.name
|
||||||
profile.description = profile_data.description
|
profile.description = profile_data.description
|
||||||
profile.tts_provider = profile_data.tts_provider
|
profile.tts_provider = profile_data.tts_provider
|
||||||
profile.tts_model = profile_data.tts_model
|
profile.tts_model = profile_data.tts_model
|
||||||
profile.speakers = profile_data.speakers
|
profile.speakers = profile_data.speakers
|
||||||
|
|
||||||
await profile.save()
|
await profile.save()
|
||||||
|
|
||||||
return SpeakerProfileResponse(
|
return SpeakerProfileResponse(
|
||||||
id=str(profile.id),
|
id=str(profile.id),
|
||||||
name=profile.name,
|
name=profile.name,
|
||||||
description=profile.description or "",
|
description=profile.description or "",
|
||||||
tts_provider=profile.tts_provider,
|
tts_provider=profile.tts_provider,
|
||||||
tts_model=profile.tts_model,
|
tts_model=profile.tts_model,
|
||||||
speakers=profile.speakers
|
speakers=profile.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update speaker profile: {e}")
|
logger.error(f"Failed to update speaker profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to update speaker profile"
|
||||||
detail=f"Failed to update speaker profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,64 +155,62 @@ async def delete_speaker_profile(profile_id: str):
|
||||||
"""Delete a speaker profile"""
|
"""Delete a speaker profile"""
|
||||||
try:
|
try:
|
||||||
profile = await SpeakerProfile.get(profile_id)
|
profile = await SpeakerProfile.get(profile_id)
|
||||||
|
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
|
||||||
detail=f"Speaker profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await profile.delete()
|
await profile.delete()
|
||||||
|
|
||||||
return {"message": "Speaker profile deleted successfully"}
|
return {"message": "Speaker profile deleted successfully"}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete speaker profile: {e}")
|
logger.error(f"Failed to delete speaker profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to delete speaker profile"
|
||||||
detail=f"Failed to delete speaker profile: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse)
|
@router.post(
|
||||||
|
"/speaker-profiles/{profile_id}/duplicate", response_model=SpeakerProfileResponse
|
||||||
|
)
|
||||||
async def duplicate_speaker_profile(profile_id: str):
|
async def duplicate_speaker_profile(profile_id: str):
|
||||||
"""Duplicate a speaker profile"""
|
"""Duplicate a speaker profile"""
|
||||||
try:
|
try:
|
||||||
original = await SpeakerProfile.get(profile_id)
|
original = await SpeakerProfile.get(profile_id)
|
||||||
|
|
||||||
if not original:
|
if not original:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404, detail=f"Speaker profile '{profile_id}' not found"
|
||||||
detail=f"Speaker profile '{profile_id}' not found"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create duplicate with modified name
|
# Create duplicate with modified name
|
||||||
duplicate = SpeakerProfile(
|
duplicate = SpeakerProfile(
|
||||||
name=f"{original.name} - Copy",
|
name=f"{original.name} - Copy",
|
||||||
description=original.description,
|
description=original.description,
|
||||||
tts_provider=original.tts_provider,
|
tts_provider=original.tts_provider,
|
||||||
tts_model=original.tts_model,
|
tts_model=original.tts_model,
|
||||||
speakers=original.speakers
|
speakers=original.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
await duplicate.save()
|
await duplicate.save()
|
||||||
|
|
||||||
return SpeakerProfileResponse(
|
return SpeakerProfileResponse(
|
||||||
id=str(duplicate.id),
|
id=str(duplicate.id),
|
||||||
name=duplicate.name,
|
name=duplicate.name,
|
||||||
description=duplicate.description or "",
|
description=duplicate.description or "",
|
||||||
tts_provider=duplicate.tts_provider,
|
tts_provider=duplicate.tts_provider,
|
||||||
tts_model=duplicate.tts_model,
|
tts_model=duplicate.tts_model,
|
||||||
speakers=duplicate.speakers
|
speakers=duplicate.speakers,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to duplicate speaker profile: {e}")
|
logger.error(f"Failed to duplicate speaker profile: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail="Failed to duplicate speaker profile"
|
||||||
detail=f"Failed to duplicate speaker profile: {str(e)}"
|
)
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@ async def get_default_prompt():
|
||||||
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
|
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
|
||||||
|
|
||||||
return DefaultPromptResponse(
|
return DefaultPromptResponse(
|
||||||
transformation_instructions=default_prompts.transformation_instructions or ""
|
transformation_instructions=default_prompts.transformation_instructions
|
||||||
|
or ""
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching default prompt: {str(e)}")
|
logger.error(f"Error fetching default prompt: {str(e)}")
|
||||||
|
|
@ -138,7 +139,9 @@ async def update_default_prompt(prompt_update: DefaultPromptUpdate):
|
||||||
try:
|
try:
|
||||||
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
|
default_prompts: DefaultPrompts = await DefaultPrompts.get_instance() # type: ignore[assignment]
|
||||||
|
|
||||||
default_prompts.transformation_instructions = prompt_update.transformation_instructions
|
default_prompts.transformation_instructions = (
|
||||||
|
prompt_update.transformation_instructions
|
||||||
|
)
|
||||||
await default_prompts.update()
|
await default_prompts.update()
|
||||||
|
|
||||||
return DefaultPromptResponse(
|
return DefaultPromptResponse(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class SearchService:
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
search_sources: bool = True,
|
search_sources: bool = True,
|
||||||
search_notes: bool = True,
|
search_notes: bool = True,
|
||||||
minimum_score: float = 0.2
|
minimum_score: float = 0.2,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search the knowledge base."""
|
"""Search the knowledge base."""
|
||||||
response = api_client.search(
|
response = api_client.search(
|
||||||
|
|
@ -31,7 +31,7 @@ class SearchService:
|
||||||
limit=limit,
|
limit=limit,
|
||||||
search_sources=search_sources,
|
search_sources=search_sources,
|
||||||
search_notes=search_notes,
|
search_notes=search_notes,
|
||||||
minimum_score=minimum_score
|
minimum_score=minimum_score,
|
||||||
)
|
)
|
||||||
if isinstance(response, dict):
|
if isinstance(response, dict):
|
||||||
return response.get("results", [])
|
return response.get("results", [])
|
||||||
|
|
@ -42,17 +42,17 @@ class SearchService:
|
||||||
question: str,
|
question: str,
|
||||||
strategy_model: str,
|
strategy_model: str,
|
||||||
answer_model: str,
|
answer_model: str,
|
||||||
final_answer_model: str
|
final_answer_model: str,
|
||||||
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Ask the knowledge base a question."""
|
"""Ask the knowledge base a question."""
|
||||||
response = api_client.ask_simple(
|
response = api_client.ask_simple(
|
||||||
question=question,
|
question=question,
|
||||||
strategy_model=strategy_model,
|
strategy_model=strategy_model,
|
||||||
answer_model=answer_model,
|
answer_model=answer_model,
|
||||||
final_answer_model=final_answer_model
|
final_answer_model=final_answer_model,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
search_service = SearchService()
|
search_service = SearchService()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
Settings service layer using API.
|
Settings service layer using API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from api.client import api_client
|
from api.client import api_client
|
||||||
|
|
@ -11,26 +10,36 @@ from open_notebook.domain.content_settings import ContentSettings
|
||||||
|
|
||||||
class SettingsService:
|
class SettingsService:
|
||||||
"""Service layer for settings operations using API."""
|
"""Service layer for settings operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for settings operations")
|
logger.info("Using API for settings operations")
|
||||||
|
|
||||||
def get_settings(self) -> ContentSettings:
|
def get_settings(self) -> ContentSettings:
|
||||||
"""Get application settings."""
|
"""Get application settings."""
|
||||||
settings_response = api_client.get_settings()
|
settings_response = api_client.get_settings()
|
||||||
settings_data = settings_response if isinstance(settings_response, dict) else settings_response[0]
|
settings_data = (
|
||||||
|
settings_response
|
||||||
|
if isinstance(settings_response, dict)
|
||||||
|
else settings_response[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Create ContentSettings object from API response
|
# Create ContentSettings object from API response
|
||||||
settings = ContentSettings(
|
settings = ContentSettings(
|
||||||
default_content_processing_engine_doc=settings_data.get("default_content_processing_engine_doc"),
|
default_content_processing_engine_doc=settings_data.get(
|
||||||
default_content_processing_engine_url=settings_data.get("default_content_processing_engine_url"),
|
"default_content_processing_engine_doc"
|
||||||
|
),
|
||||||
|
default_content_processing_engine_url=settings_data.get(
|
||||||
|
"default_content_processing_engine_url"
|
||||||
|
),
|
||||||
default_embedding_option=settings_data.get("default_embedding_option"),
|
default_embedding_option=settings_data.get("default_embedding_option"),
|
||||||
auto_delete_files=settings_data.get("auto_delete_files"),
|
auto_delete_files=settings_data.get("auto_delete_files"),
|
||||||
youtube_preferred_languages=settings_data.get("youtube_preferred_languages"),
|
youtube_preferred_languages=settings_data.get(
|
||||||
|
"youtube_preferred_languages"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
def update_settings(self, settings: ContentSettings) -> ContentSettings:
|
def update_settings(self, settings: ContentSettings) -> ContentSettings:
|
||||||
"""Update application settings."""
|
"""Update application settings."""
|
||||||
updates = {
|
updates = {
|
||||||
|
|
@ -42,17 +51,29 @@ class SettingsService:
|
||||||
}
|
}
|
||||||
|
|
||||||
settings_response = api_client.update_settings(**updates)
|
settings_response = api_client.update_settings(**updates)
|
||||||
settings_data = settings_response if isinstance(settings_response, dict) else settings_response[0]
|
settings_data = (
|
||||||
|
settings_response
|
||||||
|
if isinstance(settings_response, dict)
|
||||||
|
else settings_response[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Update the settings object with the response
|
# Update the settings object with the response
|
||||||
settings.default_content_processing_engine_doc = settings_data.get("default_content_processing_engine_doc")
|
settings.default_content_processing_engine_doc = settings_data.get(
|
||||||
settings.default_content_processing_engine_url = settings_data.get("default_content_processing_engine_url")
|
"default_content_processing_engine_doc"
|
||||||
settings.default_embedding_option = settings_data.get("default_embedding_option")
|
)
|
||||||
|
settings.default_content_processing_engine_url = settings_data.get(
|
||||||
|
"default_content_processing_engine_url"
|
||||||
|
)
|
||||||
|
settings.default_embedding_option = settings_data.get(
|
||||||
|
"default_embedding_option"
|
||||||
|
)
|
||||||
settings.auto_delete_files = settings_data.get("auto_delete_files")
|
settings.auto_delete_files = settings_data.get("auto_delete_files")
|
||||||
settings.youtube_preferred_languages = settings_data.get("youtube_preferred_languages")
|
settings.youtube_preferred_languages = settings_data.get(
|
||||||
|
"youtube_preferred_languages"
|
||||||
|
)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
settings_service = SettingsService()
|
settings_service = SettingsService()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from open_notebook.domain.notebook import Asset, Source
|
||||||
@dataclass
|
@dataclass
|
||||||
class SourceProcessingResult:
|
class SourceProcessingResult:
|
||||||
"""Result of source creation with optional async processing info."""
|
"""Result of source creation with optional async processing info."""
|
||||||
|
|
||||||
source: Source
|
source: Source
|
||||||
is_async: bool = False
|
is_async: bool = False
|
||||||
command_id: Optional[str] = None
|
command_id: Optional[str] = None
|
||||||
|
|
@ -24,38 +25,39 @@ class SourceProcessingResult:
|
||||||
@dataclass
|
@dataclass
|
||||||
class SourceWithMetadata:
|
class SourceWithMetadata:
|
||||||
"""Source object with additional metadata from API."""
|
"""Source object with additional metadata from API."""
|
||||||
|
|
||||||
source: Source
|
source: Source
|
||||||
embedded_chunks: int
|
embedded_chunks: int
|
||||||
|
|
||||||
# Expose common source properties for easy access
|
# Expose common source properties for easy access
|
||||||
@property
|
@property
|
||||||
def id(self):
|
def id(self):
|
||||||
return self.source.id
|
return self.source.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self):
|
def title(self):
|
||||||
return self.source.title
|
return self.source.title
|
||||||
|
|
||||||
@title.setter
|
@title.setter
|
||||||
def title(self, value):
|
def title(self, value):
|
||||||
self.source.title = value
|
self.source.title = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def topics(self):
|
def topics(self):
|
||||||
return self.source.topics
|
return self.source.topics
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def asset(self):
|
def asset(self):
|
||||||
return self.source.asset
|
return self.source.asset
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_text(self):
|
def full_text(self):
|
||||||
return self.source.full_text
|
return self.source.full_text
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def created(self):
|
def created(self):
|
||||||
return self.source.created
|
return self.source.created
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def updated(self):
|
def updated(self):
|
||||||
return self.source.updated
|
return self.source.updated
|
||||||
|
|
@ -67,7 +69,9 @@ class SourcesService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for sources operations")
|
logger.info("Using API for sources operations")
|
||||||
|
|
||||||
def get_all_sources(self, notebook_id: Optional[str] = None) -> List[SourceWithMetadata]:
|
def get_all_sources(
|
||||||
|
self, notebook_id: Optional[str] = None
|
||||||
|
) -> List[SourceWithMetadata]:
|
||||||
"""Get all sources with optional notebook filtering."""
|
"""Get all sources with optional notebook filtering."""
|
||||||
sources_data = api_client.get_sources(notebook_id=notebook_id)
|
sources_data = api_client.get_sources(notebook_id=notebook_id)
|
||||||
# Convert API response to SourceWithMetadata objects
|
# Convert API response to SourceWithMetadata objects
|
||||||
|
|
@ -88,11 +92,10 @@ class SourcesService:
|
||||||
source.id = source_data["id"]
|
source.id = source_data["id"]
|
||||||
source.created = source_data["created"]
|
source.created = source_data["created"]
|
||||||
source.updated = source_data["updated"]
|
source.updated = source_data["updated"]
|
||||||
|
|
||||||
# Wrap in SourceWithMetadata
|
# Wrap in SourceWithMetadata
|
||||||
source_with_metadata = SourceWithMetadata(
|
source_with_metadata = SourceWithMetadata(
|
||||||
source=source,
|
source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
|
||||||
embedded_chunks=source_data.get("embedded_chunks", 0)
|
|
||||||
)
|
)
|
||||||
sources.append(source_with_metadata)
|
sources.append(source_with_metadata)
|
||||||
return sources
|
return sources
|
||||||
|
|
@ -119,8 +122,7 @@ class SourcesService:
|
||||||
source.updated = source_data["updated"]
|
source.updated = source_data["updated"]
|
||||||
|
|
||||||
return SourceWithMetadata(
|
return SourceWithMetadata(
|
||||||
source=source,
|
source=source, embedded_chunks=source_data.get("embedded_chunks", 0)
|
||||||
embedded_chunks=source_data.get("embedded_chunks", 0)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_source(
|
def create_source(
|
||||||
|
|
@ -139,7 +141,7 @@ class SourcesService:
|
||||||
) -> Union[Source, SourceProcessingResult]:
|
) -> Union[Source, SourceProcessingResult]:
|
||||||
"""
|
"""
|
||||||
Create a new source with support for async processing.
|
Create a new source with support for async processing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
notebook_id: Single notebook ID (deprecated, use notebooks parameter)
|
notebook_id: Single notebook ID (deprecated, use notebooks parameter)
|
||||||
source_type: Type of source (link, upload, text)
|
source_type: Type of source (link, upload, text)
|
||||||
|
|
@ -152,7 +154,7 @@ class SourcesService:
|
||||||
delete_source: Whether to delete uploaded file after processing
|
delete_source: Whether to delete uploaded file after processing
|
||||||
notebooks: List of notebook IDs to add source to (preferred over notebook_id)
|
notebooks: List of notebook IDs to add source to (preferred over notebook_id)
|
||||||
async_processing: Whether to process source asynchronously
|
async_processing: Whether to process source asynchronously
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Source object for sync processing (backward compatibility)
|
Source object for sync processing (backward compatibility)
|
||||||
SourceProcessingResult for async processing (contains additional metadata)
|
SourceProcessingResult for async processing (contains additional metadata)
|
||||||
|
|
@ -193,9 +195,15 @@ class SourcesService:
|
||||||
source.updated = response_data["updated"]
|
source.updated = response_data["updated"]
|
||||||
|
|
||||||
# Check if this is an async processing response
|
# Check if this is an async processing response
|
||||||
if response_data.get("command_id") or response_data.get("status") or response_data.get("processing_info"):
|
if (
|
||||||
|
response_data.get("command_id")
|
||||||
|
or response_data.get("status")
|
||||||
|
or response_data.get("processing_info")
|
||||||
|
):
|
||||||
# Ensure source_data is a dict for accessing attributes
|
# Ensure source_data is a dict for accessing attributes
|
||||||
source_data_dict = source_data if isinstance(source_data, dict) else source_data[0]
|
source_data_dict = (
|
||||||
|
source_data if isinstance(source_data, dict) else source_data[0]
|
||||||
|
)
|
||||||
# Return enhanced result for async processing
|
# Return enhanced result for async processing
|
||||||
return SourceProcessingResult(
|
return SourceProcessingResult(
|
||||||
source=source,
|
source=source,
|
||||||
|
|
@ -228,7 +236,7 @@ class SourcesService:
|
||||||
) -> SourceProcessingResult:
|
) -> SourceProcessingResult:
|
||||||
"""
|
"""
|
||||||
Create a new source with async processing enabled.
|
Create a new source with async processing enabled.
|
||||||
|
|
||||||
This is a convenience method that always uses async processing.
|
This is a convenience method that always uses async processing.
|
||||||
Returns a SourceProcessingResult with processing status information.
|
Returns a SourceProcessingResult with processing status information.
|
||||||
"""
|
"""
|
||||||
|
|
@ -245,7 +253,7 @@ class SourcesService:
|
||||||
delete_source=delete_source,
|
delete_source=delete_source,
|
||||||
async_processing=True,
|
async_processing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Since we forced async_processing=True, this should always be a SourceProcessingResult
|
# Since we forced async_processing=True, this should always be a SourceProcessingResult
|
||||||
if isinstance(result, SourceProcessingResult):
|
if isinstance(result, SourceProcessingResult):
|
||||||
return result
|
return result
|
||||||
|
|
@ -259,14 +267,18 @@ class SourcesService:
|
||||||
def is_source_processing_complete(self, source_id: str) -> bool:
|
def is_source_processing_complete(self, source_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a source's async processing is complete.
|
Check if a source's async processing is complete.
|
||||||
|
|
||||||
Returns True if processing is complete (success or failure),
|
Returns True if processing is complete (success or failure),
|
||||||
False if still processing or queued.
|
False if still processing or queued.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
status_data = self.get_source_status(source_id)
|
status_data = self.get_source_status(source_id)
|
||||||
status = status_data.get("status")
|
status = status_data.get("status")
|
||||||
return status in ["completed", "failed", None] # None indicates legacy/sync source
|
return status in [
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
] # None indicates legacy/sync source
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking source processing status: {e}")
|
logger.error(f"Error checking source processing status: {e}")
|
||||||
return True # Assume complete on error
|
return True # Assume complete on error
|
||||||
|
|
@ -275,7 +287,7 @@ class SourcesService:
|
||||||
"""Update a source."""
|
"""Update a source."""
|
||||||
if not source.id:
|
if not source.id:
|
||||||
raise ValueError("Source ID is required for update")
|
raise ValueError("Source ID is required for update")
|
||||||
|
|
||||||
updates = {
|
updates = {
|
||||||
"title": source.title,
|
"title": source.title,
|
||||||
"topics": source.topics,
|
"topics": source.topics,
|
||||||
|
|
@ -283,7 +295,9 @@ class SourcesService:
|
||||||
source_data = api_client.update_source(source.id, **updates)
|
source_data = api_client.update_source(source.id, **updates)
|
||||||
|
|
||||||
# Ensure source_data is a dict
|
# Ensure source_data is a dict
|
||||||
source_data_dict = source_data if isinstance(source_data, dict) else source_data[0]
|
source_data_dict = (
|
||||||
|
source_data if isinstance(source_data, dict) else source_data[0]
|
||||||
|
)
|
||||||
|
|
||||||
# Update the source object with the response
|
# Update the source object with the response
|
||||||
source.title = source_data_dict["title"]
|
source.title = source_data_dict["title"]
|
||||||
|
|
@ -302,4 +316,9 @@ class SourcesService:
|
||||||
sources_service = SourcesService()
|
sources_service = SourcesService()
|
||||||
|
|
||||||
# Export important classes for easy importing
|
# Export important classes for easy importing
|
||||||
__all__ = ["SourcesService", "SourceWithMetadata", "SourceProcessingResult", "sources_service"]
|
__all__ = [
|
||||||
|
"SourcesService",
|
||||||
|
"SourceWithMetadata",
|
||||||
|
"SourceProcessingResult",
|
||||||
|
"sources_service",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ from open_notebook.domain.transformation import Transformation
|
||||||
|
|
||||||
class TransformationsService:
|
class TransformationsService:
|
||||||
"""Service layer for transformations operations using API."""
|
"""Service layer for transformations operations using API."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logger.info("Using API for transformations operations")
|
logger.info("Using API for transformations operations")
|
||||||
|
|
||||||
def get_all_transformations(self) -> List[Transformation]:
|
def get_all_transformations(self) -> List[Transformation]:
|
||||||
"""Get all transformations."""
|
"""Get all transformations."""
|
||||||
transformations_data = api_client.get_transformations()
|
transformations_data = api_client.get_transformations()
|
||||||
|
|
@ -31,11 +31,15 @@ class TransformationsService:
|
||||||
apply_default=trans_data["apply_default"],
|
apply_default=trans_data["apply_default"],
|
||||||
)
|
)
|
||||||
transformation.id = trans_data["id"]
|
transformation.id = trans_data["id"]
|
||||||
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
|
transformation.created = datetime.fromisoformat(
|
||||||
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
|
trans_data["created"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
transformation.updated = datetime.fromisoformat(
|
||||||
|
trans_data["updated"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
transformations.append(transformation)
|
transformations.append(transformation)
|
||||||
return transformations
|
return transformations
|
||||||
|
|
||||||
def get_transformation(self, transformation_id: str) -> Transformation:
|
def get_transformation(self, transformation_id: str) -> Transformation:
|
||||||
"""Get a specific transformation."""
|
"""Get a specific transformation."""
|
||||||
response = api_client.get_transformation(transformation_id)
|
response = api_client.get_transformation(transformation_id)
|
||||||
|
|
@ -48,17 +52,21 @@ class TransformationsService:
|
||||||
apply_default=trans_data["apply_default"],
|
apply_default=trans_data["apply_default"],
|
||||||
)
|
)
|
||||||
transformation.id = trans_data["id"]
|
transformation.id = trans_data["id"]
|
||||||
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
|
transformation.created = datetime.fromisoformat(
|
||||||
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
|
trans_data["created"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
transformation.updated = datetime.fromisoformat(
|
||||||
|
trans_data["updated"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
return transformation
|
return transformation
|
||||||
|
|
||||||
def create_transformation(
|
def create_transformation(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
title: str,
|
title: str,
|
||||||
description: str,
|
description: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
apply_default: bool = False
|
apply_default: bool = False,
|
||||||
) -> Transformation:
|
) -> Transformation:
|
||||||
"""Create a new transformation."""
|
"""Create a new transformation."""
|
||||||
response = api_client.create_transformation(
|
response = api_client.create_transformation(
|
||||||
|
|
@ -66,7 +74,7 @@ class TransformationsService:
|
||||||
title=title,
|
title=title,
|
||||||
description=description,
|
description=description,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
apply_default=apply_default
|
apply_default=apply_default,
|
||||||
)
|
)
|
||||||
trans_data = response if isinstance(response, dict) else response[0]
|
trans_data = response if isinstance(response, dict) else response[0]
|
||||||
transformation = Transformation(
|
transformation = Transformation(
|
||||||
|
|
@ -77,10 +85,14 @@ class TransformationsService:
|
||||||
apply_default=trans_data["apply_default"],
|
apply_default=trans_data["apply_default"],
|
||||||
)
|
)
|
||||||
transformation.id = trans_data["id"]
|
transformation.id = trans_data["id"]
|
||||||
transformation.created = datetime.fromisoformat(trans_data["created"].replace('Z', '+00:00'))
|
transformation.created = datetime.fromisoformat(
|
||||||
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
|
trans_data["created"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
transformation.updated = datetime.fromisoformat(
|
||||||
|
trans_data["updated"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
return transformation
|
return transformation
|
||||||
|
|
||||||
def update_transformation(self, transformation: Transformation) -> Transformation:
|
def update_transformation(self, transformation: Transformation) -> Transformation:
|
||||||
"""Update a transformation."""
|
"""Update a transformation."""
|
||||||
if not transformation.id:
|
if not transformation.id:
|
||||||
|
|
@ -102,29 +114,28 @@ class TransformationsService:
|
||||||
transformation.description = trans_data["description"]
|
transformation.description = trans_data["description"]
|
||||||
transformation.prompt = trans_data["prompt"]
|
transformation.prompt = trans_data["prompt"]
|
||||||
transformation.apply_default = trans_data["apply_default"]
|
transformation.apply_default = trans_data["apply_default"]
|
||||||
transformation.updated = datetime.fromisoformat(trans_data["updated"].replace('Z', '+00:00'))
|
transformation.updated = datetime.fromisoformat(
|
||||||
|
trans_data["updated"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
|
||||||
return transformation
|
return transformation
|
||||||
|
|
||||||
def delete_transformation(self, transformation_id: str) -> bool:
|
def delete_transformation(self, transformation_id: str) -> bool:
|
||||||
"""Delete a transformation."""
|
"""Delete a transformation."""
|
||||||
api_client.delete_transformation(transformation_id)
|
api_client.delete_transformation(transformation_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute_transformation(
|
def execute_transformation(
|
||||||
self,
|
self, transformation_id: str, input_text: str, model_id: str
|
||||||
transformation_id: str,
|
|
||||||
input_text: str,
|
|
||||||
model_id: str
|
|
||||||
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
||||||
"""Execute a transformation on input text."""
|
"""Execute a transformation on input text."""
|
||||||
result = api_client.execute_transformation(
|
result = api_client.execute_transformation(
|
||||||
transformation_id=transformation_id,
|
transformation_id=transformation_id,
|
||||||
input_text=input_text,
|
input_text=input_text,
|
||||||
model_id=model_id
|
model_id=model_id,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global service instance
|
# Global service instance
|
||||||
transformations_service = TransformationsService()
|
transformations_service = TransformationsService()
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,9 @@ async def embed_single_item_command(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
logger.error(f"Embedding failed for {input_data.item_type} {input_data.item_id}: {e}")
|
logger.error(
|
||||||
|
f"Embedding failed for {input_data.item_type} {input_data.item_id}: {e}"
|
||||||
|
)
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
return EmbedSingleItemOutput(
|
return EmbedSingleItemOutput(
|
||||||
|
|
@ -317,7 +319,9 @@ async def vectorize_source_command(
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Starting vectorization orchestration for source {input_data.source_id}")
|
logger.info(
|
||||||
|
f"Starting vectorization orchestration for source {input_data.source_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Load source
|
# 1. Load source
|
||||||
source = await Source.get(input_data.source_id)
|
source = await Source.get(input_data.source_id)
|
||||||
|
|
@ -331,7 +335,7 @@ async def vectorize_source_command(
|
||||||
logger.info(f"Deleting existing embeddings for source {input_data.source_id}")
|
logger.info(f"Deleting existing embeddings for source {input_data.source_id}")
|
||||||
delete_result = await repo_query(
|
delete_result = await repo_query(
|
||||||
"DELETE source_embedding WHERE source = $source_id",
|
"DELETE source_embedding WHERE source = $source_id",
|
||||||
{"source_id": ensure_record_id(input_data.source_id)}
|
{"source_id": ensure_record_id(input_data.source_id)},
|
||||||
)
|
)
|
||||||
deleted_count = len(delete_result) if delete_result else 0
|
deleted_count = len(delete_result) if delete_result else 0
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
|
|
@ -354,12 +358,12 @@ async def vectorize_source_command(
|
||||||
try:
|
try:
|
||||||
job_id = submit_command(
|
job_id = submit_command(
|
||||||
"open_notebook", # app name
|
"open_notebook", # app name
|
||||||
"embed_chunk", # command name
|
"embed_chunk", # command name
|
||||||
{
|
{
|
||||||
"source_id": input_data.source_id,
|
"source_id": input_data.source_id,
|
||||||
"chunk_index": idx,
|
"chunk_index": idx,
|
||||||
"chunk_text": chunk_text,
|
"chunk_text": chunk_text,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
jobs_submitted += 1
|
jobs_submitted += 1
|
||||||
|
|
||||||
|
|
@ -387,7 +391,9 @@ async def vectorize_source_command(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
logger.error(f"Vectorization orchestration failed for source {input_data.source_id}: {e}")
|
logger.error(
|
||||||
|
f"Vectorization orchestration failed for source {input_data.source_id}: {e}"
|
||||||
|
)
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|
||||||
return VectorizeSourceOutput(
|
return VectorizeSourceOutput(
|
||||||
|
|
@ -484,7 +490,9 @@ async def rebuild_embeddings_command(
|
||||||
try:
|
try:
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info(f"Starting embedding rebuild with mode={input_data.mode}")
|
logger.info(f"Starting embedding rebuild with mode={input_data.mode}")
|
||||||
logger.info(f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}")
|
logger.info(
|
||||||
|
f"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}"
|
||||||
|
)
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# Check embedding model availability
|
# Check embedding model availability
|
||||||
|
|
@ -561,7 +569,9 @@ async def rebuild_embeddings_command(
|
||||||
notes_processed += 1
|
notes_processed += 1
|
||||||
|
|
||||||
if idx % 10 == 0 or idx == len(items["notes"]):
|
if idx % 10 == 0 or idx == len(items["notes"]):
|
||||||
logger.info(f" Progress: {idx}/{len(items['notes'])} notes processed")
|
logger.info(
|
||||||
|
f" Progress: {idx}/{len(items['notes'])} notes processed"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to re-embed note {note_id}: {e}")
|
logger.error(f"Failed to re-embed note {note_id}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class TextProcessingInput(BaseModel):
|
||||||
operation: str = "uppercase" # uppercase, lowercase, word_count, reverse
|
operation: str = "uppercase" # uppercase, lowercase, word_count, reverse
|
||||||
delay_seconds: Optional[int] = None # For testing async behavior
|
delay_seconds: Optional[int] = None # For testing async behavior
|
||||||
|
|
||||||
|
|
||||||
class TextProcessingOutput(BaseModel):
|
class TextProcessingOutput(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
original_text: str
|
original_text: str
|
||||||
|
|
@ -20,11 +21,13 @@ class TextProcessingOutput(BaseModel):
|
||||||
processing_time: float
|
processing_time: float
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DataAnalysisInput(BaseModel):
|
class DataAnalysisInput(BaseModel):
|
||||||
numbers: List[float]
|
numbers: List[float]
|
||||||
analysis_type: str = "basic" # basic, detailed
|
analysis_type: str = "basic" # basic, detailed
|
||||||
delay_seconds: Optional[int] = None
|
delay_seconds: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class DataAnalysisOutput(BaseModel):
|
class DataAnalysisOutput(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
analysis_type: str
|
analysis_type: str
|
||||||
|
|
@ -36,6 +39,7 @@ class DataAnalysisOutput(BaseModel):
|
||||||
processing_time: float
|
processing_time: float
|
||||||
error_message: Optional[str] = None
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@command("process_text", app="open_notebook")
|
@command("process_text", app="open_notebook")
|
||||||
async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:
|
async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:
|
||||||
"""
|
"""
|
||||||
|
|
@ -43,17 +47,17 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
|
||||||
and demonstrates different processing types.
|
and demonstrates different processing types.
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Processing text with operation: {input_data.operation}")
|
logger.info(f"Processing text with operation: {input_data.operation}")
|
||||||
|
|
||||||
# Simulate processing delay if specified
|
# Simulate processing delay if specified
|
||||||
if input_data.delay_seconds:
|
if input_data.delay_seconds:
|
||||||
await asyncio.sleep(input_data.delay_seconds)
|
await asyncio.sleep(input_data.delay_seconds)
|
||||||
|
|
||||||
processed_text = None
|
processed_text = None
|
||||||
word_count = None
|
word_count = None
|
||||||
|
|
||||||
if input_data.operation == "uppercase":
|
if input_data.operation == "uppercase":
|
||||||
processed_text = input_data.text.upper()
|
processed_text = input_data.text.upper()
|
||||||
elif input_data.operation == "lowercase":
|
elif input_data.operation == "lowercase":
|
||||||
|
|
@ -65,17 +69,17 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
|
||||||
processed_text = f"Word count: {word_count}"
|
processed_text = f"Word count: {word_count}"
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown operation: {input_data.operation}")
|
raise ValueError(f"Unknown operation: {input_data.operation}")
|
||||||
|
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
return TextProcessingOutput(
|
return TextProcessingOutput(
|
||||||
success=True,
|
success=True,
|
||||||
original_text=input_data.text,
|
original_text=input_data.text,
|
||||||
processed_text=processed_text,
|
processed_text=processed_text,
|
||||||
word_count=word_count,
|
word_count=word_count,
|
||||||
processing_time=processing_time
|
processing_time=processing_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
logger.error(f"Text processing failed: {e}")
|
logger.error(f"Text processing failed: {e}")
|
||||||
|
|
@ -83,9 +87,10 @@ async def process_text_command(input_data: TextProcessingInput) -> TextProcessin
|
||||||
success=False,
|
success=False,
|
||||||
original_text=input_data.text,
|
original_text=input_data.text,
|
||||||
processing_time=processing_time,
|
processing_time=processing_time,
|
||||||
error_message=str(e)
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@command("analyze_data", app="open_notebook")
|
@command("analyze_data", app="open_notebook")
|
||||||
async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:
|
async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:
|
||||||
"""
|
"""
|
||||||
|
|
@ -93,25 +98,27 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
|
||||||
and demonstrates error handling.
|
and demonstrates error handling.
|
||||||
"""
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis")
|
logger.info(
|
||||||
|
f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis"
|
||||||
|
)
|
||||||
|
|
||||||
# Simulate processing delay if specified
|
# Simulate processing delay if specified
|
||||||
if input_data.delay_seconds:
|
if input_data.delay_seconds:
|
||||||
await asyncio.sleep(input_data.delay_seconds)
|
await asyncio.sleep(input_data.delay_seconds)
|
||||||
|
|
||||||
if not input_data.numbers:
|
if not input_data.numbers:
|
||||||
raise ValueError("No numbers provided for analysis")
|
raise ValueError("No numbers provided for analysis")
|
||||||
|
|
||||||
count = len(input_data.numbers)
|
count = len(input_data.numbers)
|
||||||
sum_value = sum(input_data.numbers)
|
sum_value = sum(input_data.numbers)
|
||||||
average = sum_value / count
|
average = sum_value / count
|
||||||
min_value = min(input_data.numbers)
|
min_value = min(input_data.numbers)
|
||||||
max_value = max(input_data.numbers)
|
max_value = max(input_data.numbers)
|
||||||
|
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
return DataAnalysisOutput(
|
return DataAnalysisOutput(
|
||||||
success=True,
|
success=True,
|
||||||
analysis_type=input_data.analysis_type,
|
analysis_type=input_data.analysis_type,
|
||||||
|
|
@ -120,9 +127,9 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
|
||||||
average=average,
|
average=average,
|
||||||
min_value=min_value,
|
min_value=min_value,
|
||||||
max_value=max_value,
|
max_value=max_value,
|
||||||
processing_time=processing_time
|
processing_time=processing_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
logger.error(f"Data analysis failed: {e}")
|
logger.error(f"Data analysis failed: {e}")
|
||||||
|
|
@ -131,5 +138,5 @@ async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOut
|
||||||
analysis_type=input_data.analysis_type,
|
analysis_type=input_data.analysis_type,
|
||||||
count=0,
|
count=0,
|
||||||
processing_time=processing_time,
|
processing_time=processing_time,
|
||||||
error_message=str(e)
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
open_notebook:
|
open_notebook:
|
||||||
image: lfnovo/open_notebook:v1-latest
|
image: lfnovo/open_notebook:v1-latest
|
||||||
|
# build:
|
||||||
|
# context: .
|
||||||
|
# dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8502:8502"
|
- "8502:8502"
|
||||||
- "5055:5055"
|
- "5055:5055"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ services:
|
||||||
- "5055:5055" # REST API
|
- "5055:5055" # REST API
|
||||||
env_file:
|
env_file:
|
||||||
- ./docker.env
|
- ./docker.env
|
||||||
|
environment:
|
||||||
|
# Override for single-container mode: SurrealDB runs on localhost inside the same container
|
||||||
|
- SURREAL_URL=ws://localhost:8000/rpc
|
||||||
volumes:
|
volumes:
|
||||||
- ./notebook_data:/app/data # Application data
|
- ./notebook_data:/app/data # Application data
|
||||||
- ./surreal_single_data:/mydata # SurrealDB data
|
- ./surreal_single_data:/mydata # SurrealDB data
|
||||||
|
|
|
||||||
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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "node start-server.js",
|
"start": "node start-server.js",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
|
@ -35,12 +38,15 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^25.7.3",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.60.0",
|
||||||
|
"react-i18next": "^16.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
|
|
@ -57,8 +63,14 @@
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.2",
|
"eslint-config-next": "15.4.2",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
"@vitest/ui": "^3.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,10 @@ import {
|
||||||
} from '@/components/ui/accordion'
|
} from '@/components/ui/accordion'
|
||||||
import { embeddingApi } from '@/lib/api/embedding'
|
import { embeddingApi } from '@/lib/api/embedding'
|
||||||
import type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding'
|
import type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export function RebuildEmbeddings() {
|
export function RebuildEmbeddings() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [mode, setMode] = useState<'existing' | 'all'>('existing')
|
const [mode, setMode] = useState<'existing' | 'all'>('existing')
|
||||||
const [includeSources, setIncludeSources] = useState(true)
|
const [includeSources, setIncludeSources] = useState(true)
|
||||||
const [includeNotes, setIncludeNotes] = useState(true)
|
const [includeNotes, setIncludeNotes] = useState(true)
|
||||||
|
|
@ -121,10 +123,10 @@ export function RebuildEmbeddings() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
🔄 Rebuild Embeddings
|
{t.advanced.rebuildEmbeddings}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Rebuild vector embeddings for your content. Use this when switching embedding models or fixing corrupted embeddings.
|
{t.advanced.rebuildEmbeddingsDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
|
@ -132,25 +134,25 @@ export function RebuildEmbeddings() {
|
||||||
{!isRebuildActive && (
|
{!isRebuildActive && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="mode">Rebuild Mode</Label>
|
<Label htmlFor="mode">{t.advanced.rebuild.mode}</Label>
|
||||||
<Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}>
|
<Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}>
|
||||||
<SelectTrigger id="mode">
|
<SelectTrigger id="mode">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="existing">Existing</SelectItem>
|
<SelectItem value="existing">{t.advanced.rebuild.existing}</SelectItem>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">{t.advanced.rebuild.all}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{mode === 'existing'
|
{mode === 'existing'
|
||||||
? 'Re-embed only items that already have embeddings (faster, for model switching)'
|
? t.advanced.rebuild.existingDesc
|
||||||
: 'Re-embed existing items + create embeddings for items without any (slower, comprehensive)'}
|
: t.advanced.rebuild.allDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3" role="group" aria-labelledby="include-label">
|
||||||
<Label>Include in Rebuild</Label>
|
<span id="include-label" className="text-sm font-medium leading-none">{t.advanced.rebuild.include}</span>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -159,7 +161,7 @@ export function RebuildEmbeddings() {
|
||||||
onCheckedChange={(checked) => setIncludeSources(checked === true)}
|
onCheckedChange={(checked) => setIncludeSources(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="sources" className="font-normal cursor-pointer">
|
<Label htmlFor="sources" className="font-normal cursor-pointer">
|
||||||
Sources
|
{t.navigation.sources}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -169,7 +171,7 @@ export function RebuildEmbeddings() {
|
||||||
onCheckedChange={(checked) => setIncludeNotes(checked === true)}
|
onCheckedChange={(checked) => setIncludeNotes(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="notes" className="font-normal cursor-pointer">
|
<Label htmlFor="notes" className="font-normal cursor-pointer">
|
||||||
Notes
|
{t.common.notes}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -179,7 +181,7 @@ export function RebuildEmbeddings() {
|
||||||
onCheckedChange={(checked) => setIncludeInsights(checked === true)}
|
onCheckedChange={(checked) => setIncludeInsights(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="insights" className="font-normal cursor-pointer">
|
<Label htmlFor="insights" className="font-normal cursor-pointer">
|
||||||
Insights
|
{t.common.insights}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,7 +189,7 @@ export function RebuildEmbeddings() {
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Please select at least one item type to rebuild
|
{t.advanced.rebuild.selectOneError}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -201,10 +203,10 @@ export function RebuildEmbeddings() {
|
||||||
{rebuildMutation.isPending ? (
|
{rebuildMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Starting Rebuild...
|
{t.advanced.rebuild.starting}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'🚀 Start Rebuild'
|
t.advanced.rebuild.startBtn
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -212,7 +214,7 @@ export function RebuildEmbeddings() {
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Failed to start rebuild: {(rebuildMutation.error as Error)?.message || 'Unknown error'}
|
{t.advanced.rebuild.failed}: {(rebuildMutation.error as Error)?.message || t.common.error}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -230,21 +232,21 @@ export function RebuildEmbeddings() {
|
||||||
{status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />}
|
{status.status === 'failed' && <XCircle className="h-5 w-5 text-red-500" />}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{status.status === 'queued' && 'Queued'}
|
{status.status === 'queued' && t.advanced.rebuild.queued}
|
||||||
{status.status === 'running' && 'Running...'}
|
{status.status === 'running' && t.advanced.rebuild.running}
|
||||||
{status.status === 'completed' && 'Completed!'}
|
{status.status === 'completed' && t.advanced.rebuild.completed}
|
||||||
{status.status === 'failed' && 'Failed'}
|
{status.status === 'failed' && t.advanced.rebuild.failed}
|
||||||
</span>
|
</span>
|
||||||
{status.status === 'running' && (
|
{status.status === 'running' && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
You can leave this page as this will run in the background
|
{t.advanced.rebuild.leavePageHint}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(status.status === 'completed' || status.status === 'failed') && (
|
{(status.status === 'completed' || status.status === 'failed') && (
|
||||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||||
Start New Rebuild
|
{t.advanced.rebuild.startNew}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -252,36 +254,39 @@ export function RebuildEmbeddings() {
|
||||||
{progressData && (
|
{progressData && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span>Progress</span>
|
<span>{t.common.progress}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{processedItems}/{totalItems} items ({progressPercent.toFixed(1)}%)
|
{t.advanced.rebuild.itemsProcessed
|
||||||
|
.replace('{processed}', processedItems.toString())
|
||||||
|
.replace('{total}', totalItems.toString())
|
||||||
|
.replace('{percent}', progressPercent.toFixed(1))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progressPercent} className="h-2" />
|
<Progress value={progressPercent} className="h-2" />
|
||||||
{failedItems > 0 && (
|
{failedItems > 0 && (
|
||||||
<p className="text-sm text-yellow-600">
|
<p className="text-sm text-yellow-600">
|
||||||
⚠️ {failedItems} items failed to process
|
⚠️ {t.advanced.rebuild.failedItems.replace('{count}', failedItems.toString())}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Sources</p>
|
<p className="text-sm text-muted-foreground">{t.navigation.sources}</p>
|
||||||
<p className="text-2xl font-bold">{sourcesProcessed}</p>
|
<p className="text-2xl font-bold">{sourcesProcessed}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Notes</p>
|
<p className="text-sm text-muted-foreground">{t.common.notes}</p>
|
||||||
<p className="text-2xl font-bold">{notesProcessed}</p>
|
<p className="text-2xl font-bold">{notesProcessed}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Insights</p>
|
<p className="text-sm text-muted-foreground">{t.common.insights}</p>
|
||||||
<p className="text-2xl font-bold">{insightsProcessed}</p>
|
<p className="text-2xl font-bold">{insightsProcessed}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">Time</p>
|
<p className="text-sm text-muted-foreground">{t.advanced.rebuild.time}</p>
|
||||||
<p className="text-2xl font-bold">
|
<p className="text-2xl font-bold">
|
||||||
{processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}
|
{processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -298,9 +303,9 @@ export function RebuildEmbeddings() {
|
||||||
|
|
||||||
{status.started_at && (
|
{status.started_at && (
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
<p>Started: {new Date(status.started_at).toLocaleString()}</p>
|
<p>{t.common.created.replace('{time}', new Date(status.started_at).toLocaleString())}</p>
|
||||||
{status.completed_at && (
|
{status.completed_at && (
|
||||||
<p>Completed: {new Date(status.completed_at).toLocaleString()}</p>
|
<p>{t.notebooks.updated}: {new Date(status.completed_at).toLocaleString()}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -308,51 +313,25 @@ export function RebuildEmbeddings() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Help Section */}
|
{/* Help Section */}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="when">
|
<AccordionItem value="when">
|
||||||
<AccordionTrigger>When should I rebuild embeddings?</AccordionTrigger>
|
<AccordionTrigger>{t.advanced.rebuild.whenToRebuild}</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-2 text-sm">
|
<AccordionContent className="space-y-2 text-sm">
|
||||||
<p><strong>You should rebuild embeddings when:</strong></p>
|
<p>{t.advanced.rebuild.whenToRebuildAns}</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
|
||||||
<li><strong>Switching embedding models:</strong> If you change from one embedding model to another, you need to rebuild all embeddings to ensure consistency.</li>
|
|
||||||
<li><strong>Upgrading model versions:</strong> When updating to a newer version of your embedding model, rebuild to take advantage of improvements.</li>
|
|
||||||
<li><strong>Fixing corrupted embeddings:</strong> If you suspect some embeddings are corrupted or missing, rebuilding can restore them.</li>
|
|
||||||
<li><strong>After bulk imports:</strong> If you imported content without embeddings, use "All" mode to embed everything.</li>
|
|
||||||
</ul>
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="time">
|
<AccordionItem value="time">
|
||||||
<AccordionTrigger>How long does rebuilding take?</AccordionTrigger>
|
<AccordionTrigger>{t.advanced.rebuild.howLong}</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-2 text-sm">
|
<AccordionContent className="space-y-2 text-sm">
|
||||||
<p><strong>Processing time depends on:</strong></p>
|
<p>{t.advanced.rebuild.howLongAns}</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
|
||||||
<li>Number of items to process</li>
|
|
||||||
<li>Embedding model speed</li>
|
|
||||||
<li>API rate limits (for cloud providers)</li>
|
|
||||||
<li>System resources</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-2"><strong>Typical rates:</strong></p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
|
||||||
<li><strong>Local models</strong> (Ollama): Very fast, limited only by hardware</li>
|
|
||||||
<li><strong>Cloud APIs</strong> (OpenAI, Google): Moderate speed, may hit rate limits with large datasets</li>
|
|
||||||
<li><strong>Sources:</strong> Slower than notes/insights (creates multiple chunks per source)</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-2"><em>Example: Rebuilding 200 items might take 2-5 minutes with cloud APIs, or under 1 minute with local models.</em></p>
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="safe">
|
<AccordionItem value="safe">
|
||||||
<AccordionTrigger>Is it safe to rebuild while using the app?</AccordionTrigger>
|
<AccordionTrigger>{t.advanced.rebuild.isSafe}</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-2 text-sm">
|
<AccordionContent className="space-y-2 text-sm">
|
||||||
<p><strong>Yes, rebuilding is safe!</strong> The rebuild process:</p>
|
<p>{t.advanced.rebuild.isSafeAns}</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
|
||||||
<li>✅ <strong>Is idempotent:</strong> Running multiple times produces the same result</li>
|
|
||||||
<li>✅ <strong>Doesn't delete content:</strong> Only replaces embeddings</li>
|
|
||||||
<li>✅ <strong>Can be run anytime:</strong> No need to stop other operations</li>
|
|
||||||
<li>✅ <strong>Handles errors gracefully:</strong> Failed items are logged and skipped</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-2">⚠️ <strong>However:</strong> Very large rebuilds (1000s of items) may temporarily slow down searches while processing.</p>
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import { useEffect, useState } from 'react'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { getConfig } from '@/lib/config'
|
import { getConfig } from '@/lib/config'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export function SystemInfo() {
|
export function SystemInfo() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [config, setConfig] = useState<{
|
const [config, setConfig] = useState<{
|
||||||
version: string
|
version: string
|
||||||
latestVersion?: string | null
|
latestVersion?: string | null
|
||||||
|
|
@ -32,8 +34,8 @@ export function SystemInfo() {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">System Information</h2>
|
<h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2>
|
||||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
<div className="text-sm text-muted-foreground">{t.common.loading}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
@ -42,37 +44,37 @@ export function SystemInfo() {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold">System Information</h2>
|
<h2 className="text-xl font-semibold">{t.advanced.systemInfo}</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Current Version */}
|
{/* Current Version */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Current Version</span>
|
<span className="text-sm font-medium">{t.advanced.currentVersion}</span>
|
||||||
<Badge variant="outline">{config?.version || 'Unknown'}</Badge>
|
<Badge variant="outline">{config?.version || t.advanced.unknown}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Latest Version */}
|
{/* Latest Version */}
|
||||||
{config?.latestVersion && (
|
{config?.latestVersion && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Latest Version</span>
|
<span className="text-sm font-medium">{t.advanced.latestVersion}</span>
|
||||||
<Badge variant="outline">{config.latestVersion}</Badge>
|
<Badge variant="outline">{config.latestVersion}</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Update Status */}
|
{/* Update Status */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Status</span>
|
<span className="text-sm font-medium">{t.advanced.status}</span>
|
||||||
{config?.hasUpdate ? (
|
{config?.hasUpdate ? (
|
||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
Update Available
|
{t.advanced.updateAvailable.replace('{version}', config.latestVersion || '')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : config?.latestVersion ? (
|
) : config?.latestVersion ? (
|
||||||
<Badge variant="outline" className="text-green-600 border-green-600">
|
<Badge variant="outline" className="text-green-600 border-green-600">
|
||||||
Up to Date
|
{t.advanced.upToDate}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-muted-foreground">
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
Unknown
|
{t.advanced.unknown}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +88,7 @@ export function SystemInfo() {
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
View on GitHub
|
{t.advanced.viewOnGithub}
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
|
|
@ -107,7 +109,7 @@ export function SystemInfo() {
|
||||||
{/* Version Check Failed Message */}
|
{/* Version Check Failed Message */}
|
||||||
{!config?.latestVersion && config?.version && (
|
{!config?.latestVersion && config?.version && (
|
||||||
<div className="pt-2 text-xs text-muted-foreground">
|
<div className="pt-2 text-xs text-muted-foreground">
|
||||||
Unable to check for updates. GitHub may be unreachable.
|
{t.advanced.updateCheckFailed}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,19 @@
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
import { RebuildEmbeddings } from './components/RebuildEmbeddings'
|
import { RebuildEmbeddings } from './components/RebuildEmbeddings'
|
||||||
import { SystemInfo } from './components/SystemInfo'
|
import { SystemInfo } from './components/SystemInfo'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function AdvancedPage() {
|
export default function AdvancedPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Advanced</h1>
|
<h1 className="text-3xl font-bold">{t.advanced.title}</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Advanced tools and utilities for power users
|
{t.advanced.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
|
import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { useCreateModel } from '@/lib/hooks/use-models'
|
import { useCreateModel } from '@/lib/hooks/use-models'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface AddModelFormProps {
|
interface AddModelFormProps {
|
||||||
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||||
|
|
@ -17,6 +18,9 @@ interface AddModelFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const providerSelectId = useId()
|
||||||
|
const modelNameInputId = useId()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const createModel = useCreateModel()
|
const createModel = useCreateModel()
|
||||||
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({
|
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({
|
||||||
|
|
@ -37,7 +41,7 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getModelTypeName = () => {
|
const getModelTypeName = () => {
|
||||||
return modelType.replace(/_/g, ' ')
|
return (t.models as Record<string, string>)[modelType] || modelType.replace(/_/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getModelPlaceholder = () => {
|
const getModelPlaceholder = () => {
|
||||||
|
|
@ -51,14 +55,14 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
||||||
case 'speech_to_text':
|
case 'speech_to_text':
|
||||||
return 'e.g., whisper-1'
|
return 'e.g., whisper-1'
|
||||||
default:
|
default:
|
||||||
return 'Enter model name'
|
return t.models.enterModelName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableProviders.length === 0) {
|
if (availableProviders.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
No providers available for {getModelTypeName()} models
|
{t.models.noProvidersForType.replace('{type}', getModelTypeName())}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -73,24 +77,34 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">
|
<Button
|
||||||
|
id={`add-model-${modelType}`}
|
||||||
|
name={`add-model-${modelType}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Model
|
{t.models.addModel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add {getModelTypeName()} Model</DialogTitle>
|
<DialogTitle>
|
||||||
|
{t.models.addSpecificModel.replace('{type}', getModelTypeName())}
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Configure a new {getModelTypeName()} model from available providers.
|
{t.models.addSpecificModelDesc.replace('{type}', getModelTypeName())}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="provider">Provider</Label>
|
<Label htmlFor={providerSelectId}>{t.models.provider}</Label>
|
||||||
<Select onValueChange={(value) => setValue('provider', value)} required>
|
<Select
|
||||||
<SelectTrigger>
|
name="provider"
|
||||||
<SelectValue placeholder="Select a provider" />
|
onValueChange={(value) => setValue('provider', value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger id={providerSelectId}>
|
||||||
|
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableProviders.map((provider) => (
|
{availableProviders.map((provider) => (
|
||||||
|
|
@ -101,32 +115,33 @@ export function AddModelForm({ modelType, providers }: AddModelFormProps) {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.provider && (
|
{errors.provider && (
|
||||||
<p className="text-sm text-destructive mt-1">Provider is required</p>
|
<p className="text-sm text-destructive mt-1">{t.models.providerRequired}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">Model Name</Label>
|
<Label htmlFor={modelNameInputId}>{t.models.modelName}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id={modelNameInputId}
|
||||||
{...register('name', { required: 'Model name is required' })}
|
{...register('name', { required: t.models.modelNameRequired })}
|
||||||
placeholder={getModelPlaceholder()}
|
placeholder={getModelPlaceholder()}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{modelType === 'language' && watch('provider') === 'azure' &&
|
{modelType === 'language' && watch('provider') === 'azure' &&
|
||||||
'For Azure, use the deployment name as the model name'}
|
t.models.azureHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createModel.isPending}>
|
<Button type="submit" disabled={createModel.isPending}>
|
||||||
{createModel.isPending ? 'Adding...' : 'Add Model'}
|
{createModel.isPending ? t.models.adding : t.models.addModel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useId } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
|
@ -11,74 +11,86 @@ import { ModelDefaults, Model } from '@/lib/types/models'
|
||||||
import { useUpdateModelDefaults } from '@/lib/hooks/use-models'
|
import { useUpdateModelDefaults } from '@/lib/hooks/use-models'
|
||||||
import { AlertCircle, X } from 'lucide-react'
|
import { AlertCircle, X } from 'lucide-react'
|
||||||
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
|
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface DefaultModelsSectionProps {
|
interface DefaultModelsSectionProps {
|
||||||
models: Model[]
|
models: Model[]
|
||||||
defaults: ModelDefaults
|
defaults: ModelDefaults
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DefaultConfig {
|
|
||||||
key: keyof ModelDefaults
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
|
||||||
required?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultConfigs: DefaultConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'default_chat_model',
|
|
||||||
label: 'Chat Model',
|
|
||||||
description: 'Used for chat conversations',
|
|
||||||
modelType: 'language',
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'default_transformation_model',
|
|
||||||
label: 'Transformation Model',
|
|
||||||
description: 'Used for summaries, insights, and transformations',
|
|
||||||
modelType: 'language',
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'default_tools_model',
|
|
||||||
label: 'Tools Model',
|
|
||||||
description: 'Used for function calling - OpenAI or Anthropic recommended',
|
|
||||||
modelType: 'language'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'large_context_model',
|
|
||||||
label: 'Large Context Model',
|
|
||||||
description: 'Used for processing large documents - Gemini recommended',
|
|
||||||
modelType: 'language'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'default_embedding_model',
|
|
||||||
label: 'Embedding Model',
|
|
||||||
description: 'Used for semantic search and vector embeddings',
|
|
||||||
modelType: 'embedding',
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'default_text_to_speech_model',
|
|
||||||
label: 'Text-to-Speech Model',
|
|
||||||
description: 'Used for podcast generation',
|
|
||||||
modelType: 'text_to_speech'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'default_speech_to_text_model',
|
|
||||||
label: 'Speech-to-Text Model',
|
|
||||||
description: 'Used for audio transcription',
|
|
||||||
modelType: 'speech_to_text'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) {
|
export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const updateDefaults = useUpdateModelDefaults()
|
const updateDefaults = useUpdateModelDefaults()
|
||||||
const { setValue, watch } = useForm<ModelDefaults>({
|
const { setValue, watch } = useForm<ModelDefaults>({
|
||||||
defaultValues: defaults
|
defaultValues: defaults
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface DefaultConfig {
|
||||||
|
key: keyof ModelDefaults
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||||
|
required?: boolean
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedId = useId()
|
||||||
|
|
||||||
|
const defaultConfigs: DefaultConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'default_chat_model',
|
||||||
|
label: t.models.chatModelLabel,
|
||||||
|
description: t.models.chatModelDesc,
|
||||||
|
modelType: 'language',
|
||||||
|
required: true,
|
||||||
|
id: `${generatedId}-chat`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default_transformation_model',
|
||||||
|
label: t.models.transformationModelLabel,
|
||||||
|
description: t.models.transformationModelDesc,
|
||||||
|
modelType: 'language',
|
||||||
|
required: true,
|
||||||
|
id: `${generatedId}-transformation`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default_tools_model',
|
||||||
|
label: t.models.toolsModelLabel,
|
||||||
|
description: t.models.toolsModelDesc,
|
||||||
|
modelType: 'language',
|
||||||
|
id: `${generatedId}-tools`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'large_context_model',
|
||||||
|
label: t.models.largeContextModelLabel,
|
||||||
|
description: t.models.largeContextModelDesc,
|
||||||
|
modelType: 'language',
|
||||||
|
id: `${generatedId}-large-context`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default_embedding_model',
|
||||||
|
label: t.models.embeddingModelLabel,
|
||||||
|
description: t.models.embeddingModelDesc,
|
||||||
|
modelType: 'embedding',
|
||||||
|
required: true,
|
||||||
|
id: `${generatedId}-embedding`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default_text_to_speech_model',
|
||||||
|
label: t.models.ttsModelLabel,
|
||||||
|
description: t.models.ttsModelDesc,
|
||||||
|
modelType: 'text_to_speech',
|
||||||
|
id: `${generatedId}-tts`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'default_speech_to_text_model',
|
||||||
|
label: t.models.sttModelLabel,
|
||||||
|
description: t.models.sttModelDesc,
|
||||||
|
modelType: 'speech_to_text',
|
||||||
|
id: `${generatedId}-stt`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// State for embedding model change dialog
|
// State for embedding model change dialog
|
||||||
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
|
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
|
||||||
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
|
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
|
||||||
|
|
@ -153,9 +165,9 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Default Model Assignments</CardTitle>
|
<CardTitle>{t.models.defaultAssignments}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure which models to use for different purposes across Open Notebook
|
{t.models.defaultAssignmentsDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
|
@ -163,8 +175,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Missing required models: {missingRequired.join(', ')}.
|
{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}
|
||||||
Open Notebook may not function properly without these.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -179,7 +190,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={config.key} className="space-y-2">
|
<div key={config.key} className="space-y-2">
|
||||||
<Label>
|
<Label htmlFor={config.id}>
|
||||||
{config.label}
|
{config.label}
|
||||||
{config.required && <span className="text-destructive ml-1">*</span>}
|
{config.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
@ -188,15 +199,18 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
|
||||||
value={currentValue || ""}
|
value={currentValue || ""}
|
||||||
onValueChange={(value) => handleChange(config.key, value)}
|
onValueChange={(value) => handleChange(config.key, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={
|
<SelectTrigger
|
||||||
config.required && !isValidModel && availableModels.length > 0
|
id={config.id}
|
||||||
? 'border-destructive'
|
className={
|
||||||
: ''
|
config.required && !isValidModel && availableModels.length > 0
|
||||||
}>
|
? 'border-destructive'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
<SelectValue placeholder={
|
<SelectValue placeholder={
|
||||||
config.required && !isValidModel && availableModels.length > 0
|
config.required && !isValidModel && availableModels.length > 0
|
||||||
? "⚠️ Required - Select a model"
|
? t.models.requiredModelPlaceholder
|
||||||
: "Select a model"
|
: t.models.selectModelPlaceholder
|
||||||
} />
|
} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -236,7 +250,7 @@ export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionP
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Which model should I choose? →
|
{t.models.whichModelToChoose}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { AlertTriangle, ExternalLink } from 'lucide-react'
|
import { AlertTriangle, ExternalLink } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface EmbeddingModelChangeDialogProps {
|
interface EmbeddingModelChangeDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -30,6 +31,7 @@ export function EmbeddingModelChangeDialog({
|
||||||
oldModelName,
|
oldModelName,
|
||||||
newModelName
|
newModelName
|
||||||
}: EmbeddingModelChangeDialogProps) {
|
}: EmbeddingModelChangeDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isConfirming, setIsConfirming] = useState(false)
|
const [isConfirming, setIsConfirming] = useState(false)
|
||||||
|
|
||||||
|
|
@ -55,54 +57,49 @@ export function EmbeddingModelChangeDialog({
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
<AlertDialogTitle>Embedding Model Change</AlertDialogTitle>
|
<AlertDialogTitle>{t.models.embeddingChangeTitle}</AlertDialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogDescription asChild>
|
<AlertDialogDescription asChild>
|
||||||
<div className="space-y-3 text-base text-muted-foreground">
|
<div className="space-y-3 text-base text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
You are about to change your embedding model{' '}
|
{t.models.embeddingChangeConfirm
|
||||||
{oldModelName && newModelName && (
|
.replace('{from}', oldModelName || '...')
|
||||||
<>
|
.replace('{to}', newModelName || '...')}
|
||||||
from <strong>{oldModelName}</strong> to <strong>{newModelName}</strong>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-muted p-4 rounded-md space-y-2">
|
<div className="bg-muted p-4 rounded-md space-y-2">
|
||||||
<p className="font-semibold text-foreground">⚠️ Important: Rebuild Required</p>
|
<p className="font-semibold text-foreground">⚠️ {t.models.rebuildRequired}</p>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Changing your embedding model requires rebuilding all existing embeddings to maintain consistency.
|
{t.models.rebuildReason}
|
||||||
Without rebuilding, your searches may return incorrect or incomplete results.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<p className="font-medium text-foreground">What happens next:</p>
|
<p className="font-medium text-foreground">{t.models.whatHappensNext}</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||||
<li>Your default embedding model will be updated</li>
|
<li>{t.models.step1}</li>
|
||||||
<li>Existing embeddings will remain unchanged until rebuild</li>
|
<li>{t.models.step2}</li>
|
||||||
<li>New content will use the new embedding model</li>
|
<li>{t.models.step3}</li>
|
||||||
<li>You should rebuild embeddings as soon as possible</li>
|
<li>{t.models.step4}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
Would you like to proceed to the Advanced page to start the rebuild now?
|
{t.models.proceedToRebuildPrompt}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||||
<AlertDialogCancel disabled={isConfirming}>
|
<AlertDialogCancel disabled={isConfirming}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleConfirmOnly}
|
onClick={handleConfirmOnly}
|
||||||
disabled={isConfirming}
|
disabled={isConfirming}
|
||||||
>
|
>
|
||||||
Change Model Only
|
{t.models.changeModelOnly}
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleConfirmAndRebuild}
|
onClick={handleConfirmAndRebuild}
|
||||||
|
|
@ -110,7 +107,7 @@ export function EmbeddingModelChangeDialog({
|
||||||
className="bg-primary"
|
className="bg-primary"
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Change & Go to Rebuild
|
{t.models.changeAndRebuild}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { useDeleteModel } from '@/lib/hooks/use-models'
|
import { useDeleteModel } from '@/lib/hooks/use-models'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ModelTypeSectionProps {
|
interface ModelTypeSectionProps {
|
||||||
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||||
|
|
@ -21,6 +22,7 @@ interface ModelTypeSectionProps {
|
||||||
const COLLAPSED_ITEM_COUNT = 5
|
const COLLAPSED_ITEM_COUNT = 5
|
||||||
|
|
||||||
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
|
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [deleteModel, setDeleteModel] = useState<Model | null>(null)
|
const [deleteModel, setDeleteModel] = useState<Model | null>(null)
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
|
@ -30,32 +32,32 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'language':
|
case 'language':
|
||||||
return {
|
return {
|
||||||
title: 'Language Models',
|
title: t.models.language,
|
||||||
description: 'Chat, transformations, and text generation',
|
description: t.models.languageDesc,
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
iconColor: 'text-blue-500',
|
iconColor: 'text-blue-500',
|
||||||
bgColor: 'bg-blue-50 dark:bg-blue-950/20'
|
bgColor: 'bg-blue-50 dark:bg-blue-950/20'
|
||||||
}
|
}
|
||||||
case 'embedding':
|
case 'embedding':
|
||||||
return {
|
return {
|
||||||
title: 'Embedding Models',
|
title: t.models.embedding,
|
||||||
description: 'Semantic search and vector embeddings',
|
description: t.models.embeddingDesc,
|
||||||
icon: Search,
|
icon: Search,
|
||||||
iconColor: 'text-green-500',
|
iconColor: 'text-green-500',
|
||||||
bgColor: 'bg-green-50 dark:bg-green-950/20'
|
bgColor: 'bg-green-50 dark:bg-green-950/20'
|
||||||
}
|
}
|
||||||
case 'text_to_speech':
|
case 'text_to_speech':
|
||||||
return {
|
return {
|
||||||
title: 'Text-to-Speech',
|
title: t.models.tts,
|
||||||
description: 'Generate audio from text',
|
description: t.models.ttsDesc,
|
||||||
icon: Volume2,
|
icon: Volume2,
|
||||||
iconColor: 'text-purple-500',
|
iconColor: 'text-purple-500',
|
||||||
bgColor: 'bg-purple-50 dark:bg-purple-950/20'
|
bgColor: 'bg-purple-50 dark:bg-purple-950/20'
|
||||||
}
|
}
|
||||||
case 'speech_to_text':
|
case 'speech_to_text':
|
||||||
return {
|
return {
|
||||||
title: 'Speech-to-Text',
|
title: t.models.stt,
|
||||||
description: 'Transcribe audio to text',
|
description: t.models.sttDesc,
|
||||||
icon: Mic,
|
icon: Mic,
|
||||||
iconColor: 'text-orange-500',
|
iconColor: 'text-orange-500',
|
||||||
bgColor: 'bg-orange-50 dark:bg-orange-950/20'
|
bgColor: 'bg-orange-50 dark:bg-orange-950/20'
|
||||||
|
|
@ -118,7 +120,7 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
|
||||||
className="cursor-pointer text-xs"
|
className="cursor-pointer text-xs"
|
||||||
onClick={() => setSelectedProvider(null)}
|
onClick={() => setSelectedProvider(null)}
|
||||||
>
|
>
|
||||||
All
|
{t.models.all}
|
||||||
</Badge>
|
</Badge>
|
||||||
{modelProviders.map(provider => (
|
{modelProviders.map(provider => (
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -143,8 +145,8 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
|
||||||
) : filteredModels.length === 0 ? (
|
) : filteredModels.length === 0 ? (
|
||||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||||
{selectedProvider
|
{selectedProvider
|
||||||
? `No ${selectedProvider} models configured`
|
? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider)
|
||||||
: 'No models configured'
|
: t.models.noModelsConfigured
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -182,12 +184,12 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<ChevronUp className="h-4 w-4 mr-2" />
|
<ChevronUp className="h-4 w-4 mr-2" />
|
||||||
Show less
|
{t.models.seeLess}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="h-4 w-4 mr-2" />
|
<ChevronDown className="h-4 w-4 mr-2" />
|
||||||
Show {filteredModels.length - COLLAPSED_ITEM_COUNT} more
|
{t.models.showMore.replace('{count}', (filteredModels.length - COLLAPSED_ITEM_COUNT).toString())}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -200,9 +202,9 @@ export function ModelTypeSection({ type, models, providers, isLoading }: ModelTy
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteModel}
|
open={!!deleteModel}
|
||||||
onOpenChange={(open) => !open && setDeleteModel(null)}
|
onOpenChange={(open) => !open && setDeleteModel(null)}
|
||||||
title="Delete Model"
|
title={t.models.deleteModel}
|
||||||
description={`Are you sure you want to delete "${deleteModel?.name}"? This action cannot be undone.`}
|
description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Check, X } from 'lucide-react'
|
import { Check, X } from 'lucide-react'
|
||||||
import { ProviderAvailability } from '@/lib/types/models'
|
import { ProviderAvailability } from '@/lib/types/models'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ProviderStatusProps {
|
interface ProviderStatusProps {
|
||||||
providers: ProviderAvailability
|
providers: ProviderAvailability
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderStatus({ providers }: ProviderStatusProps) {
|
export function ProviderStatus({ providers }: ProviderStatusProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Combine all providers, with available ones first
|
// Combine all providers, with available ones first
|
||||||
const allProviders = useMemo(
|
const allProviders = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|
@ -33,11 +35,13 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>AI Providers</CardTitle>
|
<CardTitle>{t.models.aiProviders}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure providers through environment variables to enable their models.
|
{t.models.providerConfigDesc}
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
{providers.available.length} of {allProviders.length} configured
|
{t.models.configuredCount
|
||||||
|
.replace('{count}', providers.available.length.toString())
|
||||||
|
.replace('{total}', allProviders.length.toString())}
|
||||||
</span>
|
</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -74,21 +78,21 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{provider.available ? (
|
{provider.available ? (
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
{supportedTypes.length > 0 ? (
|
{supportedTypes.length > 0 ? (
|
||||||
supportedTypes.map((type) => (
|
supportedTypes.map((type) => (
|
||||||
<Badge key={type} variant="secondary" className="text-xs font-medium">
|
<Badge key={type} variant="secondary" className="text-xs font-medium">
|
||||||
{type.replace('_', ' ')}
|
{(t.models as Record<string, string>)[type] || type.replace('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs">No models</Badge>
|
<Badge variant="outline" className="text-xs">{t.models.noModels}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
||||||
Not configured
|
{t.models.notConfigured}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -97,26 +101,28 @@ export function ProviderStatus({ providers }: ProviderStatusProps) {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allProviders.length > 6 ? (
|
{allProviders.length > 6 ? (
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
onClick={() => setExpanded((prev) => !prev)}
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{expanded ? 'See less' : `See all ${allProviders.length} providers`}
|
{expanded
|
||||||
|
? t.models.seeLess
|
||||||
|
: t.models.seeAll.replace('{count}', allProviders.length.toString())}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t">
|
<div className="mt-6 pt-4 border-t">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
|
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Learn how to configure providers →
|
{t.models.learnMore}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import { useModels, useModelDefaults, useProviders } from '@/lib/hooks/use-model
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function ModelsPage() {
|
export default function ModelsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
|
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
|
||||||
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
|
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
|
||||||
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
|
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
|
||||||
|
|
@ -35,7 +37,7 @@ export default function ModelsPage() {
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-muted-foreground">Failed to load models data</p>
|
<p className="text-muted-foreground">{t.models.failedToLoad}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
@ -48,9 +50,9 @@ export default function ModelsPage() {
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Model Management</h1>
|
<h1 className="text-2xl font-bold">{t.models.title}</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Configure AI models for different purposes across Open Notebook
|
{t.models.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useNotes } from '@/lib/hooks/use-notes'
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||||
import { useIsDesktop } from '@/lib/hooks/use-media-query'
|
import { useIsDesktop } from '@/lib/hooks/use-media-query'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { FileText, StickyNote, MessageSquare } from 'lucide-react'
|
import { FileText, StickyNote, MessageSquare } from 'lucide-react'
|
||||||
|
|
@ -25,10 +26,11 @@ export interface ContextSelections {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotebookPage() {
|
export default function NotebookPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
|
||||||
// Ensure the notebook ID is properly decoded from URL
|
// Ensure the notebook ID is properly decoded from URL
|
||||||
const notebookId = decodeURIComponent(params.id as string)
|
const notebookId = params?.id ? decodeURIComponent(params.id as string) : ''
|
||||||
|
|
||||||
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
|
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
|
||||||
const {
|
const {
|
||||||
|
|
@ -112,8 +114,8 @@ export default function NotebookPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">Notebook Not Found</h1>
|
<h1 className="text-2xl font-bold mb-4">{t.notebooks.notFound}</h1>
|
||||||
<p className="text-muted-foreground">The requested notebook could not be found.</p>
|
<p className="text-muted-foreground">{t.notebooks.notFoundDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|
@ -135,15 +137,15 @@ export default function NotebookPage() {
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="sources" className="gap-2">
|
<TabsTrigger value="sources" className="gap-2">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Sources
|
{t.navigation.sources}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="notes" className="gap-2">
|
<TabsTrigger value="notes" className="gap-2">
|
||||||
<StickyNote className="h-4 w-4" />
|
<StickyNote className="h-4 w-4" />
|
||||||
Notes
|
{t.common.notes}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="chat" className="gap-2">
|
<TabsTrigger value="chat" className="gap-2">
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-4 w-4" />
|
||||||
Chat
|
{t.common.chat}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -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 { Card, CardContent } from '@/components/ui/card'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle } from 'lucide-react'
|
||||||
import { ContextSelections } from '../[id]/page'
|
import { ContextSelections } from '../[id]/page'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ChatColumnProps {
|
interface ChatColumnProps {
|
||||||
notebookId: string
|
notebookId: string
|
||||||
|
|
@ -16,6 +17,8 @@ interface ChatColumnProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
|
export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Fetch sources and notes for this notebook
|
// Fetch sources and notes for this notebook
|
||||||
const { data: sources = [], isLoading: sourcesLoading } = useSources(notebookId)
|
const { data: sources = [], isLoading: sourcesLoading } = useSources(notebookId)
|
||||||
const { data: notes = [], isLoading: notesLoading } = useNotes(notebookId)
|
const { data: notes = [], isLoading: notesLoading } = useNotes(notebookId)
|
||||||
|
|
@ -79,8 +82,8 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
|
||||||
<CardContent className="flex-1 flex items-center justify-center">
|
<CardContent className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p className="text-sm">Unable to load chat</p>
|
<p className="text-sm">{t.chat.unableToLoadChat}</p>
|
||||||
<p className="text-xs mt-2">Please try refreshing the page</p>
|
<p className="text-xs mt-2">{t.common.refreshPage || 'Please try refreshing the page'}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -89,7 +92,7 @@ export function ChatColumn({ notebookId, contextSelections }: ChatColumnProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatPanel
|
<ChatPanel
|
||||||
title="Chat with Notebook"
|
title={t.chat.chatWithNotebook}
|
||||||
contextType="notebook"
|
contextType="notebook"
|
||||||
messages={chat.messages}
|
messages={chat.messages}
|
||||||
isStreaming={chat.isSending}
|
isStreaming={chat.isSending}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||||
import { MarkdownEditor } from '@/components/ui/markdown-editor'
|
import { MarkdownEditor } from '@/components/ui/markdown-editor'
|
||||||
import { InlineEdit } from '@/components/common/InlineEdit'
|
import { InlineEdit } from '@/components/common/InlineEdit'
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
const createNoteSchema = z.object({
|
const createNoteSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|
@ -28,6 +29,7 @@ interface NoteEditorDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) {
|
export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const createNote = useCreateNote()
|
const createNote = useCreateNote()
|
||||||
const updateNote = useUpdateNote()
|
const updateNote = useUpdateNote()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
@ -122,21 +124,23 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
|
||||||
isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen"
|
isEditorFullscreen && "!max-w-screen !max-h-screen border-none w-screen h-screen"
|
||||||
)}>
|
)}>
|
||||||
<DialogTitle className="sr-only">
|
<DialogTitle className="sr-only">
|
||||||
{isEditing ? 'Edit note' : 'Create note'}
|
{isEditing ? t.sources.editNote : t.sources.createNote}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
|
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
|
||||||
{isEditing && noteLoading ? (
|
{isEditing && noteLoading ? (
|
||||||
<div className="flex-1 flex items-center justify-center py-10">
|
<div className="flex-1 flex items-center justify-center py-10">
|
||||||
<span className="text-sm text-muted-foreground">Loading note…</span>
|
<span className="text-sm text-muted-foreground">{t.common.loading}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="border-b px-6 py-4">
|
<div className="border-b px-6 py-4">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
|
id="note-title"
|
||||||
|
name="title"
|
||||||
value={watchTitle ?? ''}
|
value={watchTitle ?? ''}
|
||||||
onSave={(value) => setValue('title', value || '')}
|
onSave={(value) => setValue('title', value || '')}
|
||||||
placeholder="Add a title..."
|
placeholder={t.sources.addTitle}
|
||||||
emptyText="Untitled Note"
|
emptyText={t.sources.untitledNote}
|
||||||
className="text-xl font-semibold"
|
className="text-xl font-semibold"
|
||||||
inputClassName="text-xl font-semibold"
|
inputClassName="text-xl font-semibold"
|
||||||
/>
|
/>
|
||||||
|
|
@ -152,10 +156,11 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
key={note?.id ?? 'new'}
|
key={note?.id ?? 'new'}
|
||||||
|
textareaId="note-content"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
height={420}
|
height={420}
|
||||||
placeholder="Write your note content here..."
|
placeholder={t.sources.writeNotePlaceholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-full min-h-[420px] [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full",
|
"w-full h-full min-h-[420px] [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full",
|
||||||
!isEditorFullscreen && "rounded-md border"
|
!isEditorFullscreen && "rounded-md border"
|
||||||
|
|
@ -172,17 +177,17 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
|
||||||
|
|
||||||
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSaving || (isEditing && noteLoading)}
|
disabled={isSaving || (isEditing && noteLoading)}
|
||||||
>
|
>
|
||||||
{isSaving
|
{isSaving
|
||||||
? isEditing ? 'Saving...' : 'Creating...'
|
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
|
||||||
: isEditing
|
: isEditing
|
||||||
? 'Save Note'
|
? t.sources.saveNote
|
||||||
: 'Create Note'}
|
: t.sources.createNoteBtn}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import {
|
||||||
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
interface NotebookCardProps {
|
interface NotebookCardProps {
|
||||||
notebook: NotebookResponse
|
notebook: NotebookResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotebookCard({ notebook }: NotebookCardProps) {
|
export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const updateNotebook = useUpdateNotebook()
|
const updateNotebook = useUpdateNotebook()
|
||||||
|
|
@ -59,7 +61,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{notebook.archived && (
|
{notebook.archived && (
|
||||||
<Badge variant="secondary" className="mt-1">
|
<Badge variant="secondary" className="mt-1">
|
||||||
Archived
|
{t.notebooks.archived}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,12 +82,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
{notebook.archived ? (
|
{notebook.archived ? (
|
||||||
<>
|
<>
|
||||||
<ArchiveRestore className="h-4 w-4 mr-2" />
|
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||||
Unarchive
|
{t.notebooks.unarchive}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
Archive
|
{t.notebooks.archive}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -97,7 +99,7 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
className="text-red-600"
|
className="text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
{t.common.delete}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -106,11 +108,14 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CardDescription className="line-clamp-2 text-sm">
|
<CardDescription className="line-clamp-2 text-sm">
|
||||||
{notebook.description || 'No description'}
|
{notebook.description || t.chat.noDescription}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })}
|
{t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: getDateLocale(language)
|
||||||
|
}))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Item counts footer */}
|
{/* Item counts footer */}
|
||||||
|
|
@ -130,9 +135,9 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={setShowDeleteDialog}
|
||||||
title="Delete Notebook"
|
title={t.notebooks.deleteNotebook}
|
||||||
description={`Are you sure you want to delete "${notebook.name}"? This action cannot be undone and will delete all sources, notes, and chat sessions.`}
|
description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,17 @@ import { Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
||||||
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
import { useUpdateNotebook, useDeleteNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
import { InlineEdit } from '@/components/common/InlineEdit'
|
import { InlineEdit } from '@/components/common/InlineEdit'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface NotebookHeaderProps {
|
interface NotebookHeaderProps {
|
||||||
notebook: NotebookResponse
|
notebook: NotebookResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
|
const dfLocale = getDateLocale(language)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
|
||||||
const updateNotebook = useUpdateNotebook()
|
const updateNotebook = useUpdateNotebook()
|
||||||
|
|
@ -57,14 +61,16 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
|
id="notebook-name"
|
||||||
|
name="notebook-name"
|
||||||
value={notebook.name}
|
value={notebook.name}
|
||||||
onSave={handleUpdateName}
|
onSave={handleUpdateName}
|
||||||
className="text-2xl font-bold"
|
className="text-2xl font-bold"
|
||||||
inputClassName="text-2xl font-bold"
|
inputClassName="text-2xl font-bold"
|
||||||
placeholder="Notebook name"
|
placeholder={t.notebooks.namePlaceholder}
|
||||||
/>
|
/>
|
||||||
{notebook.archived && (
|
{notebook.archived && (
|
||||||
<Badge variant="secondary">Archived</Badge>
|
<Badge variant="secondary">{t.notebooks.archived}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
@ -76,12 +82,12 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
{notebook.archived ? (
|
{notebook.archived ? (
|
||||||
<>
|
<>
|
||||||
<ArchiveRestore className="h-4 w-4 mr-2" />
|
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||||
Unarchive
|
{t.notebooks.unarchive}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
Archive
|
{t.notebooks.archive}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -92,24 +98,26 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
{t.common.delete}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
|
id="notebook-description"
|
||||||
|
name="notebook-description"
|
||||||
value={notebook.description || ''}
|
value={notebook.description || ''}
|
||||||
onSave={handleUpdateDescription}
|
onSave={handleUpdateDescription}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
inputClassName="text-muted-foreground"
|
inputClassName="text-muted-foreground"
|
||||||
placeholder="Add a description..."
|
placeholder={t.notebooks.addDescription}
|
||||||
multiline
|
multiline
|
||||||
emptyText="Add a description..."
|
emptyText={t.notebooks.addDescription}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Created {formatDistanceToNow(new Date(notebook.created), { addSuffix: true })} •
|
{t.common.created.replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))} •
|
||||||
Updated {formatDistanceToNow(new Date(notebook.updated), { addSuffix: true })}
|
{t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -117,9 +125,9 @@ export function NotebookHeader({ notebook }: NotebookHeaderProps) {
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={setShowDeleteDialog}
|
||||||
title="Delete Notebook"
|
title={t.notebooks.deleteNotebook}
|
||||||
description={`Are you sure you want to delete "${notebook.name}"? This action cannot be undone and will delete all sources, notes, and chat sessions.`}
|
description={t.notebooks.deleteNotebookDesc.replace('{name}', notebook.name)}
|
||||||
confirmText="Delete Forever"
|
confirmText={t.common.deleteForever}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { Book, ChevronDown, ChevronRight, Plus } from 'lucide-react'
|
import { Book, ChevronDown, ChevronRight, Plus } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface NotebookListProps {
|
interface NotebookListProps {
|
||||||
notebooks?: NotebookResponse[]
|
notebooks?: NotebookResponse[]
|
||||||
|
|
@ -29,6 +30,7 @@ export function NotebookList({
|
||||||
onAction,
|
onAction,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
}: NotebookListProps) {
|
}: NotebookListProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
const [isExpanded, setIsExpanded] = useState(!collapsible)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -43,8 +45,8 @@ export function NotebookList({
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Book}
|
icon={Book}
|
||||||
title={emptyTitle ?? `No ${title.toLowerCase()}`}
|
title={emptyTitle ?? t.common.noResults}
|
||||||
description={emptyDescription ?? 'Start by creating your first notebook to organize your research.'}
|
description={emptyDescription ?? t.chat.startByCreating}
|
||||||
action={onAction && actionLabel ? (
|
action={onAction && actionLabel ? (
|
||||||
<Button onClick={onAction} variant="outline" className="mt-4">
|
<Button onClick={onAction} variant="outline" className="mt-4">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { NoteEditorDialog } from './NoteEditorDialog'
|
import { NoteEditorDialog } from './NoteEditorDialog'
|
||||||
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { ContextToggle } from '@/components/common/ContextToggle'
|
import { ContextToggle } from '@/components/common/ContextToggle'
|
||||||
import { ContextMode } from '../[id]/page'
|
import { ContextMode } from '../[id]/page'
|
||||||
|
|
@ -22,6 +23,7 @@ import { useDeleteNote } from '@/lib/hooks/use-notes'
|
||||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||||
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
||||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface NotesColumnProps {
|
interface NotesColumnProps {
|
||||||
notes?: NoteResponse[]
|
notes?: NoteResponse[]
|
||||||
|
|
@ -38,6 +40,7 @@ export function NotesColumn({
|
||||||
contextSelections,
|
contextSelections,
|
||||||
onContextModeChange
|
onContextModeChange
|
||||||
}: NotesColumnProps) {
|
}: NotesColumnProps) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||||
const [editingNote, setEditingNote] = useState<NoteResponse | null>(null)
|
const [editingNote, setEditingNote] = useState<NoteResponse | null>(null)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
@ -48,8 +51,8 @@ export function NotesColumn({
|
||||||
// Collapsible column state
|
// Collapsible column state
|
||||||
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
|
const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()
|
||||||
const collapseButton = useMemo(
|
const collapseButton = useMemo(
|
||||||
() => createCollapseButton(toggleNotes, 'Notes'),
|
() => createCollapseButton(toggleNotes, t.common.notes),
|
||||||
[toggleNotes]
|
[toggleNotes, t.common.notes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDeleteClick = (noteId: string) => {
|
const handleDeleteClick = (noteId: string) => {
|
||||||
|
|
@ -75,12 +78,12 @@ export function NotesColumn({
|
||||||
isCollapsed={notesCollapsed}
|
isCollapsed={notesCollapsed}
|
||||||
onToggle={toggleNotes}
|
onToggle={toggleNotes}
|
||||||
collapsedIcon={StickyNote}
|
collapsedIcon={StickyNote}
|
||||||
collapsedLabel="Notes"
|
collapsedLabel={t.common.notes}
|
||||||
>
|
>
|
||||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||||
<CardHeader className="pb-3 flex-shrink-0">
|
<CardHeader className="pb-3 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-lg">Notes</CardTitle>
|
<CardTitle className="text-lg">{t.common.notes}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -90,7 +93,7 @@ export function NotesColumn({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Write Note
|
{t.common.writeNote}
|
||||||
</Button>
|
</Button>
|
||||||
{collapseButton}
|
{collapseButton}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,8 +108,8 @@ export function NotesColumn({
|
||||||
) : !notes || notes.length === 0 ? (
|
) : !notes || notes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={StickyNote}
|
icon={StickyNote}
|
||||||
title="No notes yet"
|
title={t.notebooks.noNotesYet}
|
||||||
description="Create your first note to capture insights and observations."
|
description={t.sources.createFirstNote}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -124,13 +127,16 @@ export function NotesColumn({
|
||||||
<User className="h-4 w-4 text-muted-foreground" />
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{note.note_type === 'ai' ? 'AI Generated' : 'Human'}
|
{note.note_type === 'ai' ? t.common.aiGenerated : t.common.human}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDistanceToNow(new Date(note.updated), { addSuffix: true })}
|
{formatDistanceToNow(new Date(note.updated), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: getDateLocale(language)
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Context toggle - only show if handler provided */}
|
{/* Context toggle - only show if handler provided */}
|
||||||
|
|
@ -165,7 +171,7 @@ export function NotesColumn({
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete Note
|
{t.notebooks.deleteNote}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -206,9 +212,9 @@ export function NotesColumn({
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
title="Delete Note"
|
title={t.notebooks.deleteNote}
|
||||||
description="Are you sure you want to delete this note? This action cannot be undone."
|
description={t.notebooks.deleteNoteConfirm}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
isLoading={deleteNote.isPending}
|
isLoading={deleteNote.isPending}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { useModalManager } from '@/lib/hooks/use-modal-manager'
|
||||||
import { ContextMode } from '../[id]/page'
|
import { ContextMode } from '../[id]/page'
|
||||||
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
import { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'
|
||||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface SourcesColumnProps {
|
interface SourcesColumnProps {
|
||||||
sources?: SourceListResponse[]
|
sources?: SourceListResponse[]
|
||||||
|
|
@ -48,6 +49,7 @@ export function SourcesColumn({
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: SourcesColumnProps) {
|
}: SourcesColumnProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false)
|
const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false)
|
||||||
|
|
@ -64,8 +66,8 @@ export function SourcesColumn({
|
||||||
// Collapsible column state
|
// Collapsible column state
|
||||||
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
|
const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()
|
||||||
const collapseButton = useMemo(
|
const collapseButton = useMemo(
|
||||||
() => createCollapseButton(toggleSources, 'Sources'),
|
() => createCollapseButton(toggleSources, t.navigation.sources),
|
||||||
[toggleSources]
|
[toggleSources, t.navigation.sources]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scroll container ref for infinite scroll
|
// Scroll container ref for infinite scroll
|
||||||
|
|
@ -149,29 +151,29 @@ export function SourcesColumn({
|
||||||
isCollapsed={sourcesCollapsed}
|
isCollapsed={sourcesCollapsed}
|
||||||
onToggle={toggleSources}
|
onToggle={toggleSources}
|
||||||
collapsedIcon={FileText}
|
collapsedIcon={FileText}
|
||||||
collapsedLabel="Sources"
|
collapsedLabel={t.navigation.sources}
|
||||||
>
|
>
|
||||||
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
<Card className="h-full flex flex-col flex-1 overflow-hidden">
|
||||||
<CardHeader className="pb-3 flex-shrink-0">
|
<CardHeader className="pb-3 flex-shrink-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CardTitle className="text-lg">Sources</CardTitle>
|
<CardTitle className="text-lg">{t.navigation.sources}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Source
|
{t.sources.addSource}
|
||||||
<ChevronDown className="h-4 w-4 ml-2" />
|
<ChevronDown className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
|
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add New Source
|
{t.sources.addSource}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
|
<DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>
|
||||||
<Link2 className="h-4 w-4 mr-2" />
|
<Link2 className="h-4 w-4 mr-2" />
|
||||||
Add Existing Source
|
{t.sources.addExistingTitle}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -188,8 +190,8 @@ export function SourcesColumn({
|
||||||
) : !sources || sources.length === 0 ? (
|
) : !sources || sources.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="No sources yet"
|
title={t.sources.noSourcesYet}
|
||||||
description="Add your first source to start building your knowledge base."
|
description={t.sources.createFirstSource}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -238,9 +240,9 @@ export function SourcesColumn({
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
title="Delete Source"
|
title={t.sources.delete}
|
||||||
description="Are you sure you want to delete this source? This action cannot be undone."
|
description={t.sources.deleteConfirm}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
isLoading={deleteSource.isPending}
|
isLoading={deleteSource.isPending}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
|
|
@ -249,9 +251,9 @@ export function SourcesColumn({
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={removeDialogOpen}
|
open={removeDialogOpen}
|
||||||
onOpenChange={setRemoveDialogOpen}
|
onOpenChange={setRemoveDialogOpen}
|
||||||
title="Remove Source from Notebook"
|
title={t.sources.removeFromNotebook}
|
||||||
description="Are you sure you want to remove this source from the notebook? The source itself will not be deleted."
|
description={t.sources.removeConfirm}
|
||||||
confirmText="Remove"
|
confirmText={t.common.remove}
|
||||||
onConfirm={handleRemoveConfirm}
|
onConfirm={handleRemoveConfirm}
|
||||||
isLoading={removeFromNotebook.isPending}
|
isLoading={removeFromNotebook.isPending}
|
||||||
confirmVariant="default"
|
confirmVariant="default"
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ import { Plus, RefreshCw } from 'lucide-react'
|
||||||
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
||||||
import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'
|
import { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function NotebooksPage() {
|
export default function NotebooksPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const { data: notebooks, isLoading, refetch } = useNotebooks(false)
|
const { data: notebooks, isLoading, refetch } = useNotebooks(false)
|
||||||
|
|
@ -51,21 +53,25 @@ export default function NotebooksPage() {
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">Notebooks</h1>
|
<h1 className="text-2xl font-bold">{t.notebooks.title}</h1>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<Input
|
<Input
|
||||||
|
id="notebook-search"
|
||||||
|
name="notebook-search"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(event) => setSearchTerm(event.target.value)}
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
placeholder="Search notebooks..."
|
placeholder={t.notebooks.searchPlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label={t.common.accessibility?.searchNotebooks || "Search notebooks"}
|
||||||
className="w-full sm:w-64"
|
className="w-full sm:w-64"
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New Notebook
|
{t.notebooks.newNotebook}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,21 +80,21 @@ export default function NotebooksPage() {
|
||||||
<NotebookList
|
<NotebookList
|
||||||
notebooks={filteredActive}
|
notebooks={filteredActive}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
title="Active Notebooks"
|
title={t.notebooks.activeNotebooks}
|
||||||
emptyTitle={isSearching ? 'No notebooks match your search' : undefined}
|
emptyTitle={isSearching ? t.common.noMatches : undefined}
|
||||||
emptyDescription={isSearching ? 'Try using a different notebook name.' : undefined}
|
emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}
|
||||||
onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined}
|
onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined}
|
||||||
actionLabel={!isSearching ? "Create Notebook" : undefined}
|
actionLabel={!isSearching ? t.notebooks.newNotebook : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasArchived && (
|
{hasArchived && (
|
||||||
<NotebookList
|
<NotebookList
|
||||||
notebooks={filteredArchived}
|
notebooks={filteredArchived}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
title="Archived Notebooks"
|
title={t.notebooks.archivedNotebooks}
|
||||||
collapsible
|
collapsible
|
||||||
emptyTitle={isSearching ? 'No archived notebooks match your search' : undefined}
|
emptyTitle={isSearching ? t.common.noMatches : undefined}
|
||||||
emptyDescription={isSearching ? 'Modify your search to find archived notebooks.' : undefined}
|
emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { EpisodesTab } from '@/components/podcasts/EpisodesTab'
|
import { EpisodesTab } from '@/components/podcasts/EpisodesTab'
|
||||||
import { TemplatesTab } from '@/components/podcasts/TemplatesTab'
|
import { TemplatesTab } from '@/components/podcasts/TemplatesTab'
|
||||||
import { Mic, LayoutTemplate } from 'lucide-react'
|
import { Mic, LayoutTemplate } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function PodcastsPage() {
|
export default function PodcastsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')
|
const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -16,9 +18,9 @@ export default function PodcastsPage() {
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-6 space-y-6">
|
<div className="px-6 py-6 space-y-6">
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Podcasts</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t.podcasts.listTitle}</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Keep track of generated episodes and manage reusable templates.
|
{t.podcasts.listDesc}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -28,15 +30,15 @@ export default function PodcastsPage() {
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a view</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.podcasts.chooseAView}</p>
|
||||||
<TabsList aria-label="Podcast views" className="w-full max-w-md">
|
<TabsList aria-label={t.common.accessibility.podcastViews} className="w-full max-w-md">
|
||||||
<TabsTrigger value="episodes">
|
<TabsTrigger value="episodes">
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
Episodes
|
{t.podcasts.episodesTab}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="templates">
|
<TabsTrigger value="templates">
|
||||||
<LayoutTemplate className="h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
Templates
|
{t.podcasts.templatesTab}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { AppShell } from '@/components/layout/AppShell'
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -24,10 +25,11 @@ import { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'
|
||||||
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
|
import { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
// URL params
|
// URL params
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const urlQuery = searchParams.get('q') || ''
|
const urlQuery = searchParams?.get('q') || ''
|
||||||
const rawMode = searchParams.get('mode')
|
const rawMode = searchParams?.get('mode')
|
||||||
const urlMode = rawMode === 'search' ? 'search' : 'ask'
|
const urlMode = rawMode === 'search' ? 'search' : 'ask'
|
||||||
|
|
||||||
// Tab state (controlled)
|
// Tab state (controlled)
|
||||||
|
|
@ -70,7 +72,7 @@ export default function SearchPage() {
|
||||||
}, [availableModels])
|
}, [availableModels])
|
||||||
|
|
||||||
const resolveModelName = (id?: string | null) => {
|
const resolveModelName = (id?: string | null) => {
|
||||||
if (!id) return 'Not set'
|
if (!id) return t.searchPage.notSet
|
||||||
return modelNameById.get(id) ?? id
|
return modelNameById.get(id) ?? id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,8 +132,8 @@ export default function SearchPage() {
|
||||||
|
|
||||||
// Handle URL param changes while on page (e.g., from command palette again)
|
// Handle URL param changes while on page (e.g., from command palette again)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentQ = searchParams.get('q') || ''
|
const currentQ = searchParams?.get('q') || ''
|
||||||
const rawCurrentMode = searchParams.get('mode')
|
const rawCurrentMode = searchParams?.get('mode')
|
||||||
const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask'
|
const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask'
|
||||||
|
|
||||||
// Check if URL params have changed
|
// Check if URL params have changed
|
||||||
|
|
@ -157,19 +159,19 @@ export default function SearchPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">Ask and Search</h1>
|
<h1 className="text-xl md:text-2xl font-bold mb-4 md:mb-6">{t.searchPage.askAndSearch}</h1>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6">
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className="w-full space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a mode</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.searchPage.chooseAMode}</p>
|
||||||
<TabsList aria-label="Ask or search your knowledge base" className="w-full max-w-xl">
|
<TabsList aria-label={t.common.accessibility.searchKB} className="w-full max-w-xl">
|
||||||
<TabsTrigger value="ask">
|
<TabsTrigger value="ask">
|
||||||
<MessageCircleQuestion className="h-4 w-4" />
|
<MessageCircleQuestion className="h-4 w-4" />
|
||||||
Ask (beta)
|
{t.searchPage.askBeta}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="search">
|
<TabsTrigger value="search">
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
Search
|
{t.searchPage.search}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,18 +179,19 @@ export default function SearchPage() {
|
||||||
<TabsContent value="ask" className="mt-6">
|
<TabsContent value="ask" className="mt-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Ask Your Knowledge Base (beta)</CardTitle>
|
<CardTitle className="text-lg">{t.searchPage.askYourKb}</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The LLM will answer your query based on the documents in your knowledge base.
|
{t.searchPage.askYourKbDesc}
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Question Input */}
|
{/* Question Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ask-question">Question</Label>
|
<Label htmlFor="ask-question">{t.searchPage.question}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="ask-question"
|
id="ask-question"
|
||||||
placeholder="Enter your question..."
|
name="ask-question"
|
||||||
|
placeholder={t.searchPage.enterQuestionPlaceholder}
|
||||||
value={askQuestion}
|
value={askQuestion}
|
||||||
onChange={(e) => setAskQuestion(e.target.value)}
|
onChange={(e) => setAskQuestion(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
@ -200,23 +203,23 @@ export default function SearchPage() {
|
||||||
}}
|
}}
|
||||||
disabled={ask.isStreaming}
|
disabled={ask.isStreaming}
|
||||||
rows={3}
|
rows={3}
|
||||||
aria-label="Enter your question to ask the knowledge base"
|
aria-label={t.common.accessibility.enterQuestion}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Press Cmd/Ctrl+Enter to submit</p>
|
<p className="text-xs text-muted-foreground">{t.searchPage.pressToSubmit}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Models Display */}
|
{/* Models Display */}
|
||||||
{!hasEmbeddingModel ? (
|
{!hasEmbeddingModel ? (
|
||||||
<div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md">
|
<div className="flex items-center gap-2 p-3 text-sm text-amber-600 dark:text-amber-500 bg-amber-50 dark:bg-amber-950/20 rounded-md">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<span>You can't use this feature because you have no embedding model selected. Please set one up in the Models page.</span>
|
<span>{t.searchPage.noEmbeddingModel}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
{customModels ? 'Using Custom Models' : 'Using Default Models'}
|
{customModels ? t.searchPage.usingCustomModels : t.searchPage.usingDefaultModels}
|
||||||
</Label>
|
</Label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -226,18 +229,18 @@ export default function SearchPage() {
|
||||||
className="h-auto py-1 px-2"
|
className="h-auto py-1 px-2"
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3 mr-1" />
|
<Settings className="h-3 w-3 mr-1" />
|
||||||
Advanced
|
{t.searchPage.advanced}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs flex-wrap">
|
<div className="flex gap-2 text-xs flex-wrap">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
Strategy: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
|
{t.searchPage.strategy}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
Answer: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
|
{t.searchPage.answer}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
Final: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
|
{t.searchPage.final}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -251,10 +254,10 @@ export default function SearchPage() {
|
||||||
{ask.isStreaming ? (
|
{ask.isStreaming ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner size="sm" className="mr-2" />
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
Processing...
|
{t.searchPage.processing}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Ask'
|
t.searchPage.ask
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
@ -265,7 +268,7 @@ export default function SearchPage() {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
Save to Notebooks
|
{t.searchPage.saveToNotebooks}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -308,29 +311,34 @@ export default function SearchPage() {
|
||||||
<TabsContent value="search" className="mt-6">
|
<TabsContent value="search" className="mt-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Search</CardTitle>
|
<CardTitle className="text-lg">{t.searchPage.search}</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Search your knowledge base for specific keywords or concepts
|
{t.searchPage.searchDesc}
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="search-query" className="sr-only">
|
||||||
|
{t.searchPage.search}
|
||||||
|
</Label>
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="search-query"
|
id="search-query"
|
||||||
placeholder="Enter search query..."
|
name="search-query"
|
||||||
|
placeholder={t.searchPage.enterSearchPlaceholder}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
disabled={searchMutation.isPending}
|
disabled={searchMutation.isPending}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
aria-label="Enter search query"
|
aria-label={t.common.accessibility.enterSearch}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
disabled={searchMutation.isPending || !searchQuery.trim()}
|
disabled={searchMutation.isPending || !searchQuery.trim()}
|
||||||
aria-label="Search knowledge base"
|
aria-label={t.common.accessibility.searchKBBtn}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{searchMutation.isPending ? (
|
{searchMutation.isPending ? (
|
||||||
|
|
@ -338,24 +346,25 @@ export default function SearchPage() {
|
||||||
) : (
|
) : (
|
||||||
<Search className="h-4 w-4 mr-2" />
|
<Search className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Search
|
{t.searchPage.search}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Press Enter to search</p>
|
<p className="text-xs text-muted-foreground">{t.searchPage.pressToSearch}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Options */}
|
{/* Search Options */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search Type */}
|
{/* Search Type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2" role="group" aria-labelledby="search-type-label">
|
||||||
<Label>Search Type</Label>
|
<span id="search-type-label" className="text-sm font-medium leading-none">{t.searchPage.searchType}</span>
|
||||||
{!hasEmbeddingModel && (
|
{!hasEmbeddingModel && (
|
||||||
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
|
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<span>Vector search requires an embedding model. Only text search is available.</span>
|
<span>{t.searchPage.vectorSearchWarning}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
name="search-type"
|
||||||
value={searchType}
|
value={searchType}
|
||||||
onValueChange={(value: 'text' | 'vector') => setSearchType(value)}
|
onValueChange={(value: 'text' | 'vector') => setSearchType(value)}
|
||||||
disabled={modelsLoading || searchMutation.isPending}
|
disabled={modelsLoading || searchMutation.isPending}
|
||||||
|
|
@ -363,7 +372,7 @@ export default function SearchPage() {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="text" id="text" />
|
<RadioGroupItem value="text" id="text" />
|
||||||
<Label htmlFor="text" className="font-normal cursor-pointer">
|
<Label htmlFor="text" className="font-normal cursor-pointer">
|
||||||
Text Search
|
{t.searchPage.textSearch}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|
@ -376,36 +385,38 @@ export default function SearchPage() {
|
||||||
htmlFor="vector"
|
htmlFor="vector"
|
||||||
className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}
|
className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
>
|
>
|
||||||
Vector Search
|
{t.searchPage.vectorSearch}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Locations */}
|
{/* Search Locations */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2" role="group" aria-labelledby="search-in-label">
|
||||||
<Label>Search In</Label>
|
<span id="search-in-label" className="text-sm font-medium leading-none">{t.searchPage.searchIn}</span>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="sources"
|
id="sources"
|
||||||
|
name="sources"
|
||||||
checked={searchSources}
|
checked={searchSources}
|
||||||
onCheckedChange={(checked) => setSearchSources(checked as boolean)}
|
onCheckedChange={(checked) => setSearchSources(checked as boolean)}
|
||||||
disabled={searchMutation.isPending}
|
disabled={searchMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="sources" className="font-normal cursor-pointer">
|
<Label htmlFor="sources" className="font-normal cursor-pointer">
|
||||||
Search Sources
|
{t.searchPage.searchSources}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="notes"
|
id="notes"
|
||||||
|
name="notes"
|
||||||
checked={searchNotes}
|
checked={searchNotes}
|
||||||
onCheckedChange={(checked) => setSearchNotes(checked as boolean)}
|
onCheckedChange={(checked) => setSearchNotes(checked as boolean)}
|
||||||
disabled={searchMutation.isPending}
|
disabled={searchMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="notes" className="font-normal cursor-pointer">
|
<Label htmlFor="notes" className="font-normal cursor-pointer">
|
||||||
Search Notes
|
{t.searchPage.searchNotes}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -417,15 +428,15 @@ export default function SearchPage() {
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
{searchMutation.data.total_count} result{searchMutation.data.total_count !== 1 ? 's' : ''} found
|
{t.searchPage.resultsFound.replace('{count}', searchMutation.data.total_count.toString())}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="outline">{searchMutation.data.search_type} search</Badge>
|
<Badge variant="outline">{searchMutation.data.search_type === 'text' ? t.searchPage.textSearch : t.searchPage.vectorSearch}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchMutation.data.results.length === 0 ? (
|
{searchMutation.data.results.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6 text-center text-muted-foreground">
|
<CardContent className="pt-6 text-center text-muted-foreground">
|
||||||
No results found for “{searchQuery}”
|
{t.searchPage.noResultsFor.replace('{query}', searchQuery)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -456,7 +467,7 @@ export default function SearchPage() {
|
||||||
<Collapsible className="mt-3">
|
<Collapsible className="mt-3">
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
Matches ({result.matches.length})
|
{t.searchPage.matches.replace('{count}', result.matches.length.toString())}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 space-y-1">
|
<CollapsibleContent className="mt-2 space-y-1">
|
||||||
{result.matches.map((match, i) => (
|
{result.matches.map((match, i) => (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { useSettings, useUpdateSettings } from '@/lib/hooks/use-settings'
|
import { useSettings, useUpdateSettings } from '@/lib/hooks/use-settings'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ChevronDownIcon } from 'lucide-react'
|
import { ChevronDownIcon } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
default_content_processing_engine_doc: z.enum(['auto', 'docling', 'simple']).optional(),
|
default_content_processing_engine_doc: z.enum(['auto', 'docling', 'simple']).optional(),
|
||||||
|
|
@ -24,9 +25,15 @@ const settingsSchema = z.object({
|
||||||
type SettingsFormData = z.infer<typeof settingsSchema>
|
type SettingsFormData = z.infer<typeof settingsSchema>
|
||||||
|
|
||||||
export function SettingsForm() {
|
export function SettingsForm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { data: settings, isLoading, error } = useSettings()
|
const { data: settings, isLoading, error } = useSettings()
|
||||||
const updateSettings = useUpdateSettings()
|
const updateSettings = useUpdateSettings()
|
||||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({})
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
doc: false,
|
||||||
|
url: false,
|
||||||
|
embedding: false,
|
||||||
|
files: false
|
||||||
|
})
|
||||||
const [hasResetForm, setHasResetForm] = useState(false)
|
const [hasResetForm, setHasResetForm] = useState(false)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -78,9 +85,9 @@ export function SettingsForm() {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertTitle>Failed to load settings</AlertTitle>
|
<AlertTitle>{t.settings.loadFailed}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
|
{error instanceof Error ? error.message : t.common.error}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
|
|
@ -90,31 +97,32 @@ export function SettingsForm() {
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Content Processing</CardTitle>
|
<CardTitle>{t.settings.contentProcessing}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure how documents and URLs are processed
|
{t.settings.contentProcessingDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="doc_engine">Document Processing Engine</Label>
|
<Label htmlFor="doc_engine">{t.settings.docEngine}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="default_content_processing_engine_doc"
|
name="default_content_processing_engine_doc"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
key={field.value}
|
key={field.value}
|
||||||
value={field.value || ''}
|
name={field.name}
|
||||||
onValueChange={field.onChange}
|
value={field.value || ''}
|
||||||
disabled={field.disabled || isLoading}
|
onValueChange={field.onChange}
|
||||||
>
|
disabled={field.disabled || isLoading}
|
||||||
<SelectTrigger className="w-full">
|
>
|
||||||
<SelectValue placeholder="Select document processing engine" />
|
<SelectTrigger id="doc_engine" className="w-full">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder={t.settings.docEnginePlaceholder} />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto (Recommended)</SelectItem>
|
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
|
||||||
<SelectItem value="docling">Docling</SelectItem>
|
<SelectItem value="docling">{t.settings.docling}</SelectItem>
|
||||||
<SelectItem value="simple">Simple</SelectItem>
|
<SelectItem value="simple">{t.settings.simple}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,143 +130,135 @@ export function SettingsForm() {
|
||||||
<Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>
|
<Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} />
|
||||||
Help me choose
|
{t.settings.helpMeChoose}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
||||||
<p>• <strong>Docling</strong> is a little slower but more accurate, specially if the documents contain tables and images.</p>
|
<p>{t.settings.docHelp}</p>
|
||||||
<p>• <strong>Simple</strong> will extract any content from the document without formatting it. It's ok for simple documents, but will lose quality in complex ones.</p>
|
|
||||||
<p>• <strong>Auto (recommended)</strong> will try to process through docling and default to simple.</p>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="url_engine">URL Processing Engine</Label>
|
<Label htmlFor="url_engine">{t.settings.urlEngine}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="default_content_processing_engine_url"
|
name="default_content_processing_engine_url"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
key={field.value}
|
key={field.value}
|
||||||
|
name={field.name}
|
||||||
value={field.value || ''}
|
value={field.value || ''}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
disabled={field.disabled || isLoading}
|
disabled={field.disabled || isLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="url_engine" className="w-full">
|
||||||
<SelectValue placeholder="Select URL processing engine" />
|
<SelectValue placeholder={t.settings.urlEnginePlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">Auto (Recommended)</SelectItem>
|
<SelectItem value="auto">{t.settings.autoRecommended}</SelectItem>
|
||||||
<SelectItem value="firecrawl">Firecrawl</SelectItem>
|
<SelectItem value="firecrawl">{t.settings.firecrawl}</SelectItem>
|
||||||
<SelectItem value="jina">Jina</SelectItem>
|
<SelectItem value="jina">{t.settings.jina}</SelectItem>
|
||||||
<SelectItem value="simple">Simple</SelectItem>
|
<SelectItem value="simple">{t.settings.simple}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}>
|
<Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}>
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />
|
||||||
Help me choose
|
{t.settings.helpMeChoose}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
||||||
<p>• <strong>Firecrawl</strong> is a paid service (with a free tier), and very powerful.</p>
|
<p>{t.settings.urlHelp}</p>
|
||||||
<p>• <strong>Jina</strong> is a good option as well and also has a free tier.</p>
|
|
||||||
<p>• <strong>Simple</strong> will use basic HTTP extraction and will miss content on javascript-based websites.</p>
|
|
||||||
<p>• <strong>Auto (recommended)</strong> will try to use firecrawl (if API Key is present). Then, it will use Jina until reaches the limit (or will keep using Jina if you setup the API Key). It will fallback to simple, when none of the previous options is possible.</p>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Embedding and Search</CardTitle>
|
<CardTitle>{t.settings.embeddingAndSearch}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure search and embedding options
|
{t.settings.embeddingAndSearchDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="embedding">Default Embedding Option</Label>
|
<Label htmlFor="embedding">{t.settings.defaultEmbeddingOption}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="default_embedding_option"
|
name="default_embedding_option"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
key={field.value}
|
key={field.value}
|
||||||
|
name={field.name}
|
||||||
value={field.value || ''}
|
value={field.value || ''}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
disabled={field.disabled || isLoading}
|
disabled={field.disabled || isLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="embedding" className="w-full">
|
||||||
<SelectValue placeholder="Select embedding option" />
|
<SelectValue placeholder={t.settings.embeddingOptionPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ask">Ask</SelectItem>
|
<SelectItem value="ask">{t.settings.ask}</SelectItem>
|
||||||
<SelectItem value="always">Always</SelectItem>
|
<SelectItem value="always">{t.settings.always}</SelectItem>
|
||||||
<SelectItem value="never">Never</SelectItem>
|
<SelectItem value="never">{t.settings.never}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}>
|
<Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}>
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />
|
||||||
Help me choose
|
{t.settings.helpMeChoose}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
||||||
<p>Embedding the content will make it easier to find by you and by your AI agents. If you are running a local embedding model (Ollama, for example), you shouldn't worry about cost and just embed everything. For online providers, you might want to be careful only if you process a lot of content (like 100s of documents at a day).</p>
|
<p>{t.settings.embeddingHelp}</p>
|
||||||
<p>• Choose <strong>always</strong> if you are running a local embedding model or if your content volume is not that big</p>
|
|
||||||
<p>• Choose <strong>ask</strong> if you want to decide every time</p>
|
|
||||||
<p>• Choose <strong>never</strong> if you don'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>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>File Management</CardTitle>
|
<CardTitle>{t.settings.fileManagement}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure file handling and storage options
|
{t.settings.fileManagementDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="auto_delete">Auto Delete Files</Label>
|
<Label htmlFor="auto_delete">{t.settings.autoDeleteFiles}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="auto_delete_files"
|
name="auto_delete_files"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
key={field.value}
|
key={field.value}
|
||||||
|
name={field.name}
|
||||||
value={field.value || ''}
|
value={field.value || ''}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
disabled={field.disabled || isLoading}
|
disabled={field.disabled || isLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger id="auto_delete" className="w-full">
|
||||||
<SelectValue placeholder="Select auto delete option" />
|
<SelectValue placeholder={t.settings.autoDeletePlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="yes">Yes</SelectItem>
|
<SelectItem value="yes">{t.common.yes}</SelectItem>
|
||||||
<SelectItem value="no">No</SelectItem>
|
<SelectItem value="no">{t.common.no}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}>
|
<Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}>
|
||||||
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />
|
<ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />
|
||||||
Help me choose
|
{t.settings.helpMeChoose}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
<CollapsibleContent className="mt-2 text-sm text-muted-foreground space-y-2">
|
||||||
<p>Once your files are uploaded and processed, they are not required anymore. Most users should allow Open Notebook to delete uploaded files from the upload folder automatically. Choose <strong>no</strong>, ONLY if you are using Notebook as the primary storage location for those files (which you shouldn't be at all). This option will soon be deprecated in favor of always downloading the files.</p>
|
<p>{t.settings.filesHelp}</p>
|
||||||
<p>• Choose <strong>yes</strong> (recommended) to automatically delete uploaded files after processing</p>
|
|
||||||
<p>• Choose <strong>no</strong> only if you need to keep the original files in the upload folder</p>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -266,11 +266,11 @@ export function SettingsForm() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || updateSettings.isPending}
|
disabled={!isDirty || updateSettings.isPending}
|
||||||
>
|
>
|
||||||
{updateSettings.isPending ? 'Saving...' : 'Save Settings'}
|
{updateSettings.isPending ? t.common.saving : t.navigation.settings}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import { SettingsForm } from './components/SettingsForm'
|
||||||
import { useSettings } from '@/lib/hooks/use-settings'
|
import { useSettings } from '@/lib/hooks/use-settings'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { refetch } = useSettings()
|
const { refetch } = useSettings()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -15,7 +17,7 @@ export default function SettingsPage() {
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<h1 className="text-2xl font-bold">Settings</h1>
|
<h1 className="text-2xl font-bold">{t.navigation.settings}</h1>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { SourceDetailContent } from '@/components/source/SourceDetailContent'
|
||||||
export default function SourceDetailPage() {
|
export default function SourceDetailPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sourceId = decodeURIComponent(params.id as string)
|
const sourceId = params?.id ? decodeURIComponent(params.id as string) : ''
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
|
|
||||||
// Initialize source chat
|
// Initialize source chat
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ import { FileText, Link as LinkIcon, Upload, AlignLeft, Trash2, ArrowUpDown } fr
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||||
|
|
||||||
export default function SourcesPage() {
|
export default function SourcesPage() {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const [sources, setSources] = useState<SourceListResponse[]>([])
|
const [sources, setSources] = useState<SourceListResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
|
@ -71,14 +75,14 @@ export default function SourcesPage() {
|
||||||
offsetRef.current += data.length
|
offsetRef.current += data.length
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch sources:', err)
|
console.error('Failed to fetch sources:', err)
|
||||||
setError('Failed to load sources')
|
setError(t.sources.failedToLoad)
|
||||||
toast.error('Failed to load sources')
|
toast.error(t.sources.failedToLoad)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setLoadingMore(false)
|
setLoadingMore(false)
|
||||||
loadingMoreRef.current = false
|
loadingMoreRef.current = false
|
||||||
}
|
}
|
||||||
}, [sortBy, sortOrder])
|
}, [sortBy, sortOrder, t.sources.failedToLoad])
|
||||||
|
|
||||||
// Initial load and when sort changes
|
// Initial load and when sort changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -216,9 +220,9 @@ export default function SourcesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSourceType = (source: SourceListResponse) => {
|
const getSourceType = (source: SourceListResponse) => {
|
||||||
if (source.asset?.url) return 'Link'
|
if (source.asset?.url) return t.sources.type.link
|
||||||
if (source.asset?.file_path) return 'File'
|
if (source.asset?.file_path) return t.sources.type.file
|
||||||
return 'Text'
|
return t.sources.type.text
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRowClick = useCallback((index: number, sourceId: string) => {
|
const handleRowClick = useCallback((index: number, sourceId: string) => {
|
||||||
|
|
@ -236,13 +240,14 @@ export default function SourcesPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sourcesApi.delete(deleteDialog.source.id)
|
await sourcesApi.delete(deleteDialog.source.id)
|
||||||
toast.success('Source deleted successfully')
|
toast.success(t.sources.deleteSuccess)
|
||||||
// Remove the deleted source from the list
|
// Remove the deleted source from the list
|
||||||
setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))
|
setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))
|
||||||
setDeleteDialog({ open: false, source: null })
|
setDeleteDialog({ open: false, source: null })
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to delete source:', err)
|
const error = err as { response?: { data?: { detail?: string } }, message?: string };
|
||||||
toast.error('Failed to delete source')
|
console.error('Failed to delete source:', error)
|
||||||
|
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,8 +276,8 @@ export default function SourcesPage() {
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="No sources yet"
|
title={t.sources.noSourcesYet}
|
||||||
description="Sources from all notebooks will appear here"
|
description={t.sources.allSourcesDescShort}
|
||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|
@ -282,9 +287,9 @@ export default function SourcesPage() {
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="flex flex-col h-full w-full max-w-none px-6 py-6">
|
<div className="flex flex-col h-full w-full max-w-none px-6 py-6">
|
||||||
<div className="mb-6 flex-shrink-0">
|
<div className="mb-6 flex-shrink-0">
|
||||||
<h1 className="text-3xl font-bold">All Sources</h1>
|
<h1 className="text-3xl font-bold">{t.sources.allSources}</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Browse all sources across your notebooks. Use arrow keys to navigate and Enter to open.
|
{t.sources.allSourcesDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -305,10 +310,10 @@ export default function SourcesPage() {
|
||||||
<thead className="sticky top-0 bg-background z-10">
|
<thead className="sticky top-0 bg-background z-10">
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
Type
|
{t.common.type}
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
|
||||||
Title
|
{t.common.title}
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell">
|
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -317,7 +322,7 @@ export default function SourcesPage() {
|
||||||
onClick={() => toggleSort('created')}
|
onClick={() => toggleSort('created')}
|
||||||
className="h-8 px-2 hover:bg-muted"
|
className="h-8 px-2 hover:bg-muted"
|
||||||
>
|
>
|
||||||
Created
|
{t.common.created_label}
|
||||||
<ArrowUpDown className={cn(
|
<ArrowUpDown className={cn(
|
||||||
"ml-2 h-3 w-3",
|
"ml-2 h-3 w-3",
|
||||||
sortBy === 'created' ? 'opacity-100' : 'opacity-30'
|
sortBy === 'created' ? 'opacity-100' : 'opacity-30'
|
||||||
|
|
@ -330,13 +335,13 @@ export default function SourcesPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell">
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell">
|
||||||
Insights
|
{t.sources.insights}
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell">
|
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell">
|
||||||
Embedded
|
{t.sources.embedded}
|
||||||
</th>
|
</th>
|
||||||
<th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground">
|
<th className="h-12 px-4 text-right align-middle font-medium text-muted-foreground">
|
||||||
Actions
|
{t.common.actions}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -364,7 +369,7 @@ export default function SourcesPage() {
|
||||||
<td className="h-12 px-4">
|
<td className="h-12 px-4">
|
||||||
<div className="flex flex-col overflow-hidden">
|
<div className="flex flex-col overflow-hidden">
|
||||||
<span className="font-medium truncate">
|
<span className="font-medium truncate">
|
||||||
{source.title || 'Untitled Source'}
|
{source.title || t.sources.untitledSource}
|
||||||
</span>
|
</span>
|
||||||
{source.asset?.url && (
|
{source.asset?.url && (
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
|
@ -374,14 +379,17 @@ export default function SourcesPage() {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell">
|
<td className="h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell">
|
||||||
{formatDistanceToNow(new Date(source.created), { addSuffix: true })}
|
{formatDistanceToNow(new Date(source.created), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: getDateLocale(language)
|
||||||
|
})}
|
||||||
</td>
|
</td>
|
||||||
<td className="h-12 px-4 text-center hidden md:table-cell">
|
<td className="h-12 px-4 text-center hidden md:table-cell">
|
||||||
<span className="text-sm font-medium">{source.insights_count || 0}</span>
|
<span className="text-sm font-medium">{source.insights_count || 0}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="h-12 px-4 text-center hidden lg:table-cell">
|
<td className="h-12 px-4 text-center hidden lg:table-cell">
|
||||||
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
|
<Badge variant={source.embedded ? "default" : "secondary"} className="text-xs">
|
||||||
{source.embedded ? "Yes" : "No"}
|
{source.embedded ? t.sources.yes : t.sources.no}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="h-12 px-4 text-right">
|
<td className="h-12 px-4 text-right">
|
||||||
|
|
@ -401,7 +409,7 @@ export default function SourcesPage() {
|
||||||
<td colSpan={6} className="h-16 text-center">
|
<td colSpan={6} className="h-16 text-center">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-muted-foreground">Loading more sources...</span>
|
<span className="ml-2 text-muted-foreground">{t.sources.loadingMore}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -414,9 +422,9 @@ export default function SourcesPage() {
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialog.open}
|
open={deleteDialog.open}
|
||||||
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
|
onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}
|
||||||
title="Delete Source"
|
title={t.sources.delete}
|
||||||
description={`Are you sure you want to delete "${deleteDialog.source?.title || 'this source'}"? This action cannot be undone.`}
|
description={t.sources.deleteConfirmWithTitle.replace('{title}', deleteDialog.source?.title || t.sources.untitledSource)}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useId } from 'react'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { ChevronDown, ChevronRight, Settings } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Settings } from 'lucide-react'
|
||||||
import { useDefaultPrompt, useUpdateDefaultPrompt } from '@/lib/hooks/use-transformations'
|
import { useDefaultPrompt, useUpdateDefaultPrompt } from '@/lib/hooks/use-transformations'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export function DefaultPromptEditor() {
|
export function DefaultPromptEditor() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [prompt, setPrompt] = useState('')
|
const [prompt, setPrompt] = useState('')
|
||||||
const { data: defaultPrompt, isLoading } = useDefaultPrompt()
|
const { data: defaultPrompt, isLoading } = useDefaultPrompt()
|
||||||
const updateDefaultPrompt = useUpdateDefaultPrompt()
|
const updateDefaultPrompt = useUpdateDefaultPrompt()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const textareaId = useId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (defaultPrompt) {
|
if (defaultPrompt) {
|
||||||
|
|
@ -33,9 +37,9 @@ export function DefaultPromptEditor() {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<CardTitle className="text-lg">Default Transformation Prompt</CardTitle>
|
<CardTitle className="text-lg">{t.transformations.defaultPrompt}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This will be added to all your transformation prompts
|
{t.transformations.defaultPromptDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,19 +53,26 @@ export function DefaultPromptEditor() {
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Textarea
|
<div className="space-y-2">
|
||||||
value={prompt}
|
<Label htmlFor={textareaId} className="sr-only">
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
{t.transformations.defaultPrompt}
|
||||||
placeholder="Enter your default transformation instructions..."
|
</Label>
|
||||||
className="min-h-[200px] font-mono text-sm"
|
<Textarea
|
||||||
disabled={isLoading}
|
id={textareaId}
|
||||||
/>
|
name="default-prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder={t.transformations.defaultPromptPlaceholder}
|
||||||
|
className="min-h-[200px] font-mono text-sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading || updateDefaultPrompt.isPending}
|
disabled={isLoading || updateDefaultPrompt.isPending}
|
||||||
>
|
>
|
||||||
Save
|
{t.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
|
||||||
import { ChevronDown, ChevronRight, Trash2, Wand2, Edit } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Trash2, Wand2, Edit } from 'lucide-react'
|
||||||
import { Transformation } from '@/lib/types/transformations'
|
import { Transformation } from '@/lib/types/transformations'
|
||||||
import { useDeleteTransformation } from '@/lib/hooks/use-transformations'
|
import { useDeleteTransformation } from '@/lib/hooks/use-transformations'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface TransformationCardProps {
|
interface TransformationCardProps {
|
||||||
|
|
@ -18,6 +19,7 @@ interface TransformationCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransformationCard({ transformation, onPlayground, onEdit }: TransformationCardProps) {
|
export function TransformationCard({ transformation, onPlayground, onEdit }: TransformationCardProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const deleteTransformation = useDeleteTransformation()
|
const deleteTransformation = useDeleteTransformation()
|
||||||
|
|
@ -47,7 +49,7 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{transformation.apply_default && (
|
{transformation.apply_default && (
|
||||||
<Badge variant="secondary">default</Badge>
|
<Badge variant="secondary">{t.common.default}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
@ -56,13 +58,13 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
|
||||||
{onPlayground && (
|
{onPlayground && (
|
||||||
<Button variant="outline" size="sm" onClick={onPlayground}>
|
<Button variant="outline" size="sm" onClick={onPlayground}>
|
||||||
<Wand2 className="h-4 w-4 mr-2" />
|
<Wand2 className="h-4 w-4 mr-2" />
|
||||||
Playground
|
{t.transformations.playground}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
Edit
|
{t.common.edit}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -80,19 +82,19 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Title</p>
|
<p className="text-sm text-muted-foreground">{t.common.title}</p>
|
||||||
<p className="text-sm font-medium">{transformation.title || 'Untitled'}</p>
|
<p className="text-sm font-medium">{transformation.title || t.sources.untitledSource}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{transformation.description && (
|
{transformation.description && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Description</p>
|
<p className="text-sm text-muted-foreground">{t.common.description}</p>
|
||||||
<p className="text-sm leading-6">{transformation.description}</p>
|
<p className="text-sm leading-6">{transformation.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">Prompt</p>
|
<p className="text-sm text-muted-foreground">{t.transformations.systemPrompt}</p>
|
||||||
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono">
|
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono">
|
||||||
{transformation.prompt}
|
{transformation.prompt}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
@ -105,9 +107,9 @@ export function TransformationCard({ transformation, onPlayground, onEdit }: Tra
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
onOpenChange={setShowDeleteDialog}
|
onOpenChange={setShowDeleteDialog}
|
||||||
title="Delete Transformation"
|
title={t.sources.delete}
|
||||||
description={`Are you sure you want to delete "${transformation.name}"? This action cannot be undone.`}
|
description={t.transformations.deleteConfirm}
|
||||||
confirmText="Delete"
|
confirmText={t.common.delete}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
isLoading={deleteTransformation.isPending}
|
isLoading={deleteTransformation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useId } from 'react'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
|
@ -15,12 +15,13 @@ import { useCreateTransformation, useUpdateTransformation, useTransformation } f
|
||||||
import { Transformation } from '@/lib/types/transformations'
|
import { Transformation } from '@/lib/types/transformations'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { TRANSFORMATION_QUERY_KEYS } from '@/lib/hooks/use-transformations'
|
import { TRANSFORMATION_QUERY_KEYS } from '@/lib/hooks/use-transformations'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
const transformationSchema = z.object({
|
const transformationSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1),
|
||||||
title: z.string().optional(),
|
title: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
prompt: z.string().min(1, 'Prompt is required'),
|
prompt: z.string().min(1),
|
||||||
apply_default: z.boolean().optional(),
|
apply_default: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -33,6 +34,12 @@ interface TransformationEditorDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransformationEditorDialog({ open, onOpenChange, transformation }: TransformationEditorDialogProps) {
|
export function TransformationEditorDialog({ open, onOpenChange, transformation }: TransformationEditorDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const nameId = useId()
|
||||||
|
const titleId = useId()
|
||||||
|
const defaultId = useId()
|
||||||
|
const descriptionId = useId()
|
||||||
|
const promptId = useId()
|
||||||
const isEditing = Boolean(transformation)
|
const isEditing = Boolean(transformation)
|
||||||
const { data: fetchedTransformation, isLoading } = useTransformation(transformation?.id ?? '', {
|
const { data: fetchedTransformation, isLoading } = useTransformation(transformation?.id ?? '', {
|
||||||
enabled: open && Boolean(transformation?.id),
|
enabled: open && Boolean(transformation?.id),
|
||||||
|
|
@ -111,28 +118,32 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0">
|
<DialogContent className="sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0">
|
||||||
<DialogTitle className="sr-only">
|
<DialogTitle className="sr-only">
|
||||||
{isEditing ? 'Edit transformation' : 'Create transformation'}
|
{isEditing ? t.common.edit : t.transformations.createNew}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{isEditing ? t.common.editTransformation : t.transformations.createNew}
|
||||||
|
</DialogDescription>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
|
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
|
||||||
{isEditing && isLoading ? (
|
{isEditing && isLoading ? (
|
||||||
<div className="flex-1 flex items-center justify-center py-10">
|
<div className="flex-1 flex items-center justify-center py-10">
|
||||||
<span className="text-sm text-muted-foreground">Loading transformation…</span>
|
<span className="text-sm text-muted-foreground">{t.common.loading}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="border-b px-6 py-4 space-y-4">
|
<div className="border-b px-6 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transformation-name" className="text-sm font-medium">
|
<Label htmlFor={nameId} className="text-sm font-medium">
|
||||||
Name
|
{t.transformations.name}
|
||||||
</Label>
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
id="transformation-name"
|
id={nameId}
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Unique identifier, e.g. key_topics"
|
placeholder={t.transformations.namePlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,18 +154,19 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transformation-title" className="text-sm font-medium">
|
<Label htmlFor={titleId} className="text-sm font-medium">
|
||||||
Title
|
{t.common.title}
|
||||||
</Label>
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="title"
|
name="title"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
id="transformation-title"
|
id={titleId}
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Displayed title, defaults to name"
|
placeholder={t.transformations.titlePlaceholder}
|
||||||
/>
|
autoComplete="off"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,31 +176,32 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
|
||||||
name="apply_default"
|
name="apply_default"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="transformation-default"
|
id={defaultId}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
|
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="transformation-default" className="text-sm">
|
<Label htmlFor={defaultId} className="text-sm">
|
||||||
Suggest by default on new sources
|
{t.transformations.suggestDefault}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transformation-description" className="text-sm font-medium">
|
<Label htmlFor={descriptionId} className="text-sm font-medium">
|
||||||
Description
|
{t.notebooks.addDescription.replace('...', '')}
|
||||||
</Label>
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Textarea
|
<Textarea
|
||||||
id="transformation-description"
|
id={descriptionId}
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Describe what this transformation does."
|
placeholder={t.transformations.descriptionPlaceholder}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -196,7 +209,7 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<Label className="text-sm font-medium">Prompt</Label>
|
<Label htmlFor={promptId} className="text-sm font-medium">{t.transformations.systemPrompt}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="prompt"
|
name="prompt"
|
||||||
|
|
@ -206,33 +219,34 @@ export function TransformationEditorDialog({ open, onOpenChange, transformation
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
height={420}
|
height={420}
|
||||||
placeholder="Write the prompt that will power this transformation..."
|
placeholder={t.transformations.promptPlaceholder}
|
||||||
className="rounded-md border"
|
className="rounded-md border"
|
||||||
|
textareaId={promptId}
|
||||||
|
name={field.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.prompt && (
|
{errors.prompt && (
|
||||||
<p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p>
|
<p className="text-sm text-red-600 mt-1">{errors.prompt.message}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
Prompts should be written with the source content in mind. You can ask the model to
|
{t.transformations.promptHint}
|
||||||
summarise, extract insights, or produce structured outputs such as tables.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleClose}>
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isSaving || (isEditing && isLoading)}>
|
<Button type="submit" disabled={isSaving || (isEditing && isLoading)}>
|
||||||
{isSaving
|
{isSaving
|
||||||
? isEditing ? 'Saving…' : 'Creating…'
|
? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`
|
||||||
: isEditing
|
: isEditing
|
||||||
? 'Save Transformation'
|
? t.common.editTransformation
|
||||||
: 'Create Transformation'}
|
: t.transformations.createNew}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Play, Loader2 } from 'lucide-react'
|
||||||
import { Transformation } from '@/lib/types/transformations'
|
import { Transformation } from '@/lib/types/transformations'
|
||||||
import { useExecuteTransformation } from '@/lib/hooks/use-transformations'
|
import { useExecuteTransformation } from '@/lib/hooks/use-transformations'
|
||||||
import { ModelSelector } from '@/components/common/ModelSelector'
|
import { ModelSelector } from '@/components/common/ModelSelector'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ interface TransformationPlaygroundProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransformationPlayground({ transformations, selectedTransformation }: TransformationPlaygroundProps) {
|
export function TransformationPlayground({ transformations, selectedTransformation }: TransformationPlaygroundProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selectedId, setSelectedId] = useState(selectedTransformation?.id || '')
|
const [selectedId, setSelectedId] = useState(selectedTransformation?.id || '')
|
||||||
const [inputText, setInputText] = useState('')
|
const [inputText, setInputText] = useState('')
|
||||||
const [modelId, setModelId] = useState('')
|
const [modelId, setModelId] = useState('')
|
||||||
|
|
@ -47,18 +49,18 @@ export function TransformationPlayground({ transformations, selectedTransformati
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Playground</CardTitle>
|
<CardTitle>{t.transformations.playground}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Test your transformations on sample text before applying them to your sources
|
{t.transformations.desc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transformation">Transformation</Label>
|
<Label htmlFor="transformation">{t.navigation.transformation}</Label>
|
||||||
<Select value={selectedId} onValueChange={setSelectedId}>
|
<Select name="transformation" value={selectedId} onValueChange={setSelectedId}>
|
||||||
<SelectTrigger id="transformation">
|
<SelectTrigger id="transformation">
|
||||||
<SelectValue placeholder="Select a transformation" />
|
<SelectValue placeholder={t.transformations.selectToStart} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{transformations?.map((transformation) => (
|
{transformations?.map((transformation) => (
|
||||||
|
|
@ -72,22 +74,24 @@ export function TransformationPlayground({ transformations, selectedTransformati
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
label="Model"
|
label={t.transformations.model}
|
||||||
|
name="model"
|
||||||
modelType="language"
|
modelType="language"
|
||||||
value={modelId}
|
value={modelId}
|
||||||
onChange={setModelId}
|
onChange={setModelId}
|
||||||
placeholder="Select a model"
|
placeholder={t.transformations.selectModel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="input">Input Text</Label>
|
<Label htmlFor="input">{t.transformations.inputLabel}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="input"
|
id="input"
|
||||||
|
name="input"
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
placeholder="Enter some text to transform..."
|
placeholder={t.transformations.inputPlaceholder}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
@ -102,12 +106,12 @@ export function TransformationPlayground({ transformations, selectedTransformati
|
||||||
{executeTransformation.isPending ? (
|
{executeTransformation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Running...
|
{t.transformations.running}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
Run Transformation
|
{t.transformations.runTest}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -115,7 +119,7 @@ export function TransformationPlayground({ transformations, selectedTransformati
|
||||||
|
|
||||||
{output && (
|
{output && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Output</Label>
|
<span className="text-sm font-medium leading-none">{t.transformations.outputLabel}</span>
|
||||||
<Card>
|
<Card>
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { Wand2 } from 'lucide-react'
|
import { Wand2 } from 'lucide-react'
|
||||||
import { Transformation } from '@/lib/types/transformations'
|
import { Transformation } from '@/lib/types/transformations'
|
||||||
import { TransformationEditorDialog } from './TransformationEditorDialog'
|
import { TransformationEditorDialog } from './TransformationEditorDialog'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface TransformationsListProps {
|
interface TransformationsListProps {
|
||||||
transformations: Transformation[] | undefined
|
transformations: Transformation[] | undefined
|
||||||
|
|
@ -17,6 +18,7 @@ interface TransformationsListProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransformationsList({ transformations, isLoading, onPlayground }: TransformationsListProps) {
|
export function TransformationsList({ transformations, isLoading, onPlayground }: TransformationsListProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editingTransformation, setEditingTransformation] = useState<Transformation | undefined>()
|
const [editingTransformation, setEditingTransformation] = useState<Transformation | undefined>()
|
||||||
|
|
||||||
|
|
@ -37,12 +39,12 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Wand2}
|
icon={Wand2}
|
||||||
title="No transformations yet"
|
title={t.transformations.noTransformations}
|
||||||
description="Create your first transformation to process and extract insights from your content."
|
description={t.transformations.createOne}
|
||||||
action={
|
action={
|
||||||
<Button onClick={() => handleOpenEditor()}>
|
<Button onClick={() => handleOpenEditor()}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create New Transformation
|
{t.transformations.createNew}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -53,10 +55,10 @@ export function TransformationsList({ transformations, isLoading, onPlayground }
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold">Your Transformations</h2>
|
<h2 className="text-lg font-semibold">{t.transformations.listTitle}</h2>
|
||||||
<Button onClick={() => handleOpenEditor()}>
|
<Button onClick={() => handleOpenEditor()}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create New Transformation
|
{t.transformations.createNew}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import { TransformationPlayground } from './components/TransformationPlayground'
|
||||||
import { useTransformations } from '@/lib/hooks/use-transformations'
|
import { useTransformations } from '@/lib/hooks/use-transformations'
|
||||||
import { Transformation } from '@/lib/types/transformations'
|
import { Transformation } from '@/lib/types/transformations'
|
||||||
import { Wand2, Play, RefreshCw } from 'lucide-react'
|
import { Wand2, Play, RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export default function TransformationsPage() {
|
export default function TransformationsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState('transformations')
|
const [activeTab, setActiveTab] = useState('transformations')
|
||||||
const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>()
|
const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>()
|
||||||
const { data: transformations, isLoading, refetch } = useTransformations()
|
const { data: transformations, isLoading, refetch } = useTransformations()
|
||||||
|
|
@ -27,7 +29,7 @@ export default function TransformationsPage() {
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">Transformations</h1>
|
<h1 className="text-2xl font-bold">{t.transformations.title}</h1>
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -36,21 +38,21 @@ export default function TransformationsPage() {
|
||||||
|
|
||||||
<div className="max-w-5xl">
|
<div className="max-w-5xl">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Transformations are prompts that will be used by the LLM to process a source and extract insights, summaries, etc.
|
{t.transformations.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Choose a workspace</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.transformations.workspace}</p>
|
||||||
<TabsList aria-label="Transformation views" className="w-full max-w-xl">
|
<TabsList aria-label={t.common.accessibility.transformationViews} className="w-full max-w-xl">
|
||||||
<TabsTrigger value="transformations" className="flex items-center gap-2">
|
<TabsTrigger value="transformations" className="flex items-center gap-2">
|
||||||
<Wand2 className="h-4 w-4" />
|
<Wand2 className="h-4 w-4" />
|
||||||
Transformations
|
{t.transformations.title}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="playground" className="flex items-center gap-2">
|
<TabsTrigger value="playground" className="flex items-center gap-2">
|
||||||
<Play className="h-4 w-4" />
|
<Play className="h-4 w-4" />
|
||||||
Playground
|
{t.transformations.playground}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.92 0.01 286.375);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.35 0.01 286.033);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
|
|
@ -114,63 +114,78 @@
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@apply antialiased;
|
@apply antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground transition-colors;
|
@apply bg-background text-foreground transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure proper theme inheritance for popovers and dropdowns */
|
/* Ensure proper theme inheritance for popovers and dropdowns */
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure Radix UI components inherit theme properly */
|
/* Ensure Radix UI components inherit theme properly */
|
||||||
[data-radix-popper-content-wrapper] {
|
[data-radix-popper-content-wrapper] {
|
||||||
@apply z-50;
|
@apply z-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Force theme inheritance for portaled content */
|
/* Force theme inheritance for portaled content */
|
||||||
.dark [data-radix-popper-content-wrapper],
|
.dark [data-radix-popper-content-wrapper],
|
||||||
.dark [data-overlay-container] {
|
.dark [data-overlay-container] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure sidebar gets proper theme */
|
/* Ensure sidebar gets proper theme */
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
background-color: var(--sidebar);
|
background-color: var(--sidebar);
|
||||||
color: var(--sidebar-foreground);
|
color: var(--sidebar-foreground);
|
||||||
border-color: var(--sidebar-border);
|
border-color: var(--sidebar-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced sidebar menu item hover effects */
|
||||||
|
.sidebar-menu-item {
|
||||||
|
@apply transition-all duration-200 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu-item:hover {
|
||||||
|
@apply scale-[1.02];
|
||||||
|
background-color: var(--sidebar-accent) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .sidebar-menu-item:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
/* Enhanced hover effects for cards */
|
/* Enhanced hover effects for cards */
|
||||||
.card-hover {
|
.card-hover {
|
||||||
@apply transition-all duration-200 cursor-pointer;
|
@apply transition-all duration-200 cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover:hover {
|
.card-hover:hover {
|
||||||
background-color: var(--muted) !important;
|
background-color: var(--muted) !important;
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .card-hover:hover {
|
.dark .card-hover:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure clickable cards show pointer cursor */
|
/* Ensure clickable cards show pointer cursor */
|
||||||
.clickable-card {
|
.clickable-card {
|
||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-card * {
|
.clickable-card * {
|
||||||
cursor: pointer !important;
|
cursor: pointer !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/providers/ThemeProvider";
|
||||||
import { ErrorBoundary } from "@/components/common/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/common/ErrorBoundary";
|
||||||
import { ConnectionGuard } from "@/components/common/ConnectionGuard";
|
import { ConnectionGuard } from "@/components/common/ConnectionGuard";
|
||||||
import { themeScript } from "@/lib/theme-script";
|
import { themeScript } from "@/lib/theme-script";
|
||||||
|
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -29,10 +30,12 @@ export default function RootLayout({
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ConnectionGuard>
|
<I18nProvider>
|
||||||
{children}
|
<ConnectionGuard>
|
||||||
<Toaster />
|
{children}
|
||||||
</ConnectionGuard>
|
<Toaster />
|
||||||
|
</ConnectionGuard>
|
||||||
|
</I18nProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { AlertCircle } from 'lucide-react'
|
import { AlertCircle } from 'lucide-react'
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const { login, isLoading, error } = useAuth()
|
const { login, isLoading, error } = useAuth()
|
||||||
const { authRequired, checkAuthRequired, hasHydrated, isAuthenticated } = useAuthStore()
|
const { authRequired, checkAuthRequired, hasHydrated, isAuthenticated } = useAuthStore()
|
||||||
|
|
@ -81,9 +83,9 @@ export function LoginForm() {
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle>Connection Error</CardTitle>
|
<CardTitle>{t.common.connectionError}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Unable to connect to the API server
|
{t.common.unableToConnect}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -91,21 +93,21 @@ export function LoginForm() {
|
||||||
<div className="flex items-start gap-2 text-red-600 text-sm">
|
<div className="flex items-start gap-2 text-red-600 text-sm">
|
||||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{error || 'Unable to connect to server. Please check if the API is running.'}
|
{error || t.auth.connectErrorHint}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configInfo && (
|
{configInfo && (
|
||||||
<div className="space-y-2 text-xs text-muted-foreground border-t pt-3">
|
<div className="space-y-2 text-xs text-muted-foreground border-t pt-3">
|
||||||
<div className="font-medium">Diagnostic Information:</div>
|
<div className="font-medium">{t.common.diagnosticInfo}:</div>
|
||||||
<div className="space-y-1 font-mono">
|
<div className="space-y-1 font-mono">
|
||||||
<div>Version: {configInfo.version}</div>
|
<div>{t.common.version}: {configInfo.version}</div>
|
||||||
<div>Built: {new Date(configInfo.buildTime).toLocaleString()}</div>
|
<div>{t.common.built}: {new Date(configInfo.buildTime).toLocaleString(language === 'zh-CN' ? 'zh-CN' : language === 'zh-TW' ? 'zh-TW' : 'en-US')}</div>
|
||||||
<div className="break-all">API URL: {configInfo.apiUrl}</div>
|
<div className="break-all">{t.common.apiUrl}: {configInfo.apiUrl}</div>
|
||||||
<div className="break-all">Frontend: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div>
|
<div className="break-all">{t.common.frontendUrl}: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs pt-2">
|
<div className="text-xs pt-2">
|
||||||
Check browser console for detailed logs (look for 🔧 [Config] messages)
|
{t.common.checkConsoleLogs}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -114,7 +116,7 @@ export function LoginForm() {
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Retry Connection
|
{t.common.retryConnection}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -139,9 +141,9 @@ export function LoginForm() {
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle>Open Notebook</CardTitle>
|
<CardTitle>{t.auth.loginTitle}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter your password to access the application
|
{t.auth.loginDesc}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -149,7 +151,7 @@ export function LoginForm() {
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder={t.auth.passwordPlaceholder}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|
@ -168,12 +170,12 @@ export function LoginForm() {
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isLoading || !password.trim()}
|
disabled={isLoading || !password.trim()}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
{isLoading ? t.auth.signingIn : t.auth.signIn}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{configInfo && (
|
{configInfo && (
|
||||||
<div className="text-xs text-center text-muted-foreground pt-2 border-t">
|
<div className="text-xs text-center text-muted-foreground pt-2 border-t">
|
||||||
<div>Version {configInfo.version}</div>
|
<div>{t.common.version} {configInfo.version}</div>
|
||||||
<div className="font-mono text-[10px]">{configInfo.apiUrl}</div>
|
<div className="font-mono text-[10px]">{configInfo.apiUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useCallback, useMemo, useId } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useCreateDialogs } from '@/lib/hooks/use-create-dialogs'
|
import { useCreateDialogs } from '@/lib/hooks/use-create-dialogs'
|
||||||
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
||||||
|
|
@ -29,31 +29,39 @@ import {
|
||||||
Monitor,
|
Monitor,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
|
||||||
const navigationItems = [
|
const getNavigationItems = (t: TranslationKeys) => [
|
||||||
{ name: 'Sources', href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },
|
{ name: t.navigation.sources, href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },
|
||||||
{ name: 'Notebooks', href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
|
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
|
||||||
{ name: 'Ask and Search', href: '/search', icon: Search, keywords: ['find', 'query'] },
|
{ name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] },
|
||||||
{ name: 'Podcasts', href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
|
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
|
||||||
{ name: 'Models', href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
|
{ name: t.navigation.models, href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
|
||||||
{ name: 'Transformations', href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
|
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
|
||||||
{ name: 'Settings', href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
|
{ name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
|
||||||
{ name: 'Advanced', href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
|
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const createItems = [
|
const getCreateItems = (t: TranslationKeys) => [
|
||||||
{ name: 'Create Source', action: 'source', icon: FileText },
|
{ name: t.common.newSource, action: 'source', icon: FileText },
|
||||||
{ name: 'Create Notebook', action: 'notebook', icon: Book },
|
{ name: t.common.newNotebook, action: 'notebook', icon: Book },
|
||||||
{ name: 'Create Podcast', action: 'podcast', icon: Mic },
|
{ name: t.common.newPodcast, action: 'podcast', icon: Mic },
|
||||||
]
|
]
|
||||||
|
|
||||||
const themeItems = [
|
const getThemeItems = (t: TranslationKeys) => [
|
||||||
{ name: 'Light Theme', value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] },
|
{ name: t.common.light, value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] },
|
||||||
{ name: 'Dark Theme', value: 'dark' as const, icon: Moon, keywords: ['night'] },
|
{ name: t.common.dark, value: 'dark' as const, icon: Moon, keywords: ['night'] },
|
||||||
{ name: 'System Theme', value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] },
|
{ name: t.common.system, value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const commandInputId = useId()
|
||||||
|
const navigationItems = useMemo(() => getNavigationItems(t), [t])
|
||||||
|
const createItems = useMemo(() => getCreateItems(t), [t])
|
||||||
|
const themeItems = useMemo(() => getThemeItems(t), [t])
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -147,7 +155,7 @@ export function CommandPalette() {
|
||||||
(nb.description && nb.description.toLowerCase().includes(queryLower))
|
(nb.description && nb.description.toLowerCase().includes(queryLower))
|
||||||
) ?? false)
|
) ?? false)
|
||||||
)
|
)
|
||||||
}, [queryLower, notebooks])
|
}, [queryLower, notebooks, navigationItems, createItems, themeItems])
|
||||||
|
|
||||||
// Determine if we should show the Search/Ask section at the top
|
// Determine if we should show the Search/Ask section at the top
|
||||||
const showSearchFirst = query.trim() && !hasCommandMatch
|
const showSearchFirst = query.trim() && !hasCommandMatch
|
||||||
|
|
@ -156,26 +164,30 @@ export function CommandPalette() {
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
title="Command Palette"
|
title={t.common.quickActions}
|
||||||
description="Navigate, search, or ask your knowledge base"
|
description={t.common.quickActionsDesc}
|
||||||
className="sm:max-w-lg"
|
className="sm:max-w-lg"
|
||||||
>
|
>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Type a command or search..."
|
id={commandInputId}
|
||||||
|
name="command-search"
|
||||||
|
placeholder={t.searchPage.enterSearchPlaceholder}
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
|
aria-label={t.common.search}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{/* Search/Ask - show FIRST when there's a query with no command match */}
|
{/* Search/Ask - show FIRST when there's a query with no command match */}
|
||||||
{showSearchFirst && (
|
{showSearchFirst && (
|
||||||
<CommandGroup heading="Search & Ask" forceMount>
|
<CommandGroup heading={t.searchPage.searchAndAsk} forceMount>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`__search__ ${query}`}
|
value={`__search__ ${query}`}
|
||||||
onSelect={handleSearch}
|
onSelect={handleSearch}
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span>Search for “{query}”</span>
|
<span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`__ask__ ${query}`}
|
value={`__ask__ ${query}`}
|
||||||
|
|
@ -183,13 +195,13 @@ export function CommandPalette() {
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
<MessageCircleQuestion className="h-4 w-4" />
|
<MessageCircleQuestion className="h-4 w-4" />
|
||||||
<span>Ask about “{query}”</span>
|
<span>{t.searchPage.askAbout.replace('{query}', query)}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<CommandGroup heading="Navigation">
|
<CommandGroup heading={t.navigation.nav}>
|
||||||
{navigationItems.map((item) => (
|
{navigationItems.map((item) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|
@ -203,11 +215,11 @@ export function CommandPalette() {
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{/* Notebooks */}
|
{/* Notebooks */}
|
||||||
<CommandGroup heading="Notebooks">
|
<CommandGroup heading={t.notebooks.title}>
|
||||||
{notebooksLoading ? (
|
{notebooksLoading ? (
|
||||||
<CommandItem disabled>
|
<CommandItem disabled>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span>Loading notebooks...</span>
|
<span>{t.common.loading}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
) : notebooks && notebooks.length > 0 ? (
|
) : notebooks && notebooks.length > 0 ? (
|
||||||
notebooks.map((notebook) => (
|
notebooks.map((notebook) => (
|
||||||
|
|
@ -224,7 +236,7 @@ export function CommandPalette() {
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{/* Create */}
|
{/* Create */}
|
||||||
<CommandGroup heading="Create">
|
<CommandGroup heading={t.navigation.create}>
|
||||||
{createItems.map((item) => (
|
{createItems.map((item) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.action}
|
key={item.action}
|
||||||
|
|
@ -238,7 +250,7 @@ export function CommandPalette() {
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{/* Theme */}
|
{/* Theme */}
|
||||||
<CommandGroup heading="Theme">
|
<CommandGroup heading={t.navigation.theme}>
|
||||||
{themeItems.map((item) => (
|
{themeItems.map((item) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
|
|
@ -255,14 +267,14 @@ export function CommandPalette() {
|
||||||
{query.trim() && hasCommandMatch && (
|
{query.trim() && hasCommandMatch && (
|
||||||
<>
|
<>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading="Or search your knowledge base" forceMount>
|
<CommandGroup heading={t.searchPage.orSearchKb} forceMount>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`__search__ ${query}`}
|
value={`__search__ ${query}`}
|
||||||
onSelect={handleSearch}
|
onSelect={handleSearch}
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span>Search for “{query}”</span>
|
<span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`__ask__ ${query}`}
|
value={`__ask__ ${query}`}
|
||||||
|
|
@ -270,7 +282,7 @@ export function CommandPalette() {
|
||||||
forceMount
|
forceMount
|
||||||
>
|
>
|
||||||
<MessageCircleQuestion className="h-4 w-4" />
|
<MessageCircleQuestion className="h-4 w-4" />
|
||||||
<span>Ask about “{query}”</span>
|
<span>{t.searchPage.askAbout.replace('{query}', query)}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</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,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
|
|
@ -28,11 +29,14 @@ export function ConfirmDialog({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
confirmText = 'Confirm',
|
confirmText,
|
||||||
confirmVariant = 'default',
|
confirmVariant = 'default',
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const finalConfirmText = confirmText || t.common.confirm
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
@ -41,7 +45,7 @@ export function ConfirmDialog({
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isLoading}>{t.common.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|
@ -50,10 +54,10 @@ export function ConfirmDialog({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<LoadingSpinner size="sm" className="mr-2" />
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
{confirmText}
|
{finalConfirmText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
confirmText
|
finalConfirmText
|
||||||
)}
|
)}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { ConnectionError } from '@/lib/types/config'
|
import { ConnectionError } from '@/lib/types/config'
|
||||||
import { ConnectionErrorOverlay } from '@/components/errors/ConnectionErrorOverlay'
|
import { ConnectionErrorOverlay } from '@/components/errors/ConnectionErrorOverlay'
|
||||||
import { getConfig, resetConfig } from '@/lib/config'
|
import { getConfig, resetConfig } from '@/lib/config'
|
||||||
|
|
@ -12,9 +12,18 @@ interface ConnectionGuardProps {
|
||||||
export function ConnectionGuard({ children }: ConnectionGuardProps) {
|
export function ConnectionGuard({ children }: ConnectionGuardProps) {
|
||||||
const [error, setError] = useState<ConnectionError | null>(null)
|
const [error, setError] = useState<ConnectionError | null>(null)
|
||||||
const [isChecking, setIsChecking] = useState(true)
|
const [isChecking, setIsChecking] = useState(true)
|
||||||
|
// Use a ref to track checking status to avoid dependency cycles
|
||||||
|
const isCheckingRef = useRef(false)
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
|
// Prevent re-entry if already checking
|
||||||
|
if (isCheckingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCheckingRef.current = true
|
||||||
setIsChecking(true)
|
setIsChecking(true)
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
// Reset config cache to force a fresh fetch
|
// Reset config cache to force a fresh fetch
|
||||||
|
|
@ -25,41 +34,46 @@ export function ConnectionGuard({ children }: ConnectionGuardProps) {
|
||||||
|
|
||||||
// Check if database is offline
|
// Check if database is offline
|
||||||
if (config.dbStatus === 'offline') {
|
if (config.dbStatus === 'offline') {
|
||||||
setError({
|
const dbError: ConnectionError = {
|
||||||
type: 'database-offline',
|
type: 'database-offline',
|
||||||
details: {
|
details: {
|
||||||
message: 'The API server is running, but the database is not accessible',
|
message: 'Database is offline', // Fallback message, UI will translate
|
||||||
attemptedUrl: config.apiUrl,
|
attemptedUrl: config.apiUrl,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
setError(dbError)
|
||||||
|
isCheckingRef.current = false
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got here, connection is good
|
// If we got here, connection is good
|
||||||
setError(null)
|
setError(null)
|
||||||
|
isCheckingRef.current = false
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// API is unreachable
|
// API is unreachable
|
||||||
const errorMessage =
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||||
err instanceof Error ? err.message : 'Unknown error occurred'
|
|
||||||
const attemptedUrl =
|
const attemptedUrl =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? `${window.location.origin}/api/config`
|
? `${window.location.origin}/api/config`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
setError({
|
const apiError: ConnectionError = {
|
||||||
type: 'api-unreachable',
|
type: 'api-unreachable',
|
||||||
details: {
|
details: {
|
||||||
message: 'The Open Notebook API server could not be reached',
|
message: 'Unable to connect to API', // Fallback message
|
||||||
technicalMessage: errorMessage,
|
technicalMessage: errorMessage,
|
||||||
stack: err instanceof Error ? err.stack : undefined,
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
attemptedUrl,
|
attemptedUrl,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
setError(apiError)
|
||||||
|
isCheckingRef.current = false
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, []) // Empty dependency array - stable callback
|
||||||
|
|
||||||
// Check connection on mount
|
// Check connection on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'
|
import { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ContextToggleProps {
|
interface ContextToggleProps {
|
||||||
mode: ContextMode
|
mode: ContextMode
|
||||||
|
|
@ -18,28 +19,29 @@ interface ContextToggleProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_CONFIG = {
|
|
||||||
off: {
|
|
||||||
icon: EyeOff,
|
|
||||||
label: 'Not included in chat',
|
|
||||||
color: 'text-muted-foreground',
|
|
||||||
bgColor: 'hover:bg-muted'
|
|
||||||
},
|
|
||||||
insights: {
|
|
||||||
icon: Lightbulb,
|
|
||||||
label: 'Insights only',
|
|
||||||
color: 'text-amber-600',
|
|
||||||
bgColor: 'hover:bg-amber-50'
|
|
||||||
},
|
|
||||||
full: {
|
|
||||||
icon: FileText,
|
|
||||||
label: 'Full content',
|
|
||||||
color: 'text-primary',
|
|
||||||
bgColor: 'hover:bg-primary/10'
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export function ContextToggle({ mode, hasInsights = false, onChange, className }: ContextToggleProps) {
|
export function ContextToggle({ mode, hasInsights = false, onChange, className }: ContextToggleProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const MODE_CONFIG = {
|
||||||
|
off: {
|
||||||
|
icon: EyeOff,
|
||||||
|
label: t.common.contextModes.off,
|
||||||
|
color: 'text-muted-foreground',
|
||||||
|
bgColor: 'hover:bg-muted'
|
||||||
|
},
|
||||||
|
insights: {
|
||||||
|
icon: Lightbulb,
|
||||||
|
label: t.common.contextModes.insights,
|
||||||
|
color: 'text-amber-600',
|
||||||
|
bgColor: 'hover:bg-amber-50'
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
icon: FileText,
|
||||||
|
label: t.common.contextModes.full,
|
||||||
|
color: 'text-primary',
|
||||||
|
bgColor: 'hover:bg-primary/10'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
const config = MODE_CONFIG[mode]
|
const config = MODE_CONFIG[mode]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
|
||||||
|
|
@ -77,7 +79,7 @@ export function ContextToggle({ mode, hasInsights = false, onChange, className }
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="text-xs">{config.label}</p>
|
<p className="text-xs">{config.label}</p>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
Click to cycle
|
{t.common.contextModes.clickToCycle}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import React from 'react'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||||
|
import { enUS } from '@/lib/locales/en-US'
|
||||||
|
|
||||||
|
// Use English as fallback for ErrorBoundary (class component cannot use hooks)
|
||||||
|
const t = enUS
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
|
|
@ -55,15 +59,15 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
|
||||||
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
<div className="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
||||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-red-900 dark:text-red-100">Something went wrong</CardTitle>
|
<CardTitle className="text-red-900 dark:text-red-100">{t?.common?.error || 'Something went wrong'}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
An unexpected error occurred. Please try refreshing the page.
|
{t?.common?.refreshPage || 'An unexpected error occurred. Please try refreshing the page.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
<details className="text-xs bg-muted p-3 rounded border">
|
<details className="text-xs bg-muted p-3 rounded border">
|
||||||
<summary className="cursor-pointer font-medium">Error Details</summary>
|
<summary className="cursor-pointer font-medium">{t?.common?.errorDetails || 'Error Details'}</summary>
|
||||||
<pre className="mt-2 whitespace-pre-wrap break-all">
|
<pre className="mt-2 whitespace-pre-wrap break-all">
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
@ -75,13 +79,13 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Try Again
|
{t?.common?.retry || 'Try Again'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Refresh Page
|
{t?.common?.refresh || 'Refresh Page'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect, type RefObject } from 'react'
|
import { useState, useRef, useEffect, useId, type RefObject } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface InlineEditProps {
|
interface InlineEditProps {
|
||||||
value: string
|
value: string
|
||||||
|
|
@ -11,6 +12,9 @@ interface InlineEditProps {
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
multiline?: boolean
|
multiline?: boolean
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
autocomplete?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlineEdit({
|
export function InlineEdit({
|
||||||
|
|
@ -20,8 +24,15 @@ export function InlineEdit({
|
||||||
inputClassName,
|
inputClassName,
|
||||||
placeholder,
|
placeholder,
|
||||||
multiline = false,
|
multiline = false,
|
||||||
emptyText = 'Click to edit'
|
emptyText,
|
||||||
|
id: providedId,
|
||||||
|
name,
|
||||||
|
autocomplete = 'off'
|
||||||
}: InlineEditProps) {
|
}: InlineEditProps) {
|
||||||
|
const generatedId = useId()
|
||||||
|
const id = providedId || generatedId
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const defaultEmptyText = emptyText || t.common.clickToEdit
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editValue, setEditValue] = useState(value)
|
const [editValue, setEditValue] = useState(value)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
@ -85,7 +96,7 @@ export function InlineEdit({
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value || <span className="text-muted-foreground">{emptyText}</span>}
|
{value || <span className="text-muted-foreground">{defaultEmptyText}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +122,9 @@ export function InlineEdit({
|
||||||
)}
|
)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
autoComplete={autocomplete}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +148,9 @@ export function InlineEdit({
|
||||||
)}
|
)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
autoComplete={autocomplete}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 (
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useModels } from '@/lib/hooks/use-models'
|
import { useModels } from '@/lib/hooks/use-models'
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
label?: string
|
label?: string
|
||||||
modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech'
|
modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech'
|
||||||
value: string
|
value: string
|
||||||
|
|
@ -14,24 +16,29 @@ interface ModelSelectorProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
label,
|
id,
|
||||||
modelType,
|
name,
|
||||||
value,
|
label,
|
||||||
onChange,
|
modelType,
|
||||||
placeholder = 'Select a model',
|
value,
|
||||||
disabled = false
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { data: models, isLoading } = useModels()
|
const { data: models, isLoading } = useModels()
|
||||||
|
const derivedId = useId()
|
||||||
|
const selectId = id || derivedId
|
||||||
|
|
||||||
// Filter models by type
|
// Filter models by type
|
||||||
const filteredModels = models?.filter(model => model.type === modelType) || []
|
const filteredModels = models?.filter(model => model.type === modelType) || []
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{label && <Label>{label}</Label>}
|
{label && <Label htmlFor={selectId}>{label}</Label>}
|
||||||
<Select value={value} onValueChange={onChange} disabled={disabled || isLoading}>
|
<Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id={selectId}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -40,7 +47,7 @@ export function ModelSelector({
|
||||||
</div>
|
</div>
|
||||||
) : filteredModels.length === 0 ? (
|
) : filteredModels.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-2 px-2">
|
<div className="text-sm text-muted-foreground py-2 px-2">
|
||||||
No {modelType.replace('_', ' ')} models available
|
{t.common.noResults}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredModels.map((model) => (
|
filteredModels.map((model) => (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Sun, Moon, Monitor } from 'lucide-react'
|
import { Sun, Moon, Monitor } from 'lucide-react'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
interface ThemeToggleProps {
|
||||||
iconOnly?: boolean
|
iconOnly?: boolean
|
||||||
|
|
@ -16,6 +17,7 @@ interface ThemeToggleProps {
|
||||||
|
|
||||||
export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
|
export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -23,14 +25,14 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
|
||||||
<Button
|
<Button
|
||||||
variant={iconOnly ? "ghost" : "outline"}
|
variant={iconOnly ? "ghost" : "outline"}
|
||||||
size={iconOnly ? "icon" : "default"}
|
size={iconOnly ? "icon" : "default"}
|
||||||
className={iconOnly ? "h-9 w-full" : "w-full justify-start gap-2"}
|
className={iconOnly ? "h-9 w-full sidebar-menu-item" : "w-full justify-start gap-2 sidebar-menu-item"}
|
||||||
>
|
>
|
||||||
<div className="relative h-[1.2rem] w-[1.2rem]">
|
<div className="relative h-[1.2rem] w-[1.2rem]">
|
||||||
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
</div>
|
</div>
|
||||||
{!iconOnly && <span>Theme</span>}
|
{!iconOnly && <span>{t.common.theme}</span>}
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{t.navigation.theme}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|
@ -39,21 +41,21 @@ export function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {
|
||||||
className={theme === 'light' ? 'bg-accent' : ''}
|
className={theme === 'light' ? 'bg-accent' : ''}
|
||||||
>
|
>
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
<span>Light</span>
|
<span>{t.common.light}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setTheme('dark')}
|
onClick={() => setTheme('dark')}
|
||||||
className={theme === 'dark' ? 'bg-accent' : ''}
|
className={theme === 'dark' ? 'bg-accent' : ''}
|
||||||
>
|
>
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
<span>Dark</span>
|
<span>{t.common.dark}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setTheme('system')}
|
onClick={() => setTheme('system')}
|
||||||
className={theme === 'system' ? 'bg-accent' : ''}
|
className={theme === 'system' ? 'bg-accent' : ''}
|
||||||
>
|
>
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
<span>System</span>
|
<span>{t.common.system}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
import { Database, Server, ChevronDown, ExternalLink } from 'lucide-react'
|
import { Database, Server, ChevronDown, ExternalLink } from 'lucide-react'
|
||||||
import { ConnectionError } from '@/lib/types/config'
|
import { ConnectionError } from '@/lib/types/config'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface ConnectionErrorOverlayProps {
|
interface ConnectionErrorOverlayProps {
|
||||||
error: ConnectionError
|
error: ConnectionError
|
||||||
|
|
@ -20,6 +21,7 @@ export function ConnectionErrorOverlay({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: ConnectionErrorOverlayProps) {
|
}: ConnectionErrorOverlayProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
const isApiError = error.type === 'api-unreachable'
|
const isApiError = error.type === 'api-unreachable'
|
||||||
|
|
||||||
|
|
@ -41,56 +43,56 @@ export function ConnectionErrorOverlay({
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold" id="error-title">
|
<h1 className="text-2xl font-bold" id="error-title">
|
||||||
{isApiError
|
{isApiError
|
||||||
? 'Unable to Connect to API Server'
|
? t.connectionErrors.apiTitle
|
||||||
: 'Database Connection Failed'}
|
: t.connectionErrors.dbTitle}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{isApiError
|
{isApiError
|
||||||
? 'The Open Notebook API server could not be reached'
|
? t.connectionErrors.apiDesc
|
||||||
: 'The API server is running, but the database is not accessible'}
|
: t.connectionErrors.dbDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Troubleshooting instructions */}
|
{/* Troubleshooting instructions */}
|
||||||
<div className="space-y-4 border-l-4 border-primary pl-4">
|
<div className="space-y-4 border-l-4 border-primary pl-4">
|
||||||
<h2 className="font-semibold">This usually means:</h2>
|
<h2 className="font-semibold">{t.connectionErrors.troubleshooting}</h2>
|
||||||
<ul className="list-disc list-inside space-y-2 text-sm">
|
<ul className="list-disc list-inside space-y-2 text-sm">
|
||||||
{isApiError ? (
|
{isApiError ? (
|
||||||
<>
|
<>
|
||||||
<li>The API server is not running</li>
|
<li>{t.connectionErrors.apiUnreachable1}</li>
|
||||||
<li>The API server is running on a different address</li>
|
<li>{t.connectionErrors.apiUnreachable2}</li>
|
||||||
<li>Network connectivity issues</li>
|
<li>{t.connectionErrors.apiUnreachable3}</li>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<li>SurrealDB is not running</li>
|
<li>{t.connectionErrors.dbFailed1}</li>
|
||||||
<li>Database connection settings are incorrect</li>
|
<li>{t.connectionErrors.dbFailed2}</li>
|
||||||
<li>Network issues between API and database</li>
|
<li>{t.connectionErrors.dbFailed3}</li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 className="font-semibold mt-4">Quick fixes:</h2>
|
<h2 className="font-semibold mt-4">{t.connectionErrors.quickFixes}</h2>
|
||||||
{isApiError ? (
|
{isApiError ? (
|
||||||
<div className="space-y-2 text-sm bg-muted p-4 rounded">
|
<div className="space-y-2 text-sm bg-muted p-4 rounded">
|
||||||
<p className="font-medium">Set the API_URL environment variable:</p>
|
<p className="font-medium">{t.connectionErrors.setApiUrl}</p>
|
||||||
<code className="block bg-background p-2 rounded text-xs">
|
<code className="block bg-background p-2 rounded text-xs">
|
||||||
# For Docker:
|
# {t.connectionErrors.dockerLabel}:
|
||||||
<br />
|
<br />
|
||||||
docker run -e API_URL=http://your-host:5055 ...
|
docker run -e API_URL=http://your-host:5055 ...
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
# For local development (.env file):
|
# {t.connectionErrors.localDevLabel}:
|
||||||
<br />
|
<br />
|
||||||
API_URL=http://localhost:5055
|
API_URL=http://localhost:5055
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 text-sm bg-muted p-4 rounded">
|
<div className="space-y-2 text-sm bg-muted p-4 rounded">
|
||||||
<p className="font-medium">Check if SurrealDB is running:</p>
|
<p className="font-medium">{t.connectionErrors.checkSurreal}</p>
|
||||||
<code className="block bg-background p-2 rounded text-xs">
|
<code className="block bg-background p-2 rounded text-xs">
|
||||||
# For Docker:
|
# {t.connectionErrors.dockerLabel}:
|
||||||
<br />
|
<br />
|
||||||
docker compose ps | grep surrealdb
|
docker compose ps | grep surrealdb
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -102,14 +104,14 @@ export function ConnectionErrorOverlay({
|
||||||
|
|
||||||
{/* Documentation link */}
|
{/* Documentation link */}
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p>For detailed setup instructions, see:</p>
|
<p>{t.connectionErrors.seeDocumentation}</p>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/lfnovo/open-notebook"
|
href="https://github.com/lfnovo/open-notebook"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Open Notebook Documentation
|
{t.connectionErrors.docLink}
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,7 +121,7 @@ export function ConnectionErrorOverlay({
|
||||||
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||||
<span>Show Technical Details</span>
|
<span>{t.connectionErrors.showTechnical}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 transition-transform ${
|
className={`w-4 h-4 transition-transform ${
|
||||||
showDetails ? 'rotate-180' : ''
|
showDetails ? 'rotate-180' : ''
|
||||||
|
|
@ -131,23 +133,23 @@ export function ConnectionErrorOverlay({
|
||||||
<div className="space-y-2 text-sm bg-muted p-4 rounded font-mono">
|
<div className="space-y-2 text-sm bg-muted p-4 rounded font-mono">
|
||||||
{error.details.attemptedUrl && (
|
{error.details.attemptedUrl && (
|
||||||
<div>
|
<div>
|
||||||
<strong>Attempted URL:</strong> {error.details.attemptedUrl}
|
<strong>{t.connectionErrors.attemptedUrl}:</strong> {error.details.attemptedUrl}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error.details.message && (
|
{error.details.message && (
|
||||||
<div>
|
<div>
|
||||||
<strong>Message:</strong> {error.details.message}
|
<strong>{t.connectionErrors.message}:</strong> {error.details.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error.details.technicalMessage && (
|
{error.details.technicalMessage && (
|
||||||
<div>
|
<div>
|
||||||
<strong>Technical Details:</strong>{' '}
|
<strong>{t.connectionErrors.technicalDetails}:</strong>{' '}
|
||||||
{error.details.technicalMessage}
|
{error.details.technicalMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{error.details.stack && (
|
{error.details.stack && (
|
||||||
<div>
|
<div>
|
||||||
<strong>Stack Trace:</strong>
|
<strong>{t.connectionErrors.stackTrace}:</strong>
|
||||||
<pre className="mt-2 overflow-x-auto text-xs">
|
<pre className="mt-2 overflow-x-auto text-xs">
|
||||||
{error.details.stack}
|
{error.details.stack}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
@ -161,10 +163,10 @@ export function ConnectionErrorOverlay({
|
||||||
{/* Retry button */}
|
{/* Retry button */}
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<Button onClick={onRetry} className="w-full" size="lg">
|
<Button onClick={onRetry} className="w-full" size="lg">
|
||||||
Retry Connection
|
{t.connectionErrors.retryLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground text-center mt-2">
|
<p className="text-xs text-muted-foreground text-center mt-2">
|
||||||
Press R or click the button to retry
|
{t.connectionErrors.retryHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
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,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { ThemeToggle } from '@/components/common/ThemeToggle'
|
import { ThemeToggle } from '@/components/common/ThemeToggle'
|
||||||
|
import { LanguageToggle } from '@/components/common/LanguageToggle'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import {
|
import {
|
||||||
Book,
|
Book,
|
||||||
|
|
@ -40,33 +43,33 @@ import {
|
||||||
Command,
|
Command,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navigation = [
|
const getNavigation = (t: TranslationKeys) => [
|
||||||
{
|
{
|
||||||
title: 'Collect',
|
title: t.navigation.collect,
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Sources', href: '/sources', icon: FileText },
|
{ name: t.navigation.sources, href: '/sources', icon: FileText },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Process',
|
title: t.navigation.process,
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Notebooks', href: '/notebooks', icon: Book },
|
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book },
|
||||||
{ name: 'Ask and Search', href: '/search', icon: Search },
|
{ name: t.navigation.askAndSearch, href: '/search', icon: Search },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Create',
|
title: t.navigation.create,
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Podcasts', href: '/podcasts', icon: Mic },
|
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Manage',
|
title: t.navigation.manage,
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Models', href: '/models', icon: Bot },
|
{ name: t.navigation.models, href: '/models', icon: Bot },
|
||||||
{ name: 'Transformations', href: '/transformations', icon: Shuffle },
|
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
|
||||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
{ name: t.navigation.settings, href: '/settings', icon: Settings },
|
||||||
{ name: 'Advanced', href: '/advanced', icon: Wrench },
|
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
@ -74,6 +77,8 @@ const navigation = [
|
||||||
type CreateTarget = 'source' | 'notebook' | 'podcast'
|
type CreateTarget = 'source' | 'notebook' | 'podcast'
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigation = getNavigation(t)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const { isCollapsed, toggleCollapse } = useSidebarStore()
|
const { isCollapsed, toggleCollapse } = useSidebarStore()
|
||||||
|
|
@ -134,9 +139,9 @@ export function AppSidebar() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Image src="/logo.svg" alt="Open Notebook" width={32} height={32} />
|
<Image src="/logo.svg" alt={t.common.appName} width={32} height={32} />
|
||||||
<span className="text-base font-medium text-sidebar-foreground">
|
<span className="text-base font-medium text-sidebar-foreground">
|
||||||
Open Notebook
|
{t.common.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -144,6 +149,7 @@ export function AppSidebar() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={toggleCollapse}
|
onClick={toggleCollapse}
|
||||||
className="text-sidebar-foreground hover:bg-sidebar-accent"
|
className="text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
|
data-testid="sidebar-toggle"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -173,13 +179,13 @@ export function AppSidebar() {
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0"
|
className="w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0"
|
||||||
aria-label="Create"
|
aria-label={t.common.create}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">Create</TooltipContent>
|
<TooltipContent side="right">{t.common.create}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -188,9 +194,9 @@ export function AppSidebar() {
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0"
|
className="w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create
|
{t.common.create}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
)}
|
)}
|
||||||
|
|
@ -207,8 +213,8 @@ export function AppSidebar() {
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Source
|
{t.common.source}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
|
|
@ -217,8 +223,8 @@ export function AppSidebar() {
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Book className="h-4 w-4" />
|
<Book className="h-4 w-4" />
|
||||||
Notebook
|
{t.common.notebook}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
|
|
@ -227,8 +233,8 @@ export function AppSidebar() {
|
||||||
}}
|
}}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
Podcast
|
{t.common.podcast}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -247,12 +253,12 @@ export function AppSidebar() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const isActive = pathname.startsWith(item.href)
|
const isActive = pathname?.startsWith(item.href) || false
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
variant={isActive ? 'secondary' : 'ghost'}
|
variant={isActive ? 'secondary' : 'ghost'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full gap-3 text-sidebar-foreground',
|
'w-full gap-3 text-sidebar-foreground sidebar-menu-item',
|
||||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||||
isCollapsed ? 'justify-center px-2' : 'justify-start'
|
isCollapsed ? 'justify-center px-2' : 'justify-start'
|
||||||
)}
|
)}
|
||||||
|
|
@ -296,37 +302,50 @@ export function AppSidebar() {
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="px-3 py-1.5 text-xs text-sidebar-foreground/60">
|
<div className="px-3 py-1.5 text-xs text-sidebar-foreground/60">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Command className="h-3 w-3" />
|
<Command className="h-3 w-3" />
|
||||||
Quick actions
|
{t.common.quickActions}
|
||||||
</span>
|
</span>
|
||||||
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
{isMac ? <span className="text-xs">⌘</span> : <span>Ctrl+</span>}K
|
{isMac ? <span className="text-xs">⌘</span> : <span>Ctrl+</span>}K
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-sidebar-foreground/40">
|
<p className="mt-1 text-[10px] text-sidebar-foreground/40">
|
||||||
Navigation, search, ask, theme
|
{t.common.quickActionsDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex',
|
'flex flex-col gap-2',
|
||||||
isCollapsed ? 'justify-center' : 'justify-start'
|
isCollapsed ? 'items-center' : 'items-stretch'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<Tooltip>
|
<>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<div>
|
<TooltipTrigger asChild>
|
||||||
<ThemeToggle iconOnly />
|
<div>
|
||||||
</div>
|
<ThemeToggle iconOnly />
|
||||||
</TooltipTrigger>
|
</div>
|
||||||
<TooltipContent side="right">Theme</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">{t.common.theme}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<LanguageToggle iconOnly />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{t.common.language}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ThemeToggle />
|
<>
|
||||||
|
<ThemeToggle />
|
||||||
|
<LanguageToggle />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,22 +354,24 @@ export function AppSidebar() {
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-center"
|
className="w-full justify-center sidebar-menu-item"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
aria-label={t.common.signOut}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">Sign Out</TooltipContent>
|
<TooltipContent side="right">{t.common.signOut}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start gap-3"
|
className="w-full justify-start gap-3 sidebar-menu-item"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
>
|
aria-label={t.common.signOut}
|
||||||
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Sign Out
|
{t.common.signOut}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useCreateNotebook } from '@/lib/hooks/use-notebooks'
|
import { useCreateNotebook } from '@/lib/hooks/use-notebooks'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
const createNotebookSchema = z.object({
|
const createNotebookSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
|
@ -32,6 +33,7 @@ interface CreateNotebookDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
|
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const createNotebook = useCreateNotebook()
|
const createNotebook = useCreateNotebook()
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|
@ -65,20 +67,20 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[480px]">
|
<DialogContent className="sm:max-w-[480px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New Notebook</DialogTitle>
|
<DialogTitle>{t.notebooks.createNew}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Start organizing your research with a dedicated space for related sources and notes.
|
{t.notebooks.createNewDesc}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="notebook-name">Name *</Label>
|
<Label htmlFor="notebook-name">{t.common.name || 'Name'} *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="notebook-name"
|
id="notebook-name"
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
placeholder="Enter notebook name"
|
placeholder={t.notebooks.namePlaceholder}
|
||||||
autoFocus
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||||
|
|
@ -86,21 +88,21 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="notebook-description">Description</Label>
|
<Label htmlFor="notebook-description">{t.common.description}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="notebook-description"
|
id="notebook-description"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
placeholder="Describe the purpose and scope of this notebook..."
|
placeholder={t.notebooks.descPlaceholder}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button type="button" variant="outline" onClick={closeDialog}>
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!isValid || createNotebook.isPending}>
|
<Button type="submit" disabled={!isValid || createNotebook.isPending}>
|
||||||
{createNotebook.isPending ? 'Creating…' : 'Create Notebook'}
|
{createNotebook.isPending ? t.common.creating : t.notebooks.createNew}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
|
import { getDateLocale } from '@/lib/utils/date-locale'
|
||||||
import { InfoIcon, Trash2 } from 'lucide-react'
|
import { InfoIcon, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { resolvePodcastAssetUrl } from '@/lib/api/podcasts'
|
import { resolvePodcastAssetUrl } from '@/lib/api/podcasts'
|
||||||
|
|
@ -31,6 +32,8 @@ import {
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
interface EpisodeCardProps {
|
||||||
episode: PodcastEpisode
|
episode: PodcastEpisode
|
||||||
|
|
@ -38,51 +41,52 @@ interface EpisodeCardProps {
|
||||||
deleting?: boolean
|
deleting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<
|
const getSTATUS_META = (t: TranslationKeys): Record<
|
||||||
EpisodeStatus | 'unknown',
|
EpisodeStatus | 'unknown',
|
||||||
{ label: string; className: string }
|
{ label: string; className: string }
|
||||||
> = {
|
> => ({
|
||||||
running: {
|
running: {
|
||||||
label: 'Processing',
|
label: t.podcasts.processingLabel,
|
||||||
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
},
|
},
|
||||||
processing: {
|
processing: {
|
||||||
label: 'Processing',
|
label: t.podcasts.processingLabel,
|
||||||
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
className: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
label: 'Completed',
|
label: t.podcasts.completedLabel,
|
||||||
className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
className: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
label: 'Failed',
|
label: t.podcasts.failedLabel,
|
||||||
className: 'bg-red-100 text-red-800 border-red-200',
|
className: 'bg-red-100 text-red-800 border-red-200',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
label: 'Failed',
|
label: t.podcasts.failedLabel,
|
||||||
className: 'bg-red-100 text-red-800 border-red-200',
|
className: 'bg-red-100 text-red-800 border-red-200',
|
||||||
},
|
},
|
||||||
pending: {
|
pending: {
|
||||||
label: 'Pending',
|
label: t.podcasts.pendingLabel,
|
||||||
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
||||||
},
|
},
|
||||||
submitted: {
|
submitted: {
|
||||||
label: 'Pending',
|
label: t.podcasts.pendingLabel,
|
||||||
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
className: 'bg-sky-100 text-sky-800 border-sky-200',
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
label: 'Unknown',
|
label: t.common.unknown,
|
||||||
className: 'bg-muted text-muted-foreground border-transparent',
|
className: 'bg-muted text-muted-foreground border-transparent',
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
function StatusBadge({ status }: { status?: EpisodeStatus | null }) {
|
function StatusBadge({ status }: { status?: EpisodeStatus | null }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
// Don't show badge for completed episodes
|
// Don't show badge for completed episodes
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = STATUS_META[status ?? 'unknown']
|
const meta = getSTATUS_META(t)[status ?? 'unknown']
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -133,6 +137,7 @@ function extractTranscriptEntries(transcript: unknown): TranscriptEntry[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const [audioSrc, setAudioSrc] = useState<string | undefined>()
|
const [audioSrc, setAudioSrc] = useState<string | undefined>()
|
||||||
const [audioError, setAudioError] = useState<string | null>(null)
|
const [audioError, setAudioError] = useState<string | null>(null)
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||||
|
|
@ -183,7 +188,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
setAudioSrc(revokeUrl)
|
setAudioSrc(revokeUrl)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Unable to load podcast audio', error)
|
console.error('Unable to load podcast audio', error)
|
||||||
setAudioError('Audio unavailable')
|
setAudioError(t.podcasts.audioUnavailable)
|
||||||
setAudioSrc(undefined)
|
setAudioSrc(undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -195,14 +200,19 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
URL.revokeObjectURL(revokeUrl)
|
URL.revokeObjectURL(revokeUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [episode.audio_url, episode.audio_file])
|
}, [episode.audio_url, episode.audio_file, t])
|
||||||
|
|
||||||
const createdLabel = episode.created
|
const distance = episode.created
|
||||||
? formatDistanceToNow(new Date(episode.created), {
|
? formatDistanceToNow(new Date(episode.created), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
|
locale: getDateLocale(language),
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const createdLabel = distance
|
||||||
|
? t.podcasts.created.replace('{time}', distance)
|
||||||
|
: null
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
void onDelete(episode.id)
|
void onDelete(episode.id)
|
||||||
}
|
}
|
||||||
|
|
@ -219,23 +229,23 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
<StatusBadge status={episode.job_status} />
|
<StatusBadge status={episode.job_status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Profile: {episode.episode_profile?.name ?? 'Unknown'}
|
{t.podcasts.profile}: {episode.episode_profile?.name || t.common.unknown}
|
||||||
{createdLabel ? ` • Created ${createdLabel}` : ''}
|
{createdLabel ? ` • ${createdLabel}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<InfoIcon className="mr-2 h-4 w-4" /> Details
|
<InfoIcon className="mr-2 h-4 w-4" /> {t.podcasts.details}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden">
|
<DialogContent className="w-[min(90vw,720px)] max-h-[85vh] overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{episode.name}</DialogTitle>
|
<DialogTitle>{episode.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{episode.episode_profile?.name ?? 'Unknown profile'}
|
{episode.episode_profile?.name || t.common.unknown}
|
||||||
{createdLabel ? ` • Created ${createdLabel}` : ''}
|
{createdLabel ? ` • ${createdLabel}` : ''}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 overflow-hidden">
|
<div className="space-y-4 overflow-hidden">
|
||||||
|
|
@ -247,19 +257,19 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
|
|
||||||
<Tabs defaultValue="summary" className="h-[60vh] flex flex-col">
|
<Tabs defaultValue="summary" className="h-[60vh] flex flex-col">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="summary">Summary</TabsTrigger>
|
<TabsTrigger value="summary">{t.podcasts.summaryTab}</TabsTrigger>
|
||||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
<TabsTrigger value="outline">{t.podcasts.outlineTab}</TabsTrigger>
|
||||||
<TabsTrigger value="transcript">Transcript</TabsTrigger>
|
<TabsTrigger value="transcript">{t.podcasts.transcriptTab}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="summary" className="flex-1 overflow-hidden">
|
<TabsContent value="summary" className="flex-1 overflow-hidden">
|
||||||
<ScrollArea className="h-full pr-4">
|
<ScrollArea className="h-full pr-4">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Episode Profile</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.episodeProfile}</h4>
|
||||||
<div className="grid gap-2 text-sm md:grid-cols-2">
|
<div className="grid gap-2 text-sm md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Outline Model</p>
|
<p className="text-muted-foreground">{t.podcasts.outlineModel}</p>
|
||||||
<p>
|
<p>
|
||||||
{episode.episode_profile?.outline_provider ?? '—'} /
|
{episode.episode_profile?.outline_provider ?? '—'} /
|
||||||
{' '}
|
{' '}
|
||||||
|
|
@ -267,7 +277,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Transcript Model</p>
|
<p className="text-muted-foreground">{t.podcasts.transcriptModel}</p>
|
||||||
<p>
|
<p>
|
||||||
{episode.episode_profile?.transcript_provider ?? '—'} /
|
{episode.episode_profile?.transcript_provider ?? '—'} /
|
||||||
{' '}
|
{' '}
|
||||||
|
|
@ -275,7 +285,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">Segments</p>
|
<p className="text-muted-foreground">{t.podcasts.segments}</p>
|
||||||
<p>{episode.episode_profile?.num_segments ?? '—'}</p>
|
<p>{episode.episode_profile?.num_segments ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,7 +297,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Speaker Profile</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.speakerProfile}</h4>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{episode.speaker_profile?.tts_provider ?? '—'} /{' '}
|
{episode.speaker_profile?.tts_provider ?? '—'} /{' '}
|
||||||
{episode.speaker_profile?.tts_model ?? '—'}
|
{episode.speaker_profile?.tts_model ?? '—'}
|
||||||
|
|
@ -298,12 +308,12 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
className="rounded-md border bg-muted/20 p-3 text-xs"
|
className="rounded-md border bg-muted/20 p-3 text-xs"
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-foreground">{speaker.name}</p>
|
<p className="font-semibold text-foreground">{speaker.name}</p>
|
||||||
<p className="text-muted-foreground">Voice ID: {speaker.voice_id}</p>
|
<p className="text-muted-foreground">{t.podcasts.voiceId}: {speaker.voice_id}</p>
|
||||||
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
||||||
<span className="font-semibold">Backstory:</span> {speaker.backstory}
|
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
|
||||||
<span className="font-semibold">Personality:</span> {speaker.personality}
|
<span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -311,7 +321,7 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
|
|
||||||
{episode.briefing ? (
|
{episode.briefing ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Briefing</h4>
|
<h4 className="text-sm font-semibold text-foreground">{t.podcasts.briefing}</h4>
|
||||||
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
<div className="rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
|
||||||
{episode.briefing}
|
{episode.briefing}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -328,17 +338,17 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
{outlineSegments.map((segment, index) => (
|
{outlineSegments.map((segment, index) => (
|
||||||
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-semibold text-foreground">{segment.name ?? `Segment ${index + 1}`}</p>
|
<p className="font-semibold text-foreground">{segment.name ?? `${t.podcasts.segment} ${index + 1}`}</p>
|
||||||
{segment.size ? (
|
{segment.size ? (
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge>
|
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">{segment.size}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? 'No description provided.'}</p>
|
<p className="text-muted-foreground whitespace-pre-wrap">{segment.description ?? t.podcasts.noDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">No outline available.</p>
|
<p className="text-xs text-muted-foreground">{t.podcasts.noOutline}</p>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -348,12 +358,12 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
{transcriptEntries.length > 0 ? (
|
{transcriptEntries.length > 0 ? (
|
||||||
transcriptEntries.map((entry, index) => (
|
transcriptEntries.map((entry, index) => (
|
||||||
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
<div key={index} className="rounded border bg-muted/20 p-3 text-xs space-y-1">
|
||||||
<p className="font-semibold text-foreground">{entry.speaker ?? 'Speaker'}</p>
|
<p className="font-semibold text-foreground">{entry.speaker ?? t.podcasts.speaker}</p>
|
||||||
<p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p>
|
<p className="text-muted-foreground whitespace-pre-wrap">{entry.dialogue ?? ''}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">No transcript available.</p>
|
<p className="text-xs text-muted-foreground">{t.podcasts.noTranscript}</p>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -365,20 +375,20 @@ export function EpisodeCard({ episode, onDelete, deleting }: EpisodeCardProps) {
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="text-destructive">
|
<Button variant="ghost" size="sm" className="text-destructive">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t.podcasts.delete}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete episode?</AlertDialogTitle>
|
<AlertDialogTitle>{t.podcasts.deleteEpisodeTitle}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will remove “{episode.name}” and its audio file permanently.
|
{t.podcasts.deleteEpisodeDesc.replace('{name}', episode.name)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? t.podcasts.deleting : t.podcasts.delete}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface EpisodeProfilesPanelProps {
|
interface EpisodeProfilesPanelProps {
|
||||||
episodeProfiles: EpisodeProfile[]
|
episodeProfiles: EpisodeProfile[]
|
||||||
|
|
@ -55,6 +56,7 @@ export function EpisodeProfilesPanel({
|
||||||
speakerProfiles,
|
speakerProfiles,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
}: EpisodeProfilesPanelProps) {
|
}: EpisodeProfilesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [editProfile, setEditProfile] = useState<EpisodeProfile | null>(null)
|
const [editProfile, setEditProfile] = useState<EpisodeProfile | null>(null)
|
||||||
|
|
||||||
|
|
@ -63,7 +65,7 @@ export function EpisodeProfilesPanel({
|
||||||
|
|
||||||
const sortedProfiles = useMemo(
|
const sortedProfiles = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...episodeProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
|
[...episodeProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),
|
||||||
[episodeProfiles]
|
[episodeProfiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -73,25 +75,25 @@ export function EpisodeProfilesPanel({
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Episode profiles</h2>
|
<h2 className="text-lg font-semibold">{t.podcasts.episodeProfilesTitle}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Define reusable generation settings for your shows.
|
{t.podcasts.episodeProfilesDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreateOpen(true)} disabled={disableCreate}>
|
<Button onClick={() => setCreateOpen(true)} disabled={disableCreate}>
|
||||||
Create profile
|
{t.podcasts.createProfile}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{disableCreate ? (
|
{disableCreate ? (
|
||||||
<p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900">
|
<p className="rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
Create a speaker profile before adding an episode profile.
|
{t.podcasts.createSpeakerFirst}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{sortedProfiles.length === 0 ? (
|
{sortedProfiles.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground">
|
||||||
No episode profiles yet. Create one to kickstart podcast generation.
|
{t.podcasts.noEpisodeProfiles}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -109,7 +111,7 @@ export function EpisodeProfilesPanel({
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-muted-foreground">
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
{profile.description || 'No description provided.'}
|
{profile.description || t.podcasts.noDescription}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -118,7 +120,7 @@ export function EpisodeProfilesPanel({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditProfile(profile)}
|
onClick={() => setEditProfile(profile)}
|
||||||
>
|
>
|
||||||
<Edit3 className="mr-2 h-4 w-4" /> Edit
|
<Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit}
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -142,32 +144,31 @@ export function EpisodeProfilesPanel({
|
||||||
disabled={duplicateProfile.isPending}
|
disabled={duplicateProfile.isPending}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
Duplicate
|
{t.podcasts.duplicate}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
{t.podcasts.delete}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete profile?</AlertDialogTitle>
|
<AlertDialogTitle>{t.podcasts.deleteProfileTitle}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will remove “{profile.name}”. Existing episodes keep their
|
{t.podcasts.deleteProfileDesc.replace('{name}', profile.name)}
|
||||||
data, but new ones will no longer use this configuration.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => deleteProfile.mutate(profile.id)}
|
onClick={() => deleteProfile.mutate(profile.id)}
|
||||||
disabled={deleteProfile.isPending}
|
disabled={deleteProfile.isPending}
|
||||||
>
|
>
|
||||||
{deleteProfile.isPending ? 'Deleting…' : 'Delete'}
|
{deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
@ -179,7 +180,7 @@ export function EpisodeProfilesPanel({
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Outline model
|
{t.podcasts.outlineModel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
{profile.outline_provider} / {profile.outline_model}
|
{profile.outline_provider} / {profile.outline_model}
|
||||||
|
|
@ -187,7 +188,7 @@ export function EpisodeProfilesPanel({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Transcript model
|
{t.podcasts.transcriptModel}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
{profile.transcript_provider} / {profile.transcript_model}
|
{profile.transcript_provider} / {profile.transcript_model}
|
||||||
|
|
@ -195,13 +196,13 @@ export function EpisodeProfilesPanel({
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Segments
|
{t.podcasts.segments}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground">{profile.num_segments}</p>
|
<p className="text-foreground">{profile.num_segments}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Speaker profile
|
{t.podcasts.speakerProfile}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
|
|
@ -218,7 +219,7 @@ export function EpisodeProfilesPanel({
|
||||||
{profile.default_briefing ? (
|
{profile.default_briefing ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Default briefing
|
{t.podcasts.defaultBriefingTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">
|
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">
|
||||||
{profile.default_briefing}
|
{profile.default_briefing}
|
||||||
|
|
|
||||||
|
|
@ -10,31 +10,33 @@ import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
|
import { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
|
||||||
const STATUS_ORDER: Array<{
|
const getSTATUS_ORDER = (t: TranslationKeys): Array<{
|
||||||
key: 'running' | 'completed' | 'failed' | 'pending'
|
key: 'running' | 'completed' | 'failed' | 'pending'
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
}> = [
|
}> => [
|
||||||
{
|
{
|
||||||
key: 'running',
|
key: 'running',
|
||||||
title: 'Currently Processing',
|
title: t.podcasts.statusRunningTitle,
|
||||||
description: 'Episodes that are actively generating assets.',
|
description: t.podcasts.statusRunningDesc,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pending',
|
key: 'pending',
|
||||||
title: 'Queued / Pending',
|
title: t.podcasts.statusPendingTitle,
|
||||||
description: 'Submitted episodes waiting to start processing.',
|
description: t.podcasts.statusPendingDesc,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'completed',
|
key: 'completed',
|
||||||
title: 'Completed Episodes',
|
title: t.podcasts.statusCompletedTitle,
|
||||||
description: 'Ready to review, download, or publish.',
|
description: t.podcasts.statusCompletedDesc,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'failed',
|
key: 'failed',
|
||||||
title: 'Failed Episodes',
|
title: t.podcasts.statusFailedTitle,
|
||||||
description: 'Episodes that encountered issues during generation.',
|
description: t.podcasts.statusFailedDesc,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -48,6 +50,7 @@ function SummaryBadge({ label, value }: { label: string; value: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EpisodesTab() {
|
export function EpisodesTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [showGenerateDialog, setShowGenerateDialog] = useState(false)
|
const [showGenerateDialog, setShowGenerateDialog] = useState(false)
|
||||||
const {
|
const {
|
||||||
episodes,
|
episodes,
|
||||||
|
|
@ -75,14 +78,14 @@ export function EpisodesTab() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-semibold">Episodes overview</h2>
|
<h2 className="text-xl font-semibold">{t.podcasts.overviewTitle}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Monitor podcast generation jobs and review the final artefacts.
|
{t.podcasts.overviewDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={() => setShowGenerateDialog(true)}>
|
<Button onClick={() => setShowGenerateDialog(true)}>
|
||||||
Generate Podcast
|
{t.podcasts.generateBtn}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -95,25 +98,25 @@ export function EpisodesTab() {
|
||||||
) : (
|
) : (
|
||||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Refresh
|
{t.common.refresh}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<SummaryBadge label="Total" value={statusCounts.total} />
|
<SummaryBadge label={t.podcasts.total} value={statusCounts.total} />
|
||||||
<SummaryBadge label="Processing" value={statusCounts.running} />
|
<SummaryBadge label={t.podcasts.processingLabel} value={statusCounts.running} />
|
||||||
<SummaryBadge label="Completed" value={statusCounts.completed} />
|
<SummaryBadge label={t.podcasts.completedLabel} value={statusCounts.completed} />
|
||||||
<SummaryBadge label="Failed" value={statusCounts.failed} />
|
<SummaryBadge label={t.podcasts.failedLabel} value={statusCounts.failed} />
|
||||||
<SummaryBadge label="Pending" value={statusCounts.pending} />
|
<SummaryBadge label={t.podcasts.pendingLabel} value={statusCounts.pending} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Failed to load episodes</AlertTitle>
|
<AlertTitle>{t.podcasts.loadErrorTitle}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
We could not fetch the latest podcast episodes. Try again shortly.
|
{t.podcasts.loadErrorDesc}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -121,20 +124,19 @@ export function EpisodesTab() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading episodes…
|
{t.podcasts.loadingEpisodes}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{emptyState ? (
|
{emptyState ? (
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-10 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No podcast episodes yet. Generate your first one from the notebook or source
|
{t.podcasts.noEpisodesYet}
|
||||||
chat interfaces.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{STATUS_ORDER.map(({ key, title, description }) => {
|
{getSTATUS_ORDER(t).map(({ key, title, description }) => {
|
||||||
const data = statusGroups[key]
|
const data = statusGroups[key]
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { BuildContextRequest, NoteResponse, SourceListResponse } from '@/lib/typ
|
||||||
import { PodcastGenerationRequest } from '@/lib/types/podcasts'
|
import { PodcastGenerationRequest } from '@/lib/types/podcasts'
|
||||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||||
import { useToast } from '@/lib/hooks/use-toast'
|
import { useToast } from '@/lib/hooks/use-toast'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -30,10 +31,11 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
|
||||||
const SOURCE_MODES = [
|
const getSourceModes = (t: TranslationKeys) => [
|
||||||
{ value: 'insights', label: 'Summary' },
|
{ value: 'insights', label: t.podcasts.summary },
|
||||||
{ value: 'full', label: 'Full content' },
|
{ value: 'full', label: t.podcasts.fullContent },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type SourceMode = 'off' | 'insights' | 'full'
|
type SourceMode = 'off' | 'insights' | 'full'
|
||||||
|
|
@ -74,6 +76,7 @@ interface GeneratePodcastDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
|
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
|
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
|
||||||
|
|
@ -415,22 +418,22 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
const response = await chatApi.buildContext(task.payload)
|
const response = await chatApi.buildContext(task.payload)
|
||||||
const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId
|
const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId
|
||||||
const contextString = JSON.stringify(response.context, null, 2)
|
const contextString = JSON.stringify(response.context, null, 2)
|
||||||
const snippet = `Notebook: ${notebookName}\n${contextString}`
|
const snippet = `${t.common.notebookLabel.replace('{name}', notebookName)}\n${contextString}`
|
||||||
parts.push(snippet)
|
parts.push(snippet)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to build context for notebook', task.notebookId, error)
|
console.error('Failed to build context for notebook', task.notebookId, error)
|
||||||
throw new Error('Failed to build context. Please review your selections.')
|
throw new Error(t.podcasts.buildContextFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join('\n\n')
|
return parts.join('\n\n')
|
||||||
}, [notebooks, selections])
|
}, [notebooks, selections, t])
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!selectedEpisodeProfile) {
|
if (!selectedEpisodeProfile) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Episode profile required',
|
title: t.podcasts.profileRequired,
|
||||||
description: 'Select an episode profile before generating a podcast.',
|
description: t.podcasts.profileRequiredDesc,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -438,8 +441,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
|
|
||||||
if (!episodeName.trim()) {
|
if (!episodeName.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Episode name required',
|
title: t.podcasts.nameRequired,
|
||||||
description: 'Provide a name for the episode.',
|
description: t.podcasts.nameRequiredDesc,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -450,8 +453,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
const content = await buildContentFromSelections()
|
const content = await buildContentFromSelections()
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Add context',
|
title: t.podcasts.addContext,
|
||||||
description: 'Select at least one source or note to include in the episode.',
|
description: t.podcasts.addContextDesc,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -467,6 +470,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
|
|
||||||
await generatePodcast.mutateAsync(payload)
|
await generatePodcast.mutateAsync(payload)
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t.common.success,
|
||||||
|
description: t.podcasts.podcastTaskStarted,
|
||||||
|
})
|
||||||
|
|
||||||
// Delay closing dialog slightly to ensure refetch completes
|
// Delay closing dialog slightly to ensure refetch completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
|
@ -475,8 +483,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate podcast', error)
|
console.error('Failed to generate podcast', error)
|
||||||
toast({
|
toast({
|
||||||
title: 'Podcast generation failed',
|
title: t.podcasts.generationFailed,
|
||||||
description: error instanceof Error ? error.message : 'Please try again later.',
|
description: error instanceof Error ? error.message : t.common.refreshPage,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -491,6 +499,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
resetState,
|
resetState,
|
||||||
selectedEpisodeProfile,
|
selectedEpisodeProfile,
|
||||||
toast,
|
toast,
|
||||||
|
t,
|
||||||
])
|
])
|
||||||
|
|
||||||
const isSubmitting = generatePodcast.isPending || isBuildingContext
|
const isSubmitting = generatePodcast.isPending || isBuildingContext
|
||||||
|
|
@ -504,9 +513,9 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden">
|
<DialogContent className="w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Generate Podcast Episode</DialogTitle>
|
<DialogTitle>{t.podcasts.generateEpisode}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select the content to include and configure the episode details before generating a new podcast episode.
|
{t.podcasts.generateEpisodeDesc}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -515,25 +524,27 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Content
|
{t.podcasts.content}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Pick notebooks, sources, and notes to include in this episode.
|
{t.podcasts.contentDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{selectedNotebookSummaries.reduce(
|
{t.podcasts.itemsSelected.replace(
|
||||||
(acc, summary) => acc + summary.sources + summary.notes,
|
'{count}',
|
||||||
0
|
selectedNotebookSummaries.reduce(
|
||||||
)}{' '}
|
(acc, summary) => acc + summary.sources + summary.notes,
|
||||||
items selected
|
0
|
||||||
|
).toString()
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(tokenCount > 0 || charCount > 0) && (
|
{(tokenCount > 0 || charCount > 0) && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{tokenCount > 0 && `${formatNumber(tokenCount)} tokens`}
|
{tokenCount > 0 && t.podcasts.tokens.replace('{count}', formatNumber(tokenCount))}
|
||||||
{tokenCount > 0 && charCount > 0 && ' / '}
|
{tokenCount > 0 && charCount > 0 && ' / '}
|
||||||
{charCount > 0 && `${formatNumber(charCount)} chars`}
|
{charCount > 0 && t.podcasts.chars.replace('{count}', formatNumber(charCount))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -542,11 +553,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
<div className="rounded-lg border bg-muted/30">
|
<div className="rounded-lg border bg-muted/30">
|
||||||
{notebooksQuery.isLoading ? (
|
{notebooksQuery.isLoading ? (
|
||||||
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading notebooks
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t.podcasts.loadingNotebooks}
|
||||||
</div>
|
</div>
|
||||||
) : notebooks.length === 0 ? (
|
) : notebooks.length === 0 ? (
|
||||||
<div className="p-6 text-sm text-muted-foreground">
|
<div className="p-6 text-sm text-muted-foreground">
|
||||||
No notebooks found. Create a notebook and add content before generating a podcast.
|
{t.podcasts.noNotebooksFoundInPodcasts}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[60vh]">
|
<ScrollArea className="h-[60vh]">
|
||||||
|
|
@ -572,6 +583,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
<AccordionItem key={notebook.id} value={notebook.id}>
|
<AccordionItem key={notebook.id} value={notebook.id}>
|
||||||
<div className="flex items-start gap-3 px-4 pt-3">
|
<div className="flex items-start gap-3 px-4 pt-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={`notebook-toggle-${notebook.id}`}
|
||||||
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
|
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
handleNotebookToggle(notebook.id, checked)
|
handleNotebookToggle(notebook.id, checked)
|
||||||
|
|
@ -587,21 +599,24 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
|
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
|
||||||
<div className="flex w-full items-center justify-between gap-3">
|
<Label
|
||||||
|
htmlFor={`notebook-toggle-${notebook.id}`}
|
||||||
|
className="flex w-full items-center justify-between gap-3 pointer-events-none"
|
||||||
|
>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-sm text-foreground">
|
<p className="font-medium text-sm text-foreground">
|
||||||
{notebook.name}
|
{notebook.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{summary.sources + summary.notes > 0
|
{summary.sources + summary.notes > 0
|
||||||
? `${summary.sources} sources, ${summary.notes} notes`
|
? `${summary.sources} ${t.podcasts.sources}, ${summary.notes} ${t.podcasts.notes}`
|
||||||
: 'No content selected'}
|
: t.podcasts.noContentSelected}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{sources.length} sources · {notes.length} notes
|
{sources.length} {t.podcasts.sources} · {notes.length} {t.podcasts.notes}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</Label>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
</div>
|
</div>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
|
|
@ -609,7 +624,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Sources
|
{t.podcasts.sources}
|
||||||
</h4>
|
</h4>
|
||||||
{sourcesQueries[index]?.isFetching && (
|
{sourcesQueries[index]?.isFetching && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
|
@ -617,7 +632,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
</div>
|
</div>
|
||||||
{sources.length === 0 ? (
|
{sources.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No sources available in this notebook.
|
{t.podcasts.noSources}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -629,6 +644,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={`source-selection-${source.id}`}
|
||||||
checked={mode !== 'off'}
|
checked={mode !== 'off'}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
handleSourceModeChange(
|
handleSourceModeChange(
|
||||||
|
|
@ -638,16 +654,19 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<Label
|
||||||
|
htmlFor={`source-selection-${source.id}`}
|
||||||
|
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{source.title || 'Untitled source'}
|
{source.title || t.podcasts.untitledSource}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{source.asset?.url ? 'Link' : 'File'}</span>
|
<span>{source.asset?.url ? t.podcasts.link : t.podcasts.file}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{source.embedded ? 'Embedded' : 'Not embedded'}</span>
|
<span>{source.embedded ? t.podcasts.embedded : t.podcasts.notEmbedded}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mode === 'off' ? 'off' : mode}
|
value={mode === 'off' ? 'off' : mode}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -660,10 +679,10 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
disabled={mode === 'off'}
|
disabled={mode === 'off'}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[140px]">
|
||||||
<SelectValue placeholder="Select mode" />
|
<SelectValue placeholder={t.podcasts.selectMode} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SOURCE_MODES.map((option) => (
|
{getSourceModes(t).map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
|
|
@ -688,11 +707,11 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Notes
|
{t.podcasts.notes}
|
||||||
</h4>
|
</h4>
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No notes available in this notebook.
|
{t.podcasts.noNotes}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -704,6 +723,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
id={`note-selection-${note.id}`}
|
||||||
checked={mode !== 'off'}
|
checked={mode !== 'off'}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
handleNoteToggle(
|
handleNoteToggle(
|
||||||
|
|
@ -713,14 +733,20 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col">
|
<Label
|
||||||
|
htmlFor={`note-selection-${note.id}`}
|
||||||
|
className="flex flex-1 flex-col cursor-pointer"
|
||||||
|
>
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{note.title || 'Untitled note'}
|
{note.title || t.podcasts.untitledNote}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Updated {new Date(note.updated).toLocaleString()}
|
{t.common.updated}{' '}
|
||||||
|
{new Date(note.updated).toLocaleString(
|
||||||
|
language.startsWith('zh') ? language : 'en-US'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -741,88 +767,89 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Episode Settings
|
{t.podcasts.episodeSettings}
|
||||||
</h3>
|
</h3>
|
||||||
{episodeProfilesQuery.isLoading ? (
|
{episodeProfilesQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading episode profiles
|
<Loader2 className="h-4 w-4 animate-spin" /> {t.podcasts.loadingProfiles}
|
||||||
</div>
|
</div>
|
||||||
) : episodeProfiles.length === 0 ? (
|
) : episodeProfiles.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||||
No episode profiles found. Create an episode profile before generating a podcast.
|
{t.podcasts.noProfilesFound}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="episode_profile">Episode profile</Label>
|
<Label htmlFor="episode_profile">{t.podcasts.episodeProfile}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={episodeProfileId}
|
value={episodeProfileId}
|
||||||
onValueChange={setEpisodeProfileId}
|
onValueChange={setEpisodeProfileId}
|
||||||
disabled={episodeProfiles.length === 0}
|
disabled={episodeProfiles.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="episode_profile">
|
<SelectTrigger id="episode_profile">
|
||||||
<SelectValue placeholder="Select an episode profile" />
|
<SelectValue placeholder={t.podcasts.episodeProfilePlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{episodeProfiles.map((profile) => (
|
{episodeProfiles.map((profile) => (
|
||||||
<SelectItem key={profile.id} value={profile.id}>
|
<SelectItem key={profile.id} value={profile.id}>
|
||||||
{profile.name}
|
{t.podcasts.podcastProfiles[profile.name] ?? profile.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{selectedEpisodeProfile && (
|
{selectedEpisodeProfile && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Uses speaker profile <strong>{selectedEpisodeProfile.speaker_config}</strong>
|
{t.podcasts.usesSpeakerProfile}{' '}
|
||||||
|
<strong>{selectedEpisodeProfile.speaker_config}</strong>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="episode_name">Episode name</Label>
|
<Label htmlFor="episode_name">{t.podcasts.episodeName}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="episode_name"
|
id="episode_name"
|
||||||
|
name="episode_name"
|
||||||
value={episodeName}
|
value={episodeName}
|
||||||
onChange={(event) => setEpisodeName(event.target.value)}
|
onChange={(event) => setEpisodeName(event.target.value)}
|
||||||
placeholder="e.g., AI and the Future of Work"
|
placeholder={t.podcasts.episodeNamePlaceholder}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="instructions">Additional instructions</Label>
|
<Label htmlFor="instructions">{t.podcasts.additionalInstructions}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="instructions"
|
id="instructions"
|
||||||
|
name="instructions"
|
||||||
|
placeholder={t.podcasts.instructionsPlaceholder}
|
||||||
value={instructions}
|
value={instructions}
|
||||||
onChange={(event) => setInstructions(event.target.value)}
|
onChange={(event) => setInstructions(event.target.value)}
|
||||||
placeholder="Any supplemental guidance to append to the episode briefing..."
|
className="min-h-[100px] text-xs"
|
||||||
rows={6}
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
These instructions will be appended to the episode profile's default briefing.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting || episodeProfiles.length === 0}
|
disabled={isSubmitting}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
<>
|
{isSubmitting ? t.podcasts.generating : t.podcasts.generate}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating episode...
|
</Button>
|
||||||
</>
|
<Button
|
||||||
) : (
|
variant="outline"
|
||||||
'Generate Podcast'
|
onClick={() => onOpenChange(false)}
|
||||||
)}
|
disabled={isSubmitting}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The episode will appear in the Episodes list once generation starts. Refresh the list to monitor progress.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
interface SpeakerProfilesPanelProps {
|
interface SpeakerProfilesPanelProps {
|
||||||
speakerProfiles: SpeakerProfile[]
|
speakerProfiles: SpeakerProfile[]
|
||||||
|
|
@ -48,6 +49,7 @@ export function SpeakerProfilesPanel({
|
||||||
modelOptions,
|
modelOptions,
|
||||||
usage,
|
usage,
|
||||||
}: SpeakerProfilesPanelProps) {
|
}: SpeakerProfilesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [editProfile, setEditProfile] = useState<SpeakerProfile | null>(null)
|
const [editProfile, setEditProfile] = useState<SpeakerProfile | null>(null)
|
||||||
|
|
||||||
|
|
@ -64,17 +66,17 @@ export function SpeakerProfilesPanel({
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Speaker profiles</h2>
|
<h2 className="text-lg font-semibold">{t.podcasts.speakerProfilesTitle}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configure voices and personalities for generated episodes.
|
{t.podcasts.speakerProfilesDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreateOpen(true)}>Create speaker</Button>
|
<Button onClick={() => setCreateOpen(true)}>{t.podcasts.createSpeaker}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedProfiles.length === 0 ? (
|
{sortedProfiles.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
|
||||||
No speaker profiles yet. Create one to make episode templates available.
|
{t.podcasts.noSpeakerProfiles}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -91,7 +93,7 @@ export function SpeakerProfilesPanel({
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-sm text-muted-foreground">
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
{profile.description || 'No description provided.'}
|
{profile.description || t.podcasts.noDescription}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
|
|
@ -104,8 +106,8 @@ export function SpeakerProfilesPanel({
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{usageCount > 0
|
{usageCount > 0
|
||||||
? `Used by ${usageCount} episode${usageCount === 1 ? '' : 's'}`
|
? (usageCount === 1 ? t.podcasts.usedByCount_one : t.podcasts.usedByCount_other.replace('{count}', usageCount.toString()))
|
||||||
: 'Unused'}
|
: t.podcasts.unused}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -125,14 +127,14 @@ export function SpeakerProfilesPanel({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Voice ID: {speaker.voice_id}
|
{t.podcasts.voiceId}: {speaker.voice_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
|
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||||
<span className="font-semibold">Backstory:</span> {speaker.backstory}
|
<span className="font-semibold">{t.podcasts.backstory}:</span> {speaker.backstory}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
|
<p className="mt-2 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||||
<span className="font-semibold">Personality:</span> {speaker.personality}
|
<span className="font-semibold">{t.podcasts.personality}:</span> {speaker.personality}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -144,7 +146,7 @@ export function SpeakerProfilesPanel({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setEditProfile(profile)}
|
onClick={() => setEditProfile(profile)}
|
||||||
>
|
>
|
||||||
<Edit3 className="mr-2 h-4 w-4" /> Edit
|
<Edit3 className="mr-2 h-4 w-4" /> {t.podcasts.edit}
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -168,7 +170,7 @@ export function SpeakerProfilesPanel({
|
||||||
disabled={duplicateProfile.isPending}
|
disabled={duplicateProfile.isPending}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
Duplicate
|
{t.podcasts.duplicate}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
|
|
@ -177,30 +179,30 @@ export function SpeakerProfilesPanel({
|
||||||
disabled={deleteDisabled}
|
disabled={deleteDisabled}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
{t.podcasts.delete}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete speaker profile?</AlertDialogTitle>
|
<AlertDialogTitle>{t.podcasts.deleteSpeakerProfileTitle}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Deleting “{profile.name}” cannot be undone.
|
{t.podcasts.deleteSpeakerProfileDesc.replace('{name}', profile.name)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{deleteDisabled ? (
|
{deleteDisabled ? (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Remove this speaker from episode profiles before deleting it.
|
{t.podcasts.deleteSpeakerDisabledHint}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => deleteProfile.mutate(profile.id)}
|
onClick={() => deleteProfile.mutate(profile.id)}
|
||||||
disabled={deleteDisabled || deleteProfile.isPending}
|
disabled={deleteDisabled || deleteProfile.isPending}
|
||||||
>
|
>
|
||||||
{deleteProfile.isPending ? 'Deleting…' : 'Delete'}
|
{deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts
|
||||||
import { useModels } from '@/lib/hooks/use-models'
|
import { useModels } from '@/lib/hooks/use-models'
|
||||||
import { Model } from '@/lib/types/models'
|
import { Model } from '@/lib/types/models'
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
|
|
||||||
function modelsByProvider(models: Model[], type: Model['type']) {
|
function modelsByProvider(models: Model[], type: Model['type']) {
|
||||||
return models
|
return models
|
||||||
|
|
@ -24,6 +25,7 @@ function modelsByProvider(models: Model[], type: Model['type']) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplatesTab() {
|
export function TemplatesTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
episodeProfiles,
|
episodeProfiles,
|
||||||
isLoading: loadingEpisodeProfiles,
|
isLoading: loadingEpisodeProfiles,
|
||||||
|
|
@ -58,9 +60,9 @@ export function TemplatesTab() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-semibold">Templates workspace</h2>
|
<h2 className="text-xl font-semibold">{t.podcasts.templatesWorkspaceTitle}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Build reusable episode and speaker configurations for fast podcast production.
|
{t.podcasts.templatesWorkspaceDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -72,44 +74,42 @@ export function TemplatesTab() {
|
||||||
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
|
<AccordionTrigger className="gap-2 py-4 text-left text-sm font-semibold">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lightbulb className="h-4 w-4 text-primary" />
|
<Lightbulb className="h-4 w-4 text-primary" />
|
||||||
How templates power podcast generation
|
{t.podcasts.howTemplatesPowerTitle}
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-sm text-muted-foreground">
|
<AccordionContent className="text-sm text-muted-foreground">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-muted-foreground/90">
|
<p className="text-muted-foreground/90">
|
||||||
Templates split the podcast workflow into two reusable building blocks. Mix and match
|
{t.podcasts.howTemplatesPowerDesc}
|
||||||
them whenever you generate a new episode.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-foreground">Episode profiles set the format</h4>
|
<h4 className="font-medium text-foreground">{t.podcasts.episodeProfilesSetFormat}</h4>
|
||||||
<ul className="list-disc space-y-1 pl-5">
|
<ul className="list-disc space-y-1 pl-5">
|
||||||
<li>Outline the number of segments and how the story flows</li>
|
<li>{t.podcasts.episodeProfilesList1}</li>
|
||||||
<li>Pick the language models used for briefing, outlining, and script writing</li>
|
<li>{t.podcasts.episodeProfilesList2}</li>
|
||||||
<li>Store default briefings so every episode starts with a consistent tone</li>
|
<li>{t.podcasts.episodeProfilesList3}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-foreground">Speaker profiles bring voices to life</h4>
|
<h4 className="font-medium text-foreground">{t.podcasts.speakerProfilesBringVoices}</h4>
|
||||||
<ul className="list-disc space-y-1 pl-5">
|
<ul className="list-disc space-y-1 pl-5">
|
||||||
<li>Choose the text-to-speech provider and model</li>
|
<li>{t.podcasts.speakerProfilesList1}</li>
|
||||||
<li>Capture personality, backstory, and pronunciation notes per speaker</li>
|
<li>{t.podcasts.speakerProfilesList2}</li>
|
||||||
<li>Reuse the same host or guest voices across different episode formats</li>
|
<li>{t.podcasts.speakerProfilesList3}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-foreground">Recommended workflow</h4>
|
<h4 className="font-medium text-foreground">{t.podcasts.recommendedWorkflow}</h4>
|
||||||
<ol className="list-decimal space-y-1 pl-5">
|
<ol className="list-decimal space-y-1 pl-5">
|
||||||
<li>Create speaker profiles for each voice you need</li>
|
<li>{t.podcasts.workflowStep1}</li>
|
||||||
<li>Build episode profiles that reference those speakers by name</li>
|
<li>{t.podcasts.workflowStep2}</li>
|
||||||
<li>Generate podcasts by selecting the episode profile that fits the story</li>
|
<li>{t.podcasts.workflowStep3}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
Episode profiles reference speaker profiles by name, so starting with speakers avoids
|
{t.podcasts.workflowHint}
|
||||||
missing voice assignments later.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,9 +120,9 @@ export function TemplatesTab() {
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Failed to load templates data</AlertTitle>
|
<AlertTitle>{t.podcasts.failedToLoadTemplates}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Ensure the API is running and try again. Some sections may be incomplete.
|
{t.podcasts.failedToLoadTemplatesDesc}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -130,7 +130,7 @@ export function TemplatesTab() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Loading templates…
|
{t.podcasts.loadingTemplates}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
useCreateEpisodeProfile,
|
useCreateEpisodeProfile,
|
||||||
useUpdateEpisodeProfile,
|
useUpdateEpisodeProfile,
|
||||||
} from '@/lib/hooks/use-podcasts'
|
} from '@/lib/hooks/use-podcasts'
|
||||||
|
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -30,23 +31,24 @@ import {
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { TranslationKeys } from '@/lib/locales'
|
||||||
|
|
||||||
const episodeProfileSchema = z.object({
|
const episodeProfileSchema = (t: TranslationKeys) => z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
speaker_config: z.string().min(1, 'Speaker profile is required'),
|
speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),
|
||||||
outline_provider: z.string().min(1, 'Outline provider is required'),
|
outline_provider: z.string().min(1, t.podcasts.outlineProviderRequired || 'Outline provider is required'),
|
||||||
outline_model: z.string().min(1, 'Outline model is required'),
|
outline_model: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),
|
||||||
transcript_provider: z.string().min(1, 'Transcript provider is required'),
|
transcript_provider: z.string().min(1, t.podcasts.transcriptProviderRequired || 'Transcript provider is required'),
|
||||||
transcript_model: z.string().min(1, 'Transcript model is required'),
|
transcript_model: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),
|
||||||
default_briefing: z.string().min(1, 'Default briefing is required'),
|
default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),
|
||||||
num_segments: z.number()
|
num_segments: z.number()
|
||||||
.int('Must be an integer')
|
.int(t.podcasts.segmentsInteger || 'Must be an integer')
|
||||||
.min(3, 'At least 3 segments')
|
.min(3, t.podcasts.segmentsMin || 'At least 3 segments')
|
||||||
.max(20, 'Maximum 20 segments'),
|
.max(20, t.podcasts.segmentsMax || 'Maximum 20 segments'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type EpisodeProfileFormValues = z.infer<typeof episodeProfileSchema>
|
export type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>>
|
||||||
|
|
||||||
interface EpisodeProfileFormDialogProps {
|
interface EpisodeProfileFormDialogProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
|
|
@ -65,6 +67,7 @@ export function EpisodeProfileFormDialog({
|
||||||
modelOptions,
|
modelOptions,
|
||||||
initialData,
|
initialData,
|
||||||
}: EpisodeProfileFormDialogProps) {
|
}: EpisodeProfileFormDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const createProfile = useCreateEpisodeProfile()
|
const createProfile = useCreateEpisodeProfile()
|
||||||
const updateProfile = useUpdateEpisodeProfile()
|
const updateProfile = useUpdateEpisodeProfile()
|
||||||
|
|
||||||
|
|
@ -111,7 +114,7 @@ export function EpisodeProfileFormDialog({
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<EpisodeProfileFormValues>({
|
} = useForm<EpisodeProfileFormValues>({
|
||||||
resolver: zodResolver(episodeProfileSchema),
|
resolver: zodResolver(episodeProfileSchema(t)),
|
||||||
defaultValues: getDefaults(),
|
defaultValues: getDefaults(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -185,29 +188,27 @@ export function EpisodeProfileFormDialog({
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isEdit ? 'Edit Episode Profile' : 'Create Episode Profile'}
|
{isEdit ? t.podcasts.editEpisodeProfile : t.podcasts.createEpisodeProfile}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Define how episodes should be generated and which speaker configuration
|
{t.podcasts.episodeProfileFormDesc}
|
||||||
they use by default.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{speakerProfiles.length === 0 ? (
|
{speakerProfiles.length === 0 ? (
|
||||||
<Alert className="bg-amber-50 text-amber-900">
|
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
|
||||||
<AlertTitle>No speaker profiles available</AlertTitle>
|
<AlertTitle>{t.podcasts.noSpeakerProfilesAvailable}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Create a speaker profile before configuring an episode profile.
|
{t.podcasts.noSpeakerProfilesDesc}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{providers.length === 0 ? (
|
{providers.length === 0 ? (
|
||||||
<Alert className="bg-amber-50 text-amber-900">
|
<Alert className="bg-amber-50 text-amber-900 border-amber-200">
|
||||||
<AlertTitle>No language models available</AlertTitle>
|
<AlertTitle>{t.podcasts.noLanguageModelsAvailable}</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Add language models in the Models section to configure outline and transcript
|
{t.podcasts.noLanguageModelsDesc}
|
||||||
generation.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -215,21 +216,22 @@ export function EpisodeProfileFormDialog({
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 pt-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Profile name *</Label>
|
<Label htmlFor="name">{t.podcasts.profileName} *</Label>
|
||||||
<Input id="name" placeholder="Tech discussion" {...register('name')} />
|
<Input id="name" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} />
|
||||||
{errors.name ? (
|
{errors.name ? (
|
||||||
<p className="text-xs text-red-600">{errors.name.message}</p>
|
<p className="text-xs text-red-600">{errors.name.message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="num_segments">Segments *</Label>
|
<Label htmlFor="num_segments">{t.podcasts.segments} *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="num_segments"
|
id="num_segments"
|
||||||
type="number"
|
type="number"
|
||||||
min={3}
|
min={3}
|
||||||
max={20}
|
max={20}
|
||||||
{...register('num_segments', { valueAsNumber: true })}
|
{...register('num_segments', { valueAsNumber: true })}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
{errors.num_segments ? (
|
{errors.num_segments ? (
|
||||||
<p className="text-xs text-red-600">{errors.num_segments.message}</p>
|
<p className="text-xs text-red-600">{errors.num_segments.message}</p>
|
||||||
|
|
@ -237,12 +239,13 @@ export function EpisodeProfileFormDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
<div className="md:col-span-2 space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">{t.common.description}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Short summary of when to use this profile"
|
placeholder={t.podcasts.descriptionPlaceholder}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,7 +253,7 @@ export function EpisodeProfileFormDialog({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Speaker configuration
|
{t.podcasts.speakerConfig}
|
||||||
</h3>
|
</h3>
|
||||||
<Separator className="mt-2" />
|
<Separator className="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -259,12 +262,12 @@ export function EpisodeProfileFormDialog({
|
||||||
name="speaker_config"
|
name="speaker_config"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Speaker profile *</Label>
|
<Label htmlFor="speaker_config">{t.podcasts.speakerProfile} *</Label>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="speaker_config">
|
||||||
<SelectValue placeholder="Select a speaker profile" />
|
<SelectValue placeholder={t.podcasts.selectSpeakerProfile} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent title={t.podcasts.speakerProfile}>
|
||||||
{speakerProfiles.map((profile) => (
|
{speakerProfiles.map((profile) => (
|
||||||
<SelectItem key={profile.id} value={profile.name}>
|
<SelectItem key={profile.id} value={profile.name}>
|
||||||
{profile.name}
|
{profile.name}
|
||||||
|
|
@ -285,7 +288,7 @@ export function EpisodeProfileFormDialog({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Outline generation
|
{t.podcasts.outlineGeneration}
|
||||||
</h3>
|
</h3>
|
||||||
<Separator className="mt-2" />
|
<Separator className="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -295,12 +298,12 @@ export function EpisodeProfileFormDialog({
|
||||||
name="outline_provider"
|
name="outline_provider"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Provider *</Label>
|
<Label htmlFor="outline_provider">{t.models.provider} *</Label>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="outline_provider">
|
||||||
<SelectValue placeholder="Select provider" />
|
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent title={t.models.provider}>
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
<SelectItem key={provider} value={provider}>
|
<SelectItem key={provider} value={provider}>
|
||||||
<span className="capitalize">{provider}</span>
|
<span className="capitalize">{provider}</span>
|
||||||
|
|
@ -322,12 +325,12 @@ export function EpisodeProfileFormDialog({
|
||||||
name="outline_model"
|
name="outline_model"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Model *</Label>
|
<Label htmlFor="outline_model">{t.common.model} *</Label>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="outline_model">
|
||||||
<SelectValue placeholder="Select model" />
|
<SelectValue placeholder={t.models.selectModelPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent title={t.common.model}>
|
||||||
{availableOutlineModels.map((model) => (
|
{availableOutlineModels.map((model) => (
|
||||||
<SelectItem key={model} value={model}>
|
<SelectItem key={model} value={model}>
|
||||||
{model}
|
{model}
|
||||||
|
|
@ -349,7 +352,7 @@ export function EpisodeProfileFormDialog({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Transcript generation
|
{t.podcasts.transcriptGeneration}
|
||||||
</h3>
|
</h3>
|
||||||
<Separator className="mt-2" />
|
<Separator className="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -359,12 +362,12 @@ export function EpisodeProfileFormDialog({
|
||||||
name="transcript_provider"
|
name="transcript_provider"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Provider *</Label>
|
<Label htmlFor="transcript_provider">{t.models.provider} *</Label>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="transcript_provider">
|
||||||
<SelectValue placeholder="Select provider" />
|
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent title={t.models.provider}>
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
<SelectItem key={provider} value={provider}>
|
<SelectItem key={provider} value={provider}>
|
||||||
<span className="capitalize">{provider}</span>
|
<span className="capitalize">{provider}</span>
|
||||||
|
|
@ -386,12 +389,12 @@ export function EpisodeProfileFormDialog({
|
||||||
name="transcript_model"
|
name="transcript_model"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Model *</Label>
|
<Label htmlFor="transcript_model">{t.common.model} *</Label>
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger id="transcript_model">
|
||||||
<SelectValue placeholder="Select model" />
|
<SelectValue placeholder={t.models.selectModelPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent title={t.common.model}>
|
||||||
{availableTranscriptModels.map((model) => (
|
{availableTranscriptModels.map((model) => (
|
||||||
<SelectItem key={model} value={model}>
|
<SelectItem key={model} value={model}>
|
||||||
{model}
|
{model}
|
||||||
|
|
@ -411,11 +414,11 @@ export function EpisodeProfileFormDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="default_briefing">Default briefing *</Label>
|
<Label htmlFor="default_briefing">{t.podcasts.defaultBriefingTitle} *</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="default_briefing"
|
id="default_briefing"
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="Outline the structure, tone, and goals for this episode format"
|
placeholder={t.podcasts.defaultBriefingPlaceholder}
|
||||||
{...register('default_briefing')}
|
{...register('default_briefing')}
|
||||||
/>
|
/>
|
||||||
{errors.default_briefing ? (
|
{errors.default_briefing ? (
|
||||||
|
|
@ -431,16 +434,14 @@ export function EpisodeProfileFormDialog({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={disableSubmit}>
|
<Button type="submit" disabled={disableSubmit}>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? isEdit
|
? t.common.saving
|
||||||
? 'Saving…'
|
|
||||||
: 'Creating…'
|
|
||||||
: isEdit
|
: isEdit
|
||||||
? 'Save changes'
|
? t.common.saveChanges
|
||||||
: 'Create profile'}
|
: t.podcasts.createProfile}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue