[
  {
    "path": ".dockerignore",
    "content": "# Git\n.git\n.gitignore\n\n# Python\n__pycache__\n*.pyc\n*.pyo\n*.pyd\n.venv\nvenv\nENV\nenv\n.pytest_cache\n.mypy_cache\n.ruff_cache\n\n# Frontend\nfrontend/node_modules\nfrontend/.next\nfrontend/dist\nfrontend/out\nfrontend/.env*\nfrontend/*.log\n\n# Project data\n.antigravity\n.gemini\ntmp\ndata\nmydata\nnotebook_data\nsurreal_data\nsurreal-data\nsurreal_single_data\n*.db\n*.log\ndocker.env\n.env\ndocker-compose*\n\n# Documentation & CI (not needed in image)\ndocs\n.github\n\n# IDE and OS files\n.vscode\n.idea\n*.swp\n*.swo\n*~\n.DS_Store"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐛 Bug Report\ndescription: Report a bug or unexpected behavior (app is running but misbehaving)\ntitle: \"[Bug]: \"\nlabels: [\"bug\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for reporting a bug! Please fill out the information below to help us understand and fix the issue.\n\n        **Note**: If you're having installation or setup issues, please use the \"Installation Issue\" template instead.\n\n  - type: textarea\n    id: what-happened\n    attributes:\n      label: What did you do when it broke?\n      description: Describe the steps you took that led to the bug\n      placeholder: |\n        1. I went to the Notebooks page\n        2. I clicked on \"Create New Notebook\"\n        3. I filled in the form and clicked \"Save\"\n        4. Then the error occurred...\n    validations:\n      required: true\n\n  - type: textarea\n    id: how-broke\n    attributes:\n      label: How did it break?\n      description: What happened that was unexpected? What did you expect to happen instead?\n      placeholder: |\n        Expected: The notebook should be created and I should see it in the list\n        Actual: I got an error message saying \"Failed to create notebook\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: logs-screenshots\n    attributes:\n      label: Logs or Screenshots\n      description: |\n        Please provide any error messages, logs, or screenshots that might help us understand the issue.\n\n        **How to get logs:**\n        - Docker: `docker compose logs -f open_notebook`\n        - Check browser console (F12 → Console tab)\n      placeholder: |\n        Paste logs here or drag and drop screenshots.\n\n        Error messages, stack traces, or browser console errors are very helpful!\n    validations:\n      required: false\n\n  - type: dropdown\n    id: version\n    attributes:\n      label: Open Notebook Version\n      description: Which version are you using?\n      options:\n        - v1-latest (Docker)\n        - v1-latest-single (Docker)\n        - Latest from main branch\n        - Other (please specify in additional context)\n    validations:\n      required: true\n\n  - type: textarea\n    id: environment\n    attributes:\n      label: Environment\n      description: What environment are you running in?\n      placeholder: |\n        - OS: Ubuntu 22.04 / Windows 11 / macOS 14\n        - Browser: Chrome 120\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Any other information that might be helpful\n      placeholder: \"This started happening after I upgraded to v1.5.0...\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: willing-to-contribute\n    attributes:\n      label: Contribution\n      description: Would you like to work on fixing this bug?\n      options:\n        - label: I am a developer and would like to work on fixing this issue (pending maintainer approval)\n          required: false\n\n  - type: markdown\n    attributes:\n      value: |\n        ---\n        **Next Steps:**\n        1. A maintainer will review your bug report\n        2. If you checked the box above and want to fix it, please propose your solution approach\n        3. Wait for assignment before starting development\n        4. See our [Contributing Guide](https://github.com/lfnovo/open-notebook/blob/main/CONTRIBUTING.md) for more details\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 💬 Discord Community\n    url: https://discord.gg/37XJPXfz2w\n    about: Get help from the community and share ideas\n  - name: 🤖 Installation Assistant (ChatGPT)\n    url: https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant\n    about: CustomGPT that knows all our docs. Really useful. Try it.\n  - name: 📚 Documentation\n    url: https://github.com/lfnovo/open-notebook/tree/main/docs\n    about: Browse our comprehensive documentation\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: ✨ Feature Suggestion\ndescription: Suggest a new feature or improvement for Open Notebook\ntitle: \"[Feature]: \"\nlabels: [\"enhancement\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to suggest a feature! Your ideas help make Open Notebook better for everyone.\n\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: Feature Description\n      description: What feature would you like to see added or improved?\n      placeholder: \"I would like to be able to...\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: why-helpful\n    attributes:\n      label: Why would this be helpful?\n      description: Explain how this feature would benefit you and other users\n      placeholder: \"This would help because...\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: proposed-solution\n    attributes:\n      label: Proposed Solution (Optional)\n      description: If you have ideas on how to implement this feature, please share them\n      placeholder: \"This could be implemented by...\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Any other context, screenshots, or examples that might be helpful\n      placeholder: \"For example, other tools do this by...\"\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: willing-to-contribute\n    attributes:\n      label: Contribution\n      description: Would you like to work on implementing this feature?\n      options:\n        - label: I am a developer and would like to work on implementing this feature (pending maintainer approval)\n          required: false\n\n  - type: markdown\n    attributes:\n      value: |\n        ---\n        **Next Steps:**\n        1. A maintainer will review your feature request\n        2. If approved and you checked the box above, the issue will be assigned to you\n        3. Please wait for assignment before starting development\n        4. See our [Contributing Guide](https://github.com/lfnovo/open-notebook/blob/main/CONTRIBUTING.md) for more details\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/installation_issue.yml",
    "content": "name: 🔧 Installation Issue\ndescription: Report problems with installation, setup, or connectivity\ntitle: \"[Install]: \"\nlabels: [\"installation\", \"needs-triage\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ## ⚠️ Before You Continue\n\n        **Please try these resources first:**\n\n        1. 🤖 **[Installation Assistant ChatGPT](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant)** - Our AI assistant can help you troubleshoot most installation issues instantly!\n\n        2. 📚 **[Installation Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/getting-started/installation.md)** - Comprehensive setup instructions\n\n        3. 🐋 **[Docker Deployment Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/docker.md)** - Detailed Docker setup\n\n        4. 🦙 **Ollama Issues?** Read our [Ollama Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/features/ollama.md) first\n\n        5. 💬 **[Discord Community](https://discord.gg/37XJPXfz2w)** - Get real-time help from the community\n\n        ---\n\n        If you've tried the above and still need help, please fill out the form below with as much detail as possible.\n\n  - type: dropdown\n    id: installation-method\n    attributes:\n      label: Installation Method\n      description: How are you trying to install Open Notebook?\n      options:\n        - Docker (single container - v1-latest-single)\n        - Docker (multi-container - docker-compose)\n        - Local development (make start-all)\n        - Other (please specify below)\n    validations:\n      required: true\n\n  - type: textarea\n    id: issue-description\n    attributes:\n      label: What is the issue?\n      description: Describe the installation or setup problem you're experiencing\n      placeholder: |\n        Example: \"I can't connect to the database\" or \"The container won't start\" or \"Getting 404 errors when accessing the UI\"\n    validations:\n      required: true\n\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs\n      description: |\n        Please provide relevant logs. **This is very important for diagnosing issues!**\n\n        **How to get logs:**\n        - Docker single container: `docker logs open-notebook`\n        - Docker Compose: `docker compose logs -f`\n        - Specific service: `docker compose logs -f open_notebook`\n      placeholder: |\n        Paste your logs here. Include the full error message and stack trace if available.\n      render: shell\n    validations:\n      required: false\n\n  - type: textarea\n    id: docker-compose\n    attributes:\n      label: Docker Compose Configuration\n      description: |\n        If using Docker Compose, please paste your `docker-compose.yml` file here.\n\n        **⚠️ IMPORTANT: Redact any sensitive information (API keys, passwords, etc.)**\n      placeholder: |\n        services:\n          open_notebook:\n            image: lfnovo/open_notebook:v1-latest-single\n            ports:\n              - \"8502:8502\"\n              - \"5055:5055\"\n            environment:\n              - OPENAI_API_KEY=sk-***REDACTED***\n            ...\n      render: yaml\n    validations:\n      required: false\n\n  - type: textarea\n    id: env-file\n    attributes:\n      label: Environment File\n      description: |\n        If using an `.env` or `docker.env` file, please paste it here.\n\n        **⚠️ IMPORTANT: REDACT ALL API KEYS AND PASSWORDS!**\n      placeholder: |\n        SURREAL_URL=ws://surrealdb:8000/rpc\n        SURREAL_USER=root\n        SURREAL_PASSWORD=***REDACTED***\n        OPENAI_API_KEY=sk-***REDACTED***\n        ANTHROPIC_API_KEY=sk-ant-***REDACTED***\n      render: shell\n    validations:\n      required: false\n\n  - type: textarea\n    id: system-info\n    attributes:\n      label: System Information\n      description: Tell us about your setup\n      placeholder: |\n        - Operating System: Ubuntu 22.04 / Windows 11 / macOS 14\n        - Docker version: `docker --version`\n        - Docker Compose version: `docker compose version`\n        - Architecture: amd64 / arm64 (Apple Silicon)\n        - Available disk space: `df -h`\n        - Available memory: `free -h` (Linux) or Activity Monitor (Mac)\n    validations:\n      required: false\n\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional Context\n      description: Any other information that might be helpful\n      placeholder: |\n        - Are you behind a corporate proxy or firewall?\n        - Are you using a VPN?\n        - Have you made any custom modifications?\n        - Did this work before and suddenly break?\n    validations:\n      required: false\n\n  - type: checkboxes\n    id: checklist\n    attributes:\n      label: Pre-submission Checklist\n      description: Please confirm you've tried these steps\n      options:\n        - label: I tried the [Installation Assistant ChatGPT](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant)\n          required: false\n        - label: I read the relevant documentation ([Installation Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/getting-started/installation.md) or [Ollama Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/features/ollama.md))\n          required: false\n        - label: I searched existing issues to see if this was already reported\n          required: true\n        - label: I redacted all sensitive information (API keys, passwords, etc.)\n          required: true\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Description\n\n<!-- Provide a clear and concise description of what this PR does -->\n\n## Related Issue\n\n<!-- This PR should be linked to an approved issue. If not, please create an issue first. -->\n\nFixes #<!-- issue number -->\n\n## Type of Change\n\n<!-- Mark the relevant option with an \"x\" -->\n\n- [ ] Bug fix (non-breaking change that fixes an issue)\n- [ ] New feature (non-breaking change that adds functionality)\n- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)\n- [ ] Documentation update\n- [ ] Code refactoring (no functional changes)\n- [ ] Performance improvement\n- [ ] Test coverage improvement\n\n## How Has This Been Tested?\n\n<!-- Describe the tests you ran and/or how you verified your changes work -->\n\n- [ ] Tested locally with Docker\n- [ ] Tested locally with development setup\n- [ ] Added new unit tests\n- [ ] Existing tests pass (`uv run pytest`)\n- [ ] Manual testing performed (describe below)\n\n**Test Details:**\n<!-- Describe your testing approach -->\n\n## Design Alignment\n\n<!-- This section helps ensure your PR aligns with our project vision -->\n\n**Which design principles does this PR support?** (See [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md))\n\n- [ ] Privacy First\n- [ ] Simplicity Over Features\n- [ ] API-First Architecture\n- [ ] Multi-Provider Flexibility\n- [ ] Extensibility Through Standards\n- [ ] Async-First for Performance\n\n**Explanation:**\n<!-- Brief explanation of how your changes align with these principles -->\n\n## Checklist\n\n<!-- Mark completed items with an \"x\" -->\n\n### Code Quality\n- [ ] My code follows PEP 8 style guidelines (Python)\n- [ ] My code follows TypeScript best practices (Frontend)\n- [ ] I have added type hints to my code (Python)\n- [ ] I have added JSDoc comments where appropriate (TypeScript)\n- [ ] I have performed a self-review of my code\n- [ ] I have commented my code, particularly in hard-to-understand areas\n- [ ] My changes generate no new warnings or errors\n\n### Testing\n- [ ] I have added tests that prove my fix is effective or that my feature works\n- [ ] New and existing unit tests pass locally with my changes\n- [ ] I ran linting: `make ruff` or `ruff check . --fix`\n- [ ] I ran type checking: `make lint` or `uv run python -m mypy .`\n\n### Documentation\n- [ ] I have updated the relevant documentation in `/docs` (if applicable)\n- [ ] I have added/updated docstrings for new/modified functions\n- [ ] I have updated the API documentation (if API changes were made)\n- [ ] I have added comments to complex logic\n\n### Database Changes\n- [ ] I have created migration scripts for any database schema changes (in `/migrations`)\n- [ ] Migration includes both up and down scripts\n- [ ] Migration has been tested locally\n\n### Breaking Changes\n- [ ] This PR includes breaking changes\n- [ ] I have documented the migration path for users\n- [ ] I have updated MIGRATION.md (if applicable)\n\n## Screenshots (if applicable)\n\n<!-- Add screenshots for UI changes -->\n\n## Additional Context\n\n<!-- Add any other context about the PR here -->\n\n## Pre-Submission Verification\n\nBefore submitting, please verify:\n\n- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md)\n- [ ] I have read [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md)\n- [ ] This PR addresses an approved issue that was assigned to me\n- [ ] I have not included unrelated changes in this PR\n- [ ] My PR title follows conventional commits format (e.g., \"feat: add user authentication\")\n\n---\n\n**Thank you for contributing to Open Notebook!** 🎉\n"
  },
  {
    "path": ".github/workflows/build-and-release.yml",
    "content": "name: Build and Release\n\non:\n  workflow_dispatch:\n    inputs:\n      push_latest:\n        description: 'Also push v1-latest tags'\n        required: true\n        default: false\n        type: boolean\n  release:\n    types: [published]\n\npermissions:\n  contents: read\n  packages: write\n\nenv:\n  GHCR_IMAGE: ghcr.io/lfnovo/open-notebook\n  DOCKERHUB_IMAGE: lfnovo/open_notebook\n\njobs:\n  extract-version:\n    runs-on: ubuntu-latest\n    outputs:\n      version: ${{ steps.version.outputs.version }}\n      has_dockerhub_secrets: ${{ steps.check.outputs.has_dockerhub_secrets }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Extract version from pyproject.toml\n        id: version\n        run: |\n          VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'\"' -f2)\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"Extracted version: $VERSION\"\n\n      - name: Check for Docker Hub credentials\n        id: check\n        env:\n          SECRET_DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}\n          SECRET_DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}\n        run: |\n          if [[ -n \"\"$SECRET_DOCKER_USERNAME\"\" && -n \"\"$SECRET_DOCKER_PASSWORD\"\" ]]; then\n            echo \"has_dockerhub_secrets=true\" >> $GITHUB_OUTPUT\n            echo \"Docker Hub credentials available\"\n          else\n            echo \"has_dockerhub_secrets=false\" >> $GITHUB_OUTPUT\n            echo \"Docker Hub credentials not available - will only push to GHCR\"\n          fi\n\n  build-regular:\n    needs: extract-version\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Free up disk space\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/lib/android\n          sudo rm -rf /opt/ghc\n          sudo rm -rf /opt/hostedtoolcache/CodeQL\n          sudo docker image prune --all --force\n          df -h\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Cache Docker layers\n        uses: actions/cache@v3\n        with:\n          path: /tmp/.buildx-cache\n          key: ${{ runner.os }}-buildx-regular-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-buildx-regular-\n\n      - name: Prepare Docker tags for regular build\n        id: tags-regular\n        env:\n          ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}\n          GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}\n          GITHUB_EVENT_NAME: ${{ github.event_name }}\n          GITHUB_EVENT_RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}\n          ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}\n        run: |\n          TAGS=\"\"$ENV_GHCR_IMAGE\":${{ needs.extract-version.outputs.version }}\"\n\n          # Determine if we should push latest tags\n          PUSH_LATEST=\"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\"\n          if [[ -z \"$PUSH_LATEST\" ]]; then\n            PUSH_LATEST=\"false\"\n          fi\n\n          # Add GHCR latest tag if requested or for non-prerelease releases\n          if [[ \"$PUSH_LATEST\" == \"true\" ]] || [[ \"\"$GITHUB_EVENT_NAME\"\" == \"release\" && \"\"$GITHUB_EVENT_RELEASE_PRERELEASE\"\" != \"true\" ]]; then\n            TAGS=\"${TAGS},\"$ENV_GHCR_IMAGE\":v1-latest\"\n          fi\n\n          # Add Docker Hub tags if credentials available\n          if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n            TAGS=\"${TAGS},\"$ENV_DOCKERHUB_IMAGE\":${{ needs.extract-version.outputs.version }}\"\n\n            if [[ \"$PUSH_LATEST\" == \"true\" ]] || [[ \"\"$GITHUB_EVENT_NAME\"\" == \"release\" && \"\"$GITHUB_EVENT_RELEASE_PRERELEASE\"\" != \"true\" ]]; then\n              TAGS=\"${TAGS},\"$ENV_DOCKERHUB_IMAGE\":v1-latest\"\n            fi\n          fi\n\n          echo \"tags=${TAGS}\" >> $GITHUB_OUTPUT\n          echo \"Generated tags: ${TAGS}\"\n\n      - name: Build and push regular image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.tags-regular.outputs.tags }}\n          cache-from: type=local,src=/tmp/.buildx-cache\n          cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max\n\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache\n          mv /tmp/.buildx-cache-new /tmp/.buildx-cache\n\n  build-single:\n    needs: extract-version\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Free up disk space\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/lib/android\n          sudo rm -rf /opt/ghc\n          sudo rm -rf /opt/hostedtoolcache/CodeQL\n          sudo docker image prune --all --force\n          df -h\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Cache Docker layers\n        uses: actions/cache@v3\n        with:\n          path: /tmp/.buildx-cache-single\n          key: ${{ runner.os }}-buildx-single-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-buildx-single-\n\n      - name: Prepare Docker tags for single build\n        id: tags-single\n        env:\n          ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}\n          GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}\n          GITHUB_EVENT_NAME: ${{ github.event_name }}\n          GITHUB_EVENT_RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}\n          ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}\n        run: |\n          TAGS=\"\"$ENV_GHCR_IMAGE\":${{ needs.extract-version.outputs.version }}-single\"\n\n          # Determine if we should push latest tags\n          PUSH_LATEST=\"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\"\n          if [[ -z \"$PUSH_LATEST\" ]]; then\n            PUSH_LATEST=\"false\"\n          fi\n\n          # Add GHCR latest tag if requested or for non-prerelease releases\n          if [[ \"$PUSH_LATEST\" == \"true\" ]] || [[ \"\"$GITHUB_EVENT_NAME\"\" == \"release\" && \"\"$GITHUB_EVENT_RELEASE_PRERELEASE\"\" != \"true\" ]]; then\n            TAGS=\"${TAGS},\"$ENV_GHCR_IMAGE\":v1-latest-single\"\n          fi\n\n          # Add Docker Hub tags if credentials available\n          if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n            TAGS=\"${TAGS},\"$ENV_DOCKERHUB_IMAGE\":${{ needs.extract-version.outputs.version }}-single\"\n\n            if [[ \"$PUSH_LATEST\" == \"true\" ]] || [[ \"\"$GITHUB_EVENT_NAME\"\" == \"release\" && \"\"$GITHUB_EVENT_RELEASE_PRERELEASE\"\" != \"true\" ]]; then\n              TAGS=\"${TAGS},\"$ENV_DOCKERHUB_IMAGE\":v1-latest-single\"\n            fi\n          fi\n\n          echo \"tags=${TAGS}\" >> $GITHUB_OUTPUT\n          echo \"Generated tags: ${TAGS}\"\n\n      - name: Build and push single-container image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.single\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.tags-single.outputs.tags }}\n          cache-from: type=local,src=/tmp/.buildx-cache-single\n          cache-to: type=local,dest=/tmp/.buildx-cache-single-new,mode=max\n\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache-single\n          mv /tmp/.buildx-cache-single-new /tmp/.buildx-cache-single\n\n  summary:\n    needs: [extract-version, build-regular, build-single]\n    runs-on: ubuntu-latest\n    if: always()\n    steps:\n      - name: Build Summary\n        env:\n          GITHUB_EVENT_INPUTS_PUSH_LATEST_____FALSE_: ${{ github.event.inputs.push_latest || 'false' }}\n          ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}\n          ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}\n          GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}\n        run: |\n          echo \"## Build Summary\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Version:** ${{ needs.extract-version.outputs.version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Push v1-Latest:** \"$GITHUB_EVENT_INPUTS_PUSH_LATEST_____FALSE_\"\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"### Registries:\" >> $GITHUB_STEP_SUMMARY\n          echo \"✅ **GHCR:** \\`\"$ENV_GHCR_IMAGE\"\\`\" >> $GITHUB_STEP_SUMMARY\n          if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n            echo \"✅ **Docker Hub:** \\`\"$ENV_DOCKERHUB_IMAGE\"\\`\" >> $GITHUB_STEP_SUMMARY\n          else\n            echo \"⏭️ **Docker Hub:** Skipped (credentials not configured)\" >> $GITHUB_STEP_SUMMARY\n          fi\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"### Images Built:\" >> $GITHUB_STEP_SUMMARY\n\n          if [[ \"${{ needs.build-regular.result }}\" == \"success\" ]]; then\n            echo \"✅ **Regular (GHCR):** \\`\"$ENV_GHCR_IMAGE\":${{ needs.extract-version.outputs.version }}\\`\" >> $GITHUB_STEP_SUMMARY\n            if [[ \"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\" == \"true\" ]]; then\n              echo \"✅ **Regular v1-Latest (GHCR):** \\`\"$ENV_GHCR_IMAGE\":v1-latest\\`\" >> $GITHUB_STEP_SUMMARY\n            fi\n            if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n              echo \"✅ **Regular (Docker Hub):** \\`\"$ENV_DOCKERHUB_IMAGE\":${{ needs.extract-version.outputs.version }}\\`\" >> $GITHUB_STEP_SUMMARY\n              if [[ \"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\" == \"true\" ]]; then\n                echo \"✅ **Regular v1-Latest (Docker Hub):** \\`\"$ENV_DOCKERHUB_IMAGE\":v1-latest\\`\" >> $GITHUB_STEP_SUMMARY\n              fi\n            fi\n          elif [[ \"${{ needs.build-regular.result }}\" == \"skipped\" ]]; then\n            echo \"⏭️ **Regular:** Skipped\" >> $GITHUB_STEP_SUMMARY\n          else\n            echo \"❌ **Regular:** Failed\" >> $GITHUB_STEP_SUMMARY\n          fi\n\n          if [[ \"${{ needs.build-single.result }}\" == \"success\" ]]; then\n            echo \"✅ **Single (GHCR):** \\`\"$ENV_GHCR_IMAGE\":${{ needs.extract-version.outputs.version }}-single\\`\" >> $GITHUB_STEP_SUMMARY\n            if [[ \"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\" == \"true\" ]]; then\n              echo \"✅ **Single v1-Latest (GHCR):** \\`\"$ENV_GHCR_IMAGE\":v1-latest-single\\`\" >> $GITHUB_STEP_SUMMARY\n            fi\n            if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n              echo \"✅ **Single (Docker Hub):** \\`\"$ENV_DOCKERHUB_IMAGE\":${{ needs.extract-version.outputs.version }}-single\\`\" >> $GITHUB_STEP_SUMMARY\n              if [[ \"\"$GITHUB_EVENT_INPUTS_PUSH_LATEST\"\" == \"true\" ]]; then\n                echo \"✅ **Single v1-Latest (Docker Hub):** \\`\"$ENV_DOCKERHUB_IMAGE\":v1-latest-single\\`\" >> $GITHUB_STEP_SUMMARY\n              fi\n            fi\n          elif [[ \"${{ needs.build-single.result }}\" == \"skipped\" ]]; then\n            echo \"⏭️ **Single:** Skipped\" >> $GITHUB_STEP_SUMMARY\n          else\n            echo \"❌ **Single:** Failed\" >> $GITHUB_STEP_SUMMARY\n          fi\n\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n          echo \"### Platforms:\" >> $GITHUB_STEP_SUMMARY\n          echo \"- linux/amd64\" >> $GITHUB_STEP_SUMMARY\n          echo \"- linux/arm64\" >> $GITHUB_STEP_SUMMARY"
  },
  {
    "path": ".github/workflows/build-dev.yml",
    "content": "name: Development Build\n\non:\n  pull_request:\n    branches: [ main ]\n  push:\n    branches: [ main ]\n    paths-ignore:\n      - '**.md'\n      - 'docs/**'\n      - 'notebooks/**'\n      - '.github/workflows/claude*.yml'\n  workflow_dispatch:\n    inputs:\n      platform:\n        description: 'Platform to build'\n        required: true\n        default: 'linux/amd64'\n        type: choice\n        options:\n          - linux/amd64\n          - linux/arm64\n          - linux/amd64,linux/arm64\n\npermissions:\n  contents: read\n  packages: write\n\nenv:\n  GHCR_IMAGE: ghcr.io/lfnovo/open-notebook\n  DOCKERHUB_IMAGE: lfnovo/open_notebook\n\njobs:\n  extract-version:\n    runs-on: ubuntu-latest\n    outputs:\n      version: ${{ steps.version.outputs.version }}\n      has_dockerhub_secrets: ${{ steps.check.outputs.has_dockerhub_secrets }}\n      is_push_to_main: ${{ steps.check.outputs.is_push_to_main }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Extract version from pyproject.toml\n        id: version\n        run: |\n          VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'\"' -f2)\n          echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n          echo \"Extracted version: $VERSION\"\n\n      - name: Check environment\n        id: check\n        env:\n          SECRET_DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}\n          SECRET_DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}\n        run: |\n          # Check for Docker Hub credentials\n          if [[ -n \"$SECRET_DOCKER_USERNAME\" && -n \"$SECRET_DOCKER_PASSWORD\" ]]; then\n            echo \"has_dockerhub_secrets=true\" >> $GITHUB_OUTPUT\n            echo \"Docker Hub credentials available\"\n          else\n            echo \"has_dockerhub_secrets=false\" >> $GITHUB_OUTPUT\n            echo \"Docker Hub credentials not available\"\n          fi\n\n          # Check if this is a push to main (not a PR)\n          if [[ \"${{ github.event_name }}\" == \"push\" && \"${{ github.ref }}\" == \"refs/heads/main\" ]]; then\n            echo \"is_push_to_main=true\" >> $GITHUB_OUTPUT\n            echo \"This is a push to main - will publish v1-dev tags\"\n          else\n            echo \"is_push_to_main=false\" >> $GITHUB_OUTPUT\n            echo \"This is a PR or manual run - test build only\"\n          fi\n\n  build-regular:\n    needs: extract-version\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Free up disk space\n        if: needs.extract-version.outputs.is_push_to_main == 'true'\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/lib/android\n          sudo rm -rf /opt/ghc\n          sudo rm -rf /opt/hostedtoolcache/CodeQL\n          sudo docker image prune --all --force\n          df -h\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        if: needs.extract-version.outputs.is_push_to_main == 'true'\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: needs.extract-version.outputs.is_push_to_main == 'true' && needs.extract-version.outputs.has_dockerhub_secrets == 'true'\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Cache Docker layers\n        uses: actions/cache@v3\n        with:\n          path: /tmp/.buildx-cache-dev\n          key: ${{ runner.os }}-buildx-dev-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-buildx-dev-\n\n      - name: Prepare Docker tags\n        id: tags\n        run: |\n          if [[ \"${{ needs.extract-version.outputs.is_push_to_main }}\" == \"true\" ]]; then\n            # Push to main: build and push v1-dev tags\n            TAGS=\"${{ env.GHCR_IMAGE }}:v1-dev\"\n            if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n              TAGS=\"${TAGS},${{ env.DOCKERHUB_IMAGE }}:v1-dev\"\n            fi\n            echo \"tags=${TAGS}\" >> $GITHUB_OUTPUT\n            echo \"push=true\" >> $GITHUB_OUTPUT\n            echo \"platforms=linux/amd64,linux/arm64\" >> $GITHUB_OUTPUT\n          else\n            # PR or manual: test build only\n            echo \"tags=${{ env.DOCKERHUB_IMAGE }}:${{ needs.extract-version.outputs.version }}-dev\" >> $GITHUB_OUTPUT\n            echo \"push=false\" >> $GITHUB_OUTPUT\n            echo \"platforms=${{ github.event.inputs.platform || 'linux/amd64' }}\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Build and push regular image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          platforms: ${{ steps.tags.outputs.platforms }}\n          push: ${{ steps.tags.outputs.push }}\n          tags: ${{ steps.tags.outputs.tags }}\n          cache-from: type=local,src=/tmp/.buildx-cache-dev\n          cache-to: type=local,dest=/tmp/.buildx-cache-dev-new,mode=max\n\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache-dev\n          mv /tmp/.buildx-cache-dev-new /tmp/.buildx-cache-dev\n\n  build-single:\n    needs: extract-version\n    # Only build single image on push to main\n    if: needs.extract-version.outputs.is_push_to_main == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Free up disk space\n        run: |\n          sudo rm -rf /usr/share/dotnet\n          sudo rm -rf /usr/local/lib/android\n          sudo rm -rf /opt/ghc\n          sudo rm -rf /opt/hostedtoolcache/CodeQL\n          sudo docker image prune --all --force\n          df -h\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Login to Docker Hub\n        if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Cache Docker layers\n        uses: actions/cache@v3\n        with:\n          path: /tmp/.buildx-cache-dev-single\n          key: ${{ runner.os }}-buildx-dev-single-${{ github.sha }}\n          restore-keys: |\n            ${{ runner.os }}-buildx-dev-single-\n\n      - name: Prepare Docker tags\n        id: tags\n        run: |\n          TAGS=\"${{ env.GHCR_IMAGE }}:v1-dev-single\"\n          if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n            TAGS=\"${TAGS},${{ env.DOCKERHUB_IMAGE }}:v1-dev-single\"\n          fi\n          echo \"tags=${TAGS}\" >> $GITHUB_OUTPUT\n\n      - name: Build and push single-container image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.single\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.tags.outputs.tags }}\n          cache-from: type=local,src=/tmp/.buildx-cache-dev-single\n          cache-to: type=local,dest=/tmp/.buildx-cache-dev-single-new,mode=max\n\n      - name: Move cache\n        run: |\n          rm -rf /tmp/.buildx-cache-dev-single\n          mv /tmp/.buildx-cache-dev-single-new /tmp/.buildx-cache-dev-single\n\n  summary:\n    needs: [extract-version, build-regular, build-single]\n    runs-on: ubuntu-latest\n    if: always()\n    steps:\n      - name: Development Build Summary\n        run: |\n          echo \"## Development Build Summary\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Version:** ${{ needs.extract-version.outputs.version }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Event:** ${{ github.event_name }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"**Push to Main:** ${{ needs.extract-version.outputs.is_push_to_main }}\" >> $GITHUB_STEP_SUMMARY\n          echo \"\" >> $GITHUB_STEP_SUMMARY\n\n          if [[ \"${{ needs.extract-version.outputs.is_push_to_main }}\" == \"true\" ]]; then\n            echo \"### Published Tags:\" >> $GITHUB_STEP_SUMMARY\n\n            if [[ \"${{ needs.build-regular.result }}\" == \"success\" ]]; then\n              echo \"✅ **Regular:** \\`${{ env.GHCR_IMAGE }}:v1-dev\\`\" >> $GITHUB_STEP_SUMMARY\n              if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n                echo \"✅ **Regular (Docker Hub):** \\`${{ env.DOCKERHUB_IMAGE }}:v1-dev\\`\" >> $GITHUB_STEP_SUMMARY\n              fi\n            else\n              echo \"❌ **Regular:** Build failed\" >> $GITHUB_STEP_SUMMARY\n            fi\n\n            if [[ \"${{ needs.build-single.result }}\" == \"success\" ]]; then\n              echo \"✅ **Single:** \\`${{ env.GHCR_IMAGE }}:v1-dev-single\\`\" >> $GITHUB_STEP_SUMMARY\n              if [[ \"${{ needs.extract-version.outputs.has_dockerhub_secrets }}\" == \"true\" ]]; then\n                echo \"✅ **Single (Docker Hub):** \\`${{ env.DOCKERHUB_IMAGE }}:v1-dev-single\\`\" >> $GITHUB_STEP_SUMMARY\n              fi\n            elif [[ \"${{ needs.build-single.result }}\" == \"skipped\" ]]; then\n              echo \"⏭️ **Single:** Skipped\" >> $GITHUB_STEP_SUMMARY\n            else\n              echo \"❌ **Single:** Build failed\" >> $GITHUB_STEP_SUMMARY\n            fi\n\n            echo \"\" >> $GITHUB_STEP_SUMMARY\n            echo \"### Platforms:\" >> $GITHUB_STEP_SUMMARY\n            echo \"- linux/amd64\" >> $GITHUB_STEP_SUMMARY\n            echo \"- linux/arm64\" >> $GITHUB_STEP_SUMMARY\n          else\n            echo \"### Test Build Results:\" >> $GITHUB_STEP_SUMMARY\n            if [[ \"${{ needs.build-regular.result }}\" == \"success\" ]]; then\n              echo \"✅ **Dockerfile:** Build successful\" >> $GITHUB_STEP_SUMMARY\n            else\n              echo \"❌ **Dockerfile:** Build failed\" >> $GITHUB_STEP_SUMMARY\n            fi\n            echo \"\" >> $GITHUB_STEP_SUMMARY\n            echo \"### Notes:\" >> $GITHUB_STEP_SUMMARY\n            echo \"- This is a test build (no images pushed to registry)\" >> $GITHUB_STEP_SUMMARY\n            echo \"- Merge to main to publish \\`v1-dev\\` tags\" >> $GITHUB_STEP_SUMMARY\n            echo \"- For stable releases, use the 'Build and Release' workflow\" >> $GITHUB_STEP_SUMMARY\n          fi\n"
  },
  {
    "path": ".github/workflows/claude-code-review.yml",
    "content": "name: Claude Code Review\n\non:\n  pull_request:\n    types: [opened, synchronize, ready_for_review, reopened]\n  pull_request_target:\n    types: [opened, synchronize, ready_for_review, reopened]\n    # Optional: Only run on specific file changes\n    # paths:\n    #   - \"src/**/*.ts\"\n    #   - \"src/**/*.tsx\"\n    #   - \"src/**/*.js\"\n    #   - \"src/**/*.jsx\"\n\njobs:\n  claude-review:\n    # Run for fork PRs (via pull_request_target) OR same-repo PRs (via pull_request), but not both\n    if: |\n      (github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository) ||\n      (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)\n\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n      id-token: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n          fetch-depth: 1\n          persist-credentials: false\n\n      - name: Run Claude Code Review\n        id: claude-review\n        uses: anthropics/claude-code-action@v1\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n          plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'\n          plugins: 'code-review@claude-code-plugins'\n          prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: write\n      issues: write\n      id-token: write\n      actions: read # Required for Claude to read CI results on PRs\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@v1\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # This is an optional setting that allows Claude to read CI results on PRs\n          additional_permissions: |\n            actions: read\n\n          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.\n          # prompt: 'Update the pull request description to include a summary of changes.'\n\n          # Optional: Add claude_args to customize behavior and configuration\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n          # claude_args: '--allowed-tools Bash(gh pr:*)'\n\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Tests\n\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n    paths-ignore:\n      - '**.md'\n      - 'docs/**'\n      - '.github/workflows/claude*.yml'\n\npermissions:\n  contents: read\n\njobs:\n  backend:\n    name: Backend Tests\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v4\n        with:\n          enable-cache: true\n\n      - name: Set up Python\n        run: uv python install\n\n      - name: Install dependencies\n        run: uv sync\n\n      - name: Run tests\n        run: uv run pytest tests/ -v\n\n  frontend:\n    name: Frontend Tests\n    runs-on: ubuntu-latest\n    defaults:\n      run:\n        working-directory: frontend\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: npm\n          cache-dependency-path: frontend/package-lock.json\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Run tests\n        run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": ".env\nprompts/patterns/user/\n/notebooks/\ndata/\n.uploads/\nsqlite-db/\nsurreal-data/\ndocker.env\nnotebook_data/\n# Python-specific\n*.py[cod]\n__pycache__/\n*.so\ntodo.md\ntemp/\ngoogle-credentials.json\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\n/lib/\n/lib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# PyCharm\n.idea/\n\n# VS Code\n.vscode/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# macOS\n.DS_Store\n\n# Windows\nThumbs.db\nehthumbs.db\ndesktop.ini\n\n# Linux\n*~\n\n# Log files\n*.log\n\n# Database files\n*.db\n*.sqlite3\n\n.quarentena\n\nclaude-logs/\n.claude/sessions\n**/claude-logs\n\n\ndocs/custom_gpt\ndoc_exports/\n\nspecs/\n.claude\n.sisyphus\n\n.playwright-mcp/\n\n\n\n*.local.yml\n**/*.local.md"
  },
  {
    "path": ".python-version",
    "content": "3.12\n"
  },
  {
    "path": ".worktreeinclude",
    "content": ".env\n.env.local\n.env.*\n**/.claude/settings.local.json\nCLAUDE.local.md\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n## [1.8.1] - 2026-03-10\n\n### Added\n- i18n support for Bengali (bn-IN) (#643)\n- Podcast language support via podcast-creator 0.12.0 (#645)\n- Upgrade default Azure API version for model testing and fetching (#638)\n\n### Fixed\n- Tiktoken network errors in offline/air-gapped Docker deployments — pre-downloads encoding at build time (#264, #622)\n- SurrealDB getting stuck (#656)\n\n### Dependencies\n- Bump esperanto to 2.19.5 (#657)\n- Bump langgraph from 1.0.6 to 1.0.10rc1 (#658)\n- Bump authlib from 1.6.6 to 1.6.7 (#649)\n- Bump lxml-html-clean from 0.4.3 to 0.4.4 (#646)\n- Bump rollup from 4.55.1 to 4.59.0 (#635)\n- Bump minimatch in frontend (#634)\n- Bump tar from 7.5.9 to 7.5.11 (#650, #659)\n\n## [1.7.4] - 2026-02-18\n\n### Fixed\n- Embedding large documents (3MB+) fails with 413 Payload Too Large (#594)\n- `generate_embeddings()` now batches texts in groups of 50 with per-batch retry, preventing provider payload limits from being exceeded\n- 413 errors now classified with user-friendly message in error classifier\n- Misleading \"Created 0 embedded chunks\" log in `process_source_command` — embedding is fire-and-forget, so the count was always 0; now logs \"embedding submitted\" instead\n\n## [1.7.3] - 2026-02-17\n\n### Added\n- Retry button for failed podcast episodes in the UI (#211, #218)\n- Error details displayed on failed podcast episodes (#185, #355)\n- `POST /podcasts/episodes/{id}/retry` API endpoint for re-submitting failed episodes\n- `error_message` field in podcast episode API responses\n\n### Fixed\n- Podcast generation failures now correctly marked as \"failed\" instead of \"completed\" (#300, #335)\n- Disabled automatic retries for podcast generation to prevent duplicate episode records (#302)\n\n### Dependencies\n- Bump podcast-creator to >= 0.11.2\n- Bump esperanto to >= 2.19.4\n\n## [1.7.2] - 2026-02-16\n\n### Added\n- Error classification utility that maps LLM provider errors to user-friendly messages (#506)\n- Global exception handlers in FastAPI for all custom exception types with proper HTTP status codes\n- `getApiErrorMessage()` frontend helper that falls back to backend messages when no i18n mapping exists\n\n### Fixed\n- LLM errors (invalid API key, wrong model, rate limits) now show descriptive messages instead of \"An unexpected error occurred\" (#590)\n- SSE streaming error events in source chat and ask hooks were swallowed by inner JSON parse catch blocks\n- Transformation execution errors were caught and re-wrapped as generic 500s instead of using proper status codes\n- Fail fast when source content extraction returns empty instead of retrying (#589)\n- Chat input and message overflow with long unbroken strings (#588)\n- Word-wrap overflow in source cards, note editor, inline edit, note titles, and dialog content (#588)\n- Translation proxy shadowing `name` keys (#588)\n- OpenAI-compatible provider name handling via Esperanto update (#583)\n\n### Changed\n- `ValueError` replaced with `ConfigurationError` in model provisioning for proper error classification\n- `ConfigurationError` added to command retry `stop_on` lists to avoid retrying permanent config failures\n\n### Dependencies\n- Bump esperanto to 2.19.3 (#583)\n- Bump podcast-creator to 0.9.1\n\n## [1.7.1] - 2026-02-14\n\n### Added\n- French (fr-FR) language support (#581)\n- CI test workflow and improved i18n validation (#580)\n- Expose embed `command_id` in note API responses (#545)\n\n### Fixed\n- ElevenLabs TTS credential passthrough via Esperanto update (#578)\n- Handle empty/whitespace source content without retry loop (#576)\n- Increase transformation `max_tokens` and update Esperanto dep (#568)\n- Turn the embedding field into optional (#557)\n\n### Docs\n- Fix docker container names in local setup guides (#577)\n\n### Dependencies\n- Bump langchain-core from 1.2.7 to 1.2.11 (#564)\n- Bump cryptography from 46.0.3 to 46.0.5 (#563)\n\n## [1.7.0] - 2026-02-10\n\n### Added\n- **Credential-Based Provider Management** (#477)\n  - New Settings → API Keys page for managing AI provider credentials via the UI\n  - Support for 14 providers: OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter, Voyage AI, ElevenLabs, Ollama, Azure OpenAI, OpenAI-Compatible, and Vertex AI\n  - Secure storage of API keys in SurrealDB with field-level encryption (Fernet AES-128-CBC + HMAC-SHA256)\n  - One-click connection testing, model discovery, and model registration per credential\n  - Migration tool to import existing environment variable keys into the credential system\n  - Azure OpenAI support with service-specific endpoints (LLM, Embedding, STT, TTS)\n  - OpenAI-Compatible support with per-service URL configurations\n  - Vertex AI support with project, location, and credentials path\n  - Environment variable API keys deprecated in favor of Settings UI\n\n- **Security Enhancements**\n  - Docker secrets support via `_FILE` suffix pattern (e.g., `OPEN_NOTEBOOK_PASSWORD_FILE`)\n  - Default encryption key derived from \"0p3n-N0t3b0ok\" for easy setup (change in production!)\n  - Default password \"open-notebook-change-me\" for out-of-box experience (change in production!)\n  - URL validation for SSRF protection - blocks private IPs and localhost (except for Ollama which runs locally)\n  - Security warnings logged when using default credentials\n\n- HTML clipboard detection for text sources (#426)\n  - When pasting content, automatically detects HTML format (e.g., from Word, web pages)\n  - Shows info message when HTML is detected, informing user it will be converted to Markdown\n  - Preserves formatting that would be lost with plain text paste\n  - Bump content-core to 0.11.0 for HTML to Markdown conversion support\n\n- **Improved Getting Started Experience**\n  - Simplified docker-compose.yml in repository root (single official file)\n  - Added examples/ folder with ready-made configurations:\n    - `docker-compose-ollama.yml` - Local AI with Ollama\n    - `docker-compose-speaches.yml` - Local TTS/STT with Speaches\n    - `docker-compose-full-local.yml` - 100% local setup (Ollama + Speaches)\n  - Inline quick start in README (no need to navigate to docs)\n  - Cross-references between docker-compose examples and documentation\n  - .env.example template with all configuration options\n\n### Fixed\n- Azure form race condition: all configuration now saved in single atomic request\n- Migration API \"error error\" display: added proper MigrationResult model with message field\n- Connection tester for Ollama providers: improved error handling and URL validation\n- SqliteSaver async compatibility issues in chat system (#509, #525, #538)\n- Re-embedding failures with empty content (#513, #515)\n- Deletion cascade for notes and sources (#77)\n- YouTube content availability issues (#494)\n- Large document embedding errors (#489)\n\n### Security\n- API keys are encrypted at rest using Fernet symmetric encryption\n- Keys are never returned to the frontend, only configuration status\n- SSRF protection prevents internal network access via URL validation\n\n### Docs\n- Complete documentation update for credential-based system across 25 files\n- All quick-start, installation, and configuration guides now use Settings UI workflow\n- Environment variable API key instructions moved to deprecated/legacy sections\n- Fixed broken links in installation docs\n- Added comprehensive examples/ folder with documented docker-compose configurations\n- Updated local-tts.md and local-stt.md with links to ready-made examples\n\n### Internationalization\n- Added Russian (ru-RU) language support (#524)\n- Added Italian (it-IT) language support (#508)\n\n## [1.6.2] - 2026-01-24\n\n### Fixed\n- Connection error with llama.cpp and OpenAI-compatible providers (#465)\n  - Bump Esperanto to 2.17.2 which fixes LangChain connection errors caused by garbage collection\n\n## [1.6.1] - 2026-01-22\n\n### Fixed\n- \"Failed to send message\" error with unhelpful logs when chat model is not configured (#358)\n  - Added detailed error logging with model selection context and full traceback\n  - Improved error messages to guide users to Settings → Models\n  - Added warnings when default models are not configured\n\n### Docs\n- Ollama troubleshooting: Added \"Model Name Configuration\" section emphasizing exact model names from `ollama list`\n- Added troubleshooting entry for \"Failed to send message\" error with step-by-step solutions\n- Updated AI Chat Issues documentation with model configuration guidance\n\n\n## [1.6.0] - 2026-01-21\n\n### Added\n- Content-type aware text chunking with automatic HTML, Markdown, and plain text detection (#350, #142)\n- Unified embedding generation with mean pooling for large content that exceeds model context limits\n- Dedicated embedding commands: `embed_note`, `embed_insight`, `embed_source`\n- New utility modules: `chunking.py` and `embedding.py` in `open_notebook/utils/`\n- Japanese (ja-JP) language support (#450)\n\n### Changed\n- Embedding is now fire-and-forget: domain models submit embedding commands asynchronously after save\n- `rebuild_embeddings_command` now delegates to individual embed_* commands instead of inline processing\n- Chunk size reduced to 1500 characters for better compatibility with Ollama embedding models\n- Bump Esperanto to 2.16 for increased Ollama context window support\n\n### Removed\n- Legacy embedding commands: `embed_single_item_command`, `embed_chunk_command`, `vectorize_source_command`\n- `needs_embedding()` and `get_embedding_content()` methods from domain models\n- `split_text()` function from text_utils (replaced by `chunk_text()` in chunking module)\n\n### Fixed\n- Embedding failures when content exceeds model context limits (#350, #142)\n- Empty note titles when saving from chat (clean thinking tags from prompt graph output)\n- Orphaned embedding/insight records when deleting sources (cascade delete)\n- Search results crash with null parent_id (defensive frontend check)\n- Database migration 10 cleans up existing orphaned records\n\n## [1.5.2] - 2026-01-15\n\n### Performance\n- Improved source listing speed by 20-30x (#436, closes #351)\n  - Added database indexes on `source` field for `source_insight` and `source_embedding` tables\n  - Use SurrealDB `FETCH` clause for command status instead of N async calls\n\n## [1.5.1] - 2026-01-15\n\n### Fixed\n- Podcast dialog infinite loop error caused by excessive translation Proxy accesses in loops\n- Podcast dialog UI freezing when typing episode name or additional instructions\n- Removed incorrect translation keys for user-defined episode profiles (user content should not be translated)\n\n## [1.5.0] - 2026-01-15\n\n### Added\n- Internationalization (i18n) support with Chinese (Simplified and Traditional) translations (#371, closes #344, #349, #360)\n- Frontend test infrastructure with Vitest (#371)\n- Language toggle component for switching UI language (#371)\n- Date localization using date-fns locales (#371)\n- Error message translation system (#371)\n\n### Fixed\n- Accessibility improvements: added missing `id`, `name`, and `autoComplete` attributes to form inputs (#371)\n- Added `DialogDescription` to dialogs for Radix UI accessibility compliance (#371)\n- Fixed \"Collapsible is changing from uncontrolled to controlled\" warning in SettingsForm (#371)\n- Fixed lint command for Next.js 16 compatibility (`eslint` instead of `next lint`)\n\n### Changed\n- Dockerfile optimizations: better layer caching, `--no-install-recommends` for smaller images (#371)\n- Dockerfile.single refactored into 3 separate build stages for better caching (#371)\n\n## [1.4.0] - 2026-01-14\n\n### Added\n- CTA button to empty state notebook list for better onboarding (#408)\n- Offline deployment support for Docker containers (#414)\n\n### Fixed\n- Large file uploads (>10MB) by upgrading to Next.js 16 (#423)\n- Orphaned uploaded files when sources are removed (#421)\n- Broken documentation links to ai-providers.md (#419)\n- ZIP support indication removed from UI (#418)\n- Duplicate Claude Code workflow runs on PRs (#417)\n- Claude Code review workflow now runs on PRs from forks (#416)\n\n### Changed\n- Upgraded Next.js from 15.4.10 to 16.1.1 (#423)\n- Upgraded React from 19.1.0 to 19.2.3 (#423)\n- Renamed `middleware.ts` to `proxy.ts` for Next.js 16 compatibility (#423)\n\n### Dependencies\n- next: 15.4.10 → 16.1.1\n- react: 19.1.0 → 19.2.3\n- react-dom: 19.1.0 → 19.2.3\n\n## [1.2.4] - 2025-12-14\n\n### Added\n- Infinite scroll for notebook sources - no more 50 source limit (#325)\n- Markdown table rendering in chat responses, search results, and insights (#325)\n\n### Fixed\n- Timeout errors with Ollama and local LLMs - increased to 10 minutes (#325)\n- \"Unable to Connect to API Server\" on Docker startup - frontend now waits for API health check (#325, #315)\n- SSL issues with langchain (#274)\n- Query key consistency for source mutations to properly refresh infinite scroll (#325)\n- Docker compose start-all flow (#323)\n\n### Changed\n- Timeout configuration now uses granular httpx.Timeout (short connect, long read) (#325)\n\n### Dependencies\n- Updated next.js to 15.4.10\n- Updated httpx to >=0.27.0 for SSL fix\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# Open Notebook - Root CLAUDE.md\n\nThis file provides architectural guidance for contributors working on Open Notebook at the project level.\n\n## Project Overview\n\n**Open Notebook** is an open-source, privacy-focused alternative to Google's Notebook LM. It's an AI-powered research assistant enabling users to upload multi-modal content (PDFs, audio, video, web pages), generate intelligent notes, search semantically, chat with AI models, and produce professional podcasts—all with complete control over data and choice of AI providers.\n\n**Key Values**: Privacy-first, multi-provider AI support, fully self-hosted option, open-source transparency.\n\n---\n\n## Three-Tier Architecture\n\n```\n┌─────────────────────────────────────────────────────────┐\n│              Frontend (React/Next.js)                    │\n│              frontend/ @ port 3000                       │\n├─────────────────────────────────────────────────────────┤\n│ - Notebooks, sources, notes, chat, podcasts, search UI  │\n│ - Zustand state management, TanStack Query (React Query)│\n│ - Shadcn/ui component library with Tailwind CSS         │\n└────────────────────────┬────────────────────────────────┘\n                         │ HTTP REST\n┌────────────────────────▼────────────────────────────────┐\n│              API (FastAPI)                              │\n│              api/ @ port 5055                           │\n├─────────────────────────────────────────────────────────┤\n│ - REST endpoints for notebooks, sources, notes, chat    │\n│ - LangGraph workflow orchestration                      │\n│ - Job queue for async operations (podcasts)             │\n│ - Multi-provider AI provisioning via Esperanto          │\n└────────────────────────┬────────────────────────────────┘\n                         │ SurrealQL\n┌────────────────────────▼────────────────────────────────┐\n│         Database (SurrealDB)                            │\n│         Graph database @ port 8000                      │\n├─────────────────────────────────────────────────────────┤\n│ - Records: Notebook, Source, Note, ChatSession, Credential│\n│ - Relationships: source-to-notebook, note-to-source     │\n│ - Vector embeddings for semantic search                 │\n└─────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Useful sources\n\nUser documentation is at @docs/\n\n## Tech Stack\n\n### Frontend (`frontend/`)\n- **Framework**: Next.js 16 (React 19)\n- **Language**: TypeScript\n- **State Management**: Zustand\n- **Data Fetching**: TanStack Query (React Query)\n- **Styling**: Tailwind CSS + Shadcn/ui\n- **Build Tool**: Webpack (via Next.js)\n- **i18n compatible**: All front-end changes must also consider the translation keys\n\n### API Backend (`api/` + `open_notebook/`)\n- **Framework**: FastAPI 0.104+\n- **Language**: Python 3.11+\n- **Workflows**: LangGraph state machines\n- **Database**: SurrealDB async driver\n- **AI Providers**: Esperanto library (8+ providers: OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI)\n- **Job Queue**: Surreal-Commands for async jobs (podcasts)\n- **Logging**: Loguru\n- **Validation**: Pydantic v2\n- **Testing**: Pytest\n\n### Database\n- **SurrealDB**: Graph database with built-in embedding storage and vector search\n- **Schema Migrations**: Automatic on API startup via AsyncMigrationManager\n\n### Additional Services\n- **Content Processing**: content-core library (file/URL extraction)\n- **Prompts**: AI-Prompter with Jinja2 templating\n- **Podcast Generation**: podcast-creator library\n- **Embeddings**: Multi-provider via Esperanto\n\n---\n\n## Architecture Highlights\n\n### 1. Async-First Design\n- All database queries, graph invocations, and API calls are async (await)\n- SurrealDB async driver with connection pooling\n- FastAPI handles concurrent requests efficiently\n\n### 2. LangGraph Workflows\n- **source.py**: Content ingestion (extract → embed → save)\n- **chat.py**: Conversational agent with message history\n- **ask.py**: Search + synthesis (retrieve relevant sources → LLM)\n- **transformation.py**: Custom transformations on sources\n- All use `provision_langchain_model()` for smart model selection\n\n### 3. Multi-Provider AI\n- **Esperanto library**: Unified interface to 8+ AI providers\n- **Credential system**: Individual encrypted credential records per provider; models link to credentials for direct config\n- **ModelManager**: Factory pattern with fallback logic; uses credential config when available, env vars as fallback\n- **Smart selection**: Detects large contexts, prefers long-context models\n- **Override support**: Per-request model configuration\n\n### 4. Database Schema\n- **Automatic migrations**: AsyncMigrationManager runs on API startup\n- **SurrealDB graph model**: Records with relationships and embeddings\n- **Vector search**: Built-in semantic search across all content\n- **Transactions**: Repo functions handle ACID operations\n\n### 5. Authentication\n- **Current**: Simple password middleware (insecure, dev-only)\n- **Production**: Replace with OAuth/JWT (see CONFIGURATION.md)\n\n---\n\n## Important Quirks & Gotchas\n\n### API Startup\n- **Migrations run automatically** on startup; check logs for errors\n- **Must start API before UI**: UI depends on API for all data\n- **SurrealDB must be running**: API fails without database connection\n\n### Frontend-Backend Communication\n- **Base API URL**: Configured in `.env.local` (default: http://localhost:5055)\n- **CORS enabled**: Configured in `api/main.py` (allow all origins in dev)\n- **Rate limiting**: Not built-in; add at proxy layer for production\n\n### LangGraph Workflows\n- **Blocking operations**: Chat/podcast workflows may take minutes; no timeout\n- **State persistence**: Uses SQLite checkpoint storage in `/data/sqlite-db/`\n- **Model fallback**: If primary model fails, falls back to cheaper/smaller model\n\n### Podcast Generation\n- **Async job queue**: `podcast_service.py` submits jobs but doesn't wait\n- **Track status**: Use `/commands/{command_id}` endpoint to poll status\n- **TTS failures**: Fall back to silent audio if speech synthesis fails\n\n### Content Processing\n- **File extraction**: Uses content-core library; supports 50+ file types\n- **URL handling**: Extracts text + metadata from web pages\n- **Large files**: Content processing is sync; may block API briefly\n\n---\n\n## Component References\n\nSee dedicated CLAUDE.md files for detailed guidance:\n\n- **[frontend/CLAUDE.md](frontend/CLAUDE.md)**: React/Next.js architecture, state management, API integration\n- **[api/CLAUDE.md](api/CLAUDE.md)**: FastAPI structure, service pattern, endpoint development\n- **[open_notebook/CLAUDE.md](open_notebook/CLAUDE.md)**: Backend core, domain models, LangGraph workflows, AI provisioning\n- **[open_notebook/domain/CLAUDE.md](open_notebook/domain/CLAUDE.md)**: Data models, repository pattern, search functions\n- **[open_notebook/ai/CLAUDE.md](open_notebook/ai/CLAUDE.md)**: ModelManager, AI provider integration, Esperanto usage\n- **[open_notebook/graphs/CLAUDE.md](open_notebook/graphs/CLAUDE.md)**: LangGraph workflow design, state machines\n- **[open_notebook/database/CLAUDE.md](open_notebook/database/CLAUDE.md)**: SurrealDB operations, migrations, async patterns\n\n---\n\n## Documentation Map\n\n- **[README.md](README.md)**: Project overview, features, quick start\n- **[docs/index.md](docs/index.md)**: Complete user & deployment documentation\n- **[CONFIGURATION.md](CONFIGURATION.md)**: Environment variables, model configuration\n- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Contribution guidelines\n- **[MAINTAINER_GUIDE.md](MAINTAINER_GUIDE.md)**: Release & maintenance procedures\n\n---\n\n## Testing Strategy\n\n- **Unit tests**: `tests/test_domain.py`, `test_models_api.py`\n- **Graph tests**: `tests/test_graphs.py` (workflow integration)\n- **Utils tests**: `tests/test_utils.py`, `tests/test_chunking.py`, `tests/test_embedding.py`\n- **Run all**: `uv run pytest tests/`\n- **Coverage**: Check with `pytest --cov`\n\n---\n\n## Common Tasks\n\n### Add a New API Endpoint\n1. Create router in `api/routers/feature.py`\n2. Create service in `api/feature_service.py`\n3. Define schemas in `api/models.py`\n4. Register router in `api/main.py`\n5. Test via http://localhost:5055/docs\n\n### Add a New LangGraph Workflow\n1. Create `open_notebook/graphs/workflow_name.py`\n2. Define StateDict and node functions\n3. Build graph with `.add_node()` / `.add_edge()`\n4. Invoke in service: `graph.ainvoke({\"input\": ...}, config={\"...\"})`\n5. Test with sample data in `tests/`\n\n### Add Database Migration\n1. Create `migrations/XXX_description.surql`\n2. Write SurrealQL schema changes\n3. Create `migrations/XXX_description_down.surql` (optional rollback)\n4. API auto-detects on startup; migration runs if newer than recorded version\n\n### Deploy to Production\n1. Review [CONFIGURATION.md](CONFIGURATION.md) for security settings\n2. Use `make docker-release` for multi-platform image\n3. Push to Docker Hub / GitHub Container Registry\n4. Deploy `docker compose --profile multi up`\n5. Verify migrations via API logs\n\n---\n\n## Support & Community\n\n- **Documentation**: https://open-notebook.ai\n- **Discord**: https://discord.gg/37XJPXfz2w\n- **Issues**: https://github.com/lfnovo/open-notebook/issues\n- **License**: MIT (see LICENSE)\n\n"
  },
  {
    "path": "CONFIGURATION.md",
    "content": "# Configuration Guide\n\n**📍 This file has moved!**\n\nAll configuration documentation has been consolidated into the new documentation structure.\n\n👉 **[Read the Configuration Guide](docs/5-CONFIGURATION/index.md)**\n\n---\n\n## Quick Links\n\n- **AI Provider Setup** → [AI Providers](docs/5-CONFIGURATION/ai-providers.md)\n- **Environment Variables Reference** → [Environment Reference](docs/5-CONFIGURATION/environment-reference.md)\n- **Database Configuration** → [Database Setup](docs/5-CONFIGURATION/database.md)\n- **Server Configuration** → [Server Settings](docs/5-CONFIGURATION/server.md)\n- **Security Setup** → [Security Configuration](docs/5-CONFIGURATION/security.md)\n- **Reverse Proxy** → [Reverse Proxy Setup](docs/5-CONFIGURATION/reverse-proxy.md)\n- **Advanced Tuning** → [Advanced Configuration](docs/5-CONFIGURATION/advanced.md)\n\n---\n\n## What You'll Find\n\nThe new configuration documentation includes:\n\n- **Complete environment variable reference** with examples\n- **Provider-specific setup guides** for OpenAI, Anthropic, Google, Groq, Ollama, and more\n- **Production deployment configurations** with security best practices\n- **Reverse proxy examples** for Nginx, Caddy, Traefik\n- **Database tuning** for performance optimization\n- **Troubleshooting guides** for common configuration issues\n\n---\n\nFor all configuration details, see **[docs/5-CONFIGURATION/](docs/5-CONFIGURATION/index.md)**.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Open Notebook\n\n**📍 This file has moved!**\n\nAll contribution guidelines have been consolidated into the new development documentation structure.\n\n👉 **[Read the Contributing Guide](docs/7-DEVELOPMENT/contributing.md)**\n\n---\n\n## Quick Links\n\n- **Want to contribute code?** → [Contributing Guide](docs/7-DEVELOPMENT/contributing.md)\n- **Want to understand the architecture?** → [Architecture Overview](docs/7-DEVELOPMENT/architecture.md)\n- **Want to understand our design philosophy?** → [Design Principles](docs/7-DEVELOPMENT/design-principles.md)\n- **Are you a maintainer?** → [Maintainer Guide](docs/7-DEVELOPMENT/maintainer-guide.md)\n- **New developer?** → [Quick Start](docs/7-DEVELOPMENT/quick-start.md)\n\n---\n\n## The Issue-First Workflow\n\n**TL;DR**: Create an issue first, get it assigned, THEN code.\n\nThis prevents wasted effort and ensures your work aligns with the project. [See details →](docs/7-DEVELOPMENT/contributing.md)\n\n---\n\nFor all contribution details, see **[docs/7-DEVELOPMENT/contributing.md](docs/7-DEVELOPMENT/contributing.md)**.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Build stage\nFROM python:3.12-slim-bookworm AS builder\n\n# Install uv using the official method\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\n\n# Install system dependencies required for building certain Python packages\n# Add Node.js 20.x LTS for building frontend\n# NOTE: gcc/g++/make removed - uv should download pre-built wheels. Add back if build fails.\n# NOTE: gcc/g++/make required for some python dependencies\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n    curl \\\n    build-essential \\\n    && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\\n    && apt-get install -y nodejs \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Set build optimization environment variables\nENV MAKEFLAGS=\"-j$(nproc)\"\nENV PYTHONDONTWRITEBYTECODE=1\nENV PYTHONUNBUFFERED=1\nENV UV_COMPILE_BYTECODE=1\nENV UV_LINK_MODE=copy\n\n# Set the working directory in the container to /app\nWORKDIR /app\n\n# Copy dependency files and minimal package structure first for better layer caching\nCOPY pyproject.toml uv.lock ./\nCOPY open_notebook/__init__.py ./open_notebook/__init__.py\n\n# Install dependencies with optimizations (this layer will be cached unless dependencies change)\nRUN uv sync --frozen --no-dev\n\n# Pre-download tiktoken encoding so the app works offline (issue #264).\n# /app/tiktoken-cache is intentionally outside /app/data/ so that volume mounts\n# of /app/data (for user data persistence) do not hide the pre-baked encoding.\n# config.py reads TIKTOKEN_CACHE_DIR from the environment to pick up this path.\nENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache\nRUN mkdir -p /app/tiktoken-cache && \\\n    .venv/bin/python -c \"import tiktoken; tiktoken.get_encoding('o200k_base')\"\n\n# Copy the rest of the application code\nCOPY . /app\n\n# Install frontend dependencies and build\nWORKDIR /app/frontend\nARG NPM_REGISTRY=https://registry.npmjs.org/\nCOPY frontend/package.json frontend/package-lock.json ./\nRUN npm config set registry ${NPM_REGISTRY}\nRUN npm ci\nCOPY frontend/ ./\nRUN npm run build\n\n# Return to app root\nWORKDIR /app\n\n# Runtime stage\nFROM python:3.12-slim-bookworm AS runtime\n\n# Install only runtime system dependencies (no build tools)\n# Add Node.js 20.x LTS for running frontend\nRUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \\\n    ffmpeg \\\n    supervisor \\\n    curl \\\n    && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\\n    && apt-get install -y nodejs \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Install uv using the official method\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\n\n# Set the working directory in the container to /app\nWORKDIR /app\n\n# Copy the virtual environment from builder stage\nCOPY --from=builder /app/.venv /app/.venv\n\n# Copy the source code (the rest)\nCOPY . /app\n\n# Copy pre-downloaded tiktoken encoding from builder (outside /data/ — volume-mount safe)\nCOPY --from=builder /app/tiktoken-cache /app/tiktoken-cache\n\n# Ensure uv uses the existing venv without attempting network operations\nENV UV_NO_SYNC=1\nENV VIRTUAL_ENV=/app/.venv\n# Point the app at the pre-baked tiktoken encoding (see open_notebook/config.py)\nENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache\n\n# Bind Next.js to all interfaces (required for Docker networking and reverse proxies)\nENV HOSTNAME=0.0.0.0\n\n# Copy built frontend from builder stage\nCOPY --from=builder /app/frontend/.next/standalone /app/frontend/\nCOPY --from=builder /app/frontend/.next/static /app/frontend/.next/static\nCOPY --from=builder /app/frontend/public /app/frontend/public\nCOPY --from=builder /app/frontend/start-server.js /app/frontend/start-server.js\n\n# Expose ports for Frontend and API\nEXPOSE 8502 5055\n\nRUN mkdir -p /app/data\n\n# Copy and make executable the wait-for-api script\nCOPY scripts/wait-for-api.sh /app/scripts/wait-for-api.sh\nRUN chmod +x /app/scripts/wait-for-api.sh\n\n# Copy supervisord configuration\nCOPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf\n\n# Create log directories\nRUN mkdir -p /var/log/supervisor\n\n# Runtime API URL Configuration\n# The API_URL environment variable can be set at container runtime to configure\n# where the frontend should connect to the API. This allows the same Docker image\n# to work in different deployment scenarios without rebuilding.\n#\n# If not set, the system will auto-detect based on incoming requests.\n# Set API_URL when using reverse proxies or custom domains.\n#\n# Example: docker run -e API_URL=https://your-domain.com/api ...\n\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/etc/supervisor/conf.d/supervisord.conf\"]\n"
  },
  {
    "path": "Dockerfile.single",
    "content": "# Stage 1: Frontend Builder\nFROM node:20-slim AS frontend-builder\nWORKDIR /app/frontend\n\n# Copy dependency files first to leverage cache\nCOPY frontend/package.json frontend/package-lock.json ./\nARG NPM_REGISTRY=https://registry.npmjs.org/\nRUN npm config set registry ${NPM_REGISTRY}\nRUN npm ci\n\n# Copy the rest of the frontend source\nCOPY frontend/ ./\n# Build the frontend\nRUN npm run build\n\n# Stage 2: SurrealDB binary (pinned to v2 to match docker-compose.yml)\nFROM surrealdb/surrealdb:v2 AS surreal-binary\n\n# Stage 4: Backend Builder\nFROM python:3.12-slim-bookworm AS backend-builder\n# Install build dependencies\nRUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*\n# Install uv\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\nWORKDIR /app\n\n# Set build optimization environment variables\nENV UV_HTTP_TIMEOUT=120\n\n# Copy dependency files first\nCOPY pyproject.toml uv.lock ./\nCOPY open_notebook/__init__.py ./open_notebook/__init__.py\n# Install dependencies\nRUN uv sync --frozen --no-dev\n\n# Pre-download tiktoken encoding so the app works offline (issue #264).\n# /app/tiktoken-cache is intentionally outside /app/data/ so that volume mounts\n# of /app/data (for user data persistence) do not hide the pre-baked encoding.\n# config.py reads TIKTOKEN_CACHE_DIR from the environment to pick up this path.\nENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache\nRUN mkdir -p /app/tiktoken-cache && \\\n    .venv/bin/python -c \"import tiktoken; tiktoken.get_encoding('o200k_base')\"\n\n# Stage 5: Runtime\nFROM python:3.12-slim-bookworm AS runtime\n\n# Install runtime dependencies\nRUN apt-get update && apt-get upgrade -y && apt-get install -y \\\n    ffmpeg \\\n    supervisor \\\n    curl \\\n    && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \\\n    && apt-get install -y nodejs \\\n    && rm -rf /var/lib/apt/lists/*\n\n# Install SurrealDB (copied from pinned v2 image to match docker-compose.yml)\nCOPY --from=surreal-binary /surreal /usr/local/bin/surreal\n\n# Install uv (optional but helpful for some scripts)\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/\n\nWORKDIR /app\n\n# Copy backend virtualenv and source code\nCOPY --from=backend-builder /app/.venv /app/.venv\nCOPY . /app/\n\n# Copy pre-downloaded tiktoken encoding from builder (outside /data/ — volume-mount safe)\nCOPY --from=backend-builder /app/tiktoken-cache /app/tiktoken-cache\n\n# Copy built frontend from standalone output\nCOPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/\nCOPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static\nCOPY --from=frontend-builder /app/frontend/public /app/frontend/public\n\n# Bind Next.js to all interfaces (required for Docker networking and reverse proxies)\nENV HOSTNAME=0.0.0.0\n# Point the app at the pre-baked tiktoken encoding (see open_notebook/config.py)\nENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache\n\n# Setup directories and permissions\nRUN mkdir -p /app/data /mydata\n\n# Ensure wait-for-api script is executable\nRUN chmod +x /app/scripts/wait-for-api.sh\n\n# Copy supervisord configuration\nCOPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf\n\n# Create log directories\nRUN mkdir -p /var/log/supervisor\n\n# Expose ports\nEXPOSE 8502 5055\n\n# Set startup command\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/etc/supervisor/conf.d/supervisord.conf\"]"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\nCopyright (c) 2024 Luis Novo\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "MAINTAINER_GUIDE.md",
    "content": "# Maintainer Guide\n\n**📍 This file has moved!**\n\nAll maintainer guidelines have been consolidated into the new development documentation structure.\n\n👉 **[Read the Maintainer Guide](docs/7-DEVELOPMENT/maintainer-guide.md)**\n\n---\n\n## Quick Links\n\n- **Maintainer Guide** → [docs/7-DEVELOPMENT/maintainer-guide.md](docs/7-DEVELOPMENT/maintainer-guide.md)\n- **Contributing Guide** → [docs/7-DEVELOPMENT/contributing.md](docs/7-DEVELOPMENT/contributing.md)\n- **Design Principles** → [docs/7-DEVELOPMENT/design-principles.md](docs/7-DEVELOPMENT/design-principles.md)\n\n---\n\nFor all maintainer details, see **[docs/7-DEVELOPMENT/maintainer-guide.md](docs/7-DEVELOPMENT/maintainer-guide.md)**.\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: run frontend check ruff database lint api start-all stop-all status clean-cache worker worker-start worker-stop worker-restart\n.PHONY: docker-buildx-prepare docker-buildx-clean docker-buildx-reset\n.PHONY: docker-push docker-push-latest docker-release docker-build-local tag export-docs\n\n# Get version from pyproject.toml\nVERSION := $(shell grep -m1 version pyproject.toml | cut -d'\"' -f2)\n\n# Image names for both registries\nDOCKERHUB_IMAGE := lfnovo/open_notebook\nGHCR_IMAGE := ghcr.io/lfnovo/open-notebook\n\n# Build platforms\nPLATFORMS := linux/amd64,linux/arm64\n\ndatabase:\n\tdocker compose up -d surrealdb\n\nrun:\n\t@echo \"⚠️  Warning: Starting frontend only. For full functionality, use 'make start-all'\"\n\tcd frontend && npm run dev\n\nfrontend:\n\tcd frontend && npm run dev\n\nlint:\n\tuv run python -m mypy .\n\nruff:\n\truff check . --fix\n\n# === Docker Build Setup ===\ndocker-buildx-prepare:\n\t@docker buildx inspect multi-platform-builder >/dev/null 2>&1 || \\\n\t\tdocker buildx create --use --name multi-platform-builder --driver docker-container\n\t@docker buildx use multi-platform-builder\n\ndocker-buildx-clean:\n\t@echo \"🧹 Cleaning up buildx builders...\"\n\t@docker buildx rm multi-platform-builder 2>/dev/null || true\n\t@docker ps -a | grep buildx_buildkit | awk '{print $$1}' | xargs -r docker rm -f 2>/dev/null || true\n\t@echo \"✅ Buildx cleanup complete!\"\n\ndocker-buildx-reset: docker-buildx-clean docker-buildx-prepare\n\t@echo \"✅ Buildx reset complete!\"\n\n# === Docker Build Targets ===\n\n# Build production image for local platform only (no push)\ndocker-build-local:\n\t@echo \"🔨 Building production image locally ($(shell uname -m))...\"\n\tdocker build \\\n\t\t-t $(DOCKERHUB_IMAGE):$(VERSION) \\\n\t\t-t $(DOCKERHUB_IMAGE):local \\\n\t\t.\n\t@echo \"✅ Built $(DOCKERHUB_IMAGE):$(VERSION) and $(DOCKERHUB_IMAGE):local\"\n\t@echo \"Run with: docker run -p 5055:5055 -p 3000:3000 $(DOCKERHUB_IMAGE):local\"\n\n# Build and push version tags ONLY (no latest) for both regular and single images\ndocker-push: docker-buildx-prepare\n\t@echo \"📤 Building and pushing version $(VERSION) to both registries...\"\n\t@echo \"🔨 Building regular image...\"\n\tdocker buildx build --pull \\\n\t\t--platform $(PLATFORMS) \\\n\t\t--progress=plain \\\n\t\t-t $(DOCKERHUB_IMAGE):$(VERSION) \\\n\t\t-t $(GHCR_IMAGE):$(VERSION) \\\n\t\t--push \\\n\t\t.\n\t@echo \"🔨 Building single-container image...\"\n\tdocker buildx build --pull \\\n\t\t--platform $(PLATFORMS) \\\n\t\t--progress=plain \\\n\t\t-f Dockerfile.single \\\n\t\t-t $(DOCKERHUB_IMAGE):$(VERSION)-single \\\n\t\t-t $(GHCR_IMAGE):$(VERSION)-single \\\n\t\t--push \\\n\t\t.\n\t@echo \"✅ Pushed version $(VERSION) to both registries (latest NOT updated)\"\n\t@echo \"  📦 Docker Hub:\"\n\t@echo \"    - $(DOCKERHUB_IMAGE):$(VERSION)\"\n\t@echo \"    - $(DOCKERHUB_IMAGE):$(VERSION)-single\"\n\t@echo \"  📦 GHCR:\"\n\t@echo \"    - $(GHCR_IMAGE):$(VERSION)\"\n\t@echo \"    - $(GHCR_IMAGE):$(VERSION)-single\"\n\n# Update v1-latest tags to current version (both regular and single images)\ndocker-push-latest: docker-buildx-prepare\n\t@echo \"📤 Updating v1-latest tags to version $(VERSION)...\"\n\t@echo \"🔨 Building regular image with latest tag...\"\n\tdocker buildx build --pull \\\n\t\t--platform $(PLATFORMS) \\\n\t\t--progress=plain \\\n\t\t-t $(DOCKERHUB_IMAGE):$(VERSION) \\\n\t\t-t $(DOCKERHUB_IMAGE):v1-latest \\\n\t\t-t $(GHCR_IMAGE):$(VERSION) \\\n\t\t-t $(GHCR_IMAGE):v1-latest \\\n\t\t--push \\\n\t\t.\n\t@echo \"🔨 Building single-container image with latest tag...\"\n\tdocker buildx build --pull \\\n\t\t--platform $(PLATFORMS) \\\n\t\t--progress=plain \\\n\t\t-f Dockerfile.single \\\n\t\t-t $(DOCKERHUB_IMAGE):$(VERSION)-single \\\n\t\t-t $(DOCKERHUB_IMAGE):v1-latest-single \\\n\t\t-t $(GHCR_IMAGE):$(VERSION)-single \\\n\t\t-t $(GHCR_IMAGE):v1-latest-single \\\n\t\t--push \\\n\t\t.\n\t@echo \"✅ Updated v1-latest to version $(VERSION)\"\n\t@echo \"  📦 Docker Hub:\"\n\t@echo \"    - $(DOCKERHUB_IMAGE):$(VERSION) → v1-latest\"\n\t@echo \"    - $(DOCKERHUB_IMAGE):$(VERSION)-single → v1-latest-single\"\n\t@echo \"  📦 GHCR:\"\n\t@echo \"    - $(GHCR_IMAGE):$(VERSION) → v1-latest\"\n\t@echo \"    - $(GHCR_IMAGE):$(VERSION)-single → v1-latest-single\"\n\n# Full release: push version AND update latest tags\ndocker-release: docker-push-latest\n\t@echo \"✅ Full release complete for version $(VERSION)\"\n\ntag:\n\t@version=$$(grep '^version = ' pyproject.toml | sed 's/version = \"\\(.*\\)\"/\\1/'); \\\n\techo \"Creating tag v$$version\"; \\\n\tgit tag \"v$$version\"; \\\n\tgit push origin \"v$$version\"\n\n\ndev:\n\tdocker compose -f docker-compose.dev.yml up --build \n\nfull:\n\tdocker compose -f docker-compose.full.yml up --build \n\n\napi:\n\tuv run --env-file .env run_api.py\n\n.PHONY: worker worker-start worker-stop worker-restart\n\nworker: worker-start\n\nworker-start:\n\t@echo \"Starting surreal-commands worker...\"\n\tuv run --env-file .env surreal-commands-worker --import-modules commands\n\nworker-stop:\n\t@echo \"Stopping surreal-commands worker...\"\n\tpkill -f \"surreal-commands-worker\" || true\n\nworker-restart: worker-stop\n\t@sleep 2\n\t@$(MAKE) worker-start\n\n# === Service Management ===\nstart-all:\n\t@echo \"🚀 Starting Open Notebook (Database + API + Worker + Frontend)...\"\n\t@echo \"📊 Starting SurrealDB...\"\n\t@docker compose -f docker-compose.dev.yml up -d surrealdb\n\t@sleep 3\n\t@echo \"🔧 Starting API backend...\"\n\t@uv run run_api.py &\n\t@sleep 3\n\t@echo \"⚙️ Starting background worker...\"\n\t@uv run --env-file .env surreal-commands-worker --import-modules commands &\n\t@sleep 2\n\t@echo \"🌐 Starting Next.js frontend...\"\n\t@echo \"✅ All services started!\"\n\t@echo \"📱 Frontend: http://localhost:3000\"\n\t@echo \"🔗 API: http://localhost:5055\"\n\t@echo \"📚 API Docs: http://localhost:5055/docs\"\n\tcd frontend && npm run dev\n\nstop-all:\n\t@echo \"🛑 Stopping all Open Notebook services...\"\n\t@pkill -f \"next dev\" || true\n\t@pkill -f \"surreal-commands-worker\" || true\n\t@pkill -f \"run_api.py\" || true\n\t@pkill -f \"uvicorn api.main:app\" || true\n\t@docker compose down\n\t@echo \"✅ All services stopped!\"\n\nstatus:\n\t@echo \"📊 Open Notebook Service Status:\"\n\t@echo \"Database (SurrealDB):\"\n\t@docker compose ps surrealdb 2>/dev/null || echo \"  ❌ Not running\"\n\t@echo \"API Backend:\"\n\t@pgrep -f \"run_api.py\\|uvicorn api.main:app\" >/dev/null && echo \"  ✅ Running\" || echo \"  ❌ Not running\"\n\t@echo \"Background Worker:\"\n\t@pgrep -f \"surreal-commands-worker\" >/dev/null && echo \"  ✅ Running\" || echo \"  ❌ Not running\"\n\t@echo \"Next.js Frontend:\"\n\t@pgrep -f \"next dev\" >/dev/null && echo \"  ✅ Running\" || echo \"  ❌ Not running\"\n\n# === Documentation Export ===\nexport-docs:\n\t@echo \"📚 Exporting documentation...\"\n\t@uv run python scripts/export_docs.py\n\t@echo \"✅ Documentation export complete!\"\n\n# === Cleanup ===\nclean-cache:\n\t@echo \"🧹 Cleaning cache directories...\"\n\t@find . -name \"__pycache__\" -type d -exec rm -rf {} + 2>/dev/null || true\n\t@find . -name \".mypy_cache\" -type d -exec rm -rf {} + 2>/dev/null || true\n\t@find . -name \".ruff_cache\" -type d -exec rm -rf {} + 2>/dev/null || true\n\t@find . -name \".pytest_cache\" -type d -exec rm -rf {} + 2>/dev/null || true\n\t@find . -name \"*.pyc\" -type f -delete 2>/dev/null || true\n\t@find . -name \"*.pyo\" -type f -delete 2>/dev/null || true\n\t@find . -name \"*.pyd\" -type f -delete 2>/dev/null || true\n\t@echo \"✅ Cache directories cleaned!\""
  },
  {
    "path": "README.dev.md",
    "content": "# Developer Guide\n\nThis guide is for developers working on Open Notebook. For end-user documentation, see [README.md](README.md) and [docs/](docs/).\n\n## Quick Start for Development\n\n```bash\n# 1. Clone and setup\ngit clone https://github.com/lfnovo/open-notebook.git\ncd open-notebook\n\n# 2. Copy environment files\ncp .env.example .env\ncp .env.example docker.env\n\n# 3. Install dependencies\nuv sync\n\n# 4. Start all services (recommended for development)\nmake start-all\n```\n\n## Development Workflows\n\n### When to Use What?\n\n| Workflow | Use Case | Speed | Production Parity |\n|----------|----------|-------|-------------------|\n| **Local Services** (`make start-all`) | Day-to-day development, fastest iteration | ⚡⚡⚡ Fast | Medium |\n| **Docker Compose** (`make dev`) | Testing containerized setup | ⚡⚡ Medium | High |\n| **Local Docker Build** (`make docker-build-local`) | Testing Dockerfile changes | ⚡ Slow | Very High |\n| **Multi-platform Build** (`make docker-push`) | Publishing releases | 🐌 Very Slow | Exact |\n\n---\n\n## 1. Local Development (Recommended)\n\n**Best for:** Daily development, hot reload, debugging\n\n### Setup\n\n```bash\n# Start database\nmake database\n\n# Start all services (DB + API + Worker + Frontend)\nmake start-all\n```\n\n### What This Does\n\n1. Starts SurrealDB in Docker (port 8000)\n2. Starts FastAPI backend (port 5055)\n3. Starts background worker (surreal-commands)\n4. Starts Next.js frontend (port 3000)\n\n### Individual Services\n\n```bash\n# Just the database\nmake database\n\n# Just the API\nmake api\n\n# Just the frontend\nmake frontend\n\n# Just the worker\nmake worker\n```\n\n### Checking Status\n\n```bash\n# See what's running\nmake status\n\n# Stop everything\nmake stop-all\n```\n\n### Advantages\n- ✅ Fastest iteration (hot reload)\n- ✅ Easy debugging (direct process access)\n- ✅ Low resource usage\n- ✅ Direct log access\n\n### Disadvantages\n- ❌ Doesn't test Docker build\n- ❌ Environment may differ from production\n- ❌ Requires local Python/Node setup\n\n---\n\n## 2. Docker Compose Development\n\n**Best for:** Testing containerized setup, CI/CD verification\n\n```bash\n# Start with dev profile\nmake dev\n\n# Or full stack\nmake full\n```\n\n### Configuration Files\n\n- `docker-compose.dev.yml` - Development setup\n- `docker-compose.full.yml` - Full stack setup\n- `docker-compose.yml` - Base configuration\n\n### Advantages\n- ✅ Closer to production environment\n- ✅ Isolated dependencies\n- ✅ Easy to share exact environment\n\n### Disadvantages\n- ❌ Slower rebuilds\n- ❌ More complex debugging\n- ❌ Higher resource usage\n\n---\n\n## 3. Testing Production Docker Images\n\n**Best for:** Verifying Dockerfile changes before publishing\n\n### Build Locally\n\n```bash\n# Build production image for your platform only\nmake docker-build-local\n```\n\nThis creates two tags:\n- `lfnovo/open_notebook:<version>` (from pyproject.toml)\n- `lfnovo/open_notebook:local`\n\n### Run Locally\n\n```bash\ndocker run -p 5055:5055 -p 3000:3000 lfnovo/open_notebook:local\n```\n\n### When to Use\n- ✅ Before pushing to registry\n- ✅ Testing Dockerfile changes\n- ✅ Debugging production-specific issues\n- ✅ Verifying build process\n\n---\n\n## 4. Publishing Docker Images\n\n### Workflow\n\n```bash\n# 1. Test locally first\nmake docker-build-local\n\n# 2. If successful, push version tag (no latest update)\nmake docker-push\n\n# 3. Test the pushed version in staging/production\n\n# 4. When ready, promote to latest\nmake docker-push-latest\n```\n\n### Available Commands\n\n| Command | What It Does | Updates Latest? |\n|---------|--------------|-----------------|\n| `make docker-build-local` | Build for current platform only | No registry push |\n| `make docker-push` | Push version tags to registries | ❌ No |\n| `make docker-push-latest` | Push version + update v1-latest | ✅ Yes |\n| `make docker-release` | Full release (same as docker-push-latest) | ✅ Yes |\n\n### Publishing Details\n\n- **Platforms:** `linux/amd64`, `linux/arm64`\n- **Registries:** Docker Hub + GitHub Container Registry\n- **Image Variants:** Regular + Single-container (`-single`)\n- **Version Source:** `pyproject.toml`\n\n### Creating Git Tags\n\n```bash\n# Create and push git tag matching pyproject.toml version\nmake tag\n```\n\n---\n\n## Code Quality\n\n```bash\n# Run linter with auto-fix\nmake ruff\n\n# Run type checking\nmake lint\n\n# Run tests\nuv run pytest tests/\n\n# Clean cache directories\nmake clean-cache\n```\n\n---\n\n## Common Development Tasks\n\n### Adding a New Feature\n\n1. Create feature branch\n2. Develop using `make start-all`\n3. Write tests\n4. Run `make ruff` and `make lint`\n5. Test with `make docker-build-local`\n6. Create PR\n\n### Fixing a Bug\n\n1. Reproduce locally with `make start-all`\n2. Add test case demonstrating bug\n3. Fix the bug\n4. Verify test passes\n5. Check with `make docker-build-local`\n\n### Updating Dependencies\n\n```bash\n# Add Python dependency\nuv add package-name\n\n# Update dependencies\nuv sync\n\n# Frontend dependencies\ncd frontend && npm install package-name\n```\n\n### Adding a New Language (i18n)\n\nOpen Notebook supports internationalization. To add a new language:\n\n1. **Create locale file**: Copy an existing locale as template\n   ```bash\n   cp frontend/src/lib/locales/en-US/index.ts frontend/src/lib/locales/pt-BR/index.ts\n   ```\n\n2. **Translate all strings** in the new file. The structure includes:\n   - `common`: Shared UI elements (buttons, labels)\n   - `notebooks`, `sources`, `notes`: Feature-specific strings\n   - `chat`, `search`, `podcasts`: Module-specific strings\n   - `apiErrors`: Error message translations\n\n3. **Register the locale** in `frontend/src/lib/locales/index.ts`:\n   ```typescript\n   import { ptBR } from './pt-BR'\n\n   export const locales = {\n     'en-US': enUS,\n     'zh-CN': zhCN,\n     'zh-TW': zhTW,\n     'pt-BR': ptBR,  // Add your locale\n   }\n   ```\n\n4. **Add date-fns locale** in `frontend/src/lib/utils/date-locale.ts`:\n   ```typescript\n   import { zhCN, enUS, zhTW, ptBR } from 'date-fns/locale'\n\n   const LOCALE_MAP: Record<string, Locale> = {\n     'zh-CN': zhCN,\n     'zh-TW': zhTW,\n     'en-US': enUS,\n     'pt-BR': ptBR,  // Add your locale\n   }\n   ```\n\n5. **Test**: Switch languages using the language toggle in the UI header.\n\n### Database Migrations\n\nDatabase migrations run **automatically** when the API starts.\n\n1. Create migration file: `migrations/XXX_description.surql`\n2. Write SurrealQL schema changes\n3. (Optional) Create rollback: `migrations/XXX_description_down.surql`\n4. Restart API - migration runs on startup\n\n---\n\n## Troubleshooting\n\n### Services Won't Start\n\n```bash\n# Check status\nmake status\n\n# Check database\ndocker compose ps surrealdb\n\n# View logs\ndocker compose logs surrealdb\n\n# Restart everything\nmake stop-all\nmake start-all\n```\n\n### Port Already in Use\n\n```bash\n# Find process using port\nlsof -i :5055\nlsof -i :3000\nlsof -i :8000\n\n# Kill stuck processes\nmake stop-all\n```\n\n### Database Connection Issues\n\n```bash\n# Verify SurrealDB is running\ndocker compose ps surrealdb\n\n# Check connection settings in .env\ncat .env | grep SURREAL\n```\n\n### Docker Build Fails\n\n```bash\n# Clean Docker cache\ndocker builder prune\n\n# Reset buildx\nmake docker-buildx-reset\n\n# Try local build first\nmake docker-build-local\n```\n\n---\n\n## Project Structure\n\n```\nopen-notebook/\n├── api/                    # FastAPI backend\n├── frontend/               # Next.js React frontend\n├── open_notebook/          # Python core library\n│   ├── domain/            # Domain models\n│   ├── graphs/            # LangGraph workflows\n│   ├── ai/                # AI provider integration\n│   └── database/          # SurrealDB operations\n├── migrations/             # Database migrations\n├── tests/                  # Test suite\n├── docs/                   # User documentation\n└── Makefile               # Development commands\n```\n\nSee component-specific CLAUDE.md files for detailed architecture:\n- [frontend/CLAUDE.md](frontend/CLAUDE.md)\n- [api/CLAUDE.md](api/CLAUDE.md)\n- [open_notebook/CLAUDE.md](open_notebook/CLAUDE.md)\n\n---\n\n## Environment Variables\n\n### Required for Local Development\n\n```bash\n# .env file\nSURREAL_URL=ws://localhost:8000\nSURREAL_USER=root\nSURREAL_PASS=root\nSURREAL_DB=open_notebook\nSURREAL_NS=production\n\n# AI Provider (at least one required)\nOPENAI_API_KEY=sk-...\n# OR\nANTHROPIC_API_KEY=sk-ant-...\n# OR configure other providers (see docs/5-CONFIGURATION/)\n```\n\nSee [docs/5-CONFIGURATION/](docs/5-CONFIGURATION/) for complete configuration guide.\n\n---\n\n## Performance Tips\n\n### Speed Up Local Development\n\n1. **Use `make start-all`** instead of Docker for daily work\n2. **Keep SurrealDB running** between sessions (`make database`)\n3. **Use `make docker-build-local`** only when testing Dockerfile changes\n4. **Skip multi-platform builds** until ready to publish\n\n### Reduce Resource Usage\n\n```bash\n# Stop unused services\nmake stop-all\n\n# Clean up Docker\ndocker system prune -a\n\n# Clean Python cache\nmake clean-cache\n```\n\n---\n\n## TODO: Sections to Add\n\n- [ ] Frontend development guide (hot reload, component structure)\n- [ ] API development guide (adding endpoints, services)\n- [ ] LangGraph workflow development\n- [ ] Testing strategy and coverage\n- [ ] Debugging tips (VSCode/PyCharm setup)\n- [ ] CI/CD pipeline overview\n- [ ] Release process checklist\n- [ ] Common error messages and solutions\n\n---\n\n## Resources\n\n- **Documentation:** https://open-notebook.ai\n- **Discord:** https://discord.gg/37XJPXfz2w\n- **Issues:** https://github.com/lfnovo/open-notebook/issues\n- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md)\n- **Maintainer Guide:** [MAINTAINER_GUIDE.md](MAINTAINER_GUIDE.md)\n\n---\n\n**Last Updated:** January 2025\n"
  },
  {
    "path": "README.md",
    "content": "<a id=\"readme-top\"></a>\n\n<!-- [![Contributors][contributors-shield]][contributors-url] -->\n[![Forks][forks-shield]][forks-url]\n[![Stargazers][stars-shield]][stars-url]\n[![Issues][issues-shield]][issues-url]\n[![MIT License][license-shield]][license-url]\n<!-- [![LinkedIn][linkedin-shield]][linkedin-url] -->\n\n\n<!-- PROJECT LOGO -->\n<br />\n<div align=\"center\">\n  <a href=\"https://github.com/lfnovo/open-notebook\">\n    <img src=\"docs/assets/hero.svg\" alt=\"Logo\">\n  </a>\n\n  <h3 align=\"center\">Open Notebook</h3>\n\n  <p align=\"center\">\n    An open source, privacy-focused alternative to Google's Notebook LM!\n    <br /><strong>Join our <a href=\"https://discord.gg/37XJPXfz2w\">Discord server</a> for help, to share workflow ideas, and suggest features!</strong>\n    <br />\n    <a href=\"https://www.open-notebook.ai\"><strong>Checkout our website »</strong></a>\n    <br />\n    <br />\n    <a href=\"docs/0-START-HERE/index.md\">📚 Get Started</a>\n    ·\n    <a href=\"docs/3-USER-GUIDE/index.md\">📖 User Guide</a>\n    ·\n    <a href=\"docs/2-CORE-CONCEPTS/index.md\">✨ Features</a>\n    ·\n    <a href=\"docs/1-INSTALLATION/index.md\">🚀 Deploy</a>\n  </p>\n</div>\n\n<p align=\"center\">\n<a href=\"https://trendshift.io/repositories/14536\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/14536\" alt=\"lfnovo%2Fopen-notebook | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"center\">\n  <!-- Keep these links. Translations will automatically update with the README. -->\n  <a href=\"https://zdoc.app/de/lfnovo/open-notebook\">Deutsch</a> | \n  <a href=\"https://zdoc.app/es/lfnovo/open-notebook\">Español</a> | \n  <a href=\"https://zdoc.app/fr/lfnovo/open-notebook\">français</a> | \n  <a href=\"https://zdoc.app/ja/lfnovo/open-notebook\">日本語</a> | \n  <a href=\"https://zdoc.app/ko/lfnovo/open-notebook\">한국어</a> | \n  <a href=\"https://zdoc.app/pt/lfnovo/open-notebook\">Português</a> | \n  <a href=\"https://zdoc.app/ru/lfnovo/open-notebook\">Русский</a> | \n  <a href=\"https://zdoc.app/zh/lfnovo/open-notebook\">中文</a>\n</div>\n\n## A private, multi-model, 100% local, full-featured alternative to Notebook LM\n\n![New Notebook](docs/assets/asset_list.png)\n\nIn a world dominated by Artificial Intelligence, having the ability to think 🧠 and acquire new knowledge 💡, is a skill that should not be a privilege for a few, nor restricted to a single provider.\n\n**Open Notebook empowers you to:**\n- 🔒 **Control your data** - Keep your research private and secure\n- 🤖 **Choose your AI models** - Support for 16+ providers including OpenAI, Anthropic, Ollama, LM Studio, and more\n- 📚 **Organize multi-modal content** - PDFs, videos, audio, web pages, and more\n- 🎙️ **Generate professional podcasts** - Advanced multi-speaker podcast generation\n- 🔍 **Search intelligently** - Full-text and vector search across all your content\n- 💬 **Chat with context** - AI conversations powered by your research\n- 🌐 **Multi-language UI** - English, Portuguese, Chinese (Simplified & Traditional), Japanese, Russian, and Bengali support\n\nLearn more about our project at [https://www.open-notebook.ai](https://www.open-notebook.ai)\n\n---\n\n## 🆚 Open Notebook vs Google Notebook LM\n\n| Feature | Open Notebook | Google Notebook LM | Advantage |\n|---------|---------------|--------------------|-----------|\n| **Privacy & Control** | Self-hosted, your data | Google cloud only | Complete data sovereignty |\n| **AI Provider Choice** | 16+ providers (OpenAI, Anthropic, Ollama, LM Studio, etc.) | Google models only | Flexibility and cost optimization |\n| **Podcast Speakers** | 1-4 speakers with custom profiles | 2 speakers only | Extreme flexibility |\n| **Content Transformations** | Custom and built-in | Limited options | Unlimited processing power |\n| **API Access** | Full REST API | No API | Complete automation |\n| **Deployment** | Docker, cloud, or local | Google hosted only | Deploy anywhere |\n| **Citations** | Basic references (will improve) | Comprehensive with sources | Research integrity |\n| **Customization** | Open source, fully customizable | Closed system | Unlimited extensibility |\n| **Cost** | Pay only for AI usage | Free tier + Monthly subscription | Transparent and controllable |\n\n**Why Choose Open Notebook?**\n- 🔒 **Privacy First**: Your sensitive research stays completely private\n- 💰 **Cost Control**: Choose cheaper AI providers or run locally with Ollama\n- 🎙️ **Better Podcasts**: Full script control and multi-speaker flexibility vs limited 2-speaker deep-dive format\n- 🔧 **Unlimited Customization**: Modify, extend, and integrate as needed\n- 🌐 **No Vendor Lock-in**: Switch providers, deploy anywhere, own your data\n\n### Built With\n\n[![Python][Python]][Python-url] [![Next.js][Next.js]][Next-url] [![React][React]][React-url] [![SurrealDB][SurrealDB]][SurrealDB-url] [![LangChain][LangChain]][LangChain-url]\n\n## 🚀 Quick Start (2 Minutes)\n\n### Prerequisites\n- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed\n- That's it! (API keys configured later in the UI)\n\n### Step 1: Get docker-compose.yml\n\n**Option A:** Download directly\n```bash\ncurl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml\n```\n\n**Option B:** Create the file manually\nCopy this into a new file called `docker-compose.yml`:\n\n```yaml\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    restart: always\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n    restart: always\n```\n\n### Step 2: Set Your Encryption Key\nEdit `docker-compose.yml` and change this line:\n```yaml\n- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n```\nto any secret value (e.g., `my-super-secret-key-123`)\n\n### Step 3: Start Services\n```bash\ndocker compose up -d\n```\n\nWait 15-20 seconds, then open: **http://localhost:8502**\n\n### Step 4: Configure AI Provider\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Choose your provider (OpenAI, Anthropic, Google, etc.)\n4. Paste your API key and click **Save**\n5. Click **Test Connection** → **Discover Models** → **Register Models**\n\nDone! You're ready to create your first notebook.\n\n> **Need an API key?** Get one from:\n> [OpenAI](https://platform.openai.com/api-keys) · [Anthropic](https://console.anthropic.com/) · [Google](https://aistudio.google.com/) · [Groq](https://console.groq.com/) (free tier)\n\n> **Want free local AI?** See [examples/docker-compose-ollama.yml](examples/) for Ollama setup\n\n---\n\n### 📚 More Installation Options\n\n- **[With Ollama (Free Local AI)](examples/docker-compose-ollama.yml)** - Run models locally without API costs\n- **[From Source (Developers)](docs/1-INSTALLATION/from-source.md)** - For development and contributions\n- **[Complete Installation Guide](docs/1-INSTALLATION/index.md)** - All deployment scenarios\n\n---\n\n### 📖 Need Help?\n\n- **🤖 AI Installation Assistant**: [CustomGPT to help you install](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant)\n- **🆘 Troubleshooting**: [5-minute troubleshooting guide](docs/6-TROUBLESHOOTING/quick-fixes.md)\n- **💬 Community Support**: [Discord Server](https://discord.gg/37XJPXfz2w)\n- **🐛 Report Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n\n---\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=lfnovo/open-notebook&type=date&legend=top-left)](https://www.star-history.com/#lfnovo/open-notebook&type=date&legend=top-left)\n\n\n## Provider Support Matrix\n\nThanks to the [Esperanto](https://github.com/lfnovo/esperanto) library, we support this providers out of the box!\n\n| Provider     | LLM Support | Embedding Support | Speech-to-Text | Text-to-Speech |\n|--------------|-------------|------------------|----------------|----------------|\n| OpenAI       | ✅          | ✅               | ✅             | ✅             |\n| Anthropic    | ✅          | ❌               | ❌             | ❌             |\n| Groq         | ✅          | ❌               | ✅             | ❌             |\n| Google (GenAI) | ✅          | ✅               | ❌             | ✅             |\n| Vertex AI    | ✅          | ✅               | ❌             | ✅             |\n| Ollama       | ✅          | ✅               | ❌             | ❌             |\n| Perplexity   | ✅          | ❌               | ❌             | ❌             |\n| ElevenLabs   | ❌          | ❌               | ✅             | ✅             |\n| Azure OpenAI | ✅          | ✅               | ❌             | ❌             |\n| Mistral      | ✅          | ✅               | ❌             | ❌             |\n| DeepSeek     | ✅          | ❌               | ❌             | ❌             |\n| Voyage       | ❌          | ✅               | ❌             | ❌             |\n| xAI          | ✅          | ❌               | ❌             | ❌             |\n| OpenRouter   | ✅          | ❌               | ❌             | ❌             |\n| OpenAI Compatible* | ✅          | ❌               | ❌             | ❌             |\n\n*Supports LM Studio and any OpenAI-compatible endpoint\n\n## ✨ Key Features\n\n### Core Capabilities\n- **🔒 Privacy-First**: Your data stays under your control - no cloud dependencies\n- **🎯 Multi-Notebook Organization**: Manage multiple research projects seamlessly\n- **📚 Universal Content Support**: PDFs, videos, audio, web pages, Office docs, and more\n- **🤖 Multi-Model AI Support**: 16+ providers including OpenAI, Anthropic, Ollama, Google, LM Studio, and more\n- **🎙️ Professional Podcast Generation**: Advanced multi-speaker podcasts with Episode Profiles\n- **🔍 Intelligent Search**: Full-text and vector search across all your content\n- **💬 Context-Aware Chat**: AI conversations powered by your research materials\n- **📝 AI-Assisted Notes**: Generate insights or write notes manually\n\n### Advanced Features\n- **⚡ Reasoning Model Support**: Full support for thinking models like DeepSeek-R1 and Qwen3\n- **🔧 Content Transformations**: Powerful customizable actions to summarize and extract insights\n- **🌐 Comprehensive REST API**: Full programmatic access for custom integrations [![API Docs](https://img.shields.io/badge/API-Documentation-blue?style=flat-square)](http://localhost:5055/docs)\n- **🔐 Optional Password Protection**: Secure public deployments with authentication\n- **📊 Fine-Grained Context Control**: Choose exactly what to share with AI models\n- **📎 Citations**: Get answers with proper source citations\n\n\n## Podcast Feature\n\n[![Check out our podcast sample](https://img.youtube.com/vi/D-760MlGwaI/0.jpg)](https://www.youtube.com/watch?v=D-760MlGwaI)\n\n## 📚 Documentation\n\n### Getting Started\n- **[📖 Introduction](docs/0-START-HERE/index.md)** - Learn what Open Notebook offers\n- **[⚡ Quick Start](docs/0-START-HERE/quick-start.md)** - Get up and running in 5 minutes\n- **[🔧 Installation](docs/1-INSTALLATION/index.md)** - Comprehensive setup guide\n- **[🎯 Your First Notebook](docs/0-START-HERE/first-notebook.md)** - Step-by-step tutorial\n\n### User Guide\n- **[📱 Interface Overview](docs/3-USER-GUIDE/interface-overview.md)** - Understanding the layout\n- **[📚 Notebooks](docs/3-USER-GUIDE/notebooks.md)** - Organizing your research\n- **[📄 Sources](docs/3-USER-GUIDE/sources.md)** - Managing content types\n- **[📝 Notes](docs/3-USER-GUIDE/notes.md)** - Creating and managing notes\n- **[💬 Chat](docs/3-USER-GUIDE/chat.md)** - AI conversations\n- **[🔍 Search](docs/3-USER-GUIDE/search.md)** - Finding information\n\n### Advanced Topics\n- **[🎙️ Podcast Generation](docs/2-CORE-CONCEPTS/podcasts.md)** - Create professional podcasts\n- **[🔧 Content Transformations](docs/2-CORE-CONCEPTS/transformations.md)** - Customize content processing\n- **[🤖 AI Models](docs/4-AI-PROVIDERS/index.md)** - AI model configuration\n- **[🔌 MCP Integration](docs/5-CONFIGURATION/mcp-integration.md)** - Connect with Claude Desktop, VS Code and other MCP clients\n- **[🔧 REST API Reference](docs/7-DEVELOPMENT/api-reference.md)** - Complete API documentation\n- **[🔐 Security](docs/5-CONFIGURATION/security.md)** - Password protection and privacy\n- **[🚀 Deployment](docs/1-INSTALLATION/index.md)** - Complete deployment guides for all scenarios\n\n<p align=\"right\">(<a href=\"#readme-top\">back to top</a>)</p>\n\n## 🗺️ Roadmap\n\n### Upcoming Features\n- **Live Front-End Updates**: Real-time UI updates for smoother experience\n- **Async Processing**: Faster UI through asynchronous content processing\n- **Cross-Notebook Sources**: Reuse research materials across projects\n- **Bookmark Integration**: Connect with your favorite bookmarking apps\n\n### Recently Completed ✅\n- **Next.js Frontend**: Modern React-based frontend with improved performance\n- **Comprehensive REST API**: Full programmatic access to all functionality\n- **Multi-Model Support**: 16+ AI providers including OpenAI, Anthropic, Ollama, LM Studio\n- **Advanced Podcast Generator**: Professional multi-speaker podcasts with Episode Profiles\n- **Content Transformations**: Powerful customizable actions for content processing\n- **Enhanced Citations**: Improved layout and finer control for source citations\n- **Multiple Chat Sessions**: Manage different conversations within notebooks\n\nSee the [open issues](https://github.com/lfnovo/open-notebook/issues) for a full list of proposed features and known issues.\n\n<p align=\"right\">(<a href=\"#readme-top\">back to top</a>)</p>\n\n\n## 📖 Need Help?\n- **🤖 AI Installation Assistant**: We have a [CustomGPT built to help you install Open Notebook](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant) - it will guide you through each step!\n- **New to Open Notebook?** Start with our [Getting Started Guide](docs/0-START-HERE/index.md)\n- **Need installation help?** Check our [Installation Guide](docs/1-INSTALLATION/index.md)\n- **Want to see it in action?** Try our [Quick Start Tutorial](docs/0-START-HERE/quick-start.md)\n\n## 🤝 Community & Contributing\n\n### Join the Community\n- 💬 **[Discord Server](https://discord.gg/37XJPXfz2w)** - Get help, share ideas, and connect with other users\n- 🐛 **[GitHub Issues](https://github.com/lfnovo/open-notebook/issues)** - Report bugs and request features\n- ⭐ **Star this repo** - Show your support and help others discover Open Notebook\n\n### Contributing\nWe welcome contributions! We're especially looking for help with:\n- **Frontend Development**: Help improve our modern Next.js/React UI\n- **Testing & Bug Fixes**: Make Open Notebook more robust\n- **Feature Development**: Build the coolest research tool together\n- **Documentation**: Improve guides and tutorials\n\n**Current Tech Stack**: Python, FastAPI, Next.js, React, SurrealDB\n**Future Roadmap**: Real-time updates, enhanced async processing\n\nSee our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to get started.\n\n<p align=\"right\">(<a href=\"#readme-top\">back to top</a>)</p>\n\n\n## 📄 License\n\nOpen Notebook is MIT licensed. See the [LICENSE](LICENSE) file for details.\n\n\n**Community Support**:\n- 💬 [Discord Server](https://discord.gg/37XJPXfz2w) - Get help, share ideas, and connect with users\n- 🐛 [GitHub Issues](https://github.com/lfnovo/open-notebook/issues) - Report bugs and request features\n- 🌐 [Website](https://www.open-notebook.ai) - Learn more about the project\n\n<p align=\"right\">(<a href=\"#readme-top\">back to top</a>)</p>\n\n\n<!-- MARKDOWN LINKS & IMAGES -->\n<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->\n[contributors-shield]: https://img.shields.io/github/contributors/lfnovo/open-notebook.svg?style=for-the-badge\n[contributors-url]: https://github.com/lfnovo/open-notebook/graphs/contributors\n[forks-shield]: https://img.shields.io/github/forks/lfnovo/open-notebook.svg?style=for-the-badge\n[forks-url]: https://github.com/lfnovo/open-notebook/network/members\n[stars-shield]: https://img.shields.io/github/stars/lfnovo/open-notebook.svg?style=for-the-badge\n[stars-url]: https://github.com/lfnovo/open-notebook/stargazers\n[issues-shield]: https://img.shields.io/github/issues/lfnovo/open-notebook.svg?style=for-the-badge\n[issues-url]: https://github.com/lfnovo/open-notebook/issues\n[license-shield]: https://img.shields.io/github/license/lfnovo/open-notebook.svg?style=for-the-badge\n[license-url]: https://github.com/lfnovo/open-notebook/blob/master/LICENSE.txt\n[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555\n[linkedin-url]: https://linkedin.com/in/lfnovo\n[product-screenshot]: images/screenshot.png\n[Next.js]: https://img.shields.io/badge/Next.js-000000?style=for-the-badge&logo=next.js&logoColor=white\n[Next-url]: https://nextjs.org/\n[React]: https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black\n[React-url]: https://reactjs.org/\n[Python]: https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white\n[Python-url]: https://www.python.org/\n[LangChain]: https://img.shields.io/badge/LangChain-3A3A3A?style=for-the-badge&logo=chainlink&logoColor=white\n[LangChain-url]: https://www.langchain.com/\n[SurrealDB]: https://img.shields.io/badge/SurrealDB-FF5E00?style=for-the-badge&logo=databricks&logoColor=white\n[SurrealDB-url]: https://surrealdb.com/\n"
  },
  {
    "path": "api/CLAUDE.md",
    "content": "# API Module\n\nFastAPI-based REST backend exposing services for notebooks, sources, notes, chat, podcasts, and AI model management.\n\n## Purpose\n\nFastAPI application serving three architectural layers: routes (HTTP endpoints), services (business logic), and models (request/response schemas). Integrates LangGraph workflows (chat, ask, source_chat), SurrealDB persistence, and AI providers via Esperanto.\n\n## Architecture Overview\n\n**Three layers**:\n1. **Routes** (`routers/*`): HTTP endpoints mapping to services\n2. **Services** (`*_service.py`): Business logic orchestrating domain models, database, graphs, AI providers\n3. **Models** (`models.py`): Pydantic request/response schemas with validation\n\n**Startup flow**:\n- Load .env environment variables\n- Initialize CORS middleware + password auth middleware\n- Run database migrations via AsyncMigrationManager on lifespan startup\n- Run podcast profile data migration (legacy string to model registry conversion)\n- Register all routers\n\n**Key services**:\n- `chat_service.py`: Invokes chat graph with messages, context\n- `podcast_service.py`: Orchestrates outline + transcript generation\n- `sources_service.py`: Content ingestion, vectorization, metadata\n- `notes_service.py`: Note creation, linking to sources/insights\n- `transformations_service.py`: Applies transformations to content\n- `models_service.py`: Manages AI provider/model configuration\n- `episode_profiles_service.py`: Manages podcast speaker/episode profiles\n\n## Component Catalog\n\n### Main Application\n- **main.py**: FastAPI app initialization, CORS setup, auth middleware, lifespan event, router registration\n- **Lifespan handler**: Runs AsyncMigrationManager on startup (database schema migration)\n- **Auth middleware**: PasswordAuthMiddleware protects endpoints (password-based access control)\n\n### Services (Business Logic)\n- **chat_service.py**: Invokes chat.py graph; handles message history via SqliteSaver\n- **podcast_service.py**: Generates outline (outline.jinja), then transcript (transcript.jinja) for episodes\n- **sources_service.py**: Ingests files/URLs (content_core), extracts text, vectorizes, saves to SurrealDB\n- **transformations_service.py**: Applies transformations via transformation.py graph\n- **models_service.py**: Manages ModelManager config (AI provider overrides)\n- **episode_profiles_service.py**: CRUD for EpisodeProfile and SpeakerProfile models\n- **insights_service.py**: Generates and retrieves source insights\n- **notes_service.py**: Creates notes linked to sources/insights\n\n### Models (Schemas)\n- **models.py**: Pydantic schemas for request/response validation\n- Request bodies: ChatRequest, CreateNoteRequest, PodcastGenerationRequest, etc.\n- Response bodies: ChatResponse, NoteResponse, PodcastResponse, etc.\n- Custom validators for enum fields, file paths, model references\n\n### Routers\n- **routers/chat.py**: POST /chat\n- **routers/source_chat.py**: POST /source/{source_id}/chat\n- **routers/podcasts.py**: POST /podcasts, GET /podcasts/{id}, POST /podcasts/episodes/{id}/retry, etc.\n- **routers/notes.py**: POST /notes, GET /notes/{id}\n- **routers/sources.py**: POST /sources, GET /sources/{id}, DELETE /sources/{id}\n- **routers/models.py**: GET /models, POST /models/config\n- **routers/credentials.py**: CRUD + test + discover + migrate for credential management\n- **routers/transformations.py**: POST /transformations\n- **routers/insights.py**: GET /sources/{source_id}/insights\n- **routers/auth.py**: POST /auth/password (password-based auth)\n- **routers/languages.py**: GET /languages (available podcast languages via pycountry+babel)\n- **routers/commands.py**: GET /commands/{command_id} (job status tracking)\n\n## Common Patterns\n\n- **Service injection via FastAPI**: Routers import services directly; no DI framework\n- **Async/await throughout**: All DB queries, graph invocations, AI calls are async\n- **SurrealDB transactions**: Services use repo_query, repo_create, repo_upsert from database layer\n- **Config override pattern**: Models/config override via models_service passed to graph.ainvoke(config=...)\n- **Error handling**: Custom exception hierarchy (`open_notebook.exceptions`) with global FastAPI exception handlers mapping to HTTP status codes (see Error Handling section below). LangGraph nodes use `classify_error()` to convert raw LLM provider errors into typed exceptions with user-friendly messages.\n- **Logging**: loguru logger in main.py; services expected to log key operations\n- **Response normalization**: All responses follow standard schema (data + metadata structure)\n\n## Key Dependencies\n\n- `fastapi`: FastAPI app, routers, HTTPException\n- `pydantic`: Validation models with Field, field_validator\n- `open_notebook.graphs`: chat, ask, source_chat, source, transformation graphs\n- `open_notebook.database`: SurrealDB repository functions (repo_query, repo_create, repo_upsert)\n- `open_notebook.domain`: Notebook, Source, Note, SourceInsight models\n- `open_notebook.ai.provision`: provision_langchain_model() factory\n- `ai_prompter`: Prompter for template rendering\n- `content_core`: extract_content() for file/URL processing\n- `esperanto`: AI provider client library (LLM, embeddings, TTS)\n- `surreal_commands`: Job queue for async operations (podcast generation)\n- `loguru`: Structured logging\n\n## Important Quirks & Gotchas\n\n- **Migration auto-run**: Database schema migrations run on every API startup (via lifespan); no manual migration steps\n- **PasswordAuthMiddleware is basic**: Uses simple password check; production deployments should replace with OAuth/JWT\n- **No request rate limiting**: No built-in rate limiting; deployment must add via proxy/middleware\n- **Service state is stateless**: Services don't cache results; each request re-queries database/AI models\n- **Graph invocation is blocking**: chat/podcast workflows may take minutes; no timeout handling in services\n- **Command job fire-and-forget**: podcast_service.py submits jobs but doesn't wait (async job queue pattern)\n- **Model override scoping**: Model config override via RunnableConfig is per-request only (not persistent)\n- **CORS open by default**: main.py CORS settings allow all origins (restrict before production)\n- **No OpenAPI security scheme**: API docs available without auth (disable before production)\n- **Services don't validate user permission**: All endpoints trust authentication layer; no per-notebook permission checks\n\n## Error Handling\n\n### Global Exception Handlers (`main.py`)\n\nFastAPI exception handlers map custom exception types from `open_notebook.exceptions` to HTTP status codes. All error responses include CORS headers.\n\n| Exception Class | HTTP Status | Use Case |\n|----------------|-------------|----------|\n| `NotFoundError` | 404 | Resource not found |\n| `InvalidInputError` | 400 | Bad request data |\n| `AuthenticationError` | 401 | Invalid/missing API key |\n| `RateLimitError` | 429 | Provider rate limit exceeded |\n| `ConfigurationError` | 422 | Wrong model name, missing config |\n| `NetworkError` | 502 | Cannot reach AI provider |\n| `ExternalServiceError` | 502 | Provider returned error (500/503, context length) |\n| `OpenNotebookError` (base) | 500 | Any other application error |\n\n### Error Classification (`open_notebook.utils.error_classifier`)\n\nThe `classify_error()` function maps raw exceptions from LLM providers/Esperanto/LangChain into the typed exceptions above with user-friendly messages. Used in all LangGraph graph nodes and SSE streaming handlers.\n\n**Flow**: Raw exception → keyword matching → `(ExceptionClass, user_message)` → raised → caught by global handler → HTTP response with descriptive message.\n\n### Frontend Integration\n\nThe frontend `getApiErrorMessage()` helper (`lib/utils/error-handler.ts`) tries i18n mapping first, then falls back to displaying the backend's descriptive error message directly.\n\n---\n\n## How to Add New Endpoint\n\n1. Create router file in `routers/` (e.g., `routers/new_feature.py`)\n2. Import router into `main.py` and register: `app.include_router(new_feature.router, tags=[\"new_feature\"])`\n3. Create service in `new_feature_service.py` with business logic\n4. Define request/response schemas in `models.py` (or create `new_feature_models.py`)\n5. Implement router functions calling service methods\n6. Test with `uv run uvicorn api.main:app --host 0.0.0.0 --port 5055`\n\n## Testing Patterns\n\n- **Interactive docs**: http://localhost:5055/docs (Swagger UI)\n- **Direct service tests**: Import service, call methods directly with test data\n- **Mock graphs**: Replace graph.ainvoke() with mock for testing service logic\n- **Database: Use test database** (separate SurrealDB instance or mock repo_query)\n\n---\n\n## Credential Management (API Configuration UI)\n\nThe Credential Management system enables users to configure AI provider credentials through the UI instead of environment variables. Keys are stored securely in SurrealDB (encrypted via Fernet) with database-first fallback to environment variables.\n\n### Router: `routers/credentials.py`\n\n**Endpoints**:\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/credentials` | List all credentials (optional `?provider=` filter) |\n| GET | `/credentials/by-provider/{provider}` | List credentials for a provider |\n| POST | `/credentials` | Create a new credential |\n| GET | `/credentials/{credential_id}` | Get a specific credential |\n| PUT | `/credentials/{credential_id}` | Update a credential |\n| DELETE | `/credentials/{credential_id}` | Delete a credential |\n| POST | `/credentials/{credential_id}/test` | Test connection using credential |\n| POST | `/credentials/{credential_id}/discover` | Discover available models |\n| POST | `/credentials/{credential_id}/register-models` | Register discovered models |\n| POST | `/credentials/migrate-from-provider-config` | Migrate from legacy ProviderConfig |\n\n**Supported Providers** (13 total):\n- Simple API key: `openai`, `anthropic`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `voyage`, `elevenlabs`\n- URL-based: `ollama`\n- Multi-field: `azure`, `vertex`, `openai_compatible`\n\n**Security Features**:\n- NEVER returns actual API key values (only metadata)\n- URL validation (SSRF protection) on all URL fields via `_validate_url()`\n- Allows private IPs and localhost for self-hosted services (Ollama, LM Studio)\n- Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` to be set for storing credentials\n\n### Domain Model: `Credential` (`open_notebook/domain/credential.py`)\n\nIndividual credential records replacing the old `ProviderConfig` singleton. Each credential stores:\n- Provider name, display name, modalities\n- Encrypted API key (via Fernet)\n- Provider-specific config (base_url, endpoint, api_version, etc.)\n\n### Integration with Key Provider (`open_notebook/ai/key_provider.py`)\n\nThe `key_provider` module provisions DB-stored credentials into environment variables for Esperanto compatibility:\n\n**Database-first Pattern**:\n1. API endpoint saves keys to `Credential` records (encrypted in SurrealDB)\n2. Before model provisioning, `provision_provider_keys(provider)` checks DB, then env vars\n3. Keys from DB are set as environment variables for Esperanto compatibility\n4. Existing env vars remain unchanged if no DB config exists\n\n**Key Functions**:\n- `get_api_key(provider)`: Get API key (DB first, env fallback)\n- `provision_provider_keys(provider)`: Set env vars from DB for a provider\n- `provision_all_keys()`: Load all provider keys from DB into env vars\n\n### Authentication\n\nNo changes to authentication. The `credentials` router uses the same `PasswordAuthMiddleware` as all other endpoints. Keys are protected by the same password-based auth.\n\n**Auth Flow** (unchanged from `api/auth.py`):\n- `PasswordAuthMiddleware`: Global middleware checking `Authorization: Bearer {password}` header\n- Default password: `open-notebook-change-me` (set `OPEN_NOTEBOOK_PASSWORD` in production)\n- Docker secrets support via `OPEN_NOTEBOOK_PASSWORD_FILE`\n\n### Connection Testing (`open_notebook/ai/connection_tester.py`)\n\nThe `/credentials/{credential_id}/test` endpoint uses minimal API calls to verify credentials:\n- Loads Credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()`\n- Uses cheapest/smallest models per provider (TEST_MODELS map)\n- Returns success status and descriptive message\n- Special handlers for ollama, openai_compatible, and azure providers\n\n### Migration Workflows\n\nTwo migration endpoints help users transition to the credential system:\n\n**From environment variables** (`POST /credentials/migrate-from-env`):\n1. Checks each provider for env var presence\n2. Creates Credential records from env var values\n3. Returns summary: migrated, skipped, errors\n\n**From legacy ProviderConfig** (`POST /credentials/migrate-from-provider-config`):\n1. Reads old ProviderConfig records from database\n2. Converts each to individual Credential records\n3. Returns summary: migrated, skipped, errors\n\n### Example Usage\n\n```python\n# Check status\nGET /credentials/status\n# Response: {\"configured\": {\"openai\": true, \"anthropic\": false}, \"source\": {\"openai\": \"database\", \"anthropic\": \"none\"}, \"encryption_configured\": true}\n\n# Create credential\nPOST /credentials\n{\"name\": \"My OpenAI Key\", \"provider\": \"openai\", \"modalities\": [\"language\", \"embedding\"], \"api_key\": \"sk-proj-...\"}\n\n# Test connection\nPOST /credentials/{credential_id}/test\n# Response: {\"provider\": \"openai\", \"success\": true, \"message\": \"Connection successful\"}\n\n# Discover models\nPOST /credentials/{credential_id}/discover\n# Response: {\"provider\": \"openai\", \"models\": [{\"model_id\": \"gpt-4\", \"name\": \"gpt-4\", ...}], \"credential_id\": \"...\"}\n\n# Migrate from env\nPOST /credentials/migrate-from-env\n# Response: {\"message\": \"Migration complete. Migrated 3 providers.\", \"migrated\": [\"openai\", \"anthropic\", \"groq\"], \"skipped\": [], \"errors\": []}\n```\n"
  },
  {
    "path": "api/__init__.py",
    "content": ""
  },
  {
    "path": "api/auth.py",
    "content": "from typing import Optional\n\nfrom fastapi import Depends, HTTPException, Request\nfrom fastapi.security import HTTPAuthorizationCredentials, HTTPBearer\nfrom loguru import logger\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.responses import JSONResponse\n\nfrom open_notebook.utils.encryption import get_secret_from_env\n\n\nclass PasswordAuthMiddleware(BaseHTTPMiddleware):\n    \"\"\"\n    Middleware to check password authentication for all API requests.\n    Always active with default password if OPEN_NOTEBOOK_PASSWORD is not set.\n    Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.\n    \"\"\"\n\n    def __init__(self, app, excluded_paths: Optional[list] = None):\n        super().__init__(app)\n        self.password = get_secret_from_env(\"OPEN_NOTEBOOK_PASSWORD\")\n        self.excluded_paths = excluded_paths or [\n            \"/\",\n            \"/health\",\n            \"/docs\",\n            \"/openapi.json\",\n            \"/redoc\",\n        ]\n\n    async def dispatch(self, request: Request, call_next):\n        # Skip authentication if no password is set\n        if not self.password:\n            return await call_next(request)\n\n        # Skip authentication for excluded paths\n        if request.url.path in self.excluded_paths:\n            return await call_next(request)\n\n        # Skip authentication for CORS preflight requests (OPTIONS)\n        if request.method == \"OPTIONS\":\n            return await call_next(request)\n\n        # Check authorization header\n        auth_header = request.headers.get(\"Authorization\")\n\n        if not auth_header:\n            return JSONResponse(\n                status_code=401,\n                content={\"detail\": \"Missing authorization header\"},\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        # Expected format: \"Bearer {password}\"\n        try:\n            scheme, credentials = auth_header.split(\" \", 1)\n            if scheme.lower() != \"bearer\":\n                raise ValueError(\"Invalid authentication scheme\")\n        except ValueError:\n            return JSONResponse(\n                status_code=401,\n                content={\"detail\": \"Invalid authorization header format\"},\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        # Check password\n        if credentials != self.password:\n            return JSONResponse(\n                status_code=401,\n                content={\"detail\": \"Invalid password\"},\n                headers={\"WWW-Authenticate\": \"Bearer\"},\n            )\n\n        # Password is correct, proceed with the request\n        response = await call_next(request)\n        return response\n\n\n# Optional: HTTPBearer security scheme for OpenAPI documentation\nsecurity = HTTPBearer(auto_error=False)\n\n\ndef check_api_password(\n    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),\n) -> bool:\n    \"\"\"\n    Utility function to check API password.\n    Can be used as a dependency in individual routes if needed.\n    Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.\n    Returns True without checking credentials if OPEN_NOTEBOOK_PASSWORD is not configured.\n    Raises 401 if credentials are missing or don't match the configured password.\n    \"\"\"\n    password = get_secret_from_env(\"OPEN_NOTEBOOK_PASSWORD\")\n\n    # No password configured - skip authentication\n    if not password:\n        return True\n\n    # No credentials provided\n    if not credentials:\n        raise HTTPException(\n            status_code=401,\n            detail=\"Missing authorization\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    # Check password\n    if credentials.credentials != password:\n        raise HTTPException(\n            status_code=401,\n            detail=\"Invalid password\",\n            headers={\"WWW-Authenticate\": \"Bearer\"},\n        )\n\n    return True\n"
  },
  {
    "path": "api/chat_service.py",
    "content": "\"\"\"\nChat service for API operations.\nProvides async interface for chat functionality.\n\"\"\"\n\nimport os\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\nfrom loguru import logger\n\n\nclass ChatService:\n    \"\"\"Service for chat-related API operations\"\"\"\n\n    def __init__(self):\n        self.base_url = os.getenv(\"API_BASE_URL\", \"http://127.0.0.1:5055\")\n        # Add authentication header if password is set\n        self.headers = {}\n        password = os.getenv(\"OPEN_NOTEBOOK_PASSWORD\")\n        if password:\n            self.headers[\"Authorization\"] = f\"Bearer {password}\"\n\n    async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:\n        \"\"\"Get all chat sessions for a notebook\"\"\"\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.get(\n                    f\"{self.base_url}/api/chat/sessions\",\n                    params={\"notebook_id\": notebook_id},\n                    headers=self.headers,\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error fetching chat sessions: {str(e)}\")\n            raise\n\n    async def create_session(\n        self,\n        notebook_id: str,\n        title: Optional[str] = None,\n        model_override: Optional[str] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Create a new chat session\"\"\"\n        try:\n            data: Dict[str, Any] = {\"notebook_id\": notebook_id}\n            if title is not None:\n                data[\"title\"] = title\n            if model_override is not None:\n                data[\"model_override\"] = model_override\n\n            async with httpx.AsyncClient() as client:\n                response = await client.post(\n                    f\"{self.base_url}/api/chat/sessions\",\n                    json=data,\n                    headers=self.headers,\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error creating chat session: {str(e)}\")\n            raise\n\n    async def get_session(self, session_id: str) -> Dict[str, Any]:\n        \"\"\"Get a specific session with messages\"\"\"\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.get(\n                    f\"{self.base_url}/api/chat/sessions/{session_id}\",\n                    headers=self.headers,\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error fetching session: {str(e)}\")\n            raise\n\n    async def update_session(\n        self,\n        session_id: str,\n        title: Optional[str] = None,\n        model_override: Optional[str] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Update session properties\"\"\"\n        try:\n            data: Dict[str, Any] = {}\n            if title is not None:\n                data[\"title\"] = title\n            if model_override is not None:\n                data[\"model_override\"] = model_override\n\n            if not data:\n                raise ValueError(\n                    \"At least one field must be provided to update a session\"\n                )\n\n            async with httpx.AsyncClient() as client:\n                response = await client.put(\n                    f\"{self.base_url}/api/chat/sessions/{session_id}\",\n                    json=data,\n                    headers=self.headers,\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error updating session: {str(e)}\")\n            raise\n\n    async def delete_session(self, session_id: str) -> Dict[str, Any]:\n        \"\"\"Delete a chat session\"\"\"\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.delete(\n                    f\"{self.base_url}/api/chat/sessions/{session_id}\",\n                    headers=self.headers,\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error deleting session: {str(e)}\")\n            raise\n\n    async def execute_chat(\n        self,\n        session_id: str,\n        message: str,\n        context: Dict[str, Any],\n        model_override: Optional[str] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"Execute a chat request\"\"\"\n        try:\n            data = {\"session_id\": session_id, \"message\": message, \"context\": context}\n            if model_override is not None:\n                data[\"model_override\"] = model_override\n\n            # Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs\n            timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)\n            async with httpx.AsyncClient(timeout=timeout) as client:\n                response = await client.post(\n                    f\"{self.base_url}/api/chat/execute\", json=data, headers=self.headers\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error executing chat: {str(e)}\")\n            raise\n\n    async def build_context(\n        self, notebook_id: str, context_config: Dict[str, Any]\n    ) -> Dict[str, Any]:\n        \"\"\"Build context for a notebook\"\"\"\n        try:\n            data = {\"notebook_id\": notebook_id, \"context_config\": context_config}\n\n            async with httpx.AsyncClient() as client:\n                response = await client.post(\n                    f\"{self.base_url}/api/chat/context\", json=data, headers=self.headers\n                )\n                response.raise_for_status()\n                return response.json()\n        except Exception as e:\n            logger.error(f\"Error building context: {str(e)}\")\n            raise\n\n\n# Global instance\nchat_service = ChatService()\n"
  },
  {
    "path": "api/client.py",
    "content": "\"\"\"\nAPI client for Open Notebook API.\nThis module provides a client interface to interact with the Open Notebook API.\n\"\"\"\n\nimport os\nfrom typing import Any, Dict, List, Optional, Union\n\nimport httpx\nfrom loguru import logger\n\n\nclass APIClient:\n    \"\"\"Client for Open Notebook API.\"\"\"\n\n    def __init__(self, base_url: Optional[str] = None):\n        self.base_url = base_url or os.getenv(\"API_BASE_URL\", \"http://127.0.0.1:5055\")\n        # Timeout increased to 5 minutes (300s) to accommodate slow LLM operations\n        # (transformations, insights) on slower hardware (Ollama, LM Studio, remote APIs)\n        # Configurable via API_CLIENT_TIMEOUT environment variable (in seconds)\n        timeout_str = os.getenv(\"API_CLIENT_TIMEOUT\", \"300.0\")\n        try:\n            timeout_value = float(timeout_str)\n            # Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)\n            if timeout_value < 30:\n                logger.warning(\n                    f\"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s\"\n                )\n                timeout_value = 30.0\n            elif timeout_value > 3600:\n                logger.warning(\n                    f\"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s\"\n                )\n                timeout_value = 3600.0\n            self.timeout = timeout_value\n        except ValueError:\n            logger.error(\n                f\"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s\"\n            )\n            self.timeout = 300.0\n\n        # Add authentication header if password is set\n        self.headers = {}\n        password = os.getenv(\"OPEN_NOTEBOOK_PASSWORD\")\n        if password:\n            self.headers[\"Authorization\"] = f\"Bearer {password}\"\n\n    def _make_request(\n        self, method: str, endpoint: str, timeout: Optional[float] = None, **kwargs\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Make HTTP request to the API.\"\"\"\n        url = f\"{self.base_url}{endpoint}\"\n        request_timeout = timeout if timeout is not None else self.timeout\n\n        # Merge headers\n        headers = kwargs.get(\"headers\", {})\n        headers.update(self.headers)\n        kwargs[\"headers\"] = headers\n\n        try:\n            with httpx.Client(timeout=request_timeout) as client:\n                response = client.request(method, url, **kwargs)\n                response.raise_for_status()\n                return response.json()\n        except httpx.RequestError as e:\n            logger.error(f\"Request error for {method} {url}: {str(e)}\")\n            raise ConnectionError(f\"Failed to connect to API: {str(e)}\")\n        except httpx.HTTPStatusError as e:\n            logger.error(\n                f\"HTTP error {e.response.status_code} for {method} {url}: {e.response.text}\"\n            )\n            raise RuntimeError(\n                f\"API request failed: {e.response.status_code} - {e.response.text}\"\n            )\n        except Exception as e:\n            logger.error(f\"Unexpected error for {method} {url}: {str(e)}\")\n            raise\n\n    # Notebooks API methods\n    def get_notebooks(\n        self, archived: Optional[bool] = None, order_by: str = \"updated desc\"\n    ) -> List[Dict[Any, Any]]:\n        \"\"\"Get all notebooks.\"\"\"\n        params: Dict[str, Any] = {\"order_by\": order_by}\n        if archived is not None:\n            params[\"archived\"] = str(archived).lower()\n\n        result = self._make_request(\"GET\", \"/api/notebooks\", params=params)\n        return result if isinstance(result, list) else [result]\n\n    def create_notebook(\n        self, name: str, description: str = \"\"\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new notebook.\"\"\"\n        data = {\"name\": name, \"description\": description}\n        return self._make_request(\"POST\", \"/api/notebooks\", json=data)\n\n    def get_notebook(\n        self, notebook_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific notebook.\"\"\"\n        return self._make_request(\"GET\", f\"/api/notebooks/{notebook_id}\")\n\n    def update_notebook(\n        self, notebook_id: str, **updates\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update a notebook.\"\"\"\n        return self._make_request(\"PUT\", f\"/api/notebooks/{notebook_id}\", json=updates)\n\n    def delete_notebook(\n        self, notebook_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a notebook.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/notebooks/{notebook_id}\")\n\n    # Search API methods\n    def search(\n        self,\n        query: str,\n        search_type: str = \"text\",\n        limit: int = 100,\n        search_sources: bool = True,\n        search_notes: bool = True,\n        minimum_score: float = 0.2,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Search the knowledge base.\"\"\"\n        data = {\n            \"query\": query,\n            \"type\": search_type,\n            \"limit\": limit,\n            \"search_sources\": search_sources,\n            \"search_notes\": search_notes,\n            \"minimum_score\": minimum_score,\n        }\n        return self._make_request(\"POST\", \"/api/search\", json=data)\n\n    def ask_simple(\n        self,\n        question: str,\n        strategy_model: str,\n        answer_model: str,\n        final_answer_model: str,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Ask the knowledge base a question (simple, non-streaming).\"\"\"\n        data = {\n            \"question\": question,\n            \"strategy_model\": strategy_model,\n            \"answer_model\": answer_model,\n            \"final_answer_model\": final_answer_model,\n        }\n        # Use configured timeout for long-running ask operations\n        return self._make_request(\n            \"POST\", \"/api/search/ask/simple\", json=data, timeout=self.timeout\n        )\n\n    # Models API methods\n    def get_models(self, model_type: Optional[str] = None) -> List[Dict[Any, Any]]:\n        \"\"\"Get all models with optional type filtering.\"\"\"\n        params = {}\n        if model_type:\n            params[\"type\"] = model_type\n        result = self._make_request(\"GET\", \"/api/models\", params=params)\n        return result if isinstance(result, list) else [result]\n\n    def create_model(\n        self, name: str, provider: str, model_type: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new model.\"\"\"\n        data = {\n            \"name\": name,\n            \"provider\": provider,\n            \"type\": model_type,\n        }\n        return self._make_request(\"POST\", \"/api/models\", json=data)\n\n    def delete_model(\n        self, model_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a model.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/models/{model_id}\")\n\n    def get_default_models(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get default model assignments.\"\"\"\n        return self._make_request(\"GET\", \"/api/models/defaults\")\n\n    def update_default_models(\n        self, **defaults\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update default model assignments.\"\"\"\n        return self._make_request(\"PUT\", \"/api/models/defaults\", json=defaults)\n\n    # Transformations API methods\n    def get_transformations(self) -> List[Dict[Any, Any]]:\n        \"\"\"Get all transformations.\"\"\"\n        result = self._make_request(\"GET\", \"/api/transformations\")\n        return result if isinstance(result, list) else [result]\n\n    def create_transformation(\n        self,\n        name: str,\n        title: str,\n        description: str,\n        prompt: str,\n        apply_default: bool = False,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new transformation.\"\"\"\n        data = {\n            \"name\": name,\n            \"title\": title,\n            \"description\": description,\n            \"prompt\": prompt,\n            \"apply_default\": apply_default,\n        }\n        return self._make_request(\"POST\", \"/api/transformations\", json=data)\n\n    def get_transformation(\n        self, transformation_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific transformation.\"\"\"\n        return self._make_request(\"GET\", f\"/api/transformations/{transformation_id}\")\n\n    def update_transformation(\n        self, transformation_id: str, **updates\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update a transformation.\"\"\"\n        return self._make_request(\n            \"PUT\", f\"/api/transformations/{transformation_id}\", json=updates\n        )\n\n    def delete_transformation(\n        self, transformation_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a transformation.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/transformations/{transformation_id}\")\n\n    def execute_transformation(\n        self, transformation_id: str, input_text: str, model_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Execute a transformation on input text.\"\"\"\n        data = {\n            \"transformation_id\": transformation_id,\n            \"input_text\": input_text,\n            \"model_id\": model_id,\n        }\n        # Use configured timeout for transformation operations\n        return self._make_request(\n            \"POST\", \"/api/transformations/execute\", json=data, timeout=self.timeout\n        )\n\n    # Notes API methods\n    def get_notes(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:\n        \"\"\"Get all notes with optional notebook filtering.\"\"\"\n        params = {}\n        if notebook_id:\n            params[\"notebook_id\"] = notebook_id\n        result = self._make_request(\"GET\", \"/api/notes\", params=params)\n        return result if isinstance(result, list) else [result]\n\n    def create_note(\n        self,\n        content: str,\n        title: Optional[str] = None,\n        note_type: str = \"human\",\n        notebook_id: Optional[str] = None,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new note.\"\"\"\n        data = {\n            \"content\": content,\n            \"note_type\": note_type,\n        }\n        if title:\n            data[\"title\"] = title\n        if notebook_id:\n            data[\"notebook_id\"] = notebook_id\n        return self._make_request(\"POST\", \"/api/notes\", json=data)\n\n    def get_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific note.\"\"\"\n        return self._make_request(\"GET\", f\"/api/notes/{note_id}\")\n\n    def update_note(\n        self, note_id: str, **updates\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update a note.\"\"\"\n        return self._make_request(\"PUT\", f\"/api/notes/{note_id}\", json=updates)\n\n    def delete_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a note.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/notes/{note_id}\")\n\n    # Embedding API methods\n    def embed_content(\n        self, item_id: str, item_type: str, async_processing: bool = False\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Embed content for vector search.\"\"\"\n        data = {\n            \"item_id\": item_id,\n            \"item_type\": item_type,\n            \"async_processing\": async_processing,\n        }\n        # Use configured timeout for embedding operations\n        return self._make_request(\"POST\", \"/api/embed\", json=data, timeout=self.timeout)\n\n    def rebuild_embeddings(\n        self,\n        mode: str = \"existing\",\n        include_sources: bool = True,\n        include_notes: bool = True,\n        include_insights: bool = True,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Rebuild embeddings in bulk.\n\n        Note: This operation can take a long time for large databases.\n        Consider increasing API_CLIENT_TIMEOUT to 600-900s for bulk rebuilds.\n        \"\"\"\n        data = {\n            \"mode\": mode,\n            \"include_sources\": include_sources,\n            \"include_notes\": include_notes,\n            \"include_insights\": include_insights,\n        }\n        # Use double the configured timeout for bulk rebuild operations (or configured value if already high)\n        rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))\n        return self._make_request(\n            \"POST\", \"/api/embeddings/rebuild\", json=data, timeout=rebuild_timeout\n        )\n\n    def get_rebuild_status(\n        self, command_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get status of a rebuild operation.\"\"\"\n        return self._make_request(\"GET\", f\"/api/embeddings/rebuild/{command_id}/status\")\n\n    # Settings API methods\n    def get_settings(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get all application settings.\"\"\"\n        return self._make_request(\"GET\", \"/api/settings\")\n\n    def update_settings(\n        self, **settings\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update application settings.\"\"\"\n        return self._make_request(\"PUT\", \"/api/settings\", json=settings)\n\n    # Context API methods\n    def get_notebook_context(\n        self, notebook_id: str, context_config: Optional[Dict] = None\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get context for a notebook.\"\"\"\n        data: Dict[str, Any] = {\"notebook_id\": notebook_id}\n        if context_config:\n            data[\"context_config\"] = context_config\n        result = self._make_request(\n            \"POST\", f\"/api/notebooks/{notebook_id}/context\", json=data\n        )\n        return result if isinstance(result, dict) else {}\n\n    # Sources API methods\n    def get_sources(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:\n        \"\"\"Get all sources with optional notebook filtering.\"\"\"\n        params = {}\n        if notebook_id:\n            params[\"notebook_id\"] = notebook_id\n        result = self._make_request(\"GET\", \"/api/sources\", params=params)\n        return result if isinstance(result, list) else [result]\n\n    def create_source(\n        self,\n        notebook_id: Optional[str] = None,\n        notebooks: Optional[List[str]] = None,\n        source_type: str = \"text\",\n        url: Optional[str] = None,\n        file_path: Optional[str] = None,\n        content: Optional[str] = None,\n        title: Optional[str] = None,\n        transformations: Optional[List[str]] = None,\n        embed: bool = False,\n        delete_source: bool = False,\n        async_processing: bool = False,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new source.\"\"\"\n        data = {\n            \"type\": source_type,\n            \"embed\": embed,\n            \"delete_source\": delete_source,\n            \"async_processing\": async_processing,\n        }\n\n        # Handle backward compatibility for notebook_id vs notebooks\n        if notebooks:\n            data[\"notebooks\"] = notebooks\n        elif notebook_id:\n            data[\"notebook_id\"] = notebook_id\n        else:\n            raise ValueError(\"Either notebook_id or notebooks must be provided\")\n\n        if url:\n            data[\"url\"] = url\n        if file_path:\n            data[\"file_path\"] = file_path\n        if content:\n            data[\"content\"] = content\n        if title:\n            data[\"title\"] = title\n        if transformations:\n            data[\"transformations\"] = transformations\n\n        # Use configured timeout for source creation (especially PDF processing with OCR)\n        return self._make_request(\n            \"POST\", \"/api/sources/json\", json=data, timeout=self.timeout\n        )\n\n    def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific source.\"\"\"\n        return self._make_request(\"GET\", f\"/api/sources/{source_id}\")\n\n    def get_source_status(\n        self, source_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get processing status for a source.\"\"\"\n        return self._make_request(\"GET\", f\"/api/sources/{source_id}/status\")\n\n    def update_source(\n        self, source_id: str, **updates\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update a source.\"\"\"\n        return self._make_request(\"PUT\", f\"/api/sources/{source_id}\", json=updates)\n\n    def delete_source(\n        self, source_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a source.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/sources/{source_id}\")\n\n    # Insights API methods\n    def get_source_insights(self, source_id: str) -> List[Dict[Any, Any]]:\n        \"\"\"Get all insights for a specific source.\"\"\"\n        result = self._make_request(\"GET\", f\"/api/sources/{source_id}/insights\")\n        return result if isinstance(result, list) else [result]\n\n    def get_insight(\n        self, insight_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific insight.\"\"\"\n        return self._make_request(\"GET\", f\"/api/insights/{insight_id}\")\n\n    def delete_insight(\n        self, insight_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete a specific insight.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/insights/{insight_id}\")\n\n    def save_insight_as_note(\n        self, insight_id: str, notebook_id: Optional[str] = None\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Convert an insight to a note.\"\"\"\n        data = {}\n        if notebook_id:\n            data[\"notebook_id\"] = notebook_id\n        return self._make_request(\n            \"POST\", f\"/api/insights/{insight_id}/save-as-note\", json=data\n        )\n\n    def create_source_insight(\n        self, source_id: str, transformation_id: str, model_id: Optional[str] = None\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new insight for a source by running a transformation.\"\"\"\n        data = {\"transformation_id\": transformation_id}\n        if model_id:\n            data[\"model_id\"] = model_id\n        return self._make_request(\n            \"POST\", f\"/api/sources/{source_id}/insights\", json=data\n        )\n\n    # Episode Profiles API methods\n    def get_episode_profiles(self) -> List[Dict[Any, Any]]:\n        \"\"\"Get all episode profiles.\"\"\"\n        result = self._make_request(\"GET\", \"/api/episode-profiles\")\n        return result if isinstance(result, list) else [result]\n\n    def get_episode_profile(\n        self, profile_name: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get a specific episode profile by name.\"\"\"\n        return self._make_request(\"GET\", f\"/api/episode-profiles/{profile_name}\")\n\n    def create_episode_profile(\n        self,\n        name: str,\n        description: str = \"\",\n        speaker_config: str = \"\",\n        outline_provider: str = \"\",\n        outline_model: str = \"\",\n        transcript_provider: str = \"\",\n        transcript_model: str = \"\",\n        default_briefing: str = \"\",\n        num_segments: int = 5,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Create a new episode profile.\"\"\"\n        data = {\n            \"name\": name,\n            \"description\": description,\n            \"speaker_config\": speaker_config,\n            \"outline_provider\": outline_provider,\n            \"outline_model\": outline_model,\n            \"transcript_provider\": transcript_provider,\n            \"transcript_model\": transcript_model,\n            \"default_briefing\": default_briefing,\n            \"num_segments\": num_segments,\n        }\n        return self._make_request(\"POST\", \"/api/episode-profiles\", json=data)\n\n    def update_episode_profile(\n        self, profile_id: str, **updates\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Update an episode profile.\"\"\"\n        return self._make_request(\n            \"PUT\", f\"/api/episode-profiles/{profile_id}\", json=updates\n        )\n\n    def delete_episode_profile(\n        self, profile_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Delete an episode profile.\"\"\"\n        return self._make_request(\"DELETE\", f\"/api/episode-profiles/{profile_id}\")\n\n\n# Global client instance\napi_client = APIClient()\n"
  },
  {
    "path": "api/command_service.py",
    "content": "from typing import Any, Dict, List, Optional\n\nfrom loguru import logger\nfrom surreal_commands import get_command_status, submit_command\n\n\nclass CommandService:\n    \"\"\"Generic service layer for command operations\"\"\"\n\n    @staticmethod\n    async def submit_command_job(\n        module_name: str,  # Actually app_name for surreal-commands\n        command_name: str,\n        command_args: Dict[str, Any],\n        context: Optional[Dict[str, Any]] = None,\n    ) -> str:\n        \"\"\"Submit a generic command job for background processing\"\"\"\n        try:\n            # Ensure command modules are imported before submitting\n            # This is needed because submit_command validates against local registry\n            try:\n                import commands.podcast_commands  # noqa: F401\n            except ImportError as import_err:\n                logger.error(f\"Failed to import command modules: {import_err}\")\n                raise ValueError(\"Command modules not available\")\n\n            # surreal-commands expects: submit_command(app_name, command_name, args)\n            cmd_id = submit_command(\n                module_name,  # This is actually the app name (e.g., \"open_notebook\")\n                command_name,  # Command name (e.g., \"process_text\")\n                command_args,  # Input data\n            )\n            # Convert RecordID to string if needed\n            if not cmd_id:\n                raise ValueError(\"Failed to get cmd_id from submit_command\")\n            cmd_id_str = str(cmd_id)\n            logger.info(\n                f\"Submitted command job: {cmd_id_str} for {module_name}.{command_name}\"\n            )\n            return cmd_id_str\n\n        except Exception as e:\n            logger.error(f\"Failed to submit command job: {e}\")\n            raise\n\n    @staticmethod\n    async def get_command_status(job_id: str) -> Dict[str, Any]:\n        \"\"\"Get status of any command job\"\"\"\n        try:\n            status = await get_command_status(job_id)\n            return {\n                \"job_id\": job_id,\n                \"status\": status.status if status else \"unknown\",\n                \"result\": status.result if status else None,\n                \"error_message\": getattr(status, \"error_message\", None)\n                if status\n                else None,\n                \"created\": str(status.created)\n                if status and hasattr(status, \"created\") and status.created\n                else None,\n                \"updated\": str(status.updated)\n                if status and hasattr(status, \"updated\") and status.updated\n                else None,\n                \"progress\": getattr(status, \"progress\", None) if status else None,\n            }\n        except Exception as e:\n            logger.error(f\"Failed to get command status: {e}\")\n            raise\n\n    @staticmethod\n    async def list_command_jobs(\n        module_filter: Optional[str] = None,\n        command_filter: Optional[str] = None,\n        status_filter: Optional[str] = None,\n        limit: int = 50,\n    ) -> List[Dict[str, Any]]:\n        \"\"\"List command jobs with optional filtering\"\"\"\n        # This will be implemented with proper SurrealDB queries\n        # For now, return empty list as this is foundation phase\n        return []\n\n    @staticmethod\n    async def cancel_command_job(job_id: str) -> bool:\n        \"\"\"Cancel a running command job\"\"\"\n        try:\n            # Implementation depends on surreal-commands cancellation support\n            # For now, just log the attempt\n            logger.info(f\"Attempting to cancel job: {job_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to cancel command job: {e}\")\n            raise\n"
  },
  {
    "path": "api/context_service.py",
    "content": "\"\"\"\nContext service layer using API.\n\"\"\"\n\nfrom typing import Any, Dict, List, Optional, Union\n\nfrom loguru import logger\n\nfrom api.client import api_client\n\n\nclass ContextService:\n    \"\"\"Service layer for context operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for context operations\")\n\n    def get_notebook_context(\n        self, notebook_id: str, context_config: Optional[Dict] = None\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Get context for a notebook.\"\"\"\n        result = api_client.get_notebook_context(\n            notebook_id=notebook_id, context_config=context_config\n        )\n        return result\n\n\n# Global service instance\ncontext_service = ContextService()\n"
  },
  {
    "path": "api/credentials_service.py",
    "content": "\"\"\"\nCredentials Service\n\nBusiness logic for managing AI provider credentials.\nExtracted from the credentials router to follow the service layer pattern.\n\nAll functions raise ValueError for business errors (router converts to HTTPException).\n\"\"\"\n\nimport ipaddress\nimport os\nimport socket\nfrom typing import Dict, List, Optional\nfrom urllib.parse import urlparse\n\nimport httpx\nfrom loguru import logger\nfrom pydantic import SecretStr\n\nfrom api.models import CredentialResponse\nfrom open_notebook.domain.credential import Credential\nfrom open_notebook.utils.encryption import get_secret_from_env\n\n# =============================================================================\n# Constants\n# =============================================================================\n\n# Provider environment variable configuration.\n# - \"required\": ALL listed env vars must be set for the provider to be considered configured.\n# - \"required_any\": at least ONE of the listed env vars must be set.\n# - \"optional\": additional env vars used during migration but not required.\nPROVIDER_ENV_CONFIG: Dict[str, dict] = {\n    \"openai\": {\"required\": [\"OPENAI_API_KEY\"]},\n    \"anthropic\": {\"required\": [\"ANTHROPIC_API_KEY\"]},\n    \"google\": {\"required_any\": [\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"]},\n    \"groq\": {\"required\": [\"GROQ_API_KEY\"]},\n    \"mistral\": {\"required\": [\"MISTRAL_API_KEY\"]},\n    \"deepseek\": {\"required\": [\"DEEPSEEK_API_KEY\"]},\n    \"xai\": {\"required\": [\"XAI_API_KEY\"]},\n    \"openrouter\": {\"required\": [\"OPENROUTER_API_KEY\"]},\n    \"voyage\": {\"required\": [\"VOYAGE_API_KEY\"]},\n    \"elevenlabs\": {\"required\": [\"ELEVENLABS_API_KEY\"]},\n    \"ollama\": {\"required\": [\"OLLAMA_API_BASE\"]},\n    \"vertex\": {\n        \"required\": [\"VERTEX_PROJECT\", \"VERTEX_LOCATION\"],\n        \"optional\": [\"GOOGLE_APPLICATION_CREDENTIALS\"],\n    },\n    \"azure\": {\n        \"required\": [\"AZURE_OPENAI_API_KEY\", \"AZURE_OPENAI_ENDPOINT\", \"AZURE_OPENAI_API_VERSION\"],\n        \"optional\": [\n            \"AZURE_OPENAI_ENDPOINT_LLM\",\n            \"AZURE_OPENAI_ENDPOINT_EMBEDDING\",\n            \"AZURE_OPENAI_ENDPOINT_STT\",\n            \"AZURE_OPENAI_ENDPOINT_TTS\",\n        ],\n    },\n    \"openai_compatible\": {\n        \"required_any\": [\"OPENAI_COMPATIBLE_BASE_URL\", \"OPENAI_COMPATIBLE_API_KEY\"],\n    },\n}\n\nPROVIDER_MODALITIES: Dict[str, List[str]] = {\n    \"openai\": [\"language\", \"embedding\", \"speech_to_text\", \"text_to_speech\"],\n    \"anthropic\": [\"language\"],\n    \"google\": [\"language\", \"embedding\"],\n    \"groq\": [\"language\", \"speech_to_text\"],\n    \"mistral\": [\"language\", \"embedding\"],\n    \"deepseek\": [\"language\"],\n    \"xai\": [\"language\"],\n    \"openrouter\": [\"language\"],\n    \"voyage\": [\"embedding\"],\n    \"elevenlabs\": [\"text_to_speech\"],\n    \"ollama\": [\"language\", \"embedding\"],\n    \"vertex\": [\"language\", \"embedding\"],\n    \"azure\": [\"language\", \"embedding\", \"speech_to_text\", \"text_to_speech\"],\n    \"openai_compatible\": [\"language\", \"embedding\", \"speech_to_text\", \"text_to_speech\"],\n}\n\n\n# =============================================================================\n# URL Validation (SSRF protection)\n# =============================================================================\n\n\ndef validate_url(url: str, provider: str) -> None:\n    \"\"\"\n    Validate URL format for API endpoints.\n\n    This is a self-hosted application, so we allow:\n    - Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services\n    - Localhost for local services (Ollama, LM Studio, etc.)\n\n    We only block:\n    - Invalid schemes (must be http or https)\n    - Malformed URLs\n    - Link-local addresses (169.254.x.x) - used for cloud metadata endpoints\n    - Hostnames that resolve to link-local addresses\n\n    Args:\n        url: The URL to validate\n        provider: The provider name (for logging/context)\n\n    Raises:\n        ValueError: If the URL is invalid\n    \"\"\"\n    if not url or not url.strip():\n        return  # Empty URLs handled elsewhere\n\n    try:\n        parsed = urlparse(url.strip())\n\n        # Validate scheme - only http/https allowed\n        if parsed.scheme not in (\"http\", \"https\"):\n            raise ValueError(\n                f\"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed.\"\n            )\n\n        # Extract hostname\n        hostname = parsed.hostname\n        if not hostname:\n            raise ValueError(\"Invalid URL: hostname could not be determined.\")\n\n        # Try to parse as IP address to check for dangerous addresses\n        try:\n            ip = ipaddress.ip_address(hostname)\n\n            # Block link-local addresses (169.254.x.x) - used for cloud metadata\n            # These are dangerous as they can expose cloud instance credentials\n            if ip.is_link_local:\n                raise ValueError(\n                    \"Link-local addresses (169.254.x.x) are not allowed for security reasons. \"\n                    \"These addresses are used for cloud metadata endpoints.\"\n                )\n\n            # Block IPv4-mapped IPv6 addresses pointing to link-local\n            # e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check\n            if hasattr(ip, \"ipv4_mapped\") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local:\n                raise ValueError(\n                    \"Link-local addresses (169.254.x.x) are not allowed for security reasons. \"\n                    \"These addresses are used for cloud metadata endpoints.\"\n                )\n\n        except ValueError as ve:\n            # Re-raise our own ValueErrors\n            if \"Link-local\" in str(ve) or \"Invalid URL\" in str(ve):\n                raise\n            # Not an IP address, it's a hostname - need to resolve and check\n            try:\n                # Resolve hostname to IP address\n                resolved_ips = socket.getaddrinfo(hostname, None)\n                for family, _, _, _, sockaddr in resolved_ips:\n                    ip_addr = sockaddr[0]\n                    try:\n                        parsed_ip = ipaddress.ip_address(ip_addr)\n                        if parsed_ip.is_link_local:\n                            raise ValueError(\n                                f\"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. \"\n                                \"These addresses are used for cloud metadata endpoints.\"\n                            )\n                        # Block IPv4-mapped IPv6 addresses pointing to link-local\n                        if (\n                            hasattr(parsed_ip, \"ipv4_mapped\")\n                            and parsed_ip.ipv4_mapped\n                            and parsed_ip.ipv4_mapped.is_link_local\n                        ):\n                            raise ValueError(\n                                f\"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. \"\n                                \"These addresses are used for cloud metadata endpoints.\"\n                            )\n                    except ValueError as inner_ve:\n                        if \"link-local\" in str(inner_ve).lower() or \"Link-local\" in str(inner_ve):\n                            raise\n                        # Skip non-IP addresses (e.g., IPv6 zones)\n                        continue\n            except socket.gaierror:\n                # Could not resolve hostname - allow it since the URL may be\n                # valid in the deployment environment (e.g., Azure endpoints,\n                # internal DNS names). We only block link-local addresses.\n                pass\n\n    except ValueError:\n        raise\n    except Exception:\n        raise ValueError(\"Invalid URL format. Check server logs for details.\")\n\n\n# =============================================================================\n# Helpers\n# =============================================================================\n\n\ndef require_encryption_key() -> None:\n    \"\"\"Raise ValueError if encryption key is not configured.\"\"\"\n    if not get_secret_from_env(\"OPEN_NOTEBOOK_ENCRYPTION_KEY\"):\n        raise ValueError(\n            \"Encryption key not configured. \"\n            \"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys.\"\n        )\n\n\ndef credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:\n    \"\"\"Convert a Credential domain object to API response.\"\"\"\n    return CredentialResponse(\n        id=cred.id or \"\",\n        name=cred.name,\n        provider=cred.provider,\n        modalities=cred.modalities,\n        base_url=cred.base_url,\n        endpoint=cred.endpoint,\n        api_version=cred.api_version,\n        endpoint_llm=cred.endpoint_llm,\n        endpoint_embedding=cred.endpoint_embedding,\n        endpoint_stt=cred.endpoint_stt,\n        endpoint_tts=cred.endpoint_tts,\n        project=cred.project,\n        location=cred.location,\n        credentials_path=cred.credentials_path,\n        has_api_key=cred.api_key is not None,\n        created=str(cred.created) if cred.created else \"\",\n        updated=str(cred.updated) if cred.updated else \"\",\n        model_count=model_count,\n    )\n\n\ndef check_env_configured(provider: str) -> bool:\n    \"\"\"Check if a provider has sufficient env vars configured for migration.\"\"\"\n    config = PROVIDER_ENV_CONFIG.get(provider)\n    if not config:\n        return False\n\n    if \"required_any\" in config:\n        return any(bool(os.environ.get(v, \"\").strip()) for v in config[\"required_any\"])\n    elif \"required\" in config:\n        return all(bool(os.environ.get(v, \"\").strip()) for v in config[\"required\"])\n    return False\n\n\ndef get_default_modalities(provider: str) -> List[str]:\n    \"\"\"Get default modalities for a provider.\"\"\"\n    return PROVIDER_MODALITIES.get(provider.lower(), [\"language\"])\n\n\ndef create_credential_from_env(provider: str) -> Credential:\n    \"\"\"Create a Credential from environment variables for a given provider.\"\"\"\n    modalities = get_default_modalities(provider)\n    name = \"Default (Migrated from env)\"\n\n    if provider == \"ollama\":\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            base_url=os.environ.get(\"OLLAMA_API_BASE\"),\n        )\n    elif provider == \"vertex\":\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            project=os.environ.get(\"VERTEX_PROJECT\"),\n            location=os.environ.get(\"VERTEX_LOCATION\"),\n            credentials_path=os.environ.get(\"GOOGLE_APPLICATION_CREDENTIALS\"),\n        )\n    elif provider == \"azure\":\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            api_key=SecretStr(os.environ[\"AZURE_OPENAI_API_KEY\"]),\n            endpoint=os.environ.get(\"AZURE_OPENAI_ENDPOINT\"),\n            api_version=os.environ.get(\"AZURE_OPENAI_API_VERSION\"),\n            endpoint_llm=os.environ.get(\"AZURE_OPENAI_ENDPOINT_LLM\"),\n            endpoint_embedding=os.environ.get(\"AZURE_OPENAI_ENDPOINT_EMBEDDING\"),\n            endpoint_stt=os.environ.get(\"AZURE_OPENAI_ENDPOINT_STT\"),\n            endpoint_tts=os.environ.get(\"AZURE_OPENAI_ENDPOINT_TTS\"),\n        )\n    elif provider == \"openai_compatible\":\n        api_key = os.environ.get(\"OPENAI_COMPATIBLE_API_KEY\")\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            api_key=SecretStr(api_key) if api_key else None,\n            base_url=os.environ.get(\"OPENAI_COMPATIBLE_BASE_URL\"),\n        )\n    elif provider == \"google\":\n        # Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)\n        api_key = os.environ.get(\"GOOGLE_API_KEY\") or os.environ.get(\"GEMINI_API_KEY\")\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            api_key=SecretStr(api_key) if api_key else None,\n        )\n    else:\n        # Simple API key providers\n        config = PROVIDER_ENV_CONFIG.get(provider, {})\n        required = config.get(\"required\", [])\n        env_var = required[0] if required else None\n        api_key = os.environ.get(env_var) if env_var else None\n        return Credential(\n            name=name,\n            provider=provider,\n            modalities=modalities,\n            api_key=SecretStr(api_key) if api_key else None,\n        )\n\n\n# =============================================================================\n# Service Functions\n# =============================================================================\n\n\nasync def get_provider_status() -> dict:\n    \"\"\"\n    Get configuration status: encryption key status, and per-provider\n    configured/source information.\n    \"\"\"\n    encryption_configured = bool(get_secret_from_env(\"OPEN_NOTEBOOK_ENCRYPTION_KEY\"))\n\n    configured: Dict[str, bool] = {}\n    source: Dict[str, str] = {}\n\n    for provider in PROVIDER_ENV_CONFIG:\n        env_configured = check_env_configured(provider)\n        try:\n            db_credentials = await Credential.get_by_provider(provider)\n            db_configured = len(db_credentials) > 0\n        except Exception:\n            db_configured = False\n\n        configured[provider] = db_configured or env_configured\n\n        if db_configured:\n            source[provider] = \"database\"\n        elif env_configured:\n            source[provider] = \"environment\"\n        else:\n            source[provider] = \"none\"\n\n    return {\n        \"configured\": configured,\n        \"source\": source,\n        \"encryption_configured\": encryption_configured,\n    }\n\n\nasync def get_env_status() -> Dict[str, bool]:\n    \"\"\"Check what's configured via environment variables.\"\"\"\n    env_status: Dict[str, bool] = {}\n    for provider in PROVIDER_ENV_CONFIG:\n        env_status[provider] = check_env_configured(provider)\n    return env_status\n\n\nasync def test_credential(credential_id: str) -> dict:\n    \"\"\"\n    Test connection using a credential's configuration.\n\n    Returns dict with provider, success, message keys.\n    \"\"\"\n    provider = \"unknown\"\n    try:\n        cred = await Credential.get(credential_id)\n        config = cred.to_esperanto_config()\n\n        from open_notebook.ai.connection_tester import (\n            _test_azure_connection,\n            _test_ollama_connection,\n            _test_openai_compatible_connection,\n        )\n\n        provider = cred.provider.lower()\n\n        # Handle special providers\n        if provider == \"ollama\":\n            base_url = config.get(\"base_url\", \"http://localhost:11434\")\n            success, message = await _test_ollama_connection(base_url)\n            return {\"provider\": provider, \"success\": success, \"message\": message}\n\n        if provider == \"openai_compatible\":\n            base_url = config.get(\"base_url\")\n            api_key = config.get(\"api_key\")\n            if not base_url:\n                return {\n                    \"provider\": provider,\n                    \"success\": False,\n                    \"message\": \"No base URL configured\",\n                }\n            success, message = await _test_openai_compatible_connection(\n                base_url, api_key\n            )\n            return {\"provider\": provider, \"success\": success, \"message\": message}\n\n        if provider == \"azure\":\n            success, message = await _test_azure_connection(\n                endpoint=config.get(\"endpoint\"),\n                api_key=config.get(\"api_key\"),\n                api_version=config.get(\"api_version\"),\n            )\n            return {\"provider\": provider, \"success\": success, \"message\": message}\n\n        # Standard provider: use Esperanto to create and test\n        from esperanto.factory import AIFactory\n\n        from open_notebook.ai.connection_tester import TEST_MODELS\n\n        if provider not in TEST_MODELS:\n            return {\n                \"provider\": provider,\n                \"success\": False,\n                \"message\": f\"Unknown provider: {provider}\",\n            }\n\n        test_model, test_type = TEST_MODELS[provider]\n        if not test_model:\n            return {\n                \"provider\": provider,\n                \"success\": False,\n                \"message\": f\"No test model configured for {provider}\",\n            }\n\n        if test_type == \"language\":\n            model = AIFactory.create_language(\n                model_name=test_model, provider=provider, config=config\n            )\n            lc_model = model.to_langchain()\n            await lc_model.ainvoke(\"Hi\")\n            return {\"provider\": provider, \"success\": True, \"message\": \"Connection successful\"}\n\n        elif test_type == \"embedding\":\n            model = AIFactory.create_embedding(\n                model_name=test_model, provider=provider, config=config\n            )\n            await model.aembed([\"test\"])\n            return {\"provider\": provider, \"success\": True, \"message\": \"Connection successful\"}\n\n        elif test_type == \"text_to_speech\":\n            AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)\n            return {\n                \"provider\": provider,\n                \"success\": True,\n                \"message\": \"Connection successful (key format valid)\",\n            }\n\n        return {\n            \"provider\": provider,\n            \"success\": False,\n            \"message\": f\"Unsupported test type: {test_type}\",\n        }\n\n    except Exception as e:\n        error_msg = str(e)\n        if \"401\" in error_msg or \"unauthorized\" in error_msg.lower():\n            return {\"provider\": provider, \"success\": False, \"message\": \"Invalid API key\"}\n        elif \"403\" in error_msg or \"forbidden\" in error_msg.lower():\n            return {\"provider\": provider, \"success\": False, \"message\": \"API key lacks required permissions\"}\n        elif \"rate\" in error_msg.lower() and \"limit\" in error_msg.lower():\n            return {\"provider\": provider, \"success\": True, \"message\": \"Rate limited - but connection works\"}\n        elif \"not found\" in error_msg.lower() and \"model\" in error_msg.lower():\n            return {\"provider\": provider, \"success\": True, \"message\": \"API key valid (test model not available)\"}\n        else:\n            logger.debug(f\"Test connection error for credential {credential_id}: {e}\")\n            truncated = error_msg[:100] + \"...\" if len(error_msg) > 100 else error_msg\n            return {\"provider\": provider, \"success\": False, \"message\": f\"Error: {truncated}\"}\n\n\nasync def discover_with_config(provider: str, config: dict) -> List[dict]:\n    \"\"\"\n    Discover models using explicit config instead of env vars.\n\n    Returns model names only — no type classification.\n    The user chooses the model type when registering.\n    \"\"\"\n    api_key = config.get(\"api_key\")\n    base_url = config.get(\"base_url\")\n\n    # Static model lists for providers without a listing API\n    STATIC_MODELS: Dict[str, List[str]] = {\n        \"anthropic\": [\n            \"claude-opus-4-20250514\",\n            \"claude-sonnet-4-20250514\",\n            \"claude-3-5-sonnet-20241022\",\n            \"claude-3-5-haiku-20241022\",\n            \"claude-3-opus-20240229\",\n            \"claude-3-sonnet-20240229\",\n            \"claude-3-haiku-20240307\",\n        ],\n        \"voyage\": [\n            \"voyage-3\", \"voyage-3-lite\", \"voyage-code-3\",\n            \"voyage-finance-2\", \"voyage-law-2\", \"voyage-multilingual-2\",\n        ],\n        \"elevenlabs\": [\n            \"eleven_multilingual_v2\", \"eleven_turbo_v2_5\",\n            \"eleven_turbo_v2\", \"eleven_monolingual_v1\",\n        ],\n    }\n\n    if provider in STATIC_MODELS:\n        if not api_key and provider != \"ollama\":\n            return []\n        return [\n            {\"name\": m, \"provider\": provider}\n            for m in STATIC_MODELS[provider]\n        ]\n\n    # API-based discovery URLs (OpenAI-style /models endpoints)\n    url_map = {\n        \"openai\": \"https://api.openai.com/v1/models\",\n        \"groq\": \"https://api.groq.com/openai/v1/models\",\n        \"mistral\": \"https://api.mistral.ai/v1/models\",\n        \"deepseek\": \"https://api.deepseek.com/models\",\n        \"xai\": \"https://api.x.ai/v1/models\",\n        \"openrouter\": \"https://openrouter.ai/api/v1/models\",\n    }\n\n    if provider == \"ollama\":\n        ollama_url = base_url or \"http://localhost:11434\"\n        try:\n            async with httpx.AsyncClient() as client:\n                response = await client.get(f\"{ollama_url}/api/tags\", timeout=10.0)\n                response.raise_for_status()\n                data = response.json()\n                return [\n                    {\"name\": m.get(\"name\", \"\"), \"provider\": \"ollama\"}\n                    for m in data.get(\"models\", [])\n                    if m.get(\"name\")\n                ]\n        except Exception as e:\n            logger.warning(f\"Failed to discover Ollama models: {e}\")\n            return []\n\n    if provider == \"openai_compatible\":\n        if not base_url:\n            return []\n        try:\n            headers = {}\n            if api_key:\n                headers[\"Authorization\"] = f\"Bearer {api_key}\"\n            async with httpx.AsyncClient() as client:\n                response = await client.get(\n                    f\"{base_url.rstrip('/')}/models\", headers=headers, timeout=30.0,\n                )\n                response.raise_for_status()\n                data = response.json()\n                return [\n                    {\"name\": m.get(\"id\", \"\"), \"provider\": \"openai_compatible\"}\n                    for m in data.get(\"data\", [])\n                    if m.get(\"id\")\n                ]\n        except Exception as e:\n            logger.warning(f\"Failed to discover openai_compatible models: {e}\")\n            return []\n\n    if provider == \"azure\":\n        endpoint = config.get(\"endpoint\")\n        api_version = config.get(\"api_version\", \"2024-10-21\")\n        if not endpoint or not api_key:\n            return []\n        try:\n            url = f\"{endpoint.rstrip('/')}/openai/models?api-version={api_version}\"\n            headers = {\"api-key\": api_key}\n            async with httpx.AsyncClient() as client:\n                response = await client.get(url, headers=headers, timeout=30.0)\n                response.raise_for_status()\n                data = response.json()\n                return [\n                    {\"name\": m.get(\"id\", \"\"), \"provider\": \"azure\"}\n                    for m in data.get(\"data\", [])\n                    if m.get(\"id\")\n                ]\n        except Exception as e:\n            logger.warning(f\"Failed to discover Azure models: {e}\")\n            return []\n\n    if provider == \"vertex\":\n        # Vertex AI requires service-account OAuth2 for model listing.\n        # Return a curated static list of well-known Vertex models instead.\n        VERTEX_MODELS = [\n            \"gemini-2.0-flash\",\n            \"gemini-2.0-flash-lite\",\n            \"gemini-1.5-pro\",\n            \"gemini-1.5-flash\",\n            \"text-embedding-005\",\n        ]\n        return [{\"name\": m, \"provider\": \"vertex\"} for m in VERTEX_MODELS]\n\n    if provider == \"google\":\n        try:\n            headers = {\"X-Goog-Api-Key\": api_key} if api_key else {}\n            async with httpx.AsyncClient() as client:\n                response = await client.get(\n                    \"https://generativelanguage.googleapis.com/v1/models\",\n                    headers=headers,\n                    timeout=30.0,\n                )\n                response.raise_for_status()\n                data = response.json()\n                return [\n                    {\n                        \"name\": model.get(\"name\", \"\").replace(\"models/\", \"\"),\n                        \"provider\": \"google\",\n                        \"description\": model.get(\"displayName\"),\n                    }\n                    for model in data.get(\"models\", [])\n                    if model.get(\"name\")\n                ]\n        except Exception as e:\n            logger.warning(f\"Failed to discover Google models: {e}\")\n            return []\n\n    # Standard OpenAI-style API discovery\n    discovery_url = url_map.get(provider)\n    if not discovery_url or not api_key:\n        return []\n\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                discovery_url,\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            return [\n                {\n                    \"name\": m.get(\"id\", \"\"),\n                    \"provider\": provider,\n                    \"description\": m.get(\"name\"),\n                }\n                for m in data.get(\"data\", [])\n                if m.get(\"id\")\n            ]\n    except Exception as e:\n        logger.warning(f\"Failed to discover {provider} models: {e}\")\n        return []\n\n\nasync def register_models(credential_id: str, models_data: list) -> dict:\n    \"\"\"\n    Register discovered models and link them to a credential.\n\n    Args:\n        credential_id: The credential ID to link models to\n        models_data: List of dicts with name, provider, model_type\n\n    Returns:\n        dict with created and existing counts\n    \"\"\"\n    cred = await Credential.get(credential_id)\n\n    from open_notebook.ai.models import Model\n    from open_notebook.database.repository import repo_query\n\n    # Batch fetch existing models for this provider\n    existing_models = await repo_query(\n        \"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model \"\n        \"WHERE string::lowercase(provider) = $provider\",\n        {\"provider\": cred.provider.lower()},\n    )\n    existing_keys = {(m[\"name\"], m[\"type\"]) for m in existing_models}\n\n    created = 0\n    existing = 0\n\n    for model_data in models_data:\n        key = (model_data.name.lower(), model_data.model_type.lower())\n        if key in existing_keys:\n            existing += 1\n            continue\n\n        new_model = Model(\n            name=model_data.name,\n            provider=model_data.provider or cred.provider,\n            type=model_data.model_type,\n            credential=cred.id,\n        )\n        await new_model.save()\n        created += 1\n\n    return {\"created\": created, \"existing\": existing}\n\n\nasync def migrate_from_provider_config() -> dict:\n    \"\"\"\n    Migrate existing ProviderConfig data to individual credential records.\n\n    Returns dict with message, migrated, skipped, errors.\n    \"\"\"\n    logger.info(\"=== Starting ProviderConfig migration ===\")\n\n    require_encryption_key()\n    logger.info(\"Encryption key verified\")\n\n    from open_notebook.domain.provider_config import ProviderConfig\n\n    config = await ProviderConfig.get_instance()\n    logger.info(\n        f\"Found ProviderConfig with {len(config.credentials)} provider(s): \"\n        f\"{', '.join(config.credentials.keys())}\"\n    )\n\n    migrated = []\n    skipped = []\n    errors = []\n\n    for provider, credentials_list in config.credentials.items():\n        for old_cred in credentials_list:\n            try:\n                # Check if a credential already exists for this provider with same name\n                existing = await Credential.get_by_provider(provider)\n                names = [c.name for c in existing]\n                if old_cred.name in names:\n                    logger.info(\n                        f\"[{provider}/{old_cred.name}] Already exists in DB, skipping\"\n                    )\n                    skipped.append(f\"{provider}/{old_cred.name}\")\n                    continue\n\n                # Determine modalities from the provider type\n                modalities = get_default_modalities(provider)\n\n                logger.info(f\"[{provider}/{old_cred.name}] Creating credential\")\n                new_cred = Credential(\n                    name=old_cred.name,\n                    provider=provider,\n                    modalities=modalities,\n                    api_key=old_cred.api_key,\n                    base_url=old_cred.base_url,\n                    endpoint=old_cred.endpoint,\n                    api_version=old_cred.api_version,\n                    endpoint_llm=old_cred.endpoint_llm,\n                    endpoint_embedding=old_cred.endpoint_embedding,\n                    endpoint_stt=old_cred.endpoint_stt,\n                    endpoint_tts=old_cred.endpoint_tts,\n                    project=old_cred.project,\n                    location=old_cred.location,\n                    credentials_path=old_cred.credentials_path,\n                )\n                await new_cred.save()\n                logger.info(\n                    f\"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})\"\n                )\n\n                # Link existing models for this provider to the new credential\n                from open_notebook.ai.models import Model\n                from open_notebook.database.repository import repo_query\n\n                provider_models = await repo_query(\n                    \"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE\",\n                    {\"provider\": provider.lower()},\n                )\n                if provider_models:\n                    logger.info(\n                        f\"[{provider}/{old_cred.name}] Linking {len(provider_models)} \"\n                        f\"unassigned model(s)\"\n                    )\n                    for model_data in provider_models:\n                        model = Model(**model_data)\n                        model.credential = new_cred.id\n                        await model.save()\n\n                migrated.append(f\"{provider}/{old_cred.name}\")\n\n            except Exception as e:\n                logger.error(\n                    f\"[{provider}/{old_cred.name}] Migration FAILED: \"\n                    f\"{type(e).__name__}: {e}\",\n                    exc_info=True,\n                )\n                errors.append(f\"{provider}/{old_cred.name}: {e}\")\n\n    logger.info(\n        f\"=== ProviderConfig migration complete === \"\n        f\"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}\"\n    )\n    if migrated:\n        logger.info(f\"  Migrated: {', '.join(migrated)}\")\n    if skipped:\n        logger.info(f\"  Skipped: {', '.join(skipped)}\")\n    if errors:\n        logger.error(f\"  Errors: {'; '.join(errors)}\")\n\n    return {\n        \"message\": f\"Migration complete. Migrated {len(migrated)} credentials.\",\n        \"migrated\": migrated,\n        \"skipped\": skipped,\n        \"errors\": errors,\n    }\n\n\nasync def migrate_from_env() -> dict:\n    \"\"\"\n    Migrate API keys from environment variables to credential records.\n\n    Returns dict with message, migrated, skipped, not_configured, errors.\n    \"\"\"\n    logger.info(\"=== Starting environment variable migration ===\")\n    logger.info(\n        f\"Checking {len(PROVIDER_ENV_CONFIG)} providers: \"\n        f\"{', '.join(PROVIDER_ENV_CONFIG.keys())}\"\n    )\n\n    require_encryption_key()\n    logger.info(\"Encryption key verified\")\n\n    from open_notebook.ai.models import Model\n    from open_notebook.database.repository import repo_query\n\n    migrated = []\n    skipped = []\n    not_configured = []\n    errors = []\n\n    for provider in PROVIDER_ENV_CONFIG:\n        try:\n            if not check_env_configured(provider):\n                logger.debug(f\"[{provider}] No env vars configured, skipping\")\n                not_configured.append(provider)\n                continue\n\n            logger.info(f\"[{provider}] Env vars detected, checking for existing credentials\")\n\n            existing = await Credential.get_by_provider(provider)\n            if existing:\n                logger.info(\n                    f\"[{provider}] Already has {len(existing)} credential(s) in DB, skipping\"\n                )\n                skipped.append(provider)\n                continue\n\n            logger.info(f\"[{provider}] Creating credential from env vars\")\n            cred = create_credential_from_env(provider)\n            await cred.save()\n            logger.info(f\"[{provider}] Credential saved successfully (id={cred.id})\")\n\n            # Link unassigned models to this credential\n            provider_models = await repo_query(\n                \"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE\",\n                {\"provider\": provider.lower()},\n            )\n            if provider_models:\n                logger.info(\n                    f\"[{provider}] Linking {len(provider_models)} unassigned model(s) \"\n                    f\"to credential {cred.id}\"\n                )\n                for model_data in provider_models:\n                    model = Model(**model_data)\n                    model.credential = cred.id\n                    await model.save()\n            else:\n                logger.info(f\"[{provider}] No unassigned models to link\")\n\n            migrated.append(provider)\n\n        except Exception as e:\n            logger.error(\n                f\"[{provider}] Migration FAILED: {type(e).__name__}: {e}\",\n                exc_info=True,\n            )\n            errors.append(f\"{provider}: {e}\")\n\n    logger.info(\n        f\"=== Environment variable migration complete === \"\n        f\"migrated={len(migrated)} skipped={len(skipped)} \"\n        f\"not_configured={len(not_configured)} errors={len(errors)}\"\n    )\n    if migrated:\n        logger.info(f\"  Migrated: {', '.join(migrated)}\")\n    if skipped:\n        logger.info(f\"  Skipped (already in DB): {', '.join(skipped)}\")\n    if errors:\n        logger.error(f\"  Errors: {'; '.join(errors)}\")\n\n    return {\n        \"message\": f\"Migration complete. Migrated {len(migrated)} providers.\",\n        \"migrated\": migrated,\n        \"skipped\": skipped,\n        \"not_configured\": not_configured,\n        \"errors\": errors,\n    }\n"
  },
  {
    "path": "api/embedding_service.py",
    "content": "\"\"\"\nEmbedding service layer using API.\n\"\"\"\n\nfrom typing import Any, Dict, List, Union\n\nfrom loguru import logger\n\nfrom api.client import api_client\n\n\nclass EmbeddingService:\n    \"\"\"Service layer for embedding operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for embedding operations\")\n\n    def embed_content(\n        self, item_id: str, item_type: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Embed content for vector search.\"\"\"\n        result = api_client.embed_content(item_id=item_id, item_type=item_type)\n        return result\n\n\n# Global service instance\nembedding_service = EmbeddingService()\n"
  },
  {
    "path": "api/episode_profiles_service.py",
    "content": "\"\"\"\nEpisode profiles service layer using API.\n\"\"\"\n\nfrom typing import List\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.podcasts.models import EpisodeProfile\n\n\nclass EpisodeProfilesService:\n    \"\"\"Service layer for episode profiles operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for episode profiles operations\")\n\n    def get_all_episode_profiles(self) -> List[EpisodeProfile]:\n        \"\"\"Get all episode profiles.\"\"\"\n        profiles_data = api_client.get_episode_profiles()\n        # Convert API response to EpisodeProfile objects\n        profiles = []\n        for profile_data in profiles_data:\n            profile = EpisodeProfile(\n                name=profile_data[\"name\"],\n                description=profile_data.get(\"description\", \"\"),\n                speaker_config=profile_data[\"speaker_config\"],\n                outline_provider=profile_data[\"outline_provider\"],\n                outline_model=profile_data[\"outline_model\"],\n                transcript_provider=profile_data[\"transcript_provider\"],\n                transcript_model=profile_data[\"transcript_model\"],\n                default_briefing=profile_data[\"default_briefing\"],\n                num_segments=profile_data[\"num_segments\"],\n            )\n            profile.id = profile_data[\"id\"]\n            profiles.append(profile)\n        return profiles\n\n    def get_episode_profile(self, profile_name: str) -> EpisodeProfile:\n        \"\"\"Get a specific episode profile by name.\"\"\"\n        profile_response = api_client.get_episode_profile(profile_name)\n        profile_data = (\n            profile_response\n            if isinstance(profile_response, dict)\n            else profile_response[0]\n        )\n        profile = EpisodeProfile(\n            name=profile_data[\"name\"],\n            description=profile_data.get(\"description\", \"\"),\n            speaker_config=profile_data[\"speaker_config\"],\n            outline_provider=profile_data[\"outline_provider\"],\n            outline_model=profile_data[\"outline_model\"],\n            transcript_provider=profile_data[\"transcript_provider\"],\n            transcript_model=profile_data[\"transcript_model\"],\n            default_briefing=profile_data[\"default_briefing\"],\n            num_segments=profile_data[\"num_segments\"],\n        )\n        profile.id = profile_data[\"id\"]\n        return profile\n\n    def create_episode_profile(\n        self,\n        name: str,\n        description: str = \"\",\n        speaker_config: str = \"\",\n        outline_provider: str = \"\",\n        outline_model: str = \"\",\n        transcript_provider: str = \"\",\n        transcript_model: str = \"\",\n        default_briefing: str = \"\",\n        num_segments: int = 5,\n    ) -> EpisodeProfile:\n        \"\"\"Create a new episode profile.\"\"\"\n        profile_response = api_client.create_episode_profile(\n            name=name,\n            description=description,\n            speaker_config=speaker_config,\n            outline_provider=outline_provider,\n            outline_model=outline_model,\n            transcript_provider=transcript_provider,\n            transcript_model=transcript_model,\n            default_briefing=default_briefing,\n            num_segments=num_segments,\n        )\n        profile_data = (\n            profile_response\n            if isinstance(profile_response, dict)\n            else profile_response[0]\n        )\n        profile = EpisodeProfile(\n            name=profile_data[\"name\"],\n            description=profile_data.get(\"description\", \"\"),\n            speaker_config=profile_data[\"speaker_config\"],\n            outline_provider=profile_data[\"outline_provider\"],\n            outline_model=profile_data[\"outline_model\"],\n            transcript_provider=profile_data[\"transcript_provider\"],\n            transcript_model=profile_data[\"transcript_model\"],\n            default_briefing=profile_data[\"default_briefing\"],\n            num_segments=profile_data[\"num_segments\"],\n        )\n        profile.id = profile_data[\"id\"]\n        return profile\n\n    def delete_episode_profile(self, profile_id: str) -> bool:\n        \"\"\"Delete an episode profile.\"\"\"\n        api_client.delete_episode_profile(profile_id)\n        return True\n\n\n# Global service instance\nepisode_profiles_service = EpisodeProfilesService()\n"
  },
  {
    "path": "api/insights_service.py",
    "content": "\"\"\"\nInsights service layer using API.\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.notebook import Note, SourceInsight\n\n\nclass InsightsService:\n    \"\"\"Service layer for insights operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for insights operations\")\n\n    def get_source_insights(self, source_id: str) -> List[SourceInsight]:\n        \"\"\"Get all insights for a specific source.\"\"\"\n        insights_data = api_client.get_source_insights(source_id)\n        # Convert API response to SourceInsight objects\n        insights = []\n        for insight_data in insights_data:\n            insight = SourceInsight(\n                insight_type=insight_data[\"insight_type\"],\n                content=insight_data[\"content\"],\n            )\n            insight.id = insight_data[\"id\"]\n            insight.created = insight_data[\"created\"]\n            insight.updated = insight_data[\"updated\"]\n            insights.append(insight)\n        return insights\n\n    def get_insight(self, insight_id: str) -> SourceInsight:\n        \"\"\"Get a specific insight.\"\"\"\n        insight_response = api_client.get_insight(insight_id)\n        insight_data = (\n            insight_response\n            if isinstance(insight_response, dict)\n            else insight_response[0]\n        )\n        insight = SourceInsight(\n            insight_type=insight_data[\"insight_type\"],\n            content=insight_data[\"content\"],\n        )\n        insight.id = insight_data[\"id\"]\n        insight.created = insight_data[\"created\"]\n        insight.updated = insight_data[\"updated\"]\n        # Note: source_id from API response is not stored; use await insight.get_source() if needed\n        return insight\n\n    def delete_insight(self, insight_id: str) -> bool:\n        \"\"\"Delete a specific insight.\"\"\"\n        api_client.delete_insight(insight_id)\n        return True\n\n    def save_insight_as_note(\n        self, insight_id: str, notebook_id: Optional[str] = None\n    ) -> Note:\n        \"\"\"Convert an insight to a note.\"\"\"\n        note_response = api_client.save_insight_as_note(insight_id, notebook_id)\n        note_data = (\n            note_response if isinstance(note_response, dict) else note_response[0]\n        )\n        note = Note(\n            title=note_data[\"title\"],\n            content=note_data[\"content\"],\n            note_type=note_data[\"note_type\"],\n        )\n        note.id = note_data[\"id\"]\n        note.created = note_data[\"created\"]\n        note.updated = note_data[\"updated\"]\n        return note\n\n    def create_source_insight(\n        self, source_id: str, transformation_id: str, model_id: Optional[str] = None\n    ) -> SourceInsight:\n        \"\"\"Create a new insight for a source by running a transformation.\"\"\"\n        insight_response = api_client.create_source_insight(\n            source_id, transformation_id, model_id\n        )\n        insight_data = (\n            insight_response\n            if isinstance(insight_response, dict)\n            else insight_response[0]\n        )\n        insight = SourceInsight(\n            insight_type=insight_data[\"insight_type\"],\n            content=insight_data[\"content\"],\n        )\n        insight.id = insight_data[\"id\"]\n        insight.created = insight_data[\"created\"]\n        insight.updated = insight_data[\"updated\"]\n        # Note: source_id from API response is not stored; use await insight.get_source() if needed\n        return insight\n\n\n# Global service instance\ninsights_service = InsightsService()\n"
  },
  {
    "path": "api/main.py",
    "content": "# Load environment variables\nfrom dotenv import load_dotenv\n\nload_dotenv()\n\nfrom contextlib import asynccontextmanager\n\nfrom fastapi import FastAPI, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.responses import JSONResponse\nfrom loguru import logger\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\nfrom api.auth import PasswordAuthMiddleware\nfrom open_notebook.exceptions import (\n    AuthenticationError,\n    ConfigurationError,\n    ExternalServiceError,\n    InvalidInputError,\n    NetworkError,\n    NotFoundError,\n    OpenNotebookError,\n    RateLimitError,\n)\nfrom api.routers import (\n    auth,\n    chat,\n    config,\n    context,\n    credentials,\n    embedding,\n    embedding_rebuild,\n    episode_profiles,\n    insights,\n    languages,\n    models,\n    notebooks,\n    notes,\n    podcasts,\n    search,\n    settings,\n    source_chat,\n    sources,\n    speaker_profiles,\n    transformations,\n)\nfrom api.routers import commands as commands_router\nfrom open_notebook.database.async_migrate import AsyncMigrationManager\nfrom open_notebook.utils.encryption import get_secret_from_env\n\n# Import commands to register them in the API process\ntry:\n    logger.info(\"Commands imported in API process\")\nexcept Exception as e:\n    logger.error(f\"Failed to import commands in API process: {e}\")\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    \"\"\"\n    Lifespan event handler for the FastAPI application.\n    Runs database migrations automatically on startup.\n    \"\"\"\n    import os\n\n    # Startup: Security checks\n    logger.info(\"Starting API initialization...\")\n\n    # Security check: Encryption key\n    if not get_secret_from_env(\"OPEN_NOTEBOOK_ENCRYPTION_KEY\"):\n        logger.warning(\n            \"OPEN_NOTEBOOK_ENCRYPTION_KEY not set. \"\n            \"API key encryption will fail until this is configured. \"\n            \"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string.\"\n        )\n\n    # Run database migrations\n\n    try:\n        migration_manager = AsyncMigrationManager()\n        current_version = await migration_manager.get_current_version()\n        logger.info(f\"Current database version: {current_version}\")\n\n        if await migration_manager.needs_migration():\n            logger.warning(\"Database migrations are pending. Running migrations...\")\n            await migration_manager.run_migration_up()\n            new_version = await migration_manager.get_current_version()\n            logger.success(\n                f\"Migrations completed successfully. Database is now at version {new_version}\"\n            )\n        else:\n            logger.info(\n                \"Database is already at the latest version. No migrations needed.\"\n            )\n    except Exception as e:\n        logger.error(f\"CRITICAL: Database migration failed: {str(e)}\")\n        logger.exception(e)\n        # Fail fast - don't start the API with an outdated database schema\n        raise RuntimeError(f\"Failed to run database migrations: {str(e)}\") from e\n\n    # Run podcast profile data migration (legacy strings -> Model registry)\n    try:\n        from open_notebook.podcasts.migration import migrate_podcast_profiles\n\n        await migrate_podcast_profiles()\n    except Exception as e:\n        logger.warning(f\"Podcast profile migration encountered errors: {e}\")\n        # Non-fatal: profiles can be migrated manually via UI\n\n    logger.success(\"API initialization completed successfully\")\n\n    # Yield control to the application\n    yield\n\n    # Shutdown: cleanup if needed\n    logger.info(\"API shutdown complete\")\n\n\napp = FastAPI(\n    title=\"Open Notebook API\",\n    description=\"API for Open Notebook - Research Assistant\",\n    lifespan=lifespan,\n)\n\n# Add password authentication middleware first\n# Exclude /api/auth/status and /api/config from authentication\napp.add_middleware(\n    PasswordAuthMiddleware,\n    excluded_paths=[\n        \"/\",\n        \"/health\",\n        \"/docs\",\n        \"/openapi.json\",\n        \"/redoc\",\n        \"/api/auth/status\",\n        \"/api/config\",\n    ],\n)\n\n# Add CORS middleware last (so it processes first)\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],  # In production, replace with specific origins\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\n\n\n# Custom exception handler to ensure CORS headers are included in error responses\n# This helps when errors occur before the CORS middleware can process them\n@app.exception_handler(StarletteHTTPException)\nasync def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):\n    \"\"\"\n    Custom exception handler that ensures CORS headers are included in error responses.\n    This is particularly important for 413 (Payload Too Large) errors during file uploads.\n\n    Note: If a reverse proxy (nginx, traefik) returns 413 before the request reaches\n    FastAPI, this handler won't be called. In that case, configure your reverse proxy\n    to add CORS headers to error responses.\n    \"\"\"\n    # Get the origin from the request\n    origin = request.headers.get(\"origin\", \"*\")\n\n    return JSONResponse(\n        status_code=exc.status_code,\n        content={\"detail\": exc.detail},\n        headers={\n            **(exc.headers or {}), \"Access-Control-Allow-Origin\": origin,\n            \"Access-Control-Allow-Credentials\": \"true\",\n            \"Access-Control-Allow-Methods\": \"*\",\n            \"Access-Control-Allow-Headers\": \"*\",\n        },\n    )\n\n\ndef _cors_headers(request: Request) -> dict[str, str]:\n    origin = request.headers.get(\"origin\", \"*\")\n    return {\n        \"Access-Control-Allow-Origin\": origin,\n        \"Access-Control-Allow-Credentials\": \"true\",\n        \"Access-Control-Allow-Methods\": \"*\",\n        \"Access-Control-Allow-Headers\": \"*\",\n    }\n\n\n@app.exception_handler(NotFoundError)\nasync def not_found_error_handler(request: Request, exc: NotFoundError):\n    return JSONResponse(\n        status_code=404,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(InvalidInputError)\nasync def invalid_input_error_handler(request: Request, exc: InvalidInputError):\n    return JSONResponse(\n        status_code=400,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(AuthenticationError)\nasync def authentication_error_handler(request: Request, exc: AuthenticationError):\n    return JSONResponse(\n        status_code=401,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(RateLimitError)\nasync def rate_limit_error_handler(request: Request, exc: RateLimitError):\n    return JSONResponse(\n        status_code=429,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(ConfigurationError)\nasync def configuration_error_handler(request: Request, exc: ConfigurationError):\n    return JSONResponse(\n        status_code=422,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(NetworkError)\nasync def network_error_handler(request: Request, exc: NetworkError):\n    return JSONResponse(\n        status_code=502,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(ExternalServiceError)\nasync def external_service_error_handler(request: Request, exc: ExternalServiceError):\n    return JSONResponse(\n        status_code=502,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n@app.exception_handler(OpenNotebookError)\nasync def open_notebook_error_handler(request: Request, exc: OpenNotebookError):\n    return JSONResponse(\n        status_code=500,\n        content={\"detail\": str(exc)},\n        headers=_cors_headers(request),\n    )\n\n\n# Include routers\napp.include_router(auth.router, prefix=\"/api\", tags=[\"auth\"])\napp.include_router(config.router, prefix=\"/api\", tags=[\"config\"])\napp.include_router(notebooks.router, prefix=\"/api\", tags=[\"notebooks\"])\napp.include_router(search.router, prefix=\"/api\", tags=[\"search\"])\napp.include_router(models.router, prefix=\"/api\", tags=[\"models\"])\napp.include_router(transformations.router, prefix=\"/api\", tags=[\"transformations\"])\napp.include_router(notes.router, prefix=\"/api\", tags=[\"notes\"])\napp.include_router(embedding.router, prefix=\"/api\", tags=[\"embedding\"])\napp.include_router(\n    embedding_rebuild.router, prefix=\"/api/embeddings\", tags=[\"embeddings\"]\n)\napp.include_router(settings.router, prefix=\"/api\", tags=[\"settings\"])\napp.include_router(context.router, prefix=\"/api\", tags=[\"context\"])\napp.include_router(sources.router, prefix=\"/api\", tags=[\"sources\"])\napp.include_router(insights.router, prefix=\"/api\", tags=[\"insights\"])\napp.include_router(commands_router.router, prefix=\"/api\", tags=[\"commands\"])\napp.include_router(podcasts.router, prefix=\"/api\", tags=[\"podcasts\"])\napp.include_router(episode_profiles.router, prefix=\"/api\", tags=[\"episode-profiles\"])\napp.include_router(speaker_profiles.router, prefix=\"/api\", tags=[\"speaker-profiles\"])\napp.include_router(chat.router, prefix=\"/api\", tags=[\"chat\"])\napp.include_router(source_chat.router, prefix=\"/api\", tags=[\"source-chat\"])\napp.include_router(credentials.router, prefix=\"/api\", tags=[\"credentials\"])\napp.include_router(languages.router, prefix=\"/api\", tags=[\"languages\"])\n\n\n@app.get(\"/\")\nasync def root():\n    return {\"message\": \"Open Notebook API is running\"}\n\n\n@app.get(\"/health\")\nasync def health():\n    return {\"status\": \"healthy\"}\n"
  },
  {
    "path": "api/models.py",
    "content": "from typing import Any, Dict, List, Literal, Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator\n\n\n# Notebook models\nclass NotebookCreate(BaseModel):\n    name: str = Field(..., description=\"Name of the notebook\")\n    description: str = Field(default=\"\", description=\"Description of the notebook\")\n\n\nclass NotebookUpdate(BaseModel):\n    name: Optional[str] = Field(None, description=\"Name of the notebook\")\n    description: Optional[str] = Field(None, description=\"Description of the notebook\")\n    archived: Optional[bool] = Field(\n        None, description=\"Whether the notebook is archived\"\n    )\n\n\nclass NotebookResponse(BaseModel):\n    id: str\n    name: str\n    description: str\n    archived: bool\n    created: str\n    updated: str\n    source_count: int\n    note_count: int\n\n\n# Search models\nclass SearchRequest(BaseModel):\n    query: str = Field(..., description=\"Search query\")\n    type: Literal[\"text\", \"vector\"] = Field(\"text\", description=\"Search type\")\n    limit: int = Field(100, description=\"Maximum number of results\", le=1000)\n    search_sources: bool = Field(True, description=\"Include sources in search\")\n    search_notes: bool = Field(True, description=\"Include notes in search\")\n    minimum_score: float = Field(\n        0.2, description=\"Minimum score for vector search\", ge=0, le=1\n    )\n\n\nclass SearchResponse(BaseModel):\n    results: List[Dict[str, Any]] = Field(..., description=\"Search results\")\n    total_count: int = Field(..., description=\"Total number of results\")\n    search_type: str = Field(..., description=\"Type of search performed\")\n\n\nclass AskRequest(BaseModel):\n    question: str = Field(..., description=\"Question to ask the knowledge base\")\n    strategy_model: str = Field(..., description=\"Model ID for query strategy\")\n    answer_model: str = Field(..., description=\"Model ID for individual answers\")\n    final_answer_model: str = Field(..., description=\"Model ID for final answer\")\n\n\nclass AskResponse(BaseModel):\n    answer: str = Field(..., description=\"Final answer from the knowledge base\")\n    question: str = Field(..., description=\"Original question\")\n\n\n# Models API models\nclass ModelCreate(BaseModel):\n    name: str = Field(..., description=\"Model name (e.g., gpt-5-mini, claude, gemini)\")\n    provider: str = Field(\n        ..., description=\"Provider name (e.g., openai, anthropic, gemini)\"\n    )\n    type: str = Field(\n        ...,\n        description=\"Model type (language, embedding, text_to_speech, speech_to_text)\",\n    )\n    credential: Optional[str] = Field(\n        None, description=\"Credential ID to link this model to\"\n    )\n\n\nclass ModelResponse(BaseModel):\n    id: str\n    name: str\n    provider: str\n    type: str\n    credential: Optional[str] = None\n    created: str\n    updated: str\n\n\nclass DefaultModelsResponse(BaseModel):\n    default_chat_model: Optional[str] = None\n    default_transformation_model: Optional[str] = None\n    large_context_model: Optional[str] = None\n    default_text_to_speech_model: Optional[str] = None\n    default_speech_to_text_model: Optional[str] = None\n    default_embedding_model: Optional[str] = None\n    default_tools_model: Optional[str] = None\n\n\nclass ProviderAvailabilityResponse(BaseModel):\n    available: List[str] = Field(..., description=\"List of available providers\")\n    unavailable: List[str] = Field(..., description=\"List of unavailable providers\")\n    supported_types: Dict[str, List[str]] = Field(\n        ..., description=\"Provider to supported model types mapping\"\n    )\n\n\n# Transformations API models\nclass TransformationCreate(BaseModel):\n    name: str = Field(..., description=\"Transformation name\")\n    title: str = Field(..., description=\"Display title for the transformation\")\n    description: str = Field(\n        ..., description=\"Description of what this transformation does\"\n    )\n    prompt: str = Field(..., description=\"The transformation prompt\")\n    apply_default: bool = Field(\n        False, description=\"Whether to apply this transformation by default\"\n    )\n\n\nclass TransformationUpdate(BaseModel):\n    name: Optional[str] = Field(None, description=\"Transformation name\")\n    title: Optional[str] = Field(\n        None, description=\"Display title for the transformation\"\n    )\n    description: Optional[str] = Field(\n        None, description=\"Description of what this transformation does\"\n    )\n    prompt: Optional[str] = Field(None, description=\"The transformation prompt\")\n    apply_default: Optional[bool] = Field(\n        None, description=\"Whether to apply this transformation by default\"\n    )\n\n\nclass TransformationResponse(BaseModel):\n    id: str\n    name: str\n    title: str\n    description: str\n    prompt: str\n    apply_default: bool\n    created: str\n    updated: str\n\n\nclass TransformationExecuteRequest(BaseModel):\n    model_config = ConfigDict(protected_namespaces=())\n\n    transformation_id: str = Field(\n        ..., description=\"ID of the transformation to execute\"\n    )\n    input_text: str = Field(..., description=\"Text to transform\")\n    model_id: str = Field(..., description=\"Model ID to use for the transformation\")\n\n\nclass TransformationExecuteResponse(BaseModel):\n    model_config = ConfigDict(protected_namespaces=())\n\n    output: str = Field(..., description=\"Transformed text\")\n    transformation_id: str = Field(..., description=\"ID of the transformation used\")\n    model_id: str = Field(..., description=\"Model ID used\")\n\n\n# Default Prompt API models\nclass DefaultPromptResponse(BaseModel):\n    transformation_instructions: str = Field(\n        ..., description=\"Default transformation instructions\"\n    )\n\n\nclass DefaultPromptUpdate(BaseModel):\n    transformation_instructions: str = Field(\n        ..., description=\"Default transformation instructions\"\n    )\n\n\n# Notes API models\nclass NoteCreate(BaseModel):\n    title: Optional[str] = Field(None, description=\"Note title\")\n    content: str = Field(..., description=\"Note content\")\n    note_type: Optional[str] = Field(\"human\", description=\"Type of note (human, ai)\")\n    notebook_id: Optional[str] = Field(\n        None, description=\"Notebook ID to add the note to\"\n    )\n\n\nclass NoteUpdate(BaseModel):\n    title: Optional[str] = Field(None, description=\"Note title\")\n    content: Optional[str] = Field(None, description=\"Note content\")\n    note_type: Optional[str] = Field(None, description=\"Type of note (human, ai)\")\n\n\nclass NoteResponse(BaseModel):\n    id: str\n    title: Optional[str]\n    content: Optional[str]\n    note_type: Optional[str]\n    created: str\n    updated: str\n    command_id: Optional[str] = None\n\n\n# Embedding API models\nclass EmbedRequest(BaseModel):\n    item_id: str = Field(..., description=\"ID of the item to embed\")\n    item_type: str = Field(..., description=\"Type of item (source, note)\")\n    async_processing: bool = Field(\n        False, description=\"Process asynchronously in background\"\n    )\n\n\nclass EmbedResponse(BaseModel):\n    success: bool = Field(..., description=\"Whether embedding was successful\")\n    message: str = Field(..., description=\"Result message\")\n    item_id: str = Field(..., description=\"ID of the item that was embedded\")\n    item_type: str = Field(..., description=\"Type of item that was embedded\")\n    command_id: Optional[str] = Field(\n        None, description=\"Command ID for async processing\"\n    )\n\n\n# Rebuild request/response models\nclass RebuildRequest(BaseModel):\n    mode: Literal[\"existing\", \"all\"] = Field(\n        ...,\n        description=\"Rebuild mode: 'existing' only re-embeds items with embeddings, 'all' embeds everything\",\n    )\n    include_sources: bool = Field(True, description=\"Include sources in rebuild\")\n    include_notes: bool = Field(True, description=\"Include notes in rebuild\")\n    include_insights: bool = Field(True, description=\"Include insights in rebuild\")\n\n\nclass RebuildResponse(BaseModel):\n    command_id: str = Field(..., description=\"Command ID to track progress\")\n    total_items: int = Field(..., description=\"Estimated number of items to process\")\n    message: str = Field(..., description=\"Status message\")\n\n\nclass RebuildProgress(BaseModel):\n    processed: int = Field(..., description=\"Number of items processed\")\n    total: int = Field(..., description=\"Total items to process\")\n    percentage: float = Field(..., description=\"Progress percentage\")\n\n\nclass RebuildStats(BaseModel):\n    sources: int = Field(0, description=\"Sources processed\")\n    notes: int = Field(0, description=\"Notes processed\")\n    insights: int = Field(0, description=\"Insights processed\")\n    failed: int = Field(0, description=\"Failed items\")\n\n\nclass RebuildStatusResponse(BaseModel):\n    command_id: str = Field(..., description=\"Command ID\")\n    status: str = Field(..., description=\"Status: queued, running, completed, failed\")\n    progress: Optional[RebuildProgress] = None\n    stats: Optional[RebuildStats] = None\n    started_at: Optional[str] = None\n    completed_at: Optional[str] = None\n    error_message: Optional[str] = None\n\n\n# Settings API models\nclass SettingsResponse(BaseModel):\n    default_content_processing_engine_doc: Optional[str] = None\n    default_content_processing_engine_url: Optional[str] = None\n    default_embedding_option: Optional[str] = None\n    auto_delete_files: Optional[str] = None\n    youtube_preferred_languages: Optional[List[str]] = None\n\n\nclass SettingsUpdate(BaseModel):\n    default_content_processing_engine_doc: Optional[str] = None\n    default_content_processing_engine_url: Optional[str] = None\n    default_embedding_option: Optional[str] = None\n    auto_delete_files: Optional[str] = None\n    youtube_preferred_languages: Optional[List[str]] = None\n\n\n# Sources API models\nclass AssetModel(BaseModel):\n    file_path: Optional[str] = None\n    url: Optional[str] = None\n\n\nclass SourceCreate(BaseModel):\n    # Backward compatibility: support old single notebook_id\n    notebook_id: Optional[str] = Field(\n        None, description=\"Notebook ID to add the source to (deprecated, use notebooks)\"\n    )\n    # New multi-notebook support\n    notebooks: Optional[List[str]] = Field(\n        None, description=\"List of notebook IDs to add the source to\"\n    )\n    # Required fields\n    type: str = Field(..., description=\"Source type: link, upload, or text\")\n    url: Optional[str] = Field(None, description=\"URL for link type\")\n    file_path: Optional[str] = Field(None, description=\"File path for upload type\")\n    content: Optional[str] = Field(None, description=\"Text content for text type\")\n    title: Optional[str] = Field(None, description=\"Source title\")\n    transformations: Optional[List[str]] = Field(\n        default_factory=list, description=\"Transformation IDs to apply\"\n    )\n    embed: bool = Field(False, description=\"Whether to embed content for vector search\")\n    delete_source: bool = Field(\n        False, description=\"Whether to delete uploaded file after processing\"\n    )\n    # New async processing support\n    async_processing: bool = Field(\n        False, description=\"Whether to process source asynchronously\"\n    )\n\n    @model_validator(mode=\"after\")\n    def validate_notebook_fields(self):\n        # Ensure only one of notebook_id or notebooks is provided\n        if self.notebook_id is not None and self.notebooks is not None:\n            raise ValueError(\n                \"Cannot specify both 'notebook_id' and 'notebooks'. Use 'notebooks' for multi-notebook support.\"\n            )\n\n        # Convert single notebook_id to notebooks array for internal processing\n        if self.notebook_id is not None:\n            self.notebooks = [self.notebook_id]\n            # Keep notebook_id for backward compatibility in response\n\n        # Set empty array if no notebooks specified (allow sources without notebooks)\n        if self.notebooks is None:\n            self.notebooks = []\n\n        return self\n\n\nclass SourceUpdate(BaseModel):\n    title: Optional[str] = Field(None, description=\"Source title\")\n    topics: Optional[List[str]] = Field(None, description=\"Source topics\")\n\n\nclass SourceResponse(BaseModel):\n    id: str\n    title: Optional[str]\n    topics: Optional[List[str]]\n    asset: Optional[AssetModel]\n    full_text: Optional[str]\n    embedded: bool\n    embedded_chunks: int\n    file_available: Optional[bool] = None\n    created: str\n    updated: str\n    # New fields for async processing\n    command_id: Optional[str] = None\n    status: Optional[str] = None\n    processing_info: Optional[Dict] = None\n    # Notebook associations\n    notebooks: Optional[List[str]] = None\n\n\nclass SourceListResponse(BaseModel):\n    id: str\n    title: Optional[str]\n    topics: Optional[List[str]]\n    asset: Optional[AssetModel]\n    embedded: bool  # Boolean flag indicating if source has embeddings\n    embedded_chunks: int  # Number of embedded chunks\n    insights_count: int\n    created: str\n    updated: str\n    file_available: Optional[bool] = None\n    # Status fields for async processing\n    command_id: Optional[str] = None\n    status: Optional[str] = None\n    processing_info: Optional[Dict[str, Any]] = None\n\n\n# Context API models\nclass ContextConfig(BaseModel):\n    sources: Dict[str, str] = Field(\n        default_factory=dict, description=\"Source inclusion config {source_id: level}\"\n    )\n    notes: Dict[str, str] = Field(\n        default_factory=dict, description=\"Note inclusion config {note_id: level}\"\n    )\n\n\nclass ContextRequest(BaseModel):\n    notebook_id: str = Field(..., description=\"Notebook ID to get context for\")\n    context_config: Optional[ContextConfig] = Field(\n        None, description=\"Context configuration\"\n    )\n\n\nclass ContextResponse(BaseModel):\n    notebook_id: str\n    sources: List[Dict[str, Any]] = Field(..., description=\"Source context data\")\n    notes: List[Dict[str, Any]] = Field(..., description=\"Note context data\")\n    total_tokens: Optional[int] = Field(None, description=\"Estimated token count\")\n\n\n# Insights API models\nclass SourceInsightResponse(BaseModel):\n    id: str\n    source_id: str\n    insight_type: str\n    content: str\n    created: str\n    updated: str\n\n\nclass InsightCreationResponse(BaseModel):\n    \"\"\"Response for async insight creation.\"\"\"\n\n    status: Literal[\"pending\"] = \"pending\"\n    message: str = \"Insight generation started\"\n    source_id: str\n    transformation_id: str\n    command_id: Optional[str] = None\n\n\nclass SaveAsNoteRequest(BaseModel):\n    notebook_id: Optional[str] = Field(None, description=\"Notebook ID to add note to\")\n\n\nclass CreateSourceInsightRequest(BaseModel):\n    model_config = ConfigDict(protected_namespaces=())\n\n    transformation_id: str = Field(..., description=\"ID of transformation to apply\")\n    model_id: Optional[str] = Field(\n        None, description=\"Model ID (uses default if not provided)\"\n    )\n\n\n# Source status response\nclass SourceStatusResponse(BaseModel):\n    status: Optional[str] = Field(None, description=\"Processing status\")\n    message: str = Field(..., description=\"Descriptive message about the status\")\n    processing_info: Optional[Dict[str, Any]] = Field(\n        None, description=\"Detailed processing information\"\n    )\n    command_id: Optional[str] = Field(None, description=\"Command ID if available\")\n\n\n# Error response\nclass ErrorResponse(BaseModel):\n    error: str\n    message: str\n\n\n# API Key Configuration models\nclass SetApiKeyRequest(BaseModel):\n    \"\"\"Request to set an API key for a provider.\"\"\"\n\n    api_key: Optional[str] = Field(None, description=\"API key for the provider\")\n    base_url: Optional[str] = Field(\n        None, description=\"Base URL for URL-based providers (Ollama, OpenAI-compatible)\"\n    )\n    endpoint: Optional[str] = Field(\n        None, description=\"Endpoint URL for Azure OpenAI\"\n    )\n    api_version: Optional[str] = Field(\n        None, description=\"API version for Azure OpenAI\"\n    )\n    endpoint_llm: Optional[str] = Field(\n        None, description=\"Service-specific endpoint for LLM (Azure)\"\n    )\n    endpoint_embedding: Optional[str] = Field(\n        None, description=\"Service-specific endpoint for embedding (Azure)\"\n    )\n    endpoint_stt: Optional[str] = Field(\n        None, description=\"Service-specific endpoint for STT (Azure)\"\n    )\n    endpoint_tts: Optional[str] = Field(\n        None, description=\"Service-specific endpoint for TTS (Azure)\"\n    )\n    service_type: Optional[Literal[\"llm\", \"embedding\", \"stt\", \"tts\"]] = Field(\n        None,\n        description=\"Service type for OpenAI-compatible providers (llm, embedding, stt, tts)\",\n    )\n    # Vertex AI specific fields\n    vertex_project: Optional[str] = Field(\n        None, description=\"Google Cloud Project ID for Vertex AI\"\n    )\n    vertex_location: Optional[str] = Field(\n        None, description=\"Google Cloud Region for Vertex AI (e.g., us-central1)\"\n    )\n    vertex_credentials_path: Optional[str] = Field(\n        None, description=\"Path to Google Cloud service account JSON file\"\n    )\n\n    @field_validator(\n        \"api_key\",\n        \"base_url\",\n        \"endpoint\",\n        \"api_version\",\n        \"endpoint_llm\",\n        \"endpoint_embedding\",\n        \"endpoint_stt\",\n        \"endpoint_tts\",\n        \"vertex_project\",\n        \"vertex_location\",\n        \"vertex_credentials_path\",\n        mode=\"before\",\n    )\n    @classmethod\n    def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:\n        \"\"\"Reject empty strings - convert to None or raise error.\"\"\"\n        if v is not None:\n            stripped = v.strip()\n            if not stripped:\n                return None  # Treat empty/whitespace-only as None\n            return stripped\n        return v\n\n\nclass ApiKeyStatusResponse(BaseModel):\n    \"\"\"Response showing which providers are configured and their source.\"\"\"\n\n    configured: Dict[str, bool] = Field(\n        ..., description=\"Map of provider name to whether it is configured\"\n    )\n    source: Dict[str, Literal[\"database\", \"environment\", \"none\"]] = Field(\n        ...,\n        description=\"Map of provider name to configuration source (database, environment, or none)\",\n    )\n    encryption_configured: bool = Field(\n        ...,\n        description=\"Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)\",\n    )\n\n\nclass TestConnectionResponse(BaseModel):\n    \"\"\"Response from testing a provider connection.\"\"\"\n\n    provider: str = Field(..., description=\"Provider name that was tested\")\n    success: bool = Field(..., description=\"Whether connection test succeeded\")\n    message: str = Field(..., description=\"Result message with details\")\n\n\nclass MigrateFromEnvRequest(BaseModel):\n    \"\"\"Request to migrate API keys from environment variables to database.\"\"\"\n\n    force: bool = Field(\n        False, description=\"Force overwrite existing database configurations\"\n    )\n\n\nclass MigrationResult(BaseModel):\n    \"\"\"Response from migrating API keys from environment to database.\"\"\"\n\n    message: str = Field(..., description=\"Summary message\")\n    migrated: List[str] = Field(\n        default_factory=list, description=\"Providers successfully migrated\"\n    )\n    skipped: List[str] = Field(\n        default_factory=list, description=\"Providers skipped (already in DB)\"\n    )\n    errors: List[str] = Field(\n        default_factory=list, description=\"Migration errors by provider\"\n    )\n\n\n# Notebook delete cascade models\n# Credential models\nclass CreateCredentialRequest(BaseModel):\n    \"\"\"Request to create a new credential.\"\"\"\n\n    name: str = Field(..., description=\"Credential name\")\n    provider: str = Field(..., description=\"Provider name (openai, anthropic, etc.)\")\n    modalities: List[str] = Field(\n        default_factory=list,\n        description=\"Supported modalities (language, embedding, text_to_speech, speech_to_text)\",\n    )\n    api_key: Optional[str] = Field(None, description=\"API key (stored encrypted)\")\n    base_url: Optional[str] = Field(None, description=\"Base URL\")\n    endpoint: Optional[str] = Field(None, description=\"Endpoint URL (Azure)\")\n    api_version: Optional[str] = Field(None, description=\"API version (Azure)\")\n    endpoint_llm: Optional[str] = Field(None, description=\"LLM endpoint\")\n    endpoint_embedding: Optional[str] = Field(None, description=\"Embedding endpoint\")\n    endpoint_stt: Optional[str] = Field(None, description=\"STT endpoint\")\n    endpoint_tts: Optional[str] = Field(None, description=\"TTS endpoint\")\n    project: Optional[str] = Field(None, description=\"Project ID (Vertex)\")\n    location: Optional[str] = Field(None, description=\"Location (Vertex)\")\n    credentials_path: Optional[str] = Field(\n        None, description=\"Credentials file path (Vertex)\"\n    )\n\n\nclass UpdateCredentialRequest(BaseModel):\n    \"\"\"Request to update an existing credential.\"\"\"\n\n    name: Optional[str] = Field(None, description=\"Credential name\")\n    modalities: Optional[List[str]] = Field(None, description=\"Supported modalities\")\n    api_key: Optional[str] = Field(None, description=\"API key (stored encrypted)\")\n    base_url: Optional[str] = Field(None, description=\"Base URL\")\n    endpoint: Optional[str] = Field(None, description=\"Endpoint URL\")\n    api_version: Optional[str] = Field(None, description=\"API version\")\n    endpoint_llm: Optional[str] = Field(None, description=\"LLM endpoint\")\n    endpoint_embedding: Optional[str] = Field(None, description=\"Embedding endpoint\")\n    endpoint_stt: Optional[str] = Field(None, description=\"STT endpoint\")\n    endpoint_tts: Optional[str] = Field(None, description=\"TTS endpoint\")\n    project: Optional[str] = Field(None, description=\"Project ID\")\n    location: Optional[str] = Field(None, description=\"Location\")\n    credentials_path: Optional[str] = Field(None, description=\"Credentials path\")\n\n\nclass CredentialResponse(BaseModel):\n    \"\"\"Response for a credential (never includes api_key).\"\"\"\n\n    id: str\n    name: str\n    provider: str\n    modalities: List[str]\n    base_url: Optional[str] = None\n    endpoint: Optional[str] = None\n    api_version: Optional[str] = None\n    endpoint_llm: Optional[str] = None\n    endpoint_embedding: Optional[str] = None\n    endpoint_stt: Optional[str] = None\n    endpoint_tts: Optional[str] = None\n    project: Optional[str] = None\n    location: Optional[str] = None\n    credentials_path: Optional[str] = None\n    has_api_key: bool = False\n    created: str\n    updated: str\n    model_count: int = 0\n\n\nclass CredentialDeleteResponse(BaseModel):\n    \"\"\"Response for credential deletion.\"\"\"\n\n    message: str\n    deleted_models: int = 0\n\n\nclass DiscoveredModelResponse(BaseModel):\n    \"\"\"A model discovered from a provider.\"\"\"\n\n    name: str\n    provider: str\n    model_type: Optional[str] = None\n    description: Optional[str] = None\n\n\nclass DiscoverModelsResponse(BaseModel):\n    \"\"\"Response from model discovery.\"\"\"\n\n    credential_id: str\n    provider: str\n    discovered: List[DiscoveredModelResponse]\n\n\nclass RegisterModelData(BaseModel):\n    \"\"\"A model to register with user-specified type.\"\"\"\n\n    name: str\n    provider: str\n    model_type: str  # Required: user specifies the type\n\n\nclass RegisterModelsRequest(BaseModel):\n    \"\"\"Request to register discovered models.\"\"\"\n\n    models: List[RegisterModelData]\n\n\nclass RegisterModelsResponse(BaseModel):\n    \"\"\"Response from model registration.\"\"\"\n\n    created: int\n    existing: int\n\n\nclass NotebookDeletePreview(BaseModel):\n    notebook_id: str = Field(..., description=\"ID of the notebook\")\n    notebook_name: str = Field(..., description=\"Name of the notebook\")\n    note_count: int = Field(..., description=\"Number of notes that will be deleted\")\n    exclusive_source_count: int = Field(\n        ..., description=\"Number of sources only in this notebook\"\n    )\n    shared_source_count: int = Field(\n        ..., description=\"Number of sources shared with other notebooks\"\n    )\n\n\nclass NotebookDeleteResponse(BaseModel):\n    message: str = Field(..., description=\"Success message\")\n    deleted_notes: int = Field(..., description=\"Number of notes deleted\")\n    deleted_sources: int = Field(..., description=\"Number of exclusive sources deleted\")\n    unlinked_sources: int = Field(\n        ..., description=\"Number of sources unlinked from notebook\"\n    )\n"
  },
  {
    "path": "api/models_service.py",
    "content": "\"\"\"\nModels service layer using API.\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.ai.models import DefaultModels, Model\n\n\nclass ModelsService:\n    \"\"\"Service layer for models operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for models operations\")\n\n    def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:\n        \"\"\"Get all models with optional type filtering.\"\"\"\n        models_data = api_client.get_models(model_type=model_type)\n        # Convert API response to Model objects\n        models = []\n        for model_data in models_data:\n            model = Model(\n                name=model_data[\"name\"],\n                provider=model_data[\"provider\"],\n                type=model_data[\"type\"],\n            )\n            model.id = model_data[\"id\"]\n            model.created = model_data[\"created\"]\n            model.updated = model_data[\"updated\"]\n            models.append(model)\n        return models\n\n    def create_model(self, name: str, provider: str, model_type: str) -> Model:\n        \"\"\"Create a new model.\"\"\"\n        response = api_client.create_model(name, provider, model_type)\n        model_data = response if isinstance(response, dict) else response[0]\n        model = Model(\n            name=model_data[\"name\"],\n            provider=model_data[\"provider\"],\n            type=model_data[\"type\"],\n        )\n        model.id = model_data[\"id\"]\n        model.created = model_data[\"created\"]\n        model.updated = model_data[\"updated\"]\n        return model\n\n    def delete_model(self, model_id: str) -> bool:\n        \"\"\"Delete a model.\"\"\"\n        api_client.delete_model(model_id)\n        return True\n\n    def get_default_models(self) -> DefaultModels:\n        \"\"\"Get default model assignments.\"\"\"\n        response = api_client.get_default_models()\n        defaults_data = response if isinstance(response, dict) else response[0]\n        defaults = DefaultModels()\n\n        # Set the values from API response\n        defaults.default_chat_model = defaults_data.get(\"default_chat_model\")\n        defaults.default_transformation_model = defaults_data.get(\n            \"default_transformation_model\"\n        )\n        defaults.large_context_model = defaults_data.get(\"large_context_model\")\n        defaults.default_text_to_speech_model = defaults_data.get(\n            \"default_text_to_speech_model\"\n        )\n        defaults.default_speech_to_text_model = defaults_data.get(\n            \"default_speech_to_text_model\"\n        )\n        defaults.default_embedding_model = defaults_data.get(\"default_embedding_model\")\n        defaults.default_tools_model = defaults_data.get(\"default_tools_model\")\n\n        return defaults\n\n    def update_default_models(self, defaults: DefaultModels) -> DefaultModels:\n        \"\"\"Update default model assignments.\"\"\"\n        updates = {\n            \"default_chat_model\": defaults.default_chat_model,\n            \"default_transformation_model\": defaults.default_transformation_model,\n            \"large_context_model\": defaults.large_context_model,\n            \"default_text_to_speech_model\": defaults.default_text_to_speech_model,\n            \"default_speech_to_text_model\": defaults.default_speech_to_text_model,\n            \"default_embedding_model\": defaults.default_embedding_model,\n            \"default_tools_model\": defaults.default_tools_model,\n        }\n\n        response = api_client.update_default_models(**updates)\n        defaults_data = response if isinstance(response, dict) else response[0]\n\n        # Update the defaults object with the response\n        defaults.default_chat_model = defaults_data.get(\"default_chat_model\")\n        defaults.default_transformation_model = defaults_data.get(\n            \"default_transformation_model\"\n        )\n        defaults.large_context_model = defaults_data.get(\"large_context_model\")\n        defaults.default_text_to_speech_model = defaults_data.get(\n            \"default_text_to_speech_model\"\n        )\n        defaults.default_speech_to_text_model = defaults_data.get(\n            \"default_speech_to_text_model\"\n        )\n        defaults.default_embedding_model = defaults_data.get(\"default_embedding_model\")\n        defaults.default_tools_model = defaults_data.get(\"default_tools_model\")\n\n        return defaults\n\n\n# Global service instance\nmodels_service = ModelsService()\n"
  },
  {
    "path": "api/notebook_service.py",
    "content": "\"\"\"\nNotebook service layer using API.\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.notebook import Notebook\n\n\nclass NotebookService:\n    \"\"\"Service layer for notebook operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for notebook operations\")\n\n    def get_all_notebooks(self, order_by: str = \"updated desc\") -> List[Notebook]:\n        \"\"\"Get all notebooks.\"\"\"\n        notebooks_data = api_client.get_notebooks(order_by=order_by)\n        # Convert API response to Notebook objects\n        notebooks = []\n        for nb_data in notebooks_data:\n            nb = Notebook(\n                name=nb_data[\"name\"],\n                description=nb_data[\"description\"],\n                archived=nb_data[\"archived\"],\n            )\n            nb.id = nb_data[\"id\"]\n            nb.created = nb_data[\"created\"]\n            nb.updated = nb_data[\"updated\"]\n            notebooks.append(nb)\n        return notebooks\n\n    def get_notebook(self, notebook_id: str) -> Optional[Notebook]:\n        \"\"\"Get a specific notebook.\"\"\"\n        response = api_client.get_notebook(notebook_id)\n        nb_data = response if isinstance(response, dict) else response[0]\n        nb = Notebook(\n            name=nb_data[\"name\"],\n            description=nb_data[\"description\"],\n            archived=nb_data[\"archived\"],\n        )\n        nb.id = nb_data[\"id\"]\n        nb.created = nb_data[\"created\"]\n        nb.updated = nb_data[\"updated\"]\n        return nb\n\n    def create_notebook(self, name: str, description: str = \"\") -> Notebook:\n        \"\"\"Create a new notebook.\"\"\"\n        response = api_client.create_notebook(name, description)\n        nb_data = response if isinstance(response, dict) else response[0]\n        nb = Notebook(\n            name=nb_data[\"name\"],\n            description=nb_data[\"description\"],\n            archived=nb_data[\"archived\"],\n        )\n        nb.id = nb_data[\"id\"]\n        nb.created = nb_data[\"created\"]\n        nb.updated = nb_data[\"updated\"]\n        return nb\n\n    def update_notebook(self, notebook: Notebook) -> Notebook:\n        \"\"\"Update a notebook.\"\"\"\n        updates = {\n            \"name\": notebook.name,\n            \"description\": notebook.description,\n            \"archived\": notebook.archived,\n        }\n        response = api_client.update_notebook(notebook.id or \"\", **updates)\n        nb_data = response if isinstance(response, dict) else response[0]\n        # Update the notebook object with the response\n        notebook.name = nb_data[\"name\"]\n        notebook.description = nb_data[\"description\"]\n        notebook.archived = nb_data[\"archived\"]\n        notebook.updated = nb_data[\"updated\"]\n        return notebook\n\n    def delete_notebook(self, notebook: Notebook) -> bool:\n        \"\"\"Delete a notebook.\"\"\"\n        api_client.delete_notebook(notebook.id or \"\")\n        return True\n\n\n# Global service instance\nnotebook_service = NotebookService()\n"
  },
  {
    "path": "api/notes_service.py",
    "content": "\"\"\"\nNotes service layer using API.\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.notebook import Note\n\n\nclass NotesService:\n    \"\"\"Service layer for notes operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for notes operations\")\n\n    def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:\n        \"\"\"Get all notes with optional notebook filtering.\"\"\"\n        notes_data = api_client.get_notes(notebook_id=notebook_id)\n        # Convert API response to Note objects\n        notes = []\n        for note_data in notes_data:\n            note = Note(\n                title=note_data[\"title\"],\n                content=note_data[\"content\"],\n                note_type=note_data[\"note_type\"],\n            )\n            note.id = note_data[\"id\"]\n            note.created = note_data[\"created\"]\n            note.updated = note_data[\"updated\"]\n            notes.append(note)\n        return notes\n\n    def get_note(self, note_id: str) -> Note:\n        \"\"\"Get a specific note.\"\"\"\n        note_response = api_client.get_note(note_id)\n        note_data = (\n            note_response if isinstance(note_response, dict) else note_response[0]\n        )\n        note = Note(\n            title=note_data[\"title\"],\n            content=note_data[\"content\"],\n            note_type=note_data[\"note_type\"],\n        )\n        note.id = note_data[\"id\"]\n        note.created = note_data[\"created\"]\n        note.updated = note_data[\"updated\"]\n        return note\n\n    def create_note(\n        self,\n        content: str,\n        title: Optional[str] = None,\n        note_type: str = \"human\",\n        notebook_id: Optional[str] = None,\n    ) -> Note:\n        \"\"\"Create a new note.\"\"\"\n        note_response = api_client.create_note(\n            content=content, title=title, note_type=note_type, notebook_id=notebook_id\n        )\n        note_data = (\n            note_response if isinstance(note_response, dict) else note_response[0]\n        )\n        note = Note(\n            title=note_data[\"title\"],\n            content=note_data[\"content\"],\n            note_type=note_data[\"note_type\"],\n        )\n        note.id = note_data[\"id\"]\n        note.created = note_data[\"created\"]\n        note.updated = note_data[\"updated\"]\n        return note\n\n    def update_note(self, note: Note) -> Note:\n        \"\"\"Update a note.\"\"\"\n        updates = {\n            \"title\": note.title,\n            \"content\": note.content,\n            \"note_type\": note.note_type,\n        }\n        note_response = api_client.update_note(note.id or \"\", **updates)\n        note_data = (\n            note_response if isinstance(note_response, dict) else note_response[0]\n        )\n\n        # Update the note object with the response\n        note.title = note_data[\"title\"]\n        note.content = note_data[\"content\"]\n        note.note_type = note_data[\"note_type\"]\n        note.updated = note_data[\"updated\"]\n\n        return note\n\n    def delete_note(self, note_id: str) -> bool:\n        \"\"\"Delete a note.\"\"\"\n        api_client.delete_note(note_id)\n        return True\n\n\n# Global service instance\nnotes_service = NotesService()\n"
  },
  {
    "path": "api/podcast_api_service.py",
    "content": "\"\"\"\nPodcast service layer using API client.\nThis replaces direct httpx calls in the Streamlit pages.\n\"\"\"\n\nfrom typing import Any, Dict, List\n\nfrom loguru import logger\n\nfrom api.client import api_client\n\n\nclass PodcastAPIService:\n    \"\"\"Service layer for podcast operations using API client.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API client for podcast operations\")\n\n    # Episode methods\n    def get_episodes(self) -> List[Dict[Any, Any]]:\n        \"\"\"Get all podcast episodes.\"\"\"\n        result = api_client._make_request(\"GET\", \"/api/podcasts/episodes\")\n        return result if isinstance(result, list) else [result]\n\n    def delete_episode(self, episode_id: str) -> bool:\n        \"\"\"Delete a podcast episode.\"\"\"\n        try:\n            api_client._make_request(\"DELETE\", f\"/api/podcasts/episodes/{episode_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to delete episode: {e}\")\n            return False\n\n    # Episode Profile methods\n    def get_episode_profiles(self) -> List[Dict]:\n        \"\"\"Get all episode profiles.\"\"\"\n        return api_client.get_episode_profiles()\n\n    def create_episode_profile(self, profile_data: Dict) -> bool:\n        \"\"\"Create a new episode profile.\"\"\"\n        try:\n            api_client.create_episode_profile(**profile_data)\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to create episode profile: {e}\")\n            return False\n\n    def update_episode_profile(self, profile_id: str, profile_data: Dict) -> bool:\n        \"\"\"Update an episode profile.\"\"\"\n        try:\n            api_client.update_episode_profile(profile_id, **profile_data)\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to update episode profile: {e}\")\n            return False\n\n    def delete_episode_profile(self, profile_id: str) -> bool:\n        \"\"\"Delete an episode profile.\"\"\"\n        try:\n            api_client.delete_episode_profile(profile_id)\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to delete episode profile: {e}\")\n            return False\n\n    def duplicate_episode_profile(self, profile_id: str) -> bool:\n        \"\"\"Duplicate an episode profile.\"\"\"\n        try:\n            api_client._make_request(\n                \"POST\", f\"/api/episode-profiles/{profile_id}/duplicate\"\n            )\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to duplicate episode profile: {e}\")\n            return False\n\n    # Speaker Profile methods\n    def get_speaker_profiles(self) -> List[Dict[Any, Any]]:\n        \"\"\"Get all speaker profiles.\"\"\"\n        result = api_client._make_request(\"GET\", \"/api/speaker-profiles\")\n        return result if isinstance(result, list) else [result]\n\n    def create_speaker_profile(self, profile_data: Dict) -> bool:\n        \"\"\"Create a new speaker profile.\"\"\"\n        try:\n            api_client._make_request(\"POST\", \"/api/speaker-profiles\", json=profile_data)\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to create speaker profile: {e}\")\n            return False\n\n    def update_speaker_profile(self, profile_id: str, profile_data: Dict) -> bool:\n        \"\"\"Update a speaker profile.\"\"\"\n        try:\n            api_client._make_request(\n                \"PUT\", f\"/api/speaker-profiles/{profile_id}\", json=profile_data\n            )\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to update speaker profile: {e}\")\n            return False\n\n    def delete_speaker_profile(self, profile_id: str) -> bool:\n        \"\"\"Delete a speaker profile.\"\"\"\n        try:\n            api_client._make_request(\"DELETE\", f\"/api/speaker-profiles/{profile_id}\")\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to delete speaker profile: {e}\")\n            return False\n\n    def duplicate_speaker_profile(self, profile_id: str) -> bool:\n        \"\"\"Duplicate a speaker profile.\"\"\"\n        try:\n            api_client._make_request(\n                \"POST\", f\"/api/speaker-profiles/{profile_id}/duplicate\"\n            )\n            return True\n        except Exception as e:\n            logger.error(f\"Failed to duplicate speaker profile: {e}\")\n            return False\n\n\n# Global service instance\npodcast_api_service = PodcastAPIService()\n"
  },
  {
    "path": "api/podcast_service.py",
    "content": "from typing import Any, Dict, Optional\n\nfrom fastapi import HTTPException\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom surreal_commands import get_command_status, submit_command\n\nfrom open_notebook.domain.notebook import Notebook\nfrom open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile\n\n\nclass PodcastGenerationRequest(BaseModel):\n    \"\"\"Request model for podcast generation\"\"\"\n\n    episode_profile: str\n    speaker_profile: str\n    episode_name: str\n    content: Optional[str] = None\n    notebook_id: Optional[str] = None\n    briefing_suffix: Optional[str] = None\n\n\nclass PodcastGenerationResponse(BaseModel):\n    \"\"\"Response model for podcast generation\"\"\"\n\n    job_id: str\n    status: str\n    message: str\n    episode_profile: str\n    episode_name: str\n\n\nclass PodcastService:\n    \"\"\"Service layer for podcast operations\"\"\"\n\n    @staticmethod\n    async def submit_generation_job(\n        episode_profile_name: str,\n        speaker_profile_name: str,\n        episode_name: str,\n        notebook_id: Optional[str] = None,\n        content: Optional[str] = None,\n        briefing_suffix: Optional[str] = None,\n    ) -> str:\n        \"\"\"Submit a podcast generation job for background processing\"\"\"\n        try:\n            # Validate episode profile exists\n            episode_profile = await EpisodeProfile.get_by_name(episode_profile_name)\n            if not episode_profile:\n                raise ValueError(f\"Episode profile '{episode_profile_name}' not found\")\n\n            # Validate speaker profile exists\n            speaker_profile = await SpeakerProfile.get_by_name(speaker_profile_name)\n            if not speaker_profile:\n                raise ValueError(f\"Speaker profile '{speaker_profile_name}' not found\")\n\n            # Get content from notebook if not provided directly\n            if not content and notebook_id:\n                try:\n                    notebook = await Notebook.get(notebook_id)\n                    # Get notebook context (this may need to be adjusted based on actual Notebook implementation)\n                    content = (\n                        await notebook.get_context()\n                        if hasattr(notebook, \"get_context\")\n                        else str(notebook)\n                    )\n                except Exception as e:\n                    logger.warning(\n                        f\"Failed to get notebook content, using notebook_id as content: {e}\"\n                    )\n                    content = f\"Notebook ID: {notebook_id}\"\n\n            if not content:\n                raise ValueError(\n                    \"Content is required - provide either content or notebook_id\"\n                )\n\n            # Prepare command arguments\n            command_args = {\n                \"episode_profile\": episode_profile_name,\n                \"speaker_profile\": speaker_profile_name,\n                \"episode_name\": episode_name,\n                \"content\": str(content),\n                \"briefing_suffix\": briefing_suffix,\n            }\n\n            # Ensure command modules are imported before submitting\n            # This is needed because submit_command validates against local registry\n            try:\n                import commands.podcast_commands  # noqa: F401\n            except ImportError as import_err:\n                logger.error(f\"Failed to import podcast commands: {import_err}\")\n                raise ValueError(\"Podcast commands not available\")\n\n            # Submit command to surreal-commands\n            job_id = submit_command(\"open_notebook\", \"generate_podcast\", command_args)\n\n            # Convert RecordID to string if needed\n            if not job_id:\n                raise ValueError(\"Failed to get job_id from submit_command\")\n            job_id_str = str(job_id)\n            logger.info(\n                f\"Submitted podcast generation job: {job_id_str} for episode '{episode_name}'\"\n            )\n            return job_id_str\n\n        except Exception as e:\n            logger.error(f\"Failed to submit podcast generation job: {e}\")\n            raise HTTPException(\n                status_code=500,\n                detail=f\"Failed to submit podcast generation job: {str(e)}\",\n            )\n\n    @staticmethod\n    async def get_job_status(job_id: str) -> Dict[str, Any]:\n        \"\"\"Get status of a podcast generation job\"\"\"\n        try:\n            status = await get_command_status(job_id)\n            return {\n                \"job_id\": job_id,\n                \"status\": status.status if status else \"unknown\",\n                \"result\": status.result if status else None,\n                \"error_message\": getattr(status, \"error_message\", None)\n                if status\n                else None,\n                \"created\": str(status.created)\n                if status and hasattr(status, \"created\") and status.created\n                else None,\n                \"updated\": str(status.updated)\n                if status and hasattr(status, \"updated\") and status.updated\n                else None,\n                \"progress\": getattr(status, \"progress\", None) if status else None,\n            }\n        except Exception as e:\n            logger.error(f\"Failed to get podcast job status: {e}\")\n            raise HTTPException(\n                status_code=500, detail=f\"Failed to get job status: {str(e)}\"\n            )\n\n    @staticmethod\n    async def list_episodes() -> list:\n        \"\"\"List all podcast episodes\"\"\"\n        try:\n            episodes = await PodcastEpisode.get_all(order_by=\"created desc\")\n            return episodes\n        except Exception as e:\n            logger.error(f\"Failed to list podcast episodes: {e}\")\n            raise HTTPException(\n                status_code=500, detail=f\"Failed to list episodes: {str(e)}\"\n            )\n\n    @staticmethod\n    async def get_episode(episode_id: str) -> PodcastEpisode:\n        \"\"\"Get a specific podcast episode\"\"\"\n        try:\n            episode = await PodcastEpisode.get(episode_id)\n            return episode\n        except Exception as e:\n            logger.error(f\"Failed to get podcast episode {episode_id}: {e}\")\n            raise HTTPException(status_code=404, detail=f\"Episode not found: {str(e)}\")\n\n\nclass DefaultProfiles:\n    \"\"\"Utility class for creating default profiles (if needed beyond migration data)\"\"\"\n\n    @staticmethod\n    async def create_default_episode_profiles():\n        \"\"\"Create default episode profiles if they don't exist\"\"\"\n        try:\n            # Check if profiles already exist\n            existing = await EpisodeProfile.get_all()\n            if existing:\n                logger.info(f\"Episode profiles already exist: {len(existing)} found\")\n                return existing\n\n            # This would create profiles, but since we have migration data,\n            # this is mainly for future extensibility\n            logger.info(\n                \"Default episode profiles should be created via database migration\"\n            )\n            return []\n\n        except Exception as e:\n            logger.error(f\"Failed to create default episode profiles: {e}\")\n            raise\n\n    @staticmethod\n    async def create_default_speaker_profiles():\n        \"\"\"Create default speaker profiles if they don't exist\"\"\"\n        try:\n            # Check if profiles already exist\n            existing = await SpeakerProfile.get_all()\n            if existing:\n                logger.info(f\"Speaker profiles already exist: {len(existing)} found\")\n                return existing\n\n            # This would create profiles, but since we have migration data,\n            # this is mainly for future extensibility\n            logger.info(\n                \"Default speaker profiles should be created via database migration\"\n            )\n            return []\n\n        except Exception as e:\n            logger.error(f\"Failed to create default speaker profiles: {e}\")\n            raise\n"
  },
  {
    "path": "api/routers/__init__.py",
    "content": ""
  },
  {
    "path": "api/routers/auth.py",
    "content": "\"\"\"\nAuthentication router for Open Notebook API.\nProvides endpoints to check authentication status.\n\"\"\"\n\nfrom fastapi import APIRouter\n\nfrom open_notebook.utils.encryption import get_secret_from_env\n\nrouter = APIRouter(prefix=\"/auth\", tags=[\"auth\"])\n\n\n@router.get(\"/status\")\nasync def get_auth_status():\n    \"\"\"\n    Check if authentication is enabled.\n    Returns whether a password is required to access the API.\n    Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.\n    \"\"\"\n    auth_enabled = bool(get_secret_from_env(\"OPEN_NOTEBOOK_PASSWORD\"))\n\n    return {\n        \"auth_enabled\": auth_enabled,\n        \"message\": \"Authentication is required\"\n        if auth_enabled\n        else \"Authentication is disabled\",\n    }"
  },
  {
    "path": "api/routers/chat.py",
    "content": "import asyncio\nimport traceback\nfrom typing import Any, Dict, List, Optional\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom langchain_core.runnables import RunnableConfig\nfrom loguru import logger\nfrom pydantic import BaseModel, Field\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.notebook import ChatSession, Note, Notebook, Source\nfrom open_notebook.exceptions import (\n    NotFoundError,\n)\nfrom open_notebook.graphs.chat import graph as chat_graph\nfrom open_notebook.utils.graph_utils import get_session_message_count\n\nrouter = APIRouter()\n\n\n# Request/Response models\nclass CreateSessionRequest(BaseModel):\n    notebook_id: str = Field(..., description=\"Notebook ID to create session for\")\n    title: Optional[str] = Field(None, description=\"Optional session title\")\n    model_override: Optional[str] = Field(\n        None, description=\"Optional model override for this session\"\n    )\n\n\nclass UpdateSessionRequest(BaseModel):\n    title: Optional[str] = Field(None, description=\"New session title\")\n    model_override: Optional[str] = Field(\n        None, description=\"Model override for this session\"\n    )\n\n\nclass ChatMessage(BaseModel):\n    id: str = Field(..., description=\"Message ID\")\n    type: str = Field(..., description=\"Message type (human|ai)\")\n    content: str = Field(..., description=\"Message content\")\n    timestamp: Optional[str] = Field(None, description=\"Message timestamp\")\n\n\nclass ChatSessionResponse(BaseModel):\n    id: str = Field(..., description=\"Session ID\")\n    title: str = Field(..., description=\"Session title\")\n    notebook_id: Optional[str] = Field(None, description=\"Notebook ID\")\n    created: str = Field(..., description=\"Creation timestamp\")\n    updated: str = Field(..., description=\"Last update timestamp\")\n    message_count: Optional[int] = Field(\n        None, description=\"Number of messages in session\"\n    )\n    model_override: Optional[str] = Field(\n        None, description=\"Model override for this session\"\n    )\n\n\nclass ChatSessionWithMessagesResponse(ChatSessionResponse):\n    messages: List[ChatMessage] = Field(\n        default_factory=list, description=\"Session messages\"\n    )\n\n\nclass ExecuteChatRequest(BaseModel):\n    session_id: str = Field(..., description=\"Chat session ID\")\n    message: str = Field(..., description=\"User message content\")\n    context: Dict[str, Any] = Field(\n        ..., description=\"Chat context with sources and notes\"\n    )\n    model_override: Optional[str] = Field(\n        None, description=\"Optional model override for this message\"\n    )\n\n\nclass ExecuteChatResponse(BaseModel):\n    session_id: str = Field(..., description=\"Session ID\")\n    messages: List[ChatMessage] = Field(..., description=\"Updated message list\")\n\n\nclass BuildContextRequest(BaseModel):\n    notebook_id: str = Field(..., description=\"Notebook ID\")\n    context_config: Dict[str, Any] = Field(..., description=\"Context configuration\")\n\n\nclass BuildContextResponse(BaseModel):\n    context: Dict[str, Any] = Field(..., description=\"Built context data\")\n    token_count: int = Field(..., description=\"Estimated token count\")\n    char_count: int = Field(..., description=\"Character count\")\n\n\nclass SuccessResponse(BaseModel):\n    success: bool = Field(True, description=\"Operation success status\")\n    message: str = Field(..., description=\"Success message\")\n\n\n@router.get(\"/chat/sessions\", response_model=List[ChatSessionResponse])\nasync def get_sessions(notebook_id: str = Query(..., description=\"Notebook ID\")):\n    \"\"\"Get all chat sessions for a notebook.\"\"\"\n    try:\n        # Get notebook to verify it exists\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        # Get sessions for this notebook\n        sessions_list = await notebook.get_chat_sessions()\n\n        results = []\n        for session in sessions_list:\n            session_id = str(session.id)\n\n            # Get message count from LangGraph state\n            msg_count = await get_session_message_count(chat_graph, session_id)\n\n            results.append(\n                ChatSessionResponse(\n                    id=session.id or \"\",\n                    title=session.title or \"Untitled Session\",\n                    notebook_id=notebook_id,\n                    created=str(session.created),\n                    updated=str(session.updated),\n                    message_count=msg_count,\n                    model_override=getattr(session, \"model_override\", None),\n                )\n            )\n\n        return results\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Notebook not found\")\n    except Exception as e:\n        logger.error(f\"Error fetching chat sessions: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching chat sessions: {str(e)}\"\n        )\n\n\n@router.post(\"/chat/sessions\", response_model=ChatSessionResponse)\nasync def create_session(request: CreateSessionRequest):\n    \"\"\"Create a new chat session.\"\"\"\n    try:\n        # Verify notebook exists\n        notebook = await Notebook.get(request.notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        # Create new session\n        session = ChatSession(\n            title=request.title\n            or f\"Chat Session {asyncio.get_event_loop().time():.0f}\",\n            model_override=request.model_override,\n        )\n        await session.save()\n\n        # Relate session to notebook\n        await session.relate_to_notebook(request.notebook_id)\n\n        return ChatSessionResponse(\n            id=session.id or \"\",\n            title=session.title or \"\",\n            notebook_id=request.notebook_id,\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=0,\n            model_override=session.model_override,\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Notebook not found\")\n    except Exception as e:\n        logger.error(f\"Error creating chat session: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error creating chat session: {str(e)}\"\n        )\n\n\n@router.get(\n    \"/chat/sessions/{session_id}\", response_model=ChatSessionWithMessagesResponse\n)\nasync def get_session(session_id: str):\n    \"\"\"Get a specific session with its messages.\"\"\"\n    try:\n        # Get session\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Get session state from LangGraph to retrieve messages\n        # Use sync get_state() in a thread since SqliteSaver doesn't support async\n        thread_state = await asyncio.to_thread(\n            chat_graph.get_state,\n            config=RunnableConfig(configurable={\"thread_id\": full_session_id}),\n        )\n\n        # Extract messages from state\n        messages: list[ChatMessage] = []\n        if thread_state and thread_state.values and \"messages\" in thread_state.values:\n            for msg in thread_state.values[\"messages\"]:\n                messages.append(\n                    ChatMessage(\n                        id=getattr(msg, \"id\", f\"msg_{len(messages)}\"),\n                        type=msg.type if hasattr(msg, \"type\") else \"unknown\",\n                        content=msg.content if hasattr(msg, \"content\") else str(msg),\n                        timestamp=None,  # LangChain messages don't have timestamps by default\n                    )\n                )\n\n        # Find notebook_id (we need to query the relationship)\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n\n        notebook_query = await repo_query(\n            \"SELECT out FROM refers_to WHERE in = $session_id\",\n            {\"session_id\": ensure_record_id(full_session_id)},\n        )\n\n        notebook_id = notebook_query[0][\"out\"] if notebook_query else None\n\n        if not notebook_id:\n            # This might be an old session created before API migration\n            logger.warning(\n                f\"No notebook relationship found for session {session_id} - may be an orphaned session\"\n            )\n\n        return ChatSessionWithMessagesResponse(\n            id=session.id or \"\",\n            title=session.title or \"Untitled Session\",\n            notebook_id=notebook_id,\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=len(messages),\n            messages=messages,\n            model_override=getattr(session, \"model_override\", None),\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    except Exception as e:\n        logger.error(f\"Error fetching session: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching session: {str(e)}\")\n\n\n@router.put(\"/chat/sessions/{session_id}\", response_model=ChatSessionResponse)\nasync def update_session(session_id: str, request: UpdateSessionRequest):\n    \"\"\"Update session title.\"\"\"\n    try:\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        update_data = request.model_dump(exclude_unset=True)\n\n        if \"title\" in update_data:\n            session.title = update_data[\"title\"]\n\n        if \"model_override\" in update_data:\n            session.model_override = update_data[\"model_override\"]\n\n        await session.save()\n\n        # Find notebook_id\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        notebook_query = await repo_query(\n            \"SELECT out FROM refers_to WHERE in = $session_id\",\n            {\"session_id\": ensure_record_id(full_session_id)},\n        )\n        notebook_id = notebook_query[0][\"out\"] if notebook_query else None\n\n        # Get message count from LangGraph state\n        msg_count = await get_session_message_count(chat_graph, full_session_id)\n\n        return ChatSessionResponse(\n            id=session.id or \"\",\n            title=session.title or \"\",\n            notebook_id=notebook_id,\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=msg_count,\n            model_override=session.model_override,\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    except Exception as e:\n        logger.error(f\"Error updating session: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error updating session: {str(e)}\")\n\n\n@router.delete(\"/chat/sessions/{session_id}\", response_model=SuccessResponse)\nasync def delete_session(session_id: str):\n    \"\"\"Delete a chat session.\"\"\"\n    try:\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        await session.delete()\n\n        return SuccessResponse(success=True, message=\"Session deleted successfully\")\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    except Exception as e:\n        logger.error(f\"Error deleting session: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error deleting session: {str(e)}\")\n\n\n@router.post(\"/chat/execute\", response_model=ExecuteChatResponse)\nasync def execute_chat(request: ExecuteChatRequest):\n    \"\"\"Execute a chat request and get AI response.\"\"\"\n    try:\n        # Verify session exists\n        # Ensure session_id has proper table prefix\n        full_session_id = (\n            request.session_id\n            if request.session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{request.session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Determine model override (per-request override takes precedence over session-level)\n        model_override = (\n            request.model_override\n            if request.model_override is not None\n            else getattr(session, \"model_override\", None)\n        )\n\n        # Get current state\n        # Use sync get_state() in a thread since SqliteSaver doesn't support async\n        current_state = await asyncio.to_thread(\n            chat_graph.get_state,\n            config=RunnableConfig(configurable={\"thread_id\": full_session_id}),\n        )\n\n        # Prepare state for execution\n        state_values = current_state.values if current_state else {}\n        state_values[\"messages\"] = state_values.get(\"messages\", [])\n        state_values[\"context\"] = request.context\n        state_values[\"model_override\"] = model_override\n\n        # Add user message to state\n        from langchain_core.messages import HumanMessage\n\n        user_message = HumanMessage(content=request.message)\n        state_values[\"messages\"].append(user_message)\n\n        # Execute chat graph\n        result = chat_graph.invoke(\n            input=state_values,  # type: ignore[arg-type]\n            config=RunnableConfig(\n                configurable={\n                    \"thread_id\": full_session_id,\n                    \"model_id\": model_override,\n                }\n            ),\n        )\n\n        # Update session timestamp\n        await session.save()\n\n        # Convert messages to response format\n        messages: list[ChatMessage] = []\n        for msg in result.get(\"messages\", []):\n            messages.append(\n                ChatMessage(\n                    id=getattr(msg, \"id\", f\"msg_{len(messages)}\"),\n                    type=msg.type if hasattr(msg, \"type\") else \"unknown\",\n                    content=msg.content if hasattr(msg, \"content\") else str(msg),\n                    timestamp=None,\n                )\n            )\n\n        return ExecuteChatResponse(session_id=request.session_id, messages=messages)\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Session not found\")\n    except Exception as e:\n        # Log detailed error with context for debugging\n        logger.error(\n            f\"Error executing chat: {str(e)}\\n\"\n            f\"  Session ID: {request.session_id}\\n\"\n            f\"  Model override: {request.model_override}\\n\"\n            f\"  Traceback:\\n{traceback.format_exc()}\"\n        )\n        raise HTTPException(status_code=500, detail=f\"Error executing chat: {str(e)}\")\n\n\n@router.post(\"/chat/context\", response_model=BuildContextResponse)\nasync def build_context(request: BuildContextRequest):\n    \"\"\"Build context for a notebook based on context configuration.\"\"\"\n    try:\n        # Verify notebook exists\n        notebook = await Notebook.get(request.notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        context_data: dict[str, list[dict[str, str]]] = {\"sources\": [], \"notes\": []}\n        total_content = \"\"\n\n        # Process context configuration if provided\n        if request.context_config:\n            # Process sources\n            for source_id, status in request.context_config.get(\"sources\", {}).items():\n                if \"not in\" in status:\n                    continue\n\n                try:\n                    # Add table prefix if not present\n                    full_source_id = (\n                        source_id\n                        if source_id.startswith(\"source:\")\n                        else f\"source:{source_id}\"\n                    )\n\n                    try:\n                        source = await Source.get(full_source_id)\n                    except Exception:\n                        continue\n\n                    if \"insights\" in status:\n                        source_context = await source.get_context(context_size=\"short\")\n                        context_data[\"sources\"].append(source_context)\n                        total_content += str(source_context)\n                    elif \"full content\" in status:\n                        source_context = await source.get_context(context_size=\"long\")\n                        context_data[\"sources\"].append(source_context)\n                        total_content += str(source_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing source {source_id}: {str(e)}\")\n                    continue\n\n            # Process notes\n            for note_id, status in request.context_config.get(\"notes\", {}).items():\n                if \"not in\" in status:\n                    continue\n\n                try:\n                    # Add table prefix if not present\n                    full_note_id = (\n                        note_id if note_id.startswith(\"note:\") else f\"note:{note_id}\"\n                    )\n                    note = await Note.get(full_note_id)\n                    if not note:\n                        continue\n\n                    if \"full content\" in status:\n                        note_context = note.get_context(context_size=\"long\")\n                        context_data[\"notes\"].append(note_context)\n                        total_content += str(note_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing note {note_id}: {str(e)}\")\n                    continue\n        else:\n            # Default behavior - include all sources and notes with short context\n            sources = await notebook.get_sources()\n            for source in sources:\n                try:\n                    source_context = await source.get_context(context_size=\"short\")\n                    context_data[\"sources\"].append(source_context)\n                    total_content += str(source_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing source {source.id}: {str(e)}\")\n                    continue\n\n            notes = await notebook.get_notes()\n            for note in notes:\n                try:\n                    note_context = note.get_context(context_size=\"short\")\n                    context_data[\"notes\"].append(note_context)\n                    total_content += str(note_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing note {note.id}: {str(e)}\")\n                    continue\n\n        # Calculate character and token counts\n        char_count = len(total_content)\n        # Use token count utility if available\n        try:\n            from open_notebook.utils import token_count\n\n            estimated_tokens = token_count(total_content) if total_content else 0\n        except ImportError:\n            # Fallback to simple estimation\n            estimated_tokens = char_count // 4\n\n        return BuildContextResponse(\n            context=context_data, token_count=estimated_tokens, char_count=char_count\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error building context: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error building context: {str(e)}\")\n"
  },
  {
    "path": "api/routers/commands.py",
    "content": "from typing import Any, Dict, List, Optional\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom loguru import logger\nfrom pydantic import BaseModel, Field\nfrom surreal_commands import registry\n\nfrom api.command_service import CommandService\n\nrouter = APIRouter()\n\n\nclass CommandExecutionRequest(BaseModel):\n    command: str = Field(\n        ..., description=\"Command function name (e.g., 'process_text')\"\n    )\n    app: str = Field(..., description=\"Application name (e.g., 'open_notebook')\")\n    input: Dict[str, Any] = Field(..., description=\"Arguments to pass to the command\")\n\n\nclass CommandJobResponse(BaseModel):\n    job_id: str\n    status: str\n    message: str\n\n\nclass CommandJobStatusResponse(BaseModel):\n    job_id: str\n    status: str\n    result: Optional[Dict[str, Any]] = None\n    error_message: Optional[str] = None\n    created: Optional[str] = None\n    updated: Optional[str] = None\n    progress: Optional[Dict[str, Any]] = None\n\n\n@router.post(\"/commands/jobs\", response_model=CommandJobResponse)\nasync def execute_command(request: CommandExecutionRequest):\n    \"\"\"\n    Submit a command for background processing.\n    Returns immediately with job ID for status tracking.\n\n    Example request:\n    {\n        \"command\": \"process_text\",\n        \"app\": \"open_notebook\",\n        \"input\": {\n            \"text\": \"Hello world\",\n            \"operation\": \"uppercase\"\n        }\n    }\n    \"\"\"\n    try:\n        # Submit command using app name (not module name)\n        job_id = await CommandService.submit_command_job(\n            module_name=request.app,  # This should be \"open_notebook\"\n            command_name=request.command,\n            command_args=request.input,\n        )\n\n        return CommandJobResponse(\n            job_id=job_id,\n            status=\"submitted\",\n            message=f\"Command '{request.command}' submitted successfully\",\n        )\n\n    except Exception as e:\n        logger.error(f\"Error submitting command: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to submit command\"\n        )\n\n\n@router.get(\"/commands/jobs/{job_id}\", response_model=CommandJobStatusResponse)\nasync def get_command_job_status(job_id: str):\n    \"\"\"Get the status of a specific command job\"\"\"\n    try:\n        status_data = await CommandService.get_command_status(job_id)\n        return CommandJobStatusResponse(**status_data)\n\n    except Exception as e:\n        logger.error(f\"Error fetching job status: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch job status\"\n        )\n\n\n@router.get(\"/commands/jobs\", response_model=List[Dict[str, Any]])\nasync def list_command_jobs(\n    command_filter: Optional[str] = Query(None, description=\"Filter by command name\"),\n    status_filter: Optional[str] = Query(None, description=\"Filter by status\"),\n    limit: int = Query(50, description=\"Maximum number of jobs to return\"),\n):\n    \"\"\"List command jobs with optional filtering\"\"\"\n    try:\n        jobs = await CommandService.list_command_jobs(\n            command_filter=command_filter, status_filter=status_filter, limit=limit\n        )\n        return jobs\n\n    except Exception as e:\n        logger.error(f\"Error listing command jobs: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to list command jobs\"\n        )\n\n\n@router.delete(\"/commands/jobs/{job_id}\")\nasync def cancel_command_job(job_id: str):\n    \"\"\"Cancel a running command job\"\"\"\n    try:\n        success = await CommandService.cancel_command_job(job_id)\n        return {\"job_id\": job_id, \"cancelled\": success}\n\n    except Exception as e:\n        logger.error(f\"Error cancelling command job: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to cancel command job\"\n        )\n\n\n@router.get(\"/commands/registry/debug\")\nasync def debug_registry():\n    \"\"\"Debug endpoint to see what commands are registered\"\"\"\n    try:\n        # Get all registered commands\n        all_items = registry.get_all_commands()\n\n        # Create JSON-serializable data\n        command_items = []\n        for item in all_items:\n            try:\n                command_items.append(\n                    {\n                        \"app_id\": item.app_id,\n                        \"name\": item.name,\n                        \"full_id\": f\"{item.app_id}.{item.name}\",\n                    }\n                )\n            except Exception as item_error:\n                logger.error(f\"Error processing item: {item_error}\")\n\n        # Get the basic command structure\n        try:\n            commands_dict: dict[str, list[str]] = {}\n            for item in all_items:\n                if item.app_id not in commands_dict:\n                    commands_dict[item.app_id] = []\n                commands_dict[item.app_id].append(item.name)\n        except Exception:\n            commands_dict = {}\n\n        return {\n            \"total_commands\": len(all_items),\n            \"commands_by_app\": commands_dict,\n            \"command_items\": command_items,\n        }\n\n    except Exception as e:\n        logger.error(f\"Error debugging registry: {str(e)}\")\n        return {\n            \"error\": str(e),\n            \"total_commands\": 0,\n            \"commands_by_app\": {},\n            \"command_items\": [],\n        }\n"
  },
  {
    "path": "api/routers/config.py",
    "content": "import asyncio\nimport os\nimport time\nimport tomllib\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom fastapi import APIRouter, Request\nfrom loguru import logger\n\nfrom open_notebook.database.repository import repo_query\nfrom open_notebook.utils.version_utils import (\n    compare_versions,\n    get_version_from_github_async,\n)\n\nrouter = APIRouter()\n\n# In-memory cache for version check results\n_version_cache: dict = {\n    \"latest_version\": None,\n    \"has_update\": False,\n    \"timestamp\": 0,\n    \"check_failed\": False,\n}\n\n# Cache TTL in seconds (24 hours)\nVERSION_CACHE_TTL = 24 * 60 * 60\n\n\ndef get_version() -> str:\n    \"\"\"Read version from pyproject.toml\"\"\"\n    try:\n        pyproject_path = Path(__file__).parent.parent.parent / \"pyproject.toml\"\n        with open(pyproject_path, \"rb\") as f:\n            pyproject = tomllib.load(f)\n            return pyproject.get(\"project\", {}).get(\"version\", \"unknown\")\n    except Exception as e:\n        logger.warning(f\"Could not read version from pyproject.toml: {e}\")\n        return \"unknown\"\n\n\nasync def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:\n    \"\"\"\n    Check for the latest version from GitHub with caching.\n\n    Returns:\n        tuple: (latest_version, has_update)\n        - latest_version: str or None if check failed\n        - has_update: bool indicating if update is available\n    \"\"\"\n    global _version_cache\n\n    # Check if cache is still valid (within TTL)\n    cache_age = time.time() - _version_cache[\"timestamp\"]\n    if _version_cache[\"timestamp\"] > 0 and cache_age < VERSION_CACHE_TTL:\n        logger.debug(f\"Using cached version check result (age: {cache_age:.0f}s)\")\n        return _version_cache[\"latest_version\"], _version_cache[\"has_update\"]\n\n    # Cache expired or not yet set\n    if _version_cache[\"timestamp\"] > 0:\n        logger.info(f\"Version cache expired (age: {cache_age:.0f}s), refreshing...\")\n\n    # Perform version check with strict error handling\n    try:\n        logger.info(\"Checking for latest version from GitHub...\")\n\n        # Fetch latest version from GitHub with 10-second timeout\n        latest_version = await get_version_from_github_async(\n            \"https://github.com/lfnovo/open-notebook\", \"main\"\n        )\n\n        logger.info(\n            f\"Latest version from GitHub: {latest_version}, Current version: {current_version}\"\n        )\n\n        # Compare versions\n        has_update = compare_versions(current_version, latest_version) < 0\n\n        # Cache the result\n        _version_cache[\"latest_version\"] = latest_version\n        _version_cache[\"has_update\"] = has_update\n        _version_cache[\"timestamp\"] = time.time()\n        _version_cache[\"check_failed\"] = False\n\n        logger.info(f\"Version check complete. Update available: {has_update}\")\n\n        return latest_version, has_update\n\n    except Exception as e:\n        logger.warning(f\"Version check failed: {e}\")\n\n        # Cache the failure to avoid repeated attempts\n        _version_cache[\"latest_version\"] = None\n        _version_cache[\"has_update\"] = False\n        _version_cache[\"timestamp\"] = time.time()\n        _version_cache[\"check_failed\"] = True\n\n        return None, False\n\n\nasync def check_database_health() -> dict:\n    \"\"\"\n    Check if database is reachable using a lightweight query.\n\n    Returns:\n        dict with 'status' (\"online\" | \"offline\") and optional 'error'\n    \"\"\"\n    try:\n        # 2-second timeout for database health check\n        result = await asyncio.wait_for(repo_query(\"RETURN 1\"), timeout=2.0)\n        if result:\n            return {\"status\": \"online\"}\n        return {\"status\": \"offline\", \"error\": \"Empty result\"}\n    except asyncio.TimeoutError:\n        logger.warning(\"Database health check timed out after 2 seconds\")\n        return {\"status\": \"offline\", \"error\": \"Health check timeout\"}\n    except Exception as e:\n        logger.warning(f\"Database health check failed: {e}\")\n        return {\"status\": \"offline\", \"error\": str(e)}\n\n\n@router.get(\"/config\")\nasync def get_config(request: Request):\n    \"\"\"\n    Get frontend configuration.\n\n    Returns version information and health status.\n    Note: The frontend determines the API URL via its own runtime-config endpoint,\n    so this endpoint no longer returns apiUrl.\n\n    Also checks for version updates from GitHub (with caching and error handling).\n    \"\"\"\n    # Get current version\n    current_version = get_version()\n\n    # Check for updates (with caching and error handling)\n    # This MUST NOT break the endpoint - wrapped in try-except as extra safety\n    latest_version = None\n    has_update = False\n\n    try:\n        latest_version, has_update = await get_latest_version_cached(current_version)\n    except Exception as e:\n        # Extra safety: ensure version check never breaks the config endpoint\n        logger.error(f\"Unexpected error during version check: {e}\")\n\n    # Check database health\n    db_health = await check_database_health()\n    db_status = db_health[\"status\"]\n\n    if db_status == \"offline\":\n        logger.warning(f\"Database offline: {db_health.get('error', 'Unknown error')}\")\n\n    return {\n        \"version\": current_version,\n        \"latestVersion\": latest_version,\n        \"hasUpdate\": has_update,\n        \"dbStatus\": db_status,\n    }\n"
  },
  {
    "path": "api/routers/context.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom loguru import logger\n\nfrom api.models import ContextRequest, ContextResponse\nfrom open_notebook.domain.notebook import Note, Notebook, Source\nfrom open_notebook.exceptions import InvalidInputError\nfrom open_notebook.utils import token_count\n\nrouter = APIRouter()\n\n\n@router.post(\"/notebooks/{notebook_id}/context\", response_model=ContextResponse)\nasync def get_notebook_context(notebook_id: str, context_request: ContextRequest):\n    \"\"\"Get context for a notebook based on configuration.\"\"\"\n    try:\n        # Verify notebook exists\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        context_data: dict[str, list[dict[str, str]]] = {\"note\": [], \"source\": []}\n        total_content = \"\"\n\n        # Process context configuration if provided\n        if context_request.context_config:\n            # Process sources\n            for source_id, status in context_request.context_config.sources.items():\n                if \"not in\" in status:\n                    continue\n\n                try:\n                    # Add table prefix if not present\n                    full_source_id = (\n                        source_id\n                        if source_id.startswith(\"source:\")\n                        else f\"source:{source_id}\"\n                    )\n\n                    try:\n                        source = await Source.get(full_source_id)\n                    except Exception:\n                        continue\n\n                    if \"insights\" in status:\n                        source_context = await source.get_context(context_size=\"short\")\n                        context_data[\"source\"].append(source_context)\n                        total_content += str(source_context)\n                    elif \"full content\" in status:\n                        source_context = await source.get_context(context_size=\"long\")\n                        context_data[\"source\"].append(source_context)\n                        total_content += str(source_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing source {source_id}: {str(e)}\")\n                    continue\n\n            # Process notes\n            for note_id, status in context_request.context_config.notes.items():\n                if \"not in\" in status:\n                    continue\n\n                try:\n                    # Add table prefix if not present\n                    full_note_id = (\n                        note_id if note_id.startswith(\"note:\") else f\"note:{note_id}\"\n                    )\n                    note = await Note.get(full_note_id)\n                    if not note:\n                        continue\n\n                    if \"full content\" in status:\n                        note_context = note.get_context(context_size=\"long\")\n                        context_data[\"note\"].append(note_context)\n                        total_content += str(note_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing note {note_id}: {str(e)}\")\n                    continue\n        else:\n            # Default behavior - include all sources and notes with short context\n            sources = await notebook.get_sources()\n            for source in sources:\n                try:\n                    source_context = await source.get_context(context_size=\"short\")\n                    context_data[\"source\"].append(source_context)\n                    total_content += str(source_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing source {source.id}: {str(e)}\")\n                    continue\n\n            notes = await notebook.get_notes()\n            for note in notes:\n                try:\n                    note_context = note.get_context(context_size=\"short\")\n                    context_data[\"note\"].append(note_context)\n                    total_content += str(note_context)\n                except Exception as e:\n                    logger.warning(f\"Error processing note {note.id}: {str(e)}\")\n                    continue\n\n        # Calculate estimated token count\n        estimated_tokens = token_count(total_content) if total_content else 0\n\n        return ContextResponse(\n            notebook_id=notebook_id,\n            sources=context_data[\"source\"],\n            notes=context_data[\"note\"],\n            total_tokens=estimated_tokens,\n        )\n\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error getting context for notebook {notebook_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error getting context: {str(e)}\")\n"
  },
  {
    "path": "api/routers/credentials.py",
    "content": "\"\"\"\nCredentials Router\n\nThin HTTP layer for managing individual AI provider credentials.\nBusiness logic lives in api.credentials_service.\n\nEndpoints:\n- GET /credentials - List all credentials\n- GET /credentials/by-provider/{provider} - List credentials for a provider\n- POST /credentials - Create a new credential\n- GET /credentials/{credential_id} - Get a specific credential\n- PUT /credentials/{credential_id} - Update a credential\n- DELETE /credentials/{credential_id} - Delete a credential\n- POST /credentials/{credential_id}/test - Test connection\n- POST /credentials/{credential_id}/discover - Discover models\n- POST /credentials/{credential_id}/register-models - Register models\n\nNEVER returns actual API key values - only metadata.\n\"\"\"\n\nfrom typing import List, Optional\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom loguru import logger\nfrom pydantic import SecretStr\n\nfrom api.credentials_service import (\n    credential_to_response,\n    discover_with_config,\n    migrate_from_env as svc_migrate_from_env,\n    migrate_from_provider_config as svc_migrate_from_provider_config,\n    register_models,\n    require_encryption_key,\n    test_credential as svc_test_credential,\n    validate_url,\n)\nfrom api.credentials_service import (\n    get_env_status as svc_get_env_status,\n    get_provider_status,\n)\nfrom api.models import (\n    CreateCredentialRequest,\n    CredentialDeleteResponse,\n    CredentialResponse,\n    DiscoveredModelResponse,\n    DiscoverModelsResponse,\n    RegisterModelsRequest,\n    RegisterModelsResponse,\n    UpdateCredentialRequest,\n)\nfrom open_notebook.domain.credential import Credential\n\nrouter = APIRouter(prefix=\"/credentials\", tags=[\"credentials\"])\n\n\ndef _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:\n    \"\"\"Convert a ValueError from the service layer to an HTTPException.\"\"\"\n    return HTTPException(status_code=status_code, detail=str(e))\n\n\n# =============================================================================\n# Status endpoints\n# =============================================================================\n\n\n@router.get(\"/status\")\nasync def get_status():\n    \"\"\"\n    Get configuration status: encryption key status, and per-provider\n    configured/source information.\n    \"\"\"\n    try:\n        return await get_provider_status()\n    except Exception as e:\n        logger.error(f\"Error fetching status: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to fetch credential status\")\n\n\n@router.get(\"/env-status\")\nasync def get_env_status():\n    \"\"\"Check what's configured via environment variables.\"\"\"\n    try:\n        return await svc_get_env_status()\n    except Exception as e:\n        logger.error(f\"Error checking env status: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to check environment status\")\n\n\n# =============================================================================\n# CRUD endpoints\n# =============================================================================\n\n\n@router.get(\"\", response_model=List[CredentialResponse])\nasync def list_credentials(\n    provider: Optional[str] = Query(None, description=\"Filter by provider\"),\n):\n    \"\"\"List all credentials, optionally filtered by provider.\"\"\"\n    try:\n        if provider:\n            credentials = await Credential.get_by_provider(provider)\n        else:\n            credentials = await Credential.get_all(order_by=\"provider, created\")\n\n        result = []\n        for cred in credentials:\n            models = await cred.get_linked_models()\n            result.append(credential_to_response(cred, len(models)))\n\n        return result\n\n    except Exception as e:\n        logger.error(f\"Error listing credentials: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to list credentials\")\n\n\n@router.get(\"/by-provider/{provider}\", response_model=List[CredentialResponse])\nasync def list_credentials_by_provider(provider: str):\n    \"\"\"List all credentials for a specific provider.\"\"\"\n    try:\n        credentials = await Credential.get_by_provider(provider.lower())\n        result = []\n        for cred in credentials:\n            models = await cred.get_linked_models()\n            result.append(credential_to_response(cred, len(models)))\n        return result\n    except Exception as e:\n        logger.error(f\"Error listing credentials for {provider}: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to list credentials for provider\")\n\n\n@router.post(\"\", response_model=CredentialResponse, status_code=201)\nasync def create_credential(request: CreateCredentialRequest):\n    \"\"\"Create a new credential.\"\"\"\n    try:\n        require_encryption_key()\n    except ValueError as e:\n        raise _handle_value_error(e)\n\n    # Validate all URL fields\n    for url_field in [\n        request.base_url, request.endpoint, request.endpoint_llm,\n        request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,\n    ]:\n        if url_field:\n            try:\n                validate_url(url_field, request.provider)\n            except ValueError as e:\n                raise _handle_value_error(e)\n\n    try:\n        cred = Credential(\n            name=request.name,\n            provider=request.provider.lower(),\n            modalities=request.modalities,\n            api_key=SecretStr(request.api_key) if request.api_key else None,\n            base_url=request.base_url,\n            endpoint=request.endpoint,\n            api_version=request.api_version,\n            endpoint_llm=request.endpoint_llm,\n            endpoint_embedding=request.endpoint_embedding,\n            endpoint_stt=request.endpoint_stt,\n            endpoint_tts=request.endpoint_tts,\n            project=request.project,\n            location=request.location,\n            credentials_path=request.credentials_path,\n        )\n        await cred.save()\n        return credential_to_response(cred, 0)\n\n    except Exception as e:\n        logger.error(f\"Error creating credential: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to create credential\")\n\n\n@router.get(\"/{credential_id}\", response_model=CredentialResponse)\nasync def get_credential(credential_id: str):\n    \"\"\"Get a specific credential by ID. Never returns api_key.\"\"\"\n    try:\n        cred = await Credential.get(credential_id)\n        models = await cred.get_linked_models()\n        return credential_to_response(cred, len(models))\n    except Exception as e:\n        logger.error(f\"Error fetching credential {credential_id}: {e}\")\n        raise HTTPException(status_code=404, detail=\"Credential not found\")\n\n\n@router.put(\"/{credential_id}\", response_model=CredentialResponse)\nasync def update_credential(credential_id: str, request: UpdateCredentialRequest):\n    \"\"\"Update an existing credential.\"\"\"\n    try:\n        require_encryption_key()\n    except ValueError as e:\n        raise _handle_value_error(e)\n\n    # Validate all URL fields being updated\n    for url_field in [\n        request.base_url, request.endpoint, request.endpoint_llm,\n        request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,\n    ]:\n        if url_field:\n            try:\n                validate_url(url_field, \"update\")\n            except ValueError as e:\n                raise _handle_value_error(e)\n\n    try:\n        cred = await Credential.get(credential_id)\n\n        if request.name is not None:\n            cred.name = request.name\n        if request.modalities is not None:\n            cred.modalities = request.modalities\n        if request.api_key is not None:\n            cred.api_key = SecretStr(request.api_key)\n        if request.base_url is not None:\n            cred.base_url = request.base_url or None\n        if request.endpoint is not None:\n            cred.endpoint = request.endpoint or None\n        if request.api_version is not None:\n            cred.api_version = request.api_version or None\n        if request.endpoint_llm is not None:\n            cred.endpoint_llm = request.endpoint_llm or None\n        if request.endpoint_embedding is not None:\n            cred.endpoint_embedding = request.endpoint_embedding or None\n        if request.endpoint_stt is not None:\n            cred.endpoint_stt = request.endpoint_stt or None\n        if request.endpoint_tts is not None:\n            cred.endpoint_tts = request.endpoint_tts or None\n        if request.project is not None:\n            cred.project = request.project or None\n        if request.location is not None:\n            cred.location = request.location or None\n        if request.credentials_path is not None:\n            cred.credentials_path = request.credentials_path or None\n\n        await cred.save()\n        models = await cred.get_linked_models()\n        return credential_to_response(cred, len(models))\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error updating credential {credential_id}: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to update credential\")\n\n\n@router.delete(\"/{credential_id}\", response_model=CredentialDeleteResponse)\nasync def delete_credential(\n    credential_id: str,\n    delete_models: bool = Query(False, description=\"Also delete linked models\"),\n    migrate_to: Optional[str] = Query(\n        None, description=\"Migrate linked models to this credential ID\"\n    ),\n):\n    \"\"\"\n    Delete a credential.\n\n    If the credential has linked models:\n    - Pass delete_models=true to delete them\n    - Pass migrate_to=<credential_id> to reassign them\n    - Without either, returns 409 with linked model info\n    \"\"\"\n    try:\n        cred = await Credential.get(credential_id)\n        linked_models = await cred.get_linked_models()\n\n        if linked_models and not delete_models and not migrate_to:\n            raise HTTPException(\n                status_code=409,\n                detail={\n                    \"message\": f\"Credential has {len(linked_models)} linked model(s)\",\n                    \"model_ids\": [m.id for m in linked_models],\n                    \"model_names\": [f\"{m.provider}/{m.name}\" for m in linked_models],\n                },\n            )\n\n        deleted_models = 0\n\n        if linked_models and migrate_to:\n            # Migrate models to another credential\n            target_cred = await Credential.get(migrate_to)\n            for model in linked_models:\n                model.credential = target_cred.id\n                await model.save()\n\n        elif linked_models and delete_models:\n            # Delete linked models\n            for model in linked_models:\n                await model.delete()\n                deleted_models += 1\n\n        # Delete the credential\n        await cred.delete()\n\n        return CredentialDeleteResponse(\n            message=\"Credential deleted successfully\",\n            deleted_models=deleted_models,\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting credential {credential_id}: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to delete credential\")\n\n\n# =============================================================================\n# Test / Discover / Register endpoints\n# =============================================================================\n\n\n@router.post(\"/{credential_id}/test\")\nasync def test_credential(credential_id: str):\n    \"\"\"Test connection using this credential's configuration.\"\"\"\n    return await svc_test_credential(credential_id)\n\n\n@router.post(\"/{credential_id}/discover\", response_model=DiscoverModelsResponse)\nasync def discover_models_for_credential(credential_id: str):\n    \"\"\"Discover available models using this credential's API key.\"\"\"\n    try:\n        cred = await Credential.get(credential_id)\n        config = cred.to_esperanto_config()\n        provider = cred.provider.lower()\n\n        discovered = await discover_with_config(provider, config)\n\n        return DiscoverModelsResponse(\n            credential_id=cred.id or \"\",\n            provider=provider,\n            discovered=[\n                DiscoveredModelResponse(\n                    name=d[\"name\"],\n                    provider=d[\"provider\"],\n                    description=d.get(\"description\"),\n                )\n                for d in discovered\n            ],\n        )\n\n    except Exception as e:\n        logger.error(f\"Error discovering models for credential {credential_id}: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to discover models\")\n\n\n@router.post(\"/{credential_id}/register-models\", response_model=RegisterModelsResponse)\nasync def register_models_for_credential(\n    credential_id: str, request: RegisterModelsRequest\n):\n    \"\"\"Register discovered models and link them to this credential.\"\"\"\n    try:\n        result = await register_models(credential_id, request.models)\n        return RegisterModelsResponse(**result)\n    except Exception as e:\n        logger.error(f\"Error registering models for credential {credential_id}: {e}\")\n        raise HTTPException(status_code=500, detail=\"Failed to register models\")\n\n\n# =============================================================================\n# Migration endpoints\n# =============================================================================\n\n\n@router.post(\"/migrate-from-provider-config\")\nasync def migrate_from_provider_config():\n    \"\"\"Migrate existing ProviderConfig data to individual credential records.\"\"\"\n    try:\n        return await svc_migrate_from_provider_config()\n    except ValueError as e:\n        raise _handle_value_error(e)\n    except Exception as e:\n        logger.error(f\"ProviderConfig migration FAILED: {type(e).__name__}: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=\"Migration from provider config failed\")\n\n\n@router.post(\"/migrate-from-env\")\nasync def migrate_from_env():\n    \"\"\"Migrate API keys from environment variables to credential records.\"\"\"\n    try:\n        return await svc_migrate_from_env()\n    except ValueError as e:\n        raise _handle_value_error(e)\n    except Exception as e:\n        logger.error(f\"Env migration FAILED: {type(e).__name__}: {e}\", exc_info=True)\n        raise HTTPException(status_code=500, detail=\"Migration from environment variables failed\")\n"
  },
  {
    "path": "api/routers/embedding.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom loguru import logger\n\nfrom api.command_service import CommandService\nfrom api.models import EmbedRequest, EmbedResponse\nfrom open_notebook.ai.models import model_manager\nfrom open_notebook.domain.notebook import Note, Source\n\nrouter = APIRouter()\n\n\n@router.post(\"/embed\", response_model=EmbedResponse)\nasync def embed_content(embed_request: EmbedRequest):\n    \"\"\"Embed content for vector search.\"\"\"\n    try:\n        # Check if embedding model is available\n        if not await model_manager.get_embedding_model():\n            raise HTTPException(\n                status_code=400,\n                detail=\"No embedding model configured. Please configure one in the Models section.\",\n            )\n\n        item_id = embed_request.item_id\n        item_type = embed_request.item_type.lower()\n\n        # Validate item type\n        if item_type not in [\"source\", \"note\"]:\n            raise HTTPException(\n                status_code=400, detail=\"Item type must be either 'source' or 'note'\"\n            )\n\n        # Branch based on processing mode\n        if embed_request.async_processing:\n            # ASYNC PATH: Submit command for background processing\n            logger.info(f\"Using async processing for {item_type} {item_id}\")\n\n            try:\n                # Import commands to ensure they're registered\n                import commands.embedding_commands  # noqa: F401\n\n                # Submit type-specific command\n                if item_type == \"source\":\n                    command_name = \"embed_source\"\n                    command_input = {\"source_id\": item_id}\n                else:  # note\n                    command_name = \"embed_note\"\n                    command_input = {\"note_id\": item_id}\n\n                command_id = await CommandService.submit_command_job(\n                    \"open_notebook\",\n                    command_name,\n                    command_input,\n                )\n\n                logger.info(f\"Submitted async {command_name} command: {command_id}\")\n\n                return EmbedResponse(\n                    success=True,\n                    message=\"Embedding queued for background processing\",\n                    item_id=item_id,\n                    item_type=item_type,\n                    command_id=command_id,\n                )\n\n            except Exception as e:\n                logger.error(f\"Failed to submit async embedding command: {e}\")\n                raise HTTPException(\n                    status_code=500, detail=f\"Failed to queue embedding: {str(e)}\"\n                )\n\n        else:\n            # DOMAIN MODEL PATH: Submit job via domain model convenience methods\n            # These methods internally call submit_command() - still fire-and-forget\n            logger.info(f\"Using domain model path for {item_type} {item_id}\")\n\n            command_id = None\n\n            # Get the item and submit embedding job\n            if item_type == \"source\":\n                source_item = await Source.get(item_id)\n                if not source_item:\n                    raise HTTPException(status_code=404, detail=\"Source not found\")\n\n                # Submit embed_source job (returns command_id for tracking)\n                command_id = await source_item.vectorize()\n                message = \"Source embedding job submitted\"\n\n            elif item_type == \"note\":\n                note_item = await Note.get(item_id)\n                if not note_item:\n                    raise HTTPException(status_code=404, detail=\"Note not found\")\n\n                # Note.save() internally submits embed_note command and returns command_id\n                command_id = await note_item.save()\n                message = \"Note embedding job submitted\"\n\n            return EmbedResponse(\n                success=True,\n                message=message,\n                item_id=item_id,\n                item_type=item_type,\n                command_id=command_id,\n            )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\n            f\"Error embedding {embed_request.item_type} {embed_request.item_id}: {str(e)}\"\n        )\n        raise HTTPException(\n            status_code=500, detail=f\"Error embedding content: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/routers/embedding_rebuild.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom loguru import logger\nfrom surreal_commands import get_command_status\n\nfrom api.command_service import CommandService\nfrom api.models import (\n    RebuildProgress,\n    RebuildRequest,\n    RebuildResponse,\n    RebuildStats,\n    RebuildStatusResponse,\n)\nfrom open_notebook.database.repository import repo_query\n\nrouter = APIRouter()\n\n\n@router.post(\"/rebuild\", response_model=RebuildResponse)\nasync def start_rebuild(request: RebuildRequest):\n    \"\"\"\n    Start a background job to rebuild embeddings.\n\n    - **mode**: \"existing\" (re-embed items with embeddings) or \"all\" (embed everything)\n    - **include_sources**: Include sources in rebuild (default: true)\n    - **include_notes**: Include notes in rebuild (default: true)\n    - **include_insights**: Include insights in rebuild (default: true)\n\n    Returns command ID to track progress and estimated item count.\n    \"\"\"\n    try:\n        logger.info(f\"Starting rebuild request: mode={request.mode}\")\n\n        # Import commands to ensure they're registered\n        import commands.embedding_commands  # noqa: F401\n\n        # Estimate total items (quick count query)\n        # This is a rough estimate before the command runs\n        total_estimate = 0\n\n        if request.include_sources:\n            if request.mode == \"existing\":\n                # Count sources with embeddings\n                result = await repo_query(\n                    \"\"\"\n                    SELECT VALUE count(array::distinct(\n                        SELECT VALUE source.id\n                        FROM source_embedding\n                        WHERE embedding != none AND array::len(embedding) > 0\n                    )) as count FROM {}\n                    \"\"\"\n                )\n            else:\n                # Count all sources with content\n                result = await repo_query(\n                    \"SELECT VALUE count() as count FROM source WHERE full_text != none GROUP ALL\"\n                )\n\n            if result and isinstance(result[0], dict):\n                total_estimate += result[0].get(\"count\", 0)\n            elif result:\n                total_estimate += result[0] if isinstance(result[0], int) else 0\n\n        if request.include_notes:\n            if request.mode == \"existing\":\n                result = await repo_query(\n                    \"SELECT VALUE count() as count FROM note WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL\"\n                )\n            else:\n                result = await repo_query(\n                    \"SELECT VALUE count() as count FROM note WHERE content != none GROUP ALL\"\n                )\n\n            if result and isinstance(result[0], dict):\n                total_estimate += result[0].get(\"count\", 0)\n            elif result:\n                total_estimate += result[0] if isinstance(result[0], int) else 0\n\n        if request.include_insights:\n            if request.mode == \"existing\":\n                result = await repo_query(\n                    \"SELECT VALUE count() as count FROM source_insight WHERE embedding != none AND array::len(embedding) > 0 GROUP ALL\"\n                )\n            else:\n                result = await repo_query(\n                    \"SELECT VALUE count() as count FROM source_insight GROUP ALL\"\n                )\n\n            if result and isinstance(result[0], dict):\n                total_estimate += result[0].get(\"count\", 0)\n            elif result:\n                total_estimate += result[0] if isinstance(result[0], int) else 0\n\n        logger.info(f\"Estimated {total_estimate} items to process\")\n\n        # Submit command\n        command_id = await CommandService.submit_command_job(\n            \"open_notebook\",\n            \"rebuild_embeddings\",\n            {\n                \"mode\": request.mode,\n                \"include_sources\": request.include_sources,\n                \"include_notes\": request.include_notes,\n                \"include_insights\": request.include_insights,\n            },\n        )\n\n        logger.info(f\"Submitted rebuild command: {command_id}\")\n\n        return RebuildResponse(\n            command_id=command_id,\n            total_items=total_estimate,\n            message=f\"Rebuild operation started. Estimated {total_estimate} items to process.\",\n        )\n\n    except Exception as e:\n        logger.error(f\"Failed to start rebuild: {e}\")\n        logger.exception(e)\n        raise HTTPException(\n            status_code=500, detail=f\"Failed to start rebuild operation: {str(e)}\"\n        )\n\n\n@router.get(\"/rebuild/{command_id}/status\", response_model=RebuildStatusResponse)\nasync def get_rebuild_status(command_id: str):\n    \"\"\"\n    Get the status of a rebuild operation.\n\n    Returns:\n    - **status**: queued, running, completed, failed\n    - **progress**: processed count, total count, percentage\n    - **stats**: breakdown by type (sources, notes, insights, failed)\n    - **timestamps**: started_at, completed_at\n    \"\"\"\n    try:\n        # Get command status from surreal_commands\n        status = await get_command_status(command_id)\n\n        if not status:\n            raise HTTPException(status_code=404, detail=\"Rebuild command not found\")\n\n        # Build response based on status\n        response = RebuildStatusResponse(\n            command_id=command_id,\n            status=status.status,\n        )\n\n        # Extract metadata from command result\n        if status.result and isinstance(status.result, dict):\n            result = status.result\n\n            # Build progress info\n            if \"total_items\" in result and \"jobs_submitted\" in result:\n                total = result[\"total_items\"]\n                submitted = result[\"jobs_submitted\"]\n                response.progress = RebuildProgress(\n                    processed=submitted,\n                    total=total,\n                    percentage=round((submitted / total * 100) if total > 0 else 0, 2),\n                )\n\n            # Build stats\n            response.stats = RebuildStats(\n                sources=result.get(\"sources_submitted\", 0),\n                notes=result.get(\"notes_submitted\", 0),\n                insights=result.get(\"insights_submitted\", 0),\n                failed=result.get(\"failed_submissions\", 0),\n            )\n\n        # Add timestamps\n        if hasattr(status, \"created\") and status.created:\n            response.started_at = str(status.created)\n        if hasattr(status, \"updated\") and status.updated:\n            response.completed_at = str(status.updated)\n\n        # Add error message if failed\n        if (\n            status.status == \"failed\"\n            and status.result\n            and isinstance(status.result, dict)\n        ):\n            response.error_message = status.result.get(\"error_message\", \"Unknown error\")\n\n        return response\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to get rebuild status: {e}\")\n        logger.exception(e)\n        raise HTTPException(\n            status_code=500, detail=f\"Failed to get rebuild status: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/routers/episode_profiles.py",
    "content": "from typing import List, Optional\n\nfrom fastapi import APIRouter, HTTPException\nfrom loguru import logger\nfrom pydantic import BaseModel, Field\n\nfrom open_notebook.podcasts.models import EpisodeProfile\n\nrouter = APIRouter()\n\n\nclass EpisodeProfileResponse(BaseModel):\n    id: str\n    name: str\n    description: str\n    speaker_config: str\n    outline_llm: Optional[str] = None\n    transcript_llm: Optional[str] = None\n    language: Optional[str] = None\n    default_briefing: str\n    num_segments: int\n    # Legacy fields (for display/migration awareness)\n    outline_provider: Optional[str] = None\n    outline_model: Optional[str] = None\n    transcript_provider: Optional[str] = None\n    transcript_model: Optional[str] = None\n\n\ndef _profile_to_response(profile: EpisodeProfile) -> EpisodeProfileResponse:\n    return EpisodeProfileResponse(\n        id=str(profile.id),\n        name=profile.name,\n        description=profile.description or \"\",\n        speaker_config=profile.speaker_config,\n        outline_llm=profile.outline_llm,\n        transcript_llm=profile.transcript_llm,\n        language=profile.language,\n        default_briefing=profile.default_briefing,\n        num_segments=profile.num_segments,\n        outline_provider=profile.outline_provider,\n        outline_model=profile.outline_model,\n        transcript_provider=profile.transcript_provider,\n        transcript_model=profile.transcript_model,\n    )\n\n\n@router.get(\"/episode-profiles\", response_model=List[EpisodeProfileResponse])\nasync def list_episode_profiles():\n    \"\"\"List all available episode profiles\"\"\"\n    try:\n        profiles = await EpisodeProfile.get_all(order_by=\"name asc\")\n        return [_profile_to_response(p) for p in profiles]\n    except Exception as e:\n        logger.error(f\"Failed to fetch episode profiles: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch episode profiles\"\n        )\n\n\n@router.get(\"/episode-profiles/{profile_name}\", response_model=EpisodeProfileResponse)\nasync def get_episode_profile(profile_name: str):\n    \"\"\"Get a specific episode profile by name\"\"\"\n    try:\n        profile = await EpisodeProfile.get_by_name(profile_name)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Episode profile '{profile_name}' not found\"\n            )\n\n        return _profile_to_response(profile)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to fetch episode profile '{profile_name}': {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch episode profile\"\n        )\n\n\nclass EpisodeProfileCreate(BaseModel):\n    name: str = Field(..., description=\"Unique profile name\")\n    description: str = Field(\"\", description=\"Profile description\")\n    speaker_config: str = Field(..., description=\"Reference to speaker profile name\")\n    outline_llm: Optional[str] = Field(None, description=\"Model record ID for outline\")\n    transcript_llm: Optional[str] = Field(\n        None, description=\"Model record ID for transcript\"\n    )\n    language: Optional[str] = Field(None, description=\"Podcast language code\")\n    default_briefing: str = Field(..., description=\"Default briefing template\")\n    num_segments: int = Field(default=5, description=\"Number of podcast segments\")\n    # Legacy fields (accepted but not required)\n    outline_provider: Optional[str] = None\n    outline_model: Optional[str] = None\n    transcript_provider: Optional[str] = None\n    transcript_model: Optional[str] = None\n\n\n@router.post(\"/episode-profiles\", response_model=EpisodeProfileResponse)\nasync def create_episode_profile(profile_data: EpisodeProfileCreate):\n    \"\"\"Create a new episode profile\"\"\"\n    try:\n        profile = EpisodeProfile(\n            name=profile_data.name,\n            description=profile_data.description,\n            speaker_config=profile_data.speaker_config,\n            outline_llm=profile_data.outline_llm,\n            transcript_llm=profile_data.transcript_llm,\n            language=profile_data.language,\n            default_briefing=profile_data.default_briefing,\n            num_segments=profile_data.num_segments,\n            outline_provider=profile_data.outline_provider,\n            outline_model=profile_data.outline_model,\n            transcript_provider=profile_data.transcript_provider,\n            transcript_model=profile_data.transcript_model,\n        )\n\n        await profile.save()\n        return _profile_to_response(profile)\n\n    except Exception as e:\n        logger.error(f\"Failed to create episode profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to create episode profile\"\n        )\n\n\n@router.put(\"/episode-profiles/{profile_id}\", response_model=EpisodeProfileResponse)\nasync def update_episode_profile(profile_id: str, profile_data: EpisodeProfileCreate):\n    \"\"\"Update an existing episode profile\"\"\"\n    try:\n        profile = await EpisodeProfile.get(profile_id)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Episode profile '{profile_id}' not found\"\n            )\n\n        profile.name = profile_data.name\n        profile.description = profile_data.description\n        profile.speaker_config = profile_data.speaker_config\n        profile.outline_llm = profile_data.outline_llm\n        profile.transcript_llm = profile_data.transcript_llm\n        profile.language = profile_data.language\n        profile.default_briefing = profile_data.default_briefing\n        profile.num_segments = profile_data.num_segments\n        profile.outline_provider = profile_data.outline_provider\n        profile.outline_model = profile_data.outline_model\n        profile.transcript_provider = profile_data.transcript_provider\n        profile.transcript_model = profile_data.transcript_model\n\n        await profile.save()\n        return _profile_to_response(profile)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to update episode profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to update episode profile\"\n        )\n\n\n@router.delete(\"/episode-profiles/{profile_id}\")\nasync def delete_episode_profile(profile_id: str):\n    \"\"\"Delete an episode profile\"\"\"\n    try:\n        profile = await EpisodeProfile.get(profile_id)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Episode profile '{profile_id}' not found\"\n            )\n\n        await profile.delete()\n\n        return {\"message\": \"Episode profile deleted successfully\"}\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to delete episode profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to delete episode profile\"\n        )\n\n\n@router.post(\n    \"/episode-profiles/{profile_id}/duplicate\", response_model=EpisodeProfileResponse\n)\nasync def duplicate_episode_profile(profile_id: str):\n    \"\"\"Duplicate an episode profile\"\"\"\n    try:\n        original = await EpisodeProfile.get(profile_id)\n\n        if not original:\n            raise HTTPException(\n                status_code=404, detail=f\"Episode profile '{profile_id}' not found\"\n            )\n\n        duplicate = EpisodeProfile(\n            name=f\"{original.name} - Copy\",\n            description=original.description,\n            speaker_config=original.speaker_config,\n            outline_llm=original.outline_llm,\n            transcript_llm=original.transcript_llm,\n            language=original.language,\n            default_briefing=original.default_briefing,\n            num_segments=original.num_segments,\n            outline_provider=original.outline_provider,\n            outline_model=original.outline_model,\n            transcript_provider=original.transcript_provider,\n            transcript_model=original.transcript_model,\n        )\n\n        await duplicate.save()\n        return _profile_to_response(duplicate)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to duplicate episode profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to duplicate episode profile\"\n        )\n"
  },
  {
    "path": "api/routers/insights.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom loguru import logger\n\nfrom api.models import NoteResponse, SaveAsNoteRequest, SourceInsightResponse\nfrom open_notebook.domain.notebook import SourceInsight\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\n@router.get(\"/insights/{insight_id}\", response_model=SourceInsightResponse)\nasync def get_insight(insight_id: str):\n    \"\"\"Get a specific insight by ID.\"\"\"\n    try:\n        insight = await SourceInsight.get(insight_id)\n        if not insight:\n            raise HTTPException(status_code=404, detail=\"Insight not found\")\n\n        # Get source ID from the insight relationship\n        source = await insight.get_source()\n\n        return SourceInsightResponse(\n            id=insight.id or \"\",\n            source_id=source.id or \"\",\n            insight_type=insight.insight_type,\n            content=insight.content,\n            created=str(insight.created),\n            updated=str(insight.updated),\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching insight {insight_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=\"Error fetching insight\")\n\n\n@router.delete(\"/insights/{insight_id}\")\nasync def delete_insight(insight_id: str):\n    \"\"\"Delete a specific insight.\"\"\"\n    try:\n        insight = await SourceInsight.get(insight_id)\n        if not insight:\n            raise HTTPException(status_code=404, detail=\"Insight not found\")\n\n        await insight.delete()\n\n        return {\"message\": \"Insight deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting insight {insight_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=\"Error deleting insight\")\n\n\n@router.post(\"/insights/{insight_id}/save-as-note\", response_model=NoteResponse)\nasync def save_insight_as_note(insight_id: str, request: SaveAsNoteRequest):\n    \"\"\"Convert an insight to a note.\"\"\"\n    try:\n        insight = await SourceInsight.get(insight_id)\n        if not insight:\n            raise HTTPException(status_code=404, detail=\"Insight not found\")\n\n        # Use the existing save_as_note method from the domain model\n        note = await insight.save_as_note(request.notebook_id)\n\n        return NoteResponse(\n            id=note.id or \"\",\n            title=note.title,\n            content=note.content,\n            note_type=note.note_type,\n            created=str(note.created),\n            updated=str(note.updated),\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error saving insight {insight_id} as note: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Error saving insight as note\"\n        )\n"
  },
  {
    "path": "api/routers/languages.py",
    "content": "from typing import List\n\nimport pycountry\nfrom babel import Locale\nfrom babel.core import get_global\nfrom fastapi import APIRouter\nfrom pydantic import BaseModel\n\nrouter = APIRouter()\n\n# Additional regional variants for languages where the distinction matters\n# (TTS accent, vocabulary, spelling differences)\n_EXTRA_VARIANTS = [\n    \"pt_PT\",\n    \"en_GB\",\n    \"en_AU\",\n    \"en_IN\",\n    \"es_MX\",\n    \"es_AR\",\n    \"es_CO\",\n    \"fr_CA\",\n    \"fr_CH\",\n    \"zh_TW\",\n    \"zh_HK\",\n    \"de_AT\",\n    \"de_CH\",\n    \"ar_SA\",\n    \"nl_BE\",\n]\n\n\nclass LanguageResponse(BaseModel):\n    code: str\n    name: str\n\n\n@router.get(\"/languages\", response_model=List[LanguageResponse])\nasync def list_languages():\n    \"\"\"List available languages as BCP 47 locale codes (e.g. pt-BR, en-US).\"\"\"\n    likely_subtags = get_global(\"likely_subtags\")\n    languages = []\n    seen = set()\n\n    # 1. For each language, resolve its default locale via CLDR likely subtags\n    for lang in pycountry.languages:\n        if not hasattr(lang, \"alpha_2\"):\n            continue\n\n        code = lang.alpha_2\n        likely = likely_subtags.get(code)\n\n        if likely:\n            try:\n                loc = Locale.parse(likely)\n                if loc.territory:\n                    bcp47 = f\"{loc.language}-{loc.territory}\"\n                    display = loc.get_display_name(\"en\")\n                    if bcp47 not in seen:\n                        seen.add(bcp47)\n                        languages.append(LanguageResponse(code=bcp47, name=display))\n                    continue\n            except Exception:\n                pass\n\n        # Fallback: bare language code\n        if code not in seen:\n            seen.add(code)\n            languages.append(LanguageResponse(code=code, name=lang.name))\n\n    # 2. Add important regional variants\n    for locale_str in _EXTRA_VARIANTS:\n        try:\n            loc = Locale.parse(locale_str)\n            bcp47 = f\"{loc.language}-{loc.territory}\"\n            if bcp47 not in seen:\n                seen.add(bcp47)\n                display = loc.get_display_name(\"en\")\n                languages.append(LanguageResponse(code=bcp47, name=display))\n        except Exception:\n            pass\n\n    languages.sort(key=lambda x: x.name)\n    return languages\n"
  },
  {
    "path": "api/routers/models.py",
    "content": "import os\nimport traceback\nfrom typing import Dict, List, Optional\n\nfrom esperanto import AIFactory\nfrom fastapi import APIRouter, HTTPException, Query\nfrom loguru import logger\nfrom pydantic import BaseModel\n\nfrom api.models import (\n    DefaultModelsResponse,\n    ModelCreate,\n    ModelResponse,\n    ProviderAvailabilityResponse,\n)\nfrom open_notebook.domain.credential import Credential\nfrom open_notebook.ai.connection_tester import test_individual_model\nfrom open_notebook.ai.key_provider import provision_provider_keys\nfrom open_notebook.ai.model_discovery import (\n    discover_provider_models,\n    get_provider_model_count,\n    sync_all_providers,\n    sync_provider_models,\n)\nfrom open_notebook.ai.models import DefaultModels, Model\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\n# =============================================================================\n# Model Discovery Response Models\n# =============================================================================\n\n\nclass DiscoveredModelResponse(BaseModel):\n    \"\"\"Response model for a discovered model.\"\"\"\n\n    name: str\n    provider: str\n    model_type: str\n    description: Optional[str] = None\n\n\nclass ProviderSyncResponse(BaseModel):\n    \"\"\"Response model for provider sync operation.\"\"\"\n\n    provider: str\n    discovered: int\n    new: int\n    existing: int\n\n\nclass AllProvidersSyncResponse(BaseModel):\n    \"\"\"Response model for syncing all providers.\"\"\"\n\n    results: Dict[str, ProviderSyncResponse]\n    total_discovered: int\n    total_new: int\n\n\nclass ProviderModelCountResponse(BaseModel):\n    \"\"\"Response model for provider model counts.\"\"\"\n\n    provider: str\n    counts: Dict[str, int]\n    total: int\n\n\nclass AutoAssignResult(BaseModel):\n    \"\"\"Response model for auto-assign operation.\"\"\"\n\n    assigned: Dict[str, str]  # slot_name -> model_id\n    skipped: List[str]  # slots already assigned\n    missing: List[str]  # slots with no available models\n\n\nclass ModelTestResponse(BaseModel):\n    \"\"\"Response model for individual model test.\"\"\"\n\n    success: bool\n    message: str\n    details: Optional[str] = None\n\n\n# Provider priority for auto-assignment (higher priority first)\nPROVIDER_PRIORITY = [\n    \"openai\",\n    \"anthropic\",\n    \"google\",\n    \"mistral\",\n    \"groq\",\n    \"deepseek\",\n    \"xai\",\n    \"openrouter\",\n    \"ollama\",\n    \"azure\",\n    \"openai_compatible\",\n]\n\n# Model preference patterns (preferred models within each provider)\nMODEL_PREFERENCES = {\n    \"openai\": [\"gpt-4o\", \"gpt-4\", \"gpt-3.5-turbo\"],\n    \"anthropic\": [\"claude-3-5-sonnet\", \"claude-3-opus\", \"claude-3-sonnet\"],\n    \"google\": [\"gemini-2.0\", \"gemini-1.5-pro\", \"gemini-pro\"],\n    \"mistral\": [\"mistral-large\", \"mixtral\"],\n    \"groq\": [\"llama-3.3\", \"llama-3.1\", \"mixtral\"],\n}\n\n\nasync def _check_provider_has_credential(provider: str) -> bool:\n    \"\"\"Check if a provider has any credentials configured in the database.\"\"\"\n    try:\n        credentials = await Credential.get_by_provider(provider)\n        return len(credentials) > 0\n    except Exception:\n        pass\n    return False\n\n\ndef _check_azure_support(mode: str) -> bool:\n    \"\"\"\n    Check if Azure OpenAI provider is available for a specific mode.\n\n    Args:\n        mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'\n\n    Returns:\n        bool: True if either generic or mode-specific env vars are set\n    \"\"\"\n    # Check generic configuration (applies to all modes)\n    generic = (\n        os.environ.get(\"AZURE_OPENAI_API_KEY\") is not None\n        and os.environ.get(\"AZURE_OPENAI_ENDPOINT\") is not None\n        and os.environ.get(\"AZURE_OPENAI_API_VERSION\") is not None\n    )\n\n    # Check mode-specific configuration (takes precedence)\n    specific = (\n        os.environ.get(f\"AZURE_OPENAI_API_KEY_{mode}\") is not None\n        and os.environ.get(f\"AZURE_OPENAI_ENDPOINT_{mode}\") is not None\n        and os.environ.get(f\"AZURE_OPENAI_API_VERSION_{mode}\") is not None\n    )\n\n    return generic or specific\n\n\ndef _check_openai_compatible_support(mode: str) -> bool:\n    \"\"\"\n    Check if OpenAI-compatible provider is available for a specific mode.\n\n    Args:\n        mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'\n\n    Returns:\n        bool: True if either generic or mode-specific env var is set\n    \"\"\"\n    generic = os.environ.get(\"OPENAI_COMPATIBLE_BASE_URL\") is not None\n    specific = os.environ.get(f\"OPENAI_COMPATIBLE_BASE_URL_{mode}\") is not None\n    generic_key = os.environ.get(\"OPENAI_COMPATIBLE_API_KEY\") is not None\n    specific_key = os.environ.get(f\"OPENAI_COMPATIBLE_API_KEY_{mode}\") is not None\n    return generic or specific or generic_key or specific_key\n\n\n@router.get(\"/models\", response_model=List[ModelResponse])\nasync def get_models(\n    type: Optional[str] = Query(None, description=\"Filter by model type\"),\n):\n    \"\"\"Get all configured models with optional type filtering.\"\"\"\n    try:\n        if type:\n            models = await Model.get_models_by_type(type)\n        else:\n            models = await Model.get_all()\n\n        return [\n            ModelResponse(\n                id=model.id,\n                name=model.name,\n                provider=model.provider,\n                type=model.type,\n                credential=model.credential,\n                created=str(model.created),\n                updated=str(model.updated),\n            )\n            for model in models\n        ]\n    except Exception as e:\n        logger.error(f\"Error fetching models: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching models: {str(e)}\")\n\n\n@router.post(\"/models\", response_model=ModelResponse)\nasync def create_model(model_data: ModelCreate):\n    \"\"\"Create a new model configuration.\"\"\"\n    try:\n        # Validate model type\n        valid_types = [\"language\", \"embedding\", \"text_to_speech\", \"speech_to_text\"]\n        if model_data.type not in valid_types:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Invalid model type. Must be one of: {valid_types}\",\n            )\n\n        # Check for duplicate model name under the same provider and type (case-insensitive)\n        from open_notebook.database.repository import repo_query\n\n        existing = await repo_query(\n            \"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND string::lowercase(name) = $name AND string::lowercase(type) = $type LIMIT 1\",\n            {\n                \"provider\": model_data.provider.lower(),\n                \"name\": model_data.name.lower(),\n                \"type\": model_data.type.lower(),\n            },\n        )\n        if existing:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Model '{model_data.name}' already exists for provider '{model_data.provider}' with type '{model_data.type}'\",\n            )\n\n        new_model = Model(\n            name=model_data.name,\n            provider=model_data.provider,\n            type=model_data.type,\n            credential=model_data.credential,\n        )\n        await new_model.save()\n\n        return ModelResponse(\n            id=new_model.id or \"\",\n            name=new_model.name,\n            provider=new_model.provider,\n            type=new_model.type,\n            credential=new_model.credential,\n            created=str(new_model.created),\n            updated=str(new_model.updated),\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error creating model: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error creating model: {str(e)}\")\n\n\n@router.delete(\"/models/{model_id}\")\nasync def delete_model(model_id: str):\n    \"\"\"Delete a model configuration.\"\"\"\n    try:\n        model = await Model.get(model_id)\n        if not model:\n            raise HTTPException(status_code=404, detail=\"Model not found\")\n\n        await model.delete()\n\n        return {\"message\": \"Model deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting model {model_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error deleting model: {str(e)}\")\n\n\n@router.post(\"/models/{model_id}/test\", response_model=ModelTestResponse)\nasync def test_model(model_id: str):\n    \"\"\"Test if a specific model is correctly configured and functional.\"\"\"\n    try:\n        model = await Model.get(model_id)\n        if not model:\n            raise HTTPException(status_code=404, detail=\"Model not found\")\n    except HTTPException:\n        raise\n    except Exception:\n        raise HTTPException(status_code=404, detail=\"Model not found\")\n\n    try:\n        success, message = await test_individual_model(model)\n        return ModelTestResponse(success=success, message=message)\n    except Exception as e:\n        logger.error(f\"Error testing model {model_id}: {traceback.format_exc()}\")\n        return ModelTestResponse(\n            success=False,\n            message=str(e)[:200],\n        )\n\n\n@router.get(\"/models/defaults\", response_model=DefaultModelsResponse)\nasync def get_default_models():\n    \"\"\"Get default model assignments.\"\"\"\n    try:\n        defaults = await DefaultModels.get_instance()\n\n        return DefaultModelsResponse(\n            default_chat_model=defaults.default_chat_model,  # type: ignore[attr-defined]\n            default_transformation_model=defaults.default_transformation_model,  # type: ignore[attr-defined]\n            large_context_model=defaults.large_context_model,  # type: ignore[attr-defined]\n            default_text_to_speech_model=defaults.default_text_to_speech_model,  # type: ignore[attr-defined]\n            default_speech_to_text_model=defaults.default_speech_to_text_model,  # type: ignore[attr-defined]\n            default_embedding_model=defaults.default_embedding_model,  # type: ignore[attr-defined]\n            default_tools_model=defaults.default_tools_model,  # type: ignore[attr-defined]\n        )\n    except Exception as e:\n        logger.error(f\"Error fetching default models: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching default models: {str(e)}\"\n        )\n\n\n@router.put(\"/models/defaults\", response_model=DefaultModelsResponse)\nasync def update_default_models(defaults_data: DefaultModelsResponse):\n    \"\"\"Update default model assignments.\"\"\"\n    try:\n        defaults = await DefaultModels.get_instance()\n\n        # Update only provided fields\n        if defaults_data.default_chat_model is not None:\n            defaults.default_chat_model = defaults_data.default_chat_model  # type: ignore[attr-defined]\n        if defaults_data.default_transformation_model is not None:\n            defaults.default_transformation_model = (\n                defaults_data.default_transformation_model\n            )  # type: ignore[attr-defined]\n        if defaults_data.large_context_model is not None:\n            defaults.large_context_model = defaults_data.large_context_model  # type: ignore[attr-defined]\n        if defaults_data.default_text_to_speech_model is not None:\n            defaults.default_text_to_speech_model = (\n                defaults_data.default_text_to_speech_model\n            )  # type: ignore[attr-defined]\n        if defaults_data.default_speech_to_text_model is not None:\n            defaults.default_speech_to_text_model = (\n                defaults_data.default_speech_to_text_model\n            )  # type: ignore[attr-defined]\n        if defaults_data.default_embedding_model is not None:\n            defaults.default_embedding_model = defaults_data.default_embedding_model  # type: ignore[attr-defined]\n        if defaults_data.default_tools_model is not None:\n            defaults.default_tools_model = defaults_data.default_tools_model  # type: ignore[attr-defined]\n\n        await defaults.update()\n\n        # No cache refresh needed - next access will fetch fresh data from DB\n\n        return DefaultModelsResponse(\n            default_chat_model=defaults.default_chat_model,  # type: ignore[attr-defined]\n            default_transformation_model=defaults.default_transformation_model,  # type: ignore[attr-defined]\n            large_context_model=defaults.large_context_model,  # type: ignore[attr-defined]\n            default_text_to_speech_model=defaults.default_text_to_speech_model,  # type: ignore[attr-defined]\n            default_speech_to_text_model=defaults.default_speech_to_text_model,  # type: ignore[attr-defined]\n            default_embedding_model=defaults.default_embedding_model,  # type: ignore[attr-defined]\n            default_tools_model=defaults.default_tools_model,  # type: ignore[attr-defined]\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error updating default models: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error updating default models: {str(e)}\"\n        )\n\n\n@router.get(\"/models/providers\", response_model=ProviderAvailabilityResponse)\nasync def get_provider_availability():\n    \"\"\"Get provider availability based on database config and environment variables.\"\"\"\n    try:\n        # Check which providers have credentials in the database or env vars\n        # For each provider, check DB credentials first, then env vars as fallback\n\n        # Simple env var mapping for backward compatibility\n        env_var_map = {\n            \"openai\": \"OPENAI_API_KEY\",\n            \"anthropic\": \"ANTHROPIC_API_KEY\",\n            \"google\": \"GOOGLE_API_KEY\",\n            \"groq\": \"GROQ_API_KEY\",\n            \"mistral\": \"MISTRAL_API_KEY\",\n            \"deepseek\": \"DEEPSEEK_API_KEY\",\n            \"xai\": \"XAI_API_KEY\",\n            \"openrouter\": \"OPENROUTER_API_KEY\",\n            \"voyage\": \"VOYAGE_API_KEY\",\n            \"elevenlabs\": \"ELEVENLABS_API_KEY\",\n            \"ollama\": \"OLLAMA_API_BASE\",\n        }\n\n        provider_status = {}\n\n        # Check simple providers: credential in DB or env var\n        for provider, env_var in env_var_map.items():\n            has_cred = await _check_provider_has_credential(provider)\n            has_env = os.environ.get(env_var) is not None\n            provider_status[provider] = has_cred or has_env\n\n        # Google also supports GEMINI_API_KEY\n        if not provider_status.get(\"google\"):\n            provider_status[\"google\"] = os.environ.get(\"GEMINI_API_KEY\") is not None\n\n        # Vertex: DB credential or env vars\n        provider_status[\"vertex\"] = (\n            await _check_provider_has_credential(\"vertex\")\n            or os.environ.get(\"VERTEX_PROJECT\") is not None\n        )\n\n        # Azure: DB credential or env vars\n        provider_status[\"azure\"] = (\n            await _check_provider_has_credential(\"azure\")\n            or _check_azure_support(\"LLM\")\n            or _check_azure_support(\"EMBEDDING\")\n            or _check_azure_support(\"STT\")\n            or _check_azure_support(\"TTS\")\n        )\n\n        # OpenAI-compatible: DB credential or env vars\n        provider_status[\"openai-compatible\"] = (\n            await _check_provider_has_credential(\"openai_compatible\")\n            or _check_openai_compatible_support(\"LLM\")\n            or _check_openai_compatible_support(\"EMBEDDING\")\n            or _check_openai_compatible_support(\"STT\")\n            or _check_openai_compatible_support(\"TTS\")\n        )\n\n        available_providers = [k for k, v in provider_status.items() if v]\n        unavailable_providers = [k for k, v in provider_status.items() if not v]\n\n        # Get supported model types from Esperanto\n        esperanto_available = AIFactory.get_available_providers()\n\n        # Build supported types mapping only for available providers\n        supported_types: dict[str, list[str]] = {}\n        for provider in available_providers:\n            supported_types[provider] = []\n\n            # Map Esperanto model types to our environment variable modes\n            mode_mapping = {\n                \"language\": \"LLM\",\n                \"embedding\": \"EMBEDDING\",\n                \"speech_to_text\": \"STT\",\n                \"text_to_speech\": \"TTS\",\n            }\n\n            # Special handling for openai-compatible to check mode-specific availability\n            if provider == \"openai-compatible\":\n                has_db_cred = await _check_provider_has_credential(\"openai_compatible\")\n                for model_type, mode in mode_mapping.items():\n                    if (\n                        model_type in esperanto_available\n                        and provider in esperanto_available[model_type]\n                    ):\n                        if has_db_cred or _check_openai_compatible_support(mode):\n                            supported_types[provider].append(model_type)\n            # Special handling for azure to check mode-specific availability\n            elif provider == \"azure\":\n                has_db_cred = await _check_provider_has_credential(\"azure\")\n                for model_type, mode in mode_mapping.items():\n                    if (\n                        model_type in esperanto_available\n                        and provider in esperanto_available[model_type]\n                    ):\n                        if has_db_cred or _check_azure_support(mode):\n                            supported_types[provider].append(model_type)\n            else:\n                # Standard provider detection\n                for model_type, providers in esperanto_available.items():\n                    if provider in providers:\n                        supported_types[provider].append(model_type)\n\n        return ProviderAvailabilityResponse(\n            available=available_providers,\n            unavailable=unavailable_providers,\n            supported_types=supported_types,\n        )\n    except Exception as e:\n        logger.error(f\"Error checking provider availability: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error checking provider availability: {str(e)}\"\n        )\n\n\n# =============================================================================\n# Model Discovery Endpoints\n# =============================================================================\n\n\n@router.get(\n    \"/models/discover/{provider}\", response_model=List[DiscoveredModelResponse]\n)\nasync def discover_models(provider: str):\n    \"\"\"\n    Discover available models from a provider without registering them.\n\n    This endpoint queries the provider's API to list available models\n    but does not save them to the database. Use the sync endpoint\n    to both discover and register models.\n    \"\"\"\n    try:\n        # Provision DB-stored credentials into env vars before discovery\n        await provision_provider_keys(provider)\n        discovered = await discover_provider_models(provider)\n        return [\n            DiscoveredModelResponse(\n                name=m.name,\n                provider=m.provider,\n                model_type=m.model_type,\n                description=m.description,\n            )\n            for m in discovered\n        ]\n    except Exception as e:\n        logger.error(f\"Error discovering models for {provider}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Error discovering models. Check server logs for details.\"\n        )\n\n\n@router.post(\"/models/sync/{provider}\", response_model=ProviderSyncResponse)\nasync def sync_models(provider: str):\n    \"\"\"\n    Sync models for a specific provider.\n\n    Discovers available models from the provider's API and registers\n    any new models in the database. Existing models are skipped.\n\n    Returns counts of discovered, new, and existing models.\n    \"\"\"\n    try:\n        # Provision DB-stored credentials into env vars before discovery\n        await provision_provider_keys(provider)\n        discovered, new, existing = await sync_provider_models(\n            provider, auto_register=True\n        )\n        return ProviderSyncResponse(\n            provider=provider,\n            discovered=discovered,\n            new=new,\n            existing=existing,\n        )\n    except Exception as e:\n        logger.error(f\"Error syncing models for {provider}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=\"Error syncing models. Check server logs for details.\")\n\n\n@router.post(\"/models/sync\", response_model=AllProvidersSyncResponse)\nasync def sync_all_models():\n    \"\"\"\n    Sync models for all configured providers.\n\n    Discovers and registers models from all providers that have\n    valid API keys configured. This is useful for initial setup\n    or periodic refresh of available models.\n    \"\"\"\n    try:\n        results = await sync_all_providers()\n\n        response_results = {}\n        total_discovered = 0\n        total_new = 0\n\n        for provider, (discovered, new, existing) in results.items():\n            response_results[provider] = ProviderSyncResponse(\n                provider=provider,\n                discovered=discovered,\n                new=new,\n                existing=existing,\n            )\n            total_discovered += discovered\n            total_new += new\n\n        return AllProvidersSyncResponse(\n            results=response_results,\n            total_discovered=total_discovered,\n            total_new=total_new,\n        )\n    except Exception as e:\n        logger.error(f\"Error syncing all models: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error syncing all models: {str(e)}\"\n        )\n\n\n@router.get(\"/models/count/{provider}\", response_model=ProviderModelCountResponse)\nasync def get_model_count(provider: str):\n    \"\"\"\n    Get count of registered models for a provider, grouped by type.\n\n    Returns counts for each model type (language, embedding,\n    speech_to_text, text_to_speech) as well as total count.\n    \"\"\"\n    try:\n        counts = await get_provider_model_count(provider)\n        total = sum(counts.values())\n        return ProviderModelCountResponse(\n            provider=provider,\n            counts=counts,\n            total=total,\n        )\n    except Exception as e:\n        logger.error(f\"Error getting model count for {provider}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error getting model count: {str(e)}\"\n        )\n\n\n@router.get(\"/models/by-provider/{provider}\", response_model=List[ModelResponse])\nasync def get_models_by_provider(provider: str):\n    \"\"\"\n    Get all registered models for a specific provider.\n\n    Returns models from the database that belong to the specified provider.\n    \"\"\"\n    try:\n        from open_notebook.database.repository import repo_query\n\n        models = await repo_query(\n            \"SELECT * FROM model WHERE provider = $provider ORDER BY type, name\",\n            {\"provider\": provider},\n        )\n\n        return [\n            ModelResponse(\n                id=model.get(\"id\", \"\"),\n                name=model.get(\"name\", \"\"),\n                provider=model.get(\"provider\", \"\"),\n                type=model.get(\"type\", \"\"),\n                credential=model.get(\"credential\"),\n                created=str(model.get(\"created\", \"\")),\n                updated=str(model.get(\"updated\", \"\")),\n            )\n            for model in models\n        ]\n    except Exception as e:\n        logger.error(f\"Error fetching models for {provider}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching models: {str(e)}\"\n        )\n\n\ndef _get_preferred_model(\n    models: List[Dict], provider_priority: List[str], model_preferences: Dict\n) -> Optional[Dict]:\n    \"\"\"\n    Select the best model from a list based on provider priority and model preferences.\n\n    Args:\n        models: List of model dictionaries with 'provider', 'name', 'id' keys\n        provider_priority: List of providers in preference order\n        model_preferences: Dict mapping provider to list of preferred model name patterns\n\n    Returns:\n        The best model dict, or None if no models available\n    \"\"\"\n    if not models:\n        return None\n\n    # Group models by provider\n    by_provider: Dict[str, List[Dict]] = {}\n    for model in models:\n        provider = model.get(\"provider\", \"\")\n        if provider not in by_provider:\n            by_provider[provider] = []\n        by_provider[provider].append(model)\n\n    # Find first provider with models (in priority order)\n    for provider in provider_priority:\n        if provider in by_provider:\n            provider_models = by_provider[provider]\n\n            # Check for preferred models within this provider\n            if provider in model_preferences:\n                for preference in model_preferences[provider]:\n                    for model in provider_models:\n                        if preference.lower() in model.get(\"name\", \"\").lower():\n                            return model\n\n            # Fall back to first model from this provider\n            return provider_models[0]\n\n    # Fall back to first model from any provider\n    return models[0] if models else None\n\n\n@router.post(\"/models/auto-assign\", response_model=AutoAssignResult)\nasync def auto_assign_defaults():\n    \"\"\"\n    Auto-assign default models based on available models.\n\n    This endpoint intelligently assigns the first available model of each\n    required type to the corresponding default slot. It uses provider\n    priority (preferring premium providers like OpenAI, Anthropic) and\n    model preferences within each provider.\n\n    Returns:\n        - assigned: Dict of slot names to assigned model IDs\n        - skipped: List of slots that already have models assigned\n        - missing: List of slots with no available models\n    \"\"\"\n    try:\n        from open_notebook.database.repository import repo_query\n\n        # Get current defaults\n        defaults = await DefaultModels.get_instance()\n\n        # Get all models grouped by type\n        all_models = await repo_query(\n            \"SELECT * FROM model ORDER BY provider, name\",\n            {},\n        )\n\n        # Group models by type\n        models_by_type: Dict[str, List[Dict]] = {\n            \"language\": [],\n            \"embedding\": [],\n            \"text_to_speech\": [],\n            \"speech_to_text\": [],\n        }\n\n        for model in all_models:\n            model_type = model.get(\"type\", \"\")\n            if model_type in models_by_type:\n                models_by_type[model_type].append(model)\n\n        # Define slot configuration: (slot_name, model_type, current_value)\n        slot_configs = [\n            (\"default_chat_model\", \"language\", defaults.default_chat_model),  # type: ignore[attr-defined]\n            (\"default_transformation_model\", \"language\", defaults.default_transformation_model),  # type: ignore[attr-defined]\n            (\"default_tools_model\", \"language\", defaults.default_tools_model),  # type: ignore[attr-defined]\n            (\"large_context_model\", \"language\", defaults.large_context_model),  # type: ignore[attr-defined]\n            (\"default_embedding_model\", \"embedding\", defaults.default_embedding_model),  # type: ignore[attr-defined]\n            (\"default_text_to_speech_model\", \"text_to_speech\", defaults.default_text_to_speech_model),  # type: ignore[attr-defined]\n            (\"default_speech_to_text_model\", \"speech_to_text\", defaults.default_speech_to_text_model),  # type: ignore[attr-defined]\n        ]\n\n        assigned: Dict[str, str] = {}\n        skipped: List[str] = []\n        missing: List[str] = []\n\n        for slot_name, model_type, current_value in slot_configs:\n            if current_value:\n                # Slot already has a value\n                skipped.append(slot_name)\n                continue\n\n            available_models = models_by_type.get(model_type, [])\n            if not available_models:\n                # No models of this type available\n                missing.append(slot_name)\n                continue\n\n            # Select best model for this slot\n            best_model = _get_preferred_model(\n                available_models, PROVIDER_PRIORITY, MODEL_PREFERENCES\n            )\n\n            if best_model:\n                model_id = best_model.get(\"id\", \"\")\n                assigned[slot_name] = model_id\n                # Update the defaults object\n                setattr(defaults, slot_name, model_id)\n\n        # Save updated defaults if any assignments were made\n        if assigned:\n            await defaults.update()\n\n        return AutoAssignResult(\n            assigned=assigned,\n            skipped=skipped,\n            missing=missing,\n        )\n\n    except Exception as e:\n        logger.error(f\"Error auto-assigning defaults: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error auto-assigning defaults: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/routers/notebooks.py",
    "content": "from typing import List, Optional\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom loguru import logger\n\nfrom api.models import (\n    NotebookCreate,\n    NotebookDeletePreview,\n    NotebookDeleteResponse,\n    NotebookResponse,\n    NotebookUpdate,\n)\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.notebook import Notebook, Source\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\n@router.get(\"/notebooks\", response_model=List[NotebookResponse])\nasync def get_notebooks(\n    archived: Optional[bool] = Query(None, description=\"Filter by archived status\"),\n    order_by: str = Query(\"updated desc\", description=\"Order by field and direction\"),\n):\n    \"\"\"Get all notebooks with optional filtering and ordering.\"\"\"\n    try:\n        # Build the query with counts\n        query = f\"\"\"\n            SELECT *,\n            count(<-reference.in) as source_count,\n            count(<-artifact.in) as note_count\n            FROM notebook\n            ORDER BY {order_by}\n        \"\"\"\n\n        result = await repo_query(query)\n\n        # Filter by archived status if specified\n        if archived is not None:\n            result = [nb for nb in result if nb.get(\"archived\") == archived]\n\n        return [\n            NotebookResponse(\n                id=str(nb.get(\"id\", \"\")),\n                name=nb.get(\"name\", \"\"),\n                description=nb.get(\"description\", \"\"),\n                archived=nb.get(\"archived\", False),\n                created=str(nb.get(\"created\", \"\")),\n                updated=str(nb.get(\"updated\", \"\")),\n                source_count=nb.get(\"source_count\", 0),\n                note_count=nb.get(\"note_count\", 0),\n            )\n            for nb in result\n        ]\n    except Exception as e:\n        logger.error(f\"Error fetching notebooks: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching notebooks: {str(e)}\"\n        )\n\n\n@router.post(\"/notebooks\", response_model=NotebookResponse)\nasync def create_notebook(notebook: NotebookCreate):\n    \"\"\"Create a new notebook.\"\"\"\n    try:\n        new_notebook = Notebook(\n            name=notebook.name,\n            description=notebook.description,\n        )\n        await new_notebook.save()\n\n        return NotebookResponse(\n            id=new_notebook.id or \"\",\n            name=new_notebook.name,\n            description=new_notebook.description,\n            archived=new_notebook.archived or False,\n            created=str(new_notebook.created),\n            updated=str(new_notebook.updated),\n            source_count=0,  # New notebook has no sources\n            note_count=0,  # New notebook has no notes\n        )\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error creating notebook: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error creating notebook: {str(e)}\"\n        )\n\n\n@router.get(\n    \"/notebooks/{notebook_id}/delete-preview\", response_model=NotebookDeletePreview\n)\nasync def get_notebook_delete_preview(notebook_id: str):\n    \"\"\"Get a preview of what will be deleted when this notebook is deleted.\"\"\"\n    try:\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        preview = await notebook.get_delete_preview()\n\n        return NotebookDeletePreview(\n            notebook_id=str(notebook.id),\n            notebook_name=notebook.name,\n            note_count=preview[\"note_count\"],\n            exclusive_source_count=preview[\"exclusive_source_count\"],\n            shared_source_count=preview[\"shared_source_count\"],\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error getting delete preview for notebook {notebook_id}: {e}\")\n        raise HTTPException(\n            status_code=500,\n            detail=f\"Error fetching notebook deletion preview: {str(e)}\",\n        )\n\n\n@router.get(\"/notebooks/{notebook_id}\", response_model=NotebookResponse)\nasync def get_notebook(notebook_id: str):\n    \"\"\"Get a specific notebook by ID.\"\"\"\n    try:\n        # Query with counts for single notebook\n        query = \"\"\"\n            SELECT *,\n            count(<-reference.in) as source_count,\n            count(<-artifact.in) as note_count\n            FROM $notebook_id\n        \"\"\"\n        result = await repo_query(query, {\"notebook_id\": ensure_record_id(notebook_id)})\n\n        if not result:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        nb = result[0]\n        return NotebookResponse(\n            id=str(nb.get(\"id\", \"\")),\n            name=nb.get(\"name\", \"\"),\n            description=nb.get(\"description\", \"\"),\n            archived=nb.get(\"archived\", False),\n            created=str(nb.get(\"created\", \"\")),\n            updated=str(nb.get(\"updated\", \"\")),\n            source_count=nb.get(\"source_count\", 0),\n            note_count=nb.get(\"note_count\", 0),\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching notebook {notebook_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching notebook: {str(e)}\"\n        )\n\n\n@router.put(\"/notebooks/{notebook_id}\", response_model=NotebookResponse)\nasync def update_notebook(notebook_id: str, notebook_update: NotebookUpdate):\n    \"\"\"Update a notebook.\"\"\"\n    try:\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        # Update only provided fields\n        if notebook_update.name is not None:\n            notebook.name = notebook_update.name\n        if notebook_update.description is not None:\n            notebook.description = notebook_update.description\n        if notebook_update.archived is not None:\n            notebook.archived = notebook_update.archived\n\n        await notebook.save()\n\n        # Query with counts after update\n        query = \"\"\"\n            SELECT *,\n            count(<-reference.in) as source_count,\n            count(<-artifact.in) as note_count\n            FROM $notebook_id\n        \"\"\"\n        result = await repo_query(query, {\"notebook_id\": ensure_record_id(notebook_id)})\n\n        if result:\n            nb = result[0]\n            return NotebookResponse(\n                id=str(nb.get(\"id\", \"\")),\n                name=nb.get(\"name\", \"\"),\n                description=nb.get(\"description\", \"\"),\n                archived=nb.get(\"archived\", False),\n                created=str(nb.get(\"created\", \"\")),\n                updated=str(nb.get(\"updated\", \"\")),\n                source_count=nb.get(\"source_count\", 0),\n                note_count=nb.get(\"note_count\", 0),\n            )\n\n        # Fallback if query fails\n        return NotebookResponse(\n            id=notebook.id or \"\",\n            name=notebook.name,\n            description=notebook.description,\n            archived=notebook.archived or False,\n            created=str(notebook.created),\n            updated=str(notebook.updated),\n            source_count=0,\n            note_count=0,\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error updating notebook {notebook_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error updating notebook: {str(e)}\"\n        )\n\n\n@router.post(\"/notebooks/{notebook_id}/sources/{source_id}\")\nasync def add_source_to_notebook(notebook_id: str, source_id: str):\n    \"\"\"Add an existing source to a notebook (create the reference).\"\"\"\n    try:\n        # Check if notebook exists\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        # Check if source exists\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Check if reference already exists (idempotency)\n        existing_ref = await repo_query(\n            \"SELECT * FROM reference WHERE out = $source_id AND in = $notebook_id\",\n            {\n                \"notebook_id\": ensure_record_id(notebook_id),\n                \"source_id\": ensure_record_id(source_id),\n            },\n        )\n\n        # If reference doesn't exist, create it\n        if not existing_ref:\n            await repo_query(\n                \"RELATE $source_id->reference->$notebook_id\",\n                {\n                    \"notebook_id\": ensure_record_id(notebook_id),\n                    \"source_id\": ensure_record_id(source_id),\n                },\n            )\n\n        return {\"message\": \"Source linked to notebook successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\n            f\"Error linking source {source_id} to notebook {notebook_id}: {str(e)}\"\n        )\n        raise HTTPException(\n            status_code=500, detail=f\"Error linking source to notebook: {str(e)}\"\n        )\n\n\n@router.delete(\"/notebooks/{notebook_id}/sources/{source_id}\")\nasync def remove_source_from_notebook(notebook_id: str, source_id: str):\n    \"\"\"Remove a source from a notebook (delete the reference).\"\"\"\n    try:\n        # Check if notebook exists\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        # Delete the reference record linking source to notebook\n        await repo_query(\n            \"DELETE FROM reference WHERE out = $notebook_id AND in = $source_id\",\n            {\n                \"notebook_id\": ensure_record_id(notebook_id),\n                \"source_id\": ensure_record_id(source_id),\n            },\n        )\n\n        return {\"message\": \"Source removed from notebook successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(\n            f\"Error removing source {source_id} from notebook {notebook_id}: {str(e)}\"\n        )\n        raise HTTPException(\n            status_code=500, detail=f\"Error removing source from notebook: {str(e)}\"\n        )\n\n\n@router.delete(\"/notebooks/{notebook_id}\", response_model=NotebookDeleteResponse)\nasync def delete_notebook(\n    notebook_id: str,\n    delete_exclusive_sources: bool = Query(\n        False,\n        description=\"Whether to delete sources that belong only to this notebook\",\n    ),\n):\n    \"\"\"\n    Delete a notebook with cascade deletion.\n\n    Always deletes all notes associated with the notebook.\n    If delete_exclusive_sources is True, also deletes sources that belong only\n    to this notebook (not linked to any other notebooks).\n    \"\"\"\n    try:\n        notebook = await Notebook.get(notebook_id)\n        if not notebook:\n            raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n        result = await notebook.delete(delete_exclusive_sources=delete_exclusive_sources)\n\n        return NotebookDeleteResponse(\n            message=\"Notebook deleted successfully\",\n            deleted_notes=result[\"deleted_notes\"],\n            deleted_sources=result[\"deleted_sources\"],\n            unlinked_sources=result[\"unlinked_sources\"],\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting notebook {notebook_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error deleting notebook: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/routers/notes.py",
    "content": "from typing import List, Literal, Optional\n\nfrom fastapi import APIRouter, HTTPException, Query\nfrom loguru import logger\n\nfrom api.models import NoteCreate, NoteResponse, NoteUpdate\nfrom open_notebook.domain.notebook import Note\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\n@router.get(\"/notes\", response_model=List[NoteResponse])\nasync def get_notes(\n    notebook_id: Optional[str] = Query(None, description=\"Filter by notebook ID\"),\n):\n    \"\"\"Get all notes with optional notebook filtering.\"\"\"\n    try:\n        if notebook_id:\n            # Get notes for a specific notebook\n            from open_notebook.domain.notebook import Notebook\n\n            notebook = await Notebook.get(notebook_id)\n            if not notebook:\n                raise HTTPException(status_code=404, detail=\"Notebook not found\")\n            notes = await notebook.get_notes()\n        else:\n            # Get all notes\n            notes = await Note.get_all(order_by=\"updated desc\")\n\n        return [\n            NoteResponse(\n                id=note.id or \"\",\n                title=note.title,\n                content=note.content,\n                note_type=note.note_type,\n                created=str(note.created),\n                updated=str(note.updated),\n            )\n            for note in notes\n        ]\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching notes: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching notes: {str(e)}\")\n\n\n@router.post(\"/notes\", response_model=NoteResponse)\nasync def create_note(note_data: NoteCreate):\n    \"\"\"Create a new note.\"\"\"\n    try:\n        # Auto-generate title if not provided and it's an AI note\n        title = note_data.title\n        if not title and note_data.note_type == \"ai\" and note_data.content:\n            from open_notebook.graphs.prompt import graph as prompt_graph\n\n            prompt = \"Based on the Note below, please provide a Title for this content, with max 15 words\"\n            result = await prompt_graph.ainvoke(\n                {  # type: ignore[arg-type]\n                    \"input_text\": note_data.content,\n                    \"prompt\": prompt,\n                }\n            )\n            title = result.get(\"output\", \"Untitled Note\")\n\n        # Validate note_type\n        note_type: Optional[Literal[\"human\", \"ai\"]] = None\n        if note_data.note_type in (\"human\", \"ai\"):\n            note_type = note_data.note_type  # type: ignore[assignment]\n        elif note_data.note_type is not None:\n            raise HTTPException(\n                status_code=400, detail=\"note_type must be 'human' or 'ai'\"\n            )\n\n        new_note = Note(\n            title=title,\n            content=note_data.content,\n            note_type=note_type,\n        )\n        command_id = await new_note.save()\n\n        # Add to notebook if specified\n        if note_data.notebook_id:\n            from open_notebook.domain.notebook import Notebook\n\n            notebook = await Notebook.get(note_data.notebook_id)\n            if not notebook:\n                raise HTTPException(status_code=404, detail=\"Notebook not found\")\n            await new_note.add_to_notebook(note_data.notebook_id)\n\n        return NoteResponse(\n            id=new_note.id or \"\",\n            title=new_note.title,\n            content=new_note.content,\n            note_type=new_note.note_type,\n            created=str(new_note.created),\n            updated=str(new_note.updated),\n            command_id=str(command_id) if command_id else None,\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error creating note: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error creating note: {str(e)}\")\n\n\n@router.get(\"/notes/{note_id}\", response_model=NoteResponse)\nasync def get_note(note_id: str):\n    \"\"\"Get a specific note by ID.\"\"\"\n    try:\n        note = await Note.get(note_id)\n        if not note:\n            raise HTTPException(status_code=404, detail=\"Note not found\")\n\n        return NoteResponse(\n            id=note.id or \"\",\n            title=note.title,\n            content=note.content,\n            note_type=note.note_type,\n            created=str(note.created),\n            updated=str(note.updated),\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching note {note_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching note: {str(e)}\")\n\n\n@router.put(\"/notes/{note_id}\", response_model=NoteResponse)\nasync def update_note(note_id: str, note_update: NoteUpdate):\n    \"\"\"Update a note.\"\"\"\n    try:\n        note = await Note.get(note_id)\n        if not note:\n            raise HTTPException(status_code=404, detail=\"Note not found\")\n\n        # Update only provided fields\n        if note_update.title is not None:\n            note.title = note_update.title\n        if note_update.content is not None:\n            note.content = note_update.content\n        if note_update.note_type is not None:\n            if note_update.note_type in (\"human\", \"ai\"):\n                note.note_type = note_update.note_type  # type: ignore[assignment]\n            else:\n                raise HTTPException(\n                    status_code=400, detail=\"note_type must be 'human' or 'ai'\"\n                )\n\n        command_id = await note.save()\n\n        return NoteResponse(\n            id=note.id or \"\",\n            title=note.title,\n            content=note.content,\n            note_type=note.note_type,\n            created=str(note.created),\n            updated=str(note.updated),\n            command_id=str(command_id) if command_id else None,\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error updating note {note_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error updating note: {str(e)}\")\n\n\n@router.delete(\"/notes/{note_id}\")\nasync def delete_note(note_id: str):\n    \"\"\"Delete a note.\"\"\"\n    try:\n        note = await Note.get(note_id)\n        if not note:\n            raise HTTPException(status_code=404, detail=\"Note not found\")\n\n        await note.delete()\n\n        return {\"message\": \"Note deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting note {note_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error deleting note: {str(e)}\")\n"
  },
  {
    "path": "api/routers/podcasts.py",
    "content": "from pathlib import Path\nfrom typing import List, Optional\nfrom urllib.parse import unquote, urlparse\n\nfrom fastapi import APIRouter, HTTPException\nfrom fastapi.responses import FileResponse\nfrom loguru import logger\nfrom pydantic import BaseModel\n\nfrom api.podcast_service import (\n    PodcastGenerationRequest,\n    PodcastGenerationResponse,\n    PodcastService,\n)\n\nrouter = APIRouter()\n\n\nclass PodcastEpisodeResponse(BaseModel):\n    id: str\n    name: str\n    episode_profile: dict\n    speaker_profile: dict\n    briefing: str\n    audio_file: Optional[str] = None\n    audio_url: Optional[str] = None\n    transcript: Optional[dict] = None\n    outline: Optional[dict] = None\n    created: Optional[str] = None\n    job_status: Optional[str] = None\n    error_message: Optional[str] = None\n\n\ndef _resolve_audio_path(audio_file: str) -> Path:\n    if audio_file.startswith(\"file://\"):\n        parsed = urlparse(audio_file)\n        return Path(unquote(parsed.path))\n    return Path(audio_file)\n\n\n@router.post(\"/podcasts/generate\", response_model=PodcastGenerationResponse)\nasync def generate_podcast(request: PodcastGenerationRequest):\n    \"\"\"\n    Generate a podcast episode using Episode Profiles.\n    Returns immediately with job ID for status tracking.\n    \"\"\"\n    try:\n        job_id = await PodcastService.submit_generation_job(\n            episode_profile_name=request.episode_profile,\n            speaker_profile_name=request.speaker_profile,\n            episode_name=request.episode_name,\n            notebook_id=request.notebook_id,\n            content=request.content,\n            briefing_suffix=request.briefing_suffix,\n        )\n\n        return PodcastGenerationResponse(\n            job_id=job_id,\n            status=\"submitted\",\n            message=f\"Podcast generation started for episode '{request.episode_name}'\",\n            episode_profile=request.episode_profile,\n            episode_name=request.episode_name,\n        )\n\n    except Exception as e:\n        logger.error(f\"Error generating podcast: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to generate podcast\"\n        )\n\n\n@router.get(\"/podcasts/jobs/{job_id}\")\nasync def get_podcast_job_status(job_id: str):\n    \"\"\"Get the status of a podcast generation job\"\"\"\n    try:\n        status_data = await PodcastService.get_job_status(job_id)\n        return status_data\n\n    except Exception as e:\n        logger.error(f\"Error fetching podcast job status: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch job status\"\n        )\n\n\n@router.get(\"/podcasts/episodes\", response_model=List[PodcastEpisodeResponse])\nasync def list_podcast_episodes():\n    \"\"\"List all podcast episodes\"\"\"\n    try:\n        episodes = await PodcastService.list_episodes()\n\n        response_episodes = []\n        for episode in episodes:\n            # Skip incomplete episodes without command or audio\n            if not episode.command and not episode.audio_file:\n                continue\n\n            # Get job status and error message if available\n            job_status = None\n            error_message = None\n            if episode.command:\n                try:\n                    detail = await episode.get_job_detail()\n                    job_status = detail[\"status\"]\n                    error_message = detail[\"error_message\"]\n                except Exception:\n                    job_status = \"unknown\"\n            else:\n                # No command but has audio file = completed import\n                job_status = \"completed\"\n\n            audio_url = None\n            if episode.audio_file:\n                audio_path = _resolve_audio_path(episode.audio_file)\n                if audio_path.exists():\n                    audio_url = f\"/api/podcasts/episodes/{episode.id}/audio\"\n\n            response_episodes.append(\n                PodcastEpisodeResponse(\n                    id=str(episode.id),\n                    name=episode.name,\n                    episode_profile=episode.episode_profile,\n                    speaker_profile=episode.speaker_profile,\n                    briefing=episode.briefing,\n                    audio_file=episode.audio_file,\n                    audio_url=audio_url,\n                    transcript=episode.transcript,\n                    outline=episode.outline,\n                    created=str(episode.created) if episode.created else None,\n                    job_status=job_status,\n                    error_message=error_message,\n                )\n            )\n\n        return response_episodes\n\n    except Exception as e:\n        logger.error(f\"Error listing podcast episodes: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to list podcast episodes\"\n        )\n\n\n@router.get(\"/podcasts/episodes/{episode_id}\", response_model=PodcastEpisodeResponse)\nasync def get_podcast_episode(episode_id: str):\n    \"\"\"Get a specific podcast episode\"\"\"\n    try:\n        episode = await PodcastService.get_episode(episode_id)\n\n        # Get job status and error message if available\n        job_status = None\n        error_message = None\n        if episode.command:\n            try:\n                detail = await episode.get_job_detail()\n                job_status = detail[\"status\"]\n                error_message = detail[\"error_message\"]\n            except Exception:\n                job_status = \"unknown\"\n        else:\n            # No command but has audio file = completed import\n            job_status = \"completed\" if episode.audio_file else \"unknown\"\n\n        audio_url = None\n        if episode.audio_file:\n            audio_path = _resolve_audio_path(episode.audio_file)\n            if audio_path.exists():\n                audio_url = f\"/api/podcasts/episodes/{episode.id}/audio\"\n\n        return PodcastEpisodeResponse(\n            id=str(episode.id),\n            name=episode.name,\n            episode_profile=episode.episode_profile,\n            speaker_profile=episode.speaker_profile,\n            briefing=episode.briefing,\n            audio_file=episode.audio_file,\n            audio_url=audio_url,\n            transcript=episode.transcript,\n            outline=episode.outline,\n            created=str(episode.created) if episode.created else None,\n            job_status=job_status,\n            error_message=error_message,\n        )\n\n    except Exception as e:\n        logger.error(f\"Error fetching podcast episode: {str(e)}\")\n        raise HTTPException(status_code=404, detail=\"Episode not found\")\n\n\n@router.get(\"/podcasts/episodes/{episode_id}/audio\")\nasync def stream_podcast_episode_audio(episode_id: str):\n    \"\"\"Stream the audio file associated with a podcast episode\"\"\"\n    try:\n        episode = await PodcastService.get_episode(episode_id)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching podcast episode for audio: {str(e)}\")\n        raise HTTPException(status_code=404, detail=\"Episode not found\")\n\n    if not episode.audio_file:\n        raise HTTPException(status_code=404, detail=\"Episode has no audio file\")\n\n    audio_path = _resolve_audio_path(episode.audio_file)\n    if not audio_path.exists():\n        raise HTTPException(status_code=404, detail=\"Audio file not found on disk\")\n\n    return FileResponse(\n        audio_path,\n        media_type=\"audio/mpeg\",\n        filename=audio_path.name,\n    )\n\n\n@router.post(\"/podcasts/episodes/{episode_id}/retry\")\nasync def retry_podcast_episode(episode_id: str):\n    \"\"\"Retry a failed podcast episode by deleting it and submitting a new job\"\"\"\n    try:\n        episode = await PodcastService.get_episode(episode_id)\n\n        # Validate episode is in a failed state\n        detail = await episode.get_job_detail()\n        if detail[\"status\"] not in (\"failed\", \"error\"):\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Episode is not in a failed state (current: {detail['status']})\",\n            )\n\n        # Extract params for re-submission\n        ep_profile_name = episode.episode_profile.get(\"name\")\n        sp_profile_name = episode.speaker_profile.get(\"name\")\n        episode_name = episode.name\n        content = episode.content\n\n        if not ep_profile_name or not sp_profile_name:\n            raise HTTPException(\n                status_code=400,\n                detail=\"Cannot retry: episode or speaker profile name missing from stored data\",\n            )\n\n        # Delete audio file if any\n        if episode.audio_file:\n            audio_path = _resolve_audio_path(episode.audio_file)\n            if audio_path.exists():\n                try:\n                    audio_path.unlink()\n                except Exception as e:\n                    logger.warning(f\"Failed to delete audio file {audio_path}: {e}\")\n\n        # Delete the failed episode\n        await episode.delete()\n\n        # Submit a new job\n        job_id = await PodcastService.submit_generation_job(\n            episode_profile_name=ep_profile_name,\n            speaker_profile_name=sp_profile_name,\n            episode_name=episode_name,\n            content=content,\n        )\n\n        return {\"job_id\": job_id, \"message\": \"Retry submitted successfully\"}\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error retrying podcast episode: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to retry episode\"\n        )\n\n\n@router.delete(\"/podcasts/episodes/{episode_id}\")\nasync def delete_podcast_episode(episode_id: str):\n    \"\"\"Delete a podcast episode and its associated audio file\"\"\"\n    try:\n        # Get the episode first to check if it exists and get the audio file path\n        episode = await PodcastService.get_episode(episode_id)\n\n        # Delete the physical audio file if it exists\n        if episode.audio_file:\n            audio_path = _resolve_audio_path(episode.audio_file)\n            if audio_path.exists():\n                try:\n                    audio_path.unlink()\n                    logger.info(f\"Deleted audio file: {audio_path}\")\n                except Exception as e:\n                    logger.warning(f\"Failed to delete audio file {audio_path}: {e}\")\n\n        # Delete the episode from the database\n        await episode.delete()\n\n        logger.info(f\"Deleted podcast episode: {episode_id}\")\n        return {\"message\": \"Episode deleted successfully\", \"episode_id\": episode_id}\n\n    except Exception as e:\n        logger.error(f\"Error deleting podcast episode: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to delete episode\"\n        )\n"
  },
  {
    "path": "api/routers/search.py",
    "content": "import json\nfrom typing import AsyncGenerator\n\nfrom fastapi import APIRouter, HTTPException\nfrom fastapi.responses import StreamingResponse\nfrom loguru import logger\n\nfrom api.models import AskRequest, AskResponse, SearchRequest, SearchResponse\nfrom open_notebook.ai.models import Model, model_manager\nfrom open_notebook.domain.notebook import text_search, vector_search\nfrom open_notebook.exceptions import DatabaseOperationError, InvalidInputError\nfrom open_notebook.graphs.ask import graph as ask_graph\n\nrouter = APIRouter()\n\n\n@router.post(\"/search\", response_model=SearchResponse)\nasync def search_knowledge_base(search_request: SearchRequest):\n    \"\"\"Search the knowledge base using text or vector search.\"\"\"\n    try:\n        if search_request.type == \"vector\":\n            # Check if embedding model is available for vector search\n            if not await model_manager.get_embedding_model():\n                raise HTTPException(\n                    status_code=400,\n                    detail=\"Vector search requires an embedding model. Please configure one in the Models section.\",\n                )\n\n            results = await vector_search(\n                keyword=search_request.query,\n                results=search_request.limit,\n                source=search_request.search_sources,\n                note=search_request.search_notes,\n                minimum_score=search_request.minimum_score,\n            )\n        else:\n            # Text search\n            results = await text_search(\n                keyword=search_request.query,\n                results=search_request.limit,\n                source=search_request.search_sources,\n                note=search_request.search_notes,\n            )\n\n        return SearchResponse(\n            results=results or [],\n            total_count=len(results) if results else 0,\n            search_type=search_request.type,\n        )\n\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except DatabaseOperationError as e:\n        logger.error(f\"Database error during search: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Search failed: {str(e)}\")\n    except Exception as e:\n        logger.error(f\"Unexpected error during search: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Search failed: {str(e)}\")\n\n\nasync def stream_ask_response(\n    question: str, strategy_model: Model, answer_model: Model, final_answer_model: Model\n) -> AsyncGenerator[str, None]:\n    \"\"\"Stream the ask response as Server-Sent Events.\"\"\"\n    try:\n        final_answer = None\n\n        async for chunk in ask_graph.astream(\n            input=dict(question=question),  # type: ignore[arg-type]\n            config=dict(\n                configurable=dict(\n                    strategy_model=strategy_model.id,\n                    answer_model=answer_model.id,\n                    final_answer_model=final_answer_model.id,\n                )\n            ),\n            stream_mode=\"updates\",\n        ):\n            if \"agent\" in chunk:\n                strategy_data = {\n                    \"type\": \"strategy\",\n                    \"reasoning\": chunk[\"agent\"][\"strategy\"].reasoning,\n                    \"searches\": [\n                        {\"term\": search.term, \"instructions\": search.instructions}\n                        for search in chunk[\"agent\"][\"strategy\"].searches\n                    ],\n                }\n                yield f\"data: {json.dumps(strategy_data)}\\n\\n\"\n\n            elif \"provide_answer\" in chunk:\n                for answer in chunk[\"provide_answer\"][\"answers\"]:\n                    answer_data = {\"type\": \"answer\", \"content\": answer}\n                    yield f\"data: {json.dumps(answer_data)}\\n\\n\"\n\n            elif \"write_final_answer\" in chunk:\n                final_answer = chunk[\"write_final_answer\"][\"final_answer\"]\n                final_data = {\"type\": \"final_answer\", \"content\": final_answer}\n                yield f\"data: {json.dumps(final_data)}\\n\\n\"\n\n        # Send completion signal\n        completion_data = {\"type\": \"complete\", \"final_answer\": final_answer}\n        yield f\"data: {json.dumps(completion_data)}\\n\\n\"\n\n    except Exception as e:\n        from open_notebook.utils.error_classifier import classify_error\n\n        _, user_message = classify_error(e)\n        logger.error(f\"Error in ask streaming: {str(e)}\")\n        error_data = {\"type\": \"error\", \"message\": user_message}\n        yield f\"data: {json.dumps(error_data)}\\n\\n\"\n\n\n@router.post(\"/search/ask\")\nasync def ask_knowledge_base(ask_request: AskRequest):\n    \"\"\"Ask the knowledge base a question using AI models.\"\"\"\n    try:\n        # Validate models exist\n        strategy_model = await Model.get(ask_request.strategy_model)\n        answer_model = await Model.get(ask_request.answer_model)\n        final_answer_model = await Model.get(ask_request.final_answer_model)\n\n        if not strategy_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Strategy model {ask_request.strategy_model} not found\",\n            )\n        if not answer_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Answer model {ask_request.answer_model} not found\",\n            )\n        if not final_answer_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Final answer model {ask_request.final_answer_model} not found\",\n            )\n\n        # Check if embedding model is available\n        if not await model_manager.get_embedding_model():\n            raise HTTPException(\n                status_code=400,\n                detail=\"Ask feature requires an embedding model. Please configure one in the Models section.\",\n            )\n\n        # For streaming response\n        return StreamingResponse(\n            stream_ask_response(\n                ask_request.question, strategy_model, answer_model, final_answer_model\n            ),\n            media_type=\"text/plain\",\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error in ask endpoint: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Ask operation failed: {str(e)}\")\n\n\n@router.post(\"/search/ask/simple\", response_model=AskResponse)\nasync def ask_knowledge_base_simple(ask_request: AskRequest):\n    \"\"\"Ask the knowledge base a question and return a simple response (non-streaming).\"\"\"\n    try:\n        # Validate models exist\n        strategy_model = await Model.get(ask_request.strategy_model)\n        answer_model = await Model.get(ask_request.answer_model)\n        final_answer_model = await Model.get(ask_request.final_answer_model)\n\n        if not strategy_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Strategy model {ask_request.strategy_model} not found\",\n            )\n        if not answer_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Answer model {ask_request.answer_model} not found\",\n            )\n        if not final_answer_model:\n            raise HTTPException(\n                status_code=400,\n                detail=f\"Final answer model {ask_request.final_answer_model} not found\",\n            )\n\n        # Check if embedding model is available\n        if not await model_manager.get_embedding_model():\n            raise HTTPException(\n                status_code=400,\n                detail=\"Ask feature requires an embedding model. Please configure one in the Models section.\",\n            )\n\n        # Run the ask graph and get final result\n        final_answer = None\n        async for chunk in ask_graph.astream(\n            input=dict(question=ask_request.question),  # type: ignore[arg-type]\n            config=dict(\n                configurable=dict(\n                    strategy_model=strategy_model.id,\n                    answer_model=answer_model.id,\n                    final_answer_model=final_answer_model.id,\n                )\n            ),\n            stream_mode=\"updates\",\n        ):\n            if \"write_final_answer\" in chunk:\n                final_answer = chunk[\"write_final_answer\"][\"final_answer\"]\n\n        if not final_answer:\n            raise HTTPException(status_code=500, detail=\"No answer generated\")\n\n        return AskResponse(answer=final_answer, question=ask_request.question)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error in ask simple endpoint: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Ask operation failed: {str(e)}\")\n"
  },
  {
    "path": "api/routers/settings.py",
    "content": "from fastapi import APIRouter, HTTPException\nfrom loguru import logger\n\nfrom api.models import SettingsResponse, SettingsUpdate\nfrom open_notebook.domain.content_settings import ContentSettings\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\n@router.get(\"/settings\", response_model=SettingsResponse)\nasync def get_settings():\n    \"\"\"Get all application settings.\"\"\"\n    try:\n        settings: ContentSettings = await ContentSettings.get_instance()  # type: ignore[assignment]\n\n        return SettingsResponse(\n            default_content_processing_engine_doc=settings.default_content_processing_engine_doc,\n            default_content_processing_engine_url=settings.default_content_processing_engine_url,\n            default_embedding_option=settings.default_embedding_option,\n            auto_delete_files=settings.auto_delete_files,\n            youtube_preferred_languages=settings.youtube_preferred_languages,\n        )\n    except Exception as e:\n        logger.error(f\"Error fetching settings: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Error fetching settings\"\n        )\n\n\n@router.put(\"/settings\", response_model=SettingsResponse)\nasync def update_settings(settings_update: SettingsUpdate):\n    \"\"\"Update application settings.\"\"\"\n    try:\n        settings: ContentSettings = await ContentSettings.get_instance()  # type: ignore[assignment]\n\n        # Update only provided fields\n        if settings_update.default_content_processing_engine_doc is not None:\n            # Cast to proper literal type\n            from typing import Literal, cast\n\n            settings.default_content_processing_engine_doc = cast(\n                Literal[\"auto\", \"docling\", \"simple\"],\n                settings_update.default_content_processing_engine_doc,\n            )\n        if settings_update.default_content_processing_engine_url is not None:\n            from typing import Literal, cast\n\n            settings.default_content_processing_engine_url = cast(\n                Literal[\"auto\", \"firecrawl\", \"jina\", \"simple\"],\n                settings_update.default_content_processing_engine_url,\n            )\n        if settings_update.default_embedding_option is not None:\n            from typing import Literal, cast\n\n            settings.default_embedding_option = cast(\n                Literal[\"ask\", \"always\", \"never\"],\n                settings_update.default_embedding_option,\n            )\n        if settings_update.auto_delete_files is not None:\n            from typing import Literal, cast\n\n            settings.auto_delete_files = cast(\n                Literal[\"yes\", \"no\"], settings_update.auto_delete_files\n            )\n        if settings_update.youtube_preferred_languages is not None:\n            settings.youtube_preferred_languages = (\n                settings_update.youtube_preferred_languages\n            )\n\n        await settings.update()\n\n        return SettingsResponse(\n            default_content_processing_engine_doc=settings.default_content_processing_engine_doc,\n            default_content_processing_engine_url=settings.default_content_processing_engine_url,\n            default_embedding_option=settings.default_embedding_option,\n            auto_delete_files=settings.auto_delete_files,\n            youtube_preferred_languages=settings.youtube_preferred_languages,\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error updating settings: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=\"Error updating settings\"\n        )\n"
  },
  {
    "path": "api/routers/source_chat.py",
    "content": "import asyncio\nimport json\nfrom typing import AsyncGenerator, List, Optional\n\nfrom fastapi import APIRouter, HTTPException, Path\nfrom fastapi.responses import StreamingResponse\nfrom langchain_core.messages import HumanMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom loguru import logger\nfrom pydantic import BaseModel, Field\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.notebook import ChatSession, Source\nfrom open_notebook.exceptions import (\n    NotFoundError,\n)\nfrom open_notebook.graphs.source_chat import source_chat_graph as source_chat_graph\nfrom open_notebook.utils.graph_utils import get_session_message_count\n\nrouter = APIRouter()\n\n\n# Request/Response models\nclass CreateSourceChatSessionRequest(BaseModel):\n    source_id: str = Field(..., description=\"Source ID to create chat session for\")\n    title: Optional[str] = Field(None, description=\"Optional session title\")\n    model_override: Optional[str] = Field(\n        None, description=\"Optional model override for this session\"\n    )\n\nclass UpdateSourceChatSessionRequest(BaseModel):\n    title: Optional[str] = Field(None, description=\"New session title\")\n    model_override: Optional[str] = Field(\n        None, description=\"Model override for this session\"\n    )\n\nclass ChatMessage(BaseModel):\n    id: str = Field(..., description=\"Message ID\")\n    type: str = Field(..., description=\"Message type (human|ai)\")\n    content: str = Field(..., description=\"Message content\")\n    timestamp: Optional[str] = Field(None, description=\"Message timestamp\")\n\n\nclass ContextIndicator(BaseModel):\n    sources: List[str] = Field(\n        default_factory=list, description=\"Source IDs used in context\"\n    )\n    insights: List[str] = Field(\n        default_factory=list, description=\"Insight IDs used in context\"\n    )\n    notes: List[str] = Field(\n        default_factory=list, description=\"Note IDs used in context\"\n    )\n\nclass SourceChatSessionResponse(BaseModel):\n    id: str = Field(..., description=\"Session ID\")\n    title: str = Field(..., description=\"Session title\")\n    source_id: str = Field(..., description=\"Source ID\")\n    model_override: Optional[str] = Field(\n        None, description=\"Model override for this session\"\n    )\n    created: str = Field(..., description=\"Creation timestamp\")\n    updated: str = Field(..., description=\"Last update timestamp\")\n    message_count: Optional[int] = Field(\n        None, description=\"Number of messages in session\"\n    )\n\nclass SourceChatSessionWithMessagesResponse(SourceChatSessionResponse):\n    messages: List[ChatMessage] = Field(\n        default_factory=list, description=\"Session messages\"\n    )\n    context_indicators: Optional[ContextIndicator] = Field(\n        None, description=\"Context indicators from last response\"\n    )\n\nclass SendMessageRequest(BaseModel):\n    message: str = Field(..., description=\"User message content\")\n    model_override: Optional[str] = Field(\n        None, description=\"Optional model override for this message\"\n    )\n\nclass SuccessResponse(BaseModel):\n    success: bool = Field(True, description=\"Operation success status\")\n    message: str = Field(..., description=\"Success message\")\n\n\n@router.post(\n    \"/sources/{source_id}/chat/sessions\", response_model=SourceChatSessionResponse\n)\nasync def create_source_chat_session(\n    request: CreateSourceChatSessionRequest,\n    source_id: str = Path(..., description=\"Source ID\"),\n):\n    \"\"\"Create a new chat session for a source.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Create new session with model_override support\n        session = ChatSession(\n            title=request.title or f\"Source Chat {asyncio.get_event_loop().time():.0f}\",\n            model_override=request.model_override,\n        )\n        await session.save()\n\n        # Relate session to source using \"refers_to\" relation\n        await session.relate(\"refers_to\", full_source_id)\n\n        return SourceChatSessionResponse(\n            id=session.id or \"\",\n            title=session.title or \"Untitled Session\",\n            source_id=source_id,\n            model_override=session.model_override,\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=0,\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Source not found\")\n    except Exception as e:\n        logger.error(f\"Error creating source chat session: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error creating source chat session: {str(e)}\"\n        )\n\n\n@router.get(\n    \"/sources/{source_id}/chat/sessions\", response_model=List[SourceChatSessionResponse]\n)\nasync def get_source_chat_sessions(source_id: str = Path(..., description=\"Source ID\")):\n    \"\"\"Get all chat sessions for a source.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Get sessions that refer to this source - first get relations, then sessions\n        relations = await repo_query(\n            \"SELECT in FROM refers_to WHERE out = $source_id\",\n            {\"source_id\": ensure_record_id(full_source_id)},\n        )\n\n        sessions = []\n        for relation in relations:\n            session_id_raw = relation.get(\"in\")\n            if session_id_raw:\n                session_id = str(session_id_raw)\n\n                session_result = await repo_query(f\"SELECT * FROM {session_id_raw}\")\n                if session_result and len(session_result) > 0:\n                    session_data = session_result[0]\n\n                    # Get message count from LangGraph state\n                    msg_count = await get_session_message_count(\n                        source_chat_graph, session_id\n                    )\n\n                    sessions.append(\n                        SourceChatSessionResponse(\n                            id=session_data.get(\"id\") or \"\",\n                            title=session_data.get(\"title\") or \"Untitled Session\",\n                            source_id=source_id,\n                            model_override=session_data.get(\"model_override\"),\n                            created=str(session_data.get(\"created\")),\n                            updated=str(session_data.get(\"updated\")),\n                            message_count=msg_count,\n                        )\n                    )\n\n        # Sort sessions by created date (newest first)\n        sessions.sort(key=lambda x: x.created, reverse=True)\n        return sessions\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Source not found\")\n    except Exception as e:\n        logger.error(f\"Error fetching source chat sessions: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching source chat sessions: {str(e)}\"\n        )\n\n\n@router.get(\n    \"/sources/{source_id}/chat/sessions/{session_id}\",\n    response_model=SourceChatSessionWithMessagesResponse,\n)\nasync def get_source_chat_session(\n    source_id: str = Path(..., description=\"Source ID\"),\n    session_id: str = Path(..., description=\"Session ID\"),\n):\n    \"\"\"Get a specific source chat session with its messages.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Get session\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Verify session is related to this source\n        relation_query = await repo_query(\n            \"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id\",\n            {\n                \"session_id\": ensure_record_id(full_session_id),\n                \"source_id\": ensure_record_id(full_source_id),\n            },\n        )\n\n        if not relation_query:\n            raise HTTPException(\n                status_code=404, detail=\"Session not found for this source\"\n            )\n\n        # Get session state from LangGraph to retrieve messages\n        # Use sync get_state() in a thread since SqliteSaver doesn't support async\n        thread_state = await asyncio.to_thread(\n            source_chat_graph.get_state,\n            config=RunnableConfig(configurable={\"thread_id\": full_session_id}),\n        )\n\n        # Extract messages from state\n        messages: list[ChatMessage] = []\n        context_indicators = None\n\n        if thread_state and thread_state.values:\n            # Extract messages\n            if \"messages\" in thread_state.values:\n                for msg in thread_state.values[\"messages\"]:\n                    messages.append(\n                        ChatMessage(\n                            id=getattr(msg, \"id\", f\"msg_{len(messages)}\"),\n                            type=msg.type if hasattr(msg, \"type\") else \"unknown\",\n                            content=msg.content\n                            if hasattr(msg, \"content\")\n                            else str(msg),\n                            timestamp=None,  # LangChain messages don't have timestamps by default\n                        )\n                    )\n\n            # Extract context indicators from the last state\n            if \"context_indicators\" in thread_state.values:\n                context_data = thread_state.values[\"context_indicators\"]\n                context_indicators = ContextIndicator(\n                    sources=context_data.get(\"sources\", []),\n                    insights=context_data.get(\"insights\", []),\n                    notes=context_data.get(\"notes\", []),\n                )\n\n        return SourceChatSessionWithMessagesResponse(\n            id=session.id or \"\",\n            title=session.title or \"Untitled Session\",\n            source_id=source_id,\n            model_override=getattr(session, \"model_override\", None),\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=len(messages),\n            messages=messages,\n            context_indicators=context_indicators,\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Source or session not found\")\n    except Exception as e:\n        logger.error(f\"Error fetching source chat session: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching source chat session: {str(e)}\"\n        )\n\n\n@router.put(\n    \"/sources/{source_id}/chat/sessions/{session_id}\",\n    response_model=SourceChatSessionResponse,\n)\nasync def update_source_chat_session(\n    request: UpdateSourceChatSessionRequest,\n    source_id: str = Path(..., description=\"Source ID\"),\n    session_id: str = Path(..., description=\"Session ID\"),\n):\n    \"\"\"Update source chat session title and/or model override.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Get session\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Verify session is related to this source\n        relation_query = await repo_query(\n            \"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id\",\n            {\n                \"session_id\": ensure_record_id(full_session_id),\n                \"source_id\": ensure_record_id(full_source_id),\n            },\n        )\n\n        if not relation_query:\n            raise HTTPException(\n                status_code=404, detail=\"Session not found for this source\"\n            )\n\n        # Update session fields\n        if request.title is not None:\n            session.title = request.title\n        if request.model_override is not None:\n            session.model_override = request.model_override\n\n        await session.save()\n\n        # Get message count from LangGraph state\n        msg_count = await get_session_message_count(source_chat_graph, full_session_id)\n\n        return SourceChatSessionResponse(\n            id=session.id or \"\",\n            title=session.title or \"Untitled Session\",\n            source_id=source_id,\n            model_override=getattr(session, \"model_override\", None),\n            created=str(session.created),\n            updated=str(session.updated),\n            message_count=msg_count,\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Source or session not found\")\n    except Exception as e:\n        logger.error(f\"Error updating source chat session: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error updating source chat session: {str(e)}\"\n        )\n\n\n@router.delete(\n    \"/sources/{source_id}/chat/sessions/{session_id}\", response_model=SuccessResponse\n)\nasync def delete_source_chat_session(\n    source_id: str = Path(..., description=\"Source ID\"),\n    session_id: str = Path(..., description=\"Session ID\"),\n):\n    \"\"\"Delete a source chat session.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Get session\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Verify session is related to this source\n        relation_query = await repo_query(\n            \"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id\",\n            {\n                \"session_id\": ensure_record_id(full_session_id),\n                \"source_id\": ensure_record_id(full_source_id),\n            },\n        )\n\n        if not relation_query:\n            raise HTTPException(\n                status_code=404, detail=\"Session not found for this source\"\n            )\n\n        await session.delete()\n\n        return SuccessResponse(\n            success=True, message=\"Source chat session deleted successfully\"\n        )\n    except NotFoundError:\n        raise HTTPException(status_code=404, detail=\"Source or session not found\")\n    except Exception as e:\n        logger.error(f\"Error deleting source chat session: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error deleting source chat session: {str(e)}\"\n        )\n\n\nasync def stream_source_chat_response(\n    session_id: str, source_id: str, message: str, model_override: Optional[str] = None\n) -> AsyncGenerator[str, None]:\n    \"\"\"Stream the source chat response as Server-Sent Events.\"\"\"\n    try:\n        # Get current state\n        # Use sync get_state() in a thread since SqliteSaver doesn't support async\n        current_state = await asyncio.to_thread(\n            source_chat_graph.get_state,\n            config=RunnableConfig(configurable={\"thread_id\": session_id}),\n        )\n\n        # Prepare state for execution\n        state_values = current_state.values if current_state else {}\n        state_values[\"messages\"] = state_values.get(\"messages\", [])\n        state_values[\"source_id\"] = source_id\n        state_values[\"model_override\"] = model_override\n\n        # Add user message to state\n        user_message = HumanMessage(content=message)\n        state_values[\"messages\"].append(user_message)\n\n        # Send user message event\n        user_event = {\"type\": \"user_message\", \"content\": message, \"timestamp\": None}\n        yield f\"data: {json.dumps(user_event)}\\n\\n\"\n\n        # Execute source chat graph synchronously (like notebook chat does)\n        result = source_chat_graph.invoke(\n            input=state_values,  # type: ignore[arg-type]\n            config=RunnableConfig(\n                configurable={\"thread_id\": session_id, \"model_id\": model_override}\n            ),\n        )\n\n        # Stream the complete AI response\n        if \"messages\" in result:\n            for msg in result[\"messages\"]:\n                if hasattr(msg, \"type\") and msg.type == \"ai\":\n                    ai_event = {\n                        \"type\": \"ai_message\",\n                        \"content\": msg.content if hasattr(msg, \"content\") else str(msg),\n                        \"timestamp\": None,\n                    }\n                    yield f\"data: {json.dumps(ai_event)}\\n\\n\"\n\n        # Stream context indicators\n        if \"context_indicators\" in result:\n            context_event = {\n                \"type\": \"context_indicators\",\n                \"data\": result[\"context_indicators\"],\n            }\n            yield f\"data: {json.dumps(context_event)}\\n\\n\"\n\n        # Send completion signal\n        completion_event = {\"type\": \"complete\"}\n        yield f\"data: {json.dumps(completion_event)}\\n\\n\"\n\n    except Exception as e:\n        from open_notebook.utils.error_classifier import classify_error\n\n        _, user_message = classify_error(e)\n        logger.error(f\"Error in source chat streaming: {str(e)}\")\n        error_event = {\"type\": \"error\", \"message\": user_message}\n        yield f\"data: {json.dumps(error_event)}\\n\\n\"\n\n\n@router.post(\"/sources/{source_id}/chat/sessions/{session_id}/messages\")\nasync def send_message_to_source_chat(\n    request: SendMessageRequest,\n    source_id: str = Path(..., description=\"Source ID\"),\n    session_id: str = Path(..., description=\"Session ID\"),\n):\n    \"\"\"Send a message to source chat session with SSE streaming response.\"\"\"\n    try:\n        # Verify source exists\n        full_source_id = (\n            source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n        )\n        source = await Source.get(full_source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Verify session exists and is related to source\n        full_session_id = (\n            session_id\n            if session_id.startswith(\"chat_session:\")\n            else f\"chat_session:{session_id}\"\n        )\n        session = await ChatSession.get(full_session_id)\n        if not session:\n            raise HTTPException(status_code=404, detail=\"Session not found\")\n\n        # Verify session is related to this source\n        relation_query = await repo_query(\n            \"SELECT * FROM refers_to WHERE in = $session_id AND out = $source_id\",\n            {\n                \"session_id\": ensure_record_id(full_session_id),\n                \"source_id\": ensure_record_id(full_source_id),\n            },\n        )\n\n        if not relation_query:\n            raise HTTPException(\n                status_code=404, detail=\"Session not found for this source\"\n            )\n\n        if not request.message:\n            raise HTTPException(status_code=400, detail=\"Message content is required\")\n\n        # Determine model override (request override takes precedence over session override)\n        model_override = request.model_override or getattr(\n            session, \"model_override\", None\n        )\n\n        # Update session timestamp\n        await session.save()\n\n        # Return streaming response\n        return StreamingResponse(\n            stream_source_chat_response(\n                session_id=full_session_id,\n                source_id=full_source_id,\n                message=request.message,\n                model_override=model_override,\n            ),\n            media_type=\"text/plain\",\n            headers={\n                \"Cache-Control\": \"no-cache\",\n                \"Connection\": \"keep-alive\",\n                \"Content-Type\": \"text/plain; charset=utf-8\",\n            },\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error sending message to source chat: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error sending message: {str(e)}\")\n"
  },
  {
    "path": "api/routers/sources.py",
    "content": "import asyncio\nimport os\nfrom pathlib import Path\nfrom typing import Any, List, Optional\n\nfrom fastapi import (\n    APIRouter,\n    Depends,\n    File,\n    Form,\n    HTTPException,\n    Query,\n    UploadFile,\n)\nfrom fastapi.responses import FileResponse, Response\nfrom loguru import logger\nfrom surreal_commands import execute_command_sync, submit_command\n\nfrom api.command_service import CommandService\nfrom api.models import (\n    AssetModel,\n    CreateSourceInsightRequest,\n    InsightCreationResponse,\n    SourceCreate,\n    SourceInsightResponse,\n    SourceListResponse,\n    SourceResponse,\n    SourceStatusResponse,\n    SourceUpdate,\n)\nfrom commands.source_commands import SourceProcessingInput\nfrom open_notebook.config import UPLOADS_FOLDER\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.notebook import Notebook, Source\nfrom open_notebook.domain.transformation import Transformation\nfrom open_notebook.exceptions import InvalidInputError\n\nrouter = APIRouter()\n\n\ndef generate_unique_filename(original_filename: str, upload_folder: str) -> str:\n    \"\"\"Generate unique filename like Streamlit app (append counter if file exists).\"\"\"\n    file_path = Path(upload_folder)\n    file_path.mkdir(parents=True, exist_ok=True)\n\n    # Split filename and extension\n    stem = Path(original_filename).stem\n    suffix = Path(original_filename).suffix\n\n    # Check if file exists and generate unique name\n    counter = 0\n    while True:\n        if counter == 0:\n            new_filename = original_filename\n        else:\n            new_filename = f\"{stem} ({counter}){suffix}\"\n\n        full_path = file_path / new_filename\n        if not full_path.exists():\n            return str(full_path)\n        counter += 1\n\n\nasync def save_uploaded_file(upload_file: UploadFile) -> str:\n    \"\"\"Save uploaded file to uploads folder and return file path.\"\"\"\n    if not upload_file.filename:\n        raise ValueError(\"No filename provided\")\n\n    # Generate unique filename\n    file_path = generate_unique_filename(upload_file.filename, UPLOADS_FOLDER)\n\n    try:\n        # Save file\n        with open(file_path, \"wb\") as f:\n            content = await upload_file.read()\n            f.write(content)\n\n        logger.info(f\"Saved uploaded file to: {file_path}\")\n        return file_path\n    except Exception as e:\n        logger.error(f\"Failed to save uploaded file: {e}\")\n        # Clean up partial file if it exists\n        if os.path.exists(file_path):\n            os.unlink(file_path)\n        raise\n\n\ndef parse_source_form_data(\n    type: str = Form(...),\n    notebook_id: Optional[str] = Form(None),\n    notebooks: Optional[str] = Form(None),  # JSON string of notebook IDs\n    url: Optional[str] = Form(None),\n    content: Optional[str] = Form(None),\n    title: Optional[str] = Form(None),\n    transformations: Optional[str] = Form(None),  # JSON string of transformation IDs\n    embed: str = Form(\"false\"),  # Accept as string, convert to bool\n    delete_source: str = Form(\"false\"),  # Accept as string, convert to bool\n    async_processing: str = Form(\"false\"),  # Accept as string, convert to bool\n    file: Optional[UploadFile] = File(None),\n) -> tuple[SourceCreate, Optional[UploadFile]]:\n    \"\"\"Parse form data into SourceCreate model and return upload file separately.\"\"\"\n    import json\n\n    # Convert string booleans to actual booleans\n    def str_to_bool(value: str) -> bool:\n        return value.lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n    embed_bool = str_to_bool(embed)\n    delete_source_bool = str_to_bool(delete_source)\n    async_processing_bool = str_to_bool(async_processing)\n\n    # Parse JSON strings\n    notebooks_list = None\n    if notebooks:\n        try:\n            notebooks_list = json.loads(notebooks)\n        except json.JSONDecodeError:\n            logger.error(f\"Invalid JSON in notebooks field: {notebooks}\")\n            raise ValueError(\"Invalid JSON in notebooks field\")\n\n    transformations_list = []\n    if transformations:\n        try:\n            transformations_list = json.loads(transformations)\n        except json.JSONDecodeError:\n            logger.error(f\"Invalid JSON in transformations field: {transformations}\")\n            raise ValueError(\"Invalid JSON in transformations field\")\n\n    # Create SourceCreate instance\n    try:\n        source_data = SourceCreate(\n            type=type,\n            notebook_id=notebook_id,\n            notebooks=notebooks_list,\n            url=url,\n            content=content,\n            title=title,\n            file_path=None,  # Will be set later if file is uploaded\n            transformations=transformations_list,\n            embed=embed_bool,\n            delete_source=delete_source_bool,\n            async_processing=async_processing_bool,\n        )\n        pass  # SourceCreate instance created successfully\n    except Exception as e:\n        logger.error(f\"Failed to create SourceCreate instance: {e}\")\n        raise\n\n    return source_data, file\n\n\n@router.get(\"/sources\", response_model=List[SourceListResponse])\nasync def get_sources(\n    notebook_id: Optional[str] = Query(None, description=\"Filter by notebook ID\"),\n    limit: int = Query(\n        50, ge=1, le=100, description=\"Number of sources to return (1-100)\"\n    ),\n    offset: int = Query(0, ge=0, description=\"Number of sources to skip\"),\n    sort_by: str = Query(\n        \"updated\", description=\"Field to sort by (created or updated)\"\n    ),\n    sort_order: str = Query(\"desc\", description=\"Sort order (asc or desc)\"),\n):\n    \"\"\"Get sources with pagination and sorting support.\"\"\"\n    try:\n        # Validate sort parameters\n        if sort_by not in [\"created\", \"updated\"]:\n            raise HTTPException(\n                status_code=400, detail=\"sort_by must be 'created' or 'updated'\"\n            )\n        if sort_order.lower() not in [\"asc\", \"desc\"]:\n            raise HTTPException(\n                status_code=400, detail=\"sort_order must be 'asc' or 'desc'\"\n            )\n\n        # Build ORDER BY clause\n        order_clause = f\"ORDER BY {sort_by} {sort_order.upper()}\"\n\n        # Build the query\n        if notebook_id:\n            # Verify notebook exists first\n            notebook = await Notebook.get(notebook_id)\n            if not notebook:\n                raise HTTPException(status_code=404, detail=\"Notebook not found\")\n\n            # Query sources for specific notebook - include command field with FETCH\n            query = f\"\"\"\n                SELECT id, asset, created, title, updated, topics, command,\n                (SELECT VALUE count() FROM source_insight WHERE source = $parent.id GROUP ALL)[0].count OR 0 AS insights_count,\n                (SELECT VALUE id FROM source_embedding WHERE source = $parent.id LIMIT 1) != [] AS embedded\n                FROM (select value in from reference where out=$notebook_id)\n                {order_clause}\n                LIMIT $limit START $offset\n                FETCH command\n            \"\"\"\n            result = await repo_query(\n                query,\n                {\n                    \"notebook_id\": ensure_record_id(notebook_id),\n                    \"limit\": limit,\n                    \"offset\": offset,\n                },\n            )\n        else:\n            # Query all sources - include command field with FETCH\n            query = f\"\"\"\n                SELECT id, asset, created, title, updated, topics, command,\n                (SELECT VALUE count() FROM source_insight WHERE source = $parent.id GROUP ALL)[0].count OR 0 AS insights_count,\n                (SELECT VALUE id FROM source_embedding WHERE source = $parent.id LIMIT 1) != [] AS embedded\n                FROM source\n                {order_clause}\n                LIMIT $limit START $offset\n                FETCH command\n            \"\"\"\n            result = await repo_query(query, {\"limit\": limit, \"offset\": offset})\n\n        # Convert result to response model\n        # Command data is already fetched via FETCH command clause\n        response_list = []\n        for row in result:\n            command = row.get(\"command\")\n            command_id = None\n            status = None\n            processing_info = None\n\n            # Extract status from fetched command object (already resolved by FETCH)\n            if command and isinstance(command, dict):\n                command_id = str(command.get(\"id\")) if command.get(\"id\") else None\n                status = command.get(\"status\")\n                # Extract execution metadata from nested result structure\n                result_data = command.get(\"result\")\n                execution_metadata = (\n                    result_data.get(\"execution_metadata\", {})\n                    if isinstance(result_data, dict)\n                    else {}\n                )\n                processing_info = {\n                    \"started_at\": execution_metadata.get(\"started_at\"),\n                    \"completed_at\": execution_metadata.get(\"completed_at\"),\n                    \"error\": command.get(\"error_message\"),\n                }\n            elif command:\n                # Command exists but FETCH failed to resolve it (broken reference)\n                command_id = str(command)\n                status = \"unknown\"\n\n            response_list.append(\n                SourceListResponse(\n                    id=row[\"id\"],\n                    title=row.get(\"title\"),\n                    topics=row.get(\"topics\") or [],\n                    asset=AssetModel(\n                        file_path=row[\"asset\"].get(\"file_path\")\n                        if row.get(\"asset\")\n                        else None,\n                        url=row[\"asset\"].get(\"url\") if row.get(\"asset\") else None,\n                    )\n                    if row.get(\"asset\")\n                    else None,\n                    embedded=row.get(\"embedded\", False),\n                    embedded_chunks=0,  # Not needed in list view\n                    insights_count=row.get(\"insights_count\", 0),\n                    created=str(row[\"created\"]),\n                    updated=str(row[\"updated\"]),\n                    # Status fields from fetched command\n                    command_id=command_id,\n                    status=status,\n                    processing_info=processing_info,\n                )\n            )\n\n        return response_list\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching sources: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching sources: {str(e)}\")\n\n\n@router.post(\"/sources\", response_model=SourceResponse)\nasync def create_source(\n    form_data: tuple[SourceCreate, Optional[UploadFile]] = Depends(\n        parse_source_form_data\n    ),\n):\n    \"\"\"Create a new source with support for both JSON and multipart form data.\"\"\"\n    source_data, upload_file = form_data\n\n    # Initialize file_path before try block so exception handlers can reference it\n    file_path = None\n\n    try:\n        # Verify all specified notebooks exist (backward compatibility support)\n        for notebook_id in source_data.notebooks or []:\n            notebook = await Notebook.get(notebook_id)\n            if not notebook:\n                raise HTTPException(\n                    status_code=404, detail=f\"Notebook {notebook_id} not found\"\n                )\n\n        # Handle file upload if provided\n        if upload_file and source_data.type == \"upload\":\n            try:\n                file_path = await save_uploaded_file(upload_file)\n            except Exception as e:\n                logger.error(f\"File upload failed: {e}\")\n                raise HTTPException(\n                    status_code=400, detail=f\"File upload failed: {str(e)}\"\n                )\n\n        # Prepare content_state for processing\n        content_state: dict[str, Any] = {}\n\n        if source_data.type == \"link\":\n            if not source_data.url:\n                raise HTTPException(\n                    status_code=400, detail=\"URL is required for link type\"\n                )\n            content_state[\"url\"] = source_data.url\n        elif source_data.type == \"upload\":\n            # Use uploaded file path or provided file_path (backward compatibility)\n            final_file_path = file_path or source_data.file_path\n            if not final_file_path:\n                raise HTTPException(\n                    status_code=400,\n                    detail=\"File upload or file_path is required for upload type\",\n                )\n            content_state[\"file_path\"] = final_file_path\n            content_state[\"delete_source\"] = source_data.delete_source\n        elif source_data.type == \"text\":\n            if not source_data.content:\n                raise HTTPException(\n                    status_code=400, detail=\"Content is required for text type\"\n                )\n            content_state[\"content\"] = source_data.content\n        else:\n            raise HTTPException(\n                status_code=400,\n                detail=\"Invalid source type. Must be link, upload, or text\",\n            )\n\n        # Validate transformations exist\n        transformation_ids = source_data.transformations or []\n        for trans_id in transformation_ids:\n            transformation = await Transformation.get(trans_id)\n            if not transformation:\n                raise HTTPException(\n                    status_code=404, detail=f\"Transformation {trans_id} not found\"\n                )\n\n        # Branch based on processing mode\n        if source_data.async_processing:\n            # ASYNC PATH: Create source record first, then queue command\n            logger.info(\"Using async processing path\")\n\n            # Create minimal source record - let SurrealDB generate the ID\n            source = Source(\n                title=source_data.title or \"Processing...\",\n                topics=[],\n            )\n            await source.save()\n\n            # Add source to notebooks immediately so it appears in the UI\n            # The source_graph will skip adding duplicates\n            for notebook_id in source_data.notebooks or []:\n                await source.add_to_notebook(notebook_id)\n\n            try:\n                # Import command modules to ensure they're registered\n                import commands.source_commands  # noqa: F401\n\n                # Submit command for background processing\n                command_input = SourceProcessingInput(\n                    source_id=str(source.id),\n                    content_state=content_state,\n                    notebook_ids=source_data.notebooks,\n                    transformations=transformation_ids,\n                    embed=source_data.embed,\n                )\n\n                command_id = await CommandService.submit_command_job(\n                    \"open_notebook\",  # app name\n                    \"process_source\",  # command name\n                    command_input.model_dump(),\n                )\n\n                logger.info(f\"Submitted async processing command: {command_id}\")\n\n                # Update source with command reference immediately\n                # command_id already includes 'command:' prefix\n                source.command = ensure_record_id(command_id)\n                await source.save()\n\n                # Return source with command info\n                return SourceResponse(\n                    id=source.id or \"\",\n                    title=source.title,\n                    topics=source.topics or [],\n                    asset=None,  # Will be populated after processing\n                    full_text=None,  # Will be populated after processing\n                    embedded=False,  # Will be updated after processing\n                    embedded_chunks=0,\n                    created=str(source.created),\n                    updated=str(source.updated),\n                    command_id=command_id,\n                    status=\"new\",\n                    processing_info={\"async\": True, \"queued\": True},\n                )\n\n            except Exception as e:\n                logger.error(f\"Failed to submit async processing command: {e}\")\n                # Clean up source record on command submission failure\n                try:\n                    await source.delete()\n                except Exception:\n                    pass\n                # Clean up uploaded file if we created it\n                if file_path and upload_file:\n                    try:\n                        os.unlink(file_path)\n                    except Exception:\n                        pass\n                raise HTTPException(\n                    status_code=500, detail=f\"Failed to queue processing: {str(e)}\"\n                )\n\n        else:\n            # SYNC PATH: Execute synchronously using execute_command_sync\n            logger.info(\"Using sync processing path\")\n\n            try:\n                # Import command modules to ensure they're registered\n                import commands.source_commands  # noqa: F401\n\n                # Create source record - let SurrealDB generate the ID\n                source = Source(\n                    title=source_data.title or \"Processing...\",\n                    topics=[],\n                )\n                await source.save()\n\n                # Add source to notebooks immediately so it appears in the UI\n                # The source_graph will skip adding duplicates\n                for notebook_id in source_data.notebooks or []:\n                    await source.add_to_notebook(notebook_id)\n\n                # Execute command synchronously\n                command_input = SourceProcessingInput(\n                    source_id=str(source.id),\n                    content_state=content_state,\n                    notebook_ids=source_data.notebooks,\n                    transformations=transformation_ids,\n                    embed=source_data.embed,\n                )\n\n                # Run in thread pool to avoid blocking the event loop\n                # execute_command_sync uses asyncio.run() internally which can't\n                # be called from an already-running event loop (FastAPI)\n                result = await asyncio.to_thread(\n                    execute_command_sync,\n                    \"open_notebook\",  # app name\n                    \"process_source\",  # command name\n                    command_input.model_dump(),\n                    timeout=300,  # 5 minute timeout for sync processing\n                )\n\n                if not result.is_success():\n                    logger.error(f\"Sync processing failed: {result.error_message}\")\n                    # Clean up source record\n                    try:\n                        await source.delete()\n                    except Exception:\n                        pass\n                    # Clean up uploaded file if we created it\n                    if file_path and upload_file:\n                        try:\n                            os.unlink(file_path)\n                        except Exception:\n                            pass\n                    raise HTTPException(\n                        status_code=500,\n                        detail=f\"Processing failed: {result.error_message}\",\n                    )\n\n                # Get the processed source\n                if not source.id:\n                    raise HTTPException(status_code=500, detail=\"Source ID is missing\")\n                processed_source = await Source.get(source.id)\n                if not processed_source:\n                    raise HTTPException(\n                        status_code=500, detail=\"Processed source not found\"\n                    )\n\n                embedded_chunks = await processed_source.get_embedded_chunks()\n                return SourceResponse(\n                    id=processed_source.id or \"\",\n                    title=processed_source.title,\n                    topics=processed_source.topics or [],\n                    asset=AssetModel(\n                        file_path=processed_source.asset.file_path\n                        if processed_source.asset\n                        else None,\n                        url=processed_source.asset.url\n                        if processed_source.asset\n                        else None,\n                    )\n                    if processed_source.asset\n                    else None,\n                    full_text=processed_source.full_text,\n                    embedded=embedded_chunks > 0,\n                    embedded_chunks=embedded_chunks,\n                    created=str(processed_source.created),\n                    updated=str(processed_source.updated),\n                    # No command_id or status for sync processing (legacy behavior)\n                )\n\n            except Exception as e:\n                logger.error(f\"Sync processing failed: {e}\")\n                # Clean up uploaded file if we created it\n                if file_path and upload_file:\n                    try:\n                        os.unlink(file_path)\n                    except Exception:\n                        pass\n                raise\n\n    except HTTPException:\n        # Clean up uploaded file on HTTP exceptions if we created it\n        if file_path and upload_file:\n            try:\n                os.unlink(file_path)\n            except Exception:\n                pass\n        raise\n    except InvalidInputError as e:\n        # Clean up uploaded file on validation errors if we created it\n        if file_path and upload_file:\n            try:\n                os.unlink(file_path)\n            except Exception:\n                pass\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error creating source: {str(e)}\")\n        # Clean up uploaded file on unexpected errors if we created it\n        if file_path and upload_file:\n            try:\n                os.unlink(file_path)\n            except Exception:\n                pass\n        raise HTTPException(status_code=500, detail=f\"Error creating source: {str(e)}\")\n\n\n@router.post(\"/sources/json\", response_model=SourceResponse)\nasync def create_source_json(source_data: SourceCreate):\n    \"\"\"Create a new source using JSON payload (legacy endpoint for backward compatibility).\"\"\"\n    # Convert to form data format and call main endpoint\n    form_data = (source_data, None)\n    return await create_source(form_data)\n\n\nasync def _resolve_source_file(source_id: str) -> tuple[str, str]:\n    source = await Source.get(source_id)\n    if not source:\n        raise HTTPException(status_code=404, detail=\"Source not found\")\n\n    file_path = source.asset.file_path if source.asset else None\n    if not file_path:\n        raise HTTPException(status_code=404, detail=\"Source has no file to download\")\n\n    safe_root = os.path.realpath(UPLOADS_FOLDER)\n    resolved_path = os.path.realpath(file_path)\n\n    if not resolved_path.startswith(safe_root):\n        logger.warning(\n            f\"Blocked download outside uploads directory for source {source_id}: {resolved_path}\"\n        )\n        raise HTTPException(status_code=403, detail=\"Access to file denied\")\n\n    if not os.path.exists(resolved_path):\n        raise HTTPException(status_code=404, detail=\"File not found on server\")\n\n    filename = os.path.basename(resolved_path)\n    return resolved_path, filename\n\n\ndef _is_source_file_available(source: Source) -> Optional[bool]:\n    if not source or not source.asset or not source.asset.file_path:\n        return None\n\n    file_path = source.asset.file_path\n    safe_root = os.path.realpath(UPLOADS_FOLDER)\n    resolved_path = os.path.realpath(file_path)\n\n    if not resolved_path.startswith(safe_root):\n        return False\n\n    return os.path.exists(resolved_path)\n\n\n@router.get(\"/sources/{source_id}\", response_model=SourceResponse)\nasync def get_source(source_id: str):\n    \"\"\"Get a specific source by ID.\"\"\"\n    try:\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Get status information if command exists\n        status = None\n        processing_info = None\n        if source.command:\n            try:\n                status = await source.get_status()\n                processing_info = await source.get_processing_progress()\n            except Exception as e:\n                logger.warning(f\"Failed to get status for source {source_id}: {e}\")\n                status = \"unknown\"\n\n        embedded_chunks = await source.get_embedded_chunks()\n\n        # Get associated notebooks\n        notebooks_query = await repo_query(\n            \"SELECT VALUE out FROM reference WHERE in = $source_id\",\n            {\"source_id\": ensure_record_id(source.id or source_id)},\n        )\n        notebook_ids = (\n            [str(nb_id) for nb_id in notebooks_query] if notebooks_query else []\n        )\n\n        return SourceResponse(\n            id=source.id or \"\",\n            title=source.title,\n            topics=source.topics or [],\n            asset=AssetModel(\n                file_path=source.asset.file_path if source.asset else None,\n                url=source.asset.url if source.asset else None,\n            )\n            if source.asset\n            else None,\n            full_text=source.full_text,\n            embedded=embedded_chunks > 0,\n            embedded_chunks=embedded_chunks,\n            file_available=_is_source_file_available(source),\n            created=str(source.created),\n            updated=str(source.updated),\n            # Status fields\n            command_id=str(source.command) if source.command else None,\n            status=status,\n            processing_info=processing_info,\n            # Notebook associations\n            notebooks=notebook_ids,\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching source {source_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error fetching source: {str(e)}\")\n\n\n@router.head(\"/sources/{source_id}/download\")\nasync def check_source_file(source_id: str):\n    \"\"\"Check if a source has a downloadable file.\"\"\"\n    try:\n        await _resolve_source_file(source_id)\n        return Response(status_code=200)\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error checking file for source {source_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=\"Failed to verify file\")\n\n\n@router.get(\"/sources/{source_id}/download\")\nasync def download_source_file(source_id: str):\n    \"\"\"Download the original file associated with an uploaded source.\"\"\"\n    try:\n        resolved_path, filename = await _resolve_source_file(source_id)\n        return FileResponse(\n            path=resolved_path,\n            filename=filename,\n            media_type=\"application/octet-stream\",\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error downloading file for source {source_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=\"Failed to download source file\")\n\n\n@router.get(\"/sources/{source_id}/status\", response_model=SourceStatusResponse)\nasync def get_source_status(source_id: str):\n    \"\"\"Get processing status for a source.\"\"\"\n    try:\n        # First, verify source exists\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Check if this is a legacy source (no command)\n        if not source.command:\n            return SourceStatusResponse(\n                status=None,\n                message=\"Legacy source (completed before async processing)\",\n                processing_info=None,\n                command_id=None,\n            )\n\n        # Get command status and processing info\n        try:\n            status = await source.get_status()\n            processing_info = await source.get_processing_progress()\n\n            # Generate descriptive message based on status\n            if status == \"completed\":\n                message = \"Source processing completed successfully\"\n            elif status == \"failed\":\n                message = \"Source processing failed\"\n            elif status == \"running\":\n                message = \"Source processing in progress\"\n            elif status == \"queued\":\n                message = \"Source processing queued\"\n            elif status == \"unknown\":\n                message = \"Source processing status unknown\"\n            else:\n                message = f\"Source processing status: {status}\"\n\n            return SourceStatusResponse(\n                status=status,\n                message=message,\n                processing_info=processing_info,\n                command_id=str(source.command) if source.command else None,\n            )\n\n        except Exception as e:\n            logger.warning(f\"Failed to get status for source {source_id}: {e}\")\n            return SourceStatusResponse(\n                status=\"unknown\",\n                message=\"Failed to retrieve processing status\",\n                processing_info=None,\n                command_id=str(source.command) if source.command else None,\n            )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching status for source {source_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching source status: {str(e)}\"\n        )\n\n\n@router.put(\"/sources/{source_id}\", response_model=SourceResponse)\nasync def update_source(source_id: str, source_update: SourceUpdate):\n    \"\"\"Update a source.\"\"\"\n    try:\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Update only provided fields\n        if source_update.title is not None:\n            source.title = source_update.title\n        if source_update.topics is not None:\n            source.topics = source_update.topics\n\n        await source.save()\n\n        embedded_chunks = await source.get_embedded_chunks()\n        return SourceResponse(\n            id=source.id or \"\",\n            title=source.title,\n            topics=source.topics or [],\n            asset=AssetModel(\n                file_path=source.asset.file_path if source.asset else None,\n                url=source.asset.url if source.asset else None,\n            )\n            if source.asset\n            else None,\n            full_text=source.full_text,\n            embedded=embedded_chunks > 0,\n            embedded_chunks=embedded_chunks,\n            created=str(source.created),\n            updated=str(source.updated),\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error updating source {source_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error updating source: {str(e)}\")\n\n\n@router.post(\"/sources/{source_id}/retry\", response_model=SourceResponse)\nasync def retry_source_processing(source_id: str):\n    \"\"\"Retry processing for a failed or stuck source.\"\"\"\n    try:\n        # First, verify source exists\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Check if source already has a running command\n        if source.command:\n            try:\n                status = await source.get_status()\n                if status in [\"running\", \"queued\"]:\n                    raise HTTPException(\n                        status_code=400,\n                        detail=\"Source is already processing. Cannot retry while processing is active.\",\n                    )\n            except Exception as e:\n                logger.warning(\n                    f\"Failed to check current status for source {source_id}: {e}\"\n                )\n                # Continue with retry if we can't check status\n\n        # Get notebooks that this source belongs to\n        query = \"SELECT notebook FROM reference WHERE source = $source_id\"\n        references = await repo_query(query, {\"source_id\": source_id})\n        notebook_ids = [str(ref[\"notebook\"]) for ref in references]\n\n        if not notebook_ids:\n            raise HTTPException(\n                status_code=400, detail=\"Source is not associated with any notebooks\"\n            )\n\n        # Prepare content_state based on source asset\n        content_state = {}\n        if source.asset:\n            if source.asset.file_path:\n                content_state = {\n                    \"file_path\": source.asset.file_path,\n                    \"delete_source\": False,  # Don't delete on retry\n                }\n            elif source.asset.url:\n                content_state = {\"url\": source.asset.url}\n            else:\n                raise HTTPException(\n                    status_code=400, detail=\"Source asset has no file_path or url\"\n                )\n        else:\n            # Check if it's a text source by trying to get full_text\n            if source.full_text:\n                content_state = {\"content\": source.full_text}\n            else:\n                raise HTTPException(\n                    status_code=400, detail=\"Cannot determine source content for retry\"\n                )\n\n        try:\n            # Import command modules to ensure they're registered\n            import commands.source_commands  # noqa: F401\n\n            # Submit new command for background processing\n            command_input = SourceProcessingInput(\n                source_id=str(source.id),\n                content_state=content_state,\n                notebook_ids=notebook_ids,\n                transformations=[],  # Use default transformations on retry\n                embed=True,  # Always embed on retry\n            )\n\n            command_id = await CommandService.submit_command_job(\n                \"open_notebook\",  # app name\n                \"process_source\",  # command name\n                command_input.model_dump(),\n            )\n\n            logger.info(\n                f\"Submitted retry processing command: {command_id} for source {source_id}\"\n            )\n\n            # Update source with new command ID\n            source.command = ensure_record_id(f\"command:{command_id}\")\n            await source.save()\n\n            # Get current embedded chunks count\n            embedded_chunks = await source.get_embedded_chunks()\n\n            # Return updated source response\n            return SourceResponse(\n                id=source.id or \"\",\n                title=source.title,\n                topics=source.topics or [],\n                asset=AssetModel(\n                    file_path=source.asset.file_path if source.asset else None,\n                    url=source.asset.url if source.asset else None,\n                )\n                if source.asset\n                else None,\n                full_text=source.full_text,\n                embedded=embedded_chunks > 0,\n                embedded_chunks=embedded_chunks,\n                created=str(source.created),\n                updated=str(source.updated),\n                command_id=command_id,\n                status=\"queued\",\n                processing_info={\"retry\": True, \"queued\": True},\n            )\n\n        except Exception as e:\n            logger.error(\n                f\"Failed to submit retry processing command for source {source_id}: {e}\"\n            )\n            raise HTTPException(\n                status_code=500, detail=f\"Failed to queue retry processing: {str(e)}\"\n            )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error retrying source processing for {source_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error retrying source processing: {str(e)}\"\n        )\n\n\n@router.delete(\"/sources/{source_id}\")\nasync def delete_source(source_id: str):\n    \"\"\"Delete a source.\"\"\"\n    try:\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        await source.delete()\n\n        return {\"message\": \"Source deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting source {source_id}: {str(e)}\")\n        raise HTTPException(status_code=500, detail=f\"Error deleting source: {str(e)}\")\n\n\n@router.get(\"/sources/{source_id}/insights\", response_model=List[SourceInsightResponse])\nasync def get_source_insights(source_id: str):\n    \"\"\"Get all insights for a specific source.\"\"\"\n    try:\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        insights = await source.get_insights()\n        return [\n            SourceInsightResponse(\n                id=insight.id or \"\",\n                source_id=source_id,\n                insight_type=insight.insight_type,\n                content=insight.content,\n                created=str(insight.created),\n                updated=str(insight.updated),\n            )\n            for insight in insights\n        ]\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching insights for source {source_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching insights: {str(e)}\"\n        )\n\n\n@router.post(\n    \"/sources/{source_id}/insights\",\n    response_model=InsightCreationResponse,\n    status_code=202,\n)\nasync def create_source_insight(source_id: str, request: CreateSourceInsightRequest):\n    \"\"\"\n    Start insight generation for a source by running a transformation.\n\n    This endpoint returns immediately with a 202 Accepted status.\n    The transformation runs asynchronously in the background via the job queue.\n    Poll GET /sources/{source_id}/insights to see when the insight is ready.\n    \"\"\"\n    try:\n        # Validate source exists\n        source = await Source.get(source_id)\n        if not source:\n            raise HTTPException(status_code=404, detail=\"Source not found\")\n\n        # Validate transformation exists\n        transformation = await Transformation.get(request.transformation_id)\n        if not transformation:\n            raise HTTPException(status_code=404, detail=\"Transformation not found\")\n\n        # Submit transformation as background job (fire-and-forget)\n        command_id = submit_command(\n            \"open_notebook\",\n            \"run_transformation\",\n            {\n                \"source_id\": source_id,\n                \"transformation_id\": request.transformation_id,\n            },\n        )\n        logger.info(\n            f\"Submitted run_transformation command {command_id} for source {source_id}\"\n        )\n\n        # Return immediately with command_id for status tracking\n        return InsightCreationResponse(\n            status=\"pending\",\n            message=\"Insight generation started\",\n            source_id=source_id,\n            transformation_id=request.transformation_id,\n            command_id=str(command_id),\n        )\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error starting insight generation for source {source_id}: {e}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error starting insight generation: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/routers/speaker_profiles.py",
    "content": "from typing import Any, Dict, List, Optional\n\nfrom fastapi import APIRouter, HTTPException\nfrom loguru import logger\nfrom pydantic import BaseModel, Field\n\nfrom open_notebook.podcasts.models import SpeakerProfile\n\nrouter = APIRouter()\n\n\nclass SpeakerProfileResponse(BaseModel):\n    id: str\n    name: str\n    description: str\n    voice_model: Optional[str] = None\n    speakers: List[Dict[str, Any]]\n    # Legacy fields (for display/migration awareness)\n    tts_provider: Optional[str] = None\n    tts_model: Optional[str] = None\n\n\ndef _profile_to_response(profile: SpeakerProfile) -> SpeakerProfileResponse:\n    return SpeakerProfileResponse(\n        id=str(profile.id),\n        name=profile.name,\n        description=profile.description or \"\",\n        voice_model=profile.voice_model,\n        speakers=profile.speakers,\n        tts_provider=profile.tts_provider,\n        tts_model=profile.tts_model,\n    )\n\n\n@router.get(\"/speaker-profiles\", response_model=List[SpeakerProfileResponse])\nasync def list_speaker_profiles():\n    \"\"\"List all available speaker profiles\"\"\"\n    try:\n        profiles = await SpeakerProfile.get_all(order_by=\"name asc\")\n        return [_profile_to_response(p) for p in profiles]\n    except Exception as e:\n        logger.error(f\"Failed to fetch speaker profiles: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch speaker profiles\"\n        )\n\n\n@router.get(\"/speaker-profiles/{profile_name}\", response_model=SpeakerProfileResponse)\nasync def get_speaker_profile(profile_name: str):\n    \"\"\"Get a specific speaker profile by name\"\"\"\n    try:\n        profile = await SpeakerProfile.get_by_name(profile_name)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Speaker profile '{profile_name}' not found\"\n            )\n\n        return _profile_to_response(profile)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to fetch speaker profile '{profile_name}': {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to fetch speaker profile\"\n        )\n\n\nclass SpeakerProfileCreate(BaseModel):\n    name: str = Field(..., description=\"Unique profile name\")\n    description: str = Field(\"\", description=\"Profile description\")\n    voice_model: Optional[str] = Field(None, description=\"Model record ID for TTS\")\n    speakers: List[Dict[str, Any]] = Field(\n        ..., description=\"Array of speaker configurations\"\n    )\n    # Legacy fields (accepted but not required)\n    tts_provider: Optional[str] = None\n    tts_model: Optional[str] = None\n\n\n@router.post(\"/speaker-profiles\", response_model=SpeakerProfileResponse)\nasync def create_speaker_profile(profile_data: SpeakerProfileCreate):\n    \"\"\"Create a new speaker profile\"\"\"\n    try:\n        profile = SpeakerProfile(\n            name=profile_data.name,\n            description=profile_data.description,\n            voice_model=profile_data.voice_model,\n            speakers=profile_data.speakers,\n            tts_provider=profile_data.tts_provider,\n            tts_model=profile_data.tts_model,\n        )\n\n        await profile.save()\n        return _profile_to_response(profile)\n\n    except Exception as e:\n        logger.error(f\"Failed to create speaker profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to create speaker profile\"\n        )\n\n\n@router.put(\"/speaker-profiles/{profile_id}\", response_model=SpeakerProfileResponse)\nasync def update_speaker_profile(profile_id: str, profile_data: SpeakerProfileCreate):\n    \"\"\"Update an existing speaker profile\"\"\"\n    try:\n        profile = await SpeakerProfile.get(profile_id)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Speaker profile '{profile_id}' not found\"\n            )\n\n        profile.name = profile_data.name\n        profile.description = profile_data.description\n        profile.voice_model = profile_data.voice_model\n        profile.speakers = profile_data.speakers\n        profile.tts_provider = profile_data.tts_provider\n        profile.tts_model = profile_data.tts_model\n\n        await profile.save()\n        return _profile_to_response(profile)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to update speaker profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to update speaker profile\"\n        )\n\n\n@router.delete(\"/speaker-profiles/{profile_id}\")\nasync def delete_speaker_profile(profile_id: str):\n    \"\"\"Delete a speaker profile\"\"\"\n    try:\n        profile = await SpeakerProfile.get(profile_id)\n\n        if not profile:\n            raise HTTPException(\n                status_code=404, detail=f\"Speaker profile '{profile_id}' not found\"\n            )\n\n        await profile.delete()\n\n        return {\"message\": \"Speaker profile deleted successfully\"}\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to delete speaker profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to delete speaker profile\"\n        )\n\n\n@router.post(\n    \"/speaker-profiles/{profile_id}/duplicate\", response_model=SpeakerProfileResponse\n)\nasync def duplicate_speaker_profile(profile_id: str):\n    \"\"\"Duplicate a speaker profile\"\"\"\n    try:\n        original = await SpeakerProfile.get(profile_id)\n\n        if not original:\n            raise HTTPException(\n                status_code=404, detail=f\"Speaker profile '{profile_id}' not found\"\n            )\n\n        duplicate = SpeakerProfile(\n            name=f\"{original.name} - Copy\",\n            description=original.description,\n            voice_model=original.voice_model,\n            speakers=original.speakers,\n            tts_provider=original.tts_provider,\n            tts_model=original.tts_model,\n        )\n\n        await duplicate.save()\n        return _profile_to_response(duplicate)\n\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Failed to duplicate speaker profile: {e}\")\n        raise HTTPException(\n            status_code=500, detail=\"Failed to duplicate speaker profile\"\n        )\n"
  },
  {
    "path": "api/routers/transformations.py",
    "content": "from typing import List\n\nfrom fastapi import APIRouter, HTTPException\nfrom loguru import logger\n\nfrom api.models import (\n    DefaultPromptResponse,\n    DefaultPromptUpdate,\n    TransformationCreate,\n    TransformationExecuteRequest,\n    TransformationExecuteResponse,\n    TransformationResponse,\n    TransformationUpdate,\n)\nfrom open_notebook.ai.models import Model\nfrom open_notebook.domain.transformation import DefaultPrompts, Transformation\nfrom open_notebook.exceptions import InvalidInputError, OpenNotebookError\nfrom open_notebook.graphs.transformation import graph as transformation_graph\n\nrouter = APIRouter()\n\n\n@router.get(\"/transformations\", response_model=List[TransformationResponse])\nasync def get_transformations():\n    \"\"\"Get all transformations.\"\"\"\n    try:\n        transformations = await Transformation.get_all(order_by=\"name asc\")\n\n        return [\n            TransformationResponse(\n                id=transformation.id or \"\",\n                name=transformation.name,\n                title=transformation.title,\n                description=transformation.description,\n                prompt=transformation.prompt,\n                apply_default=transformation.apply_default,\n                created=str(transformation.created),\n                updated=str(transformation.updated),\n            )\n            for transformation in transformations\n        ]\n    except Exception as e:\n        logger.error(f\"Error fetching transformations: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching transformations: {str(e)}\"\n        )\n\n\n@router.post(\"/transformations\", response_model=TransformationResponse)\nasync def create_transformation(transformation_data: TransformationCreate):\n    \"\"\"Create a new transformation.\"\"\"\n    try:\n        new_transformation = Transformation(\n            name=transformation_data.name,\n            title=transformation_data.title,\n            description=transformation_data.description,\n            prompt=transformation_data.prompt,\n            apply_default=transformation_data.apply_default,\n        )\n        await new_transformation.save()\n\n        return TransformationResponse(\n            id=new_transformation.id or \"\",\n            name=new_transformation.name,\n            title=new_transformation.title,\n            description=new_transformation.description,\n            prompt=new_transformation.prompt,\n            apply_default=new_transformation.apply_default,\n            created=str(new_transformation.created),\n            updated=str(new_transformation.updated),\n        )\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error creating transformation: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error creating transformation: {str(e)}\"\n        )\n\n\n@router.post(\"/transformations/execute\", response_model=TransformationExecuteResponse)\nasync def execute_transformation(execute_request: TransformationExecuteRequest):\n    \"\"\"Execute a transformation on input text.\"\"\"\n    try:\n        # Validate transformation exists\n        transformation = await Transformation.get(execute_request.transformation_id)\n        if not transformation:\n            raise HTTPException(status_code=404, detail=\"Transformation not found\")\n\n        # Validate model exists\n        model = await Model.get(execute_request.model_id)\n        if not model:\n            raise HTTPException(status_code=404, detail=\"Model not found\")\n\n        # Execute the transformation\n        result = await transformation_graph.ainvoke(\n            dict(  # type: ignore[arg-type]\n                input_text=execute_request.input_text,\n                transformation=transformation,\n            ),\n            config=dict(configurable={\"model_id\": execute_request.model_id}),\n        )\n\n        return TransformationExecuteResponse(\n            output=result[\"output\"],\n            transformation_id=execute_request.transformation_id,\n            model_id=execute_request.model_id,\n        )\n\n    except HTTPException:\n        raise\n    except OpenNotebookError:\n        raise  # Let global exception handlers return proper status codes\n    except Exception as e:\n        logger.error(f\"Error executing transformation: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error executing transformation: {str(e)}\"\n        )\n\n\n@router.get(\"/transformations/default-prompt\", response_model=DefaultPromptResponse)\nasync def get_default_prompt():\n    \"\"\"Get the default transformation prompt.\"\"\"\n    try:\n        default_prompts: DefaultPrompts = await DefaultPrompts.get_instance()  # type: ignore[assignment]\n\n        return DefaultPromptResponse(\n            transformation_instructions=default_prompts.transformation_instructions\n            or \"\"\n        )\n    except Exception as e:\n        logger.error(f\"Error fetching default prompt: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching default prompt: {str(e)}\"\n        )\n\n\n@router.put(\"/transformations/default-prompt\", response_model=DefaultPromptResponse)\nasync def update_default_prompt(prompt_update: DefaultPromptUpdate):\n    \"\"\"Update the default transformation prompt.\"\"\"\n    try:\n        default_prompts: DefaultPrompts = await DefaultPrompts.get_instance()  # type: ignore[assignment]\n\n        default_prompts.transformation_instructions = (\n            prompt_update.transformation_instructions\n        )\n        await default_prompts.update()\n\n        return DefaultPromptResponse(\n            transformation_instructions=default_prompts.transformation_instructions\n        )\n    except Exception as e:\n        logger.error(f\"Error updating default prompt: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error updating default prompt: {str(e)}\"\n        )\n\n\n@router.get(\n    \"/transformations/{transformation_id}\", response_model=TransformationResponse\n)\nasync def get_transformation(transformation_id: str):\n    \"\"\"Get a specific transformation by ID.\"\"\"\n    try:\n        transformation = await Transformation.get(transformation_id)\n        if not transformation:\n            raise HTTPException(status_code=404, detail=\"Transformation not found\")\n\n        return TransformationResponse(\n            id=transformation.id or \"\",\n            name=transformation.name,\n            title=transformation.title,\n            description=transformation.description,\n            prompt=transformation.prompt,\n            apply_default=transformation.apply_default,\n            created=str(transformation.created),\n            updated=str(transformation.updated),\n        )\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error fetching transformation {transformation_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error fetching transformation: {str(e)}\"\n        )\n\n\n@router.put(\n    \"/transformations/{transformation_id}\", response_model=TransformationResponse\n)\nasync def update_transformation(\n    transformation_id: str, transformation_update: TransformationUpdate\n):\n    \"\"\"Update a transformation.\"\"\"\n    try:\n        transformation = await Transformation.get(transformation_id)\n        if not transformation:\n            raise HTTPException(status_code=404, detail=\"Transformation not found\")\n\n        # Update only provided fields\n        if transformation_update.name is not None:\n            transformation.name = transformation_update.name\n        if transformation_update.title is not None:\n            transformation.title = transformation_update.title\n        if transformation_update.description is not None:\n            transformation.description = transformation_update.description\n        if transformation_update.prompt is not None:\n            transformation.prompt = transformation_update.prompt\n        if transformation_update.apply_default is not None:\n            transformation.apply_default = transformation_update.apply_default\n\n        await transformation.save()\n\n        return TransformationResponse(\n            id=transformation.id or \"\",\n            name=transformation.name,\n            title=transformation.title,\n            description=transformation.description,\n            prompt=transformation.prompt,\n            apply_default=transformation.apply_default,\n            created=str(transformation.created),\n            updated=str(transformation.updated),\n        )\n    except HTTPException:\n        raise\n    except InvalidInputError as e:\n        raise HTTPException(status_code=400, detail=str(e))\n    except Exception as e:\n        logger.error(f\"Error updating transformation {transformation_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error updating transformation: {str(e)}\"\n        )\n\n\n@router.delete(\"/transformations/{transformation_id}\")\nasync def delete_transformation(transformation_id: str):\n    \"\"\"Delete a transformation.\"\"\"\n    try:\n        transformation = await Transformation.get(transformation_id)\n        if not transformation:\n            raise HTTPException(status_code=404, detail=\"Transformation not found\")\n\n        await transformation.delete()\n\n        return {\"message\": \"Transformation deleted successfully\"}\n    except HTTPException:\n        raise\n    except Exception as e:\n        logger.error(f\"Error deleting transformation {transformation_id}: {str(e)}\")\n        raise HTTPException(\n            status_code=500, detail=f\"Error deleting transformation: {str(e)}\"\n        )\n"
  },
  {
    "path": "api/search_service.py",
    "content": "\"\"\"\nSearch service layer using API.\n\"\"\"\n\nfrom typing import Any, Dict, List, Union\n\nfrom loguru import logger\n\nfrom api.client import api_client\n\n\nclass SearchService:\n    \"\"\"Service layer for search operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for search operations\")\n\n    def search(\n        self,\n        query: str,\n        search_type: str = \"text\",\n        limit: int = 100,\n        search_sources: bool = True,\n        search_notes: bool = True,\n        minimum_score: float = 0.2,\n    ) -> List[Dict[str, Any]]:\n        \"\"\"Search the knowledge base.\"\"\"\n        response = api_client.search(\n            query=query,\n            search_type=search_type,\n            limit=limit,\n            search_sources=search_sources,\n            search_notes=search_notes,\n            minimum_score=minimum_score,\n        )\n        if isinstance(response, dict):\n            return response.get(\"results\", [])\n        return []\n\n    def ask_knowledge_base(\n        self,\n        question: str,\n        strategy_model: str,\n        answer_model: str,\n        final_answer_model: str,\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Ask the knowledge base a question.\"\"\"\n        response = api_client.ask_simple(\n            question=question,\n            strategy_model=strategy_model,\n            answer_model=answer_model,\n            final_answer_model=final_answer_model,\n        )\n        return response\n\n\n# Global service instance\nsearch_service = SearchService()\n"
  },
  {
    "path": "api/settings_service.py",
    "content": "\"\"\"\nSettings service layer using API.\n\"\"\"\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.content_settings import ContentSettings\n\n\nclass SettingsService:\n    \"\"\"Service layer for settings operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for settings operations\")\n\n    def get_settings(self) -> ContentSettings:\n        \"\"\"Get application settings.\"\"\"\n        settings_response = api_client.get_settings()\n        settings_data = (\n            settings_response\n            if isinstance(settings_response, dict)\n            else settings_response[0]\n        )\n\n        # Create ContentSettings object from API response\n        settings = ContentSettings(\n            default_content_processing_engine_doc=settings_data.get(\n                \"default_content_processing_engine_doc\"\n            ),\n            default_content_processing_engine_url=settings_data.get(\n                \"default_content_processing_engine_url\"\n            ),\n            default_embedding_option=settings_data.get(\"default_embedding_option\"),\n            auto_delete_files=settings_data.get(\"auto_delete_files\"),\n            youtube_preferred_languages=settings_data.get(\n                \"youtube_preferred_languages\"\n            ),\n        )\n\n        return settings\n\n    def update_settings(self, settings: ContentSettings) -> ContentSettings:\n        \"\"\"Update application settings.\"\"\"\n        updates = {\n            \"default_content_processing_engine_doc\": settings.default_content_processing_engine_doc,\n            \"default_content_processing_engine_url\": settings.default_content_processing_engine_url,\n            \"default_embedding_option\": settings.default_embedding_option,\n            \"auto_delete_files\": settings.auto_delete_files,\n            \"youtube_preferred_languages\": settings.youtube_preferred_languages,\n        }\n\n        settings_response = api_client.update_settings(**updates)\n        settings_data = (\n            settings_response\n            if isinstance(settings_response, dict)\n            else settings_response[0]\n        )\n\n        # Update the settings object with the response\n        settings.default_content_processing_engine_doc = settings_data.get(\n            \"default_content_processing_engine_doc\"\n        )\n        settings.default_content_processing_engine_url = settings_data.get(\n            \"default_content_processing_engine_url\"\n        )\n        settings.default_embedding_option = settings_data.get(\n            \"default_embedding_option\"\n        )\n        settings.auto_delete_files = settings_data.get(\"auto_delete_files\")\n        settings.youtube_preferred_languages = settings_data.get(\n            \"youtube_preferred_languages\"\n        )\n\n        return settings\n\n\n# Global service instance\nsettings_service = SettingsService()\n"
  },
  {
    "path": "api/sources_service.py",
    "content": "\"\"\"\nSources service layer using API.\n\"\"\"\n\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Optional, Union\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.notebook import Asset, Source\n\n\n@dataclass\nclass SourceProcessingResult:\n    \"\"\"Result of source creation with optional async processing info.\"\"\"\n\n    source: Source\n    is_async: bool = False\n    command_id: Optional[str] = None\n    status: Optional[str] = None\n    processing_info: Optional[Dict] = None\n\n\n@dataclass\nclass SourceWithMetadata:\n    \"\"\"Source object with additional metadata from API.\"\"\"\n\n    source: Source\n    embedded_chunks: int\n\n    # Expose common source properties for easy access\n    @property\n    def id(self):\n        return self.source.id\n\n    @property\n    def title(self):\n        return self.source.title\n\n    @title.setter\n    def title(self, value):\n        self.source.title = value\n\n    @property\n    def topics(self):\n        return self.source.topics\n\n    @property\n    def asset(self):\n        return self.source.asset\n\n    @property\n    def full_text(self):\n        return self.source.full_text\n\n    @property\n    def created(self):\n        return self.source.created\n\n    @property\n    def updated(self):\n        return self.source.updated\n\n\nclass SourcesService:\n    \"\"\"Service layer for sources operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for sources operations\")\n\n    def get_all_sources(\n        self, notebook_id: Optional[str] = None\n    ) -> List[SourceWithMetadata]:\n        \"\"\"Get all sources with optional notebook filtering.\"\"\"\n        sources_data = api_client.get_sources(notebook_id=notebook_id)\n        # Convert API response to SourceWithMetadata objects\n        sources = []\n        for source_data in sources_data:\n            source = Source(\n                title=source_data[\"title\"],\n                topics=source_data[\"topics\"],\n                asset=Asset(\n                    file_path=source_data[\"asset\"][\"file_path\"]\n                    if source_data[\"asset\"]\n                    else None,\n                    url=source_data[\"asset\"][\"url\"] if source_data[\"asset\"] else None,\n                )\n                if source_data[\"asset\"]\n                else None,\n            )\n            source.id = source_data[\"id\"]\n            source.created = source_data[\"created\"]\n            source.updated = source_data[\"updated\"]\n\n            # Wrap in SourceWithMetadata\n            source_with_metadata = SourceWithMetadata(\n                source=source, embedded_chunks=source_data.get(\"embedded_chunks\", 0)\n            )\n            sources.append(source_with_metadata)\n        return sources\n\n    def get_source(self, source_id: str) -> SourceWithMetadata:\n        \"\"\"Get a specific source.\"\"\"\n        response = api_client.get_source(source_id)\n        source_data = response if isinstance(response, dict) else response[0]\n        source = Source(\n            title=source_data[\"title\"],\n            topics=source_data[\"topics\"],\n            full_text=source_data[\"full_text\"],\n            asset=Asset(\n                file_path=source_data[\"asset\"][\"file_path\"]\n                if source_data[\"asset\"]\n                else None,\n                url=source_data[\"asset\"][\"url\"] if source_data[\"asset\"] else None,\n            )\n            if source_data[\"asset\"]\n            else None,\n        )\n        source.id = source_data[\"id\"]\n        source.created = source_data[\"created\"]\n        source.updated = source_data[\"updated\"]\n\n        return SourceWithMetadata(\n            source=source, embedded_chunks=source_data.get(\"embedded_chunks\", 0)\n        )\n\n    def create_source(\n        self,\n        notebook_id: Optional[str] = None,\n        source_type: str = \"text\",\n        url: Optional[str] = None,\n        file_path: Optional[str] = None,\n        content: Optional[str] = None,\n        title: Optional[str] = None,\n        transformations: Optional[List[str]] = None,\n        embed: bool = False,\n        delete_source: bool = False,\n        notebooks: Optional[List[str]] = None,\n        async_processing: bool = False,\n    ) -> Union[Source, SourceProcessingResult]:\n        \"\"\"\n        Create a new source with support for async processing.\n\n        Args:\n            notebook_id: Single notebook ID (deprecated, use notebooks parameter)\n            source_type: Type of source (link, upload, text)\n            url: URL for link sources\n            file_path: File path for upload sources\n            content: Text content for text sources\n            title: Optional source title\n            transformations: List of transformation IDs to apply\n            embed: Whether to embed content for vector search\n            delete_source: Whether to delete uploaded file after processing\n            notebooks: List of notebook IDs to add source to (preferred over notebook_id)\n            async_processing: Whether to process source asynchronously\n\n        Returns:\n            Source object for sync processing (backward compatibility)\n            SourceProcessingResult for async processing (contains additional metadata)\n        \"\"\"\n        source_data = api_client.create_source(\n            notebook_id=notebook_id,\n            notebooks=notebooks,\n            source_type=source_type,\n            url=url,\n            file_path=file_path,\n            content=content,\n            title=title,\n            transformations=transformations,\n            embed=embed,\n            delete_source=delete_source,\n            async_processing=async_processing,\n        )\n\n        # Create Source object from response\n        response_data = source_data if isinstance(source_data, dict) else source_data[0]\n        source = Source(\n            title=response_data[\"title\"],\n            topics=response_data.get(\"topics\") or [],\n            full_text=response_data.get(\"full_text\"),\n            asset=Asset(\n                file_path=response_data[\"asset\"][\"file_path\"]\n                if response_data.get(\"asset\")\n                else None,\n                url=response_data[\"asset\"][\"url\"]\n                if response_data.get(\"asset\")\n                else None,\n            )\n            if response_data.get(\"asset\")\n            else None,\n        )\n        source.id = response_data[\"id\"]\n        source.created = response_data[\"created\"]\n        source.updated = response_data[\"updated\"]\n\n        # Check if this is an async processing response\n        if (\n            response_data.get(\"command_id\")\n            or response_data.get(\"status\")\n            or response_data.get(\"processing_info\")\n        ):\n            # Ensure source_data is a dict for accessing attributes\n            source_data_dict = (\n                source_data if isinstance(source_data, dict) else source_data[0]\n            )\n            # Return enhanced result for async processing\n            return SourceProcessingResult(\n                source=source,\n                is_async=True,\n                command_id=source_data_dict.get(\"command_id\"),\n                status=source_data_dict.get(\"status\"),\n                processing_info=source_data_dict.get(\"processing_info\"),\n            )\n        else:\n            # Return simple Source for backward compatibility\n            return source\n\n    def get_source_status(self, source_id: str) -> Dict:\n        \"\"\"Get processing status for a source.\"\"\"\n        response = api_client.get_source_status(source_id)\n        return response if isinstance(response, dict) else response[0]\n\n    def create_source_async(\n        self,\n        notebook_id: Optional[str] = None,\n        source_type: str = \"text\",\n        url: Optional[str] = None,\n        file_path: Optional[str] = None,\n        content: Optional[str] = None,\n        title: Optional[str] = None,\n        transformations: Optional[List[str]] = None,\n        embed: bool = False,\n        delete_source: bool = False,\n        notebooks: Optional[List[str]] = None,\n    ) -> SourceProcessingResult:\n        \"\"\"\n        Create a new source with async processing enabled.\n\n        This is a convenience method that always uses async processing.\n        Returns a SourceProcessingResult with processing status information.\n        \"\"\"\n        result = self.create_source(\n            notebook_id=notebook_id,\n            notebooks=notebooks,\n            source_type=source_type,\n            url=url,\n            file_path=file_path,\n            content=content,\n            title=title,\n            transformations=transformations,\n            embed=embed,\n            delete_source=delete_source,\n            async_processing=True,\n        )\n\n        # Since we forced async_processing=True, this should always be a SourceProcessingResult\n        if isinstance(result, SourceProcessingResult):\n            return result\n        else:\n            # Fallback: wrap Source in SourceProcessingResult\n            return SourceProcessingResult(\n                source=result,\n                is_async=False,  # This shouldn't happen, but handle it gracefully\n            )\n\n    def is_source_processing_complete(self, source_id: str) -> bool:\n        \"\"\"\n        Check if a source's async processing is complete.\n\n        Returns True if processing is complete (success or failure),\n        False if still processing or queued.\n        \"\"\"\n        try:\n            status_data = self.get_source_status(source_id)\n            status = status_data.get(\"status\")\n            return status in [\n                \"completed\",\n                \"failed\",\n                None,\n            ]  # None indicates legacy/sync source\n        except Exception as e:\n            logger.error(f\"Error checking source processing status: {e}\")\n            return True  # Assume complete on error\n\n    def update_source(self, source: Source) -> Source:\n        \"\"\"Update a source.\"\"\"\n        if not source.id:\n            raise ValueError(\"Source ID is required for update\")\n\n        updates = {\n            \"title\": source.title,\n            \"topics\": source.topics,\n        }\n        source_data = api_client.update_source(source.id, **updates)\n\n        # Ensure source_data is a dict\n        source_data_dict = (\n            source_data if isinstance(source_data, dict) else source_data[0]\n        )\n\n        # Update the source object with the response\n        source.title = source_data_dict[\"title\"]\n        source.topics = source_data_dict[\"topics\"]\n        source.updated = source_data_dict[\"updated\"]\n\n        return source\n\n    def delete_source(self, source_id: str) -> bool:\n        \"\"\"Delete a source.\"\"\"\n        api_client.delete_source(source_id)\n        return True\n\n\n# Global service instance\nsources_service = SourcesService()\n\n# Export important classes for easy importing\n__all__ = [\n    \"SourcesService\",\n    \"SourceWithMetadata\",\n    \"SourceProcessingResult\",\n    \"sources_service\",\n]\n"
  },
  {
    "path": "api/transformations_service.py",
    "content": "\"\"\"\nTransformations service layer using API.\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Union\n\nfrom loguru import logger\n\nfrom api.client import api_client\nfrom open_notebook.domain.transformation import Transformation\n\n\nclass TransformationsService:\n    \"\"\"Service layer for transformations operations using API.\"\"\"\n\n    def __init__(self):\n        logger.info(\"Using API for transformations operations\")\n\n    def get_all_transformations(self) -> List[Transformation]:\n        \"\"\"Get all transformations.\"\"\"\n        transformations_data = api_client.get_transformations()\n        # Convert API response to Transformation objects\n        transformations = []\n        for trans_data in transformations_data:\n            transformation = Transformation(\n                name=trans_data[\"name\"],\n                title=trans_data[\"title\"],\n                description=trans_data[\"description\"],\n                prompt=trans_data[\"prompt\"],\n                apply_default=trans_data[\"apply_default\"],\n            )\n            transformation.id = trans_data[\"id\"]\n            transformation.created = datetime.fromisoformat(\n                trans_data[\"created\"].replace(\"Z\", \"+00:00\")\n            )\n            transformation.updated = datetime.fromisoformat(\n                trans_data[\"updated\"].replace(\"Z\", \"+00:00\")\n            )\n            transformations.append(transformation)\n        return transformations\n\n    def get_transformation(self, transformation_id: str) -> Transformation:\n        \"\"\"Get a specific transformation.\"\"\"\n        response = api_client.get_transformation(transformation_id)\n        trans_data = response if isinstance(response, dict) else response[0]\n        transformation = Transformation(\n            name=trans_data[\"name\"],\n            title=trans_data[\"title\"],\n            description=trans_data[\"description\"],\n            prompt=trans_data[\"prompt\"],\n            apply_default=trans_data[\"apply_default\"],\n        )\n        transformation.id = trans_data[\"id\"]\n        transformation.created = datetime.fromisoformat(\n            trans_data[\"created\"].replace(\"Z\", \"+00:00\")\n        )\n        transformation.updated = datetime.fromisoformat(\n            trans_data[\"updated\"].replace(\"Z\", \"+00:00\")\n        )\n        return transformation\n\n    def create_transformation(\n        self,\n        name: str,\n        title: str,\n        description: str,\n        prompt: str,\n        apply_default: bool = False,\n    ) -> Transformation:\n        \"\"\"Create a new transformation.\"\"\"\n        response = api_client.create_transformation(\n            name=name,\n            title=title,\n            description=description,\n            prompt=prompt,\n            apply_default=apply_default,\n        )\n        trans_data = response if isinstance(response, dict) else response[0]\n        transformation = Transformation(\n            name=trans_data[\"name\"],\n            title=trans_data[\"title\"],\n            description=trans_data[\"description\"],\n            prompt=trans_data[\"prompt\"],\n            apply_default=trans_data[\"apply_default\"],\n        )\n        transformation.id = trans_data[\"id\"]\n        transformation.created = datetime.fromisoformat(\n            trans_data[\"created\"].replace(\"Z\", \"+00:00\")\n        )\n        transformation.updated = datetime.fromisoformat(\n            trans_data[\"updated\"].replace(\"Z\", \"+00:00\")\n        )\n        return transformation\n\n    def update_transformation(self, transformation: Transformation) -> Transformation:\n        \"\"\"Update a transformation.\"\"\"\n        if not transformation.id:\n            raise ValueError(\"Transformation ID is required for update\")\n\n        updates = {\n            \"name\": transformation.name,\n            \"title\": transformation.title,\n            \"description\": transformation.description,\n            \"prompt\": transformation.prompt,\n            \"apply_default\": transformation.apply_default,\n        }\n        response = api_client.update_transformation(transformation.id, **updates)\n        trans_data = response if isinstance(response, dict) else response[0]\n\n        # Update the transformation object with the response\n        transformation.name = trans_data[\"name\"]\n        transformation.title = trans_data[\"title\"]\n        transformation.description = trans_data[\"description\"]\n        transformation.prompt = trans_data[\"prompt\"]\n        transformation.apply_default = trans_data[\"apply_default\"]\n        transformation.updated = datetime.fromisoformat(\n            trans_data[\"updated\"].replace(\"Z\", \"+00:00\")\n        )\n\n        return transformation\n\n    def delete_transformation(self, transformation_id: str) -> bool:\n        \"\"\"Delete a transformation.\"\"\"\n        api_client.delete_transformation(transformation_id)\n        return True\n\n    def execute_transformation(\n        self, transformation_id: str, input_text: str, model_id: str\n    ) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:\n        \"\"\"Execute a transformation on input text.\"\"\"\n        result = api_client.execute_transformation(\n            transformation_id=transformation_id,\n            input_text=input_text,\n            model_id=model_id,\n        )\n        return result\n\n\n# Global service instance\ntransformations_service = TransformationsService()\n"
  },
  {
    "path": "commands/CLAUDE.md",
    "content": "# Commands Module\n\n**Purpose**: Defines async command handlers for long-running operations via `surreal-commands` job queue system.\n\n## Key Components\n\n### Embedding Commands\n\n- **`embed_note_command`**: Embeds a single note using unified embedding pipeline with content-type aware processing. Uses MARKDOWN content type detection. Retry: 5 attempts, exponential jitter 1-60s.\n- **`embed_insight_command`**: Embeds a single source insight. Uses MARKDOWN content type. Retry: 5 attempts, exponential jitter 1-60s.\n- **`embed_source_command`**: Embeds a source by chunking full_text with content-type aware splitters (HTML, Markdown, plain), then batch embedding all chunks (batches of 50 with per-batch retry). Retry: 5 attempts, exponential jitter 1-60s.\n- **`create_insight_command`**: Creates a source insight with automatic retry on transaction conflicts. Creates the DB record, then submits `embed_insight` command (fire-and-forget). Retry: 5 attempts, exponential jitter 1-60s. Used by `Source.add_insight()`.\n- **`rebuild_embeddings_command`**: Submits individual embed_* commands for all sources/notes/insights. Returns immediately; actual embedding happens async. No retry (coordinator only).\n\n### Other Commands\n\n- **`process_source_command`**: Ingests content through `source_graph`, creates embeddings (optional), and generates insights. Retries on transaction conflicts (exp. jitter, max 15×, 1-120s).\n- **`run_transformation_command`**: Runs a transformation on an existing source to generate an insight. Executes the transformation graph (LLM call) then creates insight via `create_insight_command`. Used by `POST /sources/{id}/insights` API endpoint. Retry: 5 attempts, exponential jitter 1-60s.\n- **`generate_podcast_command`**: Creates podcasts via podcast-creator library. Resolves model registry references and credentials for all profiles before invoking podcast-creator. Validates that outline_llm, transcript_llm, and voice_model are configured.\n- **`process_text_command`** (example): Test fixture for text operations (uppercase, lowercase, reverse, word_count).\n- **`analyze_data_command`** (example): Test fixture for numeric aggregations.\n\n## Important Patterns\n\n- **Pydantic I/O**: All commands use `CommandInput`/`CommandOutput` subclasses for type safety and serialization.\n- **Error handling**: Permanent errors (ValueError) return failure output; all other exceptions auto-retry via surreal-commands.\n- **Retry configuration**: Uses `stop_on: [ValueError]` (blocklist approach) - retries all exceptions EXCEPT ValueError. This is more resilient than allowlist as new exception types auto-retry.\n- **Fire-and-forget embedding**: Domain models submit embed_* commands via `submit_command()` without waiting. Commands process asynchronously.\n- **Content-type aware chunking**: `embed_source_command` uses `chunk_text()` with automatic content type detection (HTML, Markdown, plain text) for optimal text splitting. Default: 1500 char chunks with 225 char overlap.\n- **Batch embedding**: `embed_source_command` uses `generate_embeddings()` which automatically batches texts (default 50) with per-batch retry to avoid exceeding provider payload limits.\n- **Mean pooling for large content**: `embed_note_command` and `embed_insight_command` use `generate_embedding()` which handles content larger than chunk size via mean pooling.\n- **Model dumping**: Recursive `full_model_dump()` utility converts Pydantic models → dicts for DB/API responses.\n- **Logging**: Uses `loguru.logger` throughout; logs execution start/end and key metrics (processing time, counts).\n- **Time tracking**: All commands measure `start_time` → `processing_time` for monitoring.\n\n## Dependencies\n\n**External**: `surreal_commands` (command decorator, job queue, submit_command), `loguru`, `pydantic`, `podcast_creator`\n**Internal**: `open_notebook.domain.notebook` (Source, Note, SourceInsight), `open_notebook.utils.chunking` (chunk_text, detect_content_type), `open_notebook.utils.embedding` (generate_embedding, generate_embeddings), `open_notebook.database.repository` (repo_query, repo_insert)\n\n## Quirks & Edge Cases\n\n- **source_commands**: `ensure_record_id()` wraps command IDs for DB storage; transaction conflicts trigger exponential backoff retry. ValueError exceptions are permanent (not retried).\n- **embedding_commands**: Content type detection uses file extension as primary source, heuristics as fallback. Chunks >1800 chars trigger secondary splitting. Empty/whitespace-only content returns ValueError (not retried).\n- **rebuild_embeddings_command**: Returns \"jobs_submitted\" not \"processed_items\" - embedding is async. Individual commands handle failures with their own retries.\n- **podcast_commands**: Profiles loaded from SurrealDB by name; model configs (credentials) resolved for ALL profiles before podcast-creator validation. Validates outline_llm/transcript_llm/voice_model are set. Episode records created mid-execution.\n- **Example commands**: Accept optional `delay_seconds` for testing async behavior; not for production.\n\n## Code Example\n\n```python\n@command(\"process_source\", app=\"open_notebook\", retry={\n    \"max_attempts\": 5,\n    \"wait_strategy\": \"exponential_jitter\",\n    \"stop_on\": [ValueError],  # Don't retry validation errors\n})\nasync def process_source_command(input_data: SourceProcessingInput) -> SourceProcessingOutput:\n    start_time = time.time()\n    try:\n        transformations = [await Transformation.get(id) for id in input_data.transformations]\n        source = await Source.get(input_data.source_id)\n        result = await source_graph.ainvoke({...})\n        return SourceProcessingOutput(success=True, ...)\n    except ValueError as e:\n        return SourceProcessingOutput(success=False, error_message=str(e))  # No retry\n    except Exception as e:\n        raise  # Retry all other exceptions\n```\n"
  },
  {
    "path": "commands/__init__.py",
    "content": "\"\"\"Surreal-commands integration for Open Notebook\"\"\"\n\nfrom .embedding_commands import (\n    embed_insight_command,\n    embed_note_command,\n    embed_source_command,\n    rebuild_embeddings_command,\n)\nfrom .example_commands import analyze_data_command, process_text_command\nfrom .podcast_commands import generate_podcast_command\nfrom .source_commands import process_source_command\n\n__all__ = [\n    # Embedding commands\n    \"embed_note_command\",\n    \"embed_insight_command\",\n    \"embed_source_command\",\n    \"rebuild_embeddings_command\",\n    # Other commands\n    \"generate_podcast_command\",\n    \"process_source_command\",\n    \"process_text_command\",\n    \"analyze_data_command\",\n]\n"
  },
  {
    "path": "commands/embedding_commands.py",
    "content": "import time\nfrom typing import Dict, List, Literal, Optional\n\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom surreal_commands import CommandInput, CommandOutput, command, submit_command\n\nfrom open_notebook.ai.models import model_manager\nfrom open_notebook.database.repository import ensure_record_id, repo_insert, repo_query\nfrom open_notebook.exceptions import ConfigurationError\nfrom open_notebook.domain.notebook import Note, Source, SourceInsight\nfrom open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type\nfrom open_notebook.utils.embedding import generate_embedding, generate_embeddings\n\n\ndef full_model_dump(model):\n    if isinstance(model, BaseModel):\n        return model.model_dump()\n    elif isinstance(model, dict):\n        return {k: full_model_dump(v) for k, v in model.items()}\n    elif isinstance(model, list):\n        return [full_model_dump(item) for item in model]\n    else:\n        return model\n\n\ndef get_command_id(input_data: CommandInput) -> str:\n    \"\"\"Extract command_id from input_data's execution context, or return 'unknown'.\"\"\"\n    if input_data.execution_context:\n        return str(input_data.execution_context.command_id)\n    return \"unknown\"\n\n\nclass RebuildEmbeddingsInput(CommandInput):\n    mode: Literal[\"existing\", \"all\"]\n    include_sources: bool = True\n    include_notes: bool = True\n    include_insights: bool = True\n\n\nclass RebuildEmbeddingsOutput(CommandOutput):\n    success: bool\n    total_items: int\n    jobs_submitted: int  # Count of embedding commands submitted\n    failed_submissions: int  # Count of items that failed to submit\n    sources_submitted: int = 0\n    notes_submitted: int = 0\n    insights_submitted: int = 0\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n# =============================================================================\n# NEW EMBEDDING COMMANDS (Phase 3)\n# =============================================================================\n\n\nclass CreateInsightInput(CommandInput):\n    \"\"\"Input for creating a source insight with automatic retry on conflicts.\"\"\"\n\n    source_id: str\n    insight_type: str\n    content: str\n\n\nclass CreateInsightOutput(CommandOutput):\n    \"\"\"Output from insight creation command.\"\"\"\n\n    success: bool\n    insight_id: Optional[str] = None\n    processing_time: float\n    error_message: Optional[str] = None\n\n\nclass EmbedNoteInput(CommandInput):\n    \"\"\"Input for embedding a single note.\"\"\"\n\n    note_id: str\n\n\nclass EmbedNoteOutput(CommandOutput):\n    \"\"\"Output from note embedding command.\"\"\"\n\n    success: bool\n    note_id: str\n    processing_time: float\n    error_message: Optional[str] = None\n\n\nclass EmbedInsightInput(CommandInput):\n    \"\"\"Input for embedding a single source insight.\"\"\"\n\n    insight_id: str\n\n\nclass EmbedInsightOutput(CommandOutput):\n    \"\"\"Output from insight embedding command.\"\"\"\n\n    success: bool\n    insight_id: str\n    processing_time: float\n    error_message: Optional[str] = None\n\n\nclass EmbedSourceInput(CommandInput):\n    \"\"\"Input for embedding a source (creates multiple chunk embeddings).\"\"\"\n\n    source_id: str\n\n\nclass EmbedSourceOutput(CommandOutput):\n    \"\"\"Output from source embedding command.\"\"\"\n\n    success: bool\n    source_id: str\n    chunks_created: int\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n@command(\n    \"embed_note\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 5,\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 60,\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",\n    },\n)\nasync def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:\n    \"\"\"\n    Generate and store embedding for a single note.\n\n    Uses the unified embedding pipeline with automatic chunking and mean pooling\n    for notes that exceed the chunk size limit.\n\n    Flow:\n    1. Load Note by ID\n    2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)\n    3. UPSERT note embedding in database\n\n    Retry Strategy:\n    - Retries up to 5 times for transient failures (network, timeout, etc.)\n    - Uses exponential-jitter backoff (1-60s)\n    - Does NOT retry permanent failures (ValueError for validation errors)\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(f\"Starting embedding for note: {input_data.note_id}\")\n\n        # 1. Load note\n        note = await Note.get(input_data.note_id)\n        if not note:\n            raise ValueError(f\"Note '{input_data.note_id}' not found\")\n\n        if not note.content or not note.content.strip():\n            raise ValueError(f\"Note '{input_data.note_id}' has no content to embed\")\n\n        # 2. Generate embedding (auto-chunks + mean pools if needed)\n        # Notes are typically markdown content\n        cmd_id = get_command_id(input_data)\n        embedding = await generate_embedding(\n            note.content, content_type=ContentType.MARKDOWN, command_id=cmd_id\n        )\n\n        # 3. UPSERT embedding into note record\n        await repo_query(\n            \"UPDATE $note_id SET embedding = $embedding\",\n            {\n                \"note_id\": ensure_record_id(input_data.note_id),\n                \"embedding\": embedding,\n            },\n        )\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully embedded note {input_data.note_id} in {processing_time:.2f}s\"\n        )\n\n        return EmbedNoteOutput(\n            success=True,\n            note_id=input_data.note_id,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Permanent failure - don't retry\n        processing_time = time.time() - start_time\n        cmd_id = get_command_id(input_data)\n        logger.error(\n            f\"Failed to embed note {input_data.note_id} (command: {cmd_id}): {e}\"\n        )\n        return EmbedNoteOutput(\n            success=False,\n            note_id=input_data.note_id,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        cmd_id = get_command_id(input_data)\n        logger.debug(\n            f\"Transient error embedding note {input_data.note_id} \"\n            f\"(command: {cmd_id}): {e}\"\n        )\n        raise\n\n\n@command(\n    \"embed_insight\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 5,\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 60,\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",\n    },\n)\nasync def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOutput:\n    \"\"\"\n    Generate and store embedding for a single source insight.\n\n    Uses the unified embedding pipeline with automatic chunking and mean pooling\n    for insights that exceed the chunk size limit.\n\n    Flow:\n    1. Load SourceInsight by ID\n    2. Generate embedding via generate_embedding() (auto-chunks + mean pools if needed)\n    3. UPSERT insight embedding in database\n\n    Retry Strategy:\n    - Retries up to 5 times for transient failures (network, timeout, etc.)\n    - Uses exponential-jitter backoff (1-60s)\n    - Does NOT retry permanent failures (ValueError for validation errors)\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(f\"Starting embedding for insight: {input_data.insight_id}\")\n\n        # 1. Load insight\n        insight = await SourceInsight.get(input_data.insight_id)\n        if not insight:\n            raise ValueError(f\"Insight '{input_data.insight_id}' not found\")\n\n        if not insight.content or not insight.content.strip():\n            raise ValueError(\n                f\"Insight '{input_data.insight_id}' has no content to embed\"\n            )\n\n        # 2. Generate embedding (auto-chunks + mean pools if needed)\n        # Insights are typically markdown content (generated by LLM)\n        cmd_id = get_command_id(input_data)\n        embedding = await generate_embedding(\n            insight.content, content_type=ContentType.MARKDOWN, command_id=cmd_id\n        )\n\n        # 3. UPSERT embedding into insight record\n        await repo_query(\n            \"UPDATE $insight_id SET embedding = $embedding\",\n            {\n                \"insight_id\": ensure_record_id(input_data.insight_id),\n                \"embedding\": embedding,\n            },\n        )\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully embedded insight {input_data.insight_id} in {processing_time:.2f}s\"\n        )\n\n        return EmbedInsightOutput(\n            success=True,\n            insight_id=input_data.insight_id,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Permanent failure - don't retry\n        processing_time = time.time() - start_time\n        cmd_id = get_command_id(input_data)\n        logger.error(\n            f\"Failed to embed insight {input_data.insight_id} (command: {cmd_id}): {e}\"\n        )\n        return EmbedInsightOutput(\n            success=False,\n            insight_id=input_data.insight_id,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        cmd_id = get_command_id(input_data)\n        logger.debug(\n            f\"Transient error embedding insight {input_data.insight_id} \"\n            f\"(command: {cmd_id}): {e}\"\n        )\n        raise\n\n\n@command(\n    \"embed_source\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 5,\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 60,\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",\n    },\n)\nasync def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutput:\n    \"\"\"\n    Generate and store embeddings for a source document.\n\n    Creates multiple chunk embeddings stored in the source_embedding table.\n    Uses content-type aware chunking based on file extension or content heuristics.\n\n    Flow:\n    1. Load Source by ID\n    2. DELETE existing source_embedding records for this source\n    3. Detect content type from file path or content\n    4. Chunk text using appropriate splitter\n    5. Generate embeddings for all chunks in batches\n    6. Bulk INSERT source_embedding records\n\n    Retry Strategy:\n    - Retries up to 5 times for transient failures (network, timeout, etc.)\n    - Uses exponential-jitter backoff (1-60s)\n    - Does NOT retry permanent failures (ValueError for validation errors)\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(f\"Starting embedding for source: {input_data.source_id}\")\n\n        # 1. Load source\n        source = await Source.get(input_data.source_id)\n        if not source:\n            raise ValueError(f\"Source '{input_data.source_id}' not found\")\n\n        if not source.full_text or not source.full_text.strip():\n            raise ValueError(f\"Source '{input_data.source_id}' has no text to embed\")\n\n        # 2. DELETE existing embeddings (idempotency)\n        logger.debug(f\"Deleting existing embeddings for source {input_data.source_id}\")\n        await repo_query(\n            \"DELETE source_embedding WHERE source = $source_id\",\n            {\"source_id\": ensure_record_id(input_data.source_id)},\n        )\n\n        # 3. Detect content type from file path if available\n        file_path = source.asset.file_path if source.asset else None\n        content_type = detect_content_type(source.full_text, file_path)\n        logger.debug(f\"Detected content type: {content_type.value}\")\n\n        # 4. Chunk text using appropriate splitter\n        chunks = chunk_text(source.full_text, content_type=content_type)\n        total_chunks = len(chunks)\n\n        # Log chunk statistics for debugging\n        chunk_sizes = [len(c) for c in chunks]\n        logger.info(\n            f\"Created {total_chunks} chunks for source {input_data.source_id} \"\n            f\"(sizes: min={min(chunk_sizes) if chunk_sizes else 0}, \"\n            f\"max={max(chunk_sizes) if chunk_sizes else 0}, \"\n            f\"avg={sum(chunk_sizes)//len(chunk_sizes) if chunk_sizes else 0} chars)\"\n        )\n\n        if total_chunks == 0:\n            raise ValueError(\"No chunks created after splitting text\")\n\n        # 5. Generate embeddings for all chunks in batches\n        cmd_id = get_command_id(input_data)\n        logger.debug(f\"Generating embeddings for {total_chunks} chunks\")\n        embeddings = await generate_embeddings(chunks, command_id=cmd_id)\n\n        # Verify we got embeddings for all chunks\n        if len(embeddings) != len(chunks):\n            raise ValueError(\n                f\"Embedding count mismatch: got {len(embeddings)} embeddings \"\n                f\"for {len(chunks)} chunks\"\n            )\n\n        # 6. Bulk INSERT source_embedding records\n        records = [\n            {\n                \"source\": ensure_record_id(input_data.source_id),\n                \"order\": idx,\n                \"content\": chunk,\n                \"embedding\": embedding,\n            }\n            for idx, (chunk, embedding) in enumerate(zip(chunks, embeddings))\n        ]\n\n        logger.debug(f\"Inserting {len(records)} source_embedding records\")\n        await repo_insert(\"source_embedding\", records)\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully embedded source {input_data.source_id}: \"\n            f\"{total_chunks} chunks in {processing_time:.2f}s\"\n        )\n\n        return EmbedSourceOutput(\n            success=True,\n            source_id=input_data.source_id,\n            chunks_created=total_chunks,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Permanent failure - don't retry\n        processing_time = time.time() - start_time\n        cmd_id = get_command_id(input_data)\n        logger.error(\n            f\"Failed to embed source {input_data.source_id} (command: {cmd_id}): {e}\"\n        )\n        return EmbedSourceOutput(\n            success=False,\n            source_id=input_data.source_id,\n            chunks_created=0,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        cmd_id = get_command_id(input_data)\n        logger.debug(\n            f\"Transient error embedding source {input_data.source_id} \"\n            f\"(command: {cmd_id}): {e}\"\n        )\n        raise\n\n\n@command(\n    \"create_insight\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 5,\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 60,\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",\n    },\n)\nasync def create_insight_command(\n    input_data: CreateInsightInput,\n) -> CreateInsightOutput:\n    \"\"\"\n    Create a source insight with automatic retry on transaction conflicts.\n\n    This command wraps the CREATE source_insight operation with retry logic\n    to handle SurrealDB transaction conflicts that occur during batch imports\n    when multiple parallel transformations try to create insights concurrently.\n\n    Flow:\n    1. CREATE source_insight record in database\n    2. Submit embed_insight command (fire-and-forget) for async embedding\n    3. Return the insight_id\n\n    Retry Strategy:\n    - Retries up to 5 times for transient failures (network, timeout, etc.)\n    - Uses exponential-jitter backoff (1-60s)\n    - Does NOT retry permanent failures (ValueError for validation errors)\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(\n            f\"Creating insight for source {input_data.source_id}: \"\n            f\"type={input_data.insight_type}\"\n        )\n\n        # 1. Create insight record in database\n        result = await repo_query(\n            \"\"\"\n            CREATE source_insight CONTENT {\n                \"source\": $source_id,\n                \"insight_type\": $insight_type,\n                \"content\": $content\n            };\n            \"\"\",\n            {\n                \"source_id\": ensure_record_id(input_data.source_id),\n                \"insight_type\": input_data.insight_type,\n                \"content\": input_data.content,\n            },\n        )\n\n        if not result or len(result) == 0:\n            raise ValueError(\"Failed to create insight - no result returned\")\n\n        insight_id = str(result[0].get(\"id\", \"\"))\n        if not insight_id:\n            raise ValueError(\"Failed to create insight - no ID in result\")\n\n        # 2. Submit embedding command (fire-and-forget)\n        submit_command(\n            \"open_notebook\",\n            \"embed_insight\",\n            {\"insight_id\": insight_id},\n        )\n        logger.debug(f\"Submitted embed_insight command for {insight_id}\")\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully created insight {insight_id} for source \"\n            f\"{input_data.source_id} in {processing_time:.2f}s\"\n        )\n\n        return CreateInsightOutput(\n            success=True,\n            insight_id=insight_id,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Permanent failure - don't retry\n        processing_time = time.time() - start_time\n        cmd_id = get_command_id(input_data)\n        logger.error(\n            f\"Failed to create insight for source {input_data.source_id} \"\n            f\"(command: {cmd_id}): {e}\"\n        )\n        return CreateInsightOutput(\n            success=False,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        cmd_id = get_command_id(input_data)\n        logger.debug(\n            f\"Transient error creating insight for source {input_data.source_id} \"\n            f\"(command: {cmd_id}): {e}\"\n        )\n        raise\n\n\nasync def collect_items_for_rebuild(\n    mode: str,\n    include_sources: bool,\n    include_notes: bool,\n    include_insights: bool,\n) -> Dict[str, List[str]]:\n    \"\"\"\n    Collect items to rebuild based on mode and include flags.\n\n    Returns:\n        Dict with keys: 'sources', 'notes', 'insights' containing lists of item IDs\n    \"\"\"\n    items: Dict[str, List[str]] = {\"sources\": [], \"notes\": [], \"insights\": []}\n\n    if include_sources:\n        if mode == \"existing\":\n            # Query sources with embeddings (via source_embedding table)\n            result = await repo_query(\n                \"\"\"\n                RETURN array::distinct(\n                    SELECT VALUE source.id\n                    FROM source_embedding\n                    WHERE embedding != none AND array::len(embedding) > 0\n                )\n                \"\"\"\n            )\n            # RETURN returns the array directly as the result (not nested)\n            if result:\n                items[\"sources\"] = [str(item) for item in result]\n            else:\n                items[\"sources\"] = []\n        else:  # mode == \"all\"\n            # Query all sources with non-empty content\n            result = await repo_query(\n                \"SELECT id FROM source WHERE full_text != none AND string::trim(full_text) != ''\"\n            )\n            items[\"sources\"] = [str(item[\"id\"]) for item in result] if result else []\n\n        logger.info(f\"Collected {len(items['sources'])} sources for rebuild\")\n\n    if include_notes:\n        if mode == \"existing\":\n            # Query notes with embeddings\n            result = await repo_query(\n                \"SELECT id FROM note WHERE embedding != none AND array::len(embedding) > 0\"\n            )\n        else:  # mode == \"all\"\n            # Query all notes with non-empty content\n            result = await repo_query(\n                \"SELECT id FROM note WHERE content != none AND string::trim(content) != ''\"\n            )\n\n        items[\"notes\"] = [str(item[\"id\"]) for item in result] if result else []\n        logger.info(f\"Collected {len(items['notes'])} notes for rebuild\")\n\n    if include_insights:\n        if mode == \"existing\":\n            # Query insights with embeddings\n            result = await repo_query(\n                \"SELECT id FROM source_insight WHERE embedding != none AND array::len(embedding) > 0\"\n            )\n        else:  # mode == \"all\"\n            # Query all insights with non-empty content\n            result = await repo_query(\n                \"SELECT id FROM source_insight WHERE content != none AND string::trim(content) != ''\"\n            )\n\n        items[\"insights\"] = [str(item[\"id\"]) for item in result] if result else []\n        logger.info(f\"Collected {len(items['insights'])} insights for rebuild\")\n\n    return items\n\n\n@command(\"rebuild_embeddings\", app=\"open_notebook\", retry=None)\nasync def rebuild_embeddings_command(\n    input_data: RebuildEmbeddingsInput,\n) -> RebuildEmbeddingsOutput:\n    \"\"\"\n    Rebuild embeddings for sources, notes, and/or insights.\n\n    This command submits individual embedding jobs for each item:\n    - embed_source for sources\n    - embed_note for notes\n    - embed_insight for insights\n\n    The command returns after submitting all jobs. Actual embedding\n    happens asynchronously via the individual commands (which have\n    their own retry strategies).\n\n    Retry Strategy:\n    - Retries disabled (retry=None) for this coordinator command\n    - Individual embed_* commands handle their own retries\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(\"=\" * 60)\n        logger.info(f\"Starting embedding rebuild with mode={input_data.mode}\")\n        logger.info(\n            f\"Include: sources={input_data.include_sources}, notes={input_data.include_notes}, insights={input_data.include_insights}\"\n        )\n        logger.info(\"=\" * 60)\n\n        # Check embedding model availability (fail fast)\n        EMBEDDING_MODEL = await model_manager.get_embedding_model()\n        if not EMBEDDING_MODEL:\n            raise ValueError(\n                \"No embedding model configured. Please configure one in the Models section.\"\n            )\n\n        logger.info(f\"Embedding model configured: {EMBEDDING_MODEL}\")\n\n        # Collect items to process (returns IDs only)\n        items = await collect_items_for_rebuild(\n            input_data.mode,\n            input_data.include_sources,\n            input_data.include_notes,\n            input_data.include_insights,\n        )\n\n        total_items = (\n            len(items[\"sources\"]) + len(items[\"notes\"]) + len(items[\"insights\"])\n        )\n        logger.info(f\"Total items to rebuild: {total_items}\")\n\n        if total_items == 0:\n            logger.warning(\"No items found to rebuild\")\n            return RebuildEmbeddingsOutput(\n                success=True,\n                total_items=0,\n                jobs_submitted=0,\n                failed_submissions=0,\n                processing_time=time.time() - start_time,\n            )\n\n        # Initialize counters\n        sources_submitted = 0\n        notes_submitted = 0\n        insights_submitted = 0\n        failed_submissions = 0\n\n        # Submit embed_source commands for sources\n        logger.info(f\"\\nSubmitting {len(items['sources'])} source embedding jobs...\")\n        for idx, source_id in enumerate(items[\"sources\"], 1):\n            try:\n                submit_command(\n                    \"open_notebook\",\n                    \"embed_source\",\n                    {\"source_id\": source_id},\n                )\n                sources_submitted += 1\n\n                if idx % 50 == 0 or idx == len(items[\"sources\"]):\n                    logger.info(\n                        f\"  Progress: {idx}/{len(items['sources'])} source jobs submitted\"\n                    )\n\n            except Exception as e:\n                logger.error(f\"Failed to submit embed_source for {source_id}: {e}\")\n                failed_submissions += 1\n\n        # Submit embed_note commands for notes\n        logger.info(f\"\\nSubmitting {len(items['notes'])} note embedding jobs...\")\n        for idx, note_id in enumerate(items[\"notes\"], 1):\n            try:\n                submit_command(\n                    \"open_notebook\",\n                    \"embed_note\",\n                    {\"note_id\": note_id},\n                )\n                notes_submitted += 1\n\n                if idx % 50 == 0 or idx == len(items[\"notes\"]):\n                    logger.info(\n                        f\"  Progress: {idx}/{len(items['notes'])} note jobs submitted\"\n                    )\n\n            except Exception as e:\n                logger.error(f\"Failed to submit embed_note for {note_id}: {e}\")\n                failed_submissions += 1\n\n        # Submit embed_insight commands for insights\n        logger.info(f\"\\nSubmitting {len(items['insights'])} insight embedding jobs...\")\n        for idx, insight_id in enumerate(items[\"insights\"], 1):\n            try:\n                submit_command(\n                    \"open_notebook\",\n                    \"embed_insight\",\n                    {\"insight_id\": insight_id},\n                )\n                insights_submitted += 1\n\n                if idx % 50 == 0 or idx == len(items[\"insights\"]):\n                    logger.info(\n                        f\"  Progress: {idx}/{len(items['insights'])} insight jobs submitted\"\n                    )\n\n            except Exception as e:\n                logger.error(f\"Failed to submit embed_insight for {insight_id}: {e}\")\n                failed_submissions += 1\n\n        processing_time = time.time() - start_time\n        jobs_submitted = sources_submitted + notes_submitted + insights_submitted\n\n        logger.info(\"=\" * 60)\n        logger.info(\"REBUILD JOBS SUBMITTED\")\n        logger.info(f\"  Total jobs submitted: {jobs_submitted}/{total_items}\")\n        logger.info(f\"  Sources: {sources_submitted}\")\n        logger.info(f\"  Notes: {notes_submitted}\")\n        logger.info(f\"  Insights: {insights_submitted}\")\n        logger.info(f\"  Failed submissions: {failed_submissions}\")\n        logger.info(f\"  Submission time: {processing_time:.2f}s\")\n        logger.info(\"  Note: Actual embedding happens asynchronously\")\n        logger.info(\"=\" * 60)\n\n        return RebuildEmbeddingsOutput(\n            success=True,\n            total_items=total_items,\n            jobs_submitted=jobs_submitted,\n            failed_submissions=failed_submissions,\n            sources_submitted=sources_submitted,\n            notes_submitted=notes_submitted,\n            insights_submitted=insights_submitted,\n            processing_time=processing_time,\n        )\n\n    except Exception as e:\n        processing_time = time.time() - start_time\n        logger.error(f\"Rebuild embeddings failed: {e}\")\n        logger.exception(e)\n\n        return RebuildEmbeddingsOutput(\n            success=False,\n            total_items=0,\n            jobs_submitted=0,\n            failed_submissions=0,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n"
  },
  {
    "path": "commands/example_commands.py",
    "content": "import asyncio\nimport time\nfrom typing import List, Optional\n\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom surreal_commands import command\n\n\nclass TextProcessingInput(BaseModel):\n    text: str\n    operation: str = \"uppercase\"  # uppercase, lowercase, word_count, reverse\n    delay_seconds: Optional[int] = None  # For testing async behavior\n\n\nclass TextProcessingOutput(BaseModel):\n    success: bool\n    original_text: str\n    processed_text: Optional[str] = None\n    word_count: Optional[int] = None\n    processing_time: float\n    error_message: Optional[str] = None\n\n\nclass DataAnalysisInput(BaseModel):\n    numbers: List[float]\n    analysis_type: str = \"basic\"  # basic, detailed\n    delay_seconds: Optional[int] = None\n\n\nclass DataAnalysisOutput(BaseModel):\n    success: bool\n    analysis_type: str\n    count: int\n    sum: Optional[float] = None\n    average: Optional[float] = None\n    min_value: Optional[float] = None\n    max_value: Optional[float] = None\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n@command(\"process_text\", app=\"open_notebook\")\nasync def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:\n    \"\"\"\n    Example command for text processing. Tests basic command functionality\n    and demonstrates different processing types.\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(f\"Processing text with operation: {input_data.operation}\")\n\n        # Simulate processing delay if specified\n        if input_data.delay_seconds:\n            await asyncio.sleep(input_data.delay_seconds)\n\n        processed_text = None\n        word_count = None\n\n        if input_data.operation == \"uppercase\":\n            processed_text = input_data.text.upper()\n        elif input_data.operation == \"lowercase\":\n            processed_text = input_data.text.lower()\n        elif input_data.operation == \"reverse\":\n            processed_text = input_data.text[::-1]\n        elif input_data.operation == \"word_count\":\n            word_count = len(input_data.text.split())\n            processed_text = f\"Word count: {word_count}\"\n        else:\n            raise ValueError(f\"Unknown operation: {input_data.operation}\")\n\n        processing_time = time.time() - start_time\n\n        return TextProcessingOutput(\n            success=True,\n            original_text=input_data.text,\n            processed_text=processed_text,\n            word_count=word_count,\n            processing_time=processing_time,\n        )\n\n    except Exception as e:\n        processing_time = time.time() - start_time\n        logger.error(f\"Text processing failed: {e}\")\n        return TextProcessingOutput(\n            success=False,\n            original_text=input_data.text,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n\n\n@command(\"analyze_data\", app=\"open_notebook\")\nasync def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:\n    \"\"\"\n    Example command for data analysis. Tests command with complex input/output\n    and demonstrates error handling.\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(\n            f\"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis\"\n        )\n\n        # Simulate processing delay if specified\n        if input_data.delay_seconds:\n            await asyncio.sleep(input_data.delay_seconds)\n\n        if not input_data.numbers:\n            raise ValueError(\"No numbers provided for analysis\")\n\n        count = len(input_data.numbers)\n        sum_value = sum(input_data.numbers)\n        average = sum_value / count\n        min_value = min(input_data.numbers)\n        max_value = max(input_data.numbers)\n\n        processing_time = time.time() - start_time\n\n        return DataAnalysisOutput(\n            success=True,\n            analysis_type=input_data.analysis_type,\n            count=count,\n            sum=sum_value,\n            average=average,\n            min_value=min_value,\n            max_value=max_value,\n            processing_time=processing_time,\n        )\n\n    except Exception as e:\n        processing_time = time.time() - start_time\n        logger.error(f\"Data analysis failed: {e}\")\n        return DataAnalysisOutput(\n            success=False,\n            analysis_type=input_data.analysis_type,\n            count=0,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n"
  },
  {
    "path": "commands/podcast_commands.py",
    "content": "import time\nimport uuid\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom surreal_commands import CommandInput, CommandOutput, command\n\nfrom open_notebook.config import DATA_FOLDER\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.podcasts.models import (\n    EpisodeProfile,\n    PodcastEpisode,\n    SpeakerProfile,\n    _resolve_model_config,\n)\n\ntry:\n    from podcast_creator import configure, create_podcast\nexcept ImportError as e:\n    logger.error(f\"Failed to import podcast_creator: {e}\")\n    raise ValueError(\"podcast_creator library not available\")\n\n\ndef build_episode_output_dir(data_folder: str) -> tuple[str, Path]:\n    \"\"\"Build a filesystem-safe output directory path for a podcast episode.\n\n    Uses a UUID as the directory name so the path is safe regardless of\n    what the user typed as episode name (spaces, special chars, etc.).\n\n    Returns:\n        A tuple of (episode_dir_name, output_dir_path).\n    \"\"\"\n    episode_dir_name = str(uuid.uuid4())\n    output_dir = Path(f\"{data_folder}/podcasts/episodes/{episode_dir_name}\")\n    return episode_dir_name, output_dir\n\n\ndef full_model_dump(model):\n    if isinstance(model, BaseModel):\n        return model.model_dump()\n    elif isinstance(model, dict):\n        return {k: full_model_dump(v) for k, v in model.items()}\n    elif isinstance(model, list):\n        return [full_model_dump(item) for item in model]\n    else:\n        return model\n\n\nclass PodcastGenerationInput(CommandInput):\n    episode_profile: str\n    speaker_profile: str\n    episode_name: str\n    content: str\n    briefing_suffix: Optional[str] = None\n\n\nclass PodcastGenerationOutput(CommandOutput):\n    success: bool\n    episode_id: Optional[str] = None\n    audio_file_path: Optional[str] = None\n    transcript: Optional[dict] = None\n    outline: Optional[dict] = None\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n@command(\"generate_podcast\", app=\"open_notebook\", retry={\"max_attempts\": 1})\nasync def generate_podcast_command(\n    input_data: PodcastGenerationInput,\n) -> PodcastGenerationOutput:\n    \"\"\"\n    Real podcast generation using podcast-creator library with Episode Profiles\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(\n            f\"Starting podcast generation for episode: {input_data.episode_name}\"\n        )\n        logger.info(f\"Using episode profile: {input_data.episode_profile}\")\n\n        # 1. Load Episode and Speaker profiles from SurrealDB\n        episode_profile = await EpisodeProfile.get_by_name(input_data.episode_profile)\n        if not episode_profile:\n            raise ValueError(\n                f\"Episode profile '{input_data.episode_profile}' not found\"\n            )\n\n        speaker_profile = await SpeakerProfile.get_by_name(\n            episode_profile.speaker_config\n        )\n        if not speaker_profile:\n            raise ValueError(\n                f\"Speaker profile '{episode_profile.speaker_config}' not found\"\n            )\n\n        logger.info(f\"Loaded episode profile: {episode_profile.name}\")\n        logger.info(f\"Loaded speaker profile: {speaker_profile.name}\")\n\n        # 2. Validate that model registry fields are populated\n        if not episode_profile.outline_llm:\n            raise ValueError(\n                f\"Episode profile '{episode_profile.name}' has no outline model configured. \"\n                \"Please update the profile to select an outline model.\"\n            )\n        if not episode_profile.transcript_llm:\n            raise ValueError(\n                f\"Episode profile '{episode_profile.name}' has no transcript model configured. \"\n                \"Please update the profile to select a transcript model.\"\n            )\n        if not speaker_profile.voice_model:\n            raise ValueError(\n                f\"Speaker profile '{speaker_profile.name}' has no voice model configured. \"\n                \"Please update the profile to select a voice model.\"\n            )\n\n        # 3. Resolve model configs with credentials\n        outline_provider, outline_model_name, outline_config = (\n            await episode_profile.resolve_outline_config()\n        )\n        transcript_provider, transcript_model_name, transcript_config = (\n            await episode_profile.resolve_transcript_config()\n        )\n        tts_provider, tts_model_name, tts_config = (\n            await speaker_profile.resolve_tts_config()\n        )\n\n        logger.info(\n            f\"Resolved models - outline: {outline_provider}/{outline_model_name}, \"\n            f\"transcript: {transcript_provider}/{transcript_model_name}, \"\n            f\"tts: {tts_provider}/{tts_model_name}\"\n        )\n\n        # 4. Load all profiles and configure podcast-creator\n        episode_profiles = await repo_query(\"SELECT * FROM episode_profile\")\n        speaker_profiles = await repo_query(\"SELECT * FROM speaker_profile\")\n\n        # Transform the surrealdb array into a dictionary for podcast-creator\n        episode_profiles_dict = {\n            profile[\"name\"]: profile for profile in episode_profiles\n        }\n        speaker_profiles_dict = {\n            profile[\"name\"]: profile for profile in speaker_profiles\n        }\n\n        # 5. Inject resolved model configs into profile dicts\n        # Resolve ALL episode profiles (podcast-creator validates all).\n        # Remove profiles that fail resolution to prevent validation errors.\n        for ep_name in list(episode_profiles_dict.keys()):\n            ep_dict = episode_profiles_dict[ep_name]\n            try:\n                if ep_dict.get(\"outline_llm\"):\n                    prov, model, conf = await _resolve_model_config(\n                        str(ep_dict[\"outline_llm\"])\n                    )\n                    ep_dict[\"outline_provider\"] = prov\n                    ep_dict[\"outline_model\"] = model\n                    ep_dict[\"outline_config\"] = conf\n                if ep_dict.get(\"transcript_llm\"):\n                    prov, model, conf = await _resolve_model_config(\n                        str(ep_dict[\"transcript_llm\"])\n                    )\n                    ep_dict[\"transcript_provider\"] = prov\n                    ep_dict[\"transcript_model\"] = model\n                    ep_dict[\"transcript_config\"] = conf\n            except Exception as e:\n                logger.warning(\n                    f\"Failed to resolve models for episode profile '{ep_name}', \"\n                    f\"removing from config to prevent validation errors: {e}\"\n                )\n                del episode_profiles_dict[ep_name]\n\n        # Resolve TTS for ALL speaker profiles (podcast-creator validates all).\n        # Remove profiles that fail resolution to prevent validation errors.\n        for sp_name in list(speaker_profiles_dict.keys()):\n            sp_dict = speaker_profiles_dict[sp_name]\n            if sp_dict.get(\"voice_model\"):\n                try:\n                    prov, model, conf = await _resolve_model_config(\n                        str(sp_dict[\"voice_model\"])\n                    )\n                    sp_dict[\"tts_provider\"] = prov\n                    sp_dict[\"tts_model\"] = model\n                    sp_dict[\"tts_config\"] = conf\n                except Exception as e:\n                    logger.warning(\n                        f\"Failed to resolve TTS for speaker profile '{sp_name}', \"\n                        f\"removing from config to prevent validation errors: {e}\"\n                    )\n                    del speaker_profiles_dict[sp_name]\n                    continue\n\n            # Per-speaker TTS overrides\n            for speaker in sp_dict.get(\"speakers\", []):\n                if speaker.get(\"voice_model\"):\n                    try:\n                        prov, model, conf = await _resolve_model_config(\n                            str(speaker[\"voice_model\"])\n                        )\n                        speaker[\"tts_provider\"] = prov\n                        speaker[\"tts_model\"] = model\n                        speaker[\"tts_config\"] = conf\n                    except Exception as e:\n                        logger.warning(\n                            f\"Failed to resolve per-speaker TTS for '{speaker.get('name')}': {e}\"\n                        )\n\n        # 6. Generate briefing\n        briefing = episode_profile.default_briefing\n        if input_data.briefing_suffix:\n            briefing += f\"\\n\\nAdditional instructions: {input_data.briefing_suffix}\"\n\n        # Create the record for the episode and associate with the ongoing command\n        episode = PodcastEpisode(\n            name=input_data.episode_name,\n            episode_profile=full_model_dump(episode_profile.model_dump()),\n            speaker_profile=full_model_dump(speaker_profile.model_dump()),\n            command=ensure_record_id(input_data.execution_context.command_id)\n            if input_data.execution_context\n            else None,\n            briefing=briefing,\n            content=input_data.content,\n            audio_file=None,\n            transcript=None,\n            outline=None,\n        )\n        await episode.save()\n\n        configure(\"speakers_config\", {\"profiles\": speaker_profiles_dict})\n        configure(\"episode_config\", {\"profiles\": episode_profiles_dict})\n\n        logger.info(\"Configured podcast-creator with episode and speaker profiles\")\n\n        logger.info(f\"Generated briefing (length: {len(briefing)} chars)\")\n\n        # 7. Create output directory using UUID for filesystem-safe paths\n        episode_dir_name, output_dir = build_episode_output_dir(DATA_FOLDER)\n        output_dir.mkdir(parents=True, exist_ok=True)\n\n        logger.info(f\"Created output directory: {output_dir}\")\n\n        # 8. Generate podcast using podcast-creator\n        logger.info(\"Starting podcast generation with podcast-creator...\")\n\n        result = await create_podcast(\n            content=input_data.content,\n            briefing=briefing,\n            episode_name=episode_dir_name,\n            output_dir=str(output_dir),\n            speaker_config=speaker_profile.name,\n            episode_profile=episode_profile.name,\n        )\n\n        episode.audio_file = (\n            str(result.get(\"final_output_file_path\")) if result else None\n        )\n        episode.transcript = {\n            \"transcript\": full_model_dump(result[\"transcript\"]) if result else None\n        }\n        episode.outline = full_model_dump(result[\"outline\"]) if result else None\n        await episode.save()\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully generated podcast episode: {episode.id} in {processing_time:.2f}s\"\n        )\n\n        return PodcastGenerationOutput(\n            success=True,\n            episode_id=str(episode.id),\n            audio_file_path=str(result.get(\"final_output_file_path\"))\n            if result\n            else None,\n            transcript={\"transcript\": full_model_dump(result[\"transcript\"])}\n            if result.get(\"transcript\")\n            else None,\n            outline=full_model_dump(result[\"outline\"])\n            if result.get(\"outline\")\n            else None,\n            processing_time=processing_time,\n        )\n\n    except ValueError:\n        raise\n\n    except Exception as e:\n        logger.error(f\"Podcast generation failed: {e}\")\n        logger.exception(e)\n\n        error_msg = str(e)\n        if \"Invalid json output\" in error_msg or \"Expecting value\" in error_msg:\n            error_msg += (\n                \"\\n\\nNOTE: This error commonly occurs with GPT-5 models that use extended thinking. \"\n                \"The model may be putting all output inside <think> tags, leaving nothing to parse. \"\n                \"Try using gpt-4o, gpt-4o-mini, or gpt-4-turbo instead in your episode profile.\"\n            )\n\n        raise RuntimeError(error_msg) from e\n"
  },
  {
    "path": "commands/source_commands.py",
    "content": "import time\nfrom typing import Any, Dict, List, Optional\n\nfrom loguru import logger\nfrom pydantic import BaseModel\nfrom surreal_commands import CommandInput, CommandOutput, command\n\nfrom open_notebook.database.repository import ensure_record_id\nfrom open_notebook.domain.notebook import Source\nfrom open_notebook.domain.transformation import Transformation\nfrom open_notebook.exceptions import ConfigurationError\n\ntry:\n    from open_notebook.graphs.source import source_graph\n    from open_notebook.graphs.transformation import graph as transform_graph\nexcept ImportError as e:\n    logger.error(f\"Failed to import graphs: {e}\")\n    raise ValueError(\"graphs not available\")\n\n\ndef full_model_dump(model):\n    if isinstance(model, BaseModel):\n        return model.model_dump()\n    elif isinstance(model, dict):\n        return {k: full_model_dump(v) for k, v in model.items()}\n    elif isinstance(model, list):\n        return [full_model_dump(item) for item in model]\n    else:\n        return model\n\n\nclass SourceProcessingInput(CommandInput):\n    source_id: str\n    content_state: Dict[str, Any]\n    notebook_ids: List[str]\n    transformations: List[str]\n    embed: bool\n\n\nclass SourceProcessingOutput(CommandOutput):\n    success: bool\n    source_id: str\n    embedded_chunks: int = 0\n    insights_created: int = 0\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n@command(\n    \"process_source\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 15,  # Handle deep queues (workaround for SurrealDB v2 transaction conflicts)\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 120,  # Allow queue to drain\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",  # Avoid log noise during transaction conflicts\n    },\n)\nasync def process_source_command(\n    input_data: SourceProcessingInput,\n) -> SourceProcessingOutput:\n    \"\"\"\n    Process source content using the source_graph workflow\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(f\"Starting source processing for source: {input_data.source_id}\")\n        logger.info(f\"Notebook IDs: {input_data.notebook_ids}\")\n        logger.info(f\"Transformations: {input_data.transformations}\")\n        logger.info(f\"Embed: {input_data.embed}\")\n\n        # 1. Load transformation objects from IDs\n        transformations = []\n        for trans_id in input_data.transformations:\n            logger.info(f\"Loading transformation: {trans_id}\")\n            transformation = await Transformation.get(trans_id)\n            if not transformation:\n                raise ValueError(f\"Transformation '{trans_id}' not found\")\n            transformations.append(transformation)\n\n        logger.info(f\"Loaded {len(transformations)} transformations\")\n\n        # 2. Get existing source record to update its command field\n        source = await Source.get(input_data.source_id)\n        if not source:\n            raise ValueError(f\"Source '{input_data.source_id}' not found\")\n\n        # Update source with command reference\n        source.command = (\n            ensure_record_id(input_data.execution_context.command_id)\n            if input_data.execution_context\n            else None\n        )\n        await source.save()\n\n        logger.info(f\"Updated source {source.id} with command reference\")\n\n        # 3. Process source with all notebooks\n        logger.info(f\"Processing source with {len(input_data.notebook_ids)} notebooks\")\n\n        # Execute source_graph with all notebooks\n        result = await source_graph.ainvoke(\n            {  # type: ignore[arg-type]\n                \"content_state\": input_data.content_state,\n                \"notebook_ids\": input_data.notebook_ids,  # Use notebook_ids (plural) as expected by SourceState\n                \"apply_transformations\": transformations,\n                \"embed\": input_data.embed,\n                \"source_id\": input_data.source_id,  # Add the source_id to the state\n            }\n        )\n\n        processed_source = result[\"source\"]\n\n        # 4. Gather processing results (notebook associations handled by source_graph)\n        # Note: embedding is fire-and-forget (async job), so we can't query the\n        # count here — it hasn't completed yet. The embed_source_command logs\n        # the actual count when it finishes.\n        insights_list = await processed_source.get_insights()\n        insights_created = len(insights_list)\n\n        processing_time = time.time() - start_time\n        embed_status = \"submitted\" if input_data.embed else \"skipped\"\n        logger.info(\n            f\"Successfully processed source: {processed_source.id} in {processing_time:.2f}s\"\n        )\n        logger.info(\n            f\"Created {insights_created} insights, embedding {embed_status}\"\n        )\n\n        return SourceProcessingOutput(\n            success=True,\n            source_id=str(processed_source.id),\n            embedded_chunks=0,\n            insights_created=insights_created,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Validation errors are permanent failures - don't retry\n        processing_time = time.time() - start_time\n        logger.error(f\"Source processing failed: {e}\")\n        return SourceProcessingOutput(\n            success=False,\n            source_id=input_data.source_id,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        logger.debug(\n            f\"Transient error processing source {input_data.source_id}: {e}\"\n        )\n        raise\n\n\n# =============================================================================\n# RUN TRANSFORMATION COMMAND\n# =============================================================================\n\n\nclass RunTransformationInput(CommandInput):\n    \"\"\"Input for running a transformation on an existing source.\"\"\"\n\n    source_id: str\n    transformation_id: str\n\n\nclass RunTransformationOutput(CommandOutput):\n    \"\"\"Output from transformation command.\"\"\"\n\n    success: bool\n    source_id: str\n    transformation_id: str\n    processing_time: float\n    error_message: Optional[str] = None\n\n\n@command(\n    \"run_transformation\",\n    app=\"open_notebook\",\n    retry={\n        \"max_attempts\": 5,\n        \"wait_strategy\": \"exponential_jitter\",\n        \"wait_min\": 1,\n        \"wait_max\": 60,\n        \"stop_on\": [ValueError, ConfigurationError],  # Don't retry validation/config errors\n        \"retry_log_level\": \"debug\",\n    },\n)\nasync def run_transformation_command(\n    input_data: RunTransformationInput,\n) -> RunTransformationOutput:\n    \"\"\"\n    Run a transformation on an existing source to generate an insight.\n\n    This command runs the transformation graph which:\n    1. Loads the source and transformation\n    2. Calls the LLM to generate insight content\n    3. Creates the insight via create_insight command (fire-and-forget)\n\n    Use this command for UI-triggered insight generation to avoid blocking\n    the HTTP request while the LLM processes.\n\n    Retry Strategy:\n    - Retries up to 5 times for transient failures (network, timeout, etc.)\n    - Uses exponential-jitter backoff (1-60s)\n    - Does NOT retry permanent failures (ValueError for validation errors)\n    \"\"\"\n    start_time = time.time()\n\n    try:\n        logger.info(\n            f\"Running transformation {input_data.transformation_id} \"\n            f\"on source {input_data.source_id}\"\n        )\n\n        # Load source\n        source = await Source.get(input_data.source_id)\n        if not source:\n            raise ValueError(f\"Source '{input_data.source_id}' not found\")\n\n        # Load transformation\n        transformation = await Transformation.get(input_data.transformation_id)\n        if not transformation:\n            raise ValueError(\n                f\"Transformation '{input_data.transformation_id}' not found\"\n            )\n\n        # Run transformation graph (includes LLM call + insight creation)\n        await transform_graph.ainvoke(\n            input=dict(source=source, transformation=transformation)\n        )\n\n        processing_time = time.time() - start_time\n        logger.info(\n            f\"Successfully ran transformation {input_data.transformation_id} \"\n            f\"on source {input_data.source_id} in {processing_time:.2f}s\"\n        )\n\n        return RunTransformationOutput(\n            success=True,\n            source_id=input_data.source_id,\n            transformation_id=input_data.transformation_id,\n            processing_time=processing_time,\n        )\n\n    except ValueError as e:\n        # Validation errors are permanent failures - don't retry\n        processing_time = time.time() - start_time\n        logger.error(\n            f\"Failed to run transformation {input_data.transformation_id} \"\n            f\"on source {input_data.source_id}: {e}\"\n        )\n        return RunTransformationOutput(\n            success=False,\n            source_id=input_data.source_id,\n            transformation_id=input_data.transformation_id,\n            processing_time=processing_time,\n            error_message=str(e),\n        )\n    except Exception as e:\n        # Transient failure - will be retried (surreal-commands logs final failure)\n        logger.debug(\n            f\"Transient error running transformation {input_data.transformation_id} \"\n            f\"on source {input_data.source_id}: {e}\"\n        )\n        raise\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root  # Required for bind mounts on Linux\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    restart: always\n    pull_policy: always\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"  # Web UI\n      - \"5055:5055\"  # REST API\n    environment:\n      # REQUIRED: Change this to your own secret string\n      # This encrypts your API keys in the database\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database connection (default values - no need to change)\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n    restart: always\n    pull_policy: always\n"
  },
  {
    "path": "docs/0-START-HERE/index.md",
    "content": "# Open Notebook - Start Here\n\n**Open Notebook** is a privacy-focused AI research assistant. Upload documents, chat with AI, generate notes, and create podcasts—all with complete control over your data.\n\n## Choose Your Path\n\n### 🚀 I want to use OpenAI (Fastest)\n**5 minutes to running.** GPT, simple setup, powerful results.\n\n→ [OpenAI Quick Start](quick-start-openai.md)\n\n---\n\n### ☁️ I want to use other cloud AI (Anthropic, Google, OpenRouter, etc.)\n**5 minutes to running.** Choose from 15+ AI providers.\n\n→ [Cloud Providers Quick Start](quick-start-cloud.md)\n\n---\n\n### 🏠 I want to run locally (Ollama or LMStudio, completely private)\n**5 minutes to running.** Keep everything private, on your machine. No costs.\n\n→ [Local Quick Start](quick-start-local.md)\n\n---\n\n## What Can You Do?\n\n- 📄 **Upload Content**: PDFs, web links, audio, video, text\n- 🤖 **Chat with AI**: Ask questions about your documents with citations\n- 📝 **Generate Notes**: AI creates summaries and insights\n- 🎙️ **Create Podcasts**: Turn research into professional audio content\n- 🔍 **Search**: Full-text and semantic search across all content\n- ⚙️ **Transform**: Extract insights, analyze themes, create summaries\n\n## Why Open Notebook?\n\n| Feature | Open Notebook | Notebook LM |\n|---------|---|---|\n| **Privacy** | Self-hosted, your control | Cloud, Google's servers |\n| **AI Choice** | 15+ providers | Google's models only |\n| **Podcast Speakers** | 1-4 customizable | 2 only |\n| **Cost** | Completely free | Free (but your data) |\n| **Offline** | Yes  | No |\n\n## Prerequisites\n\n- **Docker**: All paths use Docker (free)\n- **AI Provider**: Either a cloud API key OR use free local models (Ollama)\n\n---\n\n## Next Steps\n\n1. Pick your path above ⬆️\n2. Follow the 5-minute quick start\n3. Create your first notebook\n4. Start uploading documents!\n\n---\n\n**Need Help?** Join our [Discord community](https://discord.gg/37XJPXfz2w) or see [Full Documentation](../index.md).\n"
  },
  {
    "path": "docs/0-START-HERE/quick-start-cloud.md",
    "content": "# Quick Start - Cloud AI Providers (5 minutes)\n\nGet Open Notebook running with **Anthropic, Google, Groq, or other cloud providers**. Same simplicity as OpenAI, with more choices.\n\n## Prerequisites\n\n1. **Docker Desktop** installed\n   - [Download here](https://www.docker.com/products/docker-desktop/)\n   - Already have it? Skip to step 2\n\n2. **API Key** from your chosen provider:\n   - **OpenRouter** (100+ models, one key): https://openrouter.ai/keys\n   - **Anthropic (Claude)**: https://console.anthropic.com/\n   - **Google (Gemini)**: https://aistudio.google.com/\n   - **Groq** (fast, free tier): https://console.groq.com/\n   - **Mistral**: https://console.mistral.ai/\n   - **DeepSeek**: https://platform.deepseek.com/\n   - **xAI (Grok)**: https://console.x.ai/\n\n## Step 1: Create Configuration (1 min)\n\nCreate a new folder `open-notebook` and add this file:\n\n**docker-compose.yml**:\n```yaml\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --user root --pass password --bind 0.0.0.0:8000 rocksdb:/mydata/mydatabase.db\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    # Removed the healthcheck because the v2 image is too minimal to run wget/curl\n    restart: always\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    pull_policy: always\n    ports:\n      - \"8502:8502\"  # Web UI\n      - \"5055:5055\"  # API\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=password\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n    restart: always\n\n```\n\n**Edit the file:**\n- Replace `change-me-to-a-secret-string` with your own secret (any string works)\n\n---\n\n## Step 2: Start Services (1 min)\n\nOpen terminal in your `open-notebook` folder:\n\n```bash\ndocker compose up -d\n```\n\nWait 15-20 seconds for services to start.\n\n---\n\n## Step 3: Access Open Notebook (instant)\n\nOpen your browser:\n```\nhttp://localhost:8502\n```\n\nYou should see the Open Notebook interface!\n\n---\n\n## Step 4: Configure Your AI Provider (1 min)\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select your provider (e.g., Anthropic, Google, Groq, OpenRouter)\n4. Give it a name, paste your API key\n5. Click **Save**\n6. Click **Test Connection** — should show success\n7. Click **Discover Models** → **Register Models**\n\nYour provider's models are now available!\n\n> **Multiple providers**: You can add credentials for as many providers as you want. Just repeat this step for each provider.\n\n---\n\n## Step 5: Configure Your Model (1 min)\n\n1. Go to **Settings** (gear icon)\n2. Navigate to **Models**\n3. Select your provider's model:\n\n| Provider | Recommended Model | Notes |\n|----------|-------------------|-------|\n| **OpenRouter** | `anthropic/claude-3.5-sonnet` | Access 100+ models |\n| **Anthropic** | `claude-3-5-sonnet-latest` | Best reasoning |\n| **Google** | `gemini-2.0-flash` | Large context, fast |\n| **Groq** | `llama-3.3-70b-versatile` | Ultra-fast |\n| **Mistral** | `mistral-large-latest` | Strong European option |\n\n4. Click **Save**\n\n---\n\n## Step 6: Create Your First Notebook (1 min)\n\n1. Click **New Notebook**\n2. Name: \"My Research\"\n3. Click **Create**\n\n---\n\n## Step 7: Add Content & Chat (2 min)\n\n1. Click **Add Source**\n2. Choose **Web Link**\n3. Paste any article URL\n4. Wait for processing\n5. Go to **Chat** and ask questions!\n\n---\n\n## Verification Checklist\n\n- [ ] Docker is running\n- [ ] You can access `http://localhost:8502`\n- [ ] Provider credential is configured and tested\n- [ ] Models are registered\n- [ ] You created a notebook\n- [ ] Chat works\n\n**All checked?** You're ready to research!\n\n---\n\n## Provider Comparison\n\n| Provider | Speed | Quality | Context | Cost |\n|----------|-------|---------|---------|------|\n| **OpenRouter** | Varies | Varies | Varies | Varies (100+ models) |\n| **Anthropic** | Medium | Excellent | 200K | $$$ |\n| **Google** | Fast | Very Good | 1M+ | $$ |\n| **Groq** | Ultra-fast | Good | 128K | $ (free tier) |\n| **Mistral** | Fast | Good | 128K | $$ |\n| **DeepSeek** | Medium | Very Good | 64K | $ |\n\n---\n\n## Troubleshooting\n\n### \"Model not found\" Error\n\n1. Go to **Settings** → **API Keys**\n2. Click **Test Connection** on your credential\n3. If valid, click **Discover Models** → **Register Models**\n4. Check you have credits/access for the model\n\n### \"Cannot connect to server\"\n\n```bash\ndocker ps  # Check all services running\ndocker compose logs  # View logs\ndocker compose restart  # Restart everything\n```\n\n### Provider-Specific Issues\n\n**Anthropic**: Ensure key starts with `sk-ant-`\n**Google**: Use AI Studio key, not Cloud Console\n**Groq**: Free tier has rate limits; upgrade if needed\n\n---\n\n## Cost Estimates\n\nApproximate costs per 1K tokens:\n\n| Provider | Input | Output |\n|----------|-------|--------|\n| Anthropic (Sonnet) | $0.003 | $0.015 |\n| Google (Flash) | $0.0001 | $0.0004 |\n| Groq (Llama 70B) | Free tier available | - |\n| Mistral (Large) | $0.002 | $0.006 |\n\nCheck provider websites for current pricing.\n\n---\n\n## Next Steps\n\n1. **Add Your Content**: PDFs, web links, documents\n2. **Explore Features**: Podcasts, transformations, search\n3. **Full Documentation**: [See all features](../3-USER-GUIDE/index.md)\n\n---\n\n**Need help?** Join our [Discord community](https://discord.gg/37XJPXfz2w)!\n"
  },
  {
    "path": "docs/0-START-HERE/quick-start-local.md",
    "content": "# Quick Start - Local & Private (5 minutes)\n\nGet Open Notebook running with **100% local AI** using Ollama. No cloud API keys needed, completely private.\n\n## Prerequisites\n\n1. **Docker Desktop** installed\n   - [Download here](https://www.docker.com/products/docker-desktop/)\n   - Already have it? Skip to step 2\n\n2. **Local LLM** - Choose one:\n   - **Ollama** (recommended): [Download here](https://ollama.ai/)\n   - **LM Studio** (GUI alternative): [Download here](https://lmstudio.ai)\n\n## Step 1: Choose Your Setup (1 min)\n\n### Local Machine (Same Computer)\nEverything runs on your machine. Recommended for testing/learning.\n\n### Remote Server (Raspberry Pi, NAS, Cloud VM)\nRun on a different computer, access from another. Needs network configuration.\n\n---\n\n## Step 2: Create Configuration (1 min)\n\nCreate a new folder `open-notebook-local` and add this file:\n\n**docker-compose.yml**:\n```yaml\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --user root --pass password --bind 0.0.0.0:8000 rocksdb:/mydata/mydatabase.db\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    ports:\n      - \"8502:8502\"  # Web UI (React frontend)\n      - \"5055:5055\"  # API (required!)\n    environment:\n      # Encryption key for credential storage (required)\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database (required)\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=password\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n      - ./surreal_data:/mydata\n    depends_on:\n      - surrealdb\n    restart: always\n\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ./ollama_models:/root/.ollama\n    environment:\n      # Optional: set GPU support if available\n      - OLLAMA_NUM_GPU=0\n    restart: always\n\n```\n\n**Edit the file:**\n- Replace `change-me-to-a-secret-string` with your own secret (any string works)\n\n---\n\n## Step 3: Start Services (1 min)\n\nOpen terminal in your `open-notebook-local` folder:\n\n```bash\ndocker compose up -d\n```\n\nWait 10-15 seconds for all services to start.\n\n---\n\n## Step 4: Download a Model (2-3 min)\n\nOllama needs at least one language model. Pick one:\n\n```bash\n# Fastest & smallest (recommended for testing)\ndocker exec open-notebook-local-ollama-1 ollama pull mistral\n\n# OR: Better quality but slower\ndocker exec open-notebook-local-ollama-1 ollama pull neural-chat\n\n# OR: Even better quality, more VRAM needed\ndocker exec open-notebook-local-ollama-1 ollama pull llama2\n```\n\nThis downloads the model (will take 1-5 minutes depending on your internet).\n\n---\n\n## Step 5: Access Open Notebook (instant)\n\nOpen your browser:\n```\nhttp://localhost:8502\n```\n\nYou should see the Open Notebook interface.\n\n---\n\n## Step 6: Configure Ollama Provider (1 min)\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **Ollama**\n4. Give it a name (e.g., \"Local Ollama\")\n5. Enter the base URL: `http://ollama:11434`\n6. Click **Save**\n7. Click **Test Connection** — should show success\n8. Click **Discover Models** → **Register Models**\n\n---\n\n## Step 7: Configure Local Model (1 min)\n\n1. Go to **Settings** → **Models**\n2. Set:\n   - **Language Model**: `ollama/mistral` (or whichever model you downloaded)\n   - **Embedding Model**: `ollama/nomic-embed-text` (auto-downloads if missing)\n3. Click **Save**\n\n---\n\n## Step 8: Create Your First Notebook (1 min)\n\n1. Click **New Notebook**\n2. Name: \"My Private Research\"\n3. Click **Create**\n\n---\n\n## Step 9: Add Local Content (1 min)\n\n1. Click **Add Source**\n2. Choose **Text**\n3. Paste some text or a local document\n4. Click **Add**\n\n---\n\n## Step 10: Chat With Your Content (1 min)\n\n1. Go to **Chat**\n2. Type: \"What did you learn from this?\"\n3. Click **Send**\n4. Watch as the local Ollama model responds!\n\n---\n\n## Verification Checklist\n\n- [ ] Docker is running\n- [ ] You can access `http://localhost:8502`\n- [ ] Ollama credential is configured and tested\n- [ ] Models are registered\n- [ ] You created a notebook\n- [ ] Chat works with local model\n\n**All checked?** You have a completely **private, offline** research assistant!\n\n---\n\n## Advantages of Local Setup\n\n- **No API costs** - Free forever\n- **No internet required** - True offline capability\n- **Privacy first** - Your data never leaves your machine\n- **No subscriptions** - No monthly bills\n\n**Trade-off:** Slower than cloud models (depends on your CPU/GPU)\n\n---\n\n## Troubleshooting\n\n### \"ollama: command not found\"\n\nDocker image name might be different:\n```bash\ndocker ps  # Find the Ollama container name\ndocker exec <container_name> ollama pull mistral\n```\n\n### Model Download Stuck\n\nCheck internet connection and restart:\n```bash\ndocker compose restart ollama\n```\n\nThen retry the model pull command.\n\n### \"Address already in use\" Error\n\n```bash\ndocker compose down\ndocker compose up -d\n```\n\n### Low Performance\n\nCheck if GPU is available:\n```bash\n# Show available GPUs\ndocker exec open-notebook-local-ollama-1 ollama ps\n\n# Enable GPU in docker-compose.yml:\n# - OLLAMA_NUM_GPU=1\n```\n\nThen restart: `docker compose restart ollama`\n\n### Adding More Models\n\n```bash\n# List available models\ndocker exec open-notebook-local-ollama-1 ollama list\n\n# Pull additional model\ndocker exec open-notebook-local-ollama-1 ollama pull neural-chat\n```\n\n---\n\n## Next Steps\n\n**Now that it's running:**\n\n1. **Add Your Own Content**: PDFs, documents, articles (see 3-USER-GUIDE)\n2. **Explore Features**: Podcasts, transformations, search\n3. **Full Documentation**: [See all features](../3-USER-GUIDE/index.md)\n4. **Scale Up**: Deploy to a server with better hardware for faster responses\n5. **Benchmark Models**: Try different models to find the speed/quality tradeoff you prefer\n\n---\n\n## Alternative: Using LM Studio Instead of Ollama\n\n**Prefer a GUI?** LM Studio is easier for non-technical users:\n\n1. Download LM Studio: https://lmstudio.ai\n2. Open the app, download a model from the library\n3. Go to \"Local Server\" tab, start server (port 1234)\n4. In Open Notebook, go to **Settings** → **API Keys**\n5. Click **Add Credential** → Select **OpenAI-Compatible**\n6. Enter base URL: `http://host.docker.internal:1234/v1`\n7. Enter API key: `lm-studio` (placeholder)\n8. Click **Save**, then **Test Connection**\n9. Configure in Settings → Models → Select your LM Studio model\n\n**Note**: LM Studio runs outside Docker, use `host.docker.internal` to connect.\n\n---\n\n## Going Further\n\n- **Switch models**: Change in Settings → Models anytime\n- **Add more models**:\n  - Ollama: Run `ollama pull <model>`, then re-discover models from the credential\n  - LM Studio: Download from the app library\n- **Deploy to server**: Same docker-compose.yml works anywhere\n- **Use cloud hybrid**: Keep some local models, add cloud provider credentials for complex tasks\n\n---\n\n## Common Model Choices\n\n| Model | Speed | Quality | VRAM | Best For |\n|-------|-------|---------|------|----------|\n| **mistral** | Fast | Good | 4GB | Testing, general use |\n| **neural-chat** | Medium | Better | 6GB | Balanced, recommended |\n| **llama2** | Slow | Best | 8GB+ | Complex reasoning |\n| **phi** | Very Fast | Fair | 2GB | Minimal hardware |\n\n---\n\n**Need Help?** Join our [Discord community](https://discord.gg/37XJPXfz2w) - many users run local setups!\n"
  },
  {
    "path": "docs/0-START-HERE/quick-start-openai.md",
    "content": "# Quick Start - OpenAI (5 minutes)\n\nGet Open Notebook running with OpenAI's GPT models. Fast, powerful, and simple.\n\n## Prerequisites\n\n1. **Docker Desktop** installed\n   - [Download here](https://www.docker.com/products/docker-desktop/)\n   - Already have it? Skip to step 2\n\n2. **OpenAI API Key** (required)\n   - Go to https://platform.openai.com/api-keys\n   - Create account → Create new secret key\n   - Add at least $5 in credits to your account\n   - Copy the key (starts with `sk-`)\n\n## Step 1: Create Configuration (1 min)\n\nCreate a new folder `open-notebook` and add this file:\n\n**docker-compose.yml**:\n```yaml\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --user root --pass password --bind 0.0.0.0:8000 rocksdb:/mydata/mydatabase.db\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    pull_policy: always\n    ports:\n      - \"8502:8502\"  # Web UI\n      - \"5055:5055\"  # API\n    environment:\n      # Encryption key for credential storage (required)\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database (required)\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=password\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n    restart: always\n\n```\n\n**Edit the file:**\n- Replace `change-me-to-a-secret-string` with your own secret (any string works)\n\n---\n\n## Step 2: Start Services (1 min)\n\nOpen terminal in your `open-notebook` folder:\n\n```bash\ndocker compose up -d\n```\n\nWait 15-20 seconds for services to start.\n\n---\n\n## Step 3: Access Open Notebook (instant)\n\nOpen your browser:\n```\nhttp://localhost:8502\n```\n\nYou should see the Open Notebook interface!\n\n---\n\n## Step 4: Configure Your OpenAI Provider (1 min)\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **OpenAI**\n4. Give it a name (e.g., \"My OpenAI Key\")\n5. Paste your OpenAI API key\n6. Click **Save**\n7. Click **Test Connection** — should show success\n8. Click **Discover Models** → **Register Models**\n\nYour OpenAI models are now available!\n\n---\n\n## Step 5: Create Your First Notebook (1 min)\n\n1. Click **New Notebook**\n2. Name: \"My Research\"\n3. Click **Create**\n\n---\n\n## Step 6: Add a Source (1 min)\n\n1. Click **Add Source**\n2. Choose **Web Link**\n3. Paste: `https://en.wikipedia.org/wiki/Artificial_intelligence`\n4. Click **Add**\n5. Wait for processing (30-60 seconds)\n\n---\n\n## Step 7: Chat With Your Content (1 min)\n\n1. Go to **Chat**\n2. Type: \"What is artificial intelligence?\"\n3. Click **Send**\n4. Watch as GPT responds with information from your source!\n\n---\n\n## Verification Checklist\n\n- [ ] Docker is running\n- [ ] You can access `http://localhost:8502`\n- [ ] OpenAI credential is configured and tested\n- [ ] You created a notebook\n- [ ] You added a source\n- [ ] Chat works\n\n**All checked?** You have a fully working AI research assistant!\n\n---\n\n## Using Different Models\n\nIn your notebook, go to **Settings** → **Models** to choose:\n- `gpt-4o` - Best quality (recommended)\n- `gpt-4o-mini` - Fast and cheap (good for testing)\n\n---\n\n## Troubleshooting\n\n### \"Port 8502 already in use\"\n\nChange the port in docker-compose.yml:\n```yaml\nports:\n  - \"8503:8502\"  # Use 8503 instead\n```\n\nThen access at `http://localhost:8503`\n\n### \"API key not working\"\n\n1. Go to **Settings** → **API Keys**\n2. Click **Test Connection** on your OpenAI credential\n3. If it fails, verify your key at https://platform.openai.com\n4. Delete the credential and create a new one with the correct key\n\n### \"Cannot connect to server\"\n\n```bash\ndocker ps  # Check all services running\ndocker compose logs  # View logs\ndocker compose restart  # Restart everything\n```\n\n---\n\n## Next Steps\n\n1. **Add Your Own Content**: PDFs, web links, documents\n2. **Explore Features**: Podcasts, transformations, search\n3. **Full Documentation**: [See all features](../3-USER-GUIDE/index.md)\n\n---\n\n## Cost Estimate\n\nOpenAI pricing (approximate):\n- **Conversation**: $0.01-0.10 per 1K tokens\n- **Embeddings**: $0.02 per 1M tokens\n- **Typical usage**: $1-5/month for light use, $20-50/month for heavy use\n\nCheck https://openai.com/pricing for current rates.\n\n---\n\n**Need help?** Join our [Discord community](https://discord.gg/37XJPXfz2w)!\n"
  },
  {
    "path": "docs/1-INSTALLATION/docker-compose.md",
    "content": "# Docker Compose Installation (Recommended)\n\nMulti-container setup with separate services. **Best for most users.**\n\n> **Alternative Registry:** All images are available on both Docker Hub (`lfnovo/open_notebook`) and GitHub Container Registry (`ghcr.io/lfnovo/open-notebook`). Use GHCR if Docker Hub is blocked or you prefer GitHub-native workflows.\n\n## Prerequisites\n\n- **Docker Desktop** installed ([Download](https://www.docker.com/products/docker-desktop/))\n- **5-10 minutes** of your time\n- **API key** for at least one AI provider (OpenAI recommended for beginners)\n\n## Step 1: Get docker-compose.yml (1 min)\n\n**Option A: Download from repository**\n```bash\ncurl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml\n```\n\n**Option B: Use the official file from the repo**\n\nThe official `docker-compose.yml` is in the root of our repository: [View on GitHub](https://github.com/lfnovo/open-notebook/blob/main/docker-compose.yml)\n\nCopy that file to your project folder.\n\n**Option C: Create manually**\n\nCreate a file called `docker-compose.yml` with this content:\n\n```yaml\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root  # Required for bind mounts on Linux\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    restart: always\n    pull_policy: always\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"  # Web UI\n      - \"5055:5055\"  # REST API\n    environment:\n      # REQUIRED: Change this to your own secret string\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database connection (default values - no need to change)\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n    restart: always\n    pull_policy: always\n```\n\n**Edit the file:**\n- Replace `change-me-to-a-secret-string` with your own secret (any string works, e.g., `my-super-secret-key-123`)\n\n---\n\n## Step 2: Start Services (2 min)\n\nOpen terminal in the `open-notebook` folder:\n\n```bash\ndocker compose up -d\n```\n\nWait 15-20 seconds for all services to start:\n```\n✅ surrealdb running on :8000\n✅ open_notebook running on :8502 (UI) and :5055 (API)\n```\n\nCheck status:\n```bash\ndocker compose ps\n```\n\n---\n\n## Step 3: Verify Installation (1 min)\n\n**API Health:**\n```bash\ncurl http://localhost:5055/health\n# Should return: {\"status\": \"healthy\"}\n```\n\n**Frontend Access:**\nOpen browser to:\n```\nhttp://localhost:8502\n```\n\nYou should see the Open Notebook interface!\n\n---\n\n## Step 4: Configure AI Provider (2 min)\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select your provider (e.g., OpenAI, Anthropic, Google)\n4. Give it a name, paste your API key\n5. Click **Save**\n6. Click **Test Connection** — should show success\n7. Click **Discover Models** → **Register Models**\n\nYour models are now available!\n\n> **Need an API key?** Get one from your chosen provider:\n> - **OpenAI**: https://platform.openai.com/api-keys\n> - **Anthropic**: https://console.anthropic.com/\n> - **Google**: https://aistudio.google.com/\n> - **Groq**: https://console.groq.com/\n\n---\n\n## Step 5: First Notebook (2 min)\n\n1. Click **New Notebook**\n2. Name: \"My Research\"\n3. Description: \"Getting started\"\n4. Click **Create**\n\nDone! You now have a fully working Open Notebook instance.\n\n---\n\n## Configuration\n\n### Adding Ollama (Free Local Models)\n\nInstead of manually editing, use our ready-made example:\n\n```bash\n# Download the Ollama example\ncurl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/examples/docker-compose-ollama.yml\n\n# Or copy from repo\ncp examples/docker-compose-ollama.yml docker-compose.yml\n```\n\nSee [examples/docker-compose-ollama.yml](../../examples/docker-compose-ollama.yml) for the complete setup.\n\n**Manual setup:** Add this to your existing `docker-compose.yml`:\n\n```yaml\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ollama_models:/root/.ollama\n    restart: always\n\nvolumes:\n  ollama_models:\n```\n\nThen restart and pull a model:\n```bash\ndocker compose restart\ndocker exec open-notebook-local-ollama-1 ollama pull mistral\n```\n\nConfigure Ollama in the Settings UI:\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **Ollama**\n3. Enter base URL: `http://ollama:11434`\n4. Click **Save**, then **Test Connection**\n5. Click **Discover Models** → **Register Models**\n\n---\n\n## Environment Variables Reference\n\n| Variable | Purpose | Example |\n|----------|---------|---------|\n| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials | `my-secret-key` |\n| `SURREAL_URL` | Database connection | `ws://surrealdb:8000/rpc` |\n| `SURREAL_USER` | Database user | `root` |\n| `SURREAL_PASSWORD` | Database password | `root` |\n| `SURREAL_NAMESPACE` | Database namespace | `open_notebook` |\n| `SURREAL_DATABASE` | Database name | `open_notebook` |\n| `API_URL` | API external URL | `http://localhost:5055` |\n\nSee [Environment Reference](../5-CONFIGURATION/environment-reference.md) for complete list.\n\n---\n\n## Common Tasks\n\n### Stop Services\n```bash\ndocker compose down\n```\n\n### View Logs\n```bash\n# All services\ndocker compose logs -f\n\n# Specific service\ndocker compose logs -f api\n```\n\n### Restart Services\n```bash\ndocker compose restart\n```\n\n### Update to Latest Version\n```bash\ndocker compose down\ndocker compose pull\ndocker compose up -d\n```\n\n### Remove All Data\n```bash\ndocker compose down -v\n```\n\n---\n\n## Troubleshooting\n\n### \"Cannot connect to API\" Error\n\n1. Check if Docker is running:\n```bash\ndocker ps\n```\n\n2. Check if services are running:\n```bash\ndocker compose ps\n```\n\n3. Check API logs:\n```bash\ndocker compose logs api\n```\n\n4. Wait longer - services can take 20-30 seconds to start on first run\n\n---\n\n### Port Already in Use\n\nIf you get \"Port 8502 already in use\", change the port:\n\n```yaml\nports:\n  - \"8503:8502\"  # Use 8503 instead\n  - \"5055:5055\"  # Keep API port same\n```\n\nThen access at `http://localhost:8503`\n\n---\n\n### Credential Issues\n\n1. Go to **Settings** → **API Keys**\n2. Click **Test Connection** on the credential\n3. If it fails, verify key at provider's website\n4. Check you have credits in your account\n5. Delete and re-create the credential if needed\n\n---\n\n### Database Connection Issues\n\nCheck SurrealDB is running:\n```bash\ndocker compose logs surrealdb\n```\n\nReset database:\n```bash\ndocker compose down -v\ndocker compose up -d\n```\n\n### Database Permission Denied (Linux)\n\nIf you see `Permission denied` or `Failed to create RocksDB directory` in SurrealDB logs:\n\n```bash\ndocker compose logs surrealdb | grep -i permission\n```\n\nThis happens because SurrealDB runs as a non-root user but Docker creates bind mount directories as root. Add `user: root` to the surrealdb service:\n\n```yaml\nsurrealdb:\n  image: surrealdb/surrealdb:v2\n  user: root  # Fix for Linux bind mount permissions\n  # ... rest of config\n```\n\nThen restart:\n```bash\ndocker compose down -v\ndocker compose up -d\n```\n\n---\n\n## Alternative Setups\n\nLooking for different configurations? Check out our [examples/](../../examples/) folder:\n\n- **[Ollama Setup](../../examples/docker-compose-ollama.yml)** - Run local AI models (free, private)\n- **[Single Container](../../examples/docker-compose-single.yml)** - All-in-one container (deprecated, not recommended)\n- **[Development](../../examples/docker-compose-dev.yml)** - For contributors and developers\n\nEach example includes detailed comments and usage instructions.\n\n---\n\n## Next Steps\n\n1. **Add Content**: Sources, notebooks, documents\n2. **Configure Models**: Settings → Models (choose your preferences)\n3. **Explore Features**: Chat, search, transformations\n4. **Read Guide**: [User Guide](../3-USER-GUIDE/index.md)\n\n---\n\n## Production Deployment\n\nFor production use, see:\n- [Security Hardening](../5-CONFIGURATION/security.md)\n- [Reverse Proxy](../5-CONFIGURATION/reverse-proxy.md)\n\n---\n\n## Getting Help\n\n- **Discord**: [Community support](https://discord.gg/37XJPXfz2w)\n- **Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n- **Docs**: [Full documentation](../index.md)\n"
  },
  {
    "path": "docs/1-INSTALLATION/from-source.md",
    "content": "# From Source Installation\n\nClone the repository and run locally. **For developers and contributors.**\n\n## Prerequisites\n\n- **Python 3.11+** - [Download](https://www.python.org/)\n- **Node.js 18+** - [Download](https://nodejs.org/)\n- **Git** - [Download](https://git-scm.com/)\n- **Docker** (for SurrealDB) - [Download](https://docker.com/)\n- **uv** (Python package manager) - `curl -LsSf https://astral.sh/uv/install.sh | sh`\n- API key from OpenAI or similar (or use Ollama for free)\n\n## Quick Setup (10 minutes)\n\n### 1. Clone Repository\n\n```bash\ngit clone https://github.com/lfnovo/open-notebook.git\ncd open-notebook\n\n# If you forked it:\ngit clone https://github.com/YOUR_USERNAME/open-notebook.git\ncd open-notebook\ngit remote add upstream https://github.com/lfnovo/open-notebook.git\n```\n\n### 2. Install Python Dependencies\n\n```bash\nuv sync\nuv pip install python-magic\n```\n\n#### 2.1 Alternative: Conda Setup (Optional)\n\nIf you prefer using **Conda** to manage your environments, follow these steps instead of the standard `uv sync`:\n\n```bash\n# Create and activate the environment\nconda create -n open-notebook python=3.11 -y\nconda activate open-notebook\n\n# Install uv inside conda to maintain compatibility with the Makefile\nconda install -c conda-forge uv nodejs -y\n\n# Sync dependencies\nuv sync\n```\n\n> **Note**: Installing `uv` inside your Conda environment ensures that commands like `make start-all` and `make api` continue to work seamlessly.\n\n### 3. Start SurrealDB\n\n```bash\n# Terminal 1\nmake database\n# or: docker compose up surrealdb\n```\n\n### 4. Set Environment Variables\n\n```bash\ncp .env.example .env\n# Edit .env and set:\n# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n```\n\nAfter starting the app, configure AI providers via the **Settings → API Keys** UI in the browser.\n\n### 5. Start API\n\n```bash\n# Terminal 2\nmake api\n# or: uv run --env-file .env uvicorn api.main:app --host 0.0.0.0 --port 5055\n```\n\n### 6. Start Frontend\n\n```bash\n# Terminal 3\ncd frontend && npm install && npm run dev\n```\n\n### 7. Access\n\n- **Frontend**: http://localhost:3000\n- **API Docs**: http://localhost:5055/docs\n- **Database**: http://localhost:8000\n\n### 8. Configure AI Provider\n\n1. Open http://localhost:3000\n2. Go to **Settings** → **API Keys**\n3. Click **Add Credential** → Select your provider → Paste API key\n4. Click **Save**, then **Test Connection**\n5. Click **Discover Models** → **Register Models**\n\n---\n\n## Development Workflow\n\n### Code Quality\n\n```bash\n# Format and lint Python\nmake ruff\n# or: ruff check . --fix\n\n# Type checking\nmake lint\n# or: uv run python -m mypy .\n```\n\n### Run Tests\n\n```bash\nuv run pytest tests/\n```\n\n### Common Commands\n\n```bash\n# Start everything\nmake start-all\n\n# View API docs\nopen http://localhost:5055/docs\n\n# Check database migrations\n# (Auto-run on API startup)\n\n# Clean up\nmake clean\n```\n\n---\n\n## Troubleshooting\n\n### Python version too old\n\n```bash\npython --version  # Check version\nuv sync --python 3.11  # Use specific version\n```\n\n### npm: command not found\n\nInstall Node.js from https://nodejs.org/\n\n### Database connection errors\n\n```bash\ndocker ps  # Check SurrealDB running\ndocker logs surrealdb  # View logs\n```\n\n### Port 5055 already in use\n\n```bash\n# Use different port\nuv run uvicorn api.main:app --port 5056\n```\n\n---\n\n## Next Steps\n\n1. Read [Development Guide](../7-DEVELOPMENT/quick-start.md)\n2. See [Architecture Overview](../7-DEVELOPMENT/architecture.md)\n3. Check [Contributing Guide](../7-DEVELOPMENT/contributing.md)\n\n---\n\n## Getting Help\n\n- **Discord**: [Community](https://discord.gg/37XJPXfz2w)\n- **Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n"
  },
  {
    "path": "docs/1-INSTALLATION/index.md",
    "content": "# Installation Guide\n\nChoose your installation route based on your setup and use case.\n\n## Quick Decision: Which Route?\n\n### 🚀 I want the easiest setup (Recommended for most)\n**→ [Docker Compose](docker-compose.md)** - Multi-container setup, production-ready\n- ✅ All features working\n- ✅ Clear separation of services\n- ✅ Easy to scale\n- ✅ Works on Mac, Windows, Linux\n- ⏱️ 5 minutes to running\n\n---\n\n### 🏠 I want everything in one container (Simplified)\n**→ [Single Container](single-container.md)** - All-in-one for simple deployments\n- ✅ Minimal configuration\n- ✅ Lower resource usage\n- ✅ Good for shared hosting\n- ✅ Works on PikaPods, Railway, etc.\n- ⏱️ 3 minutes to running\n\n---\n\n### 👨‍💻 I want to develop/contribute (Developers only)\n**→ [From Source](from-source.md)** - Clone repo, set up locally\n- ✅ Full control over code\n- ✅ Easy to debug\n- ✅ Can modify and test\n- ⚠️ Requires Python 3.11+, Node.js\n- ⏱️ 10 minutes to running\n\n---\n\n\n## System Requirements\n\n### Minimum\n- **RAM**: 4GB\n- **Storage**: 2GB for app + space for documents\n- **CPU**: Any modern processor\n- **Network**: Internet (optional for offline setup)\n\n### Recommended\n- **RAM**: 8GB+\n- **Storage**: 10GB+ for documents and models\n- **CPU**: Multi-core processor\n- **GPU**: Optional (speeds up local AI models)\n\n---\n\n## AI Provider Options\n\n### Cloud-Based (Pay-as-you-go)\n- **OpenAI** - GPT-4, GPT-4o, fast and capable\n- **Anthropic (Claude)** - Claude 3.5 Sonnet, excellent reasoning\n- **Google Gemini** - Multimodal, cost-effective\n- **Groq** - Ultra-fast inference\n- **Others**: Mistral, DeepSeek, xAI, OpenRouter\n\n**Cost**: Usually $0.01-$0.10 per 1K tokens\n**Speed**: Fast (sub-second)\n**Privacy**: Your data sent to cloud\n\n### Local (Free, Private)\n- **Ollama** - Run open-source models locally\n- **LM Studio** - Desktop app for local models\n- **Hugging Face models** - Download and run\n\n**Cost**: $0 (just electricity)\n**Speed**: Depends on your hardware (slow to medium)\n**Privacy**: 100% offline\n\n---\n\n## Choose a Route\n\n**Already know which way to go?** Pick your installation path:\n\n- [Docker Compose](docker-compose.md) - **Most users**\n- [Single Container](single-container.md) - **Shared hosting**\n- [From Source](from-source.md) - **Developers**\n\n> **Privacy-first?** Any installation method works with Ollama for 100% local AI. See [Local Quick Start](../0-START-HERE/quick-start-local.md).\n\n---\n\n## Pre-Installation Checklist\n\nBefore installing, you'll need:\n\n- [ ] **Docker** (for Docker routes) or **Node.js 18+** (for source)\n- [ ] **AI Provider API key** (OpenAI, Anthropic, etc.) OR willingness to use free local models\n- [ ] **At least 4GB RAM** available\n- [ ] **Stable internet** (or offline setup with Ollama)\n\n---\n\n## Detailed Installation Instructions\n\n### For Docker Users\n1. Install [Docker Desktop](https://docker.com/products/docker-desktop)\n2. Choose: [Docker Compose](docker-compose.md) or [Single Container](single-container.md)\n3. Follow the step-by-step guide\n4. Access at `http://localhost:8502`\n\n### For Source Installation (Developers)\n1. Have Python 3.11+, Node.js 18+, Git installed\n2. Follow [From Source](from-source.md)\n3. Run `make start-all`\n4. Access at `http://localhost:8502` (frontend) or `http://localhost:5055` (API)\n\n---\n\n## After Installation\n\nOnce you're up and running:\n\n1. **Configure Models** - Choose your AI provider in Settings\n2. **Create First Notebook** - Start organizing research\n3. **Add Sources** - PDFs, web links, documents\n4. **Explore Features** - Chat, search, transformations\n5. **Read Full Guide** - [User Guide](../3-USER-GUIDE/index.md)\n\n---\n\n## Troubleshooting During Installation\n\n**Having issues?** Check the troubleshooting section in your chosen installation guide, or see [Quick Fixes](../6-TROUBLESHOOTING/quick-fixes.md).\n\n---\n\n## Need Help?\n\n- **Discord**: [Join community](https://discord.gg/37XJPXfz2w)\n- **GitHub Issues**: [Report problems](https://github.com/lfnovo/open-notebook/issues)\n- **Docs**: See [Full Documentation](../index.md)\n\n---\n\n## Production Deployment\n\nInstalling for production use? See additional resources:\n\n- [Security Hardening](../5-CONFIGURATION/security.md)\n- [Reverse Proxy Setup](../5-CONFIGURATION/reverse-proxy.md)\n- [Performance Tuning](../5-CONFIGURATION/advanced.md)\n\n---\n\n**Ready to install?** Pick a route above! ⬆️\n"
  },
  {
    "path": "docs/1-INSTALLATION/single-container.md",
    "content": "# Single Container Installation\n\nAll-in-one container setup. **Simpler than Docker Compose, but less flexible.**\n\n**Best for:** PikaPods, Railway, shared hosting, minimal setups\n\n> **Alternative Registry:** Images available on both Docker Hub (`lfnovo/open_notebook:v1-latest-single`) and GitHub Container Registry (`ghcr.io/lfnovo/open-notebook:v1-latest-single`).\n\n> **Note**: While this is a simple way to get started, we recommend [Docker Compose](docker-compose.md) for most users. Docker Compose is more flexible and will make it easier if we add more services to the setup in the future. This single-container option is best for platforms that specifically require it (PikaPods, Railway, etc.).\n\n## Prerequisites\n\n- Docker installed (for local testing)\n- API key from OpenAI, Anthropic, or another provider\n- 5 minutes\n\n## Quick Setup\n\n### For Local Testing (Docker)\n\n```yaml\n# docker-compose.yml\nservices:\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    ports:\n      - \"8502:8502\"  # Web UI (React frontend)\n      - \"5055:5055\"  # API\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n      - SURREAL_URL=ws://localhost:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./data:/app/data\n    restart: always\n```\n\nRun:\n```bash\ndocker compose up -d\n```\n\nAccess: `http://localhost:8502`\n\nThen configure your AI provider:\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select your provider → Paste API key\n3. Click **Save**, then **Test Connection**\n4. Click **Discover Models** → **Register Models**\n\n### For Cloud Platforms\n\n**PikaPods:**\n1. Click \"New App\"\n2. Search \"Open Notebook\"\n3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)\n4. Click \"Deploy\"\n5. Open the app → Go to **Settings → API Keys** to configure your AI provider\n\n**Railway:**\n1. Create new project\n2. Add `lfnovo/open_notebook:v1-latest-single`\n3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)\n4. Deploy\n5. Open the app → Go to **Settings → API Keys** to configure your AI provider\n\n**Render:**\n1. Create new Web Service\n2. Use Docker image: `lfnovo/open_notebook:v1-latest-single`\n3. Set environment variables in dashboard (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)\n4. Configure persistent disk for `/app/data` and `/mydata`\n\n**DigitalOcean App Platform:**\n1. Create new app from Docker Hub\n2. Use image: `lfnovo/open_notebook:v1-latest-single`\n3. Set port to 8502\n4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)\n5. Configure persistent storage\n\n**Heroku:**\n```bash\n# Using heroku.yml\nheroku container:push web\nheroku container:release web\nheroku config:set OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key\n```\n\n**Coolify:**\n1. Add new service → Docker Image\n2. Image: `lfnovo/open_notebook:v1-latest-single`\n3. Port: 8502\n4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)\n5. Enable persistent volumes\n6. Coolify handles HTTPS automatically\n\n---\n\n## Environment Variables\n\n| Variable | Purpose | Example |\n|----------|---------|---------|\n| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials (required) | `my-secret-key` |\n| `SURREAL_URL` | Database | `ws://localhost:8000/rpc` |\n| `SURREAL_USER` | DB user | `root` |\n| `SURREAL_PASSWORD` | DB password | `root` |\n| `SURREAL_NAMESPACE` | DB namespace | `open_notebook` |\n| `SURREAL_DATABASE` | DB name | `open_notebook` |\n| `API_URL` | External URL (for remote access) | `https://myapp.example.com` |\n\nAI provider API keys are configured via the **Settings → API Keys** UI after deployment.\n\n---\n\n## Limitations vs Docker Compose\n\n| Feature | Single Container | Docker Compose |\n|---------|------------------|-----------------|\n| Setup time | 2 minutes | 5 minutes |\n| Complexity | Minimal | Moderate |\n| Services | All bundled | Separated |\n| Scalability | Limited | Excellent |\n| Memory usage | ~800MB | ~1.2GB |\n\n---\n\n## Next Steps\n\nSame as Docker Compose setup - just access via `http://localhost:8502` (local) or your platform's URL (cloud).\n\n1. Go to **Settings → API Keys** to add your AI provider credential\n2. **Test Connection** and **Discover Models**\n\nSee [Docker Compose](docker-compose.md) for full post-install guide.\n"
  },
  {
    "path": "docs/2-CORE-CONCEPTS/ai-context-rag.md",
    "content": "# AI Context & RAG - How Open Notebook Uses Your Research\n\nOpen Notebook uses different approaches to make AI models aware of your research depending on the feature. This section explains **RAG** (used in Ask) and **full-content context** (used in Chat).\n\n---\n\n## The Problem: Making AI Aware of Your Data\n\n### Traditional Approaches (and their problems)\n\n**Option 1: Fine-Tuning**\n- Train the model on your data\n- Pro: Model becomes specialized\n- Con: Expensive, slow, permanent (can't unlearn)\n\n**Option 2: Send Everything to Cloud**\n- Upload all your data to ChatGPT/Claude API\n- Pro: Works well, fast\n- Con: Privacy nightmare, data leaves your control, expensive\n\n**Option 3: Ignore Your Data**\n- Just use the base model without your research\n- Pro: Private, free\n- Con: AI doesn't know anything about your specific topic\n\n### Open Notebook's Dual Approach\n\n**For Chat**: Sends the entire selected content to the LLM\n- Simple and transparent: You select sources, they're sent in full\n- Maximum context: AI sees everything you choose\n- You control which sources are included\n\n**For Ask (RAG)**: Retrieval-Augmented Generation\n- RAG = Retrieval-Augmented Generation\n- The insight: *Search your content, find relevant pieces, send only those*\n- Automatic: AI decides what's relevant based on your question\n\n---\n\n## How RAG Works: Three Stages\n\n### Stage 1: Content Preparation\n\nWhen you upload a source, Open Notebook prepares it for retrieval:\n\n```\n1. EXTRACT TEXT\n   PDF → text\n   URL → webpage text\n   Audio → transcribed text\n   Video → subtitles + transcription\n\n2. CHUNK INTO PIECES\n   Long documents → break into ~500-word chunks\n   Why? AI context has limits; smaller pieces are more precise\n\n3. CREATE EMBEDDINGS\n   Each chunk → semantic vector (numbers representing meaning)\n   Why? Allows finding chunks by similarity, not just keywords\n\n4. STORE IN DATABASE\n   Chunks + embeddings + metadata → searchable storage\n```\n\n**Example:**\n```\nSource: \"AI Safety Research 2026\" (50-page PDF)\n↓\nExtracted: 50 pages of text\n↓\nChunked: 150 chunks (~500 words each)\n↓\nEmbedded: Each chunk gets a vector (1536 numbers for OpenAI)\n↓\nStored: Ready for search\n```\n\n---\n\n### Stage 2: Query Time (What You Search For)\n\nWhen you ask a question, the system finds relevant content:\n\n```\n1. YOU ASK A QUESTION\n   \"What does the paper say about alignment?\"\n\n2. SYSTEM CONVERTS QUESTION TO EMBEDDING\n   Your question → vector (same way chunks are vectorized)\n\n3. SIMILARITY SEARCH\n   Find chunks most similar to your question\n   (using vector math, not keyword matching)\n\n4. RETURN TOP RESULTS\n   Usually top 5-10 most similar chunks\n\n5. YOU GET BACK\n   ✓ The relevant chunks\n   ✓ Where they came from (sources + page numbers)\n   ✓ Relevance scores\n```\n\n**Example:**\n```\nQ: \"What does the paper say about alignment?\"\n↓\nQ vector: [0.23, -0.51, 0.88, ..., 0.12]\n↓\nSearch: Compare to all chunk vectors\n↓\nResults:\n  - Chunk 47 (alignment section): similarity 0.94\n  - Chunk 63 (safety approaches): similarity 0.88\n  - Chunk 12 (related work): similarity 0.71\n```\n\n---\n\n### Stage 3: Augmentation (How AI Uses It)\n\nNow you have the relevant pieces. The AI uses them:\n\n```\nSYSTEM BUILDS A PROMPT:\n  \"You are an AI research assistant.\n\n   The user has the following research materials:\n   [CHUNK 47 CONTENT]\n   [CHUNK 63 CONTENT]\n\n   User question: 'What does the paper say about alignment?'\n\n   Answer based on the above materials.\"\n\nAI RESPONDS:\n  \"Based on the research materials, the paper approaches\n   alignment through [pulls from chunks] and emphasizes\n   [pulls from chunks]...\"\n\nSYSTEM ADDS CITATIONS:\n  \"- See research materials page 15 for approach details\n   - See research materials page 23 for emphasis on X\"\n```\n\n---\n\n## Two Search Modes: Exact vs. Semantic\n\nOpen Notebook provides two different search strategies for different goals.\n\n### 1. Text Search (Keyword Matching)\n\n**How it works:**\n- Uses BM25 ranking (the same algorithm Google uses)\n- Finds chunks containing your keywords\n- Ranks by relevance (how often keywords appear, position, etc.)\n\n**When to use:**\n- \"I remember the exact phrase 'X' and want to find it\"\n- \"I'm looking for a specific name or number\"\n- \"I need the exact quote\"\n\n**Example:**\n```\nSearch: \"transformer architecture\"\nResults:\n  1. Chunk with \"transformer architecture\" 3 times\n  2. Chunk with \"transformer\" and \"architecture\" separately\n  3. Chunk with \"transformer-based models\"\n```\n\n### 2. Vector Search (Semantic Similarity)\n\n**How it works:**\n- Converts your question to a vector (number embedding)\n- Finds chunks with similar vectors\n- No keywords needed—finds conceptually similar content\n\n**When to use:**\n- \"Find content about X (without saying exact words)\"\n- \"I'm exploring a concept\"\n- \"Find similar ideas even if worded differently\"\n\n**Example:**\n```\nSearch: \"what's the mechanism for model understanding?\"\nResults (no \"understanding\" in any chunk):\n  1. Chunk about interpretability and mechanistic analysis\n  2. Chunk about feature analysis\n  3. Chunk about attention mechanisms\n\nWhy? The vectors are semantically similar to your concept.\n```\n\n---\n\n## Context Management: Your Control Panel\n\nHere's where Open Notebook is different: **You decide what the AI sees.**\n\n### The Three Levels\n\n| Level | What's Shared | Example Cost | Privacy | Use Case |\n|-------|---------------|--------------|---------|----------|\n| **Full Content** | Complete source text | 10,000 tokens | Low | Detailed analysis, close reading |\n| **Summary Only** | AI-generated summary | 2,000 tokens | High | Background material, references |\n| **Not in Context** | Nothing | 0 tokens | Max | Confidential, irrelevant, or archived |\n\n### How It Works\n\n**Full Content:**\n```\nYou: \"What's the methodology in paper A?\"\nSystem:\n  - Searches paper A\n  - Retrieves full paper content (or large chunks)\n  - Sends to AI: \"Here's paper A. Answer about methodology.\"\n  - AI analyzes complete content\n  - Result: Detailed, precise answer\n```\n\n**Summary Only:**\n```\nYou: \"I want to chat using paper A and B\"\nSystem:\n  - For Paper A: Sends AI-generated summary (not full text)\n  - For Paper B: Sends full content (detailed analysis)\n  - AI sees 2 sources but in different detail levels\n  - Result: Uses summaries for context, details for focused content\n```\n\n**Not in Context:**\n```\nYou: \"I have 10 sources but only want 5 in context\"\nSystem:\n  - Paper A-E: In context (sent to AI)\n  - Paper F-J: Not in context (AI can't see them, doesn't search them)\n  - AI never knows these 5 sources exist\n  - Result: Tight, focused context\n```\n\n### Why This Matters\n\n**Privacy**: You control what leaves your system\n```\nScenario: Confidential company docs + public research\nControl: Public research in context → Confidential docs excluded\nResult: AI never sees confidential content\n```\n\n**Cost**: You control token usage\n```\nScenario: 100 sources for background + 5 for detailed analysis\nControl: Full content for 5 detailed, summaries for 95 background\nResult: 80% lower token cost than sending everything\n```\n\n**Quality**: You control what the AI focuses on\n```\nScenario: 20 sources, question requires deep analysis\nControl: Full content for relevant source, exclude others\nResult: AI doesn't get distracted; gives better answer\n```\n\n---\n\n## The Difference: Chat vs. Ask\n\n**IMPORTANT**: These use completely different approaches!\n\n### Chat: Full-Content Context (NO RAG)\n\n**How it works:**\n```\nYOU:\n  1. Select which sources to include in context\n  2. Set context level (full/summary/excluded)\n  3. Ask question\n\nSYSTEM:\n  - Takes ALL selected sources (respecting context levels)\n  - Sends the ENTIRE content to the LLM at once\n  - NO search, NO retrieval, NO chunking\n  - AI sees everything you selected\n\nAI:\n  - Responds based on the full content you provided\n  - Can reference any part of selected sources\n  - Conversational: context stays for follow-ups\n```\n\n**Use this when**:\n- You know which sources are relevant\n- You want conversational back-and-forth\n- You want AI to see the complete context\n- You're doing close reading or analysis\n\n**Advantages:**\n- Simple and transparent\n- AI sees everything (no missed content)\n- Conversational flow\n\n**Limitations:**\n- Limited by LLM context window\n- You must manually select relevant sources\n- Sends more tokens (higher cost with many sources)\n\n---\n\n### Ask: RAG - Automatic Retrieval\n\n**How it works:**\n```\nYOU:\n  Ask one complex question\n\nSYSTEM:\n  1. Analyzes your question\n  2. Searches across ALL your sources automatically\n  3. Finds relevant chunks using vector similarity\n  4. Retrieves only the most relevant pieces\n  5. Sends ONLY those chunks to the LLM\n  6. Synthesizes into comprehensive answer\n\nAI:\n  - Sees ONLY the retrieved chunks (not full sources)\n  - Answers based on what was found to be relevant\n  - One-shot answer (not conversational)\n```\n\n**Use this when**:\n- You have many sources and don't know which are relevant\n- You want the AI to search automatically\n- You need a comprehensive answer to a complex question\n- You want to minimize tokens sent to LLM\n\n**Advantages:**\n- Automatic search (you don't pick sources)\n- Works across many sources at once\n- Cost-effective (sends only relevant chunks)\n\n**Limitations:**\n- Not conversational (single question/answer)\n- AI only sees retrieved chunks (might miss context)\n- Search quality depends on how well question matches content\n\n---\n\n## What This Means: Privacy by Design\n\nOpen Notebook's RAG approach gives you something you don't get with ChatGPT or Claude directly:\n\n**You control the boundary between:**\n- What stays private (on your system)\n- What goes to AI (explicitly chosen)\n- What the AI can see (context levels)\n\n### The Audit Trail\n\nBecause everything is retrieved explicitly, you can ask:\n- \"Which sources did the AI use for this answer?\" → See citations\n- \"What exactly did the AI see?\" → See chunks in context level\n- \"Is the AI's claim actually in my sources?\" → Verify citation\n\nThis prevents hallucinations or misrepresentation better than most systems.\n\n---\n\n## How Embeddings Work (Simplified)\n\nThe magic of semantic search comes from embeddings. Here's the intuition:\n\n### The Idea\nInstead of storing text, store it as a list of numbers (vectors) that represent \"meaning.\"\n\n```\nChunk: \"The transformer uses attention mechanisms\"\nVector: [0.23, -0.51, 0.88, 0.12, ..., 0.34]\n        (1536 numbers for OpenAI)\n\nAnother chunk: \"Attention allows models to focus on relevant parts\"\nVector: [0.24, -0.48, 0.87, 0.15, ..., 0.35]\n        (similar numbers = similar meaning!)\n```\n\n### Why This Works\nWords that are semantically similar produce similar vectors. So:\n- \"alignment\" and \"interpretability\" have similar vectors\n- \"transformer\" and \"attention\" have related vectors\n- \"cat\" and \"dog\" are more similar than \"cat\" and \"radiator\"\n\n### How Search Works\n```\nYour question: \"How do models understand their decisions?\"\nQuestion vector: [0.25, -0.50, 0.86, 0.14, ..., 0.33]\n\nCompare to all stored vectors. Find the most similar:\n- Chunk about interpretability: similarity 0.94\n- Chunk about explainability: similarity 0.91\n- Chunk about feature attribution: similarity 0.88\n\nReturn the top matches.\n```\n\nThis is why semantic search finds conceptually similar content even when words are different.\n\n---\n\n## Key Design Decisions\n\n### 1. Search, Don't Train\n**Why?** Fine-tuning is slow and permanent. Search is flexible and reversible.\n\n### 2. Explicit Retrieval, Not Implicit Knowledge\n**Why?** You can verify what the AI saw. You have audit trails. You control what leaves your system.\n\n### 3. Multiple Search Types\n**Why?** Different questions need different search (keyword vs. semantic). Giving you both is more powerful.\n\n### 4. Context as a Permission System\n**Why?** Not everything you save needs to reach AI. You control granularly.\n\n---\n\n## Summary\n\nOpen Notebook gives you **two ways** to work with AI:\n\n### Chat (Full-Content)\n- Sends entire selected sources to LLM\n- Manual control: you pick sources\n- Conversational: back-and-forth dialog\n- Transparent: you know exactly what AI sees\n- Best for: focused analysis, close reading\n\n### Ask (RAG)\n- Searches and retrieves relevant chunks automatically\n- Automatic: AI finds what's relevant\n- One-shot: single comprehensive answer\n- Efficient: sends only relevant pieces\n- Best for: broad questions across many sources\n\n**Both approaches:**\n1. Keep your data private (doesn't leave your system by default)\n2. Give you control (you choose which features to use)\n3. Create audit trails (citations show what was used)\n4. Support multiple AI providers\n\n**Coming Soon**: The community is working on adding RAG capabilities to Chat as well, giving you the best of both worlds.\n"
  },
  {
    "path": "docs/2-CORE-CONCEPTS/chat-vs-transformations.md",
    "content": "# Chat vs. Ask vs. Transformations - Which Tool for Which Job?\n\nOpen Notebook offers different ways to work with your research. Understanding when to use each is key to using the system effectively.\n\n---\n\n## The Three Interaction Modes\n\n### 1. CHAT - Conversational Exploration with Manual Context\n\n**What it is:** Have a conversation with AI about selected sources.\n\n**The flow:**\n```\n1. You select which sources to include (\"in context\")\n2. You ask a question\n3. AI responds using ONLY those sources\n4. You ask follow-up questions (context stays same)\n5. You change sources or context level, then continue\n```\n\n**Context management:** You explicitly choose which sources the AI can see.\n\n**Conversational:** Multiple questions with shared history.\n\n**Example:**\n```\nYou: [Select sources: \"paper1.pdf\", \"research_notes.txt\"]\n     [Set context: Full content for paper1, Summary for notes]\n\nYou: \"What's the main argument in these sources?\"\nAI:  \"Paper 1 argues X [citation]. Your notes emphasize Y [citation].\"\n\nYou: \"How do they differ?\"\nAI:  \"Paper 1 focuses on X [citation], while your notes highlight Y [citation]...\"\n\nYou: [Now select different sources]\n\nYou: \"Compare to this other perspective\"\nAI:  \"This new source takes a different approach...\"\n```\n\n**Best for:**\n- Exploring a focused topic with specific sources\n- Having a dialogue (multiple back-and-forth questions)\n- When you know which sources matter\n- When you want tight control over what goes to AI\n\n---\n\n### 2. ASK - Automated Comprehensive Search\n\n**What it is:** Ask one complex question, system automatically finds relevant content.\n\n**The flow:**\n```\n1. You ask a comprehensive question\n2. System analyzes the question\n3. System automatically searches your sources\n4. System retrieves relevant chunks\n5. System synthesizes answer from all results\n6. You get one detailed answer (not conversational)\n```\n\n**Context management:** Automatic. System figures out what's relevant.\n\n**Non-conversational:** One question → one answer. No follow-ups.\n\n**Example:**\n```\nYou: \"How do these papers compare their approaches to alignment?\n      What does each one recommend?\"\n\nSystem:\n  - Breaks down the question into search strategies\n  - Searches all sources for alignment approaches\n  - Searches all sources for recommendations\n  - Retrieves top 10 relevant chunks\n  - Synthesizes: \"Paper A recommends X [citation].\n                  Paper B recommends Y [citation].\n                  They differ in Z.\"\n\nYou: [Get back one comprehensive answer]\n     [If you want to follow up, use Chat instead]\n```\n\n**Best for:**\n- Comprehensive, one-time questions\n- Comparing multiple sources at once\n- When you want the system to decide what's relevant\n- Complex questions that need multiple search angles\n- When you don't need a back-and-forth conversation\n\n---\n\n### 3. TRANSFORMATIONS - Template-Based Processing\n\n**What it is:** Apply a reusable template to a source and get structured output.\n\n**The flow:**\n```\n1. You define a transformation (or choose a preset)\n   \"Extract: main argument, methodology, limitations\"\n\n2. You apply it to ONE source at a time\n   (You can repeat for other sources)\n\n3. For the source:\n   - Source content + transformation prompt → AI\n   - Result stored as new insight/note\n\n4. You get back\n   - Structured output (main argument, methodology, limitations)\n   - Saved as a note in your notebook\n```\n\n**Context management:** Works on one source at a time.\n\n**Reusable:** Apply the same template to different sources (one by one).\n\n**Note**: Currently processes one source at a time. Batch processing (multiple sources at once) is planned for a future release.\n\n**Example:**\n```\nYou: Define transformation\n     \"For each academic paper, extract:\n      - Main research question\n      - Methodology used\n      - Key findings\n      - Limitations and gaps\n      - Recommended next research\"\n\nYou: Apply to paper 1\n\nSystem:\n  - Runs the transformation on paper 1\n  - Result stored as new note\n\nYou: Apply same transformation to paper 2, 3, etc.\n\nAfter 10 papers:\n  - You have 10 structured notes with consistent format\n  - Perfect for writing a literature review or comparison\n```\n\n**Best for:**\n- Extracting the same information from each source (run repeatedly)\n- Creating structured summaries with consistent format\n- Building a knowledge base of categorized insights\n- When you want reusable templates you can apply to each source\n\n---\n\n## Decision Tree: Which Tool to Use?\n\n```\nWhat are you trying to do?\n\n│\n├─→ \"I want to have a conversation about this topic\"\n│   └─→ Is the conversation exploratory or fixed?\n│       ├─→ Exploratory (I'll ask follow-ups)\n│       │   └─→ USE: CHAT\n│       │\n│       └─→ Fixed (One question → done)\n│           └─→ Go to next question\n│\n├─→ \"I need to compare these sources or get a comprehensive answer\"\n│   └─→ USE: ASK\n│\n├─→ \"I want to extract the same info from each source (one at a time)\"\n│   └─→ USE: TRANSFORMATIONS (apply to each source)\n│\n└─→ \"I just want to read and search\"\n    └─→ USE: Search (text or vector)\n        OR read your notes\n```\n\n---\n\n## Side-by-Side Comparison\n\n| Aspect | CHAT | ASK | TRANSFORMATIONS |\n|--------|------|-----|-----------------|\n| **What's it for?** | Conversational exploration | Comprehensive Q&A | Template-based extraction |\n| **# of questions** | Multiple (conversational) | One | One template per source |\n| **Context control** | Manual (you choose) | Automatic (system searches) | One source at a time |\n| **Conversational?** | Yes (follow-ups work) | No (one question only) | No (single operation) |\n| **Output** | Natural conversation | Natural answer | Structured note |\n| **Time** | Quick (back-and-forth) | Longer (comprehensive) | Per source |\n| **Best when** | Exploring & uncertain | Need full picture | Want consistent format |\n| **Model speed** | Any | Fast preferred | Any |\n\n---\n\n## Workflow Examples\n\n### Example 1: Academic Research\n\n```\nGoal: Write literature review from 15 papers\n\nStep 1: TRANSFORMATIONS\n  - Define: \"Extract abstract, methodology, findings, relevance\"\n  - Apply to paper 1 → get structured note\n  - Apply to paper 2 → get structured note\n  - ... repeat for all 15 papers\n  - Result: 15 structured notes with consistent format\n\nStep 2: Read the notes\n  - Now you have consistent summaries\n\nStep 3: CHAT or ASK\n  - Chat: \"Help me organize these by theme\"\n  - Ask: \"What are the common methodologies across these papers?\"\n\nStep 4: Write your review\n  - Use the transformations as foundation\n  - Use chat/ask insights for structure\n```\n\n### Example 2: Product Research\n\n```\nGoal: Understand customer feedback from interviews\n\nStep 1: Add sources (interview transcripts)\n\nStep 2: ASK\n  - \"What are the top 10 pain points mentioned?\"\n  - Get comprehensive answer with citations\n\nStep 3: CHAT\n  - \"Can you help me group these by severity?\"\n  - Continue conversation to prioritize\n\nStep 4: TRANSFORMATIONS (optional)\n  - Define: \"Extract: pain point, frequency, who mentioned it\"\n  - Apply to each interview (one by one)\n  - Get structured data for analysis\n```\n\n### Example 3: Policy Analysis\n\n```\nGoal: Compare policy documents\n\nStep 1: Add all policy documents as sources\n\nStep 2: ASK\n  - \"How do these policies differ on climate measures?\"\n  - System searches all docs, gives comprehensive comparison\n\nStep 3: CHAT (if needed)\n  - \"Which policy is most aligned with X goals?\"\n  - Have discussion about trade-offs\n\nStep 4: Export notes\n  - Save AI responses as notes for reports\n```\n\n---\n\n## Context Management: The Control Panel\n\nAll three modes let you control what the AI sees.\n\n### In CHAT and TRANSFORMATIONS\n```\nYou choose:\n  - Which sources to include\n  - Context level for each:\n    ✓ Full Content (send complete text)\n    ✓ Summary Only (send AI summary, not full text)\n    ✓ Not in Context (exclude entirely)\n\nExample:\n  Paper A: Full Content (analyzing closely)\n  Paper B: Summary Only (background)\n  Paper C: Not in Context (confidential)\n```\n\n### In ASK\n```\nContext is automatic:\n  - System searches ALL your sources\n  - Retrieves most relevant chunks\n  - Sends those to AI\n\nBut you can:\n  - Search in specific notebook\n  - Filter by source type\n  - Use the results to decide context for follow-up Chat\n```\n\n---\n\n## Model Selection\n\nEach mode works with different models:\n\n### CHAT\n- **Any model** works fine\n- Fast models (GPT-4o mini, Claude Haiku): Quick responses, good for conversation\n- Powerful models (GPT-4o, Claude Sonnet): Better reasoning, better for complex topics\n\n### ASK\n- **Fast models preferred** (because it processes multiple searches)\n- Can use powerful models if you want deep synthesis\n- Example: GPT-4 for strategy planning, GPT-4o-mini for quick facts\n\n### TRANSFORMATIONS\n- **Any model** works\n- Fast models (cost-effective for batch processing)\n- Powerful models (better quality extractions)\n\n---\n\n## Advanced: Chaining Modes Together\n\nYou can combine these modes:\n\n```\nTRANSFORMATIONS → CHAT\n  1. Use transformations to extract structured data\n  2. Use chat to discuss the results\n\nASK → TRANSFORMATIONS\n  1. Use Ask to understand what matters\n  2. Use Transformations to extract it from remaining sources\n\nCHAT → Save as Note → TRANSFORMATIONS\n  1. Have conversation (Chat)\n  2. Save good responses as notes\n  3. Use those notes as context for transformations\n```\n\n---\n\n## Summary: When to Use Each\n\n| Situation | Use | Why |\n|-----------|-----|-----|\n| \"I want to explore a topic with follow-up questions\" | **CHAT** | Conversational, you control context |\n| \"I need a comprehensive answer to one complex question\" | **ASK** | Automatic search, synthesized answer |\n| \"I want consistent summaries from each source\" | **TRANSFORMATIONS** | Template reuse, apply to each source |\n| \"I'm comparing two specific sources\" | **CHAT** | Select just those 2, have discussion |\n| \"I need to categorize each source by X criteria\" | **TRANSFORMATIONS** | Extract category from each source |\n| \"I want to understand the big picture across all sources\" | **ASK** | Automatic comprehensive search |\n| \"I want to build a knowledge base\" | **TRANSFORMATIONS** | Create structured note from each source |\n| \"I want to iterate on understanding\" | **CHAT** | Multiple questions, refine thinking |\n\nThe key insight: **Different questions need different tools.** Open Notebook gives you all three because research rarely fits one mode.\n"
  },
  {
    "path": "docs/2-CORE-CONCEPTS/index.md",
    "content": "# Core Concepts - Understand the Mental Model\n\nBefore diving into how to use Open Notebook, it's important to understand **how it thinks**. These core concepts explain the \"why\" behind the design.\n\n## The Five Mental Models\n\n### 1. [Notebooks, Sources, and Notes](notebooks-sources-notes.md)\nHow Open Notebook organizes your research. Understand the three-tier container structure and how information flows from raw materials to finished insights.\n\n**Key idea**: A notebook is a scoped research container. Sources are inputs (PDFs, URLs, etc.). Notes are outputs (your insights, AI-generated summaries, captured responses).\n\n---\n\n### 2. [AI Context & RAG](ai-context-rag.md)\nHow Open Notebook makes AI aware of your research - two different approaches.\n\n**Key idea**: **Chat** sends entire selected sources to the LLM (full context, conversational). **Ask** uses RAG (retrieval-augmented generation) to automatically search and retrieve only relevant chunks. Different tools for different needs.\n\n---\n\n### 3. [Chat vs. Transformations](chat-vs-transformations.md)\nWhy Open Notebook has different interaction modes and when to use each one.\n\n**Key idea**: Chat is conversational exploration (you control context). Transformations are insight extractions. They reduced content to smaller bits of concentrated/dense information, which is much more suitable for an AI to use. \n\n---\n\n### 4. [Context Management](chat-vs-transformations.md#context-management-the-control-panel)\nYour control panel for privacy and cost. Decide what data actually reaches AI.\n\n**Key idea**: You choose three levels—not in context (private), summary only (condensed), or full content (complete access). This gives you fine-grained control.\n\n---\n\n### 5. [Podcasts Explained](podcasts-explained.md)\nWhy Open Notebook can turn research into audio and why this matters.\n\n**Key idea**: Podcasts transform your research into a different consumption format. Instead of reading, someone can listen and absorb your insights passively.\n\n---\n\n## Read This Section If:\n\n- **You're new to Open Notebook** — Start here to understand how the system works conceptually before learning the features\n- **You're confused about Chat vs Ask** — Section 2 explains the difference (full-content vs RAG)\n- **You're wondering when to use Chat vs Transformations** — Section 3 clarifies the differences\n- **You want to understand privacy controls** — Section 4 shows you what you can control\n- **You're curious about podcasts** — Section 5 explains the architecture and why it's different from competitors\n\n---\n\n## The Big Picture\n\nOpen Notebook is built on a simple insight: **Your research deserves to stay yours**.\n\nThat means:\n- **Privacy by default** — Your data doesn't leave your infrastructure unless you explicitly choose\n- **AI as a tool, not a gatekeeper** — You decide which sources the AI sees, not the AI deciding for you\n- **Flexible consumption** — Read, listen, search, chat, or transform your research however makes sense\n\nThese core concepts explain how that works.\n\n---\n\n## Next Steps\n\n1. **Just want to use it?** → Go to [User Guide](../3-USER-GUIDE/index.md)\n2. **Want to understand it first?** → Read the 5 sections above (15 min)\n3. **Setting up for the first time?** → Go to [Installation](../1-INSTALLATION/index.md)\n\n"
  },
  {
    "path": "docs/2-CORE-CONCEPTS/notebooks-sources-notes.md",
    "content": "# Notebooks, Sources, and Notes - The Container Model\n\nOpen Notebook organizes research in three connected layers. Understanding this hierarchy is key to using the system effectively.\n\n## The Three-Layer Structure\n\n```\n┌─────────────────────────────────────┐\n│         NOTEBOOK (The Container)    │\n│     \"My AI Safety Research 2026\"   │\n├─────────────────────────────────────┤\n│                                     │\n│  SOURCES (The Raw Materials)        │\n│  ├─ safety_paper.pdf                │\n│  ├─ alignment_video.mp4             │\n│  └─ prompt_injection_article.html   │\n│                                     │\n│  NOTES (The Processed Insights)     │\n│  ├─ AI Summary (auto-generated)     │\n│  ├─ Key Concepts (transformation)   │\n│  ├─ My Research Notes (manual)      │\n│  └─ Chat Insights (from conversation)\n│                                     │\n└─────────────────────────────────────┘\n```\n\n---\n\n## 1. NOTEBOOKS - The Research Container\n\n### What Is a Notebook?\n\nA **notebook** is a *scoped container* for a research project or topic. It's your research workspace.\n\nThink of it like a physical notebook: everything inside is about the same topic, shares the same context, and builds toward the same goals.\n\n### What Goes In?\n\n- **A description** — \"This notebook collects research on X topic\"\n- **Sources** — The raw materials you add\n- **Notes** — Your insights and outputs\n- **Conversation history** — Your chats and questions\n\n### Why This Matters\n\n**Isolation**: Each notebook is completely separate. Sources in Notebook A never appear in Notebook B. This lets you:\n- Keep different research topics completely isolated\n- Reuse source names across notebooks without conflicts\n- Control which AI context applies to which research\n\n**Shared Context**: All sources and notes in a notebook inherit the notebook's context. If your notebook is titled \"AI Safety 2026\" with description \"Focusing on alignment and interpretability,\" that context applies to all AI interactions within that notebook.\n\n**Parallel Projects**: You can have 10 notebooks running simultaneously. Each one is its own isolated research environment.\n\n### Example\n\n```\nNotebook: \"Customer Research - Product Launch\"\nDescription: \"User interviews and feedback for Q1 2026 launch\"\n\n→ All sources added to this notebook are about customer feedback\n→ All notes generated are in that context\n→ When you chat, the AI knows you're analyzing product launch feedback\n→ Different from your \"Market Analysis - Competitors\" notebook\n```\n\n---\n\n## 2. SOURCES - The Raw Materials\n\n### What Is a Source?\n\nA **source** is a *single piece of input material* — the raw content you bring in. Sources never change; they're just processed and indexed.\n\n### What Can Be a Source?\n\n- **PDFs** — Research papers, reports, documents\n- **Web links** — Articles, blog posts, web pages\n- **Audio files** — Podcasts, interviews, lectures\n- **Video files** — Tutorials, presentations, recordings\n- **Plain text** — Notes, transcripts, passages\n- **Uploaded text** — Paste content directly\n\n### What Happens When You Add a Source?\n\n```\n1. EXTRACTION\n   File/URL → Extract text and metadata\n   (OCR for PDFs, web scraping for URLs, speech-to-text for audio)\n\n2. CHUNKING\n   Long text → Break into searchable chunks\n   (Prevents \"too much context\" in single query)\n\n3. EMBEDDING\n   Each chunk → Generate semantic vector\n   (Allows AI to find conceptually similar content)\n\n4. STORAGE\n   Chunks + vectors → Store in database\n   (Ready for search and retrieval)\n```\n\n### Key Properties\n\n**Immutable**: Once added, the source doesn't change. If you need a new version, add it as a new source.\n\n**Indexed**: Sources are automatically indexed for search (both text and semantic).\n\n**Scoped**: A source belongs to exactly one notebook.\n\n**Referenceable**: Other sources and notes can reference this source by citation.\n\n### Example\n\n```\nSource: \"openai_charter.pdf\"\nType: PDF document\n\nWhat happens:\n→ PDF is uploaded\n→ Text is extracted (including images)\n→ Text is split into 50 chunks (paragraphs, sections)\n→ Each chunk gets an embedding vector\n→ Now searchable by: \"OpenAI's approach to safety\"\n```\n\n---\n\n## 3. NOTES - The Processed Insights\n\n### What Is a Note?\n\nA **note** is a *processed output* — something you created or AI created based on your sources. Notes are the \"results\" of your research work.\n\n### Types of Notes\n\n#### Manual Notes\nYou write them yourself. They're your original thinking, capturing:\n- What you learned from sources\n- Your analysis and interpretations\n- Your next steps and questions\n\n#### AI-Generated Notes\nCreated by applying AI processing to sources:\n- **Transformations** — Structured extraction (main points, key concepts, methodology)\n- **Chat Responses** — Answers you saved from conversations\n- **Ask Results** — Comprehensive answers saved to your notebook\n\n#### Captured Insights\nNotes you explicitly saved from interactions:\n- \"Save this response as a note\"\n- \"Save this transformation result\"\n- Convert any AI output into a permanent note\n\n### What Can Notes Contain?\n\n- **Text** — Your writing or AI-generated content\n- **Citations** — References to specific sources\n- **Metadata** — When created, how created (manual/AI), which sources influenced it\n- **Tags** — Your categorization (optional but useful)\n\n### Why Notes Matter\n\n**Knowledge Accumulation**: Notes become your actual knowledge base. They're what you take away from the research.\n\n**Searchable**: Notes are searchable along with sources. \"Find everything about X\" includes your notes, not just sources.\n\n**Citable**: Notes can cite sources, creating an audit trail of where insights came from.\n\n**Shareable**: Notes are your outputs. You can share them, publish them, or build on them in other projects.\n\n---\n\n## How They Connect: The Data Flow\n\n```\nYOU\n │\n ├─→ Create Notebook (\"AI Research\")\n │\n ├─→ Add Sources (papers, articles, videos)\n │    └─→ System: Extract, embed, index\n │\n ├─→ Search Sources (text or semantic)\n │    └─→ System: Find relevant chunks\n │\n ├─→ Apply Transformations (extract insights)\n │    └─→ Creates Notes\n │\n ├─→ Chat with Sources (explore with context control)\n │    ├─→ Can save responses as Notes\n │    └─→ Notes include citations\n │\n ├─→ Ask Questions (automated comprehensive search)\n │    ├─→ Can save results as Notes\n │    └─→ Notes include citations\n │\n └─→ Generate Podcast (transform notebook into audio)\n     └─→ Uses all sources + notes for content\n```\n\n---\n\n## Key Design Decisions\n\n### 1. One Notebook Per Source\n\nEach source belongs to exactly one notebook. This creates clear boundaries:\n- No ambiguity about which research project a source is in\n- Easy to isolate or export a complete project\n- Clean permissions model (if someone gets access to notebook, they get access to all its sources)\n\n### 2. Immutable Sources, Mutable Notes\n\nSources never change (once added, always the same). But notes can be edited or deleted. Why?\n- Sources are evidence → evidence shouldn't be altered\n- Notes are your thinking → thinking evolves as you learn\n\n### 3. Explicit Context Control\n\nSources don't automatically go to AI. You decide which sources are \"in context\" for each interaction:\n- Chat: You manually select which sources to include\n- Ask: System automatically figures out which sources to search\n- Transformations: You choose which sources to transform\n\nThis is different from systems that always send everything to AI.\n\n---\n\n## Mental Models Explained\n\n### Notebook as Boundaries\nThink of a notebook like a Git repository:\n- Everything in it is about the same topic\n- You can clone/fork it (copy to new project)\n- It has clear entry/exit points\n- You know exactly what's included\n\n### Sources as Evidence\nThink of sources like exhibits in a legal case:\n- Once filed, they don't change\n- They can be cited and referenced\n- They're the ground truth for what you're basing claims on\n- Multiple sources can be cross-referenced\n\n### Notes as Synthesis\nThink of notes like your case brief:\n- You write them based on evidence\n- They're your interpretation\n- You can cite which evidence supports each claim\n- They're what you actually share or act on\n\n---\n\n## Common Questions\n\n### Can I move a source to a different notebook?\nNot directly. Each source is tied to one notebook. If you want it in multiple notebooks, add it again (uploads are fast if it's already processed).\n\n### Can a note reference sources from a different notebook?\nNo. Notes stay within their notebook and reference sources within that notebook. This keeps boundaries clean.\n\n### What if I want to group sources within a notebook?\nUse tags. You can tag sources (\"primary research,\" \"background,\" \"methodology\") and filter by tags.\n\n### Can I merge two notebooks?\nNot built-in, but you can manually copy sources from one notebook to another by re-uploading them.\n\n---\n\n## Summary\n\n| Concept | Purpose | Lifecycle | Scope |\n|---------|---------|-----------|-------|\n| **Notebook** | Container + context | Create once, configure | All its sources + notes |\n| **Source** | Raw material | Add → Process → Store | One notebook |\n| **Note** | Processed output | Create/capture → Edit → Share | One notebook |\n\nThis three-layer model gives you:\n- **Clear organization** (everything scoped to projects)\n- **Privacy control** (isolated notebooks)\n- **Audit trails** (notes cite sources)\n- **Flexibility** (notes can be manual or AI-generated)\n"
  },
  {
    "path": "docs/2-CORE-CONCEPTS/podcasts-explained.md",
    "content": "# Podcasts Explained - Research as Audio Dialogue\n\nPodcasts are Open Notebook's highest-level transformation: converting your research into audio dialogue for a different consumption pattern.\n\n---\n\n## Why Podcasts Matter\n\n### The Problem\nResearch naturally accumulates as text: PDFs, articles, web pages, notes. This creates a friction point:\n\n**To consume research, you must:**\n- Sit down at a desk\n- Focus intently\n- Read actively\n- Take notes\n- Set aside dedicated time\n\n**But much of life is passive time:**\n- Commuting\n- Exercising\n- Doing dishes\n- Driving\n- Walking\n- Idle moments\n\n### The Solution\nConvert your research into audio dialogue so you can consume it passively.\n\n```\nBefore (Text-based):\n  Research pile → Must schedule reading time → Requires focus\n\nAfter (Podcast):\n  Research pile → Podcast → Can listen while commuting\n                         → Absorb while exercising\n                         → Understand while walking\n                         → Engage without screen time\n```\n\n---\n\n## What Makes It Special: Open Notebook vs. Competitors\n\n### Google Notebook LM Podcasts\n- **Fixed format**: 2 hosts, always conversational\n- **Limited customization**: You can't choose who the \"hosts\" are\n- **One TTS voice per speaker**: Can't customize voices\n- **Only uses cloud services**: No local options\n\n### Open Notebook Podcasts\n- **Customizable format**: 1-4 speakers, you design them\n- **Rich speaker profiles**: Create personas with backstories and expertise\n- **Multiple TTS options**:\n  - OpenAI (natural, fast)\n  - Google TTS (high quality)\n  - ElevenLabs (beautiful voices, accents)\n  - Local TTS (privacy-first, no API calls)\n- **Async generation**: Doesn't block your work\n- **Full control**: Choose outline structure, tone, depth\n\n---\n\n## How Podcast Generation Works\n\n### Stage 1: Content Selection\n\nYou choose what goes into the podcast:\n```\nNotebook content → Which sources? → Which notes?\n                → Which topics to focus on?\n                → Depth of coverage?\n```\n\n### Stage 2: Episode Profile\n\nYou define how you want the podcast structured:\n```\nEpisode Profile\n├─ Topic: \"AI Safety Approaches\"\n├─ Length: 20 minutes\n├─ Tone: Academic but accessible\n├─ Format: Debate (2 speakers with opposing views)\n├─ Audience: Researchers new to the field\n└─ Focus areas: Main approaches, pros/cons, open questions\n```\n\n### Stage 3: Speaker Configuration\n\nYou create speaker personas (1-4 speakers):\n\n```\nSpeaker 1: \"Expert Alex\"\n├─ Expertise: \"Deep knowledge of alignment research\"\n├─ Personality: \"Rigorous, academic, patient with explanation\"\n├─ Accent: (Optional) \"British English\"\n└─ Voice Model: Selected from model registry (e.g., OpenAI TTS)\n   └─ Optional per-speaker override of the episode's default voice model\n\nSpeaker 2: \"Researcher Sam\"\n├─ Expertise: \"Field observer, pragmatic perspective\"\n├─ Personality: \"Curious, asks clarifying questions\"\n├─ Accent: \"American English\"\n└─ Voice Model: Selected from model registry (e.g., ElevenLabs TTS)\n```\n\n### Stage 4: Outline Generation\n\nSystem generates episode outline:\n```\nEPISODE: \"AI Safety Approaches\"\n\n1. Introduction (2 min)\n   Alex: Introduces topic and speakers\n   Sam: What will we cover today?\n\n2. Main Approaches (8 min)\n   Alex: Explains top 3 approaches\n   Sam: Asks about tradeoffs\n\n3. Debate: Best approach? (6 min)\n   Alex: Advocates for approach A\n   Sam: Argues for approach B\n\n4. Open Questions (3 min)\n   Both: What's unsolved?\n\n5. Conclusion (1 min)\n   Recap and where to learn more\n```\n\n### Stage 5: Dialogue Generation\n\nSystem generates dialogue based on outline:\n```\nAlex: \"Today we're exploring three major approaches to AI alignment...\"\n\nSam: \"That's a great start. Can you break down what we mean by alignment?\"\n\nAlex: \"Good question. Alignment means ensuring AI systems pursue the goals\n       we actually want them to pursue, not just what we literally asked for.\n       There's a classic example of a paperclip maximizer...\"\n\nSam: \"Interesting. So it's about solving the intention problem?\"\n\nAlex: \"Exactly. And that's where the three approaches come in...\"\n```\n\n### Stage 6: Text-to-Speech\n\nSystem converts dialogue to audio using the voice models configured in the model registry. Credentials are automatically resolved from each model's configuration.\n```\nAlex's text → Voice model (from registry) → Alex's voice (audio file)\nSam's text → Voice model (from registry) → Sam's voice (audio file)\nAudio files → Mix together → Final podcast MP3\n```\n\n---\n\n## When Things Go Wrong: Failures & Retry\n\nPodcast generation involves multiple steps (outline, transcript, TTS) and depends on external AI providers. Sometimes things fail.\n\n### What Happens on Failure\n\nWhen podcast generation fails (e.g., wrong model configured, API key expired, provider outage):\n\n- The episode is marked as **Failed** with a red badge\n- The **error message** from the AI provider is displayed so you can understand what went wrong\n- No duplicate episodes are created — automatic retries are disabled to prevent confusion\n\n### How to Retry a Failed Episode\n\n1. Go to the podcast's **Episodes** tab\n2. Find the failed episode — it shows a red \"FAILED\" badge and an error details box\n3. Click the **Retry** button\n4. The failed episode is deleted and a new generation job is submitted\n5. The new episode appears with \"pending\" status\n\n### Common Failure Causes\n\n| Error | What to Do |\n|-------|-----------|\n| Invalid API key | Check Settings -> Credentials for the TTS and language model providers |\n| Model not found | Verify the model exists in the model registry and has valid credentials configured |\n| Rate limit exceeded | Wait a few minutes and retry |\n| Provider unavailable | Check provider status page; retry later |\n\n---\n\n## Key Architecture Decisions\n\n### 1. Asynchronous Processing\nPodcasts are generated in the background. You upload → system processes → you download when ready.\n\n**Why?** Podcast generation takes time (10+ minutes for a 30-minute episode). Blocking would lock up your interface.\n\n### 2. Multi-Speaker Support\nUnlike Google Notebook LM (always 2 hosts), you choose 1-4 speakers.\n\n**Why?** Different discussions work better with different formats:\n- Expert monologue (1 speaker)\n- Interview (2 speakers: host + expert)\n- Debate (2 speakers: opposing views)\n- Panel discussion (3-4 speakers: different expertise)\n\n### 3. Speaker Customization\nYou create rich speaker profiles, not just \"Host A\" and \"Host B\".\n\n**Why?** Makes podcasts more engaging and authentic. Different speakers bring different perspectives.\n\n### 4. Multiple TTS Providers\nYou're not locked into one voice provider.\n\n**Why?**\n- Cost optimization (some providers cheaper)\n- Quality preferences (some voices more natural)\n- Privacy options (local TTS for sensitive content)\n- Accessibility (different accents, genders, styles)\n\n### 5. Local TTS Option\nCan generate podcasts entirely offline with local text-to-speech.\n\n**Why?** For sensitive research, never send audio to external APIs.\n\n---\n\n## Use Cases Show Why This Matters\n\n### Academic Publishing\n```\nTraditional: Academic paper → PDF\nProblem: Hard to consume, linear reading required\n\nOpen Notebook:\nResearch materials → Podcast (expert explaining methodology)\n                  → Podcast (debate format: different interpretations)\n                  → Different consumption for different audiences\n```\n\n### Content Creation\n```\nBlog creator: Has research pile on a topic\nProblem: Doesn't have time to write the article\n\nSolution:\nAdd research → Create podcast → Transcribe → Becomes article\nOR: Podcast BECOMES the content (upload to podcast platforms)\n```\n\n### Educational Content\n```\nEducator: Has reading materials for a course\nProblem: Students don't read the papers\n\nSolution:\nCreate podcast with expert explaining papers\nStudents listen → Better engagement → Discussions can reference podcast\n```\n\n### Market Research\n```\nProduct manager: Has interviews with customers\nProblem: Too many hours of audio to review\n\nSolution:\nCreate podcast with debate format (customer perspective vs. team perspective)\nMuch more engaging than raw transcripts\n```\n\n### Knowledge Transfer\n```\nDomain expert: Leaving the organization\nProblem: How to preserve expertise?\n\nSolution:\nCreate expert-mode podcast explaining frameworks, decision-making, context\nNew team member listens, gets context faster than reading 100 documents\n```\n\n---\n\n## The Difference: Active vs. Passive Learning\n\n### Text-Based Research (Active)\n- **Effort**: High (must focus, read, synthesize)\n- **When**: Dedicated study time\n- **Cost**: Time is expensive (can't multitask)\n- **Best for**: Deep dives, precise information\n- **Format**: Whatever you write (notes, articles, books)\n\n### Audio Podcast (Passive)\n- **Effort**: Low (just listen)\n- **When**: Anywhere, anytime\n- **Cost**: Low (can multitask)\n- **Best for**: Overview, context, exploration\n- **Format**: Dialogue (more engaging than narration)\n\n**They complement each other:**\n1. **First encounter**: Listen to podcast (passive, get context)\n2. **Deep dive**: Read source materials (active, precise)\n3. **Mastery**: Both together (understand big picture + details)\n\n---\n\n## How Podcasts Fit Into Your Workflow\n\n```\n1. Build notebook (add sources)\n   ↓\n2. Apply transformations (extract insights)\n   ↓\n3. Chat/Ask (explore content)\n   ↓\n4. Decide on podcast\n   ├─→ Create speaker profiles\n   ├─→ Define episode profile\n   ├─→ Configure voice models (from model registry)\n   └─→ Generate podcast\n   ↓\n5. Listen while commuting/exercising\n   ↓\n6. Reference sources for deep dive\n   ↓\n7. Repeat for different formats/speakers/focus\n```\n\n---\n\n## Advanced: Multiple Podcasts from Same Research\n\nYou can create different podcasts from the same sources:\n\n### Example: AI Safety Research\n```\nPodcast 1: \"Expert Monologue\"\n  Speaker: Researcher explaining field\n  Format: Educational, comprehensive\n  Audience: Students new to field\n\nPodcast 2: \"Debate Format\"\n  Speakers: Optimist vs. skeptic\n  Format: Discussion of tradeoffs\n  Audience: Advanced researchers\n\nPodcast 3: \"Interview Format\"\n  Speakers: Journalist + expert\n  Format: Q&A about practical applications\n  Audience: Industry practitioners\n```\n\nEach tells the same story from different angles.\n\n---\n\n## Privacy & Data Considerations\n\n### Where Your Data Goes\n\n**Option 1: Cloud TTS (Faster, Higher Quality)**\n```\nYour outline → API call to TTS provider\n            → Audio returned\n            → Stored in your notebook\n\nProvider sees: Your outlined script (not raw sources)\nPrivacy level: Medium (outline is shared, sources aren't)\n```\n\n**Option 2: Local TTS (Slower, Maximum Privacy)**\n```\nYour outline → Local TTS engine (runs on your machine)\n            → Audio generated locally\n            → Stored in your notebook\n\nProvider sees: Nothing\nPrivacy level: Maximum (everything local)\n```\n\n### Recommendation\n- **Sensitive research**: Use local TTS, no API calls\n- **Less sensitive**: Use ElevenLabs or Google (both handle audio data professionally)\n- **Mixed**: Use local TTS for speakers reading sensitive content\n\n---\n\n## Cost Considerations\n\n### Cloud TTS Costs\n| Provider | Cost | Quality | Speed |\n|----------|------|---------|-------|\n| OpenAI | ~$0.015 per minute | Good | Fast |\n| Google | ~$0.004 per minute | Excellent | Fast |\n| ElevenLabs | ~$0.10 per minute | Exceptional | Medium |\n| Local TTS | Free | Basic | Slow |\n\nA 30-minute podcast costs:\n- OpenAI: ~$0.45\n- Google: ~$0.12\n- ElevenLabs: ~$3.00\n- Local: Free (but slow)\n\n---\n\n## Summary: Why Podcasts Are Special\n\n**Podcasts transform your research consumption:**\n\n| Aspect | Text | Podcast |\n|--------|------|---------|\n| **How consumed?** | Active reading | Passive listening |\n| **Where consumed?** | Desk | Anywhere |\n| **Multitasking** | Hard | Easy |\n| **Time commitment** | Scheduled | Flexible |\n| **Format** | Whatever | Natural dialogue |\n| **Engagement** | Academic | Conversational |\n| **Accessibility** | Text-based | Audio-based |\n\n**In Open Notebook specifically:**\n- **Full customization** — you create speakers and format\n- **Privacy options** — local TTS for sensitive content\n- **Cost control** — choose TTS provider based on budget\n- **Non-blocking** — generates in background\n- **Multiple versions** — create different podcasts from same research\n\nThis is why podcasts matter: they change *when* and *how* you can consume your research.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/adding-sources.md",
    "content": "# Adding Sources - Getting Content Into Your Notebook\n\nSources are the raw materials of your research. This guide covers how to add different types of content.\n\n---\n\n## Quick-Start: Add Your First Source\n\n### Option 1: Upload a File (PDF, Word, etc.)\n\n```\n1. In your notebook, click \"Add Source\"\n2. Select \"Upload File\"\n3. Choose a file from your computer\n4. Click \"Upload\"\n5. Wait 30-60 seconds for processing\n6. Done! Source appears in your notebook\n```\n\n### Option 2: Add a Web Link\n\n```\n1. Click \"Add Source\"\n2. Select \"Web Link\"\n3. Paste URL: https://example.com/article\n4. Click \"Add\"\n5. Wait for processing (usually faster than files)\n6. Done!\n```\n\n### Option 3: Paste Text\n\n```\n1. Click \"Add Source\"\n2. Select \"Text\"\n3. Paste or type your content\n4. Click \"Save\"\n5. Done! Immediately available\n```\n\n---\n\n## Supported File Types\n\n### Documents\n- **PDF** (.pdf) — Best support, including scanned PDFs with OCR\n- **Word** (.docx, .doc) — Full support\n- **PowerPoint** (.pptx) — Slides converted to text\n- **Excel** (.xlsx, .xls) — Spreadsheet data\n- **EPUB** (.epub) — eBook files\n- **Markdown** (.md, .txt) — Plain text formats\n- **HTML** (.html, .htm) — Web page files\n\n**File size limits:** Up to ~100MB (varies by system)\n\n**Processing time:** 10 seconds - 2 minutes (depending on length and file type)\n\n### Audio & Video\n- **Audio**: MP3, WAV, M4A, OGG, FLAC (~30 seconds - 3 minutes per hour)\n- **Video**: MP4, AVI, MOV, MKV, WebM (~3-10 minutes per hour)\n- **YouTube**: Direct URL support\n- **Podcasts**: RSS feed URL\n\n**Automatic transcription**: Audio/video is transcribed to text automatically. This requires enabling speech-to-text in settings.\n\n### Web Content\n- **Articles**: Blog posts, news articles, Medium\n- **YouTube**: Full videos or playlists\n- **PDFs online**: Direct PDF links\n- **News**: News site articles\n\n**Just paste the URL** in \"Web Link\" section.\n\n### What Doesn't Work\n- Paywalled content (WSJ, FT, etc.) — Can't extract\n- Password-protected PDFs — Can't open\n- Pure image files (.jpg, .png) — Except scanned PDFs which have OCR\n- Very large files (>100MB) — Timeout\n\n---\n\n## What Happens When You Add a Source\n\nThe system automatically does four things:\n\n```\n1. EXTRACT TEXT\n   File/URL → Readable text\n   (PDFs get OCR if scanned)\n   (Videos get transcribed if enabled)\n\n2. BREAK INTO CHUNKS\n   Long text → ~500-word pieces\n   (So search finds specific parts, not whole document)\n\n3. CREATE EMBEDDINGS\n   Each chunk → Vector representation\n   (Enables semantic/concept search)\n\n4. INDEX & STORE\n   Everything → Database\n   (Ready to search and retrieve)\n```\n\n**Time to use:** After the progress bar completes, the source is ready immediately. Embeddings are created in the background.\n\n---\n\n## Step-by-Step for Different Types\n\n### PDFs\n\n**Best practices:**\n```\nClean PDFs:\n  1. Upload → Done\n  2. Processing time: ~30-60 seconds\n\nScanned/Image PDFs:\n  1. Upload same way\n  2. System auto-detects and uses OCR\n  3. Processing time: ~2-3 minutes\n  4. (Higher, due to OCR overhead)\n\nLarge PDFs (50+ pages):\n  1. Consider splitting into smaller files\n  2. Or upload as-is (system handles it)\n  3. Processing time scales with size\n```\n\n**Common issues:**\n- \"Can't extract text\" → PDF is corrupted or has copy protection\n- Solution: Try opening in Adobe. If it won't, the PDF is likely protected.\n\n### Web Links / Articles\n\n**Best practices:**\n```\n1. Copy full URL from browser: https://example.com/article-title\n2. Paste in \"Web Link\"\n3. Click Add\n4. Wait for extraction\n\nProcessing time: Usually 5-15 seconds\n```\n\n**What works:**\n- Standard web articles\n- Blog posts\n- News articles\n- Wikipedia pages\n- Medium posts\n- Substack articles\n\n**What doesn't work:**\n- Twitter threads (unreliable)\n- Paywalled articles (can't access)\n- JavaScript-heavy sites (content not extracted)\n\n**Pro tip:** If it doesn't work, copy the article text and paste as \"Text\" instead.\n\n### Audio Files\n\n**Best practices:**\n```\n1. Ensure speech-to-text is enabled in Settings\n2. Upload MP3, WAV, or M4A file\n3. System automatically transcribes to text\n4. Processing time: ~1 minute per 5 minutes of audio\n\nExample:\n  - 1-hour podcast → 12 minutes processing\n  - 10-minute recording → 2 minutes processing\n```\n\n**Quality matters:**\n- Clear audio: Fast transcription\n- Muffled/noisy audio: Slower, less accurate transcription\n- Background noise: Try to minimize before uploading\n\n**Tip:** If audio quality is poor, the AI might misinterpret content. You can manually correct transcription if needed.\n\n### YouTube Videos\n\n**Best practices:**\n```\nTwo ways to add:\n\nMethod 1: Direct URL\n  1. Copy YouTube URL: https://www.youtube.com/watch?v=...\n  2. Paste in \"Web Link\"\n  3. Click Add\n  4. System extracts captions (if available) + transcript\n\nMethod 2: Playlist\n  1. Paste playlist URL\n  2. System adds all videos as separate sources\n  3. Each video processed separately\n  4. Takes longer (multiple videos)\n```\n\n**What's extracted:**\n- Captions/subtitles (if available)\n- Transcription (if captions aren't available)\n- Basic metadata (title, channel, length)\n\n**Processing:**\n- 10-minute video: ~2-3 minutes\n- 1-hour video: ~10-15 minutes\n\n### Text / Paste Content\n\n**Best practices:**\n```\n1. Select \"Text\" when adding source\n2. Paste or type content\n3. System processes immediately\n4. No wait time needed\n\nGood for:\n  - Notes you want to reference\n  - Quotes from books\n  - Transcripts you have handy\n  - Quick research snippets\n```\n\n---\n\n## Managing Your Sources\n\n### Viewing Source Details\n\n```\nClick on source → See:\n  - Original file name/title\n  - When it was added\n  - Size and format\n  - Processing status\n  - Number of chunks\n```\n\n### Organizing with Metadata\n\nYou can add to each source:\n- **Title**: Better name than original filename\n- **Tags**: Category labels (\"primary research\", \"background\", \"competitor analysis\")\n- **Description**: A few notes about what it contains\n\n**Why this matters:**\n- Makes sources easier to find\n- Helps when contextualizing for Chat\n- Useful for organizing large notebooks\n\n### Searching Within Sources\n\n```\nAfter sources are added, you can:\n\nText search: \"Find exact phrase\"\nVector search: \"Find conceptually similar\"\n\nBoth search across all sources in notebook.\nResults show:\n  - Which source\n  - Which section\n  - Relevance score\n```\n\n---\n\n## Context Management: How Sources Get Used\n\nYou control how AI accesses sources:\n\n### Three Levels (for Chat)\n\n**Full Content:**\n```\nAI sees: Complete source text\nCost: 100% of tokens\nUse when: Analyzing in detail, need precise citations\nExample: \"Analyze this methodology paper closely\"\n```\n\n**Summary Only:**\n```\nAI sees: AI-generated summary (not full text)\nCost: ~10-20% of tokens\nUse when: Background material, reference context\nExample: \"Use this as context but focus on the main source\"\n```\n\n**Not in Context:**\n```\nAI sees: Nothing (excluded)\nCost: 0 tokens\nUse when: Confidential, not relevant, or archived\nExample: \"Keep this in notebook but don't use in this conversation\"\n```\n\n### How to Set Context (in Chat)\n\n```\n1. Go to Chat\n2. Click \"Select Context Sources\"\n3. For each source:\n   - Toggle ON/OFF (include/exclude)\n   - Choose level (Full/Summary/Excluded)\n4. Click \"Save\"\n5. Now chat uses these settings\n```\n\n---\n\n## Common Mistakes\n\n| Mistake | What Happens | How to Fix |\n|---------|--------------|-----------|\n| Upload 200 sources at once | System gets slow, processing stalls | Add 10-20 at a time, wait for processing |\n| Use full content for all sources | Token usage skyrockets, expensive | Use \"Summary\" or \"Excluded\" for background material |\n| Add huge PDFs without splitting | Processing is slow, search results less precise | Consider splitting large PDFs into chapters |\n| Forget source titles | Can't distinguish between similar sources | Rename sources with descriptive titles right after uploading |\n| Don't tag sources | Hard to find and organize later | Add tags immediately: \"primary\", \"background\", etc. |\n| Mix languages in one source | Transcription/embedding quality drops | Keep each language in separate sources |\n| Use same source multiple times | Takes up space, creates confusion | Add once; reuse in multiple chats/notebooks |\n\n---\n\n## Processing Status & Troubleshooting\n\n### What the Status Indicators Mean\n\n```\n🟡 Processing\n  → Source is being extracted and embedded\n  → Wait 30 seconds - 3 minutes depending on size\n  → Don't use in Chat yet\n\n🟢 Ready\n  → Source is processed and searchable\n  → Can use immediately in Chat\n  → Can apply transformations\n\n🔴 Error\n  → Something went wrong\n  → Common reasons:\n    - Unsupported file format\n    - File too large or corrupted\n    - Network timeout\n\n⚪ Not in Context\n  → Source added but excluded from Chat\n  → Still searchable, not sent to AI\n```\n\n### Common Errors & Solutions\n\n**\"Unsupported file type\"**\n- You tried to upload a format not in the list (e.g., `.webp` image)\n- Solution: Convert to supported format (PDF for documents, MP3 for audio)\n\n**\"Processing timeout\"**\n- Very large file (>100MB) or very long audio\n- Solution: Split into smaller pieces or try uploading again\n\n**\"Transcription failed\"**\n- Audio quality too poor or language not detected\n- Solution: Re-record with better quality, or paste text transcript manually\n\n**\"Web link won't extract\"**\n- Website blocks automated access or uses JavaScript for content\n- Solution: Copy the article text and paste as \"Text\" instead\n\n---\n\n## Tips for Best Results\n\n### For PDFs\n- Clean, digital PDFs work best\n- Remove copy protection if present (legally)\n- Scanned PDFs work but take longer\n\n### For Web Articles\n- Use full URL including domain\n- Avoid cookie/popup-laden sites\n- If extraction fails, copy-paste text instead\n\n### For Audio\n- Clear, well-recorded audio transcribes better\n- Remove background noise if possible\n- YouTube videos usually have good transcriptions built-in\n\n### For Large Documents\n- Consider splitting into smaller sources\n- Gives more precise search results\n- Processing is faster for smaller pieces\n\n### For Organization\n- Name sources clearly (not \"document_2.pdf\")\n- Add tags immediately after uploading\n- Use descriptions for complex documents\n\n---\n\n## What Comes After: Using Your Sources\n\nOnce you've added sources, you can:\n\n- **Chat** → Ask questions (see [Chat Effectively](chat-effectively.md))\n- **Search** → Find specific content (see [Search Effectively](search.md))\n- **Transformations** → Extract structured insights (see [Working with Notes](working-with-notes.md))\n- **Ask** → Get comprehensive answers (see [Search Effectively](search.md))\n- **Podcasts** → Turn into audio (see [Creating Podcasts](creating-podcasts.md))\n\n---\n\n## Summary Checklist\n\nBefore adding sources, confirm:\n\n- [ ] File is in supported format\n- [ ] File is under 100MB (or splitting large ones)\n- [ ] Web links are full URLs (not shortened)\n- [ ] Audio files have clear speech (if transcription-dependent)\n- [ ] You've named source clearly\n- [ ] You've added tags for organization\n- [ ] You understand context levels (Full/Summary/Excluded)\n\nDone! Sources are now ready for Chat, Search, Transformations, and more.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/api-configuration.md",
    "content": "# API Configuration\n\nConfigure AI provider credentials through the Settings UI. No file editing required.\n\n> **Credential System**: Open Notebook uses encrypted credentials stored in the database. Each credential connects to a provider and allows you to discover, register, and test models.\n\n---\n\n## Overview\n\nOpen Notebook manages AI provider access through a **credential-based system**:\n\n1. You create a **credential** for each provider (API key + settings)\n2. Credentials are **encrypted** and stored in the database\n3. You **test connections** to verify credentials work\n4. You **discover and register models** from each credential\n5. Models are linked to credentials for direct configuration\n\n---\n\n## Encryption Setup\n\nBefore storing credentials, you must configure an encryption key.\n\n### Setting the Encryption Key\n\nAdd `OPEN_NOTEBOOK_ENCRYPTION_KEY` to your docker-compose.yml:\n\n```yaml\nenvironment:\n  - OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase\n```\n\nAny string works as a key — it will be securely derived via SHA-256 internally.\n\n> **Warning**: If you change or lose the encryption key, **all stored credentials become unreadable**. Back up your encryption key securely and separately from your database backups.\n\n### Docker Secrets Support\n\nBoth password and encryption key support Docker secrets:\n\n```yaml\n# docker-compose.yml\nservices:\n  open_notebook:\n    environment:\n      - OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key\n    secrets:\n      - app_password\n      - encryption_key\n\nsecrets:\n  app_password:\n    file: ./secrets/password.txt\n  encryption_key:\n    file: ./secrets/encryption_key.txt\n```\n\n### Encryption Details\n\nAPI keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).\n\n| Configuration | Behavior |\n|---------------|----------|\n| Encryption key set | Keys encrypted with your key |\n| No encryption key set | Storing credentials is disabled |\n\n---\n\n## Accessing Credential Configuration\n\n1. Click **Settings** in the navigation bar\n2. Select **API Keys** tab\n3. You'll see existing credentials and an **Add Credential** button\n\n```\nNavigation: Settings → API Keys\n```\n\n---\n\n## Supported Providers\n\n### Cloud Providers\n\n| Provider | Required Fields | Optional Fields |\n|----------|-----------------|-----------------|\n| OpenAI | API Key | — |\n| Anthropic | API Key | — |\n| Google Gemini | API Key | — |\n| Groq | API Key | — |\n| Mistral | API Key | — |\n| DeepSeek | API Key | — |\n| xAI | API Key | — |\n| OpenRouter | API Key | — |\n| Voyage AI | API Key | — |\n| ElevenLabs | API Key | — |\n\n### Local/Self-Hosted\n\n| Provider | Required Fields | Notes |\n|----------|-----------------|-------|\n| Ollama | Base URL | Typically `http://localhost:11434` or `http://ollama:11434` |\n\n### Enterprise\n\n| Provider | Required Fields | Optional Fields |\n|----------|-----------------|-----------------|\n| Azure OpenAI | API Key, Endpoint, API Version | Service-specific endpoints (LLM, Embedding, STT, TTS) |\n| OpenAI-Compatible | Base URL | API Key, Service-specific configs |\n| Vertex AI | Project ID, Location, Credentials Path | — |\n\n---\n\n## Creating a Credential\n\n### Step 1: Add Credential\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select your provider\n4. Give it a descriptive name (e.g., \"My OpenAI Key\", \"Work Anthropic\")\n5. Fill in the required fields (API key, base URL, etc.)\n6. Click **Save**\n\n### Step 2: Test Connection\n\n1. On your new credential card, click **Test Connection**\n2. Wait for the result:\n\n| Result | Meaning |\n|--------|---------|\n| Success | Key is valid, provider accessible |\n| Invalid API key | Check key format and value |\n| Connection failed | Check URL, network, firewall |\n\n### Step 3: Discover Models\n\n1. Click **Discover Models** on the credential card\n2. The system queries the provider for available models\n3. Review the discovered models\n\n### Step 4: Register Models\n\n1. Select the models you want to use\n2. Click **Register Models**\n3. The models are now available throughout Open Notebook\n\n---\n\n## Multi-Credential Support\n\nEach provider can have **multiple credentials**. This is useful when:\n- You have different API keys for different projects\n- You want to test with different endpoints\n- Multiple team members need separate credentials\n\n### Creating Multiple Credentials\n\n1. Click **Add Credential** again\n2. Select the same provider\n3. Fill in different credentials\n4. Each credential can discover and register its own models\n\n### How Models Link to Credentials\n\nWhen you register models from a credential, those models are linked to that specific credential. This means:\n- Each model knows which API key to use\n- You can have models from different credentials for the same provider\n- Deleting a credential removes its linked models\n\n---\n\n## Testing Connections\n\nClick **Test Connection** to verify your credential:\n\n| Result | Meaning |\n|--------|---------|\n| Success | Key is valid, provider accessible |\n| Invalid API key | Check key format and value |\n| Connection failed | Check URL, network, firewall |\n| Model not available | Key valid but model access restricted |\n\nTest uses inexpensive models (e.g., `gpt-3.5-turbo`, `claude-3-haiku`) to minimize cost.\n\n---\n\n## Configuring Specific Providers\n\n### Simple Providers (API Key Only)\n\nFor OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter:\n\n1. Add credential with your API key\n2. Test connection\n3. Discover and register models\n\n### Ollama (URL-Based)\n\n1. Add credential with provider **Ollama**\n2. Enter the base URL (e.g., `http://ollama:11434`)\n3. Test connection\n4. Discover and register models\n\nOllama allows localhost and private IPs since it runs locally.\n\n### Azure OpenAI\n\nAzure requires multiple fields:\n\n| Field | Example | Required |\n|-------|---------|----------|\n| API Key | `abc123...` | Yes |\n| Endpoint | `https://myresource.openai.azure.com` | Yes |\n| API Version | `2024-02-15-preview` | Yes |\n| LLM Endpoint | `https://myresource-llm.openai.azure.com` | No |\n| Embedding Endpoint | `https://myresource-embed.openai.azure.com` | No |\n\nService-specific endpoints override the main endpoint for that service type.\n\n### OpenAI-Compatible\n\nFor custom OpenAI-compatible servers (LM Studio, vLLM, etc.):\n\n1. Add credential with provider **OpenAI-Compatible**\n2. Enter the base URL\n3. Enter API key (if required)\n4. Optionally configure per-service URLs\n\nSupports separate configurations for:\n- LLM (language models)\n- Embedding\n- STT (speech-to-text)\n- TTS (text-to-speech)\n\n### Vertex AI\n\nGoogle Cloud's enterprise AI platform:\n\n| Field | Example |\n|-------|---------|\n| Project ID | `my-gcp-project` |\n| Location | `us-central1` |\n| Credentials Path | `/path/to/service-account.json` |\n\n---\n\n## Migrating from Environment Variables\n\nIf you have existing API keys in environment variables (from a previous version):\n\n1. Open **Settings → API Keys**\n2. A banner appears: \"Environment variables detected\"\n3. Click **Migrate to Database**\n4. Keys are copied to the database (encrypted)\n5. Original environment variables remain unchanged\n\n### Migration Behavior\n\n| Scenario | Action |\n|----------|--------|\n| Key in env only | Migrated to database |\n| Key in database only | No change |\n| Key in both | Database version kept (skipped) |\n\n### After Migration\n\n- Database credentials are used for all operations\n- You can remove the API key environment variables from your docker-compose.yml\n- Keep `OPEN_NOTEBOOK_ENCRYPTION_KEY` — it's still required\n\n### Migration Banner Visibility\n\nThe migration banner only appears when:\n- You have environment variables configured\n- Those providers are **not** already in the database\n- If all env providers are already migrated, the banner won't show\n\n---\n\n## Migrating from ProviderConfig (v1.1 → v1.2)\n\nIf you're upgrading from an older version that used the ProviderConfig system:\n\n- The migration happens automatically on first startup\n- Your existing configurations are converted to credentials\n- Check **Settings → API Keys** to verify the migration succeeded\n- If you see issues, check the API logs for migration messages\n\n---\n\n## Key Storage Security\n\n### Encryption\n\nAPI keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).\n\n| Configuration | Behavior |\n|---------------|----------|\n| Encryption key set | Keys encrypted with your key |\n| No encryption key set | Storing API keys in database is disabled |\n\n### Default Credentials\n\n| Setting | Default Value | Production Recommendation |\n|---------|---------------|---------------------------|\n| Password | `open-notebook-change-me` | Set `OPEN_NOTEBOOK_PASSWORD` |\n| Encryption Key | None (must be set) | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` to any secret string |\n\n**For production deployments, always set custom credentials.**\n\n---\n\n## Deleting Credentials\n\n1. Click the **Delete** button on the credential card\n2. Confirm deletion\n3. Credential and all its linked models are removed from the database\n\n---\n\n## Troubleshooting\n\n### Credential Not Saving\n\n| Symptom | Cause | Solution |\n|---------|-------|----------|\n| Save button disabled | Empty or invalid input | Enter a valid key |\n| Error on save | Encryption key not set | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml |\n| Error on save | Database connection issue | Check database status |\n\n### Test Connection Fails\n\n| Error | Cause | Solution |\n|-------|-------|----------|\n| Invalid API key | Wrong key or format | Verify key from provider dashboard |\n| Connection refused | Wrong URL | Check base URL format |\n| Timeout | Network issue | Check firewall, proxy settings |\n| 403 Forbidden | IP restriction | Whitelist your server IP |\n\n### Migration Issues\n\n| Problem | Solution |\n|---------|----------|\n| No migration banner | No env vars detected, or already migrated |\n| Partial migration | Check error list, fix and retry |\n| Keys not working after migration | Clear browser cache, restart services |\n\n### Provider Shows \"Not Configured\"\n\n1. Check if a credential exists for this provider (Settings → API Keys)\n2. Test the credential connection\n3. Verify key format matches provider requirements\n4. Re-discover and register models if needed\n\n---\n\n## Provider-Specific Notes\n\n### OpenAI\n- Keys start with `sk-proj-` (project keys) or `sk-` (legacy)\n- Requires billing enabled on account\n\n### Anthropic\n- Keys start with `sk-ant-`\n- Check account has API access enabled\n\n### Google Gemini\n- Keys start with `AIzaSy`\n- Free tier has rate limits\n\n### Ollama\n- No API key required\n- Default URL: `http://localhost:11434` (local) or `http://ollama:11434` (Docker)\n- Ensure Ollama server is running\n\n### Azure OpenAI\n- Endpoint format: `https://{resource-name}.openai.azure.com`\n- API version format: `YYYY-MM-DD` or `YYYY-MM-DD-preview`\n- Deployment names configured separately when registering models via the credential's Discover Models dialog\n\n---\n\n## Related\n\n- **[AI Providers](../5-CONFIGURATION/ai-providers.md)** — Provider setup instructions and recommendations\n- **[Security](../5-CONFIGURATION/security.md)** — Password and encryption configuration\n- **[Environment Reference](../5-CONFIGURATION/environment-reference.md)** — All configuration options\n"
  },
  {
    "path": "docs/3-USER-GUIDE/chat-effectively.md",
    "content": "# Chat Effectively - Conversations with Your Research\n\nChat is your main tool for exploratory questions and back-and-forth dialogue. This guide covers how to use it effectively.\n\n---\n\n## Quick-Start: Your First Chat\n\n```\n1. Go to your notebook\n2. Click \"Chat\"\n3. Select which sources to include (context)\n4. Type your question\n5. Click \"Send\"\n6. Read the response\n7. Ask a follow-up (context stays same)\n8. Repeat until satisfied\n```\n\nThat's it! But doing it *well* requires understanding how context works.\n\n---\n\n## Context Management: The Key to Good Chat\n\nContext controls **what the AI is allowed to see**. This is your most important control.\n\n### The Three Levels Explained\n\n**FULL CONTENT**\n- AI sees: Complete source text\n- Cost: 100 tokens per 1K tokens of source\n- Best for: Detailed analysis, precise citations\n- Example: \"Analyze this research paper closely\"\n\n```\nYou set: Paper A → Full Content\nAI sees: Every word of Paper A\nAI can: Cite specific sentences, notice nuances\nResult: Precise, detailed answers (higher cost)\n```\n\n**SUMMARY ONLY**\n- AI sees: AI-generated 200-word summary (not full text)\n- Cost: ~10-20% of full content cost\n- Best for: Background material, reference context\n- Example: \"Use this for background, focus on the main paper\"\n\n```\nYou set: Paper B → Summary Only\nAI sees: Condensed summary, key points\nAI can: Reference main ideas but not details\nResult: Faster, cheaper answers (loses precision)\n```\n\n**NOT IN CONTEXT**\n- AI sees: Nothing\n- Cost: 0 tokens\n- Best for: Confidential, irrelevant, archived content\n- Example: \"Keep this in notebook but don't use now\"\n\n```\nYou set: Paper C → Not in Context\nAI sees: Nothing (completely excluded)\nAI can: Never reference it\nResult: No cost, no privacy risk for that source\n```\n\n### Setting Context (Step by Step)\n\n```\n1. Click \"Select Sources\"\n   (Shows list of all sources in notebook)\n\n2. For each source:\n   □ Checkbox: Include or exclude\n\n   Level dropdown:\n   ├─ Full Content\n   ├─ Summary Only\n   └─ Excluded\n\n3. Check your selections\n   Example:\n   ✓ Paper A (Full Content) - \"Main focus\"\n   ✓ Paper B (Summary Only) - \"Background\"\n   ✓ Paper C (Excluded) - \"Keep private\"\n   □ Paper D (Not included) - \"Not relevant\"\n\n4. Click \"Save Context\"\n\n5. Now chat uses these settings\n```\n\n### Context Strategies\n\n**Strategy 1: Minimalist**\n- Main source: Full Content\n- Everything else: Excluded\n- Result: Focused, cheap, precise\n\n```\nUse when:\n  - Analyzing one source deeply\n  - Budget-conscious\n  - Want focused answers\n```\n\n**Strategy 2: Comprehensive**\n- All sources: Full Content\n- Result: All context considered, expensive\n\n```\nUse when:\n  - Comprehensive analysis\n  - Unlimited budget\n  - Want AI to see everything\n```\n\n**Strategy 3: Tiered**\n- Primary sources: Full Content\n- Secondary sources: Summary Only\n- Background/reference: Excluded\n- Result: Balanced cost/quality\n\n```\nUse when:\n  - Mix of important and reference material\n  - Want thorough but not expensive\n  - Most common strategy\n```\n\n**Strategy 4: Privacy-First**\n- Sensitive docs: Excluded\n- Public research: Full Content\n- Result: Never send confidential data\n\n```\nUse when:\n  - Company confidential materials\n  - Personal sensitive data\n  - Complying with data protection\n```\n\n---\n\n## Asking Effective Questions\n\n### Good Questions vs. Poor Questions\n\n**Poor Question**\n```\n\"What do you think?\"\n\nProblems:\n- Too vague (about what?)\n- No context (what am I analyzing?)\n- Can't verify answer (citing what?)\n\nResult: Generic, shallow answer\n```\n\n**Good Question**\n```\n\"Based on the paper's methodology section,\nwhat are the three main limitations the authors acknowledge?\nPlease cite which pages mention each one.\"\n\nStrengths:\n- Specific about what you want\n- Clear scope (methodology section)\n- Asks for citations\n- Requires deep reading\n\nResult: Precise, verifiable, useful answer\n```\n\n### Question Patterns That Work\n\n**Factual Questions**\n```\n\"What does the paper say about X?\"\n\"Who are the authors?\"\n\"What year was this published?\"\n\nResult: Simple, factual answers with citations\n```\n\n**Analysis Questions**\n```\n\"How does this approach differ from the traditional method?\"\n\"What are the main assumptions underlying this argument?\"\n\"Why do you think the author chose this methodology?\"\n\nResult: Deeper thinking, comparison, critique\n```\n\n**Synthesis Questions**\n```\n\"How do these two sources approach the problem differently?\"\n\"What's the common theme across all three papers?\"\n\"If we combine these approaches, what would we get?\"\n\nResult: Cross-source insights, connections\n```\n\n**Actionable Questions**\n```\n\"What are the practical implications of this research?\"\n\"How could we apply these findings to our situation?\"\n\"What's the next logical research direction?\"\n\nResult: Practical, forward-looking answers\n```\n\n### The SPECIFIC Formula\n\nGood questions have:\n\n1. **SCOPE** - What are you analyzing?\n   \"In this research paper...\"\n   \"Looking at these three articles...\"\n   \"Based on your experience...\"\n\n2. **SPECIFICITY** - Exactly what do you want?\n   \"...the methodology...\"\n   \"...main findings...\"\n   \"...recommended next steps...\"\n\n3. **CONSTRAINT** - Any limits?\n   \"...in 3 bullet points...\"\n   \"...with citations to page numbers...\"\n   \"...comparing these two approaches...\"\n\n4. **VERIFICATION** - How can you check it?\n   \"...with specific quotes...\"\n   \"...cite your sources...\"\n   \"...link to the relevant section...\"\n\n**Example:**\n```\nPoor: \"What about transformers?\"\nGood: \"In this research paper on machine learning,\n      explain the transformer architecture in 2-3 sentences,\n      then cite which page describes the attention mechanism.\"\n```\n\n---\n\n## Follow-Up Questions (The Real Power of Chat)\n\nChat's strength is dialogue. You ask, get an answer, ask more.\n\n### Building on Responses\n\n```\nFirst question:\n\"What's the main finding?\"\n\nAI: \"The study shows X [citation]\"\n\nFollow-up question:\n\"How does that compare to Y research?\"\n\nAI: \"The key difference is Z [citation]\"\n\nNext question:\n\"Why do you think that difference matters?\"\n\nAI: \"Because it affects A, B, C [explained]\"\n```\n\n### Iterating Toward Understanding\n\n```\nRound 1: Get overview\n\"What's this source about?\"\n\nRound 2: Get details\n\"What's the most important part?\"\n\nRound 3: Compare\n\"How does it relate to my notes on X?\"\n\nRound 4: Apply\n\"What should I do with this information?\"\n```\n\n### Changing Direction\n\n```\nContext stays same, but you ask new questions:\n\nQuestion 1: \"What's the methodology?\"\nQuestion 2: \"What are the limitations?\"\nQuestion 3: \"What about the ethical implications?\"\nQuestion 4: \"Who else has done similar work?\"\n\nAll in one conversation, reusing context.\n```\n\n### Adjusting Context Between Rounds\n\n```\nAfter question 3, you realize:\n\"I need more context from another source\"\n\n1. Click \"Adjust Context\"\n2. Add new source or change context level\n3. Your conversation history stays\n4. Continue asking with new context\n```\n\n---\n\n## Citations and Verification\n\nCitations are how you verify that the AI's answer is accurate.\n\n### Understanding Citations\n\n```\nAI Response with Citation:\n\"The paper reports a 95% accuracy rate [see page 12]\"\n\nWhat this means:\n✓ The claim \"95% accuracy rate\" is from page 12\n✓ You can verify by reading page 12\n✓ If page 12 doesn't say that, the AI hallucinated\n```\n\n### Requesting Better Citations\n\n```\nIf you get a response without citations:\n\nAsk: \"Please cite the page number for that claim\"\nor: \"Show me where you found that information\"\n\nAI will:\n- Find the citation\n- Provide page numbers\n- Show you the source\n```\n\n### Verification Workflow\n\n```\n1. Get answer from Chat\n2. Check citation (which source? which page?)\n3. Click citation link (if available)\n4. See the actual text in source\n5. Does it really say what AI claimed?\n\nIf YES: Great, you can use this answer\nIf NO: The AI hallucinated, ask for correction\n```\n\n---\n\n## Common Chat Patterns\n\n### Pattern 1: Deep Dive into One Source\n\n```\n1. Set context: One source (Full Content)\n2. Question 1: Overview\n3. Question 2: Main argument\n4. Question 3: Evidence for argument\n5. Question 4: Limitations\n6. Question 5: Next steps\n\nResult: Complete understanding of one source\n```\n\n### Pattern 2: Comparative Analysis\n\n```\n1. Set context: 2-3 sources (all Full Content)\n2. Question 1: What does each source say about X?\n3. Question 2: How do they agree?\n4. Question 3: How do they disagree?\n5. Question 4: Which approach is stronger?\n\nResult: Understanding differences and trade-offs\n```\n\n### Pattern 3: Research Exploration\n\n```\n1. Set context: Many sources (mix of Full/Summary)\n2. Question 1: What are the main perspectives?\n3. Question 2: What's missing from these views?\n4. Question 3: What questions does this raise?\n5. Question 4: What should I research next?\n\nResult: Understanding landscape and gaps\n```\n\n### Pattern 4: Problem Solving\n\n```\n1. Set context: Relevant sources (Full Content)\n2. Question 1: What's the problem?\n3. Question 2: What approaches exist?\n4. Question 3: Pros and cons of each?\n5. Question 4: Which would work best for [my situation]?\n\nResult: Decision-making informed by research\n```\n\n---\n\n## Optimizing for Cost\n\nChat uses tokens for every response. Here's how to use efficiently:\n\n### Reduce Token Usage\n\n**Minimize context**\n```\nOption A: All sources, Full Content\n  Cost per response: 5,000 tokens\n\nOption B: Only relevant sources, Summary Only\n  Cost per response: 1,000 tokens\n\nSavings: 80% cheaper, same conversation\n```\n\n**Shorter questions**\n```\nVerbose: \"Could you please analyze the methodology\n         section of this paper and explain in detail\n         what the authors did?\"\n\nConcise: \"Summarize the methodology in 2-3 points.\"\n\nSavings: 20-30% per response\n```\n\n**Use cheaper models**\n```\nGPT-4o: $0.15 per 1M input tokens\nGPT-4o-mini: $0.03 per 1M input tokens\nClaude Sonnet: $0.90 per 1M input tokens\n\nFor chat: Mini/Haiku models are usually fine\nFor deep analysis: Sonnet/Opus worth the cost\n```\n\n### Budget Strategies\n\n**Exploration budget**\n- Use cheap model\n- Broad context (understand landscape)\n- Short questions\n- Result: Low cost, good overview\n\n**Analysis budget**\n- Use powerful model\n- Focused context (main source only)\n- Detailed questions\n- Result: Higher cost, deep insights\n\n**Synthesis budget**\n- Use powerful model for final synthesis\n- Multiple sources (Full Content)\n- Complex comparative questions\n- Result: Expensive but valuable output\n\n---\n\n## Troubleshooting Chat Issues\n\n### Poor Responses\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| Generic answers | Vague question | Be specific (see question patterns) |\n| Missing context | Not enough in context | Add sources or change to Full Content |\n| Incorrect info | Source not in context | Add the relevant source |\n| Hallucinating | Model confused | Ask for citations, verify claims |\n| Shallow analysis | Wrong model | Switch to more powerful model |\n\n### High Costs\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| Expensive per response | Too much context | Use Summary Only or exclude sources |\n| Many follow-ups | Exploratory chat | Use Ask instead for single comprehensive answer |\n| Long conversations | Keeping history | Archive old chats, start fresh |\n| Large sources | Full text in context | Use Summary Only for large documents |\n\n---\n\n## Best Practices\n\n### Before You Chat\n\n- [ ] Add sources you'll need\n- [ ] Decide context strategy (Tiered is usually best)\n- [ ] Choose model (cheaper for exploration, powerful for analysis)\n- [ ] Have a question in mind\n\n### During Chat\n\n- [ ] Ask specific questions (use SPECIFIC formula)\n- [ ] Check citations for factual claims\n- [ ] Follow up on unclear points\n- [ ] Adjust context if you need different sources\n\n### After Chat\n\n- [ ] Save good responses as notes\n- [ ] Archive conversation if you're done\n- [ ] Organize notes for future reference\n- [ ] Use insights in other features (Ask, Transformations, Podcasts)\n\n---\n\n## When to Use Chat vs. Ask\n\n**Use CHAT when:**\n- You want a dialogue\n- You're exploring a topic\n- You'll ask multiple related questions\n- You want to adjust context during conversation\n- You're not sure exactly what you need\n\n**Use ASK when:**\n- You have one specific question\n- You want a comprehensive answer\n- You want the system to auto-search\n- You want one response, not dialogue\n- You want maximum tokens spent on search\n\n---\n\n## Summary: Chat as Conversation\n\nChat is fundamentally different from asking ChatGPT directly:\n\n| Aspect | ChatGPT | Open Notebook Chat |\n|--------|---------|-------------------|\n| **Source control** | None (uses training) | You control which sources are visible |\n| **Cost control** | Per token | Per token, but context is your choice |\n| **Iteration** | Works | Works, with your sources changing dynamically |\n| **Citations** | Made up often | Tied to your sources (verifiable) |\n| **Privacy** | Your data to OpenAI | Your data stays local (unless you choose) |\n\nThe key insight: **Chat is retrieval-augmented generation.** AI sees only what you put in context. You control the conversation and the information flow.\n\nThat's why Chat is powerful for research. You're not just talking to an AI; you're having a conversation with your research itself.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/citations.md",
    "content": "# Citations - Verify and Trust AI Responses\n\nCitations connect AI responses to your source materials. This guide covers how to use and verify them.\n\n---\n\n## Why Citations Matter\n\nEvery AI-generated response in Open Notebook includes citations to your sources. This lets you:\n\n- **Verify claims** - Check that AI actually read what it claims\n- **Find original context** - See the full passage around a quote\n- **Catch hallucinations** - Spot when AI makes things up\n- **Build credibility** - Your notes have traceable sources\n\n---\n\n## Quick Start: Using Citations\n\n### Reading Citations\n\n```\nAI Response:\n\"The study found a 95% accuracy rate [1] using the proposed method.\"\n\n[1] = Click to see source\n\nWhat happens when you click:\n→ Opens the source document\n→ Highlights the relevant section\n→ You can verify the claim\n```\n\n### Requesting Better Citations\n\nIf a response lacks citations, ask:\n\n```\n\"Please cite the specific page or section for that claim.\"\n\"Where in the document does it say that?\"\n\"Can you quote the exact text?\"\n```\n\n---\n\n## How Citations Work\n\n### Automatic Generation\n\nWhen AI references your sources, citations are generated automatically:\n\n```\n1. AI analyzes your question\n2. Retrieves relevant source chunks\n3. Generates response with inline citations\n4. Links citations to original source locations\n```\n\n### Citation Format\n\n```\nInline format:\n\"The researchers concluded X [1] and Y [2].\"\n\nReference list:\n[1] Paper Title - Section 3.2\n[2] Report Name - Page 15\n\nClickable: Each [number] links to the source\n```\n\n---\n\n## Verifying Citations\n\n### The Verification Workflow\n\n```\nStep 1: Read AI response\n        \"The model achieved 95% accuracy [1]\"\n\nStep 2: Click citation [1]\n        → Opens source document\n        → Shows relevant passage\n\nStep 3: Verify the claim\n        Does source actually say 95%?\n        Is context correct?\n        Any nuance missed?\n\nStep 4: Trust or correct\n        ✓ Accurate → Use the insight\n        ✗ Wrong → Ask AI to correct\n```\n\n### What to Check\n\n| Check | Why |\n|-------|-----|\n| **Exact numbers** | AI sometimes rounds or misremembers |\n| **Context** | Quote might mean something different in context |\n| **Attribution** | Is this the source's claim or someone they cited? |\n| **Completeness** | Did AI miss important caveats? |\n\n---\n\n## Citations in Different Features\n\n### Chat Citations\n\n```\nContext: Sources you selected\nCitations: Reference chunks used in response\nVerification: Click to see original text\nSave: Citations preserved when saving as note\n```\n\n### Ask Feature Citations\n\n```\nContext: Auto-searched across all sources\nCitations: Multiple sources synthesized\nVerification: Each source linked separately\nQuality: Often more comprehensive than Chat\n```\n\n### Transformation Citations\n\n```\nContext: Single source being transformed\nCitations: Points back to original document\nVerification: Compare output to source\nUse: When you need structured extraction\n```\n\n---\n\n## Saving Citations\n\n### In Notes\n\nWhen you save an AI response as a note, citations are preserved:\n\n```\nOriginal response:\n\"According to the paper [1], the method works by...\"\n\nSaved note includes:\n- The text\n- The citation link\n- Reference to source document\n```\n\n### Exporting\n\nCitations work in exports:\n\n| Format | Citation Behavior |\n|--------|-------------------|\n| **Markdown** | Links preserved as `[text](link)` |\n| **Copy/Paste** | Plain text with reference numbers |\n| **PDF** | Clickable references (if supported) |\n\n---\n\n## Citation Quality Tips\n\n### Get Better Citations\n\n**Be specific in questions:**\n```\nPoor: \"What does it say about X?\"\nGood: \"What does page 15 say about X? Please quote directly.\"\n```\n\n**Request citation format:**\n```\n\"Include page numbers for each claim.\"\n\"Cite specific sections, not just document names.\"\n```\n\n**Use Full Content context:**\n```\nSummary Only → Less precise citations\nFull Content → Exact quotes possible\n```\n\n### When Citations Are Missing\n\n| Situation | Cause | Solution |\n|-----------|-------|----------|\n| No citations | AI used general knowledge | Ask: \"Base your answer only on my sources\" |\n| Vague citations | Source not in Full Content | Change context level |\n| Wrong citations | AI confused sources | Ask to verify with quotes |\n\n---\n\n## Common Issues\n\n### \"Citation doesn't match claim\"\n\n```\nProblem: AI says X, but source says Y\n\nWhat happened:\n- AI paraphrased incorrectly\n- AI combined multiple sources confusingly\n- Source was taken out of context\n\nSolution:\n1. Click citation to see original\n2. Note the discrepancy\n3. Ask AI: \"The source says Y, not X. Please correct.\"\n```\n\n### \"Can't find cited section\"\n\n```\nProblem: Citation link doesn't show relevant text\n\nWhat happened:\n- Source was chunked differently than expected\n- Information spread across multiple sections\n- Processing missed some content\n\nSolution:\n1. Search within source for key terms\n2. Ask AI for more specific location\n3. Re-process source if needed\n```\n\n### \"No citations at all\"\n\n```\nProblem: AI response has no source references\n\nWhat happened:\n- Sources not in context\n- Question asked for opinion/general knowledge\n- Model didn't find relevant content\n\nSolution:\n1. Check context settings\n2. Rephrase: \"Based on my sources, what...\"\n3. Add more relevant sources\n```\n\n---\n\n## Best Practices\n\n### For Research Integrity\n\n1. **Always verify important claims** - Don't trust AI blindly\n2. **Check context** - Quotes can be misleading out of context\n3. **Note limitations** - AI might miss nuance\n4. **Keep source access** - Don't delete sources you cite\n\n### For Academic Work\n\n1. **Use Full Content** for documents you'll cite\n2. **Request specific page numbers**\n3. **Cross-check with original sources**\n4. **Document your verification process**\n\n### For Professional Use\n\n1. **Verify before sharing** - Check claims clients will see\n2. **Keep citation trail** - Save notes with sources linked\n3. **Be transparent** - Note when insights are AI-assisted\n\n---\n\n## Summary\n\n```\nCitations = Your verification system\n\nHow to use:\n1. Read AI response\n2. Note citation markers [1], [2], etc.\n3. Click to see original source\n4. Verify claim matches source\n5. Trust verified insights\n\nWhen citations fail:\n- Ask for specific quotes\n- Change to Full Content\n- Request page numbers\n- Verify manually\n\nWhy it matters:\n- AI can hallucinate\n- Context can change meaning\n- Trust requires verification\n- Good research needs sources\n```\n\nCitations aren't just references — they're your quality control. Use them to build research you can trust.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/creating-podcasts.md",
    "content": "# Creating Podcasts - Turn Research into Audio\n\nPodcasts let you consume your research passively. This guide covers the complete workflow from setup to download.\n\n---\n\n## Quick-Start: Your First Podcast (5 Minutes)\n\n```\n1. Go to your notebook\n2. Click \"Generate Podcast\"\n3. Select sources to include\n4. Choose a speaker profile (or use default)\n5. Click \"Generate\"\n6. Wait 3-10 minutes (non-blocking)\n7. Download MP3 when ready\n8. Done!\n```\n\nThat's the minimum. Let's make it better.\n\n---\n\n## Step-by-Step: The Complete Workflow\n\n### Step 1: Prepare Your Notebook\n\n```\nBefore generating, make sure:\n\n✓ You have sources added\n  (At least 1-2 sources)\n\n✓ Sources have been processed\n  (Green \"Ready\" status)\n\n✓ Notes are organized\n  (If you want notes included)\n\n✓ You know your message\n  (What's the main story?)\n\nTypical preparation: 5-10 minutes\n```\n\n### Step 2: Choose Content\n\n```\nClick \"Generate Podcast\"\n\nYou'll see:\n- List of all sources in notebook\n- List of all notes\n\nSelect which to include:\n☑ Paper A (primary source)\n☑ Paper B (supporting source)\n☐ Old note (not relevant)\n✓ Analysis note (important)\n\nWhat to include:\n- Primary sources: Always include\n- Supporting sources: Usually include\n- Notes: Include your analysis/insights\n- Everything: Can overload podcast\n\nRecommended: 3-5 sources per podcast\n```\n\n### Step 3: Choose Episode Profile\n\nAn episode profile defines the structure and tone.\n\n**Option A: Use Preset Profile**\n\n```\nOpen Notebook provides preset profiles:\n\nAcademic Presentation (Monologue)\n├─ 1 speaker\n├─ Tone: Educational\n└─ Format: Expert explaining topic\n\nExpert Interview (2-speaker)\n├─ 2 speakers: Host + Expert\n├─ Tone: Q&A, conversational\n└─ Format: Interview with expert\n\nDebate Format (2-speaker)\n├─ 2 speakers: Pro vs. Con\n├─ Tone: Discussion, disagreement\n└─ Format: Debate about the topic\n\nPanel Discussion (3-4 speaker)\n├─ 3-4 speakers: Different perspectives\n├─ Tone: Thoughtful discussion\n└─ Format: Each brings different expertise\n\nSolo Explanation (Monologue)\n├─ 1 speaker\n├─ Tone: Conversational, friendly\n└─ Format: Personal explanation\n```\n\n**Pick based on your content:**\n- One main idea → Academic Presentation\n- You want to explain → Solo Explanation\n- Two competing views → Debate Format\n- Multiple perspectives → Panel Discussion\n- Want to explore → Expert Interview\n\n### Step 4: Customize Episode Profile (Optional)\n\nIf presets don't fit, customize:\n\n```\nEpisode Profile\n├─ Title: \"AI Safety in 2026\"\n├─ Description: \"Exploring current approaches\"\n├─ Length target: 20 minutes\n├─ Tone: \"Academic but accessible\"\n├─ Focus areas:\n│  ├─ Main approaches to alignment\n│  ├─ Pros and cons comparison\n│  └─ Open questions\n├─ Audience: \"Researchers new to field\"\n└─ Format: \"Debate between two perspectives\"\n\nHow to set:\n1. Click \"Customize\"\n2. Edit each field\n3. Click \"Save Profile\"\n4. System uses your profile for outline generation\n```\n\n### Step 5: Create or Select Speakers\n\nSpeakers are the \"voice\" of your podcast.\n\n**Option A: Use Preset Speakers**\n\n```\nOpen Notebook provides preset profiles:\n\n\"Expert Alex\"\n- Expertise: Deep knowledge\n- Personality: Rigorous, patient\n- Voice Model: Selected from model registry\n\n\"Curious Sam\"\n- Expertise: Curious newcomer\n- Personality: Asks questions\n- Voice Model: Selected from model registry\n\n\"Skeptic Jordan\"\n- Expertise: Critical perspective\n- Personality: Challenges assumptions\n- Voice Model: Selected from model registry\n\nFor your first podcast: Use presets\nFor custom podcast: Create your own\n```\n\n**Option B: Create Custom Speakers**\n\n```\nClick \"Add Speaker\"\n\nFill in:\n\nName: \"Dr. Research Expert\"\n\nExpertise:\n\"20 years in AI safety research,\n deep knowledge of alignment approaches\"\n\nPersonality:\n\"Rigorous, academic style,\n explains clearly, asks good questions\"\n\nVoice Configuration:\n- Voice Model: Select from model registry (e.g., OpenAI TTS, Google TTS, ElevenLabs)\n- Voice: Choose from available voices for the selected model\n- Per-speaker override: Each speaker can optionally use a different voice model\n\nCredentials are automatically resolved from the model configuration.\n\nExample:\nName: Dr. Research Expert\nExpertise: AI safety alignment research\nPersonality: Rigorous, academic but accessible\nVoice Model: ElevenLabs TTS (from registry), Voice: professional male\n```\n\n### Step 6: Generate Podcast\n\n```\n1. Review your setup:\n   Sources: ✓ Selected\n   Profile: ✓ Episode profile chosen\n   Speakers: ✓ Speakers configured\n\n2. Click \"Generate Podcast\"\n\n3. System begins:\n   - Analyzing your content\n   - Creating outline\n   - Writing dialogue\n   - Generating audio\n   - Mixing speakers\n\n4. Status shows progress:\n   20% Outline generation\n   40% Dialogue writing\n   60% Audio synthesis\n   80% Mixing\n   100% Complete\n\nProcessing time:\n- 5 minutes of content: 3-5 minutes\n- 15 minutes of content: 5-10 minutes\n- 30 minutes of content: 10-20 minutes\n```\n\n### Step 7: Review and Download\n\n```\nWhen complete:\n\nPreview:\n- Play audio sample\n- Review transcript\n- Check duration\n\nOptions:\n✓ Download as MP3 - Save to computer\n✓ Stream directly - Listen in browser\n✓ Share link - Get shareable URL (if public)\n✓ Regenerate - Try different speakers/profile\n\nDownload:\n1. Click \"Download as MP3\"\n2. Choose quality: 128kbps / 192kbps / 320kbps\n3. Save file: podcast_[notebook]_[date].mp3\n4. Listen!\n```\n\n---\n\n## Understanding What Happens Behind the Scenes\n\n### The Generation Pipeline\n\n```\nStage 1: CONTENT ANALYSIS (1 minute)\n  Your sources → What's the main story?\n               → Key themes?\n               → Debate points?\n\nStage 2: OUTLINE CREATION (2-3 minutes)\n  Themes → Episode structure\n        → Section breakdown\n        → Talking points\n\nStage 3: DIALOGUE WRITING (2-3 minutes)\n  Outline → Convert to natural dialogue\n         → Add speaker personalities\n         → Create flow and transitions\n\nStage 4: AUDIO SYNTHESIS (3-5 minutes per speaker)\n  Script + Speaker → Text-to-speech\n                  → Individual audio files\n                  → High quality audio\n\nStage 5: MIXING & MASTERING (1-2 minutes)\n  Multiple audio → Combine speakers\n               → Level audio\n               → Add polish\n               → Final MP3\n\nTotal: 10-20 minutes for typical podcast\n```\n\n---\n\n## Text-to-Speech Providers\n\nDifferent providers, different qualities.\n\n### OpenAI (Recommended)\n\n```\nVoices: 5 options (Alloy, Echo, Fable, Onyx, Shimmer)\nQuality: Good, natural sounding\nSpeed: Fast\nCost: ~$0.015 per minute\nBest for: General purpose, natural speech\nExample: \"I have to say, the research shows...\"\n```\n\n### Google TTS\n\n```\nVoices: Many options, various accents\nQuality: Excellent, very natural\nSpeed: Fast\nCost: ~$0.004 per minute\nBest for: High quality output, accents\nExample: \"The research demonstrates that...\"\n```\n\n### ElevenLabs\n\n```\nVoices: 100+ voices, highly customizable\nQuality: Exceptional, very expressive\nSpeed: Slower (5-10 seconds per phrase)\nCost: ~$0.10 per minute\nBest for: Premium quality, emotional range\nExample: [Can convey emotion and tone]\n```\n\n### Local TTS (Free)\n\n```\nVoices: Limited, basic options\nQuality: Basic, robotic\nSpeed: Depends on hardware (slow)\nCost: Free (local processing)\nBest for: Privacy, testing, offline use\nExample: \"The research shows...\"\nPrivacy: Everything stays on your computer\n```\n\n### Which Provider to Choose?\n\n```\nFor your first podcast: Google (quality/cost balance)\nFor privacy-sensitive: Local TTS (free, private)\nFor premium quality: ElevenLabs (best voices)\nFor budget: Google (cheapest quality option)\nFor speed: OpenAI (fast generation)\n```\n\n---\n\n## Tips for Better Podcasts\n\n### Choose Right Profile\n\n```\nSingle source analysis → Academic Presentation\n  \"Explaining one paper to someone new\"\n\nComparing two approaches → Debate Format\n  \"Pros and cons of different methods\"\n\nMultiple sources + insights → Panel Discussion\n  \"Different experts discussing topic\"\n\nNarrative exploration → Expert Interview\n  \"Host interviewing research expert\"\n\nPersonal take → Solo Explanation\n  \"You explaining your analysis\"\n```\n\n### Create Good Speakers\n\n```\nGood Speaker:\n✓ Clear expertise (know what they're talking about)\n✓ Distinct personality (not generic)\n✓ Good voice choice (matches personality)\n✓ Realistic backstory (feels like real person)\n\nBad Speaker:\n✗ Generic expertise (\"good at research\")\n✗ No personality (\"just reads\")\n✗ Mismatched voice (deep voice for young person)\n✗ Contradicts personality (serious person uses casual voice)\n```\n\n### Focus Content\n\n```\nBetter: Podcast on ONE specific topic\n  \"How transformers work\" (15 minutes, focused)\n\nWorse: Podcast on everything\n  \"All of AI 2025\" (2 hours, unfocused)\n\nGuideline:\n- 5-10 minutes: One narrow topic\n- 15-20 minutes: One broad topic\n- 30+ minutes: Multiple related subtopics\n\nShorter is usually better for podcasts.\n```\n\n### Optimize Source Selection\n\n```\nToo much content:\n  \"Here are all 20 papers\"\n  → Podcast becomes 2+ hours\n  → Unfocused\n  → Low quality\n\nRight amount:\n  \"Here are 3 key papers\"\n  → Podcast is 15-20 minutes\n  → Focused\n  → High quality\n\nRule: 3-5 sources per podcast\n     Remove long background papers\n     Keep focused on main topic\n```\n\n---\n\n## Quality Troubleshooting\n\n### Audio Sounds Robotic\n\n**Problem**: TTS voice sounds unnatural\n\n**Solutions**:\n```\n1. Switch provider: Try Google or ElevenLabs instead\n2. Choose different voice: Some voices more natural\n3. Shorter sentences: Very long sentences sound robotic\n4. Adjust pacing: Ask for \"natural, conversational pacing\"\n```\n\n### Audio Sounds Unclear\n\n**Problem**: Hard to understand what's being said\n\n**Solutions**:\n```\n1. Re-generate with different speaker\n2. Try different TTS provider\n3. Use speakers with clear accents\n4. Lower background noise (if any)\n5. Increase speech rate (if too slow)\n```\n\n### Missing Content\n\n**Problem**: Important information isn't in podcast\n\n**Solutions**:\n```\n1. Include that source in content selection\n2. Review generated outline (check before generating)\n3. Regenerate with clearer profile instructions\n4. Try different model (more thorough model)\n```\n\n### Speakers Don't Match\n\n**Problem**: Speakers sound like same person\n\n**Solutions**:\n```\n1. Choose different voice models from the registry for each speaker\n2. Choose very different voice options\n3. Increase personality differences in profile\n4. Try different speaker count (2 vs 3 vs 4)\n```\n\n### Generation Failed\n\n**Problem**: \"Podcast generation failed\"\n\n**Solutions**:\n```\n1. Check internet connection (especially TTS)\n2. Try again (might be temporary issue)\n3. Use local TTS (doesn't need internet)\n4. Reduce source count (less to process)\n5. Contact support if persistent\n```\n\n---\n\n## Advanced: Multiple Podcasts from Same Research\n\nYou can generate different podcasts from one notebook:\n\n```\nPodcast 1: Overview\n  Profile: Academic Presentation\n  Sources: Papers A, B, C\n  Speakers: One expert\n  Length: 15 minutes\n\n→ Use for \"What's this about?\" understanding\n\nPodcast 2: Deep Dive\n  Profile: Expert Interview\n  Sources: Paper A (Full) + B, C (Summary)\n  Speakers: Expert + Interviewer\n  Length: 30 minutes\n\n→ Use for detailed exploration\n\nPodcast 3: Debate\n  Profile: Debate Format\n  Sources: Papers A vs B (different approaches)\n  Speakers: Pro-A speaker + Pro-B speaker\n  Length: 20 minutes\n\n→ Use for comparing approaches\n```\n\nEach tells the same story from different angles.\n\n---\n\n## Exporting and Sharing\n\n### Download MP3\n\n```\n1. Generation complete\n2. Click \"Download\"\n3. Choose quality:\n   - 128 kbps: Smallest file, lower quality\n   - 192 kbps: Balanced (recommended)\n   - 320 kbps: Highest quality, largest file\n4. Save to computer\n5. Use in podcast app, upload to platform, etc.\n```\n\n### Export Transcript\n\n```\n1. Click \"Export Transcript\"\n2. Get full dialogue as text\n3. Useful for:\n   - Blog post content\n   - Show notes\n   - Searchable text version\n   - Accessibility\n```\n\n### Share Link\n\n```\nIf podcast is public:\n1. Click \"Share\"\n2. Get shareable link\n3. Others can listen/download\n4. Useful for:\n   - Sharing with team\n   - Public distribution\n   - Embedding on website\n```\n\n### Publish to Podcast Platforms\n\n```\nIf you want to distribute (future feature):\n1. Download MP3\n2. Upload to platform (Spotify, Apple Podcasts, etc.)\n3. Add metadata (title, description, episode notes)\n4. Your research becomes a published podcast!\n```\n\n---\n\n## Best Practices\n\n### Before Generation\n- [ ] Sources are processed and ready\n- [ ] You've chosen content to include\n- [ ] You have a clear episode profile\n- [ ] Speakers are well-defined\n- [ ] Content is focused (3-5 sources max)\n\n### During Generation\n- Don't close the browser (use background processing)\n- Check back in 5-15 minutes\n- Review transcript when complete\n- Listen to sample before downloading\n\n### After Generation\n- [ ] Download MP3 to computer\n- [ ] Save in organized folder\n- [ ] Add metadata (title, description, date)\n- [ ] Test listening in podcast app\n- [ ] Share with colleagues for feedback\n\n---\n\n## Use Cases\n\n### Academic Researcher\n```\nPodcast: Explaining your dissertation\nSpeakers: You + colleague\nContent: Your papers + supporting research\nUse: Share with advisors, test explanations\n```\n\n### Content Creator\n```\nPodcast: Research-to-podcast article\nSpeakers: Narrator + expert\nContent: Articles you've researched\nUse: Transform article into podcast version\n```\n\n### Team Research\n```\nPodcast: Weekly research updates\nSpeakers: Multiple team members\nContent: This week's papers\nUse: Team updates, knowledge sharing\n```\n\n### Learning/Teaching\n```\nPodcast: Teaching material\nSpeakers: Teacher + inquisitive student\nContent: Textbook + examples\nUse: Students learn while commuting\n```\n\n---\n\n## Cost Breakdown Example\n\n### Generate 15-minute podcast with ElevenLabs\n\n```\nGeneration (outline + dialogue):\n  No charge (included in service)\n\nText-to-speech:\n  2 speakers × 15 minutes = 30 minutes TTS\n  ElevenLabs: $0.10 per minute\n  Cost: 30 × $0.10 = $3.00\n\nProcessing:\n  Included (no additional cost)\n\nTotal: $3.00 per podcast\n\nCheaper options:\n  With Google TTS: ~$0.12\n  With OpenAI: ~$0.45\n  With Local TTS: ~$0.00\n```\n\n---\n\n## Summary: Podcasts as Research Tool\n\nPodcasts transform how you consume research:\n\n```\nBefore: Reading papers takes time, focus\nAfter: Listen while commuting, exercising, doing chores\n\nBefore: Can't share complex research easily\nAfter: Share audio of your analysis\n\nBefore: Different consumption styles isolated\nAfter: Same research, multiple formats (read/listen)\n```\n\nPodcasts aren't just for entertainment—they're a tool for making research more accessible, shareable, and consumable.\n\nThat's why they're important for Open Notebook.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/index.md",
    "content": "# User Guide - How to Use Open Notebook\n\nThis guide covers practical, step-by-step usage of Open Notebook features. You already understand the concepts; now learn how to actually use them.\n\n> **Prerequisite**: Review [2-CORE-CONCEPTS](../2-CORE-CONCEPTS/index.md) first to understand the mental models (notebooks, sources, notes, chat, transformations, podcasts).\n\n---\n\n## Start Here\n\n### [Interface Overview](interface-overview.md)\nLearn the layout before diving in. Understand the three-panel design and where everything is.\n\n---\n\n## Eight Core Features\n\n### 1. [Adding Sources](adding-sources.md)\nHow to bring content into your notebook. Supports PDFs, web links, audio, video, text, and more.\n\n**Quick links:**\n- Upload a PDF or document\n- Add a web link or article\n- Transcribe audio or video\n- Paste text directly\n- Common mistakes + fixes\n\n---\n\n### 2. [Working with Notes](working-with-notes.md)\nCreating, organizing, and using notes (both manual and AI-generated).\n\n**Quick links:**\n- Create a manual note\n- Save AI responses as notes\n- Apply transformations to generate insights\n- Organize with tags and naming\n- Use notes across your notebook\n\n---\n\n### 3. [Chat Effectively](chat-effectively.md)\nHave conversations with AI about your sources. Manage context to control what AI sees.\n\n**Quick links:**\n- Start your first chat\n- Select which sources go in context\n- Ask effective questions\n- Use follow-ups productively\n- Understand citations and verify claims\n\n---\n\n### 4. [Creating Podcasts](creating-podcasts.md)\nConvert your research into audio dialogue for passive consumption.\n\n**Quick links:**\n- Create your first podcast\n- Choose or customize speakers\n- Select TTS provider\n- Generate and download\n- Common audio quality fixes\n\n---\n\n### 5. [Search Effectively](search.md)\nTwo search modes: text-based (keyword) and vector-based (semantic). Know when to use each.\n\n**Quick links:**\n- Text search vs vector search (when to use)\n- Running effective searches\n- Using the Ask feature for comprehensive answers\n- Saving search results as notes\n- Troubleshooting poor results\n\n---\n\n### 6. [Transformations](transformations.md)\nBatch-process sources with predefined templates. Extract the same insights from multiple documents.\n\n**Quick links:**\n- Built-in transformation templates\n- Creating custom transformations\n- Applying to single or multiple sources\n- Managing transformation output\n\n---\n\n### 7. [Citations](citations.md)\nVerify AI claims by tracing them back to source material. Understand the citation system.\n\n**Quick links:**\n- Reading and clicking citations\n- Verifying claims against sources\n- Requesting better citations\n- Saving cited content as notes\n\n---\n\n### 8. [API Configuration](api-configuration.md)\nConfigure AI provider API keys directly through the Settings UI.\n\n**Quick links:**\n- Add API keys without editing files\n- Test provider connections\n- Migrate from environment variables\n- Manage Azure and OpenAI-compatible providers\n- Understand key storage and encryption\n\n---\n\n## Which Feature for Which Task?\n\n```\nTask: \"I want to explore a topic with follow-ups\"\n→ Use: Chat (add sources, select context, have conversation)\n\nTask: \"I want one comprehensive answer\"\n→ Use: Search / Ask (system finds relevant content)\n\nTask: \"I want to extract the same info from many sources\"\n→ Use: Transformations (define template, apply to all)\n\nTask: \"I want summaries of all my sources\"\n→ Use: Transformations (with built-in summary template)\n\nTask: \"I want to share my research in audio form\"\n→ Use: Podcasts (create speakers, generate episode)\n\nTask: \"I want to find that quote I remember\"\n→ Use: Search / Text Search (keyword matching)\n\nTask: \"I'm exploring a concept without knowing exact words\"\n→ Use: Search / Vector Search (semantic similarity)\n\nTask: \"I need to add or change my AI provider API keys\"\n→ Use: Settings / API Keys (configure providers without editing files)\n```\n\n---\n\n## Quick-Start Checklist: First 15 Minutes\n\n**Step 1: Create a Notebook (1 min)**\n- Name: Something descriptive (\"Q1 Market Research\", \"AI Safety Papers\", etc.)\n- Description: 1-2 sentences about what you're researching\n- This is your research container\n\n**Step 2: Add Your First Source (3 min)**\n- Pick one: PDF, web link, or text\n- Follow [Adding Sources](adding-sources.md)\n- Wait for processing (usually 30-60 seconds)\n\n**Step 3: Chat About It (3 min)**\n- Go to Chat\n- Select your source (set context to \"Full Content\")\n- Ask a simple question: \"What are the main points?\"\n- See AI respond with citations\n\n**Step 4: Save Insight as Note (2 min)**\n- Good response? Click \"Save as Note\"\n- Name it something useful (\"Main points from source X\")\n- Now you have a captured insight\n\n**Step 5: Explore More (6 min)**\n- Add another source\n- Chat about both together\n- Ask a question that compares them\n- Follow up with clarifying questions\n\n**Done!** You've used the core workflow: notebook → sources → chat → notes\n\n---\n\n## Common Mistakes to Avoid\n\n| Mistake | Problem | Fix |\n|---------|---------|-----|\n| Adding everything to one notebook | No isolation between projects | Create separate notebooks for different topics |\n| Expecting AI to know your context | Questions get generic answers | Describe your research focus in chat context |\n| Forgetting to cite sources | You can't verify claims | Click citations to check source chunks |\n| Using Chat for one-time questions | Slower than Ask | Use Ask for comprehensive Q&A, Chat for exploration |\n| Adding huge PDFs without chunking | Slow processing, poor search | Break into multiple smaller sources if possible |\n| Using same context for all chats | Expensive, unfocused | Adjust context level for each chat |\n| Ignoring vector search | Only finding exact keywords | Use vector search to explore conceptually |\n\n---\n\n## Next Steps\n\n1. **Follow each guide** in order (sources → notes → chat → podcasts → search)\n2. **Create your first notebook** with real content\n3. **Practice each feature** with your own research\n4. **Return to CORE-CONCEPTS** if you need to understand the \"why\"\n\n---\n\n## Getting Help\n\n- **Feature not working?** → Check the feature's guide (look for \"Troubleshooting\" section)\n- **Error message?** → Check [6-TROUBLESHOOTING](../6-TROUBLESHOOTING/index.md)\n- **Understanding how something works?** → Check [2-CORE-CONCEPTS](../2-CORE-CONCEPTS/index.md)\n- **Setting up for the first time?** → Go back to [1-INSTALLATION](../1-INSTALLATION/index.md)\n- **For developers** → See [7-DEVELOPMENT](../7-DEVELOPMENT/index.md)\n\n---\n\n**Ready to start?** Pick the guide for what you want to do first!\n"
  },
  {
    "path": "docs/3-USER-GUIDE/interface-overview.md",
    "content": "# Interface Overview - Finding Your Way Around\n\nOpen Notebook uses a clean three-panel layout. This guide shows you where everything is.\n\n---\n\n## The Main Layout\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│  [Logo]  Notebooks  Search  Podcasts  Models  Settings      │\n├──────────────┬──────────────┬───────────────────────────────┤\n│              │              │                               │\n│   SOURCES    │    NOTES     │           CHAT                │\n│              │              │                               │\n│  Your docs   │  Your        │   Talk to AI about            │\n│  PDFs, URLs  │  insights    │   your sources                │\n│  Videos      │  summaries   │                               │\n│              │              │                               │\n│  [+Add]      │  [+Write]    │   [Type here...]              │\n│              │              │                               │\n└──────────────┴──────────────┴───────────────────────────────┘\n```\n\n---\n\n## Navigation Bar\n\nThe top navigation takes you to main sections:\n\n| Icon | Page | What It Does |\n|------|------|--------------|\n| **Notebooks** | Main workspace | Your research projects |\n| **Search** | Ask & Search | Query across all notebooks |\n| **Podcasts** | Audio generation | Manage podcast profiles |\n| **Models** | AI configuration | Set up providers and models |\n| **Settings** | Preferences | App configuration |\n\n---\n\n## Left Panel: Sources\n\nYour research materials live here.\n\n### What You'll See\n\n```\n┌─────────────────────────┐\n│  Sources (5)            │\n│  [+ Add Source]         │\n├─────────────────────────┤\n│  ┌─────────────────┐    │\n│  │ 📄 Paper.pdf    │    │\n│  │ 🟢 Full Content │    │\n│  │ [⋮ Menu]        │    │\n│  └─────────────────┘    │\n│                         │\n│  ┌─────────────────┐    │\n│  │ 🔗 Article URL  │    │\n│  │ 🟡 Summary Only │    │\n│  │ [⋮ Menu]        │    │\n│  └─────────────────┘    │\n└─────────────────────────┘\n```\n\n### Source Card Elements\n\n- **Icon** - File type (PDF, URL, video, etc.)\n- **Title** - Document name\n- **Context indicator** - What AI can see:\n  - 🟢 Full Content\n  - 🟡 Summary Only\n  - ⛔ Not in Context\n- **Menu (⋮)** - Edit, transform, delete\n\n### Add Source Button\n\nClick to add:\n- File upload (PDF, DOCX, etc.)\n- Web URL\n- YouTube video\n- Plain text\n\n---\n\n## Middle Panel: Notes\n\nYour insights and AI-generated content.\n\n### What You'll See\n\n```\n┌─────────────────────────┐\n│  Notes (3)              │\n│  [+ Write Note]         │\n├─────────────────────────┤\n│  ┌─────────────────┐    │\n│  │ 📝 My Analysis  │    │\n│  │ Manual note     │    │\n│  │ Jan 3, 2026     │    │\n│  └─────────────────┘    │\n│                         │\n│  ┌─────────────────┐    │\n│  │ 🤖 Summary      │    │\n│  │ From transform  │    │\n│  │ Jan 2, 2026     │    │\n│  └─────────────────┘    │\n└─────────────────────────┘\n```\n\n### Note Card Elements\n\n- **Icon** - Note type (manual 📝 or AI 🤖)\n- **Title** - Note name\n- **Origin** - How it was created\n- **Date** - When created\n\n### Write Note Button\n\nClick to:\n- Create manual note\n- Add your own insights\n- Markdown supported\n\n---\n\n## Right Panel: Chat\n\nYour AI conversation space.\n\n### What You'll See\n\n```\n┌───────────────────────────────┐\n│  Chat                         │\n│  Session: Research Discussion │\n│  [+ New Session] [Sessions ▼] │\n├───────────────────────────────┤\n│                               │\n│  You: What's the main         │\n│       finding?                │\n│                               │\n│  AI: Based on the paper [1],  │\n│      the main finding is...   │\n│      [Save as Note]           │\n│                               │\n│  You: Tell me more about      │\n│       the methodology.        │\n│                               │\n├───────────────────────────────┤\n│  Context: 3 sources (12K tok) │\n├───────────────────────────────┤\n│  [Type your message...]  [↑]  │\n└───────────────────────────────┘\n```\n\n### Chat Elements\n\n- **Session selector** - Switch between conversations\n- **Message history** - Your conversation\n- **Save as Note** - Keep good responses\n- **Context indicator** - What AI can see\n- **Input field** - Type your questions\n\n---\n\n## Context Indicators\n\nThese show what AI can access:\n\n### Token Counter\n\n```\nContext: 3 sources (12,450 tokens)\n         ↑          ↑\n         Sources    Approximate cost indicator\n         included\n```\n\n### Per-Source Indicators\n\n| Indicator | Meaning | AI Access |\n|-----------|---------|-----------|\n| 🟢 Full Content | Complete text | Everything |\n| 🟡 Summary Only | AI summary | Key points only |\n| ⛔ Not in Context | Excluded | Nothing |\n\nClick any source to change its context level.\n\n---\n\n## Podcasts Tab\n\nInside a notebook, switch to Podcasts:\n\n```\n┌───────────────────────────────┐\n│  [Chat]  [Podcasts]           │\n├───────────────────────────────┤\n│  Episode Profile: [Select ▼]  │\n│                               │\n│  Speakers:                    │\n│  ├─ Host: Alex (voice model)  │\n│  └─ Guest: Sam (voice model)  │\n│                               │\n│  Include:                     │\n│  ☑ Paper.pdf                  │\n│  ☑ My Analysis (note)         │\n│  ☐ Background article         │\n│                               │\n│  [Generate Podcast]           │\n└───────────────────────────────┘\n```\n\n---\n\n## Settings Page\n\nAccess via navigation bar → Settings:\n\n### Key Sections\n\n| Section | What It Controls |\n|---------|------------------|\n| **Processing** | Document and URL extraction engines |\n| **Embedding** | Auto-embed settings |\n| **Files** | Auto-delete uploads after processing |\n| **YouTube** | Preferred transcript languages |\n\n---\n\n## Models Page\n\nConfigure AI providers:\n\n```\n┌───────────────────────────────────────┐\n│  Models                               │\n├───────────────────────────────────────┤\n│  Language Models                      │\n│  ┌─────────────────────────────────┐  │\n│  │ GPT-4o (OpenAI)         [Edit]  │  │\n│  │ Claude Sonnet (Anthropic)       │  │\n│  │ Llama 3.3 (Ollama)      [⭐]    │  │\n│  └─────────────────────────────────┘  │\n│  [+ Add Model]                        │\n│                                       │\n│  Embedding Models                     │\n│  ┌─────────────────────────────────┐  │\n│  │ text-embedding-3-small  [⭐]    │  │\n│  └─────────────────────────────────┘  │\n│                                       │\n│  Text-to-Speech                       │\n│  ┌─────────────────────────────────┐  │\n│  │ OpenAI TTS             [⭐]     │  │\n│  │ Google TTS                      │  │\n│  └─────────────────────────────────┘  │\n└───────────────────────────────────────┘\n```\n\n- **⭐** = Default model for that category\n- **[Edit]** = Modify configuration\n- **[+ Add]** = Add new model\n\n---\n\n## Search Page\n\nQuery across all notebooks:\n\n```\n┌───────────────────────────────────────┐\n│  Search                               │\n├───────────────────────────────────────┤\n│  [What are you looking for?    ] [🔍] │\n│                                       │\n│  Search type: [Text ▼] [Vector ▼]     │\n│  Search in:   [Sources] [Notes]       │\n├───────────────────────────────────────┤\n│  Results (15)                         │\n│                                       │\n│  📄 Paper.pdf - Notebook: Research    │\n│     \"...the transformer model...\"     │\n│                                       │\n│  📝 My Analysis - Notebook: Research  │\n│     \"...key findings include...\"      │\n└───────────────────────────────────────┘\n```\n\n---\n\n## Common Actions\n\n### Create a Notebook\n\n```\nNotebooks page → [+ New Notebook] → Enter name → Create\n```\n\n### Add a Source\n\n```\nInside notebook → [+ Add Source] → Choose type → Upload/paste → Wait for processing\n```\n\n### Ask a Question\n\n```\nInside notebook → Chat panel → Type question → Enter → Read response\n```\n\n### Save AI Response\n\n```\nGet good response → Click [Save as Note] → Edit title → Save\n```\n\n### Change Context Level\n\n```\nClick source → Context dropdown → Select level → Changes apply immediately\n```\n\n### Generate Podcast\n\n```\nPodcasts tab → Select profile → Choose sources → [Generate] → Wait → Download\n```\n\n---\n\n## Keyboard Shortcuts\n\n| Key | Action |\n|-----|--------|\n| `Enter` | Send chat message |\n| `Shift + Enter` | New line in chat |\n| `Escape` | Close dialogs |\n| `Ctrl/Cmd + F` | Browser find |\n\n---\n\n## Mobile View\n\nOn smaller screens, the three-panel layout stacks vertically:\n\n```\n┌─────────────────┐\n│    SOURCES      │\n│    (tap to expand)\n├─────────────────┤\n│    NOTES        │\n│    (tap to expand)\n├─────────────────┤\n│    CHAT         │\n│    (always visible)\n└─────────────────┘\n```\n\n- Panels collapse to save space\n- Tap headers to expand/collapse\n- Chat remains accessible\n- Full functionality preserved\n\n---\n\n## Tips for Efficient Navigation\n\n1. **Use keyboard** - Enter sends messages, Escape closes dialogs\n2. **Context first** - Set source context before chatting\n3. **Sessions** - Create new sessions for different topics\n4. **Search globally** - Use Search page to find across all notebooks\n5. **Models page** - Bookmark your preferred models\n\n---\n\nNow you know where everything is. Start with [Adding Sources](adding-sources.md) to begin your research!\n"
  },
  {
    "path": "docs/3-USER-GUIDE/search.md",
    "content": "# Search Effectively - Finding What You Need\n\nSearch is your gateway into your research. This guide covers two search modes and when to use each.\n\n---\n\n## Quick-Start: Find Something\n\n### Simple Search\n\n```\n1. Go to your notebook\n2. Type in search box\n3. See results (both sources and notes)\n4. Click result to view source/note\n5. Done!\n\nThat works for basic searches.\nBut you can do much better...\n```\n\n---\n\n## Two Search Modes Explained\n\nOpen Notebook has two fundamentally different search approaches.\n\n### Search Type 1: TEXT SEARCH (Keyword Matching)\n\n**How it works:**\n- You search for words: \"transformer\"\n- System finds chunks containing \"transformer\"\n- Ranked by relevance: frequency, position, context\n\n**Speed:** Very fast (instant)\n\n**When to use:**\n- You remember exact words or phrases\n- You're looking for specific terms\n- You want precise keyword matches\n- You need exact quotes\n\n**Example:**\n```\nSearch: \"attention mechanism\"\nResults:\n  1. \"The attention mechanism allows...\" (perfect match)\n  2. \"Attention and other mechanisms...\" (partial match)\n  3. \"How mechanisms work in attention...\" (includes words separately)\n\nAll contain \"attention\" AND \"mechanism\"\nRanked by how close together they are\n```\n\n**What it finds:**\n- Exact phrases: \"transformer model\"\n- Individual words: transformer OR model (too broad)\n- Names: \"Vaswani et al.\"\n- Numbers: \"1994\", \"GPT-4\"\n- Technical terms: \"LSTM\", \"convolution\"\n\n**What it doesn't find:**\n- Similar words: searching \"attention\" won't find \"focus\"\n- Synonyms: searching \"large\" won't find \"big\"\n- Concepts: searching \"similarity\" won't find \"likeness\"\n\n---\n\n### Search Type 2: VECTOR SEARCH (Semantic/Concept Matching)\n\n**How it works:**\n- Your search converted to embedding (vector)\n- All chunks converted to embeddings\n- System finds most similar embeddings\n- Ranked by semantic similarity\n\n**Speed:** A bit slower (1-2 seconds)\n\n**When to use:**\n- You're exploring a concept\n- You don't know exact words\n- You want semantically similar content\n- You're discovering, not searching\n\n**Example:**\n```\nSearch: \"What's the mechanism for understanding in models?\"\n(Notice: No chunk likely says exactly that)\n\nResults:\n  1. \"Mechanistic interpretability allows understanding...\" (semantic match)\n  2. \"Feature attribution reveals how models work...\" (conceptually similar)\n  3. \"Attention visualization shows model decisions...\" (same topic)\n\nNone contain your exact words\nBut all are semantically related\n```\n\n**What it finds:**\n- Similar concepts: \"understanding\" + \"interpretation\" + \"explainability\" (all related)\n- Paraphrases: \"big\" and \"large\" (same meaning)\n- Related ideas: \"safety\" relates to \"alignment\" (connected concepts)\n- Analogies: content about biological learning when searching \"learning\"\n\n**What it doesn't find:**\n- Exact keywords: if you search a rare word, vector search might miss it\n- Specific numbers: \"1994\" vs \"1993\" are semantically different\n- Technical jargon: \"LSTM\" and \"RNN\" are different even if related\n\n---\n\n## Decision: Text Search vs. Vector Search?\n\n```\nQuestion: \"Do I remember the exact words?\"\n\n→ YES: Use TEXT SEARCH\n   Example: \"I remember the paper said 'attention is all you need'\"\n\n→ NO: Use VECTOR SEARCH\n   Example: \"I'm looking for content about how models process information\"\n\n→ UNSURE: Try TEXT SEARCH first (faster)\n         If no results, try VECTOR SEARCH\n\nText search: \"I know what I'm looking for\"\nVector search: \"I'm exploring an idea\"\n```\n\n---\n\n## Step-by-Step: Using Each Search\n\n### Text Search\n\n```\n1. Go to search box\n2. Type your keywords: \"transformer\", \"attention\", \"2017\"\n3. Press Enter\n4. Results appear (usually instant)\n5. Click result to see context\n\nResults show:\n  - Which source contains it\n  - How many times it appears\n  - Relevance score\n  - Preview of surrounding text\n```\n\n### Vector Search\n\n```\n1. Go to search box\n2. Type your concept: \"How do models understand language?\"\n3. Choose \"Vector Search\" from dropdown\n4. Press Enter\n5. Results appear (1-2 seconds)\n6. Click result to see context\n\nResults show:\n  - Semantically related chunks\n  - Similarity score (higher = more related)\n  - Preview of surrounding text\n  - Different sources mixed together\n```\n\n---\n\n## The Ask Feature (Automated Search)\n\nAsk is different from simple search. It automatically searches, synthesizes, and answers.\n\n### How Ask Works\n\n```\nStage 1: QUESTION UNDERSTANDING\n  \"Compare the approaches in my papers\"\n  → System: \"This asks for comparison\"\n\nStage 2: SEARCH STRATEGY\n  → System: \"I should search for each approach separately\"\n\nStage 3: PARALLEL SEARCHES\n  → Search 1: \"Approach in paper A\"\n  → Search 2: \"Approach in paper B\"\n  (Multiple searches happen at once)\n\nStage 4: ANALYSIS & SYNTHESIS\n  → Per-result analysis: \"Based on paper A, the approach is...\"\n  → Per-result analysis: \"Based on paper B, the approach is...\"\n  → Final synthesis: \"Comparing A and B: A differs from B in...\"\n\nResult: Comprehensive answer, not just search results\n```\n\n### When to Use Ask vs. Simple Search\n\n| Task | Use | Why |\n|------|-----|-----|\n| \"Find the quote about X\" | **TEXT SEARCH** | Need exact words |\n| \"What does source A say about X?\" | **TEXT SEARCH** | Direct, fast answer |\n| \"Find content about X\" | **VECTOR SEARCH** | Semantic discovery |\n| \"Compare A and B\" | **ASK** | Comprehensive synthesis |\n| \"What's the big picture?\" | **ASK** | Full analysis needed |\n| \"How do these sources relate?\" | **ASK** | Cross-source synthesis |\n| \"I remember something about X\" | **TEXT SEARCH** | Recall memory |\n| \"I'm exploring the topic of X\" | **VECTOR SEARCH** | Discovery mode |\n\n---\n\n## Advanced Search Strategies\n\n### Strategy 1: Simple Search with Follow-Up\n\n```\n1. Text search: \"attention mechanism\"\n   Results: 50 matches\n\n2. Too many. Follow up with vector search:\n   \"Why is attention useful?\" (concept search)\n   Results: Most relevant papers/notes\n\n3. Better results with less noise\n```\n\n### Strategy 2: Ask for Comprehensive, Then Search for Details\n\n```\n1. Ask: \"What are the main approaches to X?\"\n   Result: Comprehensive answer about A, B, C\n\n2. Use that to identify specific sources\n\n3. Text search in those specific sources:\n   \"Why did they choose method X?\"\n   Result: Detailed information\n```\n\n### Strategy 3: Vector Search for Discovery, Text for Verification\n\n```\n1. Vector search: \"How do transformers generalize?\"\n   Results: Related conceptual papers\n\n2. Skim to understand landscape\n\n3. Text search in promising sources:\n   \"generalization\", \"extrapolation\", \"transfer\"\n   Results: Specific passages to read carefully\n```\n\n### Strategy 4: Combine Search with Chat\n\n```\n1. Vector search: \"What's new in AI 2026?\"\n   Results: Latest papers\n\n2. Go to Chat\n3. Add those papers to context\n4. Ask detailed follow-up questions\n5. Get deep analysis of results\n```\n\n---\n\n## Search Quality Issues & Fixes\n\n### Getting No Results\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| Text search: no results | Word doesn't appear | Try vector search instead |\n| Vector search: no results | Concept not in content | Try broader search term |\n| Both empty | Content not in notebook | Add sources to notebook |\n| | Sources not processed | Wait for processing to complete |\n\n### Getting Too Many Results\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| 1000+ results | Search too broad | Be more specific |\n| | All sources | Filter by source |\n| | Keyword matches rare words | Use vector search instead |\n\n### Getting Wrong Results\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| Results irrelevant | Search term has multiple meanings | Provide more context |\n| | Using text search for concepts | Try vector search |\n| Different meaning | Homonym (word means multiple things) | Add context (e.g., \"attention mechanism\") |\n\n### Getting Low Quality Results\n\n| Problem | Cause | Solution |\n|---------|-------|----------|\n| Results don't match intent | Vague search term | Be specific (\"Who invented X?\" vs \"X\") |\n| | Concept not well-represented | Add more sources on that topic |\n| | Vector embedding not trained on domain | Use text search as fallback |\n\n---\n\n## Tips for Better Searches\n\n### For Text Search\n1. **Be specific** — \"attention mechanism\" not just \"attention\"\n2. **Use exact phrases** — Put quotes around: \"attention is all you need\"\n3. **Include context** — \"LSTM vs attention\" not just \"attention\"\n4. **Use technical terms** — These are usually more precise\n5. **Try synonyms** — If first search fails, try related terms\n\n### For Vector Search\n1. **Ask a question** — \"What's the best way to X?\" is better than \"best way\"\n2. **Use natural language** — Explain what you're looking for\n3. **Be specific about intent** — \"Compare X and Y\" not \"X and Y\"\n4. **Include context** — \"In machine learning, how...\" vs just \"how...\"\n5. **Think conceptually** — What idea are you exploring?\n\n### General Tips\n1. **Start broad, then narrow** — \"AI papers\" → \"transformers\" → \"attention mechanism\"\n2. **Try both search types** — Each finds different things\n3. **Use Ask for complex questions** — Don't just search\n4. **Save good results as notes** — Create knowledge base\n5. **Filter by source if needed** — \"Search in Paper A only\"\n\n---\n\n## Search Examples\n\n### Example 1: Finding a Specific Fact\n\n**Goal:** \"Find the date the transformer was introduced\"\n\n```\nStep 1: Text search\n  \"transformer 2017\" (or year you remember)\n\nIf that works: Done!\n\nIf no results: Try\n  \"attention is all you need\" (famous paper title)\n\nCheck result for exact date\n```\n\n### Example 2: Exploring a Concept\n\n**Goal:** \"Find content about alignment interpretability\"\n\n```\nStep 1: Vector search\n  \"How do we make AI interpretable?\"\n\nResults: Papers on interpretability, transparency, alignment\n\nStep 2: Review results\n  See which papers are most relevant\n\nStep 3: Deep dive\n  Go to Chat, add top 2-3 papers\n  Ask detailed questions about alignment\n```\n\n### Example 3: Comprehensive Answer\n\n**Goal:** \"How do different approaches to AI safety compare?\"\n\n```\nStep 1: Ask\n  \"Compare the main approaches to AI safety in my sources\"\n\nResult: Comprehensive analysis comparing approaches\n\nStep 2: Identify sources\n  From answer, see which papers were most relevant\n\nStep 3: Deep dive\n  Text search in those papers:\n  \"limitations\", \"critiques\", \"open problems\"\n\nStep 4: Save as notes\n  Create comparison note from Ask result\n```\n\n### Example 4: Finding Pattern\n\n**Goal:** \"Find all papers mentioning transformers\"\n\n```\nStep 1: Text search\n  \"transformer\"\n\nResults: All papers mentioning \"transformer\"\n\nStep 2: Vector search\n  \"neural network architecture for sequence processing\"\n\nResults: Papers that don't say \"transformer\" but discuss similar concept\n\nStep 3: Combine\n  Union of text + vector results shows full landscape\n\nStep 4: Analyze\n  Go to Chat with all results\n  Ask: \"What's common across all these?\"\n```\n\n---\n\n## Search in the Workflow\n\nHow search fits with other features:\n\n```\nSOURCES\n  ↓\nSEARCH (find what matters)\n  ├─ Text search (precise)\n  ├─ Vector search (exploration)\n  └─ Ask (comprehensive)\n  ↓\nCHAT (explore with follow-ups)\n  ↓\nTRANSFORMATIONS (batch extract)\n  ↓\nNOTES (save insights)\n```\n\n### Workflow Example\n\n```\n1. Add 10 papers to notebook\n\n2. Search: \"What's the state of the art?\"\n   (Vector search explores landscape)\n\n3. Ask: \"Compare these 3 approaches\"\n   (Comprehensive synthesis)\n\n4. Chat: Deep questions about winner\n   (Follow-up exploration)\n\n5. Save best insights as notes\n   (Knowledge capture)\n\n6. Transform remaining papers\n   (Batch extraction for later)\n\n7. Create podcast from notes + sources\n   (Share findings)\n```\n\n---\n\n## Summary: Know Your Search\n\n**TEXT SEARCH** — \"I know what I'm looking for\"\n- Fast, precise, keyword-based\n- Use when you remember exact words/phrases\n- Best for: Finding specific facts, quotes, technical terms\n- Speed: Instant\n\n**VECTOR SEARCH** — \"I'm exploring an idea\"\n- Slow-ish, concept-based, semantic\n- Use when you're discovering connections\n- Best for: Concept exploration, related ideas, synonyms\n- Speed: 1-2 seconds\n\n**ASK** — \"I want a comprehensive answer\"\n- Auto-searches, auto-analyzes, synthesizes\n- Use for complex questions needing multiple sources\n- Best for: Comparisons, big-picture questions, synthesis\n- Speed: 10-30 seconds\n\nPick the right tool for your search goal, and you'll find what you need faster.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/transformations.md",
    "content": "# Transformations - Batch Processing Your Sources\n\nTransformations apply the same analysis to multiple sources at once. Instead of asking the same question repeatedly, define a template and run it across your content.\n\n---\n\n## When to Use Transformations\n\n| Use Transformations When | Use Chat Instead When |\n|-------------------------|----------------------|\n| Same analysis on many sources | One-off questions |\n| Consistent output format needed | Exploratory conversation |\n| Batch processing | Follow-up questions needed |\n| Creating structured notes | Context changes between questions |\n\n**Example**: You have 10 papers and want a summary of each. Transformation does it in one operation.\n\n---\n\n## Quick Start: Your First Transformation\n\n```\n1. Go to your notebook\n2. Click \"Transformations\" in navigation\n3. Select a built-in template (e.g., \"Summary\")\n4. Select sources to transform\n5. Click \"Apply\"\n6. Wait for processing\n7. New notes appear automatically\n```\n\n---\n\n## Built-in Transformations\n\nOpen Notebook includes ready-to-use templates:\n\n### Summary\n\n```\nWhat it does: Creates a 200-300 word overview\nOutput: Key points, main arguments, conclusions\nBest for: Quick reference, getting the gist\n```\n\n### Key Concepts\n\n```\nWhat it does: Extracts main ideas and terminology\nOutput: List of concepts with explanations\nBest for: Learning new topics, building vocabulary\n```\n\n### Methodology\n\n```\nWhat it does: Extracts research approach\nOutput: How the study was conducted\nBest for: Academic papers, research review\n```\n\n### Takeaways\n\n```\nWhat it does: Extracts actionable insights\nOutput: What you should do with this information\nBest for: Business documents, practical guides\n```\n\n### Questions\n\n```\nWhat it does: Generates questions the source raises\nOutput: Open questions, gaps, follow-up research\nBest for: Literature review, research planning\n```\n\n---\n\n## Creating Custom Transformations\n\n### Step-by-Step\n\n```\n1. Go to \"Transformations\" page\n2. Click \"Create New\"\n3. Enter a name: \"Academic Paper Analysis\"\n4. Write your prompt template:\n\n   \"Analyze this academic paper and extract:\n\n   1. **Research Question**: What problem does this address?\n   2. **Hypothesis**: What did they predict?\n   3. **Methodology**: How did they test it?\n   4. **Key Findings**: What did they discover? (numbered list)\n   5. **Limitations**: What caveats do the authors mention?\n   6. **Future Work**: What do they suggest next?\n\n   Be specific and cite page numbers where possible.\"\n\n5. Click \"Save\"\n6. Your transformation appears in the list\n```\n\n### Prompt Template Tips\n\n**Be specific about format:**\n```\nGood: \"List 5 key points as bullet points\"\nBad: \"What are the key points?\"\n```\n\n**Request structure:**\n```\nGood: \"Create sections for: Summary, Methods, Results\"\nBad: \"Tell me about this paper\"\n```\n\n**Ask for citations:**\n```\nGood: \"Cite page numbers for each claim\"\nBad: (no citation request)\n```\n\n**Set length expectations:**\n```\nGood: \"In 200-300 words, summarize...\"\nBad: \"Summarize this\"\n```\n\n---\n\n## Applying Transformations\n\n### To a Single Source\n\n```\n1. In Sources panel, click source menu (⋮)\n2. Select \"Transform\"\n3. Choose transformation template\n4. Click \"Apply\"\n5. Note appears when done\n```\n\n### To Multiple Sources (Batch)\n\n```\n1. Go to Transformations page\n2. Select your template\n3. Check multiple sources\n4. Click \"Apply to Selected\"\n5. Processing runs in parallel\n6. One note per source created\n```\n\n### Processing Time\n\n| Sources | Typical Time |\n|---------|--------------|\n| 1 source | 30 seconds - 1 minute |\n| 5 sources | 2-3 minutes |\n| 10 sources | 4-5 minutes |\n| 20+ sources | 8-10 minutes |\n\nProcessing runs in background. You can continue working.\n\n---\n\n## Transformation Examples\n\n### Literature Review Template\n\n```\nName: Literature Review Entry\n\nPrompt:\n\"For this research paper, create a literature review entry:\n\n**Citation**: [Author(s), Year, Title, Journal]\n**Research Question**: What problem is addressed?\n**Methodology**: What approach was used?\n**Sample**: What population/data was studied?\n**Key Findings**:\n1. [Finding with page citation]\n2. [Finding with page citation]\n3. [Finding with page citation]\n**Strengths**: What did this study do well?\n**Limitations**: What are the gaps?\n**Relevance**: How does this connect to my research?\n\nKeep each section to 2-3 sentences.\"\n```\n\n### Meeting Notes Template\n\n```\nName: Meeting Summary\n\nPrompt:\n\"From this meeting transcript, extract:\n\n**Attendees**: Who was present\n**Date/Time**: When it occurred\n**Key Decisions**: What was decided (numbered)\n**Action Items**:\n- [ ] Task (Owner, Due Date)\n**Open Questions**: Unresolved issues\n**Next Steps**: What happens next\n\nFormat as clear, scannable notes.\"\n```\n\n### Competitor Analysis Template\n\n```\nName: Competitor Analysis\n\nPrompt:\n\"Analyze this company/product document:\n\n**Company**: Name and overview\n**Products/Services**: What they offer\n**Target Market**: Who they serve\n**Pricing**: If available\n**Strengths**: Competitive advantages\n**Weaknesses**: Gaps or limitations\n**Opportunities**: How we compare\n**Threats**: What they do better\n\nBe objective and cite specific details.\"\n```\n\n### Technical Documentation Template\n\n```\nName: API Documentation Summary\n\nPrompt:\n\"Extract from this technical document:\n\n**Overview**: What does this do? (1-2 sentences)\n**Authentication**: How to authenticate\n**Key Endpoints**:\n- Endpoint 1: [method] [path] - [purpose]\n- Endpoint 2: ...\n**Common Parameters**: Frequently used params\n**Rate Limits**: If mentioned\n**Error Codes**: Key error responses\n**Example Usage**: Simple code example if possible\n\nKeep technical but concise.\"\n```\n\n---\n\n## Managing Transformations\n\n### Edit a Transformation\n\n```\n1. Go to Transformations page\n2. Find your template\n3. Click \"Edit\"\n4. Modify the prompt\n5. Click \"Save\"\n```\n\n### Delete a Transformation\n\n```\n1. Go to Transformations page\n2. Find the template\n3. Click \"Delete\"\n4. Confirm\n```\n\n### Reorder/Organize\n\nBuilt-in transformations appear first, then custom ones alphabetically.\n\n---\n\n## Transformation Output\n\n### Where Results Go\n\n- Each source produces one note\n- Notes appear in your notebook's Notes panel\n- Notes are tagged with transformation name\n- Original source is linked\n\n### Note Naming\n\n```\nDefault: \"[Transformation Name] - [Source Title]\"\nExample: \"Summary - Research Paper 2025.pdf\"\n```\n\n### Editing Output\n\n```\n1. Click the generated note\n2. Click \"Edit\"\n3. Refine the content\n4. Save\n```\n\n---\n\n## Best Practices\n\n### Template Design\n\n1. **Start specific** - Vague prompts give vague results\n2. **Use formatting** - Headings, bullets, numbered lists\n3. **Request citations** - Make results verifiable\n4. **Set length** - Prevent overly long or short output\n5. **Test first** - Run on one source before batch\n\n### Source Selection\n\n1. **Similar content** - Same transformation on similar sources\n2. **Reasonable size** - Very long sources may need splitting\n3. **Processed status** - Ensure sources are fully processed\n\n### Quality Control\n\n1. **Review samples** - Check first few outputs before trusting batch\n2. **Edit as needed** - Transformations are starting points\n3. **Iterate prompts** - Refine based on results\n\n---\n\n## Common Issues\n\n### Generic Output\n\n**Problem**: Results are too vague\n**Solution**: Make prompt more specific, add format requirements\n\n### Missing Information\n\n**Problem**: Key details not extracted\n**Solution**: Explicitly ask for what you need in prompt\n\n### Inconsistent Format\n\n**Problem**: Each note looks different\n**Solution**: Add clear formatting instructions to prompt\n\n### Too Long/Short\n\n**Problem**: Output doesn't match expectations\n**Solution**: Specify word count or section lengths\n\n### Processing Fails\n\n**Problem**: Transformation doesn't complete\n**Solution**:\n- Check source is processed\n- Try shorter/simpler prompt\n- Process sources individually\n\n---\n\n## Transformations vs. Chat vs. Ask\n\n| Feature | Transformations | Chat | Ask |\n|---------|----------------|------|-----|\n| **Input** | Predefined template | Your questions | Your question |\n| **Scope** | One source at a time | Selected sources | Auto-searched |\n| **Output** | Structured note | Conversation | Comprehensive answer |\n| **Best for** | Batch processing | Exploration | One-shot answers |\n| **Follow-up** | Run again | Ask more | New query |\n\n---\n\n## Summary\n\n```\nTransformations = Batch AI Processing\n\nHow to use:\n1. Define template (or use built-in)\n2. Select sources\n3. Apply transformation\n4. Get structured notes\n\nWhen to use:\n- Same analysis on many sources\n- Consistent output needed\n- Building structured knowledge base\n- Saving time on repetitive tasks\n\nTips:\n- Be specific in prompts\n- Request formatting\n- Test before batch\n- Edit output as needed\n```\n\nTransformations turn repetitive analysis into one-click operations. Define once, apply many times.\n"
  },
  {
    "path": "docs/3-USER-GUIDE/working-with-notes.md",
    "content": "# Working with Notes - Capturing and Organizing Insights\n\nNotes are your processed knowledge. This guide covers how to create, organize, and use them effectively.\n\n---\n\n## What Are Notes?\n\nNotes are your **research output** — the insights you capture from analyzing sources. They can be:\n\n- **Manual** — You write them yourself\n- **AI-Generated** — From Chat responses, Ask results, or Transformations\n- **Hybrid** — AI insight + your edits and additions\n\nUnlike sources (which never change), notes are mutable — you edit, refine, and organize them.\n\n---\n\n## Quick-Start: Create Your First Note\n\n### Method 1: Manual Note (Write Yourself)\n\n```\n1. In your notebook, go to \"Notes\" section\n2. Click \"Create New Note\"\n3. Give it a title: \"Key insights from source X\"\n4. Write your content (markdown supported)\n5. Click \"Save\"\n6. Done! Note appears in your notebook\n```\n\n### Method 2: Save from Chat\n\n```\n1. Have a Chat conversation\n2. Get a good response from AI\n3. Click \"Save as Note\" button under response\n4. Give the note a title\n5. Add any additional context\n6. Click \"Save\"\n7. Done! Note appears in your notebook\n```\n\n### Method 3: Apply Transformation\n\n```\n1. Go to \"Transformations\"\n2. Select a template (or create custom)\n3. Click \"Apply to sources\"\n4. Select which sources to transform\n5. Wait for processing\n6. New notes automatically appear\n7. Done! Each source produces one note\n```\n\n---\n\n## Creating Manual Notes\n\n### Basic Structure\n\n```\nTitle: \"What you're capturing\"\n       (Make it descriptive)\n\nContent:\n  - Main points\n  - Your analysis\n  - Questions raised\n  - Next steps\n\nMetadata:\n  - Tags: How to categorize\n  - Related sources: Which documents influenced this\n  - Date: Auto-added when created\n```\n\n### Markdown Support\n\nYou can format notes with markdown:\n\n```markdown\n# Heading\n## Subheading\n### Sub-subheading\n\n**Bold text** for emphasis\n*Italic text* for secondary emphasis\n\n- Bullet lists\n- Like this\n\n1. Numbered lists\n2. Like this\n\n> Quotes and important callouts\n\n[Links work](https://example.com)\n```\n\n### Example Note Structure\n\n```markdown\n# Key Findings from \"AI Safety Paper 2025\"\n\n## Main Argument\nThe paper argues that X approach is better than Y because...\n\n## Methodology\nThe authors use [methodology] to test this hypothesis.\n\n## Key Results\n- Result 1: [specific finding with citation]\n- Result 2: [specific finding with citation]\n- Result 3: [specific finding with citation]\n\n## Gaps & Limitations\n1. The paper assumes X, which might not hold in Y scenario\n2. Limited to Z population/domain\n3. Future work needed on A, B, C\n\n## My Thoughts\n- This connects to previous research on...\n- Potential application in...\n\n## Next Steps\n- [ ] Read the referenced paper on X\n- [ ] Find similar studies on Y\n- [ ] Discuss implications with team\n```\n\n---\n\n## AI-Generated Notes: Three Sources\n\n### 1. Save from Chat\n\n```\nWorkflow:\n  Chat → Good response → \"Save as Note\"\n         → Edit if needed → Save\n\nWhen to use:\n  - AI response answers your question well\n  - You want to keep the answer for reference\n  - You're building a knowledge base from conversations\n\nQuality:\n  - Quality = quality of your Chat question\n  - Better context = better responses = better notes\n  - Ask specific questions for useful notes\n```\n\n### 2. Save from Ask\n\n```\nWorkflow:\n  Ask → Comprehensive answer → \"Save as Note\"\n      → Edit if needed → Save\n\nWhen to use:\n  - You need a one-time comprehensive answer\n  - You want to save the synthesized result\n  - Building a knowledge base of comprehensive answers\n\nQuality:\n  - System automatically found relevant sources\n  - Results already have citations\n  - Often higher quality than Chat (more thorough)\n```\n\n### 3. Transformations (Batch Processing)\n\n```\nWorkflow:\n  Define transformation → Apply to sources → Notes auto-created\n                      → Review & edit → Organize\n\nExample Transformation:\n  Template: \"Extract: main argument, methodology, key findings\"\n  Apply to: 5 sources\n  Result: 5 new notes with consistent structure\n\nWhen to use:\n  - Same extraction from many sources\n  - Building structured knowledge base\n  - Creating consistent summaries\n```\n\n---\n\n## Using Transformations for Batch Insights\n\n### Built-in Transformations\n\nOpen Notebook comes with presets:\n\n**Summary**\n```\nExtracts: Main points, key arguments, conclusions\nOutput: 200-300 word summary of source\nBest for: Quick reference summaries\n```\n\n**Key Concepts**\n```\nExtracts: Main ideas, concepts, terminology\nOutput: List of concepts with explanations\nBest for: Learning and terminology\n```\n\n**Methodology**\n```\nExtracts: Research approach, methods, data\nOutput: How the research was conducted\nBest for: Academic sources, methodology review\n```\n\n**Takeaways**\n```\nExtracts: Actionable insights, recommendations\nOutput: What you should do with this information\nBest for: Practical/business sources\n```\n\n### How to Apply Transformation\n\n```\n1. Go to \"Transformations\"\n2. Select a template\n3. Click \"Apply\"\n4. Select which sources (one or many)\n5. Wait for processing (usually 30 seconds - 2 minutes)\n6. New notes appear in your notebook\n7. Edit if needed\n```\n\n### Create Custom Transformation\n\n```\n1. Click \"Create Custom Transformation\"\n2. Write your extraction template:\n\n   Example:\n   \"For this academic paper, extract:\n    - Central research question\n    - Hypothesis tested\n    - Methodology used\n    - Key findings (numbered)\n    - Limitations acknowledged\n    - Recommendations for future work\"\n\n3. Click \"Save Template\"\n4. Apply to one or many sources\n5. System generates notes with consistent structure\n```\n\n---\n\n## Organizing Notes\n\n### Naming Conventions\n\n**Option 1: Date-based**\n```\n2026-01-03 - Key points from X source\n2026-01-04 - Comparison between A and B\nBenefit: Easy to see what you did when\n```\n\n**Option 2: Topic-based**\n```\nAI Safety - Alignment approaches\nAI Safety - Interpretability research\nBenefit: Groups by subject matter\n```\n\n**Option 3: Type-based**\n```\nSUMMARY: Paper on X\nQUESTION: What about Y?\nINSIGHT: Connection between Z and W\nBenefit: Easy to filter by type\n```\n\n**Option 4: Source-based**\n```\nFrom: Paper A - Main insights\nFrom: Video B - Interesting implications\nBenefit: Easy to trace back to sources\n```\n\n**Best practice:** Combine approaches\n```\n[Date] [Source] - [Topic] - [Type]\n2026-01-03 - Paper A - AI Safety - Takeaways\n```\n\n### Using Tags\n\nTags are labels for categorization. Add them when creating notes:\n\n```\nExample tags:\n  - \"primary-research\" (direct source analysis)\n  - \"background\" (supporting material)\n  - \"methodology\" (about research methods)\n  - \"insights\" (your original thinking)\n  - \"questions\" (open questions raised)\n  - \"follow-up\" (needs more work)\n  - \"published\" (ready to share/use)\n```\n\n**Benefits of tags:**\n- Filter notes by tag\n- Find all notes of a type\n- Organize workflow (e.g., find all \"follow-up\" notes)\n\n### Note Linking & References\n\nYou can reference sources within notes:\n\n```markdown\n# Analysis of Paper A\n\nAs shown in Paper A (see \"main argument\" section),\nthe authors argue that...\n\n## Related Sources\n- Paper B discusses similar approach\n- Video C shows practical application\n- My note on \"Comparative analysis\" has more\n```\n\n---\n\n## Editing and Refining Notes\n\n### Improving AI-Generated Notes\n\n```\nAI Note:\n  \"The paper discusses machine learning\"\n\nWhat you might change:\n  \"The paper proposes a supervised learning approach\n   to classification problems, using neural networks\n   with attention mechanisms (see pp. 15-18).\"\n\nHow to edit:\n  1. Click note\n  2. Click \"Edit\"\n  3. Refine the content\n  4. Click \"Save\"\n```\n\n### Adding Citations\n\n```\nWhen saving from Chat/Ask:\n  - Citations auto-added\n  - Shows which sources informed answer\n  - You can verify by clicking\n\nWhen manual notes:\n  - Add manually: \"From Paper A, page 15: ...\"\n  - Or reference: \"As discussed in [source]\"\n```\n\n---\n\n## Searching Your Notes\n\nNotes are fully searchable:\n\n### Text Search\n```\nFind exact phrase: \"attention mechanism\"\nResults: All notes containing that phrase\nUse when: Looking for specific terms or quotes\n```\n\n### Vector/Semantic Search\n```\nFind concept: \"How do models understand?\"\nResults: Notes about interpretability, mechanistic understanding, etc.\nUse when: Exploring conceptually (words not exact)\n```\n\n### Combined Search\n```\nText search notes → Find keyword matches\nVector search notes → Find conceptual matches\nBoth work across sources + notes together\n```\n\n---\n\n## Exporting and Sharing Notes\n\n### Options\n\n**Copy to clipboard**\n```\nClick \"Share\" → \"Copy\" → Paste anywhere\nGood for: Sharing one note via email/chat\n```\n\n**Export as Markdown**\n```\nClick \"Share\" → \"Export as MD\" → Saves as .md file\nGood for: Sharing with others, version control\n```\n\n**Create note collection**\n```\nSelect multiple notes → \"Export collection\"\n→ Creates organized markdown document\nGood for: Sharing a topic overview\n```\n\n**Publish to web**\n```\nClick \"Publish\" → Get shareable link\nGood for: Publishing publicly (if desired)\n```\n\n---\n\n## Organizing Your Notebook's Notes\n\n### By Research Phase\n\n**Phase 1: Discovery**\n- Initial summaries\n- Questions raised\n- Interesting findings\n\n**Phase 2: Deep Dive**\n- Detailed analysis\n- Comparative insights\n- Methodology reviews\n\n**Phase 3: Synthesis**\n- Connections across sources\n- Original thinking\n- Conclusions\n\n### By Content Type\n\n**Summaries**\n- High-level overviews\n- Generated by transformations\n- Quick reference\n\n**Questions**\n- Open questions\n- Things to research more\n- Gaps to fill\n\n**Insights**\n- Your original analysis\n- Connections made\n- Conclusions reached\n\n**Tasks**\n- Follow-up research\n- Sources to add\n- People to contact\n\n---\n\n## Using Notes in Other Features\n\n### In Chat\n\n```\nYou can reference notes:\n\"Based on my note 'Key findings from A',\nhow does this compare to B?\"\n\nNotes become part of context.\nTreated like sources but smaller/more focused.\n```\n\n### In Transformations\n\n```\nNotes can be transformed:\n1. Select notes as input\n2. Apply transformation\n3. Get new derived notes\n\nExample: Transform 5 analysis notes → Create synthesis\n```\n\n### In Podcasts\n\n```\nNotes are used to create podcast content:\n1. Generate podcast for notebook\n2. System includes notes in content selection\n3. Notes become part of episode outline\n```\n\n---\n\n## Best Practices\n\n### For Manual Notes\n1. **Write clearly** — Future you will appreciate it\n2. **Add context** — Why this matters, not just what it says\n3. **Link to sources** — You can verify later\n4. **Date them** — Track your thinking over time\n5. **Tag immediately** — Don't defer organization\n\n### For AI-Generated Notes\n1. **Review before saving** — Verify quality\n2. **Edit for clarity** — AI might miss nuance\n3. **Add your thoughts** — Make it your own\n4. **Include citations** — Understand sources\n5. **Organize right away** — While context is fresh\n\n### For Organization\n1. **Consistent naming** — Your future self will thank you\n2. **Tag everything** — Makes filtering later much easier\n3. **Link related notes** — Create knowledge network\n4. **Review periodically** — Refactor as understanding evolves\n5. **Archive old notes** — Keep working space clean\n\n---\n\n## Common Mistakes\n\n| Mistake | Problem | Solution |\n|---------|---------|----------|\n| Save every Chat response | Notebook becomes cluttered with low-quality notes | Only save good responses that answer your questions |\n| Don't add tags | Can't find notes later | Tag immediately when creating |\n| Poor note titles | Can't remember what's in them | Use descriptive titles, include key concept |\n| Never link notes together | Miss connections between ideas | Add references to related notes |\n| Forget the source | Can't verify claims later | Always link back to source |\n| Never edit AI notes | Keep generic AI responses | Refine for clarity and context |\n| Create one giant note | Too long to be useful | Split into focused notes by subtopic |\n\n---\n\n## Summary: Note Lifecycle\n\n```\n1. CREATE\n   ├─ Manual: Write from scratch\n   ├─ From Chat: Save good response\n   ├─ From Ask: Save synthesis\n   └─ From Transform: Batch process\n\n2. EDIT & REFINE\n   ├─ Improve clarity\n   ├─ Add context\n   ├─ Fix AI mistakes\n   └─ Add citations\n\n3. ORGANIZE\n   ├─ Name clearly\n   ├─ Add tags\n   ├─ Link related\n   └─ Categorize\n\n4. USE\n   ├─ Reference in Chat\n   ├─ Transform for synthesis\n   ├─ Export for sharing\n   └─ Build on with new questions\n\n5. MAINTAIN\n   ├─ Periodically review\n   ├─ Update as understanding grows\n   ├─ Archive when done\n   └─ Learn from organized knowledge\n```\n\nYour notes become your actual knowledge base. The more you invest in organizing them, the more valuable they become.\n"
  },
  {
    "path": "docs/4-AI-PROVIDERS/index.md",
    "content": "# AI Providers - Comparison & Selection Guide\n\nOpen Notebook supports 15+ AI providers. This guide helps you **choose the right provider** for your needs.\n\n> 💡 **Just want to set up a provider?** Skip to the [Configuration Guide](../5-CONFIGURATION/ai-providers.md) for detailed setup instructions.\n\n---\n\n## Quick Decision: Which Provider?\n\n### Cloud Providers (Easiest)\n\n**OpenAI (Recommended)**\n- Cost: ~$0.03-0.15 per 1K tokens\n- Speed: Very fast\n- Quality: Excellent\n- Best for: Most users (best quality/price balance)\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#openai)\n\n**Anthropic (Claude)**\n- Cost: ~$0.80-3.00 per 1M tokens\n- Speed: Fast\n- Quality: Excellent\n- Best for: Long context (200K tokens), reasoning, latest AI\n- Advantage: Superior long-context handling\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#anthropic-claude)\n\n**Google Gemini**\n- Cost: ~$0.075-0.30 per 1K tokens\n- Speed: Very fast\n- Quality: Good to excellent\n- Best for: Multimodal (images, audio, video)\n- Advantage: Longest context (up to 2M tokens)\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#google-gemini)\n\n**Groq (Ultra-Fast)**\n- Cost: ~$0.05 per 1M tokens (cheapest)\n- Speed: Ultra-fast (fastest available)\n- Quality: Good\n- Best for: Budget-conscious, transformations, speed-critical tasks\n- Disadvantage: Limited model selection\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#groq)\n\n**OpenRouter (100+ Models)**\n- Cost: Pay-per-model (varies widely)\n- Speed: Varies by model\n- Quality: Varies by model\n- Best for: Model comparison, testing, unified billing\n- Advantage: One API key for 100+ models from different providers\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#openrouter)\n\n### Local / Self-Hosted (Free)\n\n**Ollama (Recommended for Local)**\n- Cost: Free (electricity only)\n- Speed: Depends on hardware (slow on CPU, fast on GPU)\n- Quality: Good (open-source models)\n- Setup: 10 minutes\n- Best for: Privacy-first, offline use\n- Privacy: 100% local, nothing leaves your machine\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#ollama-recommended-for-local)\n\n**LM Studio (Alternative)**\n- Cost: Free (electricity only)\n- Speed: Depends on hardware\n- Quality: Good (same models as Ollama)\n- Setup: 15 minutes (GUI interface)\n- Best for: Non-technical users who prefer GUI over CLI\n- Privacy: 100% local\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#lm-studio-local-alternative)\n\n### Enterprise\n\n**Azure OpenAI**\n- Cost: Same as OpenAI (usage-based)\n- Speed: Very fast\n- Quality: Excellent (same models as OpenAI)\n- Setup: 10 minutes (more complex)\n- Best for: Enterprise, compliance (HIPAA, SOC2), VPC integration\n\n→ [Setup Guide](../5-CONFIGURATION/ai-providers.md#azure-openai)\n\n---\n\n## Comparison Table\n\n| Provider | Speed | Cost | Quality | Privacy | Setup | Context |\n|----------|-------|------|---------|---------|-------|---------|\n| **OpenAI** | Very Fast | $$ | Excellent | Low | 5 min | 128K |\n| **Anthropic** | Fast | $$ | Excellent | Low | 5 min | 200K |\n| **Google** | Very Fast | $$ | Good-Excellent | Low | 5 min | 2M |\n| **Groq** | Ultra Fast | $ | Good | Low | 5 min | 32K |\n| **OpenRouter** | Varies | Varies | Varies | Low | 5 min | Varies |\n| **Ollama** | Slow-Medium | Free | Good | Max | 10 min | Varies |\n| **LM Studio** | Slow-Medium | Free | Good | Max | 15 min | Varies |\n| **Azure** | Very Fast | $$ | Excellent | High | 10 min | 128K |\n\n---\n\n## Choosing Your Provider\n\n### I want the easiest setup\n→ **OpenAI** — Most popular, best community support\n\n### I have unlimited budget\n→ **OpenAI** — Best quality\n\n### I want to save money\n→ **Groq** — Cheapest cloud ($0.05 per 1M tokens)\n\n### I want privacy/offline\n→ **Ollama** — Free, local, private\n\n### I want a GUI (not CLI)\n→ **LM Studio** — Desktop app\n\n### I'm in an enterprise\n→ **Azure OpenAI** — Compliance, support\n\n### I need long context (200K+ tokens)\n→ **Anthropic** — Best long-context model\n\n### I need multimodal (images, audio, video)\n→ **Google Gemini** — Best multimodal support\n\n### I want access to many models with one API key\n→ **OpenRouter** — 100+ models, unified billing\n\n---\n\n## Ready to Set Up Your Provider?\n\nNow that you've chosen a provider, follow the detailed setup instructions:\n\n→ **[AI Providers Configuration Guide](../5-CONFIGURATION/ai-providers.md)**\n\nThis guide includes:\n- Step-by-step setup instructions for each provider via the Settings UI\n- How to add credentials, test connections, and discover models\n- Model selection and recommendations\n- Provider-specific troubleshooting\n- Hardware requirements (for local providers)\n- Cost optimization tips\n\n---\n\n## Cost Estimator\n\n### OpenAI\n```\nLight use (10 chats/day): $1-5/month\nMedium use (50 chats/day): $10-30/month\nHeavy use (all-day use): $50-100+/month\n```\n\n### Anthropic\n```\nLight use: $1-3/month\nMedium use: $5-20/month\nHeavy use: $20-50+/month\n```\n\n### Groq\n```\nLight use: $0-1/month\nMedium use: $2-5/month\nHeavy use: $5-20/month\n```\n\n### Ollama\n```\nAny use: Free (electricity only)\n8GB GPU running 24/7: ~$10/month electricity\n```\n\n---\n\n## Next Steps\n\n1. **You've chosen a provider** (from this comparison guide)\n2. **Follow the setup guide**: [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md)\n3. **Add your credential** in Settings → API Keys\n4. **Test your connection** and discover models\n5. **Start using Open Notebook!**\n\n---\n\n## Need Help?\n\n- **Setup issues?** See [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md) for detailed troubleshooting per provider\n- **General problems?** Check [Troubleshooting Guide](../6-TROUBLESHOOTING/index.md)\n- **Questions?** Join [Discord community](https://discord.gg/37XJPXfz2w)\n"
  },
  {
    "path": "docs/5-CONFIGURATION/advanced.md",
    "content": "# Advanced Configuration\n\nPerformance tuning, debugging, and advanced features.\n\n---\n\n## Performance Tuning\n\n### Concurrency Control\n\n```env\n# Max concurrent database operations (default: 5)\n# Increase: Faster processing, more conflicts\n# Decrease: Slower, fewer conflicts\nSURREAL_COMMANDS_MAX_TASKS=5\n```\n\n**Guidelines:**\n- CPU: 2 cores → 2-3 tasks\n- CPU: 4 cores → 5 tasks (default)\n- CPU: 8+ cores → 10-20 tasks\n\nHigher concurrency = more throughput but more database conflicts (retries handle this).\n\n### Retry Strategy\n\n```env\n# How to wait between retries\nSURREAL_COMMANDS_RETRY_WAIT_STRATEGY=exponential_jitter\n\n# Options:\n# - exponential_jitter (recommended)\n# - exponential\n# - fixed\n# - random\n```\n\nFor high-concurrency deployments, use `exponential_jitter` to prevent thundering herd.\n\n### Timeout Tuning\n\n```env\n# Client timeout (default: 300 seconds)\nAPI_CLIENT_TIMEOUT=300\n\n# LLM timeout (default: 60 seconds)\nESPERANTO_LLM_TIMEOUT=60\n```\n\n**Guideline:** Set `API_CLIENT_TIMEOUT` > `ESPERANTO_LLM_TIMEOUT` + buffer\n\n```\nExample:\n  ESPERANTO_LLM_TIMEOUT=120\n  API_CLIENT_TIMEOUT=180  # 120 + 60 second buffer\n```\n\n---\n\n## Batching\n\n### TTS Batch Size\n\nFor podcast generation, control concurrent TTS requests:\n\n```env\n# Default: 5\nTTS_BATCH_SIZE=2\n```\n\n**Providers and recommendations:**\n- OpenAI: 5 (can handle many concurrent)\n- Google: 4 (good concurrency)\n- ElevenLabs: 2 (limited concurrent requests)\n- Local TTS: 1 (single-threaded)\n\nLower = slower but more stable. Higher = faster but more load on provider.\n\n---\n\n## Logging & Debugging\n\n### Enable Detailed Logging\n\n```bash\n# Start with debug logging\nRUST_LOG=debug  # For Rust components\nLOGLEVEL=DEBUG  # For Python components\n```\n\n### Debug Specific Components\n\n```bash\n# Only surreal operations\nRUST_LOG=surrealdb=debug\n\n# Only langchain\nLOGLEVEL=langchain:debug\n\n# Only specific module\nRUST_LOG=open_notebook::database=debug\n```\n\n### LangSmith Tracing\n\nFor debugging LLM workflows:\n\n```env\nLANGCHAIN_TRACING_V2=true\nLANGCHAIN_ENDPOINT=\"https://api.smith.langchain.com\"\nLANGCHAIN_API_KEY=your-key\nLANGCHAIN_PROJECT=\"Open Notebook\"\n```\n\nThen visit https://smith.langchain.com to see traces.\n\n---\n\n## Port Configuration\n\n### Default Ports\n\n```\nFrontend: 8502 (Docker deployment)\nFrontend: 3000 (Development from source)\nAPI: 5055\nSurrealDB: 8000\n```\n\n### Changing Frontend Port\n\nEdit `docker-compose.yml`:\n\n```yaml\nservices:\n  open-notebook:\n    ports:\n      - \"8001:8502\"  # Change from 8502 to 8001\n```\n\nAccess at: `http://localhost:8001`\n\nAPI auto-detects to: `http://localhost:5055` ✓\n\n### Changing API Port\n\n```yaml\nservices:\n  open-notebook:\n    ports:\n      - \"127.0.0.1:8502:8502\"  # Frontend\n      - \"5056:5055\"            # Change API from 5055 to 5056\n    environment:\n      - API_URL=http://localhost:5056  # Update API_URL\n```\n\nAccess API directly: `http://localhost:5056/docs`\n\n**Note:** When changing API port, you must set `API_URL` explicitly since auto-detection assumes port 5055.\n\n### Changing SurrealDB Port\n\n```yaml\nservices:\n  surrealdb:\n    ports:\n      - \"8001:8000\"  # Change from 8000 to 8001\n    environment:\n      - SURREAL_URL=ws://surrealdb:8001/rpc  # Update connection URL\n```\n\n**Important:** Internal Docker network uses container name (`surrealdb`), not `localhost`.\n\n---\n\n## SSL/TLS Configuration\n\n### Custom CA Certificate\n\nFor self-signed certs on local providers:\n\n```env\nESPERANTO_SSL_CA_BUNDLE=/path/to/ca-bundle.pem\n```\n\n### Disable Verification (Development Only)\n\n```env\n# WARNING: Only for testing/development\n# Vulnerable to MITM attacks\nESPERANTO_SSL_VERIFY=false\n```\n\n---\n\n## Multi-Provider Setup\n\n### Use Different Providers for Different Tasks\n\nConfigure multiple AI providers via **Settings → API Keys**. Each provider gets its own credential:\n\n1. Add a credential for your main language model provider (e.g., OpenAI, Anthropic)\n2. Add a credential for embeddings (e.g., Voyage AI, or use the same provider)\n3. Add a credential for TTS (e.g., ElevenLabs, or OpenAI-Compatible for local Speaches)\n4. Each credential's models are registered and available independently\n\n### Multiple Endpoints for OpenAI-Compatible\n\nWhen using OpenAI-Compatible providers, you can configure per-service URLs in a single credential:\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **OpenAI-Compatible**\n3. Configure separate URLs for LLM, Embedding, TTS, and STT\n4. Click **Save**, then **Test Connection**\n\n---\n\n## Security Hardening\n\n### Change Default Credentials\n\n```env\n# Don't use defaults in production\nSURREAL_USER=your_secure_username\nSURREAL_PASSWORD=$(openssl rand -base64 32)  # Generate secure password\n```\n\n### Add Password Protection\n\n```env\n# Protect your Open Notebook instance\nOPEN_NOTEBOOK_PASSWORD=your_secure_password\n```\n\n### Use HTTPS\n\n```env\n# Always use HTTPS in production\nAPI_URL=https://mynotebook.example.com\n```\n\n### Firewall Rules\n\nRestrict access to your Open Notebook:\n- Port 8502 (frontend): Only from your IP\n- Port 5055 (API): Only from frontend\n- Port 8000 (SurrealDB): Never expose to internet\n\n---\n\n## Web Scraping & Content Extraction\n\nOpen Notebook uses multiple services for content extraction:\n\n### Firecrawl\n\nFor advanced web scraping:\n\n```env\nFIRECRAWL_API_KEY=your-key\n```\n\nGet key from: https://firecrawl.dev/\n\n### Jina AI\n\nAlternative web extraction:\n\n```env\nJINA_API_KEY=your-key\n```\n\nGet key from: https://jina.ai/\n\n---\n\n## Environment Variable Groups\n\n### Credential Storage (Required)\n```env\nOPEN_NOTEBOOK_ENCRYPTION_KEY    # Required for storing credentials\n```\n\nAI provider API keys are configured via **Settings → API Keys** (not environment variables).\n\n### Database\n```env\nSURREAL_URL\nSURREAL_USER\nSURREAL_PASSWORD\nSURREAL_NAMESPACE\nSURREAL_DATABASE\n```\n\n### Performance\n```env\nSURREAL_COMMANDS_MAX_TASKS\nSURREAL_COMMANDS_RETRY_ENABLED\nSURREAL_COMMANDS_RETRY_MAX_ATTEMPTS\nSURREAL_COMMANDS_RETRY_WAIT_STRATEGY\nSURREAL_COMMANDS_RETRY_WAIT_MIN\nSURREAL_COMMANDS_RETRY_WAIT_MAX\n```\n\n### API Settings\n```env\nAPI_URL\nINTERNAL_API_URL\nAPI_CLIENT_TIMEOUT\nESPERANTO_LLM_TIMEOUT\n```\n\n### Audio/TTS\n```env\nTTS_BATCH_SIZE\n```\n\n> **Note:** `ELEVENLABS_API_KEY` is deprecated. Configure ElevenLabs via **Settings → API Keys**.\n\n### Debugging\n```env\nLANGCHAIN_TRACING_V2\nLANGCHAIN_ENDPOINT\nLANGCHAIN_API_KEY\nLANGCHAIN_PROJECT\n```\n\n---\n\n## Testing Configuration\n\n### Quick Test\n\n```bash\n# Test API health\ncurl http://localhost:5055/health\n\n# Test with sample (requires configured credential and registered models)\ncurl -X POST http://localhost:5055/api/chat \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"message\":\"Hello\"}'\n```\n\n### Validate Config\n\n```bash\n# Check environment variables are set\nenv | grep OPEN_NOTEBOOK_ENCRYPTION_KEY\n\n# Verify database connection\npython -c \"import os; print(os.getenv('SURREAL_URL'))\"\n```\n\n---\n\n## Troubleshooting Performance\n\n### High Memory Usage\n\n```env\n# Reduce concurrency\nSURREAL_COMMANDS_MAX_TASKS=2\n\n# Reduce TTS batch size\nTTS_BATCH_SIZE=1\n```\n\n### High CPU Usage\n\n```env\n# Check worker count\nSURREAL_COMMANDS_MAX_TASKS\n\n# Reduce if maxed out:\nSURREAL_COMMANDS_MAX_TASKS=5\n```\n\n### Slow Responses\n\n```env\n# Check timeout settings\nAPI_CLIENT_TIMEOUT=300\n\n# Check retry config\nSURREAL_COMMANDS_RETRY_MAX_ATTEMPTS=3\n```\n\n### Database Conflicts\n\n```env\n# Reduce concurrency\nSURREAL_COMMANDS_MAX_TASKS=3\n\n# Use jitter strategy\nSURREAL_COMMANDS_RETRY_WAIT_STRATEGY=exponential_jitter\n```\n\n---\n\n## Backup & Restore\n\n### Data Locations\n\n| Path | Contents |\n|------|----------|\n| `./data` or `/app/data` | Uploads, podcasts, checkpoints |\n| `./surreal_data` or `/mydata` | SurrealDB database files |\n\n### Quick Backup\n\n```bash\n# Stop services (recommended for consistency)\ndocker compose down\n\n# Create timestamped backup\ntar -czf backup-$(date +%Y%m%d-%H%M%S).tar.gz \\\n  notebook_data/ surreal_data/\n\n# Restart services\ndocker compose up -d\n```\n\n### Automated Backup Script\n\n```bash\n#!/bin/bash\n# backup.sh - Run daily via cron\n\nBACKUP_DIR=\"/path/to/backups\"\nDATE=$(date +%Y%m%d-%H%M%S)\n\n# Create backup\ntar -czf \"$BACKUP_DIR/open-notebook-$DATE.tar.gz\" \\\n  /path/to/notebook_data \\\n  /path/to/surreal_data\n\n# Keep only last 7 days\nfind \"$BACKUP_DIR\" -name \"open-notebook-*.tar.gz\" -mtime +7 -delete\n\necho \"Backup complete: open-notebook-$DATE.tar.gz\"\n```\n\nAdd to cron:\n```bash\n# Daily backup at 2 AM\n0 2 * * * /path/to/backup.sh >> /var/log/open-notebook-backup.log 2>&1\n```\n\n### Restore\n\n```bash\n# Stop services\ndocker compose down\n\n# Remove old data (careful!)\nrm -rf notebook_data/ surreal_data/\n\n# Extract backup\ntar -xzf backup-20240115-120000.tar.gz\n\n# Restart services\ndocker compose up -d\n```\n\n### Migration Between Servers\n\n```bash\n# On source server\ndocker compose down\ntar -czf open-notebook-migration.tar.gz notebook_data/ surreal_data/\n\n# Transfer to new server\nscp open-notebook-migration.tar.gz user@newserver:/path/\n\n# On new server\ntar -xzf open-notebook-migration.tar.gz\ndocker compose up -d\n```\n\n---\n\n## Container Management\n\n### Common Commands\n\n```bash\n# Start services\ndocker compose up -d\n\n# Stop services\ndocker compose down\n\n# View logs (all services)\ndocker compose logs -f\n\n# View logs (specific service)\ndocker compose logs -f api\n\n# Restart specific service\ndocker compose restart api\n\n# Update to latest version\ndocker compose down\ndocker compose pull\ndocker compose up -d\n\n# Check resource usage\ndocker stats\n\n# Check service health\ndocker compose ps\n```\n\n### Clean Up\n\n```bash\n# Remove stopped containers\ndocker compose rm\n\n# Remove unused images\ndocker image prune\n\n# Full cleanup (careful!)\ndocker system prune -a\n```\n\n---\n\n## Summary\n\n**Most deployments need:**\n- One AI provider API key\n- Default database settings\n- Default timeouts\n\n**Tune performance only if:**\n- You have specific bottlenecks\n- High-concurrency workload\n- Custom hardware (very fast or very slow)\n\n**Advanced features:**\n- Firecrawl for better web scraping\n- LangSmith for debugging workflows\n- Custom CA bundles for self-signed certs\n"
  },
  {
    "path": "docs/5-CONFIGURATION/ai-providers.md",
    "content": "# AI Providers - Configuration Guide\n\nComplete setup instructions for each AI provider via the **Settings UI**.\n\n> **New in v1.2**: All AI provider credentials are now managed through the Settings UI. Environment variables for API keys are deprecated.\n\n---\n\n## How Provider Setup Works\n\nOpen Notebook uses a **credential-based system** for managing AI providers:\n\n1. **Get your API key** from the provider's website\n2. **Open Settings** → **API Keys** → **Add Credential**\n3. **Test the connection** to verify it works\n4. **Discover & Register Models** to make them available\n5. **Start using** the provider in your notebooks\n\n> **Prerequisite**: You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your docker-compose.yml before storing credentials. See [API Configuration](../3-USER-GUIDE/api-configuration.md#encryption-setup) for details.\n\n---\n\n## Cloud Providers (Recommended for Most)\n\n### OpenAI\n\n**Cost:** ~$0.03-0.15 per 1K tokens (varies by model)\n\n**Get Your API Key:**\n1. Go to https://platform.openai.com/api-keys\n2. Create account (if needed)\n3. Create new API key (starts with \"sk-proj-\")\n4. Add $5+ credits to account\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **OpenAI**\n4. Give it a name (e.g., \"My OpenAI Key\")\n5. Paste your API key\n6. Click **Save**, then **Test Connection**\n7. Click **Discover Models** to find available models\n8. Click **Register Models** to make them available\n\n**Available Models (in Open Notebook):**\n- `gpt-4o` — Best quality, fast (latest version)\n- `gpt-4o-mini` — Fast, cheap, good for testing\n- `o1` — Advanced reasoning model (slower, more expensive)\n- `o1-mini` — Faster reasoning model\n\n**Recommended:**\n- For general use: `gpt-4o` (best balance)\n- For testing/cheap: `gpt-4o-mini` (90% cheaper)\n- For complex reasoning: `o1` (best for hard problems)\n\n**Cost Estimate:**\n```\nLight use: $1-5/month\nMedium use: $10-30/month\nHeavy use: $50-100+/month\n```\n\n**Troubleshooting:**\n- \"Invalid API key\" → Check key starts with \"sk-proj-\" and test the connection in Settings\n- \"Rate limit exceeded\" → Wait or upgrade account\n- \"Model not available\" → Try gpt-4o-mini instead, or re-discover models\n\n---\n\n### Anthropic (Claude)\n\n**Cost:** ~$0.80-3.00 per 1M tokens (cheaper than OpenAI for long context)\n\n**Get Your API Key:**\n1. Go to https://console.anthropic.com/\n2. Create account or login\n3. Go to API keys section\n4. Create new API key (starts with \"sk-ant-\")\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **Anthropic**\n4. Give it a name, paste your API key\n5. Click **Save**, then **Test Connection**\n6. Click **Discover Models** → **Register Models**\n\n**Available Models:**\n- `claude-sonnet-4-5-20250929` — Latest, best quality (recommended)\n- `claude-3-5-sonnet-20241022` — Previous generation, still excellent\n- `claude-3-5-haiku-20241022` — Fast, cheap\n- `claude-opus-4-5-20251101` — Most powerful, expensive\n\n**Recommended:**\n- For general use: `claude-sonnet-4-5` (best overall, latest)\n- For cheap: `claude-3-5-haiku` (80% cheaper)\n- For complex: `claude-opus-4-5` (most capable)\n\n**Cost Estimate:**\n```\nSonnet: $3-20/month (typical use)\nHaiku: $0.50-3/month\nOpus: $10-50+/month\n```\n\n**Advantages:**\n- Great long-context support (200K tokens)\n- Excellent reasoning\n- Fast processing\n\n**Troubleshooting:**\n- \"Invalid API key\" → Check it starts with \"sk-ant-\" and test in Settings\n- \"Overloaded\" → Anthropic is busy, retry later\n- \"Model unavailable\" → Re-discover models from the credential\n\n---\n\n### Google Gemini\n\n**Cost:** ~$0.075-0.30 per 1K tokens (competitive with OpenAI)\n\n**Get Your API Key:**\n1. Go to https://aistudio.google.com/app/apikey\n2. Create account or login\n3. Create new API key\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **Google Gemini**\n4. Give it a name, paste your API key\n5. Click **Save**, then **Test Connection**\n6. Click **Discover Models** → **Register Models**\n\n**Available Models:**\n- `gemini-2.0-flash-exp` — Latest experimental, fastest (recommended)\n- `gemini-2.0-flash` — Stable version, fast, cheap\n\n**Recommended:**\n- For general use: `gemini-2.0-flash-exp` (best value, latest)\n- For cheap: `gemini-1.5-flash` (very cheap)\n- For complex/long context: `gemini-1.5-pro-latest` (2M token context)\n\n**Advantages:**\n- Very long context (1M tokens)\n- Multimodal (images, audio, video)\n- Good for podcasts\n\n**Troubleshooting:**\n- \"API key invalid\" → Get fresh key from aistudio.google.com\n- \"Quota exceeded\" → Free tier limited, upgrade account\n- \"Model not found\" → Re-discover models from the credential\n\n---\n\n### Groq\n\n**Cost:** ~$0.05 per 1M tokens (cheapest, but limited models)\n\n**Get Your API Key:**\n1. Go to https://console.groq.com/keys\n2. Create account or login\n3. Create new API key\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **Groq**\n4. Give it a name, paste your API key\n5. Click **Save**, then **Test Connection**\n6. Click **Discover Models** → **Register Models**\n\n**Available Models:**\n- `llama-3.3-70b-versatile` — Best on Groq (recommended)\n- `llama-3.1-70b-versatile` — Fast, capable\n- `mixtral-8x7b-32768` — Good alternative\n- `gemma2-9b-it` — Small, very fast\n\n**Recommended:**\n- For quality: `llama-3.3-70b-versatile` (best overall)\n- For speed: `gemma2-9b-it` (ultra-fast)\n- For balance: `llama-3.1-70b-versatile`\n\n**Advantages:**\n- Ultra-fast inference\n- Very cheap\n- Great for transformations/batch work\n\n**Disadvantages:**\n- Limited model selection\n- Smaller models than OpenAI/Anthropic\n\n**Troubleshooting:**\n- \"Rate limited\" → Free tier has limits, upgrade\n- \"Model not available\" → Re-discover models from the credential\n\n---\n\n### OpenRouter\n\n**Cost:** Varies by model ($0.05-15 per 1M tokens)\n\n**Get Your API Key:**\n1. Go to https://openrouter.ai/keys\n2. Create account or login\n3. Add credits to your account\n4. Create new API key\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **OpenRouter**\n4. Give it a name, paste your API key\n5. Click **Save**, then **Test Connection**\n6. Click **Discover Models** → **Register Models**\n\n**Available Models (100+ options):**\n- OpenAI: `openai/gpt-4o`, `openai/o1`\n- Anthropic: `anthropic/claude-sonnet-4.5`, `anthropic/claude-3.5-haiku`\n- Google: `google/gemini-2.0-flash-exp`, `google/gemini-1.5-pro`\n- Meta: `meta-llama/llama-3.3-70b-instruct`, `meta-llama/llama-3.1-405b-instruct`\n- Mistral: `mistralai/mistral-large-2411`\n- DeepSeek: `deepseek/deepseek-chat`\n- And many more...\n\n**Recommended:**\n- For quality: `anthropic/claude-sonnet-4.5` (best overall)\n- For speed/cost: `google/gemini-2.0-flash-exp` (very fast, cheap)\n- For open-source: `meta-llama/llama-3.3-70b-instruct`\n- For reasoning: `openai/o1`\n\n**Advantages:**\n- One API key for 100+ models\n- Unified billing\n- Easy model comparison\n- Access to models that may have waitlists elsewhere\n\n**Cost Estimate:**\n```\nLight use: $1-5/month\nMedium use: $10-30/month\nHeavy use: Depends on models chosen\n```\n\n**Troubleshooting:**\n- \"Invalid API key\" → Check it starts with \"sk-or-\"\n- \"Insufficient credits\" → Add credits at openrouter.ai\n- \"Model not available\" → Check model ID spelling (use full path)\n\n---\n\n## Self-Hosted / Local\n\n### Ollama (Recommended for Local)\n\n**Cost:** Free (electricity only)\n\n**Setup Ollama:**\n1. Install Ollama: https://ollama.ai\n2. Run Ollama in background: `ollama serve`\n3. Download a model: `ollama pull mistral`\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **Ollama**\n4. Give it a name (e.g., \"Local Ollama\")\n5. Enter the base URL:\n   - Same machine (non-Docker): `http://localhost:11434`\n   - Docker with Ollama on host: `http://host.docker.internal:11434`\n   - Docker with Ollama container: `http://ollama:11434`\n6. Click **Save**, then **Test Connection**\n7. Click **Discover Models** → **Register Models**\n\nSee [Ollama Setup Guide](ollama.md) for detailed network configuration.\n\n**Available Models:**\n- `llama3.3:70b` — Best quality (requires 40GB+ RAM)\n- `llama3.1:8b` — Recommended, balanced (8GB RAM)\n- `qwen2.5:7b` — Excellent for code and reasoning\n- `mistral:7b` — Good general purpose\n- `phi3:3.8b` — Small, fast (4GB RAM)\n- `gemma2:9b` — Google's model, balanced\n- Many more: `ollama list` to see available\n\n**Recommended:**\n- For quality (with GPU): `llama3.3:70b` (best)\n- For general use: `llama3.1:8b` (best balance)\n- For speed/low memory: `phi3:3.8b` (very fast)\n- For coding: `qwen2.5:7b` (excellent at code)\n\n**Hardware Requirements:**\n```\nGPU (NVIDIA/AMD):\n  8GB VRAM: Runs most models fine\n  6GB VRAM: Works, slower\n  4GB VRAM: Small models only\n\nCPU-only:\n  16GB+ RAM: Slow but works\n  8GB RAM: Very slow\n  4GB RAM: Not recommended\n```\n\n**Advantages:**\n- Completely private (runs locally)\n- Free (electricity only)\n- No API key needed\n- Works offline\n\n**Disadvantages:**\n- Slower than cloud (unless on GPU)\n- Smaller models than cloud\n- Requires local hardware\n\n**Troubleshooting:**\n- \"Connection refused\" → Ollama not running or wrong URL in credential\n- \"Model not found\" → Download it: `ollama pull modelname`\n- \"Out of memory\" → Use smaller model or add more RAM\n\n---\n\n### LM Studio (Local Alternative)\n\n**Cost:** Free\n\n**Setup LM Studio:**\n1. Download LM Studio: https://lmstudio.ai\n2. Open app\n3. Download a model from library\n4. Go to \"Local Server\" tab\n5. Start server (default port: 1234)\n\n**Configure in Open Notebook:**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **OpenAI-Compatible**\n4. Give it a name (e.g., \"LM Studio\")\n5. Enter the base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)\n6. API key: `lm-studio` (placeholder, LM Studio doesn't require one)\n7. Click **Save**, then **Test Connection**\n\n**Advantages:**\n- GUI interface (easier than Ollama CLI)\n- Good model selection\n- Privacy-focused\n- Works offline\n\n**Disadvantages:**\n- Desktop only (Mac/Windows/Linux)\n- Slower than cloud\n- Requires local GPU\n\n---\n\n### Custom OpenAI-Compatible\n\nFor Text Generation UI, vLLM, or other OpenAI-compatible endpoints:\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential**\n3. Select provider: **OpenAI-Compatible**\n4. Enter the base URL for your endpoint (e.g., `http://localhost:8000/v1`)\n5. Enter API key if required\n6. Optionally configure per-service URLs (LLM, Embedding, TTS, STT)\n7. Click **Save**, then **Test Connection**\n\nSee [OpenAI-Compatible Setup](openai-compatible.md) for detailed instructions.\n\n---\n\n## Enterprise\n\n### Azure OpenAI\n\n**Cost:** Same as OpenAI (usage-based)\n\n**Configure in Open Notebook:**\n1. Create Azure OpenAI service in Azure portal\n2. Deploy GPT-4/3.5-turbo model\n3. Get your endpoint and key\n4. Go to **Settings** → **API Keys**\n5. Click **Add Credential**\n6. Select provider: **Azure OpenAI**\n7. Fill in: API Key, Endpoint, API Version\n8. Optionally configure service-specific endpoints (LLM, Embedding)\n9. Click **Save**, then **Test Connection**\n\n**Advantages:**\n- Enterprise support\n- VPC integration\n- Compliance (HIPAA, SOC2, etc.)\n\n**Disadvantages:**\n- More complex setup\n- Higher overhead\n- Requires Azure account\n\n---\n\n## Embeddings (For Search/Semantic Features)\n\nBy default, Open Notebook uses the LLM provider's embeddings. Embedding models are discovered and registered through the same credential system — when you discover models from a credential, embedding models are included alongside language models.\n\n---\n\n## Choosing Your Provider\n\n**1. Don't want to run locally and don't want to mess around with different providers:**\n\nUse OpenAI\n- Cloud-based\n- Good quality\n- Reasonable cost\n- Simplest setup, supports all modes (text, embedding, tts, stt, etc)\n\n**For budget-conscious:** Groq, OpenRouter or Ollama\n- Groq: Super cheap cloud\n- Ollama: Free, but local\n- OpenRouter: many open source models very accessible\n\n**For privacy-first:** Ollama or LM Studio and Speaches ([TTS](local-tts.md), [STT](local-stt.md))\n- Everything stays local\n- Works offline\n- No API keys sent anywhere\n\n**For enterprise:** Azure OpenAI\n- Compliance\n- VPC integration\n- Support\n\n---\n\n## Next Steps\n\n1. **Choose your provider** from above\n2. **Get API key** (if cloud) or install locally (if Ollama)\n3. **Set `OPEN_NOTEBOOK_ENCRYPTION_KEY`** in your docker-compose.yml (required for storing credentials)\n4. **Open Settings** → **API Keys** → **Add Credential**\n5. **Test Connection** to verify it works\n6. **Discover & Register Models** to make them available\n7. **Verify it works** with a test chat\n\n> **Multiple providers**: You can add credentials for as many providers as you want. Create separate credentials for different projects or team members.\n\nDone!\n\n---\n\n## Legacy: Environment Variables (Deprecated)\n\n> **Deprecated**: Configuring AI provider API keys via environment variables is deprecated. Use the Settings UI instead. Environment variables may still work as a fallback but are no longer the recommended approach.\n\nIf you are migrating from an older version that used environment variables, go to **Settings** → **API Keys** and click the **Migrate to Database** button to import your existing keys into the credential system.\n\n---\n\n## Related\n\n- **[API Configuration](../3-USER-GUIDE/api-configuration.md)** — Detailed credential management guide\n- **[Environment Reference](environment-reference.md)** - Complete list of all environment variables\n- **[Advanced Configuration](advanced.md)** - Timeouts, SSL, performance tuning\n- **[Ollama Setup](ollama.md)** - Detailed Ollama configuration guide\n- **[OpenAI-Compatible](openai-compatible.md)** - LM Studio and other compatible providers\n- **[Local TTS Setup](local-tts.md)** - Text-to-speech with Speaches\n- **[Local STT Setup](local-stt.md)** - Speech-to-text with Speaches\n- **[Troubleshooting](../6-TROUBLESHOOTING/quick-fixes.md)** - Common issues and fixes\n"
  },
  {
    "path": "docs/5-CONFIGURATION/database.md",
    "content": "# Database - SurrealDB Configuration\n\nOpen Notebook uses SurrealDB for its database needs. \n\n---\n\n## Default Configuration\n\nOpen Notebook should work out of the box with SurrealDB as long as the environment variables are correctly setup. \n\n\n### DB running in the same docker compose as Open Notebook (recommended)\n\nThe example above is for when you are running SurrealDB as a separate docker container, which is the method described [here](../1-INSTALLATION/docker-compose.md) (and our recommended method). \n\n```env\nSURREAL_URL=\"ws://surrealdb:8000/rpc\"\nSURREAL_USER=\"root\"\nSURREAL_PASSWORD=\"root\"\nSURREAL_NAMESPACE=\"open_notebook\"\nSURREAL_DATABASE=\"open_notebook\"\n```\n\n### DB running in the host machine and Open Notebook running in Docker\n\nIf ON is running in docker and SurrealDB is on your host machine, you need to point to it. \n\n```env\nSURREAL_URL=\"ws://your-machine-ip:8000/rpc\" #or host.docker.internal\nSURREAL_USER=\"root\"\nSURREAL_PASSWORD=\"root\"\nSURREAL_NAMESPACE=\"open_notebook\"\nSURREAL_DATABASE=\"open_notebook\"\n```\n\n### Open Notebook and Surreal are running on the same machine\n\nIf you are running both services locally or if you are using the deprecated [single container setup](../1-INSTALLATION/single-container.md)\n\n```env\nSURREAL_URL=\"ws://localhost:8000/rpc\"\nSURREAL_USER=\"root\"\nSURREAL_PASSWORD=\"root\"\nSURREAL_NAMESPACE=\"open_notebook\"\nSURREAL_DATABASE=\"open_notebook\"\n```\n\n## Multiple databases\n\nYou can have multiple namespaces in one SurrealDB instance and you can also have multiple databases in one instance. So, if you want to setup multiple open noteobok deployments for different users, you don't need to deploy multiple databases. \n"
  },
  {
    "path": "docs/5-CONFIGURATION/environment-reference.md",
    "content": "# Complete Environment Reference\n\nComprehensive list of all environment variables available in Open Notebook.\n\n---\n\n## API Configuration\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `API_URL` | No | Auto-detected | URL where frontend reaches API (e.g., http://localhost:5055) |\n| `INTERNAL_API_URL` | No | http://localhost:5055 | Internal API URL for Next.js server-side proxying |\n| `API_CLIENT_TIMEOUT` | No | 300 | Client timeout in seconds (how long to wait for API response) |\n| `OPEN_NOTEBOOK_PASSWORD` | No | None | Password to protect Open Notebook instance |\n| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | **Yes** | None | Secret string to encrypt credentials stored in database (any string works). **Required** for the credential system. Supports Docker secrets via `_FILE` suffix. |\n| `HOSTNAME` | No | `0.0.0.0` (in Docker) | Network interface for Next.js to bind to. Default `0.0.0.0` ensures accessibility from reverse proxies |\n\n> **Important**: `OPEN_NOTEBOOK_ENCRYPTION_KEY` is required for storing AI provider credentials via the Settings UI. Without it, you cannot save credentials. If you change or lose this key, all stored credentials become unreadable.\n\n---\n\n## Database: SurrealDB\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `SURREAL_URL` | Yes | ws://surrealdb:8000/rpc | SurrealDB WebSocket connection URL |\n| `SURREAL_USER` | Yes | root | SurrealDB username |\n| `SURREAL_PASSWORD` | Yes | root | SurrealDB password |\n| `SURREAL_NAMESPACE` | Yes | open_notebook | SurrealDB namespace |\n| `SURREAL_DATABASE` | Yes | open_notebook | SurrealDB database name |\n\n---\n\n## Database: Retry Configuration\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `SURREAL_COMMANDS_RETRY_ENABLED` | No | true | Enable retries on failure |\n| `SURREAL_COMMANDS_RETRY_MAX_ATTEMPTS` | No | 3 | Maximum retry attempts |\n| `SURREAL_COMMANDS_RETRY_WAIT_STRATEGY` | No | exponential_jitter | Retry wait strategy (exponential_jitter/exponential/fixed/random) |\n| `SURREAL_COMMANDS_RETRY_WAIT_MIN` | No | 1 | Minimum wait time between retries (seconds) |\n| `SURREAL_COMMANDS_RETRY_WAIT_MAX` | No | 30 | Maximum wait time between retries (seconds) |\n\n---\n\n## Database: Concurrency\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `SURREAL_COMMANDS_MAX_TASKS` | No | 5 | Maximum concurrent database tasks |\n\n---\n\n## LLM Timeouts\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `ESPERANTO_LLM_TIMEOUT` | No | 60 | LLM inference timeout in seconds |\n| `ESPERANTO_SSL_VERIFY` | No | true | Verify SSL certificates (false = development only) |\n| `ESPERANTO_SSL_CA_BUNDLE` | No | None | Path to custom CA certificate bundle |\n\n---\n\n## Text-to-Speech (TTS)\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `TTS_BATCH_SIZE` | No | 5 | Concurrent TTS requests (1-5, depends on provider) |\n\n---\n\n## Content Extraction\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `FIRECRAWL_API_KEY` | No | None | Firecrawl API key for advanced web scraping |\n| `JINA_API_KEY` | No | None | Jina AI API key for web extraction |\n\n**Setup:**\n- Firecrawl: https://firecrawl.dev/\n- Jina: https://jina.ai/\n\n---\n\n## Network / Proxy\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `HTTP_PROXY` | No | None | HTTP proxy URL for outbound HTTP requests |\n| `HTTPS_PROXY` | No | None | HTTPS proxy URL for outbound HTTPS requests |\n| `NO_PROXY` | No | None | Comma-separated list of hosts to bypass proxy |\n\nRoute all outbound HTTP requests through a proxy server. Useful for corporate/firewalled environments.\n\nThe underlying libraries (esperanto, content-core, podcast-creator) automatically detect proxy settings from these standard environment variables.\n\n**Affects:**\n- AI provider API calls (OpenAI, Anthropic, Google, Groq, etc.)\n- Content extraction from URLs (web scraping, YouTube transcripts)\n- Podcast generation (LLM and TTS provider calls)\n\n**Format:** `http://[user:pass@]host:port` or `https://[user:pass@]host:port`\n\n**Examples:**\n```bash\n# Basic proxy\nHTTP_PROXY=http://proxy.corp.com:8080\nHTTPS_PROXY=http://proxy.corp.com:8080\n\n# Authenticated proxy\nHTTP_PROXY=http://user:password@proxy.corp.com:8080\nHTTPS_PROXY=http://user:password@proxy.corp.com:8080\n\n# Bypass proxy for local hosts\nNO_PROXY=localhost,127.0.0.1,.local\n```\n\n---\n\n## Debugging & Monitoring\n\n| Variable | Required? | Default | Description |\n|----------|-----------|---------|-------------|\n| `LANGCHAIN_TRACING_V2` | No | false | Enable LangSmith tracing |\n| `LANGCHAIN_ENDPOINT` | No | https://api.smith.langchain.com | LangSmith endpoint |\n| `LANGCHAIN_API_KEY` | No | None | LangSmith API key |\n| `LANGCHAIN_PROJECT` | No | Open Notebook | LangSmith project name |\n\n**Setup:** https://smith.langchain.com/\n\n---\n\n## Environment Variables by Use Case\n\n### Minimal Setup (New Installation)\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\nSURREAL_URL=ws://surrealdb:8000/rpc\nSURREAL_USER=root\nSURREAL_PASSWORD=password\nSURREAL_NAMESPACE=open_notebook\nSURREAL_DATABASE=open_notebook\n```\nThen configure AI providers via **Settings → API Keys** in the browser.\n\n### Production Deployment\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-strong-secret-key\nOPEN_NOTEBOOK_PASSWORD=your-secure-password\nAPI_URL=https://mynotebook.example.com\nSURREAL_USER=production_user\nSURREAL_PASSWORD=secure_password\n```\n\n### Self-Hosted Behind Reverse Proxy\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key\nAPI_URL=https://mynotebook.example.com\n```\n\n### Corporate Environment (Behind Proxy)\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key\nHTTP_PROXY=http://proxy.corp.com:8080\nHTTPS_PROXY=http://proxy.corp.com:8080\nNO_PROXY=localhost,127.0.0.1\n```\n\n### High-Performance Deployment\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key\nSURREAL_COMMANDS_MAX_TASKS=10\nTTS_BATCH_SIZE=5\nAPI_CLIENT_TIMEOUT=600\n```\n\n### Debugging\n```\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key\nLANGCHAIN_TRACING_V2=true\nLANGCHAIN_API_KEY=your-key\n```\n\n---\n\n## Validation\n\nCheck if a variable is set:\n\n```bash\n# Check single variable\necho $OPEN_NOTEBOOK_ENCRYPTION_KEY\n\n# Check multiple\nenv | grep -E \"OPEN_NOTEBOOK|API_URL\"\n\n# Print all config\nenv | grep -E \"^[A-Z_]+=\" | sort\n```\n\n---\n\n## Notes\n\n- **Case-sensitive:** `OPEN_NOTEBOOK_ENCRYPTION_KEY` ≠ `open_notebook_encryption_key`\n- **No spaces:** `OPEN_NOTEBOOK_ENCRYPTION_KEY=my-key` not `OPEN_NOTEBOOK_ENCRYPTION_KEY = my-key`\n- **Quote values:** Use quotes for values with spaces: `API_URL=\"http://my server:5055\"`\n- **Restart required:** Changes take effect after restarting services\n- **Secrets:** Don't commit encryption keys or passwords to git\n- **AI Providers:** Configure via **Settings → API Keys** in the browser (not via env vars)\n- **Migration:** Use Settings UI to migrate existing env vars to the credential system. See [API Configuration](../3-USER-GUIDE/api-configuration.md#migrating-from-environment-variables)\n\n---\n\n## Quick Setup Checklist\n\n- [ ] Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml\n- [ ] Set database credentials (`SURREAL_*`)\n- [ ] Start services\n- [ ] Open browser → Go to **Settings → API Keys**\n- [ ] **Add Credential** for your AI provider\n- [ ] **Test Connection** to verify\n- [ ] **Discover & Register Models**\n- [ ] Set `API_URL` if behind reverse proxy\n- [ ] Change `SURREAL_PASSWORD` in production\n- [ ] Try a test chat\n\nDone!\n\n---\n\n## Legacy: AI Provider Environment Variables (Deprecated)\n\n> **Deprecated**: The following AI provider API key environment variables are deprecated. Configure providers via the Settings UI instead. These variables may still work as a fallback but are no longer recommended.\n\nIf you have these variables configured from a previous installation, click the **Migrate to Database** button in **Settings → API Keys** to import them into the credential system, then remove them from your configuration.\n\n| Variable | Provider | Replacement |\n|----------|----------|-------------|\n| `OPENAI_API_KEY` | OpenAI | Settings → API Keys → Add OpenAI Credential |\n| `ANTHROPIC_API_KEY` | Anthropic | Settings → API Keys → Add Anthropic Credential |\n| `GOOGLE_API_KEY` | Google Gemini | Settings → API Keys → Add Google Credential |\n| `GEMINI_API_BASE_URL` | Google Gemini | Configure in Google Gemini credential |\n| `VERTEX_PROJECT` | Vertex AI | Settings → API Keys → Add Vertex AI Credential |\n| `VERTEX_LOCATION` | Vertex AI | Configure in Vertex AI credential |\n| `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI | Configure in Vertex AI credential |\n| `GROQ_API_KEY` | Groq | Settings → API Keys → Add Groq Credential |\n| `MISTRAL_API_KEY` | Mistral | Settings → API Keys → Add Mistral Credential |\n| `DEEPSEEK_API_KEY` | DeepSeek | Settings → API Keys → Add DeepSeek Credential |\n| `XAI_API_KEY` | xAI | Settings → API Keys → Add xAI Credential |\n| `OLLAMA_API_BASE` | Ollama | Settings → API Keys → Add Ollama Credential |\n| `OPENROUTER_API_KEY` | OpenRouter | Settings → API Keys → Add OpenRouter Credential |\n| `OPENROUTER_BASE_URL` | OpenRouter | Configure in OpenRouter credential |\n| `VOYAGE_API_KEY` | Voyage AI | Settings → API Keys → Add Voyage AI Credential |\n| `ELEVENLABS_API_KEY` | ElevenLabs | Settings → API Keys → Add ElevenLabs Credential |\n| `OPENAI_COMPATIBLE_BASE_URL` | OpenAI-Compatible | Settings → API Keys → Add OpenAI-Compatible Credential |\n| `OPENAI_COMPATIBLE_API_KEY` | OpenAI-Compatible | Configure in OpenAI-Compatible credential |\n| `OPENAI_COMPATIBLE_BASE_URL_LLM` | OpenAI-Compatible | Configure per-service URL in credential |\n| `OPENAI_COMPATIBLE_API_KEY_LLM` | OpenAI-Compatible | Configure per-service key in credential |\n| `OPENAI_COMPATIBLE_BASE_URL_EMBEDDING` | OpenAI-Compatible | Configure per-service URL in credential |\n| `OPENAI_COMPATIBLE_API_KEY_EMBEDDING` | OpenAI-Compatible | Configure per-service key in credential |\n| `OPENAI_COMPATIBLE_BASE_URL_STT` | OpenAI-Compatible | Configure per-service URL in credential |\n| `OPENAI_COMPATIBLE_API_KEY_STT` | OpenAI-Compatible | Configure per-service key in credential |\n| `OPENAI_COMPATIBLE_BASE_URL_TTS` | OpenAI-Compatible | Configure per-service URL in credential |\n| `OPENAI_COMPATIBLE_API_KEY_TTS` | OpenAI-Compatible | Configure per-service key in credential |\n| `AZURE_OPENAI_API_KEY` | Azure OpenAI | Settings → API Keys → Add Azure OpenAI Credential |\n| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI | Configure in Azure OpenAI credential |\n| `AZURE_OPENAI_API_VERSION` | Azure OpenAI | Configure in Azure OpenAI credential |\n| `AZURE_OPENAI_API_KEY_LLM` | Azure OpenAI | Configure per-service in credential |\n| `AZURE_OPENAI_ENDPOINT_LLM` | Azure OpenAI | Configure per-service in credential |\n| `AZURE_OPENAI_API_VERSION_LLM` | Azure OpenAI | Configure per-service in credential |\n| `AZURE_OPENAI_API_KEY_EMBEDDING` | Azure OpenAI | Configure per-service in credential |\n| `AZURE_OPENAI_ENDPOINT_EMBEDDING` | Azure OpenAI | Configure per-service in credential |\n| `AZURE_OPENAI_API_VERSION_EMBEDDING` | Azure OpenAI | Configure per-service in credential |\n"
  },
  {
    "path": "docs/5-CONFIGURATION/index.md",
    "content": "# Configuration - Essential Settings\n\nConfiguration is how you customize Open Notebook for your specific setup. This section covers what you need to know.\n\n---\n\n## What Needs Configuration?\n\nThree things:\n\n1. **AI Provider** — Which LLM/embedding service you're using (OpenAI, Anthropic, Ollama, etc.)\n2. **Database** — How to connect to SurrealDB (usually pre-configured)\n3. **Server** — API URL, ports, timeouts (usually auto-detected)\n\n---\n\n## Quick Decision: Which Provider?\n\n### Option 1: Cloud Provider (Fastest)\n- **OpenRouter (recommended)** (access to all models with one key)\n- **OpenAI** (GPT)\n- **Anthropic** (Claude)\n- **Google Gemini** (multi-modal, long context)\n- **Groq** (ultra-fast inference)\n\nSetup: Get API key → Add credential in Settings UI → Done\n\n→ Go to **[AI Providers Guide](ai-providers.md)**\n\n### Option 2: Local (Free & Private)\n- **Ollama** (open-source models, on your machine)\n\n→ Go to **[Ollama Setup](ollama.md)**\n\n### Option 3: OpenAI-Compatible\n- **LM Studio** (local)\n- **Custom endpoints**\n\n→ Go to **[OpenAI-Compatible Guide](openai-compatible.md)**\n\n---\n\n## Configuration File\n\nUse the right file depending on your setup.\n\n### `.env` (Local Development)\n\nYou will only use .env if you are running Open Notebook locally.\n\n```\nLocated in: project root\nUse for: Development on your machine\nFormat: KEY=value, one per line\n```\n\n### `docker.env` (Docker Deployment)\n\nYou will use this file to hold your environment variables if you are using docker-compose and prefer not to put the variables directly in the compose file. \n```\nLocated in: project root (or ./docker)\nUse for: Docker deployments\nFormat: Same as .env\nLoaded by: docker-compose.yml\n```\n\n---\n\n## Most Important Settings\n\nAll of the settings provided below are to be placed inside your environment file (.env or docker.env depending on your setup).\n\n\n###  Surreal Database\n\nThis is the database used by the app.\n\n```\nSURREAL_URL=ws://surrealdb:8000/rpc\nSURREAL_USER=root\nSURREAL_PASSWORD=root  # Change in production!\nSURREAL_NAMESPACE=open_notebook\nSURREAL_DATABASE=open_notebook\n```\n\n> The only thing that is critical to not miss is the hostname in the `SURREAL_URL`. Check what URL to use based on your deployment, [here](database.md).\n\n\n### AI Provider (Credentials)\n\nWe need access to LLMs in order for the app to work. AI provider credentials are configured via the **Settings UI**:\n\n1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment (required for storing credentials)\n2. Start services\n3. Go to **Settings → API Keys → Add Credential**\n4. Select your provider, paste your API key\n5. **Test Connection** → **Discover Models** → **Register Models**\n\n```\n# Required in your .env or docker-compose.yml:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n```\n\n> **Ollama users**: Add an Ollama credential in Settings → API Keys with the correct base URL. See [Ollama Setup](ollama.md) for network configuration help.\n\n> **LM Studio / OpenAI-Compatible**: Add an OpenAI-Compatible credential in Settings → API Keys. See [OpenAI-Compatible Guide](openai-compatible.md).\n\n\n### API URL (If Behind Reverse Proxy)\nYou only need to worry about this if you are deploying on a proxy or if you are changing port information. Otherwise, skip this.\n\n```\nAPI_URL=https://your-domain.com\n# Usually auto-detected. Only set if needed.\n```\n\nAuto-detection works for most setups.\n\n---\n\n## Configuration by Scenario\n\n### Scenario 1: Docker on Localhost (Default)\n```env\n# In docker.env:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n# Everything else uses defaults\n# Then configure AI provider in Settings → API Keys\n```\n\n### Scenario 2: Docker on Remote Server\n```env\n# In docker.env:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\nAPI_URL=http://your-server-ip:5055\n```\n\n### Scenario 3: Behind Reverse Proxy (Nginx/Cloudflare)\n```env\n# In docker.env:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\nAPI_URL=https://your-domain.com\n# The reverse proxy handles HTTPS\n```\n\n### Scenario 4: Using Ollama Locally\n```env\n# In .env:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n# Then add Ollama credential in Settings → API Keys\n```\n\n### Scenario 5: Using Azure OpenAI\n```env\n# In docker.env:\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n# Then add Azure OpenAI credential in Settings → API Keys\n```\n\n---\n\n## Configuration Sections\n\n### [AI Providers](ai-providers.md)\n- OpenAI configuration\n- Anthropic configuration\n- Google Gemini configuration\n- Groq configuration\n- Ollama configuration\n- Azure OpenAI configuration\n- OpenAI-compatible configuration\n\n### [Database](database.md)\n- SurrealDB setup\n- Connection strings\n- Database vs. namespace\n- Running your own SurrealDB\n\n### [Advanced](advanced.md)\n- Ports and networking\n- Timeouts and concurrency\n- SSL/security\n- Retry configuration\n- Worker concurrency\n- Language models & embeddings\n- Speech-to-text & text-to-speech\n- Debugging and logging\n\n### [Reverse Proxy](reverse-proxy.md)\n- Nginx, Caddy, Traefik configs\n- Custom domain setup\n- SSL/HTTPS configuration\n- Coolify and other platforms\n\n### [Security](security.md)\n- Password protection\n- API authentication\n- Production hardening\n- Firewall configuration\n\n### [Local TTS](local-tts.md)\n- Speaches setup for local text-to-speech\n- GPU acceleration\n- Voice options\n- Docker networking\n\n### [Local STT](local-stt.md)\n- Speaches setup for local speech-to-text\n- Whisper model options\n- GPU acceleration\n- Docker networking\n\n### [Ollama](ollama.md)\n- Setting up and pointing to an Ollama server\n- Downloading models\n- Using embedding\n\n### [OpenAI-Compatible Providers](openai-compatible.md)\n- LM Studio, vLLM, Text Generation WebUI\n- Connection configuration\n- Docker networking\n- Troubleshooting\n\n### [Complete Reference](environment-reference.md)\n- All environment variables\n- Grouped by category\n- What each one does\n- Default values\n\n---\n\n## How to Add Configuration\n\n### Method 1: Settings UI (For AI Provider Credentials)\n\nThe recommended way to configure AI providers:\n\n```\n1. Open Open Notebook in your browser\n2. Go to Settings → API Keys\n3. Click \"Add Credential\"\n4. Select provider, enter API key\n5. Click Save, then Test Connection\n6. Click Discover Models → Register Models\n```\n\nNo file editing, no restarts. Credentials stored securely (encrypted) in database.\n\n→ **[Full Guide: API Configuration](../3-USER-GUIDE/api-configuration.md)**\n\n### Method 2: Edit `.env` File (Infrastructure Settings)\n\nFor database, network, and encryption key settings:\n\n```bash\n1. Open .env in your editor\n2. Set OPEN_NOTEBOOK_ENCRYPTION_KEY and database vars\n3. Save\n4. Restart services\n```\n\n### Method 3: Set Docker Environment (Deployment)\n\n```bash\n# In docker-compose.yml:\nservices:\n  api:\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key\n      - API_URL=https://your-domain.com\n```\n\n---\n\n## Verification\n\nAfter configuration, verify it works:\n\n```\n1. Open your notebook\n2. Go to Settings → Models\n3. You should see your configured provider\n4. Try a simple Chat question\n5. If it responds, configuration is correct!\n```\n\n---\n\n## Common Mistakes\n\n| Mistake | Problem | Fix |\n|---------|---------|-----|\n| No credential configured | Models not available | Add credential in Settings → API Keys |\n| Missing encryption key | Can't save credentials | Set OPEN_NOTEBOOK_ENCRYPTION_KEY |\n| Wrong database URL | Can't start API | Check SURREAL_URL format |\n| Expose port 5055 | \"Can't connect to server\" | Expose 5055 in docker-compose |\n| Typo in env var | Settings ignored | Check spelling (case-sensitive!) |\n| Don't restart | Old config still used | Restart services after env changes |\n\n---\n\n## What Comes After Configuration\n\nOnce configured:\n\n1. **[Quick Start](../0-START-HERE/index.md)** — Run your first notebook\n2. **[Installation](../1-INSTALLATION/index.md)** — Multi-route deployment guides\n3. **[User Guide](../3-USER-GUIDE/index.md)** — How to use each feature\n\n---\n\n## Getting Help\n\n- **Configuration error?** → Check [Troubleshooting](../6-TROUBLESHOOTING/quick-fixes.md)\n- **Provider-specific issue?** → Check [AI Providers](ai-providers.md)\n- **Need complete reference?** → See [Environment Reference](environment-reference.md)\n\n---\n\n## Summary\n\n**Minimal configuration to run:**\n1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment\n2. Start services\n3. Add AI provider credential in Settings → API Keys\n4. Done!\n\nEverything else is optional optimization.\n"
  },
  {
    "path": "docs/5-CONFIGURATION/local-stt.md",
    "content": "# Local Speech-to-Text Setup\n\nRun speech-to-text locally for free, private audio/video transcription using OpenAI-compatible STT servers.\n\n---\n\n## Why Local STT?\n\n| Benefit | Description |\n|---------|-------------|\n| **Free** | No per-minute costs after setup |\n| **Private** | Audio never leaves your machine |\n| **Unlimited** | No rate limits or quotas |\n| **Offline** | Works without internet |\n\n---\n\n## Quick Start with Speaches\n\n[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible server that supports both TTS and STT. It uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper) for transcription.\n\n> **💡 Ready-made Docker Compose files available:**\n> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook\n> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)\n>\n> These include complete setup instructions and configuration examples. Just copy and run!\n\n### Step 1: Create Docker Compose File\n\nCreate a folder and add `docker-compose.yml`:\n\n```yaml\nservices:\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cpu\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    restart: unless-stopped\n\nvolumes:\n  hf-hub-cache:\n```\n\n### Step 2: Start and Download Model\n\n```bash\n# Start Speaches\ndocker compose up -d\n\n# Wait for startup\nsleep 10\n\n# Download Whisper model (~500MB for small)\ndocker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small\n```\n\nModels can also be downloaded automatically on first use, but pre-downloading avoids delays.\n\n### Step 3: Test\n\n```bash\n# Create a test audio file (or use your own)\n# Then transcribe it:\ncurl \"http://localhost:8969/v1/audio/transcriptions\" \\\n  -F \"file=@test.mp3\" \\\n  -F \"model=Systran/faster-whisper-small\"\n```\n\nYou should see the transcribed text in the response.\n\n### Step 4: Configure Open Notebook\n\n**Via Settings UI (Recommended):**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **OpenAI-Compatible**\n3. Enter base URL for STT: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)\n4. Click **Save**, then **Test Connection**\n\n**Legacy (Deprecated) — Environment variables:**\n```yaml\n# In your Open Notebook docker-compose.yml\nenvironment:\n  - OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8969/v1\n```\n\n```bash\n# Local development\nexport OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:8969/v1\n```\n\n### Step 5: Add Model in Open Notebook\n\n1. Go to **Settings** → **Models**\n2. Click **Add Model** in Speech-to-Text section\n3. Configure:\n   - **Provider**: `openai_compatible`\n   - **Model Name**: `Systran/faster-whisper-small`\n   - **Display Name**: `Local Whisper`\n4. Click **Save**\n5. Set as default if desired\n\n---\n\n## Available Models\n\nSpeaches supports various Whisper model sizes. Larger models are more accurate but slower:\n\n| Model | Size | Speed | Accuracy | VRAM (GPU) |\n|-------|------|-------|----------|------------|\n| `Systran/faster-whisper-tiny` | ~75 MB | Fastest | Basic | ~1 GB |\n| `Systran/faster-whisper-base` | ~150 MB | Fast | Good | ~1 GB |\n| `Systran/faster-whisper-small` | ~500 MB | Medium | Better | ~2 GB |\n| `Systran/faster-whisper-medium` | ~1.5 GB | Slow | Great | ~5 GB |\n| `Systran/faster-whisper-large-v3` | ~3 GB | Slowest | Best | ~10 GB |\n| `Systran/faster-distil-whisper-small.en` | ~400 MB | Fast | Good (English only) | ~2 GB |\n\n### List Available Models\n\n```bash\ndocker compose exec speaches uv tool run speaches-cli registry ls --task automatic-speech-recognition\n```\n\n### Recommended Models\n\n- **For speed**: `Systran/faster-whisper-tiny` or `Systran/faster-whisper-base`\n- **For balance**: `Systran/faster-whisper-small` (recommended)\n- **For accuracy**: `Systran/faster-whisper-large-v3`\n\n---\n\n## GPU Acceleration\n\nFor faster transcription with NVIDIA GPUs:\n\n```yaml\nservices:\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cuda\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    environment:\n      - WHISPER__TTL=-1  # Keep model in VRAM (recommended if you have enough memory)\n    restart: unless-stopped\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: 1\n              capabilities: [gpu]\n\nvolumes:\n  hf-hub-cache:\n```\n\n### Keep Model in Memory\n\nBy default, Speaches unloads models after some time. To keep the Whisper model loaded for instant transcription:\n\n```yaml\nenvironment:\n  - WHISPER__TTL=-1  # Never unload\n```\n\nThis is recommended if you have enough RAM/VRAM, as loading the model can take a few seconds.\n\n---\n\n## Docker Networking\n\nWhen configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate STT base URL for your setup:\n\n### Open Notebook in Docker (macOS/Windows)\n\n**STT Base URL:** `http://host.docker.internal:8969/v1`\n\n### Open Notebook in Docker (Linux)\n\n**STT Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`\n\n**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`\n\n### Remote Server\n\nRun Speaches on a different machine:\n\n**STT Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)\n\n---\n\n## Language Support\n\nWhisper supports 99+ languages. Specify the language for better accuracy:\n\n```bash\ncurl \"http://localhost:8969/v1/audio/transcriptions\" \\\n  -F \"file=@audio.mp3\" \\\n  -F \"model=Systran/faster-whisper-small\" \\\n  -F \"language=ru\"\n```\n\nCommon language codes:\n- `en` - English\n- `ru` - Russian\n- `es` - Spanish\n- `fr` - French\n- `de` - German\n- `zh` - Chinese\n- `ja` - Japanese\n\n---\n\n## Troubleshooting\n\n### Service Won't Start\n\n```bash\n# Check logs\ndocker compose logs speaches\n\n# Verify port available\nlsof -i :8969\n\n# Restart\ndocker compose down && docker compose up -d\n```\n\n### Connection Refused\n\n```bash\n# Test Speaches is running\ncurl http://localhost:8969/v1/models\n\n# From inside Open Notebook container\ndocker exec -it open-notebook curl http://host.docker.internal:8969/v1/models\n```\n\n### Model Download Fails\n\nModels are downloaded automatically on first use. If download fails:\n\n```bash\n# Check available disk space\ndf -h\n\n# Check Docker logs for errors\ndocker compose logs speaches\n\n# Restart and try again\ndocker compose restart speaches\n```\n\n### Poor Transcription Quality\n\n- Use a larger model (`faster-whisper-medium` or `large-v3`)\n- Specify the correct language\n- Ensure audio quality is good (clear speech, minimal background noise)\n- Try different audio formats (WAV often works better than MP3)\n\n### Slow Transcription\n\n| Solution | How |\n|----------|-----|\n| Use GPU | Switch to `latest-cuda` image |\n| Smaller model | Use `faster-whisper-tiny` or `base` |\n| More CPU | Allocate more cores in Docker |\n| SSD storage | Move Docker volumes to SSD |\n\n---\n\n## Performance Tips\n\n### Recommended Specs\n\n| Component | Minimum | Recommended |\n|-----------|---------|-------------|\n| CPU | 2 cores | 4+ cores |\n| RAM | 2 GB | 8+ GB |\n| Storage | 5 GB | 10 GB (for multiple models) |\n| GPU | None | NVIDIA (optional, much faster) |\n\n### Resource Limits\n\n```yaml\nservices:\n  speaches:\n    # ... other config\n    mem_limit: 4g\n    cpus: 2\n```\n\n### Monitor Usage\n\n```bash\ndocker stats speaches\n```\n\n---\n\n## Comparison: Local vs Cloud\n\n| Aspect | Local (Speaches) | Cloud (OpenAI Whisper) |\n|--------|------------------|------------------------|\n| **Cost** | Free | $0.006/min |\n| **Privacy** | Complete | Data sent to provider |\n| **Speed** | Depends on hardware | Usually faster |\n| **Quality** | Excellent (same Whisper) | Excellent |\n| **Setup** | Moderate | Simple API key |\n| **Offline** | Yes | No |\n| **Languages** | 99+ | 99+ |\n\n### When to Use Local\n\n- Privacy-sensitive content\n- High-volume transcription\n- Development/testing\n- Offline environments\n- Cost control\n\n### When to Use Cloud\n\n- Limited hardware\n- Time-sensitive projects\n- No GPU available\n- Simple setup preferred\n\n---\n\n## Using Both TTS and STT\n\nSpeaches supports both TTS and STT in one server. In **Settings → API Keys**, add a single **OpenAI-Compatible** credential and configure both the TTS and STT base URLs to point to the same Speaches server (e.g., `http://localhost:8969/v1`).\n\nSee **[Local TTS Setup](local-tts.md)** for TTS configuration.\n\n---\n\n## Other Local STT Options\n\nAny OpenAI-compatible STT server works:\n\n| Server | Description |\n|--------|-------------|\n| [Speaches](https://github.com/speaches-ai/speaches) | TTS + STT in one (recommended) |\n| [faster-whisper-server](https://github.com/fedirz/faster-whisper-server) | Lightweight STT only |\n| [whisper.cpp](https://github.com/ggerganov/whisper.cpp) | C++ implementation with server mode |\n| [LocalAI](https://github.com/mudler/LocalAI) | Multi-model local AI server |\n\nThe key requirements:\n\n1. Server implements `/v1/audio/transcriptions` endpoint\n2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the STT base URL\n3. Add model with provider `openai_compatible`\n\n---\n\n## Related\n\n- **[Local TTS Setup](local-tts.md)** - Text-to-speech with Speaches\n- **[OpenAI-Compatible Providers](openai-compatible.md)** - General compatible provider setup\n- **[AI Providers](ai-providers.md)** - All provider configuration"
  },
  {
    "path": "docs/5-CONFIGURATION/local-tts.md",
    "content": "# Local Text-to-Speech Setup\n\nRun text-to-speech locally for free, private podcast generation using OpenAI-compatible TTS servers.\n\n---\n\n## Why Local TTS?\n\n| Benefit | Description |\n|---------|-------------|\n| **Free** | No per-character costs after setup |\n| **Private** | Audio never leaves your machine |\n| **Unlimited** | No rate limits or quotas |\n| **Offline** | Works without internet |\n\n---\n\n## Quick Start with Speaches\n\n[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible TTS server.\n\n> **💡 Ready-made Docker Compose files available:**\n> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook\n> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)\n>\n> These include complete setup instructions and configuration examples. Just copy and run!\n\n### Step 1: Create Docker Compose File\n\nCreate a folder and add `docker-compose.yml`:\n\n```yaml\nservices:\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cpu\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    restart: unless-stopped\n\nvolumes:\n  hf-hub-cache:\n```\n\n### Step 2: Start and Download Model\n\n```bash\n# Start Speaches\ndocker compose up -d\n\n# Wait for startup\nsleep 10\n\n# Download voice model (~500MB)\ndocker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX\n```\n\n### Step 3: Test\n\n```bash\ncurl \"http://localhost:8969/v1/audio/speech\" -s \\\n  -H \"Content-Type: application/json\" \\\n  --output test.mp3 \\\n  --data '{\n    \"input\": \"Hello! Local TTS is working.\",\n    \"model\": \"speaches-ai/Kokoro-82M-v1.0-ONNX\",\n    \"voice\": \"af_bella\"\n  }'\n```\n\nPlay `test.mp3` to verify.\n\n### Step 4: Configure Open Notebook\n\n**Via Settings UI (Recommended):**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **OpenAI-Compatible**\n3. Enter base URL for TTS: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)\n4. Click **Save**, then **Test Connection**\n\n**Legacy (Deprecated) — Environment variables:**\n```yaml\n# In your Open Notebook docker-compose.yml\nenvironment:\n  - OPENAI_COMPATIBLE_BASE_URL_TTS=http://host.docker.internal:8969/v1\n```\n\n```bash\n# Local development\nexport OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1\n```\n\n### Step 5: Add Model in Open Notebook\n\n1. Go to **Settings** → **Models**\n2. Click **Add Model** in Text-to-Speech section\n3. Configure:\n   - **Provider**: `openai_compatible`\n   - **Model Name**: `speaches-ai/Kokoro-82M-v1.0-ONNX`\n   - **Display Name**: `Local TTS`\n4. Click **Save**\n5. Set as default if desired\n\n---\n\n## Available Voices\n\nThe Kokoro model includes multiple voices:\n\n### Female Voices\n| Voice ID | Description |\n|----------|-------------|\n| `af_bella` | Clear, professional |\n| `af_sarah` | Warm, friendly |\n| `af_nicole` | Energetic, expressive |\n\n### Male Voices\n| Voice ID | Description |\n|----------|-------------|\n| `am_adam` | Deep, authoritative |\n| `am_michael` | Friendly, conversational |\n\n### British Accents\n| Voice ID | Description |\n|----------|-------------|\n| `bf_emma` | British female, professional |\n| `bm_george` | British male, formal |\n\n### Test Different Voices\n\n```bash\nfor voice in af_bella af_sarah am_adam am_michael; do\n  curl \"http://localhost:8969/v1/audio/speech\" -s \\\n    -H \"Content-Type: application/json\" \\\n    --output \"test_${voice}.mp3\" \\\n    --data \"{\n      \\\"input\\\": \\\"Hello, this is the ${voice} voice.\\\",\n      \\\"model\\\": \\\"speaches-ai/Kokoro-82M-v1.0-ONNX\\\",\n      \\\"voice\\\": \\\"${voice}\\\"\n    }\"\ndone\n```\n\n---\n\n## GPU Acceleration\n\nFor faster generation with NVIDIA GPUs:\n\n```yaml\nservices:\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cuda\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    restart: unless-stopped\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: 1\n              capabilities: [gpu]\n\nvolumes:\n  hf-hub-cache:\n```\n\n---\n\n## Docker Networking\n\nWhen configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate TTS base URL for your setup:\n\n### Open Notebook in Docker (macOS/Windows)\n\n**TTS Base URL:** `http://host.docker.internal:8969/v1`\n\n### Open Notebook in Docker (Linux)\n\n**TTS Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`\n\n**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`\n\n### Remote Server\n\nRun Speaches on a different machine:\n\n**TTS Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)\n\n---\n\n## Multi-Speaker Podcasts\n\nConfigure different voices for each speaker:\n\n```\nSpeaker 1 (Host):\n  Model: speaches-ai/Kokoro-82M-v1.0-ONNX\n  Voice: af_bella\n\nSpeaker 2 (Guest):\n  Model: speaches-ai/Kokoro-82M-v1.0-ONNX\n  Voice: am_adam\n\nSpeaker 3 (Narrator):\n  Model: speaches-ai/Kokoro-82M-v1.0-ONNX\n  Voice: bf_emma\n```\n\n---\n\n## Troubleshooting\n\n### Service Won't Start\n\n```bash\n# Check logs\ndocker compose logs speaches\n\n# Verify port available\nlsof -i :8969\n\n# Restart\ndocker compose down && docker compose up -d\n```\n\n### Connection Refused\n\n```bash\n# Test Speaches is running\ncurl http://localhost:8969/v1/models\n\n# From inside Open Notebook container\ndocker exec -it open-notebook curl http://host.docker.internal:8969/v1/models\n```\n\n### Model Not Found\n\n```bash\n# List downloaded models\ndocker compose exec speaches uv tool run speaches-cli model list\n\n# Download if missing\ndocker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX\n```\n\n### Poor Audio Quality\n\n- Try different voices\n- Adjust speed: `\"speed\": 0.9` to `1.2`\n- Check model downloaded completely\n- Allocate more memory\n\n### Slow Generation\n\n| Solution | How |\n|----------|-----|\n| Use GPU | Switch to `latest-cuda` image |\n| More CPU | Allocate more cores in Docker |\n| Faster model | Use smaller/quantized models |\n| SSD storage | Move Docker volumes to SSD |\n\n---\n\n## Performance Tips\n\n### Recommended Specs\n\n| Component | Minimum | Recommended |\n|-----------|---------|-------------|\n| CPU | 2 cores | 4+ cores |\n| RAM | 2 GB | 4+ GB |\n| Storage | 5 GB | 10 GB (for multiple models) |\n| GPU | None | NVIDIA (optional) |\n\n### Resource Limits\n\n```yaml\nservices:\n  speaches:\n    # ... other config\n    mem_limit: 4g\n    cpus: 2\n```\n\n### Monitor Usage\n\n```bash\ndocker stats speaches\n```\n\n---\n\n## Comparison: Local vs Cloud\n\n| Aspect | Local (Speaches) | Cloud (OpenAI/ElevenLabs) |\n|--------|------------------|---------------------------|\n| **Cost** | Free | $0.015-0.10/min |\n| **Privacy** | Complete | Data sent to provider |\n| **Speed** | Depends on hardware | Usually faster |\n| **Quality** | Good | Excellent |\n| **Setup** | Moderate | Simple API key |\n| **Offline** | Yes | No |\n| **Voices** | Limited | Many options |\n\n### When to Use Local\n\n- Privacy-sensitive content\n- High-volume generation\n- Development/testing\n- Offline environments\n- Cost control\n\n### When to Use Cloud\n\n- Premium quality needs\n- Multiple languages\n- Time-sensitive projects\n- Limited hardware\n\n---\n\n## Other Local TTS Options\n\nAny OpenAI-compatible TTS server works. The key is:\n\n1. Server implements `/v1/audio/speech` endpoint\n2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the TTS base URL\n3. Add model with provider `openai_compatible`\n\n---\n\n## Related\n\n- **[Local STT Setup](local-stt.md)** - Speech-to-text with Speaches\n- **[OpenAI-Compatible Providers](openai-compatible.md)** - General compatible provider setup\n- **[AI Providers](ai-providers.md)** - All provider configuration\n- **[Creating Podcasts](../3-USER-GUIDE/creating-podcasts.md)** - Using TTS for podcasts\n"
  },
  {
    "path": "docs/5-CONFIGURATION/mcp-integration.md",
    "content": "# Model Context Protocol (MCP) Integration\n\nOpen Notebook can be seamlessly integrated into your AI workflows using the **Model Context Protocol (MCP)**, enabling direct access to your notebooks, sources, and chat functionality from AI assistants like Claude Desktop and VS Code extensions.\n\n## What is MCP?\n\nThe [Model Context Protocol](https://modelcontextprotocol.io) is an open standard that allows AI applications to securely connect to external data sources and tools. With the Open Notebook MCP server, you can:\n\n- 📚 **Access your notebooks** directly from Claude Desktop or VS Code\n- 🔍 **Search your research content** without leaving your AI assistant\n- 💬 **Create and manage chat sessions** with your research as context\n- 📝 **Generate notes** and insights on-the-fly\n- 🤖 **Automate workflows** using the full Open Notebook API\n\n## Quick Setup\n\n### For Claude Desktop\n\n1. **Install the MCP server** (automatically from PyPI):\n\n   ```bash\n   # No manual installation needed! Claude Desktop will use uvx to run it automatically\n   ```\n\n2. **Configure Claude Desktop**:\n\n   **macOS/Linux**: Edit `~/Library/Application Support/Claude/claude_desktop_config.json`\n\n   ```json\n   {\n     \"mcpServers\": {\n       \"open-notebook\": {\n         \"command\": \"uvx\",\n         \"args\": [\"open-notebook-mcp\"],\n         \"env\": {\n           \"OPEN_NOTEBOOK_URL\": \"http://localhost:5055\",\n           \"OPEN_NOTEBOOK_PASSWORD\": \"your_password_here\"\n         }\n       }\n     }\n   }\n   ```\n\n   **Windows**: Edit `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n   ```json\n   {\n     \"mcpServers\": {\n       \"open-notebook\": {\n         \"command\": \"uvx\",\n         \"args\": [\"open-notebook-mcp\"],\n         \"env\": {\n           \"OPEN_NOTEBOOK_URL\": \"http://localhost:5055\",\n           \"OPEN_NOTEBOOK_PASSWORD\": \"your_password_here\"\n         }\n       }\n     }\n   }\n   ```\n\n3. **Restart Claude Desktop** and start using your notebooks in conversations!\n\n### For VS Code (Cline and other MCP-compatible extensions)\n\nAdd to your VS Code settings or `.vscode/mcp.json`:\n\n```json\n{\n  \"servers\": {\n    \"open-notebook\": {\n      \"command\": \"uvx\",\n      \"args\": [\"open-notebook-mcp\"],\n      \"env\": {\n        \"OPEN_NOTEBOOK_URL\": \"http://localhost:5055\",\n        \"OPEN_NOTEBOOK_PASSWORD\": \"your_password_here\"\n      }\n    }\n  }\n}\n```\n\n## Configuration\n\n- **OPEN_NOTEBOOK_URL**: URL to your Open Notebook API (default: `http://localhost:5055`)\n- **OPEN_NOTEBOOK_PASSWORD**: Optional - only needed if you've enabled password protection\n\n### For Remote Servers\n\nIf your Open Notebook instance is running on a remote server, update the URL accordingly:\n\n```json\n\"OPEN_NOTEBOOK_URL\": \"http://192.168.1.100:5055\"\n```\n\nOr with a domain:\n\n```json\n\"OPEN_NOTEBOOK_URL\": \"https://notebook.yourdomain.com/api\"\n```\n\n## What You Can Do\n\nOnce connected, you can ask Claude or your AI assistant to:\n\n- _\"Search my research notebooks for information about [topic]\"_\n- _\"Create a new note summarizing the key points from our conversation\"_\n- _\"List all my notebooks\"_\n- _\"Start a chat session about [specific source or topic]\"_\n- _\"What sources do I have in my [notebook name] notebook?\"_\n- _\"Add this PDF to my research notebook\"_\n- _\"Show me all notes in [notebook name]\"_\n\nThe MCP server provides full access to Open Notebook's capabilities, allowing you to manage your research seamlessly from within your AI assistant.\n\n## Available Tools\n\nThe Open Notebook MCP server exposes these capabilities:\n\n### Notebooks\n\n- List notebooks\n- Get notebook details\n- Create new notebooks\n- Update notebook information\n- Delete notebooks\n\n### Sources\n\n- List sources in a notebook\n- Get source details\n- Add new sources (links, files, text)\n- Update source metadata\n- Delete sources\n\n### Notes\n\n- List notes in a notebook\n- Get note details\n- Create new notes\n- Update notes\n- Delete notes\n\n### Chat\n\n- Create chat sessions\n- Send messages to chat sessions\n- Get chat history\n- List chat sessions\n\n### Search\n\n- Vector search across content\n- Text search across content\n- Filter by notebook\n\n### Models\n\n- List configured AI models\n- Get model details\n- Create model configurations\n- Update model settings\n\n### Settings\n\n- Get application settings\n- Update settings\n\n## MCP Server Repository\n\nThe Open Notebook MCP server is developed and maintained by the Epochal team:\n\n**🔗 GitHub**: [Epochal-dev/open-notebook-mcp](https://github.com/Epochal-dev/open-notebook-mcp)\n\nContributions, issues, and feature requests are welcome!\n\n## Finding the Server\n\nThe Open Notebook MCP server is published to the official MCP Registry:\n\n- **Registry**: Search for \"open-notebook\" at [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io)\n- **PyPI**: [pypi.org/project/open-notebook-mcp](https://pypi.org/project/open-notebook-mcp)\n- **GitHub**: [Epochal-dev/open-notebook-mcp](https://github.com/Epochal-dev/open-notebook-mcp)\n\n## Troubleshooting\n\n### Connection Errors\n\n1. Verify the `OPEN_NOTEBOOK_URL` is correct and accessible\n2. If using password protection, ensure `OPEN_NOTEBOOK_PASSWORD` is set correctly\n3. For remote servers, make sure port 5055 is accessible from your machine\n4. Check firewall settings if connecting to a remote server\n\n## Using with Other MCP Clients\n\nThe Open Notebook MCP server follows the standard MCP protocol and can be used with any MCP-compatible client. Check your client's documentation for configuration details.\n\n## Learn More\n\n- [Model Context Protocol Documentation](https://modelcontextprotocol.io)\n"
  },
  {
    "path": "docs/5-CONFIGURATION/ollama.md",
    "content": "# Ollama Setup Guide\n\nOllama provides free, local AI models that run on your own hardware. This guide covers everything you need to know about setting up Ollama with Open Notebook, including different deployment scenarios and network configurations.\n\n## Why Choose Ollama?\n\n- **🆓 Completely Free**: No API costs after initial setup\n- **🔒 Full Privacy**: Your data never leaves your local network\n- **📱 Offline Capable**: Works without internet connection\n- **🚀 Fast**: Local inference with no network latency\n- **🧠 Reasoning Models**: Support for advanced reasoning models like DeepSeek-R1\n- **💾 Model Variety**: Access to hundreds of open-source models\n\n## Quick Start\n\n### 1. Install Ollama\n\n**Linux/macOS:**\n```bash\ncurl -fsSL https://ollama.ai/install.sh | sh\n```\n\n**Windows:**\nDownload and install from [ollama.ai](https://ollama.ai/download)\n\n### 2. Pull Required Models\n\n```bash\n# Language models (choose one or more)\nollama pull qwen3              # Excellent general purpose, 7B parameters\nollama pull gemma3            # Google's model, good performance\nollama pull deepseek-r1       # Advanced reasoning model\nollama pull phi4              # Microsoft's efficient model\n\n# Embedding model (required for search)\nollama pull mxbai-embed-large  # Best embedding model for Ollama\n```\n\n### 3. Configure Open Notebook\n\n**Via Settings UI (Recommended):**\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **Ollama**\n3. Enter the base URL (see [Network Configuration](#network-configuration-guide) below for correct URL)\n4. Click **Save**, then **Test Connection**\n5. Click **Discover Models** → **Register Models**\n\n**Legacy (Deprecated) — Environment variables:**\n```bash\n# For local installation:\nexport OLLAMA_API_BASE=http://localhost:11434\n# For Docker installation:\nexport OLLAMA_API_BASE=http://host.docker.internal:11434\n```\n\n> **Note**: The `OLLAMA_API_BASE` environment variable is deprecated. Configure Ollama via Settings → API Keys instead.\n\n## Network Configuration Guide\n\nWhen adding an Ollama credential in **Settings → API Keys**, you need to enter the correct base URL. The correct URL depends on your deployment scenario:\n\n### Scenario 1: Local Installation (Same Machine)\n\nWhen both Open Notebook and Ollama run directly on your machine:\n\n**Base URL to enter in Settings → API Keys:** `http://localhost:11434`\n\nAlternative: `http://127.0.0.1:11434` (use if you have DNS resolution issues with localhost)\n\n### Scenario 2: Open Notebook in Docker, Ollama on Host\n\nWhen Open Notebook runs in Docker but Ollama runs on your host machine:\n\n**Base URL to enter in Settings → API Keys:** `http://host.docker.internal:11434`\n\n**⚠️ CRITICAL: Ollama must accept external connections:**\n```bash\n# Start Ollama with external access enabled\nexport OLLAMA_HOST=0.0.0.0:11434\nollama serve\n```\n\n**⚠️ LINUX USERS: Extra configuration required!**\n\nOn Linux, `host.docker.internal` doesn't resolve automatically like it does on macOS/Windows. You must add `extra_hosts` to your docker-compose.yml:\n\n```yaml\nservices:\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    # ... other settings ...\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n```\n\nWithout this, you'll get connection errors like:\n```\nhttpcore.ConnectError: [Errno -2] Name or service not known\n```\n\n**Why `host.docker.internal`?**\n- Docker containers can't reach `localhost` on the host\n- `host.docker.internal` is Docker's special hostname for the host machine\n- Available on Docker Desktop for Mac/Windows; **requires `extra_hosts` on Linux**\n\n**Why `OLLAMA_HOST=0.0.0.0:11434`?**\n- By default, Ollama only binds to localhost and rejects external connections\n- Docker containers are considered \"external\" even when running on the same machine\n- Setting `OLLAMA_HOST=0.0.0.0:11434` allows connections from Docker containers\n\n### Scenario 3: Both in Docker (Same Compose)\n\nWhen both Open Notebook and Ollama run in the same Docker Compose stack:\n\n**Base URL to enter in Settings → API Keys:** `http://ollama:11434`\n\n**Docker Compose Example:**\n\n```yaml\nversion: '3.8'\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n    volumes:\n      - ./notebook_data:/app/data\n      - ./surreal_data:/mydata\n    depends_on:\n      - ollama\n\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ollama_data:/root/.ollama\n    # Optional: GPU support\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: 1\n              capabilities: [gpu]\n\nvolumes:\n  ollama_data:\n```\n\n### Scenario 4: Remote Ollama Server\n\nWhen Ollama runs on a different machine in your network:\n\n**Base URL to enter in Settings → API Keys:** `http://192.168.1.100:11434` (replace with your Ollama server's IP)\n\n**Security Note:** Only use this in trusted networks. Ollama doesn't have built-in authentication.\n\n### Scenario 5: Ollama with Custom Port\n\nIf you've configured Ollama to use a different port:\n\n```bash\n# Start Ollama on custom port\nOLLAMA_HOST=0.0.0.0:8080 ollama serve\n```\n\n**Base URL to enter in Settings → API Keys:** `http://localhost:8080`\n\n## Model Recommendations\n\n### Language Models\n\n| Model | Size | Best For | Quality | Speed |\n|-------|------|----------|---------|-------|\n| **qwen3** | 7B | General purpose, coding | Excellent | Fast |\n| **deepseek-r1** | 7B | Reasoning, problem-solving | Exceptional | Medium |\n| **gemma3** | 7B | Balanced performance | Very Good | Fast |\n| **phi4** | 14B | Efficiency on small hardware | Good | Very Fast |\n| **llama3** | 8B | General purpose | Very Good | Medium |\n\n### Embedding Models\n\n| Model | Best For | Performance |\n|-------|----------|-------------|\n| **mxbai-embed-large** | General search | Excellent |\n| **nomic-embed-text** | Document similarity | Good |\n| **all-minilm** | Lightweight option | Fair |\n\n### Installation Commands\n\n```bash\n# Essential models\nollama pull qwen3                 # Primary language model\nollama pull mxbai-embed-large     # Search embeddings\n\n# Optional reasoning model\nollama pull deepseek-r1           # Advanced reasoning\n\n# Alternative language models\nollama pull gemma3                # Google's model\nollama pull phi4                  # Microsoft's efficient model\n```\n\n## Hardware Requirements\n\n### Minimum Requirements\n- **RAM**: 8GB (for 7B models)\n- **Storage**: 10GB free space per model\n- **CPU**: Modern multi-core processor\n\n### Recommended Setup\n- **RAM**: 16GB+ (for multiple models)\n- **Storage**: SSD with 50GB+ free space\n- **GPU**: NVIDIA GPU with 8GB+ VRAM (optional but faster)\n\n### GPU Acceleration\n\n**NVIDIA GPU (CUDA):**\n```bash\n# Install NVIDIA Container Toolkit for Docker\n# Then use the Docker Compose example above with GPU support\n\n# For local installation, Ollama auto-detects CUDA\nollama pull qwen3\n```\n\n**Apple Silicon (M1/M2/M3):**\n```bash\n# Ollama automatically uses Metal acceleration\n# No additional setup required\nollama pull qwen3\n```\n\n**AMD GPUs:**\n```bash\n# ROCm support varies by model and system\n# Check Ollama documentation for latest compatibility\n```\n\n## Troubleshooting\n\n### Model Name Configuration (Critical)\n\n**⚠️ IMPORTANT: Model names must exactly match the output of `ollama list`**\n\nThis is the most common cause of \"Failed to send message\" errors. Open Notebook requires the **exact model name** as it appears in Ollama.\n\n**Step 1: Get the exact model name**\n```bash\nollama list\n```\n\nExample output:\n```\nNAME                        ID              SIZE      MODIFIED\nmxbai-embed-large:latest    468836162de7    669 MB    7 minutes ago\ngemma3:12b                  f4031aab637d    8.1 GB    2 months ago\nqwen3:32b                   030ee887880f    20 GB     9 days ago\n```\n\n**Step 2: Use the exact name when adding the model in Open Notebook**\n\n| ✅ Correct | ❌ Wrong |\n|-----------|----------|\n| `gemma3:12b` | `gemma3` (missing tag) |\n| `qwen3:32b` | `qwen3-32b` (wrong format) |\n| `mxbai-embed-large:latest` | `mxbai-embed-large` (missing tag) |\n\n**Note:** Some models use `:latest` as the default tag. If `ollama list` shows `model:latest`, you must use `model:latest` in Open Notebook, not just `model`.\n\n**Step 3: Configure in Open Notebook**\n\n1. Go to **Settings → Models**\n2. Click **Add Model**\n3. Enter the **exact name** from `ollama list`\n4. Select provider: `ollama`\n5. Select type: `language` (for chat) or `embedding` (for search)\n6. Save the model\n7. Set it as the default for the appropriate task (chat, transformation, etc.)\n\n### Common Issues\n\n**1. \"Ollama unavailable\" in Open Notebook**\n\n**Check Ollama is running:**\n```bash\ncurl http://localhost:11434/api/tags\n```\n\n**Verify credential is configured:**\nCheck **Settings → API Keys** for an Ollama credential with the correct base URL.\n\n**⚠️ IMPORTANT: Enable external connections (most common fix):**\n```bash\n# If Open Notebook runs in Docker or on a different machine,\n# Ollama must bind to all interfaces, not just localhost\nexport OLLAMA_HOST=0.0.0.0:11434\nollama serve\n```\n> **Why this is needed:** By default, Ollama only accepts connections from `localhost` (127.0.0.1). When Open Notebook runs in Docker or on a different machine, it can't reach Ollama unless you configure `OLLAMA_HOST=0.0.0.0:11434` to accept external connections.\n\n**Restart Ollama:**\n```bash\n# Linux/macOS\nsudo systemctl restart ollama\n# or\nollama serve\n\n# Windows\n# Restart from system tray or Services\n```\n\n**2. Docker networking issues**\n\n**From inside Open Notebook container, test Ollama:**\n```bash\n# Get into container\ndocker exec -it open-notebook bash\n\n# Test connection\ncurl http://host.docker.internal:11434/api/tags\n```\n\n**If this fails on Linux** with \"Name or service not known\", you need to add `extra_hosts` to your docker-compose.yml. See the [Docker-Specific Troubleshooting](#docker-specific-troubleshooting) section below.\n\n**3. Models not downloading**\n\n**Check disk space:**\n```bash\ndf -h\n```\n\n**Manual model pull:**\n```bash\nollama pull qwen3 --verbose\n```\n\n**Clear failed downloads:**\n```bash\nollama rm qwen3\nollama pull qwen3\n```\n\n**4. Slow performance**\n\n**Check model size vs available RAM:**\n```bash\nollama ps  # Show running models\nfree -h    # Check available memory\n```\n\n**Use smaller models:**\n```bash\nollama pull phi4         # Instead of larger models\nollama pull gemma3:2b   # 2B parameter variant\n```\n\n**5. Port conflicts**\n\n**Check what's using port 11434:**\n```bash\nlsof -i :11434\nnetstat -tulpn | grep 11434\n```\n\n**Use custom port:**\n```bash\nOLLAMA_HOST=0.0.0.0:8080 ollama serve\n```\nThen update the base URL in **Settings → API Keys** to `http://localhost:8080`\n\n**6. \"Failed to send message\" in Chat**\n\n**Symptom:** Chat shows \"Failed to send message\" toast notification. Logs may show:\n```\nError executing chat: Model is not a LanguageModel: None\n```\n\n**Causes (in order of likelihood):**\n\n1. **Model name mismatch**: The model name in Open Notebook doesn't exactly match `ollama list`\n2. **No default model configured**: You haven't set a default chat model in Settings → Models\n3. **Model was deleted**: You removed the model from Ollama but didn't update Open Notebook's defaults\n4. **Model record deleted**: The model was removed from Open Notebook but is still set as default\n\n**Solutions:**\n\n**Check 1: Verify model names match exactly**\n```bash\n# Get exact model names from Ollama\nollama list\n\n# Compare with what's configured in Open Notebook\n# Go to Settings → Models and verify the names match EXACTLY\n```\n\n**Check 2: Verify default models are set**\n1. Go to **Settings → Models**\n2. Scroll to **Default Models** section\n3. Ensure **Default Chat Model** has a value selected\n4. If empty, select an available language model\n\n**Check 3: Refresh after changes**\nIf you've added/removed models in Ollama:\n1. Refresh the Open Notebook page\n2. Go to Settings → Models\n3. Re-add any missing models with exact names from `ollama list`\n4. Re-select default models if needed\n\n**Check 4: Test the model directly**\n```bash\n# Verify Ollama can use the model\nollama run gemma3:12b \"Hello, world\"\n```\n\n### Docker-Specific Troubleshooting\n\n**1. Linux: `host.docker.internal` not resolving (Most Common)**\n\nIf you see `Name or service not known` errors on Linux, add `extra_hosts` to your docker-compose.yml:\n\n```yaml\nservices:\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    extra_hosts:\n      - \"host.docker.internal:host-gateway\"\n    environment:\n    # ... rest of your config\n```\n\nThen in **Settings → API Keys**, use base URL: `http://host.docker.internal:11434`\n\nThis maps `host.docker.internal` to your host machine's IP. macOS/Windows Docker Desktop does this automatically, but Linux requires explicit configuration.\n\n**2. Host networking on Linux (alternative):**\n```bash\n# Use host networking if host.docker.internal doesn't work\ndocker run --network host lfnovo/open_notebook:v1-latest-single\n```\nThen in **Settings → API Keys**, use base URL: `http://localhost:11434`\n\n**3. Custom bridge network:**\n```yaml\nversion: '3.8'\nnetworks:\n  ollama_network:\n    driver: bridge\n\nservices:\n  open-notebook:\n    networks:\n      - ollama_network\n    environment:\n  ollama:\n    networks:\n      - ollama_network\n```\n\nThen in **Settings → API Keys**, use base URL: `http://ollama:11434`\n\n**4. Firewall issues:**\n```bash\n# Allow Ollama port through firewall\nsudo ufw allow 11434\n# or\nsudo firewall-cmd --add-port=11434/tcp --permanent\n```\n\n## Performance Optimization\n\n### Model Management\n\n**List installed models:**\n```bash\nollama list\n```\n\n**Remove unused models:**\n```bash\nollama rm model_name\n```\n\n**Show running models:**\n```bash\nollama ps\n```\n\n**Preload models for faster startup:**\n```bash\n# Keep model in memory\ncurl http://localhost:11434/api/generate -d '{\n  \"model\": \"qwen3\",\n  \"prompt\": \"test\",\n  \"keep_alive\": -1\n}'\n```\n\n### System Optimization\n\n**Linux: Increase file limits:**\n```bash\necho \"* soft nofile 65536\" >> /etc/security/limits.conf\necho \"* hard nofile 65536\" >> /etc/security/limits.conf\n```\n\n**macOS: Increase memory limits:**\n```bash\n# Add to ~/.zshrc or ~/.bash_profile\nexport OLLAMA_MAX_LOADED_MODELS=2\nexport OLLAMA_NUM_PARALLEL=4\n```\n\n**Docker: Resource allocation:**\n```yaml\nservices:\n  ollama:\n    deploy:\n      resources:\n        limits:\n          memory: 8G\n          cpus: '4'\n```\n\n## Advanced Configuration\n\n### Environment Variables\n\n```bash\n# Ollama server configuration\nexport OLLAMA_HOST=0.0.0.0:11434      # Bind to all interfaces\nexport OLLAMA_KEEP_ALIVE=5m            # Keep models in memory\nexport OLLAMA_MAX_LOADED_MODELS=3      # Max concurrent models\nexport OLLAMA_MAX_QUEUE=512            # Request queue size\nexport OLLAMA_NUM_PARALLEL=4           # Parallel request handling\nexport OLLAMA_FLASH_ATTENTION=1        # Enable flash attention (if supported)\n\n# Open Notebook configuration (configure via Settings → API Keys instead)\n# OLLAMA_API_BASE=http://localhost:11434  # Deprecated — use Settings UI\n```\n\n### SSL Configuration (Self-Signed Certificates)\n\nIf you're running Ollama behind a reverse proxy with self-signed SSL certificates (e.g., Caddy, nginx with custom certs), you may encounter SSL verification errors:\n\n```\n[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate\n```\n\n**Solutions:**\n\n**Option 1: Use a custom CA bundle (recommended)**\n```bash\n# Point to your CA certificate file\nexport ESPERANTO_SSL_CA_BUNDLE=/path/to/your/ca-bundle.pem\n```\n\n**Option 2: Disable SSL verification (development only)**\n```bash\n# WARNING: Only use in trusted development environments\nexport ESPERANTO_SSL_VERIFY=false\n```\n\n**Docker Compose example with SSL configuration:**\n```yaml\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n      # Option 1: Custom CA bundle (if Ollama uses self-signed SSL)\n      - ESPERANTO_SSL_CA_BUNDLE=/certs/ca-bundle.pem\n      # Option 2: Disable verification (dev only)\n      # - ESPERANTO_SSL_VERIFY=false\n    volumes:\n      - /path/to/your/ca-bundle.pem:/certs/ca-bundle.pem:ro\n```\n\n> **Security Note:** Disabling SSL verification exposes you to man-in-the-middle attacks. Always prefer using a custom CA bundle in production environments.\n\n### Custom Model Imports\n\n**Import custom models:**\n```bash\n# Create Modelfile\ncat > Modelfile << EOF\nFROM qwen3\nPARAMETER temperature 0.7\nPARAMETER top_p 0.9\nSYSTEM \"You are a helpful research assistant.\"\nEOF\n\n# Create custom model\nollama create my-research-model -f Modelfile\n```\n\n**Use in Open Notebook:**\n1. Go to Models\n2. Add new model: `my-research-model`\n3. Set as default for specific tasks\n\n### Monitoring and Logging\n\n**Monitor Ollama logs:**\n```bash\n# Linux (systemd)\njournalctl -u ollama -f\n\n# Docker\ndocker logs -f ollama\n\n# Manual run with verbose logging\nOLLAMA_DEBUG=1 ollama serve\n```\n\n**Resource monitoring:**\n```bash\n# CPU and memory usage\nhtop\n\n# GPU usage (NVIDIA)\nnvidia-smi -l 1\n\n# Model-specific metrics\nollama ps\n```\n\n## Integration Examples\n\n### Python Script Integration\n\n```python\nimport requests\nimport os\n\n# Test Ollama connection\nollama_base = os.environ.get('OLLAMA_API_BASE', 'http://localhost:11434')\nresponse = requests.get(f'{ollama_base}/api/tags')\nprint(f\"Available models: {response.json()}\")\n\n# Generate text\npayload = {\n    \"model\": \"qwen3\",\n    \"prompt\": \"Explain quantum computing\",\n    \"stream\": False\n}\nresponse = requests.post(f'{ollama_base}/api/generate', json=payload)\nprint(response.json()['response'])\n```\n\n### Health Check Script\n\n```bash\n#!/bin/bash\n# ollama-health-check.sh\n\nOLLAMA_API_BASE=${OLLAMA_API_BASE:-\"http://localhost:11434\"}\n\necho \"Checking Ollama health...\"\nif curl -s \"${OLLAMA_API_BASE}/api/tags\" > /dev/null; then\n    echo \"✅ Ollama is running\"\n    echo \"Available models:\"\n    curl -s \"${OLLAMA_API_BASE}/api/tags\" | jq -r '.models[].name'\nelse\n    echo \"❌ Ollama is not accessible at ${OLLAMA_API_BASE}\"\n    exit 1\nfi\n```\n\n## Migration from Other Providers\n\n### Coming from OpenAI\n\n**Similar performance models:**\n- GPT-4 → `qwen3` or `deepseek-r1`\n- GPT-3.5 → `gemma3` or `phi4`\n- text-embedding-ada-002 → `mxbai-embed-large`\n\n**Cost comparison:**\n- OpenAI: $0.01-0.06 per 1K tokens\n- Ollama: $0 after hardware investment\n\n### Coming from Anthropic\n\n**Claude replacement suggestions:**\n- Claude 3.5 Sonnet → `deepseek-r1` (reasoning)\n- Claude 3 Haiku → `phi4` (speed)\n\n## Best Practices\n\n### Security\n\n1. **Network Security:**\n   - Run Ollama only on trusted networks\n   - Use firewall rules to limit access\n   - Consider VPN for remote access\n\n2. **Model Verification:**\n   - Only pull models from trusted sources\n   - Verify model checksums when possible\n\n3. **Resource Limits:**\n   - Set memory and CPU limits in production\n   - Monitor resource usage regularly\n\n### Performance\n\n1. **Model Selection:**\n   - Use appropriate model size for your hardware\n   - Smaller models for simple tasks\n   - Reasoning models only when needed\n\n2. **Resource Management:**\n   - Preload frequently used models\n   - Remove unused models regularly\n   - Monitor system resources\n\n3. **Network Optimization:**\n   - Use local networks for better latency\n   - Consider SSD storage for faster model loading\n\n## Getting Help\n\n**Community Resources:**\n- [Ollama GitHub](https://github.com/jmorganca/ollama) - Official repository\n- [Ollama Discord](https://discord.gg/ollama) - Community support\n- [Open Notebook Discord](https://discord.gg/37XJPXfz2w) - Integration help\n\n**Debugging Resources:**\n- Check Ollama logs for error messages\n- Test connection with curl commands\n- Verify environment variables\n- Monitor system resources\n\nThis comprehensive guide should help you successfully deploy and optimize Ollama with Open Notebook. Start with the Quick Start section and refer to specific scenarios as needed."
  },
  {
    "path": "docs/5-CONFIGURATION/openai-compatible.md",
    "content": "# OpenAI-Compatible Providers\n\nUse any server that implements the OpenAI API format with Open Notebook. This includes LM Studio, Text Generation WebUI, vLLM, and many others.\n\n---\n\n## What is OpenAI-Compatible?\n\nMany AI tools implement the same API format as OpenAI:\n\n```\nPOST /v1/chat/completions\nPOST /v1/embeddings\nPOST /v1/audio/speech\n```\n\nOpen Notebook can connect to any server using this format.\n\n---\n\n## Common Compatible Servers\n\n| Server | Use Case | URL |\n|--------|----------|-----|\n| **LM Studio** | Desktop GUI for local models | https://lmstudio.ai |\n| **Text Generation WebUI** | Full-featured local inference | https://github.com/oobabooga/text-generation-webui |\n| **vLLM** | High-performance serving | https://github.com/vllm-project/vllm |\n| **Ollama** | Simple local models | (Use native Ollama provider instead) |\n| **LocalAI** | Local AI inference | https://github.com/mudler/LocalAI |\n| **llama.cpp server** | Lightweight inference | https://github.com/ggerganov/llama.cpp |\n\n---\n\n## Quick Setup: LM Studio\n\n### Step 1: Install and Start LM Studio\n\n1. Download from https://lmstudio.ai\n2. Install and launch\n3. Download a model (e.g., Llama 3)\n4. Start the local server (default: port 1234)\n\n### Step 2: Configure in Settings UI (Recommended)\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **OpenAI-Compatible**\n3. Enter base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)\n4. API key: `lm-studio` (placeholder, LM Studio doesn't require one)\n5. Click **Save**, then **Test Connection**\n\n**Legacy (Deprecated) — Environment variables:**\n```bash\nexport OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1\nexport OPENAI_COMPATIBLE_API_KEY=not-needed\n```\n\n### Step 3: Add Model in Open Notebook\n\n1. Go to **Settings** → **Models**\n2. Click **Add Model**\n3. Configure:\n   - **Provider**: `openai_compatible`\n   - **Model Name**: Your model name from LM Studio\n   - **Display Name**: `LM Studio - Llama 3`\n4. Click **Save**\n\n---\n\n## Configuration via Settings UI\n\nThe recommended way to configure OpenAI-compatible providers is through the Settings UI:\n\n1. Go to **Settings** → **API Keys**\n2. Click **Add Credential** → Select **OpenAI-Compatible**\n3. Enter your base URL and API key (if needed)\n4. Optionally configure per-service URLs for LLM, Embedding, TTS, and STT\n5. Click **Save**, then **Test Connection**\n\n## Legacy: Environment Variables (Deprecated)\n\n> **Deprecated**: These environment variables are deprecated. Use the Settings UI instead.\n\n### Language Models (Chat)\n\n```bash\nOPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1\nOPENAI_COMPATIBLE_API_KEY=optional-api-key\n```\n\n### Embeddings\n\n```bash\nOPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:1234/v1\nOPENAI_COMPATIBLE_API_KEY_EMBEDDING=optional-api-key\n```\n\n### Text-to-Speech\n\n```bash\nOPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1\nOPENAI_COMPATIBLE_API_KEY_TTS=optional-api-key\n```\n\n### Speech-to-Text\n\n```bash\nOPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:9000/v1\nOPENAI_COMPATIBLE_API_KEY_STT=optional-api-key\n```\n\n---\n\n## Docker Networking\n\nWhen Open Notebook runs in Docker and your compatible server runs on the host, use the appropriate base URL when adding your credential in **Settings → API Keys**:\n\n### macOS / Windows\n\n**Base URL:** `http://host.docker.internal:1234/v1`\n\n### Linux\n\n**Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:1234/v1`\n\n**Option 2:** Use host networking mode: `docker run --network host ...`\nThen use base URL: `http://localhost:1234/v1`\n\n### Same Docker Network\n\n```yaml\n# docker-compose.yml\nservices:\n  open-notebook:\n    # ...\n\n  lm-studio:\n    # your LM Studio container\n    ports:\n      - \"1234:1234\"\n```\n\n**Base URL in Settings → API Keys:** `http://lm-studio:1234/v1`\n\n---\n\n## Text Generation WebUI Setup\n\n### Start with API Enabled\n\n```bash\npython server.py --api --listen\n```\n\n### Configure Open Notebook\n\nIn **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:5000/v1`\n\n### Docker Compose Example\n\n```yaml\nservices:\n  text-gen:\n    image: atinoda/text-generation-webui:default\n    ports:\n      - \"5000:5000\"\n      - \"7860:7860\"\n    volumes:\n      - ./models:/app/models\n    command: --api --listen\n\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    depends_on:\n      - text-gen\n```\n\nThen in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://text-gen:5000/v1`\n\n---\n\n## vLLM Setup\n\n### Start vLLM Server\n\n```bash\npython -m vllm.entrypoints.openai.api_server \\\n  --model meta-llama/Llama-3.1-8B-Instruct \\\n  --port 8000\n```\n\n### Configure Open Notebook\n\nIn **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:8000/v1`\n\n### Docker Compose with GPU\n\n```yaml\nservices:\n  vllm:\n    image: vllm/vllm-openai:latest\n    command: --model meta-llama/Llama-3.1-8B-Instruct\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ~/.cache/huggingface:/root/.cache/huggingface\n    deploy:\n      resources:\n        reservations:\n          devices:\n            - driver: nvidia\n              count: 1\n              capabilities: [gpu]\n\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    depends_on:\n      - vllm\n```\n\nThen in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://vllm:8000/v1`\n\n---\n\n## Adding Models in Open Notebook\n\n### Via Settings UI\n\n1. Go to **Settings** → **Models**\n2. Click **Add Model** in appropriate section\n3. Select **Provider**: `openai_compatible`\n4. Enter **Model Name**: exactly as the server expects\n5. Enter **Display Name**: your preferred name\n6. Click **Save**\n\n### Model Name Format\n\nThe model name must match what your server expects:\n\n| Server | Model Name Format |\n|--------|-------------------|\n| LM Studio | As shown in LM Studio UI |\n| vLLM | HuggingFace model path |\n| Text Gen WebUI | As loaded in UI |\n| llama.cpp | Model file name |\n\n---\n\n## Testing Connection\n\n### Test API Endpoint\n\n```bash\n# Test chat completions\ncurl http://localhost:1234/v1/chat/completions \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"your-model-name\",\n    \"messages\": [{\"role\": \"user\", \"content\": \"Hello\"}]\n  }'\n```\n\n### Test from Inside Docker\n\n```bash\ndocker exec -it open-notebook curl http://host.docker.internal:1234/v1/models\n```\n\n---\n\n## Troubleshooting\n\n### Connection Refused\n\n```\nProblem: Cannot connect to server\n\nSolutions:\n1. Verify server is running\n2. Check port is correct\n3. Test with curl directly\n4. Check Docker networking (use host.docker.internal)\n5. Verify firewall allows connection\n```\n\n### Model Not Found\n\n```\nProblem: Server returns \"model not found\"\n\nSolutions:\n1. Check model is loaded in server\n2. Verify exact model name spelling\n3. List available models: curl http://localhost:1234/v1/models\n4. Update model name in Open Notebook\n```\n\n### Slow Responses\n\n```\nProblem: Requests take very long\n\nSolutions:\n1. Check server resources (RAM, GPU)\n2. Use smaller/quantized model\n3. Reduce context length\n4. Enable GPU acceleration if available\n```\n\n### Authentication Errors\n\n```\nProblem: 401 or authentication failed\n\nSolutions:\n1. Check if server requires API key\n2. Set the API key in your credential (Settings → API Keys)\n3. Some servers need any non-empty key (use a placeholder like \"not-needed\")\n```\n\n### Timeout Errors\n\n```\nProblem: Request times out\n\nSolutions:\n1. Model may be loading (first request slow)\n2. Increase timeout settings\n3. Check server logs for errors\n4. Reduce request size\n```\n\n---\n\n## Multiple Compatible Endpoints\n\nYou can use different compatible servers for different purposes. When adding an **OpenAI-Compatible** credential in **Settings → API Keys**, you can configure per-service URLs:\n\n- **LLM URL**: e.g., `http://localhost:1234/v1` (LM Studio)\n- **Embedding URL**: e.g., `http://localhost:8080/v1` (different server)\n- **TTS URL**: e.g., `http://localhost:8969/v1` (Speaches)\n- **STT URL**: e.g., `http://localhost:9000/v1` (Speaches)\n\nAlternatively, add each as a separate credential with its own base URL.\n\n---\n\n## Performance Tips\n\n### Model Selection\n\n| Model Size | RAM Needed | Speed |\n|------------|------------|-------|\n| 7B | 8GB | Fast |\n| 13B | 16GB | Medium |\n| 70B | 64GB+ | Slow |\n\n### Quantization\n\nUse quantized models (Q4, Q5) for faster inference with less RAM:\n\n```\nllama-3-8b-q4_k_m.gguf  → ~4GB RAM, fast\nllama-3-8b-f16.gguf     → ~16GB RAM, slower\n```\n\n### GPU Acceleration\n\nEnable GPU in your server for much faster inference:\n- LM Studio: Settings → GPU layers\n- vLLM: Automatic with CUDA\n- llama.cpp: `--n-gpu-layers 35`\n\n---\n\n## Comparison: Native vs Compatible\n\n| Aspect | Native Provider | OpenAI Compatible |\n|--------|-----------------|-------------------|\n| **Setup** | API key only | Server + configuration |\n| **Models** | Provider's models | Any compatible model |\n| **Cost** | Pay per token | Free (local) |\n| **Speed** | Usually fast | Depends on hardware |\n| **Features** | Full support | Basic features |\n\nUse OpenAI-compatible when:\n- Running local models\n- Using custom/fine-tuned models\n- Privacy requirements\n- Cost control\n\n---\n\n## Related\n\n- **[Local TTS Setup](local-tts.md)** - Text-to-speech with Speaches\n- **[Local STT Setup](local-stt.md)** - Speech-to-text with Speaches\n- **[AI Providers](ai-providers.md)** - All provider options\n- **[Ollama Setup](ollama.md)** - Native Ollama integration\n"
  },
  {
    "path": "docs/5-CONFIGURATION/reverse-proxy.md",
    "content": "# Reverse Proxy Configuration\n\nDeploy Open Notebook behind nginx, Caddy, Traefik, or other reverse proxies with custom domains and HTTPS.\n\n---\n\n## Simplified Setup (v1.1+)\n\nStarting with v1.1, Open Notebook uses Next.js rewrites to simplify configuration. **You only need to proxy to one port** - Next.js handles internal API routing automatically.\n\n### How It Works\n\n```\nBrowser → Reverse Proxy → Port 8502 (Next.js)\n                             ↓ (internal proxy)\n                          Port 5055 (FastAPI)\n```\n\nNext.js automatically forwards `/api/*` requests to the FastAPI backend, so your reverse proxy only needs one port!\n\n---\n\n## Quick Configuration Examples\n\n### Nginx (Recommended)\n\n```nginx\nserver {\n    listen 443 ssl http2;\n    server_name notebook.example.com;\n\n    ssl_certificate /etc/nginx/ssl/fullchain.pem;\n    ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n\n    # Allow file uploads up to 100MB\n    client_max_body_size 100M;\n\n    # Single location block - that's it!\n    location / {\n        proxy_pass http://open-notebook:8502;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_cache_bypass $http_upgrade;\n    }\n}\n\n# HTTP to HTTPS redirect\nserver {\n    listen 80;\n    server_name notebook.example.com;\n    return 301 https://$server_name$request_uri;\n}\n```\n\n### Caddy\n\n```caddy\nnotebook.example.com {\n    reverse_proxy open-notebook:8502 {\n        transport http {\n            read_timeout 600s\n            write_timeout 600s\n        }\n    }\n}\n```\n\nCaddy handles HTTPS automatically. The timeout settings ensure long-running operations (transformations, podcast generation) don't fail.\n\n### Traefik\n\n```yaml\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    environment:\n      - API_URL=https://notebook.example.com\n    labels:\n      - \"traefik.enable=true\"\n      - \"traefik.http.routers.notebook.rule=Host(`notebook.example.com`)\"\n      - \"traefik.http.routers.notebook.entrypoints=websecure\"\n      - \"traefik.http.routers.notebook.tls.certresolver=myresolver\"\n      - \"traefik.http.services.notebook.loadbalancer.server.port=8502\"\n      # Timeout for long-running operations (transformations, podcasts)\n      - \"traefik.http.services.notebook.loadbalancer.responseforwarding.flushinterval=100ms\"\n    networks:\n      - traefik-network\n```\n\n**Note**: For Traefik v2+, you may also need to configure `serversTransport` timeouts in your static configuration:\n\n```yaml\n# traefik.yml (static configuration)\nserversTransport:\n  forwardingTimeouts:\n    dialTimeout: 30s\n    responseHeaderTimeout: 600s\n    idleConnTimeout: 90s\n```\n\n### Coolify\n\n1. Create new service with `lfnovo/open_notebook:v1-latest-single`\n2. Set port to **8502**\n3. Add environment: `API_URL=https://your-domain.com`\n4. Enable HTTPS in Coolify\n5. Done!\n\n---\n\n## Environment Variables\n\n```bash\n# Required for reverse proxy setups\nAPI_URL=https://your-domain.com\n\n# Optional: For multi-container deployments\n# INTERNAL_API_URL=http://api-service:5055\n```\n\n**Important**: Set `API_URL` to your public URL (with https://).\n\n**Note on HOSTNAME**: The Docker images set `HOSTNAME=0.0.0.0` by default, which ensures Next.js binds to all interfaces and is accessible from reverse proxies. You typically don't need to set this manually.\n\n---\n\n## Understanding API_URL\n\nThe frontend uses a three-tier priority system to determine the API URL:\n\n1. **Runtime Configuration** (Highest Priority): `API_URL` environment variable set at container runtime\n2. **Build-time Configuration**: `NEXT_PUBLIC_API_URL` baked into the Docker image\n3. **Auto-detection** (Fallback): Infers from the incoming HTTP request headers\n\n### Auto-Detection Details\n\nWhen `API_URL` is not set, the Next.js frontend:\n- Analyzes the incoming HTTP request\n- Extracts the hostname from the `host` header\n- Respects the `X-Forwarded-Proto` header (for HTTPS behind reverse proxies)\n- Constructs the API URL as `{protocol}://{hostname}:5055`\n- Example: Request to `http://10.20.30.20:8502` → API URL becomes `http://10.20.30.20:5055`\n\n**Why set API_URL explicitly?**\n- **Reliability**: Auto-detection can fail with complex proxy setups\n- **HTTPS**: Ensures frontend uses `https://` when behind SSL-terminating proxy\n- **Custom domains**: Works correctly with domain names instead of IP addresses\n- **Port mapping**: Avoids exposing port 5055 in the URL when using reverse proxy\n\n**Important**: Don't include `/api` at the end - the system adds this automatically!\n\n---\n\n## Complete Docker Compose Example\n\n```yaml\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    container_name: open-notebook\n    environment:\n      - API_URL=https://notebook.example.com\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}\n      - OPEN_NOTEBOOK_PASSWORD=${OPEN_NOTEBOOK_PASSWORD}\n    volumes:\n      - ./notebook_data:/app/data\n      - ./surreal_data:/mydata\n    # Only expose to localhost (nginx handles public access)\n    ports:\n      - \"127.0.0.1:8502:8502\"\n    restart: unless-stopped\n\n  nginx:\n    image: nginx:alpine\n    container_name: nginx-proxy\n    ports:\n      - \"80:80\"\n      - \"443:443\"\n    volumes:\n      - ./nginx.conf:/etc/nginx/nginx.conf:ro\n      - ./ssl:/etc/nginx/ssl:ro\n    depends_on:\n      - open-notebook\n    restart: unless-stopped\n```\n\n---\n\n## Full Nginx Configuration\n\n```nginx\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    upstream notebook {\n        server open-notebook:8502;\n    }\n\n    # HTTP redirect\n    server {\n        listen 80;\n        server_name notebook.example.com;\n        return 301 https://$server_name$request_uri;\n    }\n\n    # HTTPS server\n    server {\n        listen 443 ssl http2;\n        server_name notebook.example.com;\n\n        ssl_certificate /etc/nginx/ssl/fullchain.pem;\n        ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n        ssl_protocols TLSv1.2 TLSv1.3;\n        ssl_ciphers HIGH:!aNULL:!MD5;\n\n        # Allow file uploads up to 100MB\n        client_max_body_size 100M;\n\n        # Security headers\n        add_header X-Frame-Options DENY;\n        add_header X-Content-Type-Options nosniff;\n        add_header X-XSS-Protection \"1; mode=block\";\n        add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\";\n\n        # Proxy settings\n        location / {\n            proxy_pass http://notebook;\n            proxy_http_version 1.1;\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_cache_bypass $http_upgrade;\n\n            # Timeouts for long-running operations (transformations, podcasts, etc.)\n            # 600s matches the frontend timeout for slow LLM operations\n            proxy_read_timeout 600s;\n            proxy_connect_timeout 60s;\n            proxy_send_timeout 600s;\n        }\n    }\n}\n```\n\n---\n\n## Direct API Access (Optional)\n\nIf external scripts or integrations need direct API access, route `/api/*` directly:\n\n```nginx\n# Direct API access (for external integrations)\nlocation /api/ {\n    proxy_pass http://open-notebook:5055/api/;\n    proxy_http_version 1.1;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto $scheme;\n}\n\n# Frontend (handles all other traffic)\nlocation / {\n    proxy_pass http://open-notebook:8502;\n    # ... same headers as above\n}\n```\n\n**Note**: This is only needed for external API integrations. Browser traffic works fine with single-port setup.\n\n---\n\n## Advanced Scenarios\n\n### Remote Server Access (LAN/VPS)\n\nAccessing Open Notebook from a different machine on your network:\n\n**Step 1: Get your server IP**\n```bash\n# On the server running Open Notebook:\nhostname -I\n# or\nifconfig | grep \"inet \"\n# Note the IP (e.g., 192.168.1.100)\n```\n\n**Step 2: Configure API_URL**\n```bash\n# In docker-compose.yml or .env:\nAPI_URL=http://192.168.1.100:5055\n```\n\n**Step 3: Expose ports**\n```yaml\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    environment:\n      - API_URL=http://192.168.1.100:5055\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n```\n\n**Step 4: Access from client machine**\n```bash\n# In browser on other machine:\nhttp://192.168.1.100:8502\n```\n\n**Troubleshooting**:\n- Check firewall: `sudo ufw allow 8502 && sudo ufw allow 5055`\n- Verify connectivity: `ping 192.168.1.100` from client machine\n- Test port: `telnet 192.168.1.100 8502` from client machine\n\n---\n\n### API on Separate Subdomain\n\nHost the API and frontend on different subdomains:\n\n**docker-compose.yml:**\n```yaml\nservices:\n  open-notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    environment:\n      - API_URL=https://api.notebook.example.com\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}\n    # Don't expose ports (nginx handles routing)\n```\n\n**nginx.conf:**\n```nginx\n# Frontend server\nserver {\n    listen 443 ssl http2;\n    server_name notebook.example.com;\n\n    ssl_certificate /etc/nginx/ssl/fullchain.pem;\n    ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n\n    location / {\n        proxy_pass http://open-notebook:8502;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection 'upgrade';\n        proxy_cache_bypass $http_upgrade;\n    }\n}\n\n# API server (separate subdomain)\nserver {\n    listen 443 ssl http2;\n    server_name api.notebook.example.com;\n\n    ssl_certificate /etc/nginx/ssl/fullchain.pem;\n    ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n\n    location / {\n        proxy_pass http://open-notebook:5055;\n        proxy_http_version 1.1;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n    }\n}\n```\n\n**Use case**: Separate DNS records, different rate limiting, or isolated API access control.\n\n---\n\n### Multi-Container Deployment (Advanced)\n\nFor complex deployments with separate frontend and API containers:\n\n**docker-compose.yml:**\n```yaml\nservices:\n  frontend:\n    image: lfnovo/open_notebook_frontend:v1-latest\n    pull_policy: always\n    environment:\n      - API_URL=https://notebook.example.com\n    ports:\n      - \"8502:8502\"\n\n  api:\n    image: lfnovo/open_notebook_api:v1-latest\n    pull_policy: always\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}\n    ports:\n      - \"5055:5055\"\n    depends_on:\n      - surrealdb\n\n  surrealdb:\n    image: surrealdb/surrealdb:latest\n    command: start --log trace --user root --pass root file:/mydata/database.db\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n```\n\n**nginx.conf:**\n```nginx\nhttp {\n    upstream frontend {\n        server frontend:8502;\n    }\n\n    upstream api {\n        server api:5055;\n    }\n\n    server {\n        listen 443 ssl http2;\n        server_name notebook.example.com;\n\n        # API routes\n        location /api/ {\n            proxy_pass http://api/api/;\n            proxy_http_version 1.1;\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n        }\n\n        # Frontend (catch-all)\n        location / {\n            proxy_pass http://frontend;\n            proxy_http_version 1.1;\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_cache_bypass $http_upgrade;\n        }\n    }\n}\n```\n\n**Note**: Most users should use the single-container approach (`v1-latest-single`). Multi-container is only needed for custom scaling or isolation requirements.\n\n---\n\n## SSL Certificates\n\n### Let's Encrypt with Certbot\n\n```bash\n# Install certbot\nsudo apt install certbot python3-certbot-nginx\n\n# Get certificate\nsudo certbot --nginx -d notebook.example.com\n\n# Auto-renewal (usually configured automatically)\nsudo certbot renew --dry-run\n```\n\n### Let's Encrypt with Caddy\n\nCaddy handles SSL automatically - no configuration needed!\n\n### Self-Signed (Development Only)\n\n```bash\nopenssl req -x509 -nodes -days 365 -newkey rsa:2048 \\\n  -keyout ssl/privkey.pem \\\n  -out ssl/fullchain.pem \\\n  -subj \"/CN=localhost\"\n```\n\n---\n\n## Troubleshooting\n\n### \"Unable to connect to server\"\n\n1. **Check API_URL is set**:\n   ```bash\n   docker exec open-notebook env | grep API_URL\n   ```\n\n2. **Verify reverse proxy reaches container**:\n   ```bash\n   curl -I http://localhost:8502\n   ```\n\n3. **Check browser console** (F12):\n   - Look for connection errors\n   - Check what URL it's trying to reach\n\n### Mixed Content Errors\n\nFrontend using HTTPS but trying to reach HTTP API:\n\n```bash\n# Ensure API_URL uses https://\nAPI_URL=https://notebook.example.com  # Not http://\n```\n\n### WebSocket Issues\n\nEnsure your proxy supports WebSocket upgrades:\n\n```nginx\nproxy_http_version 1.1;\nproxy_set_header Upgrade $http_upgrade;\nproxy_set_header Connection 'upgrade';\n```\n\n### 502 Bad Gateway\n\n1. Check container is running: `docker ps`\n2. Check container logs: `docker logs open-notebook`\n3. Verify nginx can reach container (same network)\n\n### Timeout Errors\n\n**Symptoms:**\n- `socket hang up` or `ECONNRESET` errors\n- `Timeout after 30000ms` errors\n- Operations fail after exactly 30 seconds\n\n**Cause:** Your reverse proxy has a default timeout (often 30s) that's shorter than Open Notebook's operations.\n\n**Solutions by proxy:**\n\n**Nginx:**\n```nginx\nproxy_read_timeout 600s;\nproxy_send_timeout 600s;\n```\n\n**Caddy:**\n```caddy\nreverse_proxy open-notebook:8502 {\n    transport http {\n        read_timeout 600s\n        write_timeout 600s\n    }\n}\n```\n\n**Traefik (static config):**\n```yaml\nserversTransport:\n  forwardingTimeouts:\n    responseHeaderTimeout: 600s\n```\n\n**Application-level timeouts:**\n\nIf you still experience timeouts after configuring your proxy, you can also adjust the application timeouts:\n\n```bash\n# In .env file:\nAPI_CLIENT_TIMEOUT=600      # API client timeout (default: 300s)\nESPERANTO_LLM_TIMEOUT=180   # LLM inference timeout (default: 60s)\n```\n\nSee [Advanced Configuration](advanced.md) for more timeout options.\n\n---\n\n### How to Debug Configuration Issues\n\n**Step 1: Check browser console** (F12 → Console tab)\n```\nLook for messages starting with 🔧 [Config]\nThese show the configuration detection process\nYou'll see which API URL is being used\n```\n\n**Example good output:**\n```\n✅ [Config] Runtime API URL from server: https://your-domain.com\n```\n\n**Example bad output:**\n```\n❌ [Config] Failed to fetch runtime config\n⚠️  [Config] Using auto-detected URL: http://localhost:5055\n```\n\n**Step 2: Test API directly**\n```bash\n# Should return JSON config\ncurl https://your-domain.com/api/config\n\n# Expected output:\n{\"status\":\"ok\",\"credentials_configured\":true,...}\n```\n\n**Step 3: Check Docker logs**\n```bash\ndocker logs open-notebook\n\n# Look for:\n# - Frontend startup: \"▲ Next.js ready on http://0.0.0.0:8502\"\n# - API startup: \"INFO:     Uvicorn running on http://0.0.0.0:5055\"\n# - Connection errors or CORS issues\n```\n\n**Step 4: Verify environment variable**\n```bash\ndocker exec open-notebook env | grep API_URL\n\n# Should show:\n# API_URL=https://your-domain.com\n```\n\n---\n\n### Frontend Adds `:5055` to URL (Versions ≤ 1.0.10)\n\n**Symptoms** (only in older versions):\n- You set `API_URL=https://your-domain.com`\n- Browser console shows: \"Attempted URL: https://your-domain.com:5055/api/config\"\n- CORS errors with \"Status code: (null)\"\n\n**Root Cause:**\nIn versions ≤ 1.0.10, the frontend's config endpoint was at `/api/runtime-config`, which got intercepted by reverse proxies routing all `/api/*` requests to the backend. This prevented the frontend from reading the `API_URL` environment variable.\n\n**Solution:**\nUpgrade to version 1.0.11 or later. The config endpoint has been moved to `/config` which avoids the `/api/*` routing conflict.\n\n**Verification:**\nCheck browser console (F12) - should see: `✅ [Config] Runtime API URL from server: https://your-domain.com`\n\n**If you can't upgrade**, explicitly configure the `/config` route:\n```nginx\n# Only needed for versions ≤ 1.0.10\nlocation = /config {\n    proxy_pass http://open-notebook:8502;\n    proxy_http_version 1.1;\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n}\n```\n\n---\n\n### File Upload Errors (413 Payload Too Large)\n\n**Symptoms:**\n```\nCORS header 'Access-Control-Allow-Origin' missing. Status code: 413.\nError creating source. Please try again.\n```\n\n**Root Cause:**\nWhen uploading files, your reverse proxy may reject the request due to body size limits *before* it reaches the application. Since the error happens at the proxy level, CORS headers are not included in the response.\n\n**Version Requirement:**\n- **Open Notebook v1.3.2+** is required for file uploads >10MB\n- Uses Next.js 16+ which supports the `proxyClientMaxBodySize` configuration option\n- Check your version: Settings → About (bottom of settings page)\n\n**Solutions:**\n\n1. **Nginx - Increase body size limit**:\n   ```nginx\n   server {\n       # Allow larger file uploads (default is 1MB)\n       client_max_body_size 100M;\n\n       # Add CORS headers to error responses\n       error_page 413 = @cors_error_413;\n\n       location @cors_error_413 {\n           add_header 'Access-Control-Allow-Origin' '*' always;\n           add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;\n           add_header 'Access-Control-Allow-Headers' '*' always;\n           return 413 '{\"detail\": \"File too large. Maximum size is 100MB.\"}';\n       }\n\n       location / {\n           # ... your existing proxy configuration\n       }\n   }\n   ```\n\n2. **Traefik - Increase buffer size**:\n   ```yaml\n   # In your traefik configuration\n   http:\n     middlewares:\n       large-body:\n         buffering:\n           maxRequestBodyBytes: 104857600  # 100MB\n   ```\n\n   Apply middleware to your router:\n   ```yaml\n   labels:\n     - \"traefik.http.routers.notebook.middlewares=large-body\"\n   ```\n\n3. **Kubernetes Ingress (nginx-ingress)**:\n   ```yaml\n   apiVersion: networking.k8s.io/v1\n   kind: Ingress\n   metadata:\n     name: open-notebook\n     annotations:\n       nginx.ingress.kubernetes.io/proxy-body-size: \"100m\"\n       # Add CORS headers for error responses\n       nginx.ingress.kubernetes.io/configuration-snippet: |\n         more_set_headers \"Access-Control-Allow-Origin: *\";\n   ```\n\n4. **Caddy**:\n   ```caddy\n   notebook.example.com {\n       request_body {\n           max_size 100MB\n       }\n       reverse_proxy open-notebook:8502 {\n           transport http {\n               read_timeout 600s\n               write_timeout 600s\n           }\n       }\n   }\n   ```\n\n**Note:** Open Notebook's API includes CORS headers in error responses, but this only works for errors that reach the application. Proxy-level errors (like 413 from nginx) need to be configured at the proxy level.\n\n---\n\n### CORS Errors\n\n**Symptoms:**\n```\nAccess-Control-Allow-Origin header is missing\nCross-Origin Request Blocked\nResponse to preflight request doesn't pass access control check\n```\n\n**Possible Causes:**\n\n1. **Missing proxy headers**:\n   ```nginx\n   # Make sure these are set:\n   proxy_set_header X-Forwarded-Proto $scheme;\n   proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n   proxy_set_header Host $host;\n   ```\n\n2. **API_URL protocol mismatch**:\n   ```bash\n   # Frontend is HTTPS, but API_URL is HTTP:\n   API_URL=http://notebook.example.com  # ❌ Wrong\n   API_URL=https://notebook.example.com # ✅ Correct\n   ```\n\n3. **Reverse proxy not forwarding `/api/*` correctly**:\n   ```nginx\n   # Make sure this works:\n   location /api/ {\n       proxy_pass http://open-notebook:5055/api/;  # Note the trailing slash!\n   }\n   ```\n\n---\n\n### Missing Authorization Header\n\n**Symptoms:**\n```json\n{\"detail\": \"Missing authorization header\"}\n```\n\nThis happens when:\n- You have set `OPEN_NOTEBOOK_PASSWORD` for authentication\n- You're trying to access `/api/config` directly without logging in first\n\n**Solution:**\nThis is **expected behavior**! The frontend handles authentication automatically. Just:\n1. Access the frontend URL (not `/api/` directly)\n2. Log in through the UI\n3. The frontend will handle authorization headers for all API calls\n\n**For API integrations:** Include the password in the Authorization header:\n```bash\ncurl -H \"Authorization: Bearer your-password-here\" \\\n  https://your-domain.com/api/config\n```\n\n---\n\n### SSL/TLS Certificate Errors\n\n**Symptoms:**\n- Browser shows \"Your connection is not private\"\n- Certificate warnings\n- Mixed content errors\n\n**Solutions:**\n\n1. **Use Let's Encrypt** (recommended):\n   ```bash\n   sudo certbot --nginx -d notebook.example.com\n   ```\n\n2. **Check certificate paths** in nginx:\n   ```nginx\n   ssl_certificate /etc/nginx/ssl/fullchain.pem;      # Full chain\n   ssl_certificate_key /etc/nginx/ssl/privkey.pem;    # Private key\n   ```\n\n3. **Verify certificate is valid**:\n   ```bash\n   openssl x509 -in /etc/nginx/ssl/fullchain.pem -text -noout\n   ```\n\n4. **For development**, use self-signed (not for production):\n   ```bash\n   openssl req -x509 -nodes -days 365 -newkey rsa:2048 \\\n     -keyout ssl/privkey.pem -out ssl/fullchain.pem \\\n     -subj \"/CN=localhost\"\n   ```\n\n---\n\n## Best Practices\n\n1. **Always use HTTPS** in production\n2. **Set API_URL explicitly** when using reverse proxies to avoid auto-detection issues\n3. **Bind to localhost** (`127.0.0.1:8502`) and let proxy handle public access for security\n4. **Enable security headers** (HSTS, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)\n5. **Set up certificate renewal** for Let's Encrypt (usually automatic with certbot)\n6. **Keep ports 5055 and 8502 accessible** from your reverse proxy container (use Docker networks)\n7. **Use environment files** (`.env` or `docker.env`) to manage configuration securely\n8. **Test your configuration** before going live:\n   - Check browser console for config messages\n   - Test API: `curl https://your-domain.com/api/config`\n   - Verify authentication works\n   - Check long-running operations (podcast generation)\n9. **Monitor logs** regularly: `docker logs open-notebook`\n10. **Don't include `/api` in API_URL** - the system adds this automatically\n\n---\n\n## Legacy Configurations (Pre-v1.1)\n\nIf you're running Open Notebook **version 1.0.x or earlier**, you may need to use the legacy two-port configuration where you explicitly route `/api/*` to port 5055.\n\n**Check your version:**\n```bash\ndocker exec open-notebook cat /app/package.json | grep version\n```\n\n**If version < 1.1.0**, you may need:\n- Explicit `/api/*` routing to port 5055 in reverse proxy\n- Explicit `/config` endpoint routing for versions ≤ 1.0.10\n- See the \"Frontend Adds `:5055` to URL\" troubleshooting section above\n\n**Recommendation:** Upgrade to v1.1+ for simplified configuration and better performance.\n\n---\n\n## Related\n\n- **[Security Configuration](security.md)** - Password protection and hardening\n- **[Advanced Configuration](advanced.md)** - Ports, timeouts, and SSL settings\n- **[Troubleshooting](../6-TROUBLESHOOTING/connection-issues.md)** - Connection problems\n- **[Docker Deployment](../1-INSTALLATION/docker-compose.md)** - Complete deployment guide\n"
  },
  {
    "path": "docs/5-CONFIGURATION/security.md",
    "content": "# Security Configuration\n\nProtect your Open Notebook deployment with password authentication and production hardening.\n\n---\n\n## API Key Encryption\n\nOpen Notebook encrypts API keys stored in the database using Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256).\n\n### Configuration Methods\n\n| Method | Documentation |\n|--------|---------------|\n| **Settings UI** | [API Configuration Guide](../3-USER-GUIDE/api-configuration.md) |\n| **Environment Variables** | This page (below) |\n\n### Setup\n\nSet the encryption key to any secret string:\n\n```bash\n# .env or docker.env\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase\n```\n\nAny string works — it will be securely derived via SHA-256 internally. Use a strong passphrase for production deployments.\n\n### Default Credentials\n\n| Setting | Default | Security Level |\n|---------|---------|----------------|\n| Password | `open-notebook-change-me` | Development only |\n| Encryption Key | **None** (must be configured) | Required for API key storage |\n\n**The encryption key has no default.** You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` before using the API key configuration feature. Without it, encrypting/decrypting API keys will fail.\n\n### Docker Secrets Support\n\nBoth settings support Docker secrets via `_FILE` suffix:\n\n```yaml\nenvironment:\n  - OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password\n  - OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key\n```\n\n### Security Notes\n\n| Scenario | Behavior |\n|----------|----------|\n| Key configured | API keys encrypted with your key |\n| No key configured | Encryption/decryption will fail (key is required) |\n| Key changed | Old encrypted keys become unreadable |\n| Legacy data | Unencrypted keys still work (graceful fallback) |\n\n### Key Management\n\n- **Keep secret**: Never commit the encryption key to version control\n- **Backup securely**: Store the key separately from database backups\n- **No rotation yet**: Changing the key requires re-saving all API keys\n- **Per-deployment**: Each instance should have its own encryption key\n\n---\n\n## When to Use Password Protection\n\n### Use it for:\n- Public cloud deployments (PikaPods, Railway, DigitalOcean)\n- Shared network environments\n- Any deployment accessible beyond localhost\n\n### You can skip it for:\n- Local development on your machine\n- Private, isolated networks\n- Single-user local setups\n\n---\n\n## Quick Setup\n\n### Docker Deployment\n\n```yaml\n# docker-compose.yml\nservices:\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    environment:\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key\n      - OPEN_NOTEBOOK_PASSWORD=your_secure_password\n    # ... rest of config\n```\n\nOr using environment file:\n\n```bash\n# docker.env\nOPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key\nOPEN_NOTEBOOK_PASSWORD=your_secure_password\n```\n\n> **Important**: The encryption key is **required** for credential storage. Without it, you cannot save AI provider credentials via the Settings UI. If you change or lose the encryption key, all stored credentials become unreadable.\n\n### Development Setup\n\n```bash\n# .env\nOPEN_NOTEBOOK_PASSWORD=your_secure_password\n```\n\n---\n\n## Password Requirements\n\n### Good Passwords\n\n```bash\n# Strong: 20+ characters, mixed case, numbers, symbols\nOPEN_NOTEBOOK_PASSWORD=MySecure2024!Research#Tool\nOPEN_NOTEBOOK_PASSWORD=Notebook$Dev$2024$Strong!\n\n# Generated (recommended)\nOPEN_NOTEBOOK_PASSWORD=$(openssl rand -base64 24)\n```\n\n### Bad Passwords\n\n```bash\n# DON'T use these\nOPEN_NOTEBOOK_PASSWORD=password123\nOPEN_NOTEBOOK_PASSWORD=opennotebook\nOPEN_NOTEBOOK_PASSWORD=admin\n```\n\n---\n\n## How It Works\n\n### Frontend Protection\n\n1. Login form appears on first visit\n2. Password stored in browser session\n3. Session persists until browser closes\n4. Clear browser data to log out\n\n### API Protection\n\nAll API endpoints require authentication:\n\n```bash\n# Authenticated request\ncurl -H \"Authorization: Bearer your_password\" \\\n  http://localhost:5055/api/notebooks\n\n# Unauthenticated (will fail)\ncurl http://localhost:5055/api/notebooks\n# Returns: {\"detail\": \"Missing authorization header\"}\n```\n\n### Unprotected Endpoints\n\nThese work without authentication:\n\n- `/health` - System health check\n- `/docs` - API documentation\n- `/openapi.json` - OpenAPI spec\n\n---\n\n## API Authentication Examples\n\n### curl\n\n```bash\n# List notebooks\ncurl -H \"Authorization: Bearer your_password\" \\\n  http://localhost:5055/api/notebooks\n\n# Create notebook\ncurl -X POST \\\n  -H \"Authorization: Bearer your_password\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\": \"My Notebook\", \"description\": \"Research notes\"}' \\\n  http://localhost:5055/api/notebooks\n\n# Upload file\ncurl -X POST \\\n  -H \"Authorization: Bearer your_password\" \\\n  -F \"file=@document.pdf\" \\\n  http://localhost:5055/api/sources/upload\n```\n\n### Python\n\n```python\nimport requests\n\nclass OpenNotebookClient:\n    def __init__(self, base_url: str, password: str):\n        self.base_url = base_url\n        self.headers = {\"Authorization\": f\"Bearer {password}\"}\n\n    def get_notebooks(self):\n        response = requests.get(\n            f\"{self.base_url}/api/notebooks\",\n            headers=self.headers\n        )\n        return response.json()\n\n    def create_notebook(self, name: str, description: str = None):\n        response = requests.post(\n            f\"{self.base_url}/api/notebooks\",\n            headers=self.headers,\n            json={\"name\": name, \"description\": description}\n        )\n        return response.json()\n\n# Usage\nclient = OpenNotebookClient(\"http://localhost:5055\", \"your_password\")\nnotebooks = client.get_notebooks()\n```\n\n### JavaScript/TypeScript\n\n```javascript\nconst API_URL = 'http://localhost:5055';\nconst PASSWORD = 'your_password';\n\nasync function getNotebooks() {\n  const response = await fetch(`${API_URL}/api/notebooks`, {\n    headers: {\n      'Authorization': `Bearer ${PASSWORD}`\n    }\n  });\n  return response.json();\n}\n```\n\n---\n\n## Production Hardening\n\n### Docker Security\n\n```yaml\nservices:\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest-single\n    pull_policy: always\n    ports:\n      - \"127.0.0.1:8502:8502\"  # Bind to localhost only\n    environment:\n      - OPEN_NOTEBOOK_PASSWORD=your_secure_password\n    security_opt:\n      - no-new-privileges:true\n    deploy:\n      resources:\n        limits:\n          memory: 2G\n          cpus: \"1.0\"\n    restart: always\n```\n\n### Firewall Configuration\n\n```bash\n# UFW (Ubuntu)\nsudo ufw allow ssh\nsudo ufw allow 80/tcp\nsudo ufw allow 443/tcp\nsudo ufw deny 8502/tcp   # Block direct access\nsudo ufw deny 5055/tcp   # Block direct API access\nsudo ufw enable\n\n# iptables\niptables -A INPUT -p tcp --dport 22 -j ACCEPT\niptables -A INPUT -p tcp --dport 80 -j ACCEPT\niptables -A INPUT -p tcp --dport 443 -j ACCEPT\niptables -A INPUT -p tcp --dport 8502 -j DROP\niptables -A INPUT -p tcp --dport 5055 -j DROP\n```\n\n### Reverse Proxy with SSL\n\nSee [Reverse Proxy Configuration](reverse-proxy.md) for complete nginx/Caddy/Traefik setup with HTTPS.\n\n---\n\n## Security Limitations\n\nOpen Notebook's password protection provides **basic access control**, not enterprise-grade security:\n\n| Feature | Status |\n|---------|--------|\n| Password transmission | Plain text (use HTTPS!) |\n| Password storage | In memory |\n| User management | Single password for all |\n| Session timeout | None (until browser close) |\n| Rate limiting | None |\n| Audit logging | None |\n\n### Risk Mitigation\n\n1. **Always use HTTPS** - Encrypt traffic with TLS\n2. **Strong passwords** - 20+ characters, complex\n3. **Network security** - Firewall, VPN for sensitive deployments\n4. **Regular updates** - Keep containers and dependencies updated\n5. **Monitoring** - Check logs for suspicious activity\n6. **Backups** - Regular backups of data\n\n---\n\n## Enterprise Considerations\n\nFor deployments requiring advanced security:\n\n| Need | Solution |\n|------|----------|\n| SSO/OAuth | Implement OAuth2/SAML proxy |\n| Role-based access | Custom middleware |\n| Audit logging | Log aggregation service |\n| Rate limiting | API gateway or nginx |\n| Data encryption | Encrypt volumes at rest |\n| Network segmentation | Docker networks, VPC |\n\n---\n\n## Troubleshooting\n\n### Password Not Working\n\n```bash\n# Check env var is set\ndocker exec open-notebook env | grep OPEN_NOTEBOOK_PASSWORD\n\n# Check logs\ndocker logs open-notebook | grep -i auth\n\n# Test API directly\ncurl -H \"Authorization: Bearer your_password\" \\\n  http://localhost:5055/health\n```\n\n### 401 Unauthorized Errors\n\n```bash\n# Check header format\ncurl -v -H \"Authorization: Bearer your_password\" \\\n  http://localhost:5055/api/notebooks\n\n# Verify password matches\necho \"Password length: $(echo -n $OPEN_NOTEBOOK_PASSWORD | wc -c)\"\n```\n\n### Cannot Access After Setting Password\n\n1. Clear browser cache and cookies\n2. Try incognito/private mode\n3. Check browser console for errors\n4. Verify password is correct in environment\n\n### Security Testing\n\n```bash\n# Without password (should fail)\ncurl http://localhost:5055/api/notebooks\n# Expected: {\"detail\": \"Missing authorization header\"}\n\n# With correct password (should succeed)\ncurl -H \"Authorization: Bearer your_password\" \\\n  http://localhost:5055/api/notebooks\n\n# Health check (should work without password)\ncurl http://localhost:5055/health\n```\n\n---\n\n## Reporting Security Issues\n\nIf you discover security vulnerabilities:\n\n1. **Do NOT open public issues**\n2. Contact maintainers directly\n3. Provide detailed information\n4. Allow time for fixes before disclosure\n\n---\n\n## Related\n\n- **[Reverse Proxy](reverse-proxy.md)** - HTTPS and SSL setup\n- **[Advanced Configuration](advanced.md)** - Ports, timeouts, and SSL settings\n- **[Environment Reference](environment-reference.md)** - All configuration options\n"
  },
  {
    "path": "docs/6-TROUBLESHOOTING/ai-chat-issues.md",
    "content": "# AI & Chat Issues - Model Configuration & Quality\n\nProblems with AI models, chat, and response quality.\n\n> **Note:** Open Notebook now shows descriptive error messages for AI provider failures. Instead of a generic \"An unexpected error occurred\", you'll see specific messages like \"Authentication failed. Please check your API key\" or \"Rate limit exceeded. Please wait a moment and try again.\" These messages help you diagnose and fix issues faster.\n\n---\n\n## \"Failed to send message\" Error\n\n**Symptom:** Chat shows \"Failed to send message\" toast. Logs show:\n```\nError executing chat: Model is not a LanguageModel: None\n```\n\n**Cause:** No valid language model configured for chat\n\n**Solutions:**\n\n### Solution 1: Check Default Model Configuration\n```\n1. Go to Settings → Models\n2. Scroll to \"Default Models\" section\n3. Verify \"Default Chat Model\" has a model selected\n4. If empty, select an available language model\n5. Click Save\n```\n\n### Solution 2: Verify Model Names (Ollama Users)\n```bash\n# Get exact model names\nollama list\n\n# Example output:\n# NAME                   SIZE      MODIFIED\n# gemma3:12b            8.1 GB    2 months ago\n\n# The model name in Open Notebook must be EXACTLY \"gemma3:12b\"\n# NOT \"gemma3\" or \"gemma3-12b\"\n```\n\n### Solution 3: Re-add Missing Models\n```\n1. Note the exact model names from your provider\n2. Go to Settings → Models\n3. Delete any misconfigured models\n4. Add models with exact names\n5. Set new defaults\n```\n\n### Solution 4: Check Model Still Exists\n```bash\n# For Ollama: verify model is installed\nollama list\n\n# For cloud providers: verify API key is valid\n# and you have access to the model\n```\n\n> **Tip:** This error often occurs when you delete a model from Ollama but forget to update the default models in Open Notebook. Always re-configure defaults after removing models.\n\n---\n\n## \"Models not available\" or \"Models not showing\"\n\n**Symptom:** Settings → Models shows empty, or \"No models configured\"\n\n**Cause:** No credential configured, or credential has invalid API key\n\n**Solutions:**\n\n### Solution 1: Add Credential via Settings UI\n```\n1. Go to Settings → API Keys\n2. Click \"Add Credential\"\n3. Select your provider (e.g., OpenAI, Anthropic, Google)\n4. Enter your API key\n5. Click Save, then Test Connection\n6. Click Discover Models → Register Models\n7. Go to Settings → Models to verify\n```\n\n### Solution 2: Check Key is Valid\n```\n1. Go to Settings → API Keys\n2. Click \"Test Connection\" on your credential\n3. If it shows \"Invalid API key\":\n   - Get a fresh key from the provider's website\n   - Delete the credential and create a new one\n```\n\n### Solution 3: Switch Provider\n```\n1. Go to Settings → API Keys\n2. Add a credential for a different provider\n3. Test Connection → Discover Models → Register Models\n4. Go to Settings → Models to select the new provider's models\n```\n\n---\n\n## \"Invalid API key\" or \"Unauthorized\"\n\n**Symptom:** Error when trying to chat: \"Invalid API key\"\n\n**Cause:** Credential has wrong, expired, or revoked API key\n\n**Solutions:**\n\n### Step 1: Test Your Credential\n```\n1. Go to Settings → API Keys\n2. Click \"Test Connection\" on your credential\n3. If it fails, proceed to Step 2\n```\n\n### Step 2: Get Fresh Key\n```\nGo to provider's dashboard:\n- OpenAI: https://platform.openai.com/api-keys (starts with sk-proj-)\n- Anthropic: https://console.anthropic.com/ (starts with sk-ant-)\n- Google: https://aistudio.google.com/app/apikey (starts with AIzaSy)\n\nGenerate new key and copy exactly (no extra spaces)\n```\n\n### Step 3: Update Credential\n```\n1. Go to Settings → API Keys\n2. Delete the old credential\n3. Click \"Add Credential\" → select provider\n4. Paste the new key\n5. Click Save, then Test Connection\n6. Re-discover and register models if needed\n```\n\n### Step 4: Verify in UI\n```\n1. Go to Settings → Models\n2. Verify models are available\n3. Try a test chat\n```\n\n---\n\n## Chat Returns Generic/Bad Responses\n\n**Symptom:** AI responses are shallow, generic, or wrong\n\n**Cause:** Bad context, vague question, or wrong model\n\n**Solutions:**\n\n### Solution 1: Check Context\n```\n1. In Chat, click \"Select Sources\"\n2. Verify sources you want are CHECKED\n3. Set them to \"Full Content\" (not \"Summary Only\")\n4. Click \"Save\"\n5. Try chat again\n```\n\n### Solution 2: Ask Better Question\n```\nBad:     \"What do you think?\"\nGood:    \"Based on the paper's methodology, what are 3 limitations?\"\n\nBad:     \"Tell me about X\"\nGood:    \"Summarize X in 3 bullet points with page citations\"\n```\n\n### Solution 3: Use Stronger Model\n```\nOpenAI:\n  Current: gpt-4o-mini → Switch to: gpt-4o\n\nAnthropic:\n  Current: claude-3-5-haiku → Switch to: claude-3-5-sonnet\n\nTo change:\n1. Settings → Models\n2. Select model\n3. Try chat again\n```\n\n### Solution 4: Add More Sources\n```\nIf:  \"Response seems incomplete\"\nTry: Add more relevant sources to provide context\n```\n\n---\n\n## Chat is Very Slow\n\n**Symptom:** Chat responses take minutes\n\n**Cause:** Large context, slow model, or overloaded API\n\n**Solutions:**\n\n### Solution 1: Use Faster Model\n```bash\nFastest: Groq (any model)\nFast: OpenAI gpt-4o-mini\nMedium: Anthropic claude-3-5-haiku\nSlow: Anthropic claude-3-5-sonnet\n\nSwitch in: Settings → Models\n```\n\n### Solution 2: Reduce Context\n```\n1. Chat → Select Sources\n2. Uncheck sources you don't need\n3. Or switch to \"Summary Only\" for background sources\n4. Save and try again\n```\n\n### Solution 3: Increase Timeout\n```bash\n# In .env:\nAPI_CLIENT_TIMEOUT=600  # 10 minutes\n\n# Restart:\ndocker compose restart\n```\n\n### Solution 4: Check System Load\n```bash\n# See if API is overloaded:\ndocker stats\n\n# If CPU >80% or memory >90%:\n# Reduce: SURREAL_COMMANDS_MAX_TASKS=2\n# Restart: docker compose restart\n```\n\n---\n\n## Chat Doesn't Remember History\n\n**Symptom:** Each message treated as separate, no context between questions\n\n**Cause:** Chat history not saved or new chat started\n\n**Solution:**\n\n```\n1. Make sure you're in same Chat (not new Chat)\n2. Check Chat title at top\n3. If it's blank, start new Chat with a title\n4. Each named Chat keeps its history\n5. If you start new Chat, history is separate\n```\n\n---\n\n## \"Rate limit exceeded\"\n\n**Symptom:** Error: \"Rate limit exceeded\" or \"Too many requests\"\n\n**Cause:** Hit provider's API rate limit\n\n**Solutions:**\n\n### For Cloud Providers (OpenAI, Anthropic, etc.)\n\n**Immediate:**\n- Wait 1-2 minutes\n- Try again\n\n**Short term:**\n- Use cheaper/smaller model\n- Reduce concurrent operations\n- Space out requests\n\n**Long term:**\n- Upgrade your account\n- Switch to different provider\n- Use Ollama (local, no limits)\n\n### Check Account Status\n```\nOpenAI: https://platform.openai.com/account/usage/overview\nAnthropic: https://console.anthropic.com/account/billing/overview\nGoogle: Google Cloud Console\n```\n\n### For Ollama (Local)\n- No rate limits\n- Use `ollama pull mistral` for best model\n- Restart if hitting resource limits\n\n---\n\n## \"Context length exceeded\" or \"Token limit\"\n\n**Symptom:** Error about too many tokens\n\n**Cause:** Sources too large for model\n\n**Solutions:**\n\n### Solution 1: Use Model with Longer Context\n```\nCurrent: GPT-4o (128K tokens) → Switch to: Claude (200K tokens)\nCurrent: Claude Haiku (200K) → Switch to: Gemini (1M tokens)\n\nTo change: Settings → Models\n```\n\n### Solution 2: Reduce Context\n```\n1. Select fewer sources\n2. Or use \"Summary Only\" instead of \"Full Content\"\n3. Or split large documents into smaller pieces\n```\n\n### Solution 3: For Ollama (Local)\n```bash\n# Use smaller model:\nollama pull phi  # Very small\n# Instead of: ollama pull neural-chat  # Large\n```\n\n---\n\n## \"API call failed\" or Timeout\n\n**Symptom:** Generic API error, response times out\n\n**Cause:** Provider API down, network issue, or slow service\n\n**Solutions:**\n\n### Check Provider Status\n```\nOpenAI: https://status.openai.com/\nAnthropic: Check website\nGoogle: Google Cloud Status\nGroq: Check website\n```\n\n### Retry Operation\n```\n1. Wait 30 seconds\n2. Try again\n```\n\n### Use Different Model/Provider\n```\n1. Settings → Models\n2. Try different provider\n3. If OpenAI down, use Anthropic\n```\n\n### Check Network\n```bash\n# Verify internet working:\nping google.com\n\n# Test API endpoint directly:\ncurl https://api.openai.com/v1/models \\\n  -H \"Authorization: Bearer YOUR_KEY\"\n```\n\n---\n\n## Responses Include Hallucinations\n\n**Symptom:** AI makes up facts that aren't in sources\n\n**Cause:** Sources not in context, or model guessing\n\n**Solutions:**\n\n### Solution 1: Verify Context\n```\n1. Click citation in response\n2. Check source actually says that\n3. If not, sources weren't in context\n4. Add source to context and try again\n```\n\n### Solution 2: Request Citations\n```\nAsk: \"Answer this with citations to specific pages\"\n\nThe AI will be more careful if asked for citations\n```\n\n### Solution 3: Use Stronger Model\n```\nWeaker models hallucinate more\nSwitch to: GPT-4o or Claude Sonnet\n```\n\n---\n\n## High API Costs\n\n**Symptom:** API bills are higher than expected\n\n**Cause:** Using expensive model, large context, many requests\n\n**Solutions:**\n\n### Use Cheaper Model\n```\nExpensive: gpt-4o\nCheaper: gpt-4o-mini (10x cheaper)\n\nExpensive: Claude Sonnet\nCheaper: Claude Haiku (5x cheaper)\n\nGroq: Ultra cheap but fewer models\n```\n\n### Reduce Context\n```\nIn Chat:\n1. Select fewer sources\n2. Use \"Summary Only\" for background\n3. Ask more specific questions\n```\n\n### Switch to Ollama (Free)\n```bash\n# Install Ollama\n# Run: ollama serve\n# Download: ollama pull mistral\n# Set: OLLAMA_API_BASE=http://localhost:11434\n# Cost: Free!\n```\n\n---\n\n## Still Having Chat Issues?\n\n- Try [Quick Fixes](quick-fixes.md)\n- Try [Chat Effectively Guide](../3-USER-GUIDE/chat-effectively.md)\n- Check logs: `docker compose logs api | grep -i \"error\"`\n- Ask for help: [Troubleshooting Index](index.md#getting-help)\n"
  },
  {
    "path": "docs/6-TROUBLESHOOTING/connection-issues.md",
    "content": "# Connection Issues - Network & API Problems\n\nFrontend can't reach API or services won't communicate.\n\n---\n\n## \"Cannot connect to server\" (Most Common)\n\n**What it looks like:**\n- Browser shows error page\n- \"Unable to reach API\"\n- \"Cannot connect to server\"\n- UI loads but can't create notebooks\n\n**Diagnosis:**\n\n```bash\n# Check if API is running\ndocker ps | grep api\n# Should see \"api\" service running\n\n# Check if API is responding\ncurl http://localhost:5055/health\n# Should show: {\"status\":\"ok\"}\n\n# Check if frontend is running\ndocker ps | grep frontend\n# Should see \"frontend\" or React service running\n```\n\n**Solutions:**\n\n### Solution 1: API Not Running\n```bash\n# Start API\ndocker compose up api -d\n\n# Wait 5 seconds\nsleep 5\n\n# Verify it's running\ndocker compose logs api | tail -20\n```\n\n### Solution 2: Port Not Exposed\n```bash\n# Check docker-compose.yml has port mapping:\n# api:\n#   ports:\n#     - \"5055:5055\"\n\n# If missing, add it and restart:\ndocker compose down\ndocker compose up -d\n```\n\n### Solution 3: API_URL Mismatch\n```bash\n# In .env, check API_URL:\ncat .env | grep API_URL\n\n# Should match your frontend URL:\n# Frontend: http://localhost:8502\n# API_URL: http://localhost:5055\n\n# If wrong, fix it:\n# API_URL=http://localhost:5055\n# Then restart:\ndocker compose restart frontend\n```\n\n### Solution 4: Firewall Blocking\n```bash\n# Verify port 5055 is accessible\nnetstat -tlnp | grep 5055\n# Should show port listening\n\n# If on different machine, try:\n# Instead of localhost, use your IP:\nAPI_URL=http://192.168.1.100:5055\n```\n\n### Solution 5: Services Not Started\n```bash\n# Restart everything\ndocker compose restart\n\n# Wait 10 seconds\nsleep 10\n\n# Check all services\ndocker compose ps\n# All should show \"Up\"\n```\n\n---\n\n## Connection Refused\n\n**What it looks like:**\n```\nConnection refused\nECONNREFUSED\nError: socket hang up\n```\n\n**Diagnosis:**\n- API port (5055) not open\n- API crashed\n- Wrong IP/hostname\n\n**Solution:**\n\n```bash\n# Step 1: Check if API is running\ndocker ps | grep api\n\n# Step 2: Check if port is listening\nlsof -i :5055\n# or\nnetstat -tlnp | grep 5055\n\n# Step 3: Check API logs\ndocker compose logs api | tail -30\n# Look for errors\n\n# Step 4: Restart API\ndocker compose restart api\ndocker compose logs api | grep -i \"error\"\n```\n\n---\n\n## Timeout / Slow Connection\n\n**What it looks like:**\n- Page loads slowly\n- Request times out\n- \"Gateway timeout\" error\n\n**Causes:**\n- API is overloaded\n- Network is slow\n- Reverse proxy issue\n\n**Solutions:**\n\n### Check API Performance\n```bash\n# See CPU/memory usage\ndocker stats\n\n# Check logs for slow operations\ndocker compose logs api | grep \"slow\\|timeout\"\n```\n\n### Reduce Load\n```bash\n# In .env:\nSURREAL_COMMANDS_MAX_TASKS=2\nAPI_CLIENT_TIMEOUT=600\n\n# Restart\ndocker compose restart\n```\n\n### Check Network\n```bash\n# Test latency\nping localhost\n\n# Test API directly\ntime curl http://localhost:5055/health\n\n# Should be < 100ms\n```\n\n---\n\n## 502 Bad Gateway (Reverse Proxy)\n\n**What it looks like:**\n```\n502 Bad Gateway\nThe server is temporarily unable to service the request\n```\n\n**Cause:** Reverse proxy can't reach API\n\n**Solutions:**\n\n### Check Backend is Running\n```bash\n# From the reverse proxy server\ncurl http://localhost:5055/health\n\n# Should work\n```\n\n### Check Reverse Proxy Config\n```nginx\n# Nginx example (correct):\nlocation /api {\n    proxy_pass http://localhost:5055/api;\n    proxy_http_version 1.1;\n}\n\n# Common mistake (wrong):\nlocation /api {\n    proxy_pass http://localhost:5055;  # Missing /api\n}\n```\n\n### Set API_URL for HTTPS\n```bash\n# In .env:\nAPI_URL=https://yourdomain.com\n\n# Restart\ndocker compose restart\n```\n\n---\n\n## Intermittent Disconnects\n\n**What it looks like:**\n- Works sometimes, fails other times\n- Sporadic \"cannot connect\" errors\n- Works then stops working\n\n**Cause:** Transient network issue or database conflicts\n\n**Solutions:**\n\n### Enable Retry Logic\n```bash\n# In .env:\nSURREAL_COMMANDS_RETRY_ENABLED=true\nSURREAL_COMMANDS_RETRY_MAX_ATTEMPTS=5\nSURREAL_COMMANDS_RETRY_WAIT_STRATEGY=exponential_jitter\n\n# Restart\ndocker compose restart\n```\n\n### Reduce Concurrency\n```bash\n# In .env:\nSURREAL_COMMANDS_MAX_TASKS=2\n\n# Restart\ndocker compose restart\n```\n\n### Check Network Stability\n```bash\n# Monitor connection\nping google.com\n\n# Long-running test\nping -c 100 google.com | grep \"packet loss\"\n# Should be 0% loss\n```\n\n---\n\n## Different Machine / Remote Access\n\n**You want to access Open Notebook from another computer**\n\n**Solution:**\n\n### Step 1: Get Your Machine IP\n```bash\n# On the server running Open Notebook:\nifconfig | grep \"inet \"\n# or\nhostname -I\n# Note the IP (e.g., 192.168.1.100)\n```\n\n### Step 2: Update API_URL\n```bash\n# In .env:\nAPI_URL=http://192.168.1.100:5055\n\n# Restart\ndocker compose restart\n```\n\n### Step 3: Access from Other Machine\n```bash\n# In browser on other machine:\nhttp://192.168.1.100:8502\n# (or your server IP)\n```\n\n### Step 4: Verify Port is Exposed\n```bash\n# On server:\ndocker compose ps\n\n# Should show port mapping:\n# 0.0.0.0:8502->8502/tcp\n# 0.0.0.0:5055->5055/tcp\n```\n\n### If Still Doesn't Work\n```bash\n# Check firewall on server\nsudo ufw status\n# May need to open ports:\nsudo ufw allow 8502\nsudo ufw allow 5055\n\n# Check on different machine:\ntelnet 192.168.1.100 5055\n# Should connect\n```\n\n---\n\n## CORS Error (Browser Console)\n\n**What it looks like:**\n```\nCross-Origin Request Blocked\nAccess-Control-Allow-Origin\n```\n\n**In browser console (F12):**\n```\nCORS policy: Response to preflight request doesn't pass access control check\n```\n\n**Cause:** Frontend and API URLs don't match\n\n**Solution:**\n\n```bash\n# Check browser console error for what URLs are being used\n# The error shows:\n# - Requesting from: http://localhost:8502\n# - Trying to reach: http://localhost:5055\n\n# Make sure API_URL matches:\nAPI_URL=http://localhost:5055\n\n# And protocol matches (http/https)\n# Restart\ndocker compose restart frontend\n```\n\n---\n\n## Testing Connection\n\n**Full diagnostic:**\n\n```bash\n# 1. Services running?\ndocker compose ps\n# All should show \"Up\"\n\n# 2. Ports listening?\nnetstat -tlnp | grep -E \"8502|5055|8000\"\n\n# 3. API responding?\ncurl http://localhost:5055/health\n\n# 4. Frontend accessible?\ncurl http://localhost:8502 | head\n\n# 5. Network OK?\nping google.com\n\n# 6. No firewall?\nsudo ufw status | grep -E \"5055|8502|8000\"\n```\n\n---\n\n## Checklist for Remote Access\n\n- [ ] Server IP noted (e.g., 192.168.1.100)\n- [ ] Ports 8502, 5055, 8000 exposed in docker-compose\n- [ ] API_URL set to server IP\n- [ ] Firewall allows ports 8502, 5055, 8000\n- [ ] Can reach server from client machine (ping IP)\n- [ ] All services running (docker compose ps)\n- [ ] Can curl API from client (curl http://IP:5055/health)\n\n---\n\n## SSL Certificate Errors\n\n**What it looks like:**\n```\n[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed\nConnection error when using HTTPS endpoints\nWorks with HTTP but fails with HTTPS\n```\n\n**Cause:** Self-signed certificates not trusted by Python's SSL verification\n\n**Solutions:**\n\n### Solution 1: Use Custom CA Bundle (Recommended)\n```bash\n# In .env:\nESPERANTO_SSL_CA_BUNDLE=/path/to/your/ca-bundle.pem\n\n# For Docker, mount the certificate:\n# In docker-compose.yml:\nvolumes:\n  - /path/to/your/ca-bundle.pem:/certs/ca-bundle.pem:ro\nenvironment:\n  - ESPERANTO_SSL_CA_BUNDLE=/certs/ca-bundle.pem\n```\n\n### Solution 2: Disable SSL Verification (Development Only)\n```bash\n# WARNING: Only use in trusted development environments\n# In .env:\nESPERANTO_SSL_VERIFY=false\n```\n\n### Solution 3: Use HTTP Instead\nIf services are on a trusted local network, HTTP is acceptable:\n```\nChange the base URL in your credential (Settings → API Keys) from https:// to http://\nExample: http://localhost:1234/v1\n```\n\n> **Security Note:** Disabling SSL verification exposes you to man-in-the-middle attacks. Always prefer custom CA bundle or HTTP on trusted networks.\n\n---\n\n## Still Having Issues?\n\n- Check [Quick Fixes](quick-fixes.md)\n- Check [FAQ](faq.md)\n- Check logs: `docker compose logs`\n- Try restart: `docker compose restart`\n- Check firewall: `sudo ufw status`\n- Ask for help on [Discord](https://discord.gg/37XJPXfz2w)\n"
  },
  {
    "path": "docs/6-TROUBLESHOOTING/faq.md",
    "content": "# Frequently Asked Questions\n\nCommon questions about Open Notebook usage, configuration, and best practices.\n\n---\n\n## General Usage\n\n### What is Open Notebook?\n\nOpen Notebook is an open-source, privacy-focused alternative to Google's Notebook LM. It allows you to:\n- Create and manage research notebooks\n- Chat with your documents using AI\n- Generate podcasts from your content\n- Search across all your sources with semantic search\n- Transform and analyze your content\n\n### How is it different from Google Notebook LM?\n\n**Privacy**: Your data stays local by default. Only your chosen AI providers receive queries.\n**Flexibility**: Support for 15+ AI providers (OpenAI, Anthropic, Google, local models, etc.)\n**Customization**: Open source, so you can modify and extend functionality\n**Control**: You control your data, models, and processing\n\n### Can I use Open Notebook offline?\n\n**Partially**: The application runs locally, but requires internet for:\n- AI model API calls (unless using local models like Ollama)\n- Web content scraping\n\n**Fully offline**: Possible with local models (Ollama) for basic functionality.\n\n### What file types are supported?\n\n**Documents**: PDF, DOCX, TXT, Markdown\n**Web Content**: URLs, YouTube videos\n**Media**: MP3, WAV, M4A (audio), MP4, AVI, MOV (video)\n**Other**: Direct text input, CSV, code files\n\n### How much does it cost?\n\n**Software**: Free (open source)\n**AI API costs**: Pay-per-use to providers:\n- OpenAI: ~$0.50-5 per 1M tokens\n- Anthropic: ~$3-75 per 1M tokens\n- Google: Often free tier available\n- Local models: Free after initial setup\n\n**Typical monthly costs**: $5-50 for moderate usage.\n\n---\n\n## AI Models and Providers\n\n### Which AI provider should I choose?\n\n**For beginners**: OpenAI (reliable, well-documented)\n**For privacy**: Local models (Ollama) or European providers (Mistral)\n**For cost optimization**: Groq, Google (free tier), or OpenRouter\n**For long context**: Anthropic (200K tokens) or Google Gemini (1M tokens)\n\n### Can I use multiple providers?\n\n**Yes**: Configure different providers for different tasks:\n- OpenAI for chat\n- Google for embeddings\n- ElevenLabs for text-to-speech\n- Anthropic for complex reasoning\n\n### What are the best model combinations?\n\n**Budget-friendly**:\n- Language: `gpt-4o-mini` (OpenAI) or `deepseek-chat`\n- Embedding: `text-embedding-3-small` (OpenAI)\n\n**High-quality**:\n- Language: `claude-3-5-sonnet` (Anthropic) or `gpt-4o` (OpenAI)\n- Embedding: `text-embedding-3-large` (OpenAI)\n\n**Privacy-focused**:\n- Language: Local Ollama models (mistral, llama3)\n- Embedding: Local embedding models\n\n### How do I optimize AI costs?\n\n**Model selection**:\n- Use smaller models for simple tasks (gpt-4o-mini, claude-3-5-haiku)\n- Use larger models only for complex reasoning\n- Leverage free tiers when available\n\n**Usage optimization**:\n- Use \"Summary Only\" context for background sources\n- Ask more specific questions\n- Use local models (Ollama) for frequent tasks\n\n---\n\n## Data Management\n\n### Where is my data stored?\n\n**Local storage**: By default, all data is stored locally:\n- Database: SurrealDB files in `surreal_data/`\n- Uploads: Files in `data/uploads/`\n- Podcasts: Generated audio in `data/podcasts/`\n- No external data transmission (except to chosen AI providers)\n\n### How do I backup my data?\n\n```bash\n# Create backup\ntar -czf backup-$(date +%Y%m%d).tar.gz data/ surreal_data/\n\n# Restore backup\ntar -xzf backup-20240101.tar.gz\n```\n\n### Can I sync data between devices?\n\n**Currently**: No built-in sync functionality.\n**Workarounds**:\n- Use shared network storage for data directories\n- Manual backup/restore between devices\n\n### What happens if I delete a notebook?\n\n**Soft deletion**: Notebooks are marked as archived, not permanently deleted.\n**Recovery**: Archived notebooks can be restored from the database.\n\n---\n\n## Best Practices\n\n### How should I organize my notebooks?\n\n- **By topic**: Separate notebooks for different research areas\n- **By project**: One notebook per project or course\n- **By time period**: Monthly or quarterly notebooks\n\n**Recommended size**: 20-100 sources per notebook for best performance.\n\n### How do I get the best search results?\n\n- Use descriptive queries (\"data analysis methods\" not just \"data\")\n- Combine multiple related terms\n- Use natural language (ask questions as you would to a human)\n- Try both text search (keywords) and vector search (concepts)\n\n### How can I improve chat responses?\n\n- Provide context: Reference specific sources or topics\n- Be specific: Ask detailed questions rather than general ones\n- Request citations: \"Answer with page citations\"\n- Use follow-up questions: Build on previous responses\n\n### What are the security best practices?\n\n- Never share API keys publicly\n- Use `OPEN_NOTEBOOK_PASSWORD` for public deployments\n- Use HTTPS for production (via reverse proxy)\n- Keep Docker images updated\n- Encrypt backups if they contain sensitive data\n\n---\n\n## Technical Questions\n\n### Can I use Open Notebook programmatically?\n\n**Yes**: Open Notebook provides a REST API:\n- Full API documentation at `http://localhost:5055/docs`\n- Support for all UI functionality\n- Authentication via password header\n\n### Can I run Open Notebook in production?\n\n**Yes**: Designed for production use with:\n- Docker deployment\n- Security features (password protection)\n- Monitoring and logging\n- Reverse proxy support (nginx, Caddy, Traefik)\n\n### What are the system requirements?\n\n**Minimum**:\n- 4GB RAM\n- 2 CPU cores\n- 10GB disk space\n\n**Recommended**:\n- 8GB+ RAM\n- 4+ CPU cores\n- SSD storage\n- For local models: 16GB+ RAM, GPU recommended\n\n---\n\n## Timeout and Performance\n\n### Why do I get timeout errors?\n\n**Common causes**:\n- Large context (too many sources)\n- Slow AI provider\n- Local models on CPU (slow)\n- First request (model loading)\n\n**Solutions**:\n```bash\n# In .env:\nAPI_CLIENT_TIMEOUT=600  # 10 minutes for slow setups\nESPERANTO_LLM_TIMEOUT=180  # 3 minutes for model inference\n```\n\n### Recommended timeouts by setup:\n\n| Setup | API_CLIENT_TIMEOUT |\n|-------|-------------------|\n| Cloud APIs (OpenAI, Anthropic) | 300 (default) |\n| Local Ollama with GPU | 600 |\n| Local Ollama with CPU | 1200 |\n| Remote LM Studio | 900 |\n\n---\n\n## Getting Help\n\n### My question isn't answered here\n\n1. Check the troubleshooting guides in this section\n2. Search existing GitHub issues\n3. Ask in the Discord community\n4. Create a GitHub issue with detailed information\n\n### How do I report a bug?\n\nInclude:\n- Steps to reproduce\n- Expected vs actual behavior\n- Error messages and logs\n- System information\n- Configuration details (without API keys)\n\nSubmit to: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n\n### Where can I get help?\n\n- **Discord**: https://discord.gg/37XJPXfz2w (fastest)\n- **GitHub Issues**: Bug reports and feature requests\n- **Documentation**: This docs site\n\n---\n\n## Related\n\n- [Quick Fixes](quick-fixes.md) - Common issues with 1-minute solutions\n- [AI & Chat Issues](ai-chat-issues.md) - Model and chat problems\n- [Connection Issues](connection-issues.md) - Network and API problems\n"
  },
  {
    "path": "docs/6-TROUBLESHOOTING/index.md",
    "content": "# Troubleshooting - Problem Solving Guide\n\nHaving issues? Use this guide to diagnose and fix problems.\n\n---\n\n## How to Use This Guide\n\n**Step 1: Identify your problem**\n- What's the symptom? (error message, behavior, something not working?)\n- When did it happen? (during install, while using, after update?)\n\n**Step 2: Find the right guide**\n- Look below for your symptom\n- Go to the specific troubleshooting guide\n\n**Step 3: Follow the steps**\n- Guides are organized by symptom, not by root cause\n- Each has diagnostic steps and solutions\n\n---\n\n## Quick Problem Map\n\n### During Installation\n\n- **Docker won't start** → [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error)\n- **Port already in use** → [Quick Fixes](quick-fixes.md#3-port-x-already-in-use)\n- **Permission denied** → [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error)\n- **Can't connect to database** → [Connection Issues](connection-issues.md)\n\n### When Starting\n\n- **API won't start** → [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error)\n- **Frontend won't load** → [Connection Issues](connection-issues.md)\n- **\"Cannot connect to server\" error** → [Connection Issues](connection-issues.md)\n\n### Settings / Configuration\n\n- **Models not showing** → [AI & Chat Issues](ai-chat-issues.md)\n- **\"Invalid API key\"** → [AI & Chat Issues](ai-chat-issues.md)\n- **Can't find Settings** → [Quick Fixes](quick-fixes.md)\n\n### Using Features\n\n- **Chat not working** → [AI & Chat Issues](ai-chat-issues.md)\n- **Chat responses are slow** → [AI & Chat Issues](ai-chat-issues.md)\n- **Chat gives bad answers** → [AI & Chat Issues](ai-chat-issues.md)\n\n### Adding Content\n\n- **Can't upload PDF** → [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n- **File won't process** → [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n- **Web link won't extract** → [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n\n### Search\n\n- **Search returns no results** → [Quick Fixes](quick-fixes.md#7-search-returns-nothing)\n- **Search returns wrong results** → [Quick Fixes](quick-fixes.md#7-search-returns-nothing)\n\n### Podcasts\n\n- **Can't generate podcast** → [Quick Fixes](quick-fixes.md#8-podcast-generation-failed)\n- **Podcast shows \"FAILED\" badge** → Check the error message displayed on the episode, then use the **Retry** button. See [Podcasts Explained](../2-CORE-CONCEPTS/podcasts-explained.md#when-things-go-wrong-failures--retry)\n- **Podcast audio is robotic** → [Quick Fixes](quick-fixes.md#8-podcast-generation-failed)\n- **Podcast generation times out** → [Quick Fixes](quick-fixes.md#8-podcast-generation-failed)\n\n---\n\n## Troubleshooting by Error Message\n\n### \"Cannot connect to server\"\n→ [Connection Issues](connection-issues.md) — Frontend can't reach API\n\n### \"Invalid API key\"\n→ [AI & Chat Issues](ai-chat-issues.md) — Wrong or missing API key\n\n### \"Models not available\"\n→ [AI & Chat Issues](ai-chat-issues.md) — Model not configured\n\n### \"Connection refused\"\n→ [Connection Issues](connection-issues.md) — Service not running or port wrong\n\n### \"Port already in use\"\n→ [Quick Fixes](quick-fixes.md#3-port-x-already-in-use) — Port conflict\n\n### \"Permission denied\"\n→ [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error) — File permissions issue\n\n### \"Unsupported file type\"\n→ [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format) — File format not supported\n\n### \"Processing timeout\"\n→ [Quick Fixes](quick-fixes.md#5-chat-is-very-slow) — File too large or slow processing\n\n---\n\n## Troubleshooting by Component\n\n### Frontend (Browser/UI)\n- Can't access UI → [Connection Issues](connection-issues.md)\n- UI is slow → [Quick Fixes](quick-fixes.md)\n- Button/feature missing → [Quick Fixes](quick-fixes.md)\n\n### API (Backend)\n- API won't start → [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error)\n- API errors in logs → [Quick Fixes](quick-fixes.md#9-services-wont-start-or-docker-error)\n- API is slow → [Quick Fixes](quick-fixes.md)\n\n### Database\n- Can't connect to database → [Connection Issues](connection-issues.md)\n- Data lost after restart → [FAQ](faq.md#how-do-i-backup-my-data)\n\n### AI / Chat\n- Chat not working → [AI & Chat Issues](ai-chat-issues.md)\n- Bad responses → [AI & Chat Issues](ai-chat-issues.md)\n- Cost too high → [AI & Chat Issues](ai-chat-issues.md#high-api-costs)\n\n### Sources\n- Can't upload file → [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n- File won't process → [Quick Fixes](quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n\n### Podcasts\n- Won't generate → [Quick Fixes](quick-fixes.md#8-podcast-generation-failed)\n- Bad audio quality → [Quick Fixes](quick-fixes.md#8-podcast-generation-failed)\n\n---\n\n## Diagnostic Checklist\n\n**When something isn't working:**\n\n- [ ] Check if services are running: `docker ps`\n- [ ] Check logs: `docker compose logs api` (or frontend, surrealdb)\n- [ ] Verify ports are exposed: `netstat -tlnp` or `lsof -i :5055`\n- [ ] Test connectivity: `curl http://localhost:5055/health`\n- [ ] Check environment variables: `docker inspect <container>`\n- [ ] Try restarting: `docker compose restart`\n- [ ] Check firewall/antivirus isn't blocking\n\n---\n\n## Getting Help\n\nIf you can't find the answer here:\n\n1. **Check the relevant guide** — Read completely, try all steps\n2. **Check the FAQ** — [Frequently Asked Questions](faq.md)\n3. **Search our Discord** — Others may have had same issue\n4. **Check logs** — Most issues show error messages in logs\n5. **Report on GitHub** — Include error message, steps to reproduce\n\n### How to Report an Issue\n\nInclude:\n1. Error message (exact)\n2. Steps to reproduce\n3. Logs: `docker compose logs`\n4. Your setup: Docker/local, provider, OS\n5. What you've already tried\n\n→ [Report on GitHub](https://github.com/lfnovo/open-notebook/issues)\n\n---\n\n## Guides\n\n### [Quick Fixes](quick-fixes.md)\nTop 10 most common issues with 1-minute solutions.\n\n### [Connection Issues](connection-issues.md)\nFrontend can't reach API, network problems.\n\n### [AI & Chat Issues](ai-chat-issues.md)\nChat not working, bad responses, slow performance.\n\n### [FAQ](faq.md)\nFrequently asked questions about usage, costs, and best practices.\n\n---\n\n## Common Solutions\n\n**Service won't start?**\n```bash\n# Check logs\ndocker compose logs\n\n# Restart everything\ndocker compose restart\n\n# Nuclear option: rebuild\ndocker compose down\ndocker compose up --build\n```\n\n**Port conflict?**\n```bash\n# Find what's using port 5055\nlsof -i :5055\n# Kill it or use different port\n```\n\n**Can't connect?**\n```bash\n# Test API directly\ncurl http://localhost:5055/health\n# Should return: {\"status\":\"ok\"}\n```\n\n**Slow performance?**\n```bash\n# Check resource usage\ndocker stats\n\n# Reduce concurrency in .env\nSURREAL_COMMANDS_MAX_TASKS=2\n```\n\n**High costs?**\n```bash\n# Switch to cheaper model\n# In Settings → Models → Choose gpt-4o-mini (OpenAI)\n# Or use Ollama (free)\n```\n\n---\n\n## Still Stuck?\n\n**Before asking for help:**\n1. Read the relevant guide completely\n2. Try all the steps\n3. Check the logs\n4. Restart services\n5. Search existing issues on GitHub\n\n**Then:**\n- **Discord**: https://discord.gg/37XJPXfz2w (fastest response)\n- **GitHub Issues**: https://github.com/lfnovo/open-notebook/issues\n"
  },
  {
    "path": "docs/6-TROUBLESHOOTING/quick-fixes.md",
    "content": "# Quick Fixes - Top 11 Issues & Solutions\n\nCommon problems with 1-minute solutions.\n\n---\n\n## #1: \"Cannot connect to server\"\n\n**Symptom:** Browser shows error \"Cannot connect to server\" or \"Unable to reach API\"\n\n**Cause:** Frontend can't reach API\n\n**Solution (1 minute):**\n\n```bash\n# Step 1: Check if API is running\ndocker ps | grep api\n\n# Step 2: Verify port 5055 is accessible\ncurl http://localhost:5055/health\n\n# Expected output: {\"status\":\"ok\"}\n\n# If that doesn't work:\n# Step 3: Restart services\ndocker compose restart\n\n# Step 4: Try again\n# Open http://localhost:8502 in browser\n```\n\n**If still broken:**\n- Check `API_URL` in .env (should match your frontend URL)\n- See [Connection Issues](connection-issues.md)\n\n---\n\n## #2: \"Invalid API key\" or \"Models not showing\"\n\n**Symptom:** Settings → Models shows \"No models available\"\n\n**Cause:** No credential configured, or credential has invalid API key\n\n**Solution (1 minute):**\n\n```\n1. Go to Settings → API Keys\n2. If no credential exists, click \"Add Credential\" and add one\n3. If a credential exists, click \"Test Connection\"\n4. If test fails, delete and re-create with correct key\n5. After test passes, click \"Discover Models\" → \"Register Models\"\n6. Go to Settings → Models to verify models appear\n```\n\n**If still broken:**\n- Make sure key has no extra spaces\n- Generate a fresh key from provider dashboard\n- Check that `OPEN_NOTEBOOK_ENCRYPTION_KEY` is set in docker-compose.yml\n- See [AI & Chat Issues](ai-chat-issues.md)\n\n---\n\n## #3: \"Port X already in use\"\n\n**Symptom:** Docker error \"Port 8502 is already allocated\"\n\n**Cause:** Another service using that port\n\n**Solution (1 minute):**\n\n```bash\n# Option 1: Stop the other service\n# Find what's using port 8502\nlsof -i :8502\n# Kill it or close the app\n\n# Option 2: Use different port\n# Edit docker-compose.yml\n# Change: - \"8502:8502\"\n# To:     - \"8503:8502\"\n\n# Then restart\ndocker compose restart\n# Access at: http://localhost:8503\n```\n\n---\n\n## #4: \"Cannot process file\" or \"Unsupported format\"\n\n**Symptom:** Upload fails or says \"File format not supported\"\n\n**Cause:** File type not supported or too large\n\n**Solution (1 minute):**\n\n```bash\n# Check if file format is supported:\n# ✓ PDF, DOCX, PPTX, XLSX (documents)\n# ✓ MP3, WAV, M4A (audio)\n# ✓ MP4, AVI, MOV (video)\n# ✓ URLs/web links\n\n# ✗ Pure images (.jpg without OCR)\n# ✗ Files > 100MB\n\n# Try these:\n# - Convert to PDF if possible\n# - Split large files\n# - Try uploading again\n```\n\n---\n\n## #5: \"Chat is very slow\"\n\n**Symptom:** Chat responses take minutes or timeout\n\n**Cause:** Slow AI provider, large context, or overloaded system\n\n**Solution (1 minute):**\n\n```bash\n# Step 1: Check which model you're using\n# Settings → Models\n# Note the model name\n\n# Step 2: Try a cheaper/faster model\n# OpenAI: Switch to gpt-4o-mini (10x cheaper, slightly faster)\n# Anthropic: Switch to claude-3-5-haiku (fastest)\n# Groq: Use any model (ultra-fast)\n\n# Step 3: Reduce context\n# Chat: Select fewer sources\n# Use \"Summary Only\" instead of \"Full Content\"\n\n# Step 4: Check if API is overloaded\ndocker stats\n# Look at CPU/memory usage\n```\n\nFor deep dive: See [AI & Chat Issues](ai-chat-issues.md)\n\n---\n\n## #6: \"Chat gives bad responses\"\n\n**Symptom:** AI responses are generic, wrong, or irrelevant\n\n**Cause:** Bad context, vague question, or wrong model\n\n**Solution (1 minute):**\n\n```bash\n# Step 1: Make sure sources are in context\n# Click \"Select Sources\" in Chat\n# Verify relevant sources are checked and set to \"Full Content\"\n\n# Step 2: Ask a specific question\n# Bad: \"What do you think?\"\n# Good: \"Based on the paper's methodology section, what are the 3 main limitations?\"\n\n# Step 3: Try a more powerful model\n# OpenAI: Use gpt-4o (better reasoning)\n# Anthropic: Use claude-3-5-sonnet (best reasoning)\n\n# Step 4: Check citations\n# Click citations to verify AI actually saw those sources\n```\n\nFor detailed help: See [Chat Effectively](../3-USER-GUIDE/chat-effectively.md)\n\n---\n\n## #7: \"Search returns nothing\"\n\n**Symptom:** Search shows 0 results even though content exists\n\n**Cause:** Wrong search type or poor query\n\n**Solution (1 minute):**\n\n```bash\n# Try a different search type:\n\n# If you searched with KEYWORDS:\n# Try VECTOR SEARCH instead\n# (Concept-based, not keyword-based)\n\n# If you searched for CONCEPTS:\n# Try TEXT SEARCH instead\n# (Look for specific words in your query)\n\n# Try simpler search:\n# Instead of: \"How do transformers work in neural networks?\"\n# Try: \"transformers\" or \"neural networks\"\n\n# Check sources are processed:\n# Go to notebook\n# All sources should show green \"Ready\" status\n```\n\nFor detailed help: See [Search Effectively](../3-USER-GUIDE/search.md)\n\n---\n\n## #8: \"Podcast generation failed\"\n\n**Symptom:** \"Podcast generation failed\" error\n\n**Cause:** Insufficient content, API quota, or network issue\n\n**Solution (1 minute):**\n\n```bash\n# Step 1: Make sure you have content\n# Select at least 1-2 sources\n# Avoid single-sentence sources\n\n# Step 2: Try again\n# Sometimes it's a temporary API issue\n# Wait 30 seconds and retry\n\n# Step 3: Check your TTS provider has quota\n# OpenAI: Check account has credits\n# ElevenLabs: Check monthly quota\n# Google: Check API quota\n\n# Step 4: Try different TTS provider\n# In podcast generation, choose \"Google\" or \"Local\"\n# instead of \"ElevenLabs\"\n```\n\nFor detailed help: See [FAQ](faq.md)\n\n---\n\n## #9: \"Services won't start\" or Docker error\n\n**Symptom:** Docker error when running `docker compose up`\n\n**Cause:** Corrupt configuration, permission issue, or resource issue\n\n**Solution (1 minute):**\n\n```bash\n# Step 1: Check logs\ndocker compose logs\n\n# Step 2: Try restart\ndocker compose restart\n\n# Step 3: If that fails, rebuild\ndocker compose down\ndocker compose up --build\n\n# Step 4: Check disk space\ndf -h\n# Need at least 5GB free\n\n# Step 5: Check Docker has enough memory\n# Docker settings → Resources → Memory: 4GB+\n```\n\n---\n\n## #10: \"Database says 'too many connections'\"\n\n**Symptom:** Error about database connections\n\n**Cause:** Too many concurrent operations\n\n**Solution (1 minute):**\n\n```bash\n# In .env, reduce concurrency:\nSURREAL_COMMANDS_MAX_TASKS=2\n\n# Then restart:\ndocker compose restart\n\n# This makes it slower but more stable\n```\n\n---\n\n## #11: Slow Startup or Download Timeouts (China/Slow Networks)\n\n**Symptom:** Container crashes on startup, worker enters FATAL state, or pip/uv downloads fail\n\n**Cause:** Slow network or restricted access to Python package repositories\n\n**Solution:**\n\n### Increase Download Timeout\n```yaml\n# In docker-compose.yml environment:\nenvironment:\n  - UV_HTTP_TIMEOUT=600  # 10 minutes (default is 30s)\n```\n\n### Use Chinese Mirrors (if in China)\n```yaml\nenvironment:\n  - UV_HTTP_TIMEOUT=600\n  - UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple\n  - PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple\n```\n\n**Alternative Chinese mirrors:**\n- Tsinghua: `https://pypi.tuna.tsinghua.edu.cn/simple`\n- Aliyun: `https://mirrors.aliyun.com/pypi/simple/`\n- Huawei: `https://repo.huaweicloud.com/repository/pypi/simple`\n\n**Note:** First startup may take several minutes while dependencies download. Subsequent starts will be faster.\n\n---\n\n## Quick Troubleshooting Checklist\n\nWhen something breaks:\n\n- [ ] **Restart services:** `docker compose restart`\n- [ ] **Check logs:** `docker compose logs`\n- [ ] **Verify connectivity:** `curl http://localhost:5055/health`\n- [ ] **Check .env:** API keys set? API_URL correct?\n- [ ] **Check resources:** `docker stats` (CPU/memory)\n- [ ] **Clear cache:** `docker system prune` (free space)\n- [ ] **Rebuild if needed:** `docker compose up --build`\n\n---\n\n## Nuclear Options (Last Resort)\n\n**Completely reset (will lose all data in Docker):**\n\n```bash\ndocker compose down -v\ndocker compose up --build\n```\n\n**Reset to defaults:**\n```bash\n# Backup your .env first!\ncp .env .env.backup\n\n# Reset to example\ncp .env.example .env\n\n# Edit with your API keys\n# Restart\ndocker compose up\n```\n\n---\n\n## Prevention Tips\n\n1. **Keep backups** — Export your notebooks regularly\n2. **Monitor logs** — Check `docker compose logs` periodically\n3. **Update regularly** — Pull latest image: `docker pull lfnovo/open_notebook:latest`\n4. **Document changes** — Keep notes on what you configured\n5. **Test after updates** — Verify everything works\n\n---\n\n## Still Stuck?\n\n- **Look up your exact error** in [Troubleshooting Index](index.md)\n- **Check the FAQ** in [FAQ](faq.md)\n- **Check logs:** `docker compose logs | head -50`\n- **Ask for help:** [Discord](https://discord.gg/37XJPXfz2w) or [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/api-reference.md",
    "content": "# API Reference\n\nComplete REST API for Open Notebook. All endpoints are served from the API backend (default: `http://localhost:5055`).\n\n**Base URL**: `http://localhost:5055` (development) or environment-specific production URL\n\n**Interactive Docs**: Use FastAPI's built-in Swagger UI at `http://localhost:5055/docs` for live testing and exploration. This is the primary reference for all endpoints, request/response schemas, and real-time testing.\n\n---\n\n## Quick Start\n\n### 1. Authentication\n\nSimple password-based (development only):\n\n```bash\ncurl http://localhost:5055/api/notebooks \\\n  -H \"Authorization: Bearer your_password\"\n```\n\n**⚠️ Production**: Replace with OAuth/JWT. See [Security Configuration](../5-CONFIGURATION/security.md) for details.\n\n### 2. Base API Flow\n\nMost operations follow this pattern:\n1. Create a **Notebook** (container for research)\n2. Add **Sources** (PDFs, URLs, text)\n3. Query via **Chat** or **Search**\n4. View results and **Notes**\n\n### 3. Testing Endpoints\n\nInstead of memorizing endpoints, use the interactive API docs:\n- Navigate to `http://localhost:5055/docs`\n- Try requests directly in the browser\n- See request/response schemas in real-time\n- Test with your own data\n\n---\n\n## API Endpoints Overview\n\n### Main Resource Types\n\n**Notebooks** - Research projects containing sources and notes\n- `GET/POST /notebooks` - List and create\n- `GET/PUT/DELETE /notebooks/{id}` - Read, update, delete\n\n**Sources** - Content items (PDFs, URLs, text)\n- `GET/POST /sources` - List and add content\n- `GET /sources/{id}` - Fetch source details\n- `POST /sources/{id}/retry` - Retry failed processing\n- `GET /sources/{id}/download` - Download original file\n\n**Notes** - User-created or AI-generated research notes\n- `GET/POST /notes` - List and create\n- `GET/PUT/DELETE /notes/{id}` - Read, update, delete\n\n**Chat** - Conversational AI interface\n- `GET/POST /chat/sessions` - Manage chat sessions\n- `POST /chat/execute` - Send message and get response\n- `POST /chat/context/build` - Prepare context for chat\n\n**Search** - Find content by text or semantic similarity\n- `POST /search` - Full-text or vector search\n- `POST /ask` - Ask a question (search + synthesize)\n\n**Transformations** - Custom prompts for extracting insights\n- `GET/POST /transformations` - Create custom extraction rules\n- `POST /sources/{id}/insights` - Apply transformation to source\n\n**Models** - Configure AI providers\n- `GET /models` - Available models\n- `GET /models/defaults` - Current defaults\n- `POST /models/config` - Set defaults\n\n**Credentials** - Manage AI provider credentials\n- `GET/POST /credentials` - List and create credentials\n- `GET/PUT/DELETE /credentials/{id}` - CRUD operations\n- `POST /credentials/{id}/test` - Test connection\n- `POST /credentials/{id}/discover` - Discover models from provider\n- `POST /credentials/{id}/register-models` - Register discovered models\n- `GET /credentials/status` - Provider status overview\n- `GET /credentials/env-status` - Environment variable status\n- `POST /credentials/migrate-from-env` - Migrate env vars to credentials\n\n**Health & Status**\n- `GET /health` - Health check\n- `GET /commands/{id}` - Track async operations\n\n---\n\n## Authentication\n\n### Current (Development)\n\nAll requests require password header:\n\n```bash\ncurl -H \"Authorization: Bearer your_password\" http://localhost:5055/api/notebooks\n```\n\nPassword configured via `OPEN_NOTEBOOK_PASSWORD` environment variable.\n\n> **📖 See [Security Configuration](../5-CONFIGURATION/security.md)** for complete authentication setup, API examples, and production hardening.\n\n### Production\n\n**⚠️ Not secure.** Replace with:\n- OAuth 2.0 (recommended)\n- JWT tokens\n- API keys\n\nSee [Security Configuration](../5-CONFIGURATION/security.md) for production setup.\n\n---\n\n## Common Patterns\n\n### Pagination\n\n```bash\n# List sources with limit/offset\ncurl 'http://localhost:5055/sources?limit=20&offset=10'\n```\n\n### Filtering & Sorting\n\n```bash\n# Filter by notebook, sort by date\ncurl 'http://localhost:5055/sources?notebook_id=notebook:abc&sort_by=created&sort_order=asc'\n```\n\n### Async Operations\n\nSome operations (source processing, podcast generation) return immediately with a command ID:\n\n```bash\n# Submit async operation\ncurl -X POST http://localhost:5055/sources -F async_processing=true\n# Response: {\"id\": \"source:src001\", \"command_id\": \"command:cmd123\"}\n\n# Poll status\ncurl http://localhost:5055/commands/command:cmd123\n```\n\n### Streaming Responses\n\nThe `/ask` endpoint streams responses as Server-Sent Events:\n\n```bash\ncurl -N 'http://localhost:5055/ask' \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"question\": \"What is AI?\"}'\n\n# Outputs: data: {\"type\":\"strategy\",...}\n#          data: {\"type\":\"answer\",...}\n#          data: {\"type\":\"final_answer\",...}\n```\n\n### Multipart File Upload\n\n```bash\ncurl -X POST http://localhost:5055/sources \\\n  -F \"type=upload\" \\\n  -F \"notebook_id=notebook:abc\" \\\n  -F \"file=@document.pdf\"\n```\n\n---\n\n## Error Handling\n\nAll errors return JSON with status code:\n\n```json\n{\"detail\": \"Notebook not found\"}\n```\n\n### Common Status Codes\n\n| Code | Meaning | Example |\n|------|---------|---------|\n| 200 | Success | Operation completed |\n| 400 | Bad Request | Invalid input |\n| 404 | Not Found | Resource doesn't exist |\n| 409 | Conflict | Resource already exists |\n| 500 | Server Error | Database/processing error |\n\n---\n\n## Tips for Developers\n\n1. **Start with interactive docs** (`http://localhost:5055/docs`) - this is the definitive reference\n2. **Enable logging** for debugging (check API logs: `docker logs`)\n3. **Streaming endpoints** require special handling (Server-Sent Events, not standard JSON)\n4. **Async operations** return immediately; always poll status before assuming completion\n5. **Vector search** requires embedding model configured (check `/models`)\n6. **Model overrides** are per-request; set in body, not config\n7. **CORS enabled** in development; configure for production\n\n---\n\n## Learning Path\n\n1. **Authentication**: Add `X-Password` header to all requests\n2. **Create a notebook**: `POST /notebooks` with name and description\n3. **Add a source**: `POST /sources` with file, URL, or text\n4. **Query your content**: `POST /chat/execute` to ask questions\n5. **Explore advanced features**: Search, transformations, streaming\n\n---\n\n## Production Considerations\n\n- Replace password auth with OAuth/JWT (see [Security](../5-CONFIGURATION/security.md))\n- Add rate limiting via reverse proxy (Nginx, CloudFlare, Kong)\n- Enable CORS restrictions (currently allows all origins)\n- Use HTTPS via reverse proxy (see [Reverse Proxy](../5-CONFIGURATION/reverse-proxy.md))\n- Set up API versioning strategy (currently implicit)\n\nSee [Security Configuration](../5-CONFIGURATION/security.md) and [Reverse Proxy Setup](../5-CONFIGURATION/reverse-proxy.md) for complete production setup.\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/architecture.md",
    "content": "# Open Notebook Architecture\n\n## High-Level Overview\n\nOpen Notebook follows a three-tier architecture with clear separation of concerns:\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  Your Browser                                           │\n│  Access: http://your-server-ip:8502                     │\n└────────────────┬────────────────────────────────────────┘\n                 │\n                 ▼\n         ┌───────────────┐\n         │   Port 8502   │  ← Next.js Frontend (what you see)\n         │   Frontend    │    Also proxies API requests internally!\n         └───────┬───────┘\n                 │ proxies /api/* requests ↓\n                 ▼\n         ┌───────────────┐\n         │   Port 5055   │  ← FastAPI Backend (handles requests)\n         │     API       │\n         └───────┬───────┘\n                 │\n                 ▼\n         ┌───────────────┐\n         │   SurrealDB   │  ← Database (internal, auto-configured)\n         │   (Port 8000) │\n         └───────────────┘\n```\n\n**Key Points:**\n- **v1.1+**: Next.js automatically proxies `/api/*` requests to the backend, simplifying reverse proxy setup\n- Your browser loads the frontend from port 8502\n- The frontend needs to know where to find the API - when accessing remotely, set: `API_URL=http://your-server-ip:5055`\n- **Behind reverse proxy?** You only need to proxy to port 8502 now! See [Reverse Proxy Configuration](../5-CONFIGURATION/reverse-proxy.md)\n\n---\n\n## Detailed Architecture\n\nOpen Notebook is built on a **three-tier, async-first architecture** designed for scalability, modularity, and multi-provider AI flexibility. The system separates concerns across frontend, API, and database layers, with LangGraph powering intelligent workflows and Esperanto enabling seamless integration with 8+ AI providers.\n\n**Core Philosophy**:\n- Privacy-first: Users control their data and AI provider choice\n- Async/await throughout: Non-blocking operations for responsive UX\n- Domain-Driven Design: Clear separation between domain models, repositories, and orchestrators\n- Multi-provider flexibility: Swap AI providers without changing application code\n- Self-hosted capable: All components deployable in isolated environments\n\n---\n\n## Three-Tier Architecture\n\n### Layer 1: Frontend (React/Next.js @ port 3000)\n\n**Purpose**: Responsive, interactive user interface for research, notes, chat, and podcast management.\n\n**Technology Stack**:\n- **Framework**: Next.js 15 with React 19\n- **Language**: TypeScript with strict type checking\n- **State Management**: Zustand (lightweight store) + TanStack Query (server state)\n- **Styling**: Tailwind CSS + Shadcn/ui component library\n- **Build Tool**: Webpack (bundled via Next.js)\n\n**Key Responsibilities**:\n- Render notebooks, sources, notes, chat sessions, and podcasts\n- Handle user interactions (create, read, update, delete operations)\n- Manage complex UI state (modals, file uploads, real-time search)\n- Stream responses from API (chat, podcast generation)\n- Display embeddings, vector search results, and insights\n\n**Communication Pattern**:\n- All data fetched via REST API (async requests to port 5055)\n- Configured base URL: `http://localhost:5055` (dev) or environment-specific (prod)\n- TanStack Query handles caching, refetching, and data synchronization\n- Zustand stores global state (user, notebooks, selected context)\n- CORS enabled on API side for cross-origin requests\n\n**Component Architecture**:\n- `/src/app/`: Next.js App Router (pages, layouts)\n- `/src/components/`: Reusable React components (buttons, forms, cards)\n- `/src/hooks/`: Custom hooks (useNotebook, useChat, useSearch)\n- `/src/lib/`: Utility functions, API clients, validators\n- `/src/styles/`: Global CSS, Tailwind config\n\n---\n\n### Layer 2: API (FastAPI @ port 5055)\n\n**Purpose**: RESTful backend exposing operations on notebooks, sources, notes, chat sessions, and AI models.\n\n**Technology Stack**:\n- **Framework**: FastAPI 0.104+ (async Python web framework)\n- **Language**: Python 3.11+\n- **Validation**: Pydantic v2 (request/response schemas)\n- **Logging**: Loguru (structured JSON logging)\n- **Testing**: Pytest (unit and integration tests)\n\n**Architecture**:\n```\nFastAPI App (main.py)\n  ├── Routers (HTTP endpoints)\n  │   ├── routers/notebooks.py (CRUD operations)\n  │   ├── routers/sources.py (content ingestion, upload)\n  │   ├── routers/notes.py (note management)\n  │   ├── routers/chat.py (conversation sessions)\n  │   ├── routers/search.py (full-text + vector search)\n  │   ├── routers/transformations.py (custom transformations)\n  │   ├── routers/models.py (AI model configuration)\n  │   └── routers/*.py (11 additional routers)\n  │\n  ├── Services (business logic)\n  │   ├── *_service.py (orchestration, graph invocation)\n  │   ├── command_service.py (async job submission)\n  │   └── middleware (auth, logging)\n  │\n  ├── Models (Pydantic schemas)\n  │   └── models.py (validation, serialization)\n  │\n  └── Lifespan (startup/shutdown)\n      └── AsyncMigrationManager (database schema migrations)\n```\n\n**Key Responsibilities**:\n1. **HTTP Interface**: Accept REST requests, validate, return JSON responses\n2. **Business Logic**: Orchestrate domain models, repository operations, and workflows\n3. **Async Job Queue**: Submit long-running tasks (podcast generation, source processing)\n4. **Database Migrations**: Run schema updates on startup\n5. **Error Handling**: Catch exceptions, return appropriate HTTP status codes\n6. **Logging**: Track operations for debugging and monitoring\n\n**Startup Flow**:\n1. Load `.env` environment variables\n2. Initialize FastAPI app with CORS + auth middleware\n3. Run AsyncMigrationManager (creates/updates database schema)\n4. Register all routers (20+ endpoints)\n5. Server ready on port 5055\n\n**Request-Response Cycle**:\n```\nHTTP Request → Router → Service → Domain/Repository → SurrealDB\n                                       ↓\n                                  LangGraph (optional)\n                                       ↓\nResponse ← Pydantic serialization ← Service ← Result\n```\n\n---\n\n### Layer 3: Database (SurrealDB @ port 8000)\n\n**Purpose**: Graph database with built-in vector embeddings, semantic search, and relationship management.\n\n**Technology Stack**:\n- **Database**: SurrealDB (multi-model, ACID transactions)\n- **Query Language**: SurrealQL (SQL-like syntax with graph operations)\n- **Async Driver**: Async Rust client for Python\n- **Migrations**: Manual `.surql` files in `/migrations/` (auto-run on API startup)\n\n**Core Tables**:\n\n| Table | Purpose | Key Fields |\n|-------|---------|-----------|\n| `notebook` | Research project container | id, name, description, archived, created, updated |\n| `source` | Content item (PDF, URL, text) | id, title, full_text, topics, asset, created, updated |\n| `source_embedding` | Vector embeddings for semantic search | id, source, embedding, chunk_text, chunk_index |\n| `note` | User-created research notes | id, title, content, note_type (human/ai), created, updated |\n| `chat_session` | Conversation session | id, notebook_id, title, messages (JSON), created, updated |\n| `transformation` | Custom transformation rules | id, name, description, prompt, created, updated |\n| `source_insight` | Transformation output | id, source_id, insight_type, content, created, updated |\n| `reference` | Relationship: source → notebook | out (source), in (notebook) |\n| `artifact` | Relationship: note → notebook | out (note), in (notebook) |\n\n**Relationship Graph**:\n```\nNotebook\n  ↓ (referenced_by)\nSource\n  ├→ SourceEmbedding (1:many for chunked text)\n  ├→ SourceInsight (1:many for transformation outputs)\n  └→ Note (via artifact relationship)\n    ├→ Embedding (semantic search)\n    └→ Topics (tags)\n\nChatSession\n  ├→ Notebook\n  └→ Messages (stored as JSON array)\n```\n\n**Vector Search Capability**:\n- Embeddings stored natively in SurrealDB\n- Full-text search on `source.full_text` and `note.content`\n- Cosine similarity search on embedding vectors\n- Semantic search integrates with search endpoint\n\n**Connection Management**:\n- Async connection pooling (configurable size)\n- Transaction support for multi-record operations\n- Schema auto-validation via migrations\n- Query timeout protection (prevent infinite queries)\n\n---\n\n## Tech Stack Rationale\n\n### Why Python + FastAPI?\n\n**Python**:\n- Rich AI/ML ecosystem (LangChain, LangGraph, transformers, scikit-learn)\n- Rapid prototyping and deployment\n- Extensive async support (asyncio, async/await)\n- Strong type hints (Pydantic, mypy)\n\n**FastAPI**:\n- Modern, async-first framework\n- Automatic OpenAPI documentation (Swagger UI @ /docs)\n- Built-in request validation (Pydantic)\n- Excellent performance (benchmarked near C/Rust speeds)\n- Easy middleware/dependency injection\n\n### Why Next.js + React + TypeScript?\n\n**Next.js**:\n- Full-stack React framework with SSR/SSG\n- File-based routing (intuitive project structure)\n- Built-in API routes (optional backend co-location)\n- Optimized image/code splitting\n- Easy deployment (Vercel, Docker, self-hosted)\n\n**React 19**:\n- Component-based UI (reusable, testable)\n- Excellent tooling and community\n- Client-side state management (Zustand)\n- Server-side state sync (TanStack Query)\n\n**TypeScript**:\n- Type safety catches errors at compile time\n- Better IDE autocomplete and refactoring\n- Documentation via types (self-documenting code)\n- Easier onboarding for new contributors\n\n### Why SurrealDB?\n\n**SurrealDB**:\n- Native graph database (relationships are first-class)\n- Built-in vector embeddings (no separate vector DB)\n- ACID transactions (data consistency)\n- Multi-model (relational + document + graph)\n- Full-text search + semantic search in one query\n- Self-hosted (unlike managed Pinecone/Weaviate)\n- Flexible SurrealQL (SQL-like syntax)\n\n**Alternative Considered**: PostgreSQL + pgvector (more mature but separate extensions)\n\n### Why Esperanto for AI Providers?\n\n**Esperanto Library**:\n- Unified interface to 8+ LLM providers (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI)\n- Multi-provider embeddings (OpenAI, Google, Ollama, Mistral, Voyage)\n- TTS/STT integration (OpenAI, Groq, ElevenLabs, Google)\n- Smart provider selection (fallback logic, cost optimization)\n- Per-request model override support\n- Local Ollama support (completely self-hosted option)\n\n**Alternative Considered**: LangChain's provider abstraction (more verbose, less flexible)\n\n---\n\n## LangGraph Workflows\n\nLangGraph is a state machine library that orchestrates multi-step AI workflows. Open Notebook uses five core workflows:\n\n### 1. **Source Processing Workflow** (`open_notebook/graphs/source.py`)\n\n**Purpose**: Ingest content (PDF, URL, text) and prepare for search/insights.\n\n**Flow**:\n```\nInput (file/URL/text)\n  ↓\nExtract Content (content-core library)\n  ↓\nClean & tokenize text\n  ↓\nGenerate Embeddings (Esperanto)\n  ↓\nCreate SourceEmbedding records (chunked + indexed)\n  ↓\nExtract Topics (LLM summarization)\n  ↓\nSave to SurrealDB\n  ↓\nOutput (Source record with embeddings)\n```\n\n**State Dict**:\n```python\n{\n  \"content_state\": {\"file_path\" | \"url\" | \"content\": str},\n  \"source_id\": str,\n  \"full_text\": str,\n  \"embeddings\": List[Dict],\n  \"topics\": List[str],\n  \"notebook_ids\": List[str],\n}\n```\n\n**Invoked By**: Sources API (`POST /sources`)\n\n---\n\n### 2. **Chat Workflow** (`open_notebook/graphs/chat.py`)\n\n**Purpose**: Conduct multi-turn conversations with AI model, referencing notebook context.\n\n**Flow**:\n```\nUser Message\n  ↓\nBuild Context (selected sources/notes)\n  ↓\nAdd Message to Session\n  ↓\nCreate Chat Prompt (system + history + context)\n  ↓\nCall LLM (via Esperanto)\n  ↓\nStream Response\n  ↓\nSave AI Message to ChatSession\n  ↓\nOutput (complete message)\n```\n\n**State Dict**:\n```python\n{\n  \"session_id\": str,\n  \"messages\": List[BaseMessage],\n  \"context\": Dict[str, Any],  # sources, notes, snippets\n  \"response\": str,\n  \"model_override\": Optional[str],\n}\n```\n\n**Key Features**:\n- Message history persisted in SurrealDB (SqliteSaver checkpoint)\n- Context building via `build_context_for_chat()` utility\n- Token counting to prevent overflow\n- Per-message model override support\n\n**Invoked By**: Chat API (`POST /chat/execute`)\n\n---\n\n### 3. **Ask Workflow** (`open_notebook/graphs/ask.py`)\n\n**Purpose**: Answer user questions by searching sources and synthesizing responses.\n\n**Flow**:\n```\nUser Question\n  ↓\nPlan Search Strategy (LLM generates searches)\n  ↓\nExecute Searches (vector + text search)\n  ↓\nScore & Rank Results\n  ↓\nProvide Answers (LLM synthesizes from results)\n  ↓\nStream Responses\n  ↓\nOutput (final answer)\n```\n\n**State Dict**:\n```python\n{\n  \"question\": str,\n  \"strategy\": SearchStrategy,\n  \"answers\": List[str],\n  \"final_answer\": str,\n  \"sources_used\": List[Source],\n}\n```\n\n**Streaming**: Uses `astream()` to emit updates in real-time (strategy → answers → final answer)\n\n**Invoked By**: Search API (`POST /ask` with streaming)\n\n---\n\n### 4. **Transformation Workflow** (`open_notebook/graphs/transformation.py`)\n\n**Purpose**: Apply custom transformations to sources (extract summaries, key points, etc).\n\n**Flow**:\n```\nSource + Transformation Rule\n  ↓\nGenerate Prompt (Jinja2 template)\n  ↓\nCall LLM\n  ↓\nParse Output\n  ↓\nCreate SourceInsight record\n  ↓\nOutput (insight with type + content)\n```\n\n**Example Transformations**:\n- Summary (5-sentence overview)\n- Key Points (bulleted list)\n- Quotes (notable excerpts)\n- Q&A (generated questions and answers)\n\n**Invoked By**: Sources API (`POST /sources/{id}/insights`)\n\n---\n\n### 5. **Prompt Workflow** (`open_notebook/graphs/prompt.py`)\n\n**Purpose**: Generic LLM task execution (e.g., auto-generate note titles, analyze content).\n\n**Flow**:\n```\nInput Text + Prompt\n  ↓\nCall LLM (simple request-response)\n  ↓\nOutput (completion)\n```\n\n**Used For**: Note title generation, content analysis, etc.\n\n---\n\n## AI Provider Integration Pattern\n\n### ModelManager: Centralized Factory\n\nLocated in `open_notebook/ai/models.py`, ModelManager handles:\n\n1. **Provider Detection**: Check environment variables for available providers\n2. **Model Selection**: Choose best model based on context size and task\n3. **Fallback Logic**: If primary provider unavailable, try backup\n4. **Cost Optimization**: Prefer cheaper models for simple tasks\n5. **Token Calculation**: Estimate cost before LLM call\n\n**Usage**:\n```python\nfrom open_notebook.ai.provision import provision_langchain_model\n\n# Get best LLM for context size\nmodel = await provision_langchain_model(\n    task=\"chat\",  # or \"search\", \"extraction\"\n    model_override=\"anthropic/claude-opus-4\",  # optional\n    context_size=8000,  # estimated tokens\n)\n\n# Invoke model\nresponse = await model.ainvoke({\"input\": prompt})\n```\n\n### Multi-Provider Support\n\n**LLM Providers**:\n- OpenAI (gpt-4, gpt-4-turbo, gpt-3.5-turbo)\n- Anthropic (claude-opus, claude-sonnet, claude-haiku)\n- Google (gemini-pro, gemini-1.5)\n- Groq (mixtral, llama-2)\n- Ollama (local models)\n- Mistral (mistral-large, mistral-medium)\n- DeepSeek (deepseek-chat)\n- xAI (grok)\n\n**Embedding Providers**:\n- OpenAI (text-embedding-3-large, text-embedding-3-small)\n- Google (embedding-001)\n- Ollama (local embeddings)\n- Mistral (mistral-embed)\n- Voyage (voyage-large-2)\n\n**TTS Providers**:\n- OpenAI (tts-1, tts-1-hd)\n- Groq (no TTS, fallback to OpenAI)\n- ElevenLabs (multilingual voices)\n- Google TTS (text-to-speech)\n\n### Per-Request Override\n\nEvery LangGraph invocation accepts a `config` parameter to override models:\n\n```python\nresult = await graph.ainvoke(\n    input={...},\n    config={\n        \"configurable\": {\n            \"model_override\": \"anthropic/claude-opus-4\"  # Use Claude instead\n        }\n    }\n)\n```\n\n---\n\n## Design Patterns\n\n### 1. **Domain-Driven Design (DDD)**\n\n**Domain Objects** (`open_notebook/domain/`):\n- `Notebook`: Research container with relationships to sources/notes\n- `Source`: Content item (PDF, URL, text) with embeddings\n- `Note`: User-created or AI-generated research note\n- `ChatSession`: Conversation history for a notebook\n- `Transformation`: Custom rule for extracting insights\n\n**Repository Pattern**:\n- Database access layer (`open_notebook/database/repository.py`)\n- `repo_query()`: Execute SurrealQL queries\n- `repo_create()`: Insert records\n- `repo_upsert()`: Merge records\n- `repo_delete()`: Remove records\n\n**Entity Methods**:\n```python\n# Domain methods (business logic)\nnotebook = await Notebook.get(id)\nawait notebook.save()\nnotes = await notebook.get_notes()\nsources = await notebook.get_sources()\n```\n\n### 2. **Async-First Architecture**\n\n**All I/O is async**:\n- Database queries: `await repo_query(...)`\n- LLM calls: `await model.ainvoke(...)`\n- File I/O: `await upload_file.read()`\n- Graph invocations: `await graph.ainvoke(...)`\n\n**Benefits**:\n- Non-blocking request handling (FastAPI serves multiple concurrent requests)\n- Better resource utilization (I/O waiting doesn't block CPU)\n- Natural fit for Python async/await syntax\n\n**Example**:\n```python\n@router.post(\"/sources\")\nasync def create_source(source_data: SourceCreate):\n    # All operations are non-blocking\n    source = Source(title=source_data.title)\n    await source.save()  # async database operation\n    await graph.ainvoke({...})  # async LangGraph invocation\n    return SourceResponse(...)\n```\n\n### 3. **Service Pattern**\n\nServices orchestrate domain objects, repositories, and workflows:\n\n```python\n# api/notebook_service.py\nclass NotebookService:\n    async def get_notebook_with_stats(notebook_id: str):\n        notebook = await Notebook.get(notebook_id)\n        sources = await notebook.get_sources()\n        notes = await notebook.get_notes()\n        return {\n            \"notebook\": notebook,\n            \"source_count\": len(sources),\n            \"note_count\": len(notes),\n        }\n```\n\n**Responsibilities**:\n- Validate inputs (Pydantic)\n- Orchestrate database operations\n- Invoke workflows (LangGraph graphs)\n- Handle errors and return appropriate status codes\n- Log operations\n\n### 4. **Streaming Pattern**\n\nFor long-running operations (ask workflow, podcast generation), stream results as Server-Sent Events:\n\n```python\n@router.post(\"/ask\", response_class=StreamingResponse)\nasync def ask(request: AskRequest):\n    async def stream_response():\n        async for chunk in ask_graph.astream(input={...}):\n            yield f\"data: {json.dumps(chunk)}\\n\\n\"\n    return StreamingResponse(stream_response(), media_type=\"text/event-stream\")\n```\n\n### 5. **Job Queue Pattern**\n\nFor async background tasks (source processing), use Surreal-Commands job queue:\n\n```python\n# Submit job\ncommand_id = await CommandService.submit_command_job(\n    app=\"open_notebook\",\n    command=\"process_source\",\n    input={...}\n)\n\n# Poll status\nstatus = await source.get_status()\n```\n\n---\n\n## Service Communication Patterns\n\n### Frontend → API\n\n1. **REST requests** (HTTP GET/POST/PUT/DELETE)\n2. **JSON request/response bodies**\n3. **Standard HTTP status codes** (200, 400, 404, 500)\n4. **Optional streaming** (Server-Sent Events for long operations)\n\n**Example**:\n```typescript\n// Frontend\nconst response = await fetch(\"http://localhost:5055/sources\", {\n  method: \"POST\",\n  body: formData,  // multipart/form-data for file upload\n});\nconst source = await response.json();\n```\n\n### API → SurrealDB\n\n1. **SurrealQL queries** (similar to SQL)\n2. **Async driver** with connection pooling\n3. **Type-safe record IDs** (record_id syntax)\n4. **Transaction support** for multi-step operations\n\n**Example**:\n```python\n# API\nresult = await repo_query(\n    \"SELECT * FROM source WHERE notebook = $notebook_id\",\n    {\"notebook_id\": ensure_record_id(notebook_id)}\n)\n```\n\n### API → AI Providers (via Esperanto)\n\n1. **Esperanto unified interface**\n2. **Per-request provider override**\n3. **Automatic fallback on failure**\n4. **Token counting and cost estimation**\n\n**Example**:\n```python\n# API\nmodel = await provision_langchain_model(task=\"chat\")\nresponse = await model.ainvoke({\"input\": prompt})\n```\n\n### API → Job Queue (Surreal-Commands)\n\n1. **Async job submission**\n2. **Fire-and-forget pattern**\n3. **Status polling via `/commands/{id}` endpoint**\n4. **Job completion callbacks (optional)**\n\n**Example**:\n```python\n# Submit async source processing\ncommand_id = await CommandService.submit_command_job(...)\n\n# Client polls status\nresponse = await fetch(f\"http://localhost:5055/commands/{command_id}\")\nstatus = await response.json()  # returns { status: \"running|queued|completed|failed\" }\n```\n\n---\n\n## Database Schema Overview\n\n### Core Schema Structure\n\n**Tables** (20+):\n- Notebooks (with soft-delete via `archived` flag)\n- Sources (content + metadata)\n- SourceEmbeddings (vector chunks)\n- Notes (user-created + AI-generated)\n- ChatSessions (conversation history)\n- Transformations (custom rules)\n- SourceInsights (transformation outputs)\n- Relationships (notebook→source, notebook→note)\n\n**Migrations**:\n- Automatic on API startup\n- Located in `/migrations/` directory\n- Numbered sequentially (001_*.surql, 002_*.surql, etc)\n- Tracked in `_sbl_migrations` table\n- Rollback via `_down.surql` files (manual)\n\n### Relationship Model\n\n**Graph Relationships**:\n```\nNotebook\n  ← reference ← Source (many:many)\n  ← artifact ← Note (many:many)\n\nSource\n  → source_embedding (one:many)\n  → source_insight (one:many)\n  → embedding (via source_embedding)\n\nChatSession\n  → messages (JSON array in database)\n  → notebook_id (reference to Notebook)\n\nTransformation\n  → source_insight (one:many)\n```\n\n**Query Example** (get all sources in a notebook with counts):\n```sql\nSELECT id, title,\n  count(<-reference.in) as note_count,\n  count(<-embedding.in) as embedded_chunks\nFROM source\nWHERE notebook = $notebook_id\nORDER BY updated DESC\n```\n\n---\n\n## Key Architectural Decisions\n\n### 1. **Async Throughout**\n\nAll I/O operations are non-blocking to maximize concurrency and responsiveness.\n\n**Trade-off**: Slightly more complex code (async/await syntax) vs. high throughput.\n\n### 2. **Multi-Provider from Day 1**\n\nBuilt-in support for 8+ AI providers prevents vendor lock-in.\n\n**Trade-off**: Added complexity in ModelManager vs. flexibility and cost optimization.\n\n### 3. **Graph-First Workflows**\n\nLangGraph state machines for complex multi-step operations (ask, chat, transformations).\n\n**Trade-off**: Steeper learning curve vs. maintainable, debuggable workflows.\n\n### 4. **Self-Hosted Database**\n\nSurrealDB for graph + vector search in one system (no external dependencies).\n\n**Trade-off**: Operational responsibility vs. simplified architecture and cost savings.\n\n### 5. **Job Queue for Long-Running Tasks**\n\nAsync job submission (source processing, podcast generation) prevents request timeouts.\n\n**Trade-off**: Eventual consistency vs. responsive user experience.\n\n---\n\n## Important Quirks & Gotchas\n\n### API Startup\n\n- **Migrations run automatically** on every startup; check logs for errors\n- **SurrealDB must be running** before starting API (connection test in lifespan)\n- **Auth middleware is basic** (password-only); upgrade to OAuth/JWT for production\n\n### Database Operations\n\n- **Record IDs use SurrealDB syntax** (table:id format, e.g., \"notebook:abc123\")\n- **ensure_record_id()** helper prevents malformed IDs\n- **Soft deletes** via `archived` field (data not removed, just marked inactive)\n- **Timestamps in ISO 8601 format** (created, updated fields)\n\n### LangGraph Workflows\n\n- **State persistence** via SqliteSaver in `/data/sqlite-db/`\n- **No built-in timeout**; long workflows may block requests (use streaming for UX)\n- **Model fallback** automatic if primary provider unavailable\n- **Checkpoint IDs** must be unique per session (avoid collisions)\n\n### AI Provider Integration\n\n- **Esperanto library** handles all provider APIs (no direct API calls)\n- **Per-request override** via RunnableConfig (temporary, not persistent)\n- **Cost estimation** via token counting (not 100% accurate, use for guidance)\n- **Fallback logic** tries cheaper models if primary fails\n\n### File Uploads\n\n- **Stored in `/data/uploads/`** directory (not database)\n- **Unique filename generation** prevents overwrites (counter suffix)\n- **Content-core library** extracts text from 50+ file types\n- **Large files** may block API briefly (sync content extraction)\n\n---\n\n## Performance Considerations\n\n### Optimization Strategies\n\n1. **Connection Pooling**: SurrealDB async driver with configurable pool size\n2. **Query Caching**: TanStack Query on frontend (client-side caching)\n3. **Embedding Reuse**: Vector search uses pre-computed embeddings\n4. **Chunking**: Sources split into chunks for better search relevance\n5. **Async Operations**: Non-blocking I/O for high concurrency\n6. **Lazy Loading**: Frontend requests only needed data (pagination)\n\n### Bottlenecks\n\n1. **LLM Calls**: Latency depends on provider (typically 1-30 seconds)\n2. **Embedding Generation**: Time proportional to content size and provider\n3. **Vector Search**: Similarity computation over all embeddings\n4. **Content Extraction**: Sync operation in source processing\n\n### Monitoring\n\n- **API Logs**: Check loguru output for errors and slow operations\n- **Database Queries**: SurrealDB metrics available via admin UI\n- **Token Usage**: Estimated via `estimate_tokens()` utility\n- **Job Status**: Poll `/commands/{id}` for async operations\n\n---\n\n## Extension Points\n\n### Adding a New Workflow\n\n1. Create `open_notebook/graphs/workflow_name.py`\n2. Define StateDict and node functions\n3. Build graph with `.add_node()` / `.add_edge()`\n4. Create service in `api/workflow_service.py`\n5. Register router in `api/main.py`\n6. Add tests in `tests/test_workflow.py`\n\n### Adding a New Data Model\n\n1. Create model in `open_notebook/domain/model_name.py`\n2. Inherit from BaseModel (domain object)\n3. Implement `save()`, `get()`, `delete()` methods (CRUD)\n4. Add repository functions if complex queries needed\n5. Create database migration in `migrations/`\n6. Add API routes and models in `api/`\n\n### Adding a New AI Provider\n\n1. Configure Esperanto for new provider (see .env.example)\n2. ModelManager automatically detects via environment variables\n3. Override via per-request config (no code changes needed)\n4. Test fallback logic if provider unavailable\n\n---\n\n## Deployment Considerations\n\n### Development\n\n- All services on localhost (3000, 5055, 8000)\n- Auto-reload on file changes (Next.js, FastAPI)\n- Hot-reload database migrations\n- Open API docs at http://localhost:5055/docs\n\n### Production\n\n- **Frontend**: Deploy to Vercel, Netlify, or Docker\n- **API**: Docker container (see Dockerfile)\n- **Database**: SurrealDB container or managed service\n- **Environment**: Secure .env file with API keys\n- **SSL/TLS**: Reverse proxy (Nginx, CloudFlare)\n- **Rate Limiting**: Add at proxy layer\n- **Auth**: Replace PasswordAuthMiddleware with OAuth/JWT\n- **Monitoring**: Log aggregation (CloudWatch, DataDog, etc)\n\n---\n\n## Summary\n\nOpen Notebook's architecture provides a solid foundation for privacy-focused, AI-powered research. The separation of concerns (frontend/API/database), async-first design, and multi-provider flexibility enable rapid development and easy deployment. LangGraph workflows orchestrate complex AI tasks, while Esperanto abstracts provider details. The result is a scalable, maintainable system that puts users in control of their data and AI provider choice.\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/code-standards.md",
    "content": "# Code Standards\n\nThis document outlines coding standards and best practices for Open Notebook contributions. All code should follow these guidelines to ensure consistency, readability, and maintainability.\n\n## Python Standards\n\n### Code Formatting\n\nWe follow **PEP 8** with some specific guidelines:\n\n- Use **Ruff** for linting and formatting\n- Maximum line length: **88 characters**\n- Use **double quotes** for strings\n- Use **trailing commas** in multi-line structures\n\n### Type Hints\n\nAlways use type hints for function parameters and return values:\n\n```python\nfrom typing import List, Optional, Dict, Any\nfrom pydantic import BaseModel\n\nasync def process_content(\n    content: str,\n    options: Optional[Dict[str, Any]] = None\n) -> ProcessedContent:\n    \"\"\"Process content with optional configuration.\"\"\"\n    # Implementation\n```\n\n### Async/Await Patterns\n\nUse async/await consistently throughout the codebase:\n\n```python\n# Good\nasync def fetch_data(url: str) -> Dict[str, Any]:\n    async with aiohttp.ClientSession() as session:\n        async with session.get(url) as response:\n            return await response.json()\n\n# Bad - mixing sync and async\ndef fetch_data(url: str) -> Dict[str, Any]:\n    loop = asyncio.get_event_loop()\n    return loop.run_until_complete(async_fetch(url))\n```\n\n### Error Handling\n\nUse structured error handling with custom exceptions:\n\n```python\nfrom open_notebook.exceptions import DatabaseOperationError, InvalidInputError\n\nasync def create_notebook(name: str, description: str) -> Notebook:\n    \"\"\"Create a new notebook with validation.\"\"\"\n    if not name.strip():\n        raise InvalidInputError(\"Notebook name cannot be empty\")\n\n    try:\n        notebook = Notebook(name=name, description=description)\n        await notebook.save()\n        return notebook\n    except Exception as e:\n        raise DatabaseOperationError(f\"Failed to create notebook: {str(e)}\")\n```\n\n### Documentation (Google-style Docstrings)\n\nUse Google-style docstrings for all functions, classes, and modules:\n\n```python\nasync def vector_search(\n    query: str,\n    limit: int = 10,\n    minimum_score: float = 0.2\n) -> List[SearchResult]:\n    \"\"\"Perform vector search across embedded content.\n\n    Args:\n        query: Search query string\n        limit: Maximum number of results to return\n        minimum_score: Minimum similarity score for results\n\n    Returns:\n        List of search results sorted by relevance score\n\n    Raises:\n        InvalidInputError: If query is empty or limit is invalid\n        DatabaseOperationError: If search operation fails\n    \"\"\"\n    # Implementation\n```\n\n#### Module Docstrings\n```python\n\"\"\"\nNotebook domain model and operations.\n\nThis module contains the core Notebook class and related operations for\nmanaging research notebooks within the Open Notebook system.\n\"\"\"\n```\n\n#### Class Docstrings\n```python\nclass Notebook(BaseModel):\n    \"\"\"A research notebook containing sources, notes, and chat sessions.\n\n    Notebooks are the primary organizational unit in Open Notebook, allowing\n    users to group related research materials and maintain separate contexts\n    for different projects.\n\n    Attributes:\n        name: The notebook's display name\n        description: Optional description of the notebook's purpose\n        archived: Whether the notebook is archived (default: False)\n        created: Timestamp of creation\n        updated: Timestamp of last update\n    \"\"\"\n```\n\n#### Function Docstrings\n```python\nasync def create_notebook(\n    name: str,\n    description: str = \"\",\n    user_id: Optional[str] = None\n) -> Notebook:\n    \"\"\"Create a new notebook with validation.\n\n    Args:\n        name: The notebook name (required, non-empty)\n        description: Optional notebook description\n        user_id: Optional user ID for multi-user deployments\n\n    Returns:\n        The created notebook instance\n\n    Raises:\n        InvalidInputError: If name is empty or invalid\n        DatabaseOperationError: If creation fails\n\n    Example:\n        ```python\n        notebook = await create_notebook(\n            name=\"AI Research\",\n            description=\"Research on AI applications\"\n        )\n        ```\n    \"\"\"\n```\n\n## FastAPI Standards\n\n### Router Organization\n\nOrganize endpoints by domain:\n\n```python\n# api/routers/notebooks.py\nfrom fastapi import APIRouter, HTTPException, Query\nfrom typing import List, Optional\n\nrouter = APIRouter()\n\n@router.get(\"/notebooks\", response_model=List[NotebookResponse])\nasync def get_notebooks(\n    archived: Optional[bool] = Query(None, description=\"Filter by archived status\"),\n    order_by: str = Query(\"updated desc\", description=\"Order by field and direction\"),\n):\n    \"\"\"Get all notebooks with optional filtering and ordering.\"\"\"\n    # Implementation\n```\n\n### Request/Response Models\n\nUse Pydantic models for validation:\n\n```python\nfrom pydantic import BaseModel, Field\nfrom typing import Optional\n\nclass NotebookCreate(BaseModel):\n    name: str = Field(..., description=\"Name of the notebook\", min_length=1)\n    description: str = Field(default=\"\", description=\"Description of the notebook\")\n\nclass NotebookResponse(BaseModel):\n    id: str\n    name: str\n    description: str\n    archived: bool\n    created: str\n    updated: str\n```\n\n### Error Handling\n\nUse consistent error responses:\n\n```python\nfrom fastapi import HTTPException\nfrom loguru import logger\n\ntry:\n    result = await some_operation()\n    return result\nexcept InvalidInputError as e:\n    raise HTTPException(status_code=400, detail=str(e))\nexcept DatabaseOperationError as e:\n    logger.error(f\"Database error: {str(e)}\")\n    raise HTTPException(status_code=500, detail=\"Internal server error\")\n```\n\n### API Documentation\n\nUse FastAPI's automatic documentation features:\n\n```python\n@router.post(\n    \"/notebooks\",\n    response_model=NotebookResponse,\n    summary=\"Create a new notebook\",\n    description=\"Create a new notebook with the specified name and description.\",\n    responses={\n        201: {\"description\": \"Notebook created successfully\"},\n        400: {\"description\": \"Invalid input data\"},\n        500: {\"description\": \"Internal server error\"}\n    }\n)\nasync def create_notebook(notebook: NotebookCreate):\n    \"\"\"Create a new notebook.\"\"\"\n    # Implementation\n```\n\n## Database Standards\n\n### SurrealDB Patterns\n\nUse the repository pattern consistently:\n\n```python\nfrom open_notebook.database.repository import repo_create, repo_query, repo_update\n\n# Create records\nasync def create_notebook(data: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"Create a new notebook record.\"\"\"\n    return await repo_create(\"notebook\", data)\n\n# Query with parameters\nasync def find_notebooks_by_user(user_id: str) -> List[Dict[str, Any]]:\n    \"\"\"Find notebooks for a specific user.\"\"\"\n    return await repo_query(\n        \"SELECT * FROM notebook WHERE user_id = $user_id\",\n        {\"user_id\": user_id}\n    )\n\n# Update records\nasync def update_notebook(notebook_id: str, data: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"Update a notebook record.\"\"\"\n    return await repo_update(\"notebook\", notebook_id, data)\n```\n\n### Schema Management\n\nUse migrations for schema changes:\n\n```surrealql\n-- migrations/8.surrealql\nDEFINE TABLE IF NOT EXISTS new_feature SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS name ON TABLE new_feature TYPE string;\nDEFINE FIELD IF NOT EXISTS description ON TABLE new_feature TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS created ON TABLE new_feature TYPE datetime DEFAULT time::now();\nDEFINE FIELD IF NOT EXISTS updated ON TABLE new_feature TYPE datetime DEFAULT time::now();\n```\n\n## TypeScript Standards\n\n### Basic Guidelines\n\nFollow TypeScript best practices:\n\n- Use strict mode enabled in `tsconfig.json`\n- Use proper type annotations for all variables and functions\n- Avoid using `any` type unless absolutely necessary\n- Use `interface` for object shapes, `type` for unions and other advanced types\n\n### Component Structure\n\n- Use functional components with hooks\n- Keep components focused and single-responsibility\n- Extract reusable logic into custom hooks\n- Use proper TypeScript types for props\n\n### Error Handling\n\n- Handle errors explicitly\n- Provide meaningful error messages\n- Log errors appropriately\n- Don't suppress errors silently\n\n## Code Quality Tools\n\nWe use these tools to maintain code quality:\n\n- **Ruff**: Linting and code formatting\n  - Run with: `uv run ruff check . --fix`\n  - Format with: `uv run ruff format .`\n\n- **MyPy**: Static type checking\n  - Run with: `uv run python -m mypy .`\n\n- **Pytest**: Testing framework\n  - Run with: `uv run pytest`\n\n## Common Patterns\n\n### Async Database Operations\n\n```python\nasync def get_notebook_with_sources(notebook_id: str) -> Notebook:\n    \"\"\"Retrieve notebook with all related sources.\"\"\"\n    notebook_data = await repo_query(\n        \"SELECT * FROM notebook WHERE id = $id\",\n        {\"id\": notebook_id}\n    )\n    if not notebook_data:\n        raise InvalidInputError(f\"Notebook {notebook_id} not found\")\n\n    sources_data = await repo_query(\n        \"SELECT * FROM source WHERE notebook_id = $notebook_id\",\n        {\"notebook_id\": notebook_id}\n    )\n\n    return Notebook(\n        **notebook_data[0],\n        sources=[Source(**s) for s in sources_data]\n    )\n```\n\n### Model Validation\n\n```python\nfrom pydantic import BaseModel, validator\n\nclass NotebookInput(BaseModel):\n    name: str\n    description: str = \"\"\n\n    @validator('name')\n    def name_not_empty(cls, v):\n        if not v.strip():\n            raise ValueError('Name cannot be empty')\n        return v.strip()\n```\n\n## Code Review Checklist\n\nBefore submitting code for review, ensure:\n\n- [ ] Code follows PEP 8 / TypeScript best practices\n- [ ] Type hints are present for all functions\n- [ ] Docstrings are complete and accurate\n- [ ] Error handling is appropriate\n- [ ] Tests are included and passing\n- [ ] No debug code (console.logs, print statements) left behind\n- [ ] Commit messages are clear and follow conventions\n- [ ] Documentation is updated if needed\n\n---\n\n**See also:**\n- [Testing Guide](testing.md) - How to write tests\n- [Contributing Guide](contributing.md) - Overall contribution workflow\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/contributing.md",
    "content": "# Contributing to Open Notebook\n\nThank you for your interest in contributing to Open Notebook! We welcome contributions from developers of all skill levels. This guide will help you understand our contribution workflow and what makes a good contribution.\n\n## 🚨 Issue-First Workflow\n\n**To maintain project coherence and avoid wasted effort, please follow this process:**\n\n1. **Create an issue first** - Before writing any code, create an issue describing the bug or feature\n2. **Propose your solution** - Explain how you plan to implement the fix or feature\n3. **Wait for assignment** - A maintainer will review and assign the issue to you if approved\n4. **Only then start coding** - This ensures your work aligns with the project's vision and architecture\n\n**Why this process?**\n- Prevents duplicate work\n- Ensures solutions align with our architecture and design principles\n- Saves your time by getting feedback before coding\n- Helps maintainers manage the project direction\n\n> ⚠️ **Pull requests without an assigned issue may be closed**, even if the code is good. We want to respect your time by making sure work is aligned before it starts.\n\n## Code of Conduct\n\nBy participating in this project, you are expected to uphold our Code of Conduct. Be respectful, constructive, and collaborative.\n\n## How Can I Contribute?\n\n### Reporting Bugs\n\n1. **Search existing issues** - Check if the bug was already reported\n2. **Create a bug report** - Use the [Bug Report template](https://github.com/lfnovo/open-notebook/issues/new?template=bug_report.yml)\n3. **Provide details** - Include:\n   - Steps to reproduce\n   - Expected vs actual behavior\n   - Logs, screenshots, or error messages\n   - Your environment (OS, Docker version, Open Notebook version)\n4. **Indicate if you want to fix it** - Check the \"I would like to work on this\" box if you're interested\n\n### Suggesting Features\n\n1. **Search existing issues** - Check if the feature was already suggested\n2. **Create a feature request** - Use the [Feature Request template](https://github.com/lfnovo/open-notebook/issues/new?template=feature_request.yml)\n3. **Explain the value** - Describe why this feature would be helpful\n4. **Propose implementation** - If you have ideas on how to implement it, share them\n5. **Indicate if you want to build it** - Check the \"I would like to work on this\" box if you're interested\n\n### Contributing Code (Pull Requests)\n\n**IMPORTANT: Follow the issue-first workflow above before starting any PR**\n\nOnce your issue is assigned:\n\n1. **Fork the repo** and create your branch from `main`\n2. **Understand our vision and principles** - Read [design-principles.md](design-principles.md) to understand what guides our decisions\n3. **Follow our architecture** - Refer to the architecture documentation to understand project structure\n4. **Write quality code** - Follow the standards outlined in [code-standards.md](code-standards.md)\n5. **Test your changes** - See [testing.md](testing.md) for test guidelines\n6. **Update documentation** - If you changed functionality, update the relevant docs\n7. **Create your PR**:\n   - Reference the issue number (e.g., \"Fixes #123\")\n   - Describe what changed and why\n   - Include screenshots for UI changes\n   - Keep PRs focused - one issue per PR\n\n### What Makes a Good Contribution?\n\n✅ **We love PRs that:**\n- Solve a real problem described in an issue\n- Follow our architecture and coding standards\n- Include tests and documentation\n- Are well-scoped (focused on one thing)\n- Have clear commit messages\n\n❌ **We may close PRs that:**\n- Don't have an associated approved issue\n- Introduce breaking changes without discussion\n- Conflict with our architectural vision\n- Lack tests or documentation\n- Try to solve multiple unrelated problems\n\n## Git Commit Messages\n\n- Use the present tense (\"Add feature\" not \"Added feature\")\n- Use the imperative mood (\"Move cursor to...\" not \"Moves cursor to...\")\n- Limit the first line to 72 characters or less\n- Reference issues and pull requests liberally after the first line\n\n## Development Workflow\n\n### Branch Strategy\n\nWe use a **feature branch workflow**:\n\n1. **Main Branch**: `main` - production-ready code\n2. **Feature Branches**: `feature/description` - new features\n3. **Bug Fixes**: `fix/description` - bug fixes\n4. **Documentation**: `docs/description` - documentation updates\n\n### Making Changes\n\n1. **Create a feature branch**:\n```bash\ngit checkout -b feature/amazing-new-feature\n```\n\n2. **Make your changes** following our coding standards\n\n3. **Test your changes**:\n```bash\n# Run tests\nuv run pytest\n\n# Run linting\nuv run ruff check .\n\n# Run formatting\nuv run ruff format .\n```\n\n4. **Commit your changes**:\n```bash\ngit add .\ngit commit -m \"feat: add amazing new feature\"\n```\n\n5. **Push and create PR**:\n```bash\ngit push origin feature/amazing-new-feature\n# Then create a Pull Request on GitHub\n```\n\n### Keeping Your Fork Updated\n\n```bash\n# Fetch upstream changes\ngit fetch upstream\n\n# Switch to main and merge\ngit checkout main\ngit merge upstream/main\n\n# Push to your fork\ngit push origin main\n```\n\n## Pull Request Process\n\nWhen you create a pull request:\n\n1. **Link your issue** - Reference the issue number in PR description\n2. **Describe your changes** - Explain what changed and why\n3. **Provide test evidence** - Screenshots, test results, or logs\n4. **Check PR template** - Ensure you've completed all required sections\n5. **Wait for review** - A maintainer will review your PR within a week\n\n### PR Review Expectations\n\n- Code review feedback is about the code, not the person\n- Be open to suggestions and alternative approaches\n- Address review comments with clarity and respect\n- Ask questions if feedback is unclear\n\n## Current Priority Areas\n\nWe're actively looking for contributions in these areas:\n\n1. **Frontend Enhancement** - Help improve the Next.js/React UI with real-time updates and better UX\n2. **Testing** - Expand test coverage across all components\n3. **Performance** - Async processing improvements and caching\n4. **Documentation** - API examples and user guides\n5. **Integrations** - New content sources and AI providers\n\n## Getting Help\n\n### Community Support\n\n- **Discord**: [Join our Discord server](https://discord.gg/37XJPXfz2w) for real-time help\n- **GitHub Discussions**: For longer-form questions and ideas\n- **GitHub Issues**: For bug reports and feature requests\n\n### Documentation References\n\n- [Design Principles](design-principles.md) - Understanding our project vision\n- [Code Standards](code-standards.md) - Coding guidelines by language\n- [Testing Guide](testing.md) - How to write tests\n- [Development Setup](development-setup.md) - Getting started locally\n\n## Recognition\n\nWe recognize contributions through:\n\n- **GitHub credits** on releases\n- **Community recognition** in Discord\n- **Contribution statistics** in project analytics\n- **Maintainer consideration** for active contributors\n\n---\n\nThank you for contributing to Open Notebook! Your contributions help make research more accessible and private for everyone.\n\nFor questions about this guide or contributing in general, please reach out on [Discord](https://discord.gg/37XJPXfz2w) or open a GitHub Discussion.\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/design-principles.md",
    "content": "# Design Principles & Project Vision\n\nThis document outlines the core principles, vision, and design philosophy that guide Open Notebook's development. All contributors should read and understand these principles before proposing changes or new features.\n\n## 🎯 Project Vision\n\nOpen Notebook aims to be a **privacy-focused, self-hosted alternative to Google's Notebook LM** that empowers users to:\n\n1. **Own their research data** - Full control over where data lives and who can access it\n2. **Choose their AI providers** - Freedom to use any AI provider or run models locally\n3. **Customize their workflows** - Flexibility to adapt the tool to different research needs\n4. **Access their work anywhere** - Through web UI, API, or integrations\n\n### What Open Notebook IS\n\n- A **research assistant** for managing and understanding content\n- A **platform** that connects various AI providers\n- A **privacy-first** tool that keeps your data under your control\n- An **extensible system** with APIs and customization options\n\n### What Open Notebook IS NOT\n\n- A document editor (use Google Docs, Notion, etc. for that)\n- A file storage system (use Dropbox, S3, etc. for that)\n- A general-purpose chatbot (use ChatGPT, Claude, etc. for that)\n- A replacement for your entire workflow (it's one tool in your toolkit)\n\n## 🏗️ Core Design Principles\n\n### 1. Privacy First\n\n**Principle**: User data and research should stay under user control by default.\n\n**In Practice**:\n- Self-hosted deployment is the primary use case\n- No telemetry or analytics without explicit opt-in\n- No hard dependency on specific cloud services\n- Clear documentation on what data goes where\n\n**Example Decisions**:\n- ✅ Support for local Ollama models\n- ✅ Configurable AI provider selection\n- ❌ Hard-coded cloud service integrations\n- ❌ Required external service dependencies\n\n### 2. Simplicity Over Features\n\n**Principle**: The tool should be easy to understand and use, even if it means fewer features.\n\n**In Practice**:\n- Clear, focused UI with well-defined sections\n- Sensible defaults that work for most users\n- Advanced features hidden behind optional configuration\n- Documentation written for non-technical users\n\n**Example Decisions**:\n- ✅ Three-column layout (Sources, Notes, Chat)\n- ✅ Default models that work out of the box\n- ❌ Overwhelming users with too many options upfront\n- ❌ Complex multi-step workflows for basic tasks\n\n### 3. API-First Architecture\n\n**Principle**: All functionality should be accessible via API, not just the UI.\n\n**In Practice**:\n- UI calls the same API that external clients use\n- Comprehensive REST API with OpenAPI documentation\n- No \"UI-only\" features that can't be automated\n- Clear separation between frontend and backend\n\n**Example Decisions**:\n- ✅ FastAPI backend with full API documentation\n- ✅ Consistent API patterns across all endpoints\n- ❌ Business logic in UI components\n- ❌ Features that require direct database access\n\n### 4. Multi-Provider Flexibility\n\n**Principle**: Users should never be locked into a single AI provider.\n\n**In Practice**:\n- Support for multiple AI providers through Esperanto library\n- Easy switching between providers and models\n- Clear documentation on provider limitations\n- Graceful degradation when providers are unavailable\n\n**Example Decisions**:\n- ✅ Support for 16+ AI providers\n- ✅ Per-feature model selection (chat, embeddings, TTS)\n- ❌ Features that only work with OpenAI\n- ❌ Hard-coded API endpoints for specific providers\n\n### 5. Extensibility Through Standards\n\n**Principle**: The system should be extensible through well-defined interfaces, not by forking.\n\n**In Practice**:\n- Plugin systems for transformations and commands\n- Standard data formats (JSON, Markdown)\n- Clear extension points in the architecture\n- Documentation for common customization scenarios\n\n**Example Decisions**:\n- ✅ Custom transformation templates\n- ✅ Background command system\n- ✅ Jinja2 prompt templates\n- ❌ Hard-coded business logic without extension points\n\n### 6. Async-First for Performance\n\n**Principle**: Long-running operations should not block the user interface or API.\n\n**In Practice**:\n- Async/await patterns throughout the backend\n- Background job processing for heavy workloads\n- Status updates and progress tracking\n- Graceful handling of slow AI provider responses\n\n**Example Decisions**:\n- ✅ AsyncIO for database operations\n- ✅ Background commands for podcast generation\n- ✅ Streaming responses for chat\n- ❌ Synchronous blocking operations in API endpoints\n\n## 🎨 UI/UX Principles\n\n### Focus on Content, Not Chrome\n\n- Minimize UI clutter and distractions\n- Content should occupy most of the screen space\n- Controls appear when needed, not always visible\n- Consistent layout across different views\n\n### Progressive Disclosure\n\n- Show simple options first, advanced options on demand\n- Don't overwhelm new users with every possible setting\n- Provide sensible defaults that work for 80% of use cases\n- Make power features discoverable but not intrusive\n\n### Responsive and Fast\n\n- UI should feel instant for common operations\n- Show loading states for operations that take time\n- Cache and optimize where possible\n- Degrade gracefully on slow connections\n\n## 🔧 Technical Principles\n\n### Clean Separation of Concerns\n\n**Layers should not leak**:\n- Frontend should not know about database structure\n- API should not contain business logic (delegate to domain layer)\n- Domain models should not know about HTTP requests\n- Database layer should not know about AI providers\n\n### Type Safety and Validation\n\n**Catch errors early**:\n- Use Pydantic models for all API boundaries\n- Type hints throughout Python codebase\n- TypeScript for frontend code\n- Validate data at system boundaries\n\n### Test What Matters\n\n**Focus on valuable tests**:\n- Test business logic and domain models\n- Test API contracts and error handling\n- Don't test framework code (FastAPI, React, etc.)\n- Integration tests for critical workflows\n\n### Database as Source of Truth\n\n**SurrealDB is our single source of truth**:\n- All state persisted in database\n- No business logic in database layer\n- Use SurrealDB features (record links, queries) appropriately\n- Schema migrations for all schema changes\n\n## 🚫 Anti-Patterns to Avoid\n\n### Feature Creep\n\n**What it looks like**:\n- Adding features because they're \"cool\" or \"easy\"\n- Building features for edge cases before common cases work well\n- Trying to be everything to everyone\n\n**Why we avoid it**:\n- Increases complexity and maintenance burden\n- Makes the tool harder to learn and use\n- Dilutes the core value proposition\n\n**Instead**:\n- Focus on core use cases\n- Say no to features that don't align with vision\n- Build extensibility points for edge cases\n\n### Premature Optimization\n\n**What it looks like**:\n- Optimizing code before knowing if it's slow\n- Complex caching strategies without measuring impact\n- Trading code clarity for marginal performance gains\n\n**Why we avoid it**:\n- Makes code harder to understand and maintain\n- Optimizes the wrong things\n- Wastes development time\n\n**Instead**:\n- Measure first, optimize second\n- Focus on algorithmic improvements\n- Profile before making performance changes\n\n### Over-Engineering\n\n**What it looks like**:\n- Building abstraction layers \"in case we need them later\"\n- Implementing design patterns for 3-line functions\n- Creating frameworks instead of solving problems\n\n**Why we avoid it**:\n- Increases cognitive load for contributors\n- Makes simple changes require touching many files\n- Hides the actual business logic\n\n**Instead**:\n- Start simple, refactor when patterns emerge\n- Optimize for readability and clarity\n- Use abstractions when they simplify, not complicate\n\n### Breaking Changes Without Migration Path\n\n**What it looks like**:\n- Changing database schema without migration scripts\n- Modifying API contracts without versioning\n- Removing features without deprecation warnings\n\n**Why we avoid it**:\n- Breaks existing installations\n- Frustrates users and contributors\n- Creates maintenance nightmares\n\n**Instead**:\n- Always provide migration scripts for schema changes\n- Deprecate before removing\n- Document breaking changes clearly\n\n## 🤝 Decision-Making Framework\n\nWhen evaluating new features or changes, ask:\n\n### 1. Does it align with our vision?\n- Does it help users own their research data?\n- Does it support privacy and self-hosting?\n- Does it fit our core use cases?\n\n### 2. Does it follow our principles?\n- Is it simple to use and understand?\n- Does it work via API?\n- Does it support multiple providers?\n- Can it be extended by users?\n\n### 3. Is the implementation sound?\n- Does it maintain separation of concerns?\n- Is it properly typed and validated?\n- Does it include tests?\n- Is it documented?\n\n### 4. What is the cost?\n- How much complexity does it add?\n- How much maintenance burden?\n- Does it introduce new dependencies?\n- Will it be used enough to justify the cost?\n\n### 5. Are there alternatives?\n- Can existing features solve this problem?\n- Can this be built as a plugin or extension?\n- Should this be a separate tool instead?\n\n## 📚 Examples of Principle-Driven Decisions\n\n### Why we migrated from Streamlit to Next.js\n\n**Principle**: API-First Architecture\n\n**Reasoning**:\n- Streamlit coupled UI and backend logic\n- Difficult to build external integrations\n- Limited control over API behavior\n- Next.js + FastAPI provides clear separation\n\n### Why we use Esperanto for AI providers\n\n**Principle**: Multi-Provider Flexibility\n\n**Reasoning**:\n- Abstracts provider-specific details\n- Easy to add new providers\n- Consistent interface across providers\n- No vendor lock-in\n\n### Why we have a Background Command System\n\n**Principle**: Async-First for Performance\n\n**Reasoning**:\n- Podcast generation takes minutes\n- Users shouldn't wait for long operations\n- Need status tracking and error handling\n- Supports future batch operations\n\n### Why we support Local Ollama\n\n**Principle**: Privacy First\n\n**Reasoning**:\n- Enables fully offline operation\n- No data sent to external services\n- Free for users after hardware cost\n- Aligns with self-hosted philosophy\n\n## 🔄 Evolution of Principles\n\nThese principles are not set in stone. As the project grows and we learn from users, some principles may evolve. However, changes to core principles should be:\n\n1. **Well-justified** - Clear reasoning for why the change is needed\n2. **Discussed openly** - Community input on major changes\n3. **Documented** - Updated in this document with explanation\n4. **Gradual** - Not implemented as breaking changes when possible\n\n---\n\n## For Contributors\n\nWhen proposing a feature or change:\n\n1. **Reference these principles** - Explain how your proposal aligns\n2. **Identify trade-offs** - Be honest about what you're trading for what\n3. **Suggest alternatives** - Show you've considered other approaches\n4. **Be open to feedback** - Maintainers may see concerns you don't\n\n**Remember**: A \"no\" to a feature isn't a judgment on you or your idea. It means we're staying focused on our core vision. We appreciate all contributions and ideas!\n\n---\n\n**Questions about these principles?** Open a discussion on GitHub or join our [Discord](https://discord.gg/37XJPXfz2w).\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/development-setup.md",
    "content": "# Local Development Setup\n\nThis guide walks you through setting up Open Notebook for local development. Follow these steps to get the full stack running on your machine.\n\n## Prerequisites\n\nBefore you start, ensure you have the following installed:\n\n- **Python 3.11+** - Check with: `python --version`\n- **uv** (recommended) or **pip** - Install from: https://github.com/astral-sh/uv\n- **SurrealDB** - Via Docker or binary (see below)\n- **Docker** (optional) - For containerized database\n- **Node.js 18+** (optional) - For frontend development\n- **Git** - For version control\n\n## Step 1: Clone and Initial Setup\n\n```bash\n# Clone the repository\ngit clone https://github.com/lfnovo/open-notebook.git\ncd open-notebook\n\n# Add upstream remote for keeping your fork updated\ngit remote add upstream https://github.com/lfnovo/open-notebook.git\n```\n\n## Step 2: Install Python Dependencies\n\n```bash\n# Using uv (recommended)\nuv sync\n\n# Or using pip\npip install -e .\n```\n\n## Step 3: Environment Variables\n\nCreate a `.env` file in the project root with your configuration:\n\n```bash\n# Copy from example\ncp .env.example .env\n```\n\nEdit `.env` with your settings:\n\n```bash\n# Database\nSURREAL_URL=ws://localhost:8000/rpc\nSURREAL_USER=root\nSURREAL_PASSWORD=password\nSURREAL_NAMESPACE=open_notebook\nSURREAL_DATABASE=development\n\n# Credential encryption (required for storing API keys)\nOPEN_NOTEBOOK_ENCRYPTION_KEY=my-dev-secret-key\n\n# Application\nAPP_PASSWORD=  # Optional password protection\nDEBUG=true\nLOG_LEVEL=DEBUG\n```\n\n### AI Provider Configuration\n\nAfter starting the API and frontend, configure your AI provider via the Settings UI:\n\n1. Open **http://localhost:3000** → **Settings** → **API Keys**\n2. Click **Add Credential** → Select your provider\n3. Enter your API key (get from provider dashboard)\n4. Click **Save**, then **Test Connection**\n5. Click **Discover Models** → **Register Models**\n\nPopular providers:\n- **OpenAI** - https://platform.openai.com/api-keys\n- **Anthropic (Claude)** - https://console.anthropic.com/\n- **Google** - https://ai.google.dev/\n- **Groq** - https://console.groq.com/\n\nFor local development, you can also use:\n- **Ollama** - Run locally without API keys (see \"Local Ollama\" below)\n\n> **Note:** API key environment variables (e.g., `OPENAI_API_KEY`) are deprecated. Use the Settings UI to manage credentials instead.\n\n## Step 4: Start SurrealDB\n\n### Option A: Using Docker (Recommended)\n\n```bash\n# Start SurrealDB in memory\ndocker run -d --name surrealdb -p 8000:8000 \\\n  surrealdb/surrealdb:v2 start \\\n  --user root --pass password \\\n  --bind 0.0.0.0:8000 memory\n\n# Or with persistent storage\ndocker run -d --name surrealdb -p 8000:8000 \\\n  -v surrealdb_data:/data \\\n  surrealdb/surrealdb:v2 start \\\n  --user root --pass password \\\n  --bind 0.0.0.0:8000 file:/data/surreal.db\n```\n\n### Option B: Using Make\n\n```bash\nmake database\n```\n\n### Option C: Using Docker Compose\n\n```bash\ndocker compose up -d surrealdb\n```\n\n### Verify SurrealDB is Running\n\n```bash\n# Should show server information\ncurl http://localhost:8000/\n```\n\n## Step 5: Run Database Migrations\n\nDatabase migrations run automatically when you start the API. The first startup will apply any pending migrations.\n\nTo verify migrations manually:\n\n```bash\n# API will run migrations on startup\nuv run python -m api.main\n```\n\nCheck the logs - you should see messages like:\n```\nRunning migration 001_initial_schema\nRunning migration 002_add_vectors\n...\nMigrations completed successfully\n```\n\n## Step 6: Start the API Server\n\nIn a new terminal window:\n\n```bash\n# Terminal 2: Start API (port 5055)\nuv run --env-file .env uvicorn api.main:app --host 0.0.0.0 --port 5055\n\n# Or using the shortcut\nmake api\n```\n\nYou should see:\n```\nINFO:     Application startup complete\nINFO:     Uvicorn running on http://0.0.0.0:5055\n```\n\n### Verify API is Running\n\n```bash\n# Check health endpoint\ncurl http://localhost:5055/health\n\n# View API documentation\nopen http://localhost:5055/docs\n```\n\n## Step 7: Start the Frontend (Optional)\n\nIf you want to work on the frontend, start Next.js in another terminal:\n\n```bash\n# Terminal 3: Start Next.js frontend (port 3000)\ncd frontend\nnpm install  # First time only\nnpm run dev\n```\n\nYou should see:\n```\n> next dev\n  ▲ Next.js 16.x\n  - Local:        http://localhost:3000\n```\n\n### Access the Frontend\n\nOpen your browser to: http://localhost:3000\n\n## Verification Checklist\n\nAfter setup, verify everything is working:\n\n- [ ] **SurrealDB**: `curl http://localhost:8000/` returns content\n- [ ] **API**: `curl http://localhost:5055/health` returns `{\"status\": \"ok\"}`\n- [ ] **API Docs**: `open http://localhost:5055/docs` works\n- [ ] **Database**: API logs show migrations completing\n- [ ] **Frontend** (optional): `http://localhost:3000` loads\n\n## Starting Services Together\n\n### Quick Start All Services\n\n```bash\nmake start-all\n```\n\nThis starts SurrealDB, API, and frontend in one command.\n\n### Individual Terminals (Recommended for Development)\n\n**Terminal 1 - Database:**\n```bash\nmake database\n```\n\n**Terminal 2 - API:**\n```bash\nmake api\n```\n\n**Terminal 3 - Frontend:**\n```bash\ncd frontend && npm run dev\n```\n\n## Development Tools Setup\n\n### Pre-commit Hooks (Optional but Recommended)\n\nInstall git hooks to automatically check code quality:\n\n```bash\nuv run pre-commit install\n```\n\nNow your commits will be checked before they're made.\n\n### Code Quality Commands\n\n```bash\n# Lint Python code (auto-fix)\nmake ruff\n# or: ruff check . --fix\n\n# Type check Python code\nmake lint\n# or: uv run python -m mypy .\n\n# Run tests\nuv run pytest\n\n# Run tests with coverage\nuv run pytest --cov=open_notebook\n```\n\n## Common Development Tasks\n\n### Running Tests\n\n```bash\n# Run all tests\nuv run pytest\n\n# Run specific test file\nuv run pytest tests/test_notebooks.py\n\n# Run with coverage report\nuv run pytest --cov=open_notebook --cov-report=html\n```\n\n### Creating a Feature Branch\n\n```bash\n# Create and switch to new branch\ngit checkout -b feature/my-feature\n\n# Make changes, then commit\ngit add .\ngit commit -m \"feat: add my feature\"\n\n# Push to your fork\ngit push origin feature/my-feature\n```\n\n### Updating from Upstream\n\n```bash\n# Fetch latest changes\ngit fetch upstream\n\n# Rebase your branch\ngit rebase upstream/main\n\n# Push updated branch\ngit push origin feature/my-feature -f\n```\n\n## Troubleshooting\n\n### \"Connection refused\" on SurrealDB\n\n**Problem**: API can't connect to SurrealDB\n\n**Solutions**:\n1. Check if SurrealDB is running: `docker ps | grep surrealdb`\n2. Verify URL in `.env`: Should be `ws://localhost:8000/rpc`\n3. Restart SurrealDB: `docker stop surrealdb && docker rm surrealdb`\n4. Then restart with: `docker run -d --name surrealdb -p 8000:8000 surrealdb/surrealdb:v2 start --user root --pass password --bind 0.0.0.0:8000 memory`\n\n### \"Address already in use\"\n\n**Problem**: Port 5055 or 3000 is already in use\n\n**Solutions**:\n```bash\n# Find process using port\nlsof -i :5055  # Check port 5055\n\n# Kill process (macOS/Linux)\nkill -9 <PID>\n\n# Or use different port\nuvicorn api.main:app --port 5056\n```\n\n### Module not found errors\n\n**Problem**: Import errors when running API\n\n**Solutions**:\n```bash\n# Reinstall dependencies\nuv sync\n\n# Or with pip\npip install -e .\n```\n\n### Database migration failures\n\n**Problem**: API fails to start with migration errors\n\n**Solutions**:\n1. Check SurrealDB is running: `curl http://localhost:8000/`\n2. Check credentials in `.env` match your SurrealDB setup\n3. Check logs for specific migration error: `make api 2>&1 | grep -i migration`\n4. Verify database exists: Check SurrealDB console at http://localhost:8000/\n\n### Migrations not applying\n\n**Problem**: Database schema seems outdated\n\n**Solutions**:\n1. Restart API - migrations run on startup: `make api`\n2. Check logs show \"Migrations completed successfully\"\n3. Verify `/migrations/` folder exists and has files\n4. Check SurrealDB is writable and not in read-only mode\n\n## Optional: Local Ollama Setup\n\nFor testing with local AI models:\n\n```bash\n# Install Ollama from https://ollama.ai\n\n# Pull a model (e.g., Mistral 7B)\nollama pull mistral\n```\n\nThen configure via the Settings UI:\n1. Go to **Settings** → **API Keys** → **Add Credential** → **Ollama**\n2. Enter base URL: `http://localhost:11434`\n3. Click **Save**, then **Test Connection**\n4. Click **Discover Models** → **Register Models**\n\n## Optional: Docker Development Environment\n\nRun entire stack in Docker:\n\n```bash\n# Start all services\ndocker compose --profile multi up\n\n# Logs\ndocker compose logs -f\n\n# Stop services\ndocker compose down\n```\n\n## Next Steps\n\nAfter setup is complete:\n\n1. **Read the Contributing Guide** - [contributing.md](contributing.md)\n2. **Explore the Architecture** - Check the documentation\n3. **Find an Issue** - Look for \"good first issue\" on GitHub\n4. **Set Up Pre-commit** - Install git hooks for code quality\n5. **Join Discord** - https://discord.gg/37XJPXfz2w\n\n## Getting Help\n\nIf you get stuck:\n\n- **Discord**: [Join our server](https://discord.gg/37XJPXfz2w) for real-time help\n- **GitHub Issues**: Check existing issues for similar problems\n- **GitHub Discussions**: Ask questions in discussions\n- **Documentation**: See [code-standards.md](code-standards.md) and [testing.md](testing.md)\n\n---\n\n**Ready to contribute?** Go to [contributing.md](contributing.md) for the contribution workflow.\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/index.md",
    "content": "# Development\n\nWelcome to the Open Notebook development documentation! Whether you're contributing code, understanding our architecture, or maintaining the project, you'll find guidance here.\n\n## 🎯 Pick Your Path\n\n### 👨‍💻 I Want to Contribute Code\n\nStart with **[Contributing Guide](contributing.md)** for the workflow, then check:\n- **[Quick Start](quick-start.md)** - Clone, install, verify in 5 minutes\n- **[Development Setup](development-setup.md)** - Complete local environment guide\n- **[Code Standards](code-standards.md)** - How to write code that fits our style\n- **[Testing](testing.md)** - How to write and run tests\n\n**First time?** Check out our [Contributing Guide](contributing.md) for the issue-first workflow.\n\n---\n\n### 🏗️ I Want to Understand the Architecture\n\n**[Architecture Overview](architecture.md)** covers:\n- 3-tier system design\n- Tech stack and rationale\n- Key components and workflows\n- Design patterns we use\n\nFor deeper dives, check `/open_notebook/` CLAUDE.md for component-specific guidance.\n\n---\n\n### 👨‍🔧 I'm a Maintainer\n\n**[Maintainer Guide](maintainer-guide.md)** covers:\n- Issue triage and management\n- Pull request review process\n- Communication templates\n- Best practices\n\n---\n\n## 📚 Quick Links\n\n| Document | For | Purpose |\n|---|---|---|\n| [Quick Start](quick-start.md) | New developers | Clone, install, and verify setup (5 min) |\n| [Development Setup](development-setup.md) | Local development | Complete environment setup guide |\n| [Contributing](contributing.md) | Code contributors | Workflow: issue → code → PR |\n| [Code Standards](code-standards.md) | Writing code | Style guides for Python, FastAPI, DB |\n| [Testing](testing.md) | Testing code | How to write and run tests |\n| [Architecture](architecture.md) | Understanding system | System design, tech stack, workflows |\n| [Design Principles](design-principles.md) | All developers | What guides our decisions |\n| [API Reference](api-reference.md) | Building integrations | Complete REST API documentation |\n| [Maintainer Guide](maintainer-guide.md) | Maintainers | Managing issues, PRs, releases |\n\n---\n\n## 🚀 Current Development Priorities\n\nWe're actively looking for help with:\n\n1. **Frontend Enhancement** - Improve Next.js/React UI with real-time updates\n2. **Performance** - Async processing and caching optimizations\n3. **Testing** - Expand test coverage across components\n4. **Documentation** - API examples and developer guides\n5. **Integrations** - New content sources and AI providers\n\nSee GitHub Issues labeled `good first issue` or `help wanted`.\n\n---\n\n## 💬 Getting Help\n\n- **Discord**: [Join our server](https://discord.gg/37XJPXfz2w) for real-time discussions\n- **GitHub Discussions**: For architecture questions\n- **GitHub Issues**: For bugs and features\n\nDon't be shy! We're here to help new contributors succeed.\n\n---\n\n## 📖 Additional Resources\n\n### External Documentation\n- [FastAPI Docs](https://fastapi.tiangolo.com/)\n- [SurrealDB Docs](https://surrealdb.com/docs)\n- [LangChain Docs](https://python.langchain.com/)\n- [Next.js Docs](https://nextjs.org/docs)\n\n### Our Libraries\n- [Esperanto](https://github.com/lfnovo/esperanto) - Multi-provider AI abstraction\n- [Content Core](https://github.com/lfnovo/content-core) - Content processing\n- [Podcast Creator](https://github.com/lfnovo/podcast-creator) - Podcast generation\n\n---\n\nReady to get started? Head over to **[Quick Start](quick-start.md)**! 🎉\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/maintainer-guide.md",
    "content": "# Maintainer Guide\n\nThis guide is for project maintainers to help manage contributions effectively while maintaining project quality and vision.\n\n## Table of Contents\n\n- [Issue Management](#issue-management)\n- [Pull Request Review](#pull-request-review)\n- [Common Scenarios](#common-scenarios)\n- [Communication Templates](#communication-templates)\n\n## Issue Management\n\n### When a New Issue is Created\n\n**1. Initial Triage** (within 24-48 hours)\n\n- Add appropriate labels:\n  - `bug`, `enhancement`, `documentation`, etc.\n  - `good first issue` for beginner-friendly tasks\n  - `needs-triage` until reviewed\n  - `help wanted` if you'd welcome community contributions\n\n- Quick assessment:\n  - Is it clear and well-described?\n  - Is it aligned with project vision? (See [design-principles.md](design-principles.md))\n  - Does it duplicate an existing issue?\n\n**2. Initial Response**\n\n```markdown\nThanks for opening this issue! We'll review it and get back to you soon.\n\n[If it's a bug] In the meantime, have you checked our troubleshooting guide?\n\n[If it's a feature] You might find our [design principles](design-principles.md) helpful for understanding what we're building toward.\n```\n\n**3. Decision Making**\n\nAsk yourself:\n- Does this align with our [design principles](design-principles.md)?\n- Is this something we want in the core project, or better as a plugin/extension?\n- Do we have the capacity to support this feature long-term?\n- Will this benefit most users, or just a specific use case?\n\n**4. Issue Assignment**\n\nIf the contributor checked \"I am a developer and would like to work on this\":\n\n**For Accepted Issues:**\n```markdown\nGreat idea! This aligns well with our goals, particularly [specific design principle].\n\nI see you'd like to work on this. Before you start:\n\n1. Please share your proposed approach/solution\n2. Review our [Contributing Guide](contributing.md) and [Design Principles](design-principles.md)\n3. Once we agree on the approach, I'll assign this to you\n\nLooking forward to your thoughts!\n```\n\n**For Issues Needing Clarification:**\n```markdown\nThanks for offering to work on this! Before we proceed, we need to clarify a few things:\n\n1. [Question 1]\n2. [Question 2]\n\nOnce we have these details, we can discuss the best approach.\n```\n\n**For Issues Not Aligned with Vision:**\n```markdown\nThank you for the suggestion and for offering to work on this!\n\nAfter reviewing against our [design principles](design-principles.md), we've decided not to pursue this in the core project because [specific reason].\n\nHowever, you might be able to achieve this through [alternative approach, if applicable].\n\nWe appreciate your interest in contributing! Feel free to check out our [open issues](link) for other ways to contribute.\n```\n\n### Labels to Use\n\n**Priority:**\n- `priority: critical` - Security issues, data loss bugs\n- `priority: high` - Major functionality broken\n- `priority: medium` - Annoying bugs, useful features\n- `priority: low` - Nice to have, edge cases\n\n**Status:**\n- `needs-triage` - Not yet reviewed by maintainer\n- `needs-info` - Waiting for more information from reporter\n- `needs-discussion` - Requires community/team discussion\n- `ready` - Approved and ready to be worked on\n- `in-progress` - Someone is actively working on this\n- `blocked` - Cannot proceed due to external dependency\n\n**Type:**\n- `bug` - Something is broken\n- `enhancement` - New feature or improvement\n- `documentation` - Documentation improvements\n- `question` - General questions\n- `refactor` - Code cleanup/restructuring\n\n**Difficulty:**\n- `good first issue` - Good for newcomers\n- `help wanted` - Community contributions welcome\n- `advanced` - Requires deep codebase knowledge\n\n## Pull Request Review\n\n### Initial PR Review Checklist\n\n**Before diving into code:**\n\n- [ ] Is there an associated approved issue?\n- [ ] Does the PR reference the issue number?\n- [ ] Is the PR description clear about what changed and why?\n- [ ] Did the contributor check the relevant boxes in the PR template?\n- [ ] Are there tests? Screenshots (for UI changes)?\n\n**Red Flags** (may require closing PR):\n- No associated issue\n- Issue was not assigned to contributor\n- PR tries to solve multiple unrelated problems\n- Breaking changes without discussion\n- Conflicts with project vision\n\n### Code Review Process\n\n**1. High-Level Review**\n\n- Does the approach align with our architecture?\n- Is the solution appropriately scoped?\n- Are there simpler alternatives?\n- Does it follow our design principles?\n\n**2. Code Quality Review**\n\nPython:\n- [ ] Follows PEP 8\n- [ ] Has type hints\n- [ ] Has docstrings\n- [ ] Proper error handling\n- [ ] No security vulnerabilities\n\nTypeScript/Frontend:\n- [ ] Follows TypeScript best practices\n- [ ] Proper component structure\n- [ ] No console.logs left in production code\n- [ ] Accessible UI components\n\n**3. Testing Review**\n\n- [ ] Has appropriate test coverage\n- [ ] Tests are meaningful (not just for coverage percentage)\n- [ ] Tests pass locally and in CI\n- [ ] Edge cases are tested\n\n**4. Documentation Review**\n\n- [ ] Code is well-commented\n- [ ] Complex logic is explained\n- [ ] User-facing documentation updated (if applicable)\n- [ ] API documentation updated (if API changed)\n- [ ] Migration guide provided (if breaking change)\n\n### Providing Feedback\n\n**Positive Feedback** (important!):\n```markdown\nThanks for this PR! I really like [specific thing they did well].\n\n[Feedback on what needs to change]\n```\n\n**Requesting Changes:**\n```markdown\nThis is a great start! A few things to address:\n\n1. **[High-level concern]**: [Explanation and suggested approach]\n2. **[Code quality issue]**: [Specific example and fix]\n3. **[Testing gap]**: [What scenarios need coverage]\n\nLet me know if you have questions about any of this!\n```\n\n**Suggesting Alternative Approach:**\n```markdown\nI appreciate the effort you put into this! However, I'm concerned about [specific issue].\n\nHave you considered [alternative approach]? It might be better because [reasons].\n\nWhat do you think?\n```\n\n## Common Scenarios\n\n### Scenario 1: Good Code, Wrong Approach\n\n**Situation**: Contributor wrote quality code, but solved the problem in a way that doesn't fit our architecture.\n\n**Response:**\n```markdown\nThank you for this PR! The code quality is great, and I can see you put thought into this.\n\nHowever, I'm concerned that this approach [specific architectural concern]. In our architecture, we [explain the pattern we follow].\n\nWould you be open to refactoring this to [suggested approach]? I'm happy to provide guidance on the specifics.\n\nAlternatively, if you don't have time for a refactor, I can take over and finish this up (with credit to you, of course).\n\nLet me know what you prefer!\n```\n\n### Scenario 2: PR Without Assigned Issue\n\n**Situation**: Contributor submitted PR without going through issue approval process.\n\n**Response:**\n```markdown\nThanks for the PR! I appreciate you taking the time to contribute.\n\nHowever, to maintain project coherence, we require all PRs to be linked to an approved issue that was assigned to the contributor. This is explained in our [Contributing Guide](contributing.md).\n\nThis helps us:\n- Ensure work aligns with project vision\n- Prevent duplicate efforts\n- Discuss approach before implementation\n\nCould you please:\n1. Create an issue describing this change\n2. Wait for it to be reviewed and assigned to you\n3. We can then reopen this PR or you can create a new one\n\nSorry for the inconvenience - this process helps us manage the project effectively.\n```\n\n### Scenario 3: Feature Request Not Aligned with Vision\n\n**Situation**: Well-intentioned feature that doesn't fit project goals.\n\n**Response:**\n```markdown\nThank you for this suggestion! I can see how this would be useful for [specific use case].\n\nAfter reviewing against our [design principles](design-principles.md), we've decided not to include this in the core project because [specific reason - e.g., \"it conflicts with our 'Simplicity Over Features' principle\" or \"it would require dependencies that conflict with our privacy-first approach\"].\n\nSome alternatives:\n- [If applicable] This could be built as a plugin/extension\n- [If applicable] This functionality might be achievable through [existing feature]\n- [If applicable] You might be interested in [other tool] which is designed for this use case\n\nWe appreciate your contribution and hope you understand. Feel free to check our roadmap or open issues for other ways to contribute!\n```\n\n### Scenario 4: Contributor Ghosts After Feedback\n\n**Situation**: You requested changes, but contributor hasn't responded in 2+ weeks.\n\n**After 2 weeks:**\n```markdown\nHey there! Just checking in on this PR. Do you have time to address the feedback, or would you like someone else to take over?\n\nNo pressure either way - just want to make sure this doesn't fall through the cracks.\n```\n\n**After 1 month with no response:**\n```markdown\nThanks again for starting this work! Since we haven't heard back, I'm going to close this PR for now.\n\nIf you want to pick this up again in the future, feel free to reopen it or create a new PR. Alternatively, I'll mark the issue as available for someone else to work on.\n\nWe appreciate your contribution!\n```\n\nThen:\n- Close the PR\n- Unassign the issue\n- Add `help wanted` label to the issue\n\n### Scenario 5: Breaking Changes Without Discussion\n\n**Situation**: PR introduces breaking changes that weren't discussed.\n\n**Response:**\n```markdown\nThanks for this PR! However, I notice this introduces breaking changes that weren't discussed in the original issue.\n\nBreaking changes require:\n1. Prior discussion and approval\n2. Migration guide for users\n3. Deprecation period (when possible)\n4. Clear documentation of the change\n\nCould we discuss the breaking changes first? Specifically:\n- [What breaks and why]\n- [Who will be affected]\n- [Migration path]\n\nWe may need to adjust the approach to minimize impact on existing users.\n```\n\n## Communication Templates\n\n### Closing a PR (Misaligned with Vision)\n\n```markdown\nThank you for taking the time to contribute! We really appreciate it.\n\nAfter careful review, we've decided not to merge this PR because [specific reason related to design principles].\n\nThis isn't a reflection on your code quality - it's about maintaining focus on our core goals as outlined in [design-principles.md](design-principles.md).\n\nWe'd love to have you contribute in other ways! Check out:\n- Good first issues\n- Help wanted issues\n- Our roadmap\n\nThanks again for your interest in Open Notebook!\n```\n\n### Closing a Stale Issue\n\n```markdown\nWe're closing this issue due to inactivity. If this is still relevant, feel free to reopen it with updated information.\n\nThanks!\n```\n\n### Asking for More Information\n\n```markdown\nThanks for reporting this! To help us investigate, could you provide:\n\n1. [Specific information needed]\n2. [Logs, screenshots, etc.]\n3. [Steps to reproduce]\n\nThis will help us understand the issue better and find a solution.\n```\n\n### Thanking a Contributor\n\n```markdown\nMerged!\n\nThank you so much for this contribution, @username! [Specific thing they did well].\n\nThis will be included in the next release.\n```\n\n## Best Practices\n\n### Be Kind and Respectful\n\n- Thank contributors for their time and effort\n- Assume good intentions\n- Be patient with newcomers\n- Explain *why*, not just *what*\n\n### Be Clear and Direct\n\n- Don't leave ambiguity about next steps\n- Be specific about what needs to change\n- Explain architectural decisions\n- Set clear expectations\n\n### Be Consistent\n\n- Apply the same standards to all contributors\n- Follow the process you've defined\n- Document decisions for future reference\n\n### Be Protective of Project Vision\n\n- It's okay to say \"no\"\n- Prioritize long-term maintainability\n- Don't accept features you can't support\n- Keep the project focused\n\n### Be Responsive\n\n- Respond to issues within 48 hours (even just to acknowledge)\n- Review PRs within a week when possible\n- Keep contributors updated on status\n- Close stale issues/PRs to keep things tidy\n\n## When in Doubt\n\nAsk yourself:\n1. Does this align with our [design principles](design-principles.md)?\n2. Will we be able to maintain this feature long-term?\n3. Does this benefit most users, or just an edge case?\n4. Is there a simpler alternative?\n5. Would I want to support this in 2 years?\n\nIf you're unsure, it's perfectly fine to:\n- Ask for input from other maintainers\n- Start a discussion issue\n- Sleep on it before making a decision\n\n---\n\n**Remember**: Good maintainership is about balancing openness to contributions with protection of project vision. You're not being mean by saying \"no\" to things that don't fit - you're being a responsible steward of the project.\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/quick-start.md",
    "content": "# Quick Start - Development\n\nGet Open Notebook running locally in 5 minutes.\n\n## Prerequisites\n\n- **Python 3.11+**\n- **Git**\n- **uv** (package manager) - install with `curl -LsSf https://astral.sh/uv/install.sh | sh`\n- **Docker** (optional, for SurrealDB)\n\n## 1. Clone the Repository (2 min)\n\n```bash\n# Fork the repository on GitHub first, then clone your fork\ngit clone https://github.com/YOUR_USERNAME/open-notebook.git\ncd open-notebook\n\n# Add upstream remote for updates\ngit remote add upstream https://github.com/lfnovo/open-notebook.git\n```\n\n## 2. Install Dependencies (2 min)\n\n```bash\n# Install Python dependencies\nuv sync\n\n# Verify uv is working\nuv --version\n```\n\n## 3. Start Services (1 min)\n\nIn separate terminal windows:\n\n```bash\n# Terminal 1: Start SurrealDB (database)\nmake database\n# or: docker run -d --name surrealdb -p 8000:8000 surrealdb/surrealdb:v2 start --user root --pass password --bind 0.0.0.0:8000 memory\n\n# Terminal 2: Start API (backend on port 5055)\nmake api\n# or: uv run --env-file .env uvicorn api.main:app --host 0.0.0.0 --port 5055\n\n# Terminal 3: Start Frontend (UI on port 3000)\ncd frontend && npm run dev\n```\n\n## 4. Verify Everything Works (instant)\n\n- **API Health**: http://localhost:5055/health → should return `{\"status\": \"ok\"}`\n- **API Docs**: http://localhost:5055/docs → interactive API documentation\n- **Frontend**: http://localhost:3000 → Open Notebook UI\n\n**All three show up?** ✅ You're ready to develop!\n\n---\n\n## Next Steps\n\n- **First Issue?** Pick a [good first issue](https://github.com/lfnovo/open-notebook/issues?q=label%3A%22good+first+issue%22)\n- **Understand the code?** Read [Architecture Overview](architecture.md)\n- **Make changes?** Follow [Contributing Guide](contributing.md)\n- **Setup details?** See [Development Setup](development-setup.md)\n\n---\n\n## Troubleshooting\n\n### \"Port 5055 already in use\"\n```bash\n# Find what's using the port\nlsof -i :5055\n\n# Use a different port\nuv run uvicorn api.main:app --port 5056\n```\n\n### \"Can't connect to SurrealDB\"\n```bash\n# Check if SurrealDB is running\ndocker ps | grep surrealdb\n\n# Restart it\nmake database\n```\n\n### \"Python version is too old\"\n```bash\n# Check your Python version\npython --version  # Should be 3.11+\n\n# Use Python 3.11 specifically\nuv sync --python 3.11\n```\n\n### \"npm: command not found\"\n```bash\n# Install Node.js from https://nodejs.org/\n# Then install frontend dependencies\ncd frontend && npm install\n```\n\n---\n\n## Common Development Commands\n\n```bash\n# Run tests\nuv run pytest\n\n# Format code\nmake ruff\n\n# Type checking\nmake lint\n\n# Run the full stack\nmake start-all\n\n# View API documentation\nopen http://localhost:5055/docs\n```\n\n---\n\nNeed more help? See [Development Setup](development-setup.md) for details or join our [Discord](https://discord.gg/37XJPXfz2w).\n"
  },
  {
    "path": "docs/7-DEVELOPMENT/testing.md",
    "content": "# Testing Guide\n\nThis document provides guidelines for writing tests in Open Notebook. Testing is critical to maintaining code quality and preventing regressions.\n\n## Testing Philosophy\n\n### What to Test\n\nFocus on testing the things that matter most:\n\n- **Business Logic** - Core domain models and their operations\n- **API Contracts** - HTTP endpoint behavior and error handling\n- **Critical Workflows** - End-to-end flows that users depend on\n- **Data Persistence** - Database operations and data integrity\n- **Error Conditions** - How the system handles failures gracefully\n\n### What NOT to Test\n\nDon't waste time testing framework code:\n\n- Framework functionality (FastAPI, React, etc.)\n- Third-party library implementation\n- Simple getters/setters without logic\n- View/presentation layer rendering (unless it contains logic)\n\n## Test Structure\n\nWe use **pytest** with async support for all Python tests:\n\n```python\nimport pytest\nfrom httpx import AsyncClient\nfrom open_notebook.domain.notebook import Notebook\n\n@pytest.mark.asyncio\nasync def test_create_notebook():\n    \"\"\"Test notebook creation.\"\"\"\n    notebook = Notebook(name=\"Test Notebook\", description=\"Test description\")\n    await notebook.save()\n\n    assert notebook.id is not None\n    assert notebook.name == \"Test Notebook\"\n    assert notebook.created is not None\n\n@pytest.mark.asyncio\nasync def test_api_create_notebook():\n    \"\"\"Test notebook creation via API.\"\"\"\n    async with AsyncClient(app=app, base_url=\"http://test\") as client:\n        response = await client.post(\n            \"/api/notebooks\",\n            json={\"name\": \"Test Notebook\", \"description\": \"Test description\"}\n        )\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"name\"] == \"Test Notebook\"\n```\n\n## Test Categories\n\n### 1. Unit Tests\n\nTest individual functions and methods in isolation:\n\n```python\n@pytest.mark.asyncio\nasync def test_notebook_validation():\n    \"\"\"Test that notebook name validation works.\"\"\"\n    with pytest.raises(InvalidInputError):\n        Notebook(name=\"\", description=\"test\")\n\n@pytest.mark.asyncio\nasync def test_notebook_archive():\n    \"\"\"Test notebook archiving.\"\"\"\n    notebook = Notebook(name=\"Test\", description=\"\")\n    notebook.archive()\n    assert notebook.archived is True\n```\n\n**Location**: `tests/unit/`\n\n### 2. Integration Tests\n\nTest component interactions and database operations:\n\n```python\n@pytest.mark.asyncio\nasync def test_create_notebook_with_sources():\n    \"\"\"Test creating a notebook and adding sources.\"\"\"\n    notebook = await create_notebook(name=\"Research\", description=\"\")\n    source = await add_source(notebook_id=notebook.id, url=\"https://example.com\")\n\n    retrieved = await get_notebook_with_sources(notebook.id)\n    assert len(retrieved.sources) == 1\n    assert retrieved.sources[0].id == source.id\n```\n\n**Location**: `tests/integration/`\n\n### 3. API Tests\n\nTest HTTP endpoints and error responses:\n\n```python\n@pytest.mark.asyncio\nasync def test_get_notebooks_endpoint():\n    \"\"\"Test GET /notebooks endpoint.\"\"\"\n    async with AsyncClient(app=app, base_url=\"http://test\") as client:\n        response = await client.get(\"/api/notebooks\")\n        assert response.status_code == 200\n        data = response.json()\n        assert isinstance(data, list)\n\n@pytest.mark.asyncio\nasync def test_create_notebook_validation():\n    \"\"\"Test that invalid input is rejected.\"\"\"\n    async with AsyncClient(app=app, base_url=\"http://test\") as client:\n        response = await client.post(\n            \"/api/notebooks\",\n            json={\"name\": \"\", \"description\": \"\"}\n        )\n        assert response.status_code == 400\n```\n\n**Location**: `tests/api/`\n\n### 4. Database Tests\n\nTest data persistence and query correctness:\n\n```python\n@pytest.mark.asyncio\nasync def test_save_and_retrieve_notebook():\n    \"\"\"Test saving and retrieving a notebook from database.\"\"\"\n    notebook = Notebook(name=\"Test\", description=\"desc\")\n    await notebook.save()\n\n    retrieved = await Notebook.get(notebook.id)\n    assert retrieved.name == \"Test\"\n    assert retrieved.description == \"desc\"\n\n@pytest.mark.asyncio\nasync def test_query_by_criteria():\n    \"\"\"Test querying notebooks by criteria.\"\"\"\n    await create_notebook(\"Active\", \"\")\n    await create_notebook(\"Archived\", \"\")\n\n    active = await repo_query(\n        \"SELECT * FROM notebook WHERE archived = false\"\n    )\n    assert len(active) >= 1\n```\n\n**Location**: `tests/database/`\n\n## Running Tests\n\n### Run All Tests\n\n```bash\nuv run pytest\n```\n\n### Run Specific Test File\n\n```bash\nuv run pytest tests/test_notebooks.py\n```\n\n### Run Specific Test Function\n\n```bash\nuv run pytest tests/test_notebooks.py::test_create_notebook\n```\n\n### Run with Coverage Report\n\n```bash\nuv run pytest --cov=open_notebook\n```\n\n### Run Only Unit Tests\n\n```bash\nuv run pytest tests/unit/\n```\n\n### Run Only Integration Tests\n\n```bash\nuv run pytest tests/integration/\n```\n\n### Run Tests in Verbose Mode\n\n```bash\nuv run pytest -v\n```\n\n### Run Tests with Output\n\n```bash\nuv run pytest -s\n```\n\n## Test Fixtures\n\nUse pytest fixtures for common setup and teardown:\n\n```python\nimport pytest\n\n@pytest.fixture\nasync def test_notebook():\n    \"\"\"Create a test notebook.\"\"\"\n    notebook = Notebook(name=\"Test Notebook\", description=\"Test description\")\n    await notebook.save()\n    yield notebook\n    await notebook.delete()\n\n@pytest.fixture\nasync def api_client():\n    \"\"\"Create an API test client.\"\"\"\n    async with AsyncClient(app=app, base_url=\"http://test\") as client:\n        yield client\n\n@pytest.fixture\nasync def test_notebook_with_sources(test_notebook):\n    \"\"\"Create a test notebook with sample sources.\"\"\"\n    source1 = Source(notebook_id=test_notebook.id, url=\"https://example.com\")\n    source2 = Source(notebook_id=test_notebook.id, url=\"https://example.org\")\n    await source1.save()\n    await source2.save()\n\n    test_notebook.sources = [source1, source2]\n    yield test_notebook\n\n    # Cleanup\n    await source1.delete()\n    await source2.delete()\n```\n\n## Best Practices\n\n### 1. Write Descriptive Test Names\n\n```python\n# Good - clearly describes what is being tested\nasync def test_create_notebook_with_valid_name_succeeds():\n    ...\n\n# Bad - vague about what's being tested\nasync def test_notebook():\n    ...\n```\n\n### 2. Use Docstrings\n\n```python\n@pytest.mark.asyncio\nasync def test_vector_search_returns_sorted_results():\n    \"\"\"Test that vector search results are sorted by relevance score.\"\"\"\n    # Implementation\n```\n\n### 3. Test Edge Cases\n\n```python\n@pytest.mark.asyncio\nasync def test_search_with_empty_query():\n    \"\"\"Test that empty query raises error.\"\"\"\n    with pytest.raises(InvalidInputError):\n        await vector_search(\"\")\n\n@pytest.mark.asyncio\nasync def test_search_with_very_long_query():\n    \"\"\"Test that very long query is handled.\"\"\"\n    long_query = \"x\" * 10000\n    results = await vector_search(long_query)\n    assert isinstance(results, list)\n\n@pytest.mark.asyncio\nasync def test_search_with_special_characters():\n    \"\"\"Test that special characters are handled.\"\"\"\n    results = await vector_search(\"@#$%^&*()\")\n    assert isinstance(results, list)\n```\n\n### 4. Use Assertions Effectively\n\n```python\n# Good - specific assertions\nassert notebook.name == \"Test\"\nassert len(notebook.sources) == 3\nassert notebook.created is not None\n\n# Less good - too broad\nassert notebook is not None\nassert notebook  # ambiguous what's being tested\n```\n\n### 5. Test Both Success and Failure Cases\n\n```python\n@pytest.mark.asyncio\nasync def test_create_notebook_success():\n    \"\"\"Test successful notebook creation.\"\"\"\n    notebook = await create_notebook(name=\"Research\", description=\"AI\")\n    assert notebook.id is not None\n    assert notebook.name == \"Research\"\n\n@pytest.mark.asyncio\nasync def test_create_notebook_empty_name_fails():\n    \"\"\"Test that empty name raises error.\"\"\"\n    with pytest.raises(InvalidInputError):\n        await create_notebook(name=\"\", description=\"\")\n\n@pytest.mark.asyncio\nasync def test_create_notebook_duplicate_fails():\n    \"\"\"Test that duplicate names are handled.\"\"\"\n    await create_notebook(name=\"Research\", description=\"\")\n    with pytest.raises(DuplicateError):\n        await create_notebook(name=\"Research\", description=\"\")\n```\n\n### 6. Keep Tests Independent\n\n```python\n# Good - test is self-contained\n@pytest.mark.asyncio\nasync def test_archive_notebook():\n    notebook = Notebook(name=\"Test\", description=\"\")\n    await notebook.save()\n    await notebook.archive()\n    assert notebook.archived is True\n\n# Bad - depends on another test's state\n@pytest.mark.asyncio\nasync def test_archive_existing_notebook():\n    # Assumes test_create_notebook ran first\n    await notebook.archive()  # notebook undefined\n```\n\n### 7. Use Fixtures for Reusable Setup\n\n```python\n# Instead of repeating setup:\n@pytest.fixture\nasync def client_with_auth(api_client, mock_auth):\n    \"\"\"Client with authentication set up.\"\"\"\n    api_client.headers.update({\"Authorization\": f\"Bearer {mock_auth.token}\"})\n    yield api_client\n\n@pytest.mark.asyncio\nasync def test_protected_endpoint(client_with_auth):\n    \"\"\"Test protected endpoint.\"\"\"\n    response = await client_with_auth.get(\"/api/protected\")\n    assert response.status_code == 200\n```\n\n## Coverage Goals\n\n- Aim for 70%+ overall coverage\n- 90%+ coverage for critical business logic\n- Don't obsess over 100% - focus on meaningful tests\n- Use `--cov` flag to check coverage: `uv run pytest --cov=open_notebook`\n\n## Async Test Patterns\n\n### Testing Async Functions\n\n```python\n@pytest.mark.asyncio\nasync def test_async_operation():\n    \"\"\"Test async function.\"\"\"\n    result = await some_async_function()\n    assert result is not None\n```\n\n### Testing Concurrent Operations\n\n```python\n@pytest.mark.asyncio\nasync def test_concurrent_notebook_creation():\n    \"\"\"Test creating multiple notebooks concurrently.\"\"\"\n    tasks = [\n        create_notebook(f\"Notebook {i}\", \"\")\n        for i in range(10)\n    ]\n    notebooks = await asyncio.gather(*tasks)\n    assert len(notebooks) == 10\n    assert all(n.id for n in notebooks)\n```\n\n## Common Testing Errors\n\n### Error: \"event loop is closed\"\n\nSolution: Use the async fixture properly:\n```python\n@pytest.fixture\nasync def notebook():  # Use async fixture\n    notebook = Notebook(name=\"Test\", description=\"\")\n    await notebook.save()\n    yield notebook\n    await notebook.delete()\n```\n\n### Error: \"object is not awaitable\"\n\nSolution: Make sure you're using await:\n```python\n# Wrong\nresult = create_notebook(\"Test\", \"\")\n\n# Right\nresult = await create_notebook(\"Test\", \"\")\n```\n\n---\n\n**See also:**\n- [Code Standards](code-standards.md) - Code formatting and style\n- [Contributing Guide](contributing.md) - Overall contribution workflow\n"
  },
  {
    "path": "docs/SECURITY_REVIEW.md",
    "content": "# Security Review - API Configuration UI\n\n## Date: 2026-01-27 (Updated: 2026-01-28)\n## Reviewer: Security Audit\n\n---\n\n## Summary\n\nSecurity review of the API key management implementation for Open Notebook. The implementation uses a database-first approach with environment variable fallback.\n\n---\n\n## Encryption\n\n| Item | Status | Notes |\n|------|--------|-------|\n| Fernet encryption implemented | PASS | `open_notebook/utils/encryption.py` uses AES-128-CBC + HMAC-SHA256 |\n| Keys encrypted before DB storage | PASS | `encrypt_value()` applied on save |\n| Keys decrypted only when needed | PASS | `decrypt_value()` called when reading |\n| Encryption key required | PASS | No default key; ValueError if not configured |\n| Docker secrets support | PASS | `_FILE` suffix pattern supported |\n| Documented in .env.example | PASS | Encryption key documented |\n\n---\n\n## API Security\n\n| Item | Status | Notes |\n|------|--------|-------|\n| Test endpoint implemented | PASS | `connection_tester.py` validates keys |\n| Test doesn't expose keys | PASS | Only returns success/failure |\n| Error messages don't leak info | PASS | Generic error messages |\n| URL validation for SSRF | PASS | Blocks private IPs (except Ollama) |\n| Rate limiting | NOT IMPL | Future enhancement |\n\n---\n\n## Frontend Security\n\n| Item | Status | Notes |\n|------|--------|-------|\n| No keys in localStorage | PASS | Keys only in React state during entry |\n| Keys masked in UI | PASS | Shows `************` placeholder |\n| No keys in console.log | PASS | No logging of sensitive data |\n| autocomplete attributes | PARTIAL | Some forms missing autocomplete=\"off\" |\n\n---\n\n## Authentication\n\n| Item | Status | Notes |\n|------|--------|-------|\n| Password protection | PASS | Bearer token authentication |\n| Default password | PASS | \"open-notebook-change-me\" when not set |\n| Docker secrets support | PASS | `_FILE` suffix for password |\n| Security warnings | PASS | Logged when using defaults |\n\n---\n\n## Files Reviewed\n\n| Component | Path | Status |\n|-----------|------|--------|\n| Encryption | `open_notebook/utils/encryption.py` | PASS |\n| Credential model | `open_notebook/domain/credential.py` | PASS |\n| Credentials router | `api/routers/credentials.py` | PASS |\n| Key provider | `open_notebook/ai/key_provider.py` | PASS |\n| Connection tester | `open_notebook/ai/connection_tester.py` | PASS |\n| Auth middleware | `api/auth.py` | PASS |\n| Frontend forms | `frontend/src/components/settings/*.tsx` | PASS |\n| Environment example | `.env.example` | PASS |\n\n---\n\n## Remaining Recommendations\n\n### Future Improvements\n\n1. **Rate limiting** - Add rate limiting on `/credentials/*` endpoints\n2. **Autocomplete attributes** - Add `autocomplete=\"new-password\"` to all password inputs\n3. **Show last 4 characters** - Display `********xxxx` format for key identification\n4. **Audit logging** - Log API key changes with timestamps\n\n---\n\n## Conclusion\n\nThe API Configuration UI implementation meets security requirements:\n\n- API keys encrypted at rest using Fernet (key must be explicitly configured)\n- Keys never returned to frontend\n- URL validation prevents SSRF attacks\n- Docker secrets supported for production deployments\n\n**Review Status: PASS**\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Open Notebook Documentation\n\nWelcome to Open Notebook - a privacy-focused AI research assistant. This documentation is organized for different needs.\n\n---\n\n## 🎯 Choose Your Path\n\n### I'm brand new\n→ Start here: **[0-START-HERE](0-START-HERE/index.md)**\n- Learn what Open Notebook is\n- Pick your setup path (OpenAI, cloud, local/Ollama)\n- 5-minute quick start\n\n### I need to install/deploy\n→ Go here: **[1-INSTALLATION](1-INSTALLATION/index.md)**\n- Multiple installation routes\n- Docker Compose (recommended)\n- From source (developers)\n- Single container (shared hosting)\n\n### I want to understand how it works\n→ Read this: **[2-CORE-CONCEPTS](2-CORE-CONCEPTS/index.md)**\n- Mental models and architecture\n- How RAG (retrieval-augmented generation) works\n- Notebooks, sources, and notes explained\n- Chat vs. transformations vs. podcasts\n\n### I want to use it (tutorials)\n→ Follow this: **[3-USER-GUIDE](3-USER-GUIDE/index.md)**\n- How to add sources (PDFs, URLs, audio, video)\n- Creating and organizing notes\n- Chat effectively with your research\n- Creating podcasts from research\n- Search techniques\n\n### I need to configure it\n→ Check this: **[5-CONFIGURATION](5-CONFIGURATION/index.md)**\n- Choose and setup AI provider\n- API configuration\n- Database setup\n- Advanced tuning\n\n### I need provider-specific help\n→ Go here: **[4-AI-PROVIDERS](4-AI-PROVIDERS/index.md)**\n- OpenAI, Anthropic, Google, Groq, Ollama, Azure\n- Model comparisons\n- Cost estimates\n- Setup paths\n\n### Something's not working\n→ Troubleshoot: **[6-TROUBLESHOOTING](6-TROUBLESHOOTING/index.md)**\n- Quick fixes (top 10 issues)\n- Installation problems\n- Connection issues\n- AI/chat problems\n- Content processing issues\n- Podcast problems\n\n### I want to contribute/develop\n→ Read this: **[7-DEVELOPMENT](7-DEVELOPMENT/index.md)**\n- Architecture and tech stack\n- Contributing guidelines\n- API reference\n- Testing\n\n---\n\n## 📊 Documentation Overview\n\n### By Section\n\n**[0-START-HERE](0-START-HERE/index.md)** — Entry point\n- What is Open Notebook?\n- Quick start guides (3 routes)\n- First 5 minutes\n\n**[1-INSTALLATION](1-INSTALLATION/index.md)** — Getting it running\n- Multiple installation routes\n- Docker, single-container, from-source\n- Requirements and setup\n\n**[2-CORE-CONCEPTS](2-CORE-CONCEPTS/index.md)** — Understanding the system\n- Notebooks, sources, notes hierarchy\n- RAG (retrieval-augmented generation)\n- Chat, transformations, podcasts\n- Context management\n\n**[3-USER-GUIDE](3-USER-GUIDE/index.md)** — Using features\n- Adding sources (all types)\n- Working with notes\n- Chat effectively\n- Creating podcasts\n- Searching (text and semantic)\n\n**[4-AI-PROVIDERS](4-AI-PROVIDERS/index.md)** — AI configuration\n- Provider comparison\n- Setup for each provider\n- Model recommendations\n- Cost estimates\n\n**[5-CONFIGURATION](5-CONFIGURATION/index.md)** — Complete reference\n- AI provider setup (detailed)\n- Database configuration\n- Server/API settings\n- Advanced tuning\n- Environment variables (complete reference)\n\n**[6-TROUBLESHOOTING](6-TROUBLESHOOTING/index.md)** — Problem solving\n- Quick fixes (top 10)\n- Installation issues\n- Connection problems\n- AI/chat issues\n- Content processing\n- Podcast generation\n- Getting help\n\n**[7-DEVELOPMENT](7-DEVELOPMENT/index.md)** — For contributors\n- Architecture\n- Contributing guidelines\n- API reference\n- Testing & development\n\n---\n\n## 🔍 Find What You Need\n\n### By Problem Type\n\n**Installation & Setup**\n- Fresh install? → [0-START-HERE](0-START-HERE/index.md)\n- Detailed installation routes? → [1-INSTALLATION](1-INSTALLATION/index.md)\n- Configuration reference? → [5-CONFIGURATION](5-CONFIGURATION/index.md)\n- Provider setup? → [4-AI-PROVIDERS](4-AI-PROVIDERS/index.md)\n\n**Using Open Notebook**\n- How to use features? → [3-USER-GUIDE](3-USER-GUIDE/index.md)\n- Understanding concepts? → [2-CORE-CONCEPTS](2-CORE-CONCEPTS/index.md)\n- Chat not working? → [6-TROUBLESHOOTING - AI Issues](6-TROUBLESHOOTING/ai-chat-issues.md)\n- Files won't upload? → [6-TROUBLESHOOTING - Quick Fixes](6-TROUBLESHOOTING/quick-fixes.md#4-cannot-process-file-or-unsupported-format)\n\n**Troubleshooting**\n- Quick fix? → [6-TROUBLESHOOTING - Quick Fixes](6-TROUBLESHOOTING/quick-fixes.md)\n- Can't connect? → [6-TROUBLESHOOTING - Connection](6-TROUBLESHOOTING/connection-issues.md)\n- Chat issues? → [6-TROUBLESHOOTING - AI Issues](6-TROUBLESHOOTING/ai-chat-issues.md)\n- Podcast problems? → [6-TROUBLESHOOTING - Quick Fixes](6-TROUBLESHOOTING/quick-fixes.md#8-podcast-generation-failed)\n\n**Development**\n- Architecture? → [7-DEVELOPMENT - Architecture](7-DEVELOPMENT/architecture.md)\n- Contributing? → [7-DEVELOPMENT - Contributing](7-DEVELOPMENT/contributing.md)\n- API reference? → [7-DEVELOPMENT - API Reference](7-DEVELOPMENT/api-reference.md)\n\n---\n\n## 📚 Reading Paths\n\n### Path 1: Complete Beginner (1-2 hours)\n1. [0-START-HERE/index.md](0-START-HERE/index.md) — Understand what it is\n2. [0-START-HERE Quick Start](0-START-HERE/index.md) — Set it up\n3. [2-CORE-CONCEPTS/index.md](2-CORE-CONCEPTS/index.md) — Understand concepts\n4. [3-USER-GUIDE/index.md](3-USER-GUIDE/index.md) — Learn features\n\n**Result:** Fully understand how to use Open Notebook\n\n### Path 2: Get Running Fast (15 minutes)\n1. [0-START-HERE](0-START-HERE/index.md) — Pick your path\n2. Follow quick-start guide for your setup\n3. Start using!\n\n**Result:** Running in 15 minutes, learn details later\n\n### Path 3: DevOps/Deployment (1-2 hours)\n1. [1-INSTALLATION](1-INSTALLATION/index.md) — Understand routes\n2. [5-CONFIGURATION](5-CONFIGURATION/index.md) — Reference setup\n3. [7-DEVELOPMENT - Architecture](../7-DEVELOPMENT/architecture.md) — Understand system\n\n**Result:** Ready to deploy to production\n\n### Path 4: Troubleshooting (5-30 minutes)\n1. [6-TROUBLESHOOTING/index.md](6-TROUBLESHOOTING/index.md) — Identify problem\n2. Find specific guide\n3. Follow solutions\n\n**Result:** Problem solved!\n\n---\n\n## ❓ Common Questions\n\n**Q: Where do I start?**\nA: → [0-START-HERE](0-START-HERE/index.md) — Choose your setup path\n\n**Q: How do I install it?**\nA: → [1-INSTALLATION](1-INSTALLATION/index.md) — Multiple routes available\n\n**Q: How do I use [feature]?**\nA: → [3-USER-GUIDE](3-USER-GUIDE/index.md) — Step-by-step tutorials\n\n**Q: Why does [feature] work like that?**\nA: → [2-CORE-CONCEPTS](2-CORE-CONCEPTS/index.md) — Understand the mental model\n\n**Q: How do I configure [provider]?**\nA: → [4-AI-PROVIDERS](4-AI-PROVIDERS/index.md) or [5-CONFIGURATION](5-CONFIGURATION/index.md)\n\n**Q: Something's broken, what do I do?**\nA: → [6-TROUBLESHOOTING](6-TROUBLESHOOTING/index.md) — Problem solver\n\n**Q: How does the system work?**\nA: → [2-CORE-CONCEPTS](2-CORE-CONCEPTS/index.md) — Architecture and concepts\n\n**Q: Can I contribute?**\nA: → [7-DEVELOPMENT](../7-DEVELOPMENT/index.md) — Contributing guide\n\n---\n\n## 📖 How This Documentation is Organized\n\n### Principles\n- **Progressive Disclosure**: Start simple, go deeper if needed\n- **Multiple Entry Routes**: Different paths for different users\n- **High Signal-to-Noise**: Focused content, no fluff\n- **Step-by-Step**: Clear instructions you can follow\n- **Decision Trees**: Help you pick the right path\n- **Symptom-Based**: Troubleshooting by what's broken\n\n### Structure\n- **0-START-HERE** — Entry point (everyone starts here)\n- **1-INSTALLATION** — Multiple setup routes\n- **2-CORE-CONCEPTS** — Mental models (understand why)\n- **3-USER-GUIDE** — How to use (step-by-step)\n- **4-AI-PROVIDERS** — Provider guides\n- **5-CONFIGURATION** — Reference material\n- **6-TROUBLESHOOTING** — Problem solving\n- **7-DEVELOPMENT** — For contributors\n\n---\n\n## 🚀 Quick Navigation\n\n### First Time?\n→ **[START HERE](0-START-HERE/index.md)**\n\n### Just Want to Use It?\n→ **[QUICK START](0-START-HERE/index.md)** (5 minutes)\n\n### Something Broken?\n→ **[TROUBLESHOOTING](6-TROUBLESHOOTING/index.md)**\n\n### Full Reference?\n→ **[CONFIGURATION](5-CONFIGURATION/index.md)**\n\n### Developer?\n→ **[DEVELOPMENT](7-DEVELOPMENT/index.md)**\n\n---\n\n## 📞 Getting Help\n\n- **Discord Community** — https://discord.gg/37XJPXfz2w\n- **GitHub Issues** — https://github.com/lfnovo/open-notebook/issues\n- **Documentation** — You're reading it!\n\n---\n\n## 📈 Documentation Stats\n\n- **8 major sections**\n- **35+ focused guides**\n- **~80,000 words**\n- **Covers all features**\n- **Multiple entry paths**\n- **Progressive difficulty**\n\n---\n\n## 🎯 Start Here\n\n**First time using Open Notebook?**\n→ Go to **[0-START-HERE](0-START-HERE/index.md)**\n\n**Experienced, looking for specific help?**\n→ Use the navigation above to find your section\n\n**Something not working?**\n→ Go to **[TROUBLESHOOTING](6-TROUBLESHOOTING/index.md)**\n\n---\n\nLast updated: January 2026 | Open Notebook v1.2.4+\n"
  },
  {
    "path": "examples/README.md",
    "content": "# Docker Compose Examples\n\nThis folder contains different `docker-compose.yml` configurations for various use cases.\n\n## 📋 Available Examples\n\n### `docker-compose-full-local.yml` - 100% Local AI (No Cloud APIs) 🌟\n**Use this if:** You want complete privacy with zero external API dependencies\n\n**Features:**\n- **Ollama**: Local LLM and embeddings (mistral, llama, etc.)\n- **Speaches**: Local TTS (text-to-speech) and STT (speech-to-text)\n- Everything runs on your machine - nothing sent to cloud\n- Perfect for privacy, offline work, or air-gapped environments\n\n**Setup:**\n1. Copy to your project folder as `docker-compose.yml`\n2. Run: `docker compose up -d`\n3. Download models (see file comments for commands)\n4. Configure all providers in UI (detailed instructions in file)\n\n**Requirements:**\n- Minimum: 8GB RAM, 20GB disk, 4 CPU cores\n- Recommended: 16GB+ RAM, NVIDIA GPU (8GB+ VRAM), 50GB disk\n\n**Documentation:**\n- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)\n- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)\n\n---\n\n### `docker-compose-speaches.yml` - Local Speech Processing\n**Use this if:** You want free TTS/STT but use cloud LLMs\n\n**Features:**\n- **Speaches**: Local text-to-speech and speech-to-text\n- Use with cloud LLM providers (OpenAI, Anthropic, etc.)\n- Great for podcast generation without TTS API costs\n- Private audio processing\n\n**Setup:**\n1. Copy to your project folder as `docker-compose.yml`\n2. Run: `docker compose up -d`\n3. Download speech models (see file for commands)\n4. Configure cloud LLM + local Speaches in UI\n\n**Documentation:**\n- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)\n- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)\n\n---\n\n### `docker-compose-ollama.yml` - Free Local AI with Ollama\n**Use this if:** You want to run AI models locally without API costs\n\n**Features:**\n- Includes Ollama service for local AI models\n- No external API keys needed (for LLM and embeddings)\n- Full privacy - everything runs on your machine\n- Great for testing or privacy-focused deployments\n\n**Setup:**\n1. Copy to your project folder as `docker-compose.yml`\n2. Run: `docker compose up -d`\n3. Pull a model: `docker exec open_notebook-ollama-1 ollama pull mistral`\n4. Configure in UI: Settings → API Keys → Add Ollama (URL: `http://ollama:11434`)\n\n**Recommended models:**\n- **LLM**: `mistral`, `llama3.1`, `qwen2.5`\n- **Embeddings**: `nomic-embed-text`, `mxbai-embed-large`\n\n---\n\n### `docker-compose-single.yml` - Single Container (Deprecated)\n**Use this if:** You need all services in one container (not recommended)\n\n**⚠️ Deprecated:** We recommend using the standard multi-container setup (`docker-compose.yml` in root) for better reliability and easier troubleshooting.\n\n**Features:**\n- Single container includes SurrealDB, API, and Frontend\n- Simpler for very constrained environments\n- Less flexible for debugging and scaling\n\n---\n\n### `docker-compose-dev.yml` - Development Setup\n**Use this if:** You're contributing to Open Notebook or developing custom features\n\n**Features:**\n- Hot-reload for code changes\n- Separate backend and frontend services\n- Build from source instead of using pre-built images\n- Includes development tools and debugging\n\n**Prerequisites:**\n- Python 3.11+\n- Node.js 18+\n- uv (Python package manager)\n\n**Setup:**\nSee [Development Guide](../docs/7-DEVELOPMENT/index.md)\n\n---\n\n## 🔄 How to Use These Examples\n\n1. **Choose** the example that fits your use case\n2. **Copy** the file to your project folder:\n   ```bash\n   cp examples/docker-compose-ollama.yml docker-compose.yml\n   ```\n3. **Edit** the `OPEN_NOTEBOOK_ENCRYPTION_KEY` value\n4. **Run** the services:\n   ```bash\n   docker compose up -d\n   ```\n\n---\n\n## 💡 Need a Custom Setup?\n\nYou can combine features from multiple examples. Common customizations:\n\n### Add Ollama to Standard Setup\nAdd this to the main `docker-compose.yml`:\n\n```yaml\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ollama_models:/root/.ollama\n    restart: always\n\nvolumes:\n  ollama_models:\n```\n\n### Add Reverse Proxy\nSee [Reverse Proxy Guide](../docs/5-CONFIGURATION/reverse-proxy.md)\n\n### Add Basic Auth\nAdd to `open_notebook` service environment:\n```yaml\n- BASIC_AUTH_USERNAME=admin\n- BASIC_AUTH_PASSWORD=your-secure-password\n```\n\n---\n\n## 📚 Documentation\n\n- [Installation Guide](../docs/1-INSTALLATION/index.md)\n- [Configuration Reference](../docs/5-CONFIGURATION/environment-reference.md)\n- [Troubleshooting](../docs/6-TROUBLESHOOTING/index.md)\n\n---\n\n## 🆘 Need Help?\n\n- **Discord**: [Join our community](https://discord.gg/37XJPXfz2w)\n- **Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)\n"
  },
  {
    "path": "examples/docker-compose-dev.yml",
    "content": "services:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    ports:\n      - \"8000:8000\"\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    pull_policy: always\n    user: root\n    restart: always\n  open_notebook:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    env_file:\n      - ./docker.env\n    depends_on:\n      - surrealdb\n    volumes:\n      - ./notebook_data:/app/data\n    restart: always\n    \n"
  },
  {
    "path": "examples/docker-compose-full-local.yml",
    "content": "# Docker Compose - 100% Local AI Setup\n#\n# This is the complete privacy-focused setup with NO external APIs needed:\n# - Ollama: Local LLM and embeddings (mistral, llama, nomic-embed, etc.)\n# - Speaches: Local TTS (text-to-speech) and STT (speech-to-text)\n# - Open Notebook: Your research assistant\n# - SurrealDB: Local database\n#\n# Perfect for:\n# - Complete privacy (nothing leaves your machine)\n# - Offline work\n# - No API costs\n# - Air-gapped environments\n# - Testing and development\n#\n# Usage:\n#   1. Copy this file to your project folder as docker-compose.yml\n#   2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below\n#   3. Run: docker compose up -d\n#   4. Pull models (see instructions below)\n#   5. Configure providers in UI\n#\n# Full documentation:\n# - Ollama setup: https://github.com/lfnovo/open-notebook/blob/main/examples/README.md\n# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md\n# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md\n\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    restart: always\n    pull_policy: always\n\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ollama_models:/root/.ollama\n    restart: always\n    pull_policy: always\n    # For GPU acceleration (NVIDIA), add:\n    # deploy:\n    #   resources:\n    #     reservations:\n    #       devices:\n    #         - driver: nvidia\n    #           count: 1\n    #           capabilities: [gpu]\n\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cpu\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    restart: unless-stopped\n    # For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda\n    # and add GPU device mapping (see docs)\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    environment:\n      # REQUIRED: Change this to your own secret string\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database connection\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n\n      # Ollama connection (optional, can also configure via UI)\n      - OLLAMA_BASE_URL=http://ollama:11434\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n      - ollama\n      - speaches\n    restart: always\n    pull_policy: always\n\nvolumes:\n  ollama_models:\n  hf-hub-cache:\n\n# ==========================================\n# AFTER STARTING: Download Models\n# ==========================================\n#\n# Ollama Models (LLM):\n#   docker exec open_notebook-ollama-1 ollama pull mistral\n#   docker exec open_notebook-ollama-1 ollama pull llama3.1\n#   docker exec open_notebook-ollama-1 ollama pull qwen2.5\n#\n# Ollama Models (Embeddings):\n#   docker exec open_notebook-ollama-1 ollama pull nomic-embed-text\n#   docker exec open_notebook-ollama-1 ollama pull mxbai-embed-large\n#\n# Speaches (TTS):\n#   docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX\n#\n# Speaches (STT):\n#   docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small\n#\n# ==========================================\n# CONFIGURATION IN OPEN NOTEBOOK\n# ==========================================\n#\n# 1. Configure Ollama:\n#    - Go to Settings → API Keys\n#    - Add Credential → Select \"Ollama\"\n#    - Base URL: http://ollama:11434\n#    - Save → Test Connection → Discover Models → Register Models\n#\n# 2. Configure Speaches (TTS/STT):\n#    - Go to Settings → API Keys\n#    - Add Credential → Select \"OpenAI-Compatible\"\n#    - Name: \"Local Speaches\"\n#    - Base URL for TTS: http://host.docker.internal:8969/v1  (macOS/Windows)\n#                    or: http://172.17.0.1:8969/v1           (Linux)\n#    - Base URL for STT: (same as TTS)\n#    - Save → Test Connection\n#\n# 3. Discover Speech Models:\n#    - In the Speaches credential you just created, click Discover Models\n#    - Select and register the models you need (e.g. TTS and STT)\n#    - If models aren't discovered automatically, add them manually:\n#      * TTS: speaches-ai/Kokoro-82M-v1.0-ONNX\n#      * STT: Systran/faster-whisper-small\n#\n# ==========================================\n# RECOMMENDED MODELS\n# ==========================================\n#\n# For LLM (choose based on your hardware):\n# - Fast: mistral (7B), qwen2.5 (7B)\n# - Balanced: llama3.1 (8B)\n# - Best quality: qwen2.5 (14B+), llama3.1 (70B) - requires powerful GPU\n#\n# For Embeddings:\n# - nomic-embed-text (recommended, 137M params)\n# - mxbai-embed-large (334M params, better quality)\n#\n# For TTS:\n# - speaches-ai/Kokoro-82M-v1.0-ONNX (good quality, fast)\n#\n# For STT (Whisper):\n# - faster-whisper-small (balanced, ~500MB)\n# - faster-whisper-base (faster, less accurate)\n# - faster-whisper-large-v3 (best quality, slower, ~3GB)\n#\n# ==========================================\n# HARDWARE REQUIREMENTS\n# ==========================================\n#\n# Minimum (CPU only):\n# - 8 GB RAM\n# - 20 GB disk space\n# - 4 CPU cores\n#\n# Recommended (with GPU):\n# - 16+ GB RAM\n# - 8+ GB VRAM (NVIDIA GPU)\n# - 50 GB disk space\n# - 8+ CPU cores\n#\n# ==========================================\n# COST COMPARISON\n# ==========================================\n#\n# Local (this setup):\n# - Cost: $0 (after hardware)\n# - Privacy: 100% private\n# - Speed: Depends on hardware\n#\n# Cloud (OpenAI + ElevenLabs):\n# - LLM: ~$0.01-0.10 per 1K tokens\n# - Embeddings: ~$0.0001 per 1K tokens\n# - TTS: ~$0.015 per minute\n# - STT: ~$0.006 per minute\n# - Privacy: Data sent to providers\n# - Speed: Usually faster\n"
  },
  {
    "path": "examples/docker-compose-ollama.yml",
    "content": "# Docker Compose with Ollama (Free Local AI)\n#\n# This setup includes Ollama for running local AI models without API costs.\n# Great for privacy-focused deployments or testing without cloud dependencies.\n#\n# Usage:\n#   1. Copy this file to your project folder as docker-compose.yml\n#   2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below\n#   3. Run: docker compose up -d\n#   4. Pull a model: docker exec open_notebook-ollama-1 ollama pull mistral\n#   5. Configure Ollama in UI: Settings → API Keys → Add Ollama (URL: http://ollama:11434)\n\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    restart: always\n    pull_policy: always\n\n  ollama:\n    image: ollama/ollama:latest\n    ports:\n      - \"11434:11434\"\n    volumes:\n      - ollama_models:/root/.ollama\n    restart: always\n    pull_policy: always\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    environment:\n      # REQUIRED: Change this to your own secret string\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database connection\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n\n      # Ollama connection\n      - OLLAMA_BASE_URL=http://ollama:11434\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n      - ollama\n    restart: always\n    pull_policy: always\n\nvolumes:\n  ollama_models:\n"
  },
  {
    "path": "examples/docker-compose-single.yml",
    "content": "services:\n  open_notebook_single:\n    # image: lfnovo/open_notebook:v1-latest-single\n    build:\n      context: .\n      dockerfile: Dockerfile.single\n    ports:\n      - \"8502:8502\"  # Next.js Frontend\n      - \"5055:5055\"  # REST API\n    env_file:\n      - ./docker.env\n    environment:\n      # Override for single-container mode: SurrealDB runs on localhost inside the same container\n      - SURREAL_URL=ws://localhost:8000/rpc\n    volumes:\n      - ./notebook_data:/app/data          # Application data\n      - ./surreal_single_data:/mydata      # SurrealDB data\n    restart: always\n    # Single container includes all services: SurrealDB, API, Worker, and Next.js Frontend\n    # Access:\n    # - Next.js UI: http://localhost:8502\n    # - REST API: http://localhost:5055\n    # - API Documentation: http://localhost:5055/docs"
  },
  {
    "path": "examples/docker-compose-speaches.yml",
    "content": "# Docker Compose with Speaches (Local TTS/STT)\n#\n# This setup includes Speaches for free, private speech processing:\n# - Text-to-Speech (TTS): Generate podcast audio locally\n# - Speech-to-Text (STT): Transcribe audio/video content locally\n#\n# Why Speaches?\n# - Free: No per-minute/per-character costs\n# - Private: Audio never leaves your machine\n# - Offline: Works without internet\n# - OpenAI-compatible: Drop-in replacement for OpenAI TTS/STT\n#\n# Usage:\n#   1. Copy this file to your project folder as docker-compose.yml\n#   2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below\n#   3. Run: docker compose up -d\n#   4. Download models (see instructions below)\n#   5. Configure in UI: Settings → API Keys → Add OpenAI-Compatible\n#\n# Full documentation:\n# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md\n# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md\n\nservices:\n  surrealdb:\n    image: surrealdb/surrealdb:v2\n    command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db\n    user: root\n    ports:\n      - \"8000:8000\"\n    volumes:\n      - ./surreal_data:/mydata\n    environment:\n      - SURREAL_EXPERIMENTAL_GRAPHQL=true\n    restart: always\n    pull_policy: always\n\n  speaches:\n    image: ghcr.io/speaches-ai/speaches:latest-cpu\n    container_name: speaches\n    ports:\n      - \"8969:8000\"\n    volumes:\n      - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub\n    restart: unless-stopped\n    # For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda\n    # and add GPU device mapping (see docs/5-CONFIGURATION/local-tts.md)\n\n  open_notebook:\n    image: lfnovo/open_notebook:v1-latest\n    ports:\n      - \"8502:8502\"\n      - \"5055:5055\"\n    environment:\n      # REQUIRED: Change this to your own secret string\n      - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string\n\n      # Database connection\n      - SURREAL_URL=ws://surrealdb:8000/rpc\n      - SURREAL_USER=root\n      - SURREAL_PASSWORD=root\n      - SURREAL_NAMESPACE=open_notebook\n      - SURREAL_DATABASE=open_notebook\n    volumes:\n      - ./notebook_data:/app/data\n    depends_on:\n      - surrealdb\n      - speaches\n    restart: always\n    pull_policy: always\n\nvolumes:\n  hf-hub-cache:\n\n# ==========================================\n# AFTER STARTING: Download Speech Models\n# ==========================================\n#\n# For TTS (Text-to-Speech):\n#   docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX\n#\n# For STT (Speech-to-Text):\n#   docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small\n#\n# ==========================================\n# CONFIGURATION IN OPEN NOTEBOOK\n# ==========================================\n#\n# 1. Go to Settings → API Keys\n# 2. Click \"Add Credential\" → Select \"OpenAI-Compatible\"\n# 3. Configure:\n#    - Name: \"Local Speaches\"\n#    - Base URL for TTS: http://host.docker.internal:8969/v1  (macOS/Windows)\n#                    or: http://172.17.0.1:8969/v1           (Linux)\n#    - Base URL for STT: (same as TTS)\n# 4. Click Save → Test Connection\n#\n# 5. Go to Settings → Models\n# 6. Add TTS Model:\n#    - Provider: openai_compatible\n#    - Model Name: speaches-ai/Kokoro-82M-v1.0-ONNX\n#    - Display Name: Local TTS\n#\n# 7. Add STT Model:\n#    - Provider: openai_compatible\n#    - Model Name: Systran/faster-whisper-small\n#    - Display Name: Local Whisper\n#\n# ==========================================\n# TESTING\n# ==========================================\n#\n# Test TTS:\n#   curl \"http://localhost:8969/v1/audio/speech\" -s \\\n#     -H \"Content-Type: application/json\" \\\n#     --output test.mp3 \\\n#     --data '{\"input\": \"Hello, local TTS works!\", \"model\": \"speaches-ai/Kokoro-82M-v1.0-ONNX\", \"voice\": \"af_bella\"}'\n#\n# Test STT:\n#   curl \"http://localhost:8969/v1/audio/transcriptions\" \\\n#     -F \"file=@test.mp3\" \\\n#     -F \"model=Systran/faster-whisper-small\"\n#\n# Available voices: af_bella, af_sarah, am_adam, am_michael, bf_emma, bm_george\n# Available models: See docs/5-CONFIGURATION/local-stt.md for model sizes\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\ndoc_exports/"
  },
  {
    "path": "frontend/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/app/globals.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}"
  },
  {
    "path": "frontend/eslint.config.mjs",
    "content": "import { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { FlatCompat } from \"@eslint/eslintrc\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst compat = new FlatCompat({\n  baseDirectory: __dirname,\n});\n\nconst eslintConfig = [\n  ...compat.extends(\"next/core-web-vitals\", \"next/typescript\"),\n];\n\nexport default eslintConfig;\n"
  },
  {
    "path": "frontend/next.config.ts",
    "content": "import type { NextConfig } from \"next\";\n\nconst nextConfig: NextConfig = {\n  // Enable standalone output for optimized Docker deployment\n  output: \"standalone\",\n\n  // Experimental features\n  // Type assertion needed: proxyClientMaxBodySize is valid in Next.js 15 but types lag behind\n  experimental: {\n    // Increase proxy body size limit for file uploads (default is 10MB)\n    // This allows larger files to be uploaded through the /api/* rewrite proxy to FastAPI\n    proxyClientMaxBodySize: '100mb',\n  } as NextConfig['experimental'],\n\n  // API Rewrites: Proxy /api/* requests to FastAPI backend\n  // This simplifies reverse proxy configuration - users only need to proxy to port 8502\n  // Next.js handles internal routing to the API backend on port 5055\n  async rewrites() {\n    // INTERNAL_API_URL: Where Next.js server-side should proxy API requests\n    // Default: http://localhost:5055 (single-container deployment)\n    // Override for multi-container: INTERNAL_API_URL=http://api-service:5055\n    const internalApiUrl = process.env.INTERNAL_API_URL || 'http://localhost:5055'\n\n    console.log(`[Next.js Rewrites] Proxying /api/* to ${internalApiUrl}/api/*`)\n\n    return [\n      {\n        source: '/api/:path*',\n        destination: `${internalApiUrl}/api/:path*`,\n      },\n    ]\n  },\n};\n\nexport default nextConfig;\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"frontend\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"node start-server.js\",\n    \"lint\": \"eslint src/\",\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\",\n    \"test:ui\": \"vitest --ui\"\n  },\n  \"dependencies\": {\n    \"@hookform/resolvers\": \"^5.1.1\",\n    \"@monaco-editor/react\": \"^4.7.0\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.14\",\n    \"@radix-ui/react-checkbox\": \"^1.3.2\",\n    \"@radix-ui/react-collapsible\": \"^1.1.11\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.7\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.9\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-tabs\": \"^1.1.12\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@tailwindcss/typography\": \"^0.5.16\",\n    \"@tanstack/react-query\": \"^5.83.0\",\n    \"@uiw/react-md-editor\": \"^4.0.8\",\n    \"axios\": \"^1.13.5\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"i18next\": \"^25.7.3\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"lucide-react\": \"^0.525.0\",\n    \"next\": \"^16.1.5\",\n    \"next-themes\": \"^0.4.6\",\n    \"react\": \"^19.2.3\",\n    \"react-dom\": \"^19.2.3\",\n    \"react-hook-form\": \"^7.60.0\",\n    \"react-i18next\": \"^16.5.0\",\n    \"react-markdown\": \"^10.1.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"sonner\": \"^2.0.6\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"use-debounce\": \"^10.0.6\",\n    \"zod\": \"^4.0.5\",\n    \"zustand\": \"^5.0.6\"\n  },\n  \"devDependencies\": {\n    \"@eslint/eslintrc\": \"^3\",\n    \"@tailwindcss/postcss\": \"^4\",\n    \"@types/node\": \"^20\",\n    \"@types/react\": \"^19\",\n    \"@types/react-dom\": \"^19\",\n    \"eslint\": \"^9\",\n    \"eslint-config-next\": \"15.4.2\",\n    \"jsdom\": \"^26.0.0\",\n    \"tailwindcss\": \"^4\",\n    \"tw-animate-css\": \"^1.3.5\",\n    \"typescript\": \"^5\",\n    \"vitest\": \"^3.0.0\",\n    \"@vitest/ui\": \"^3.0.0\",\n    \"@vitejs/plugin-react\": \"^4.3.4\",\n    \"@testing-library/react\": \"^16.2.0\",\n    \"@testing-library/jest-dom\": \"^6.6.3\"\n  }\n}\n"
  },
  {
    "path": "frontend/postcss.config.mjs",
    "content": "const config = {\n  plugins: [\"@tailwindcss/postcss\"],\n};\n\nexport default config;\n"
  },
  {
    "path": "frontend/src/CLAUDE.md",
    "content": "# Frontend Architecture\n\nNext.js React application providing UI for Open Notebook research assistant. Three-layer architecture: **pages** (Next.js App Router), **components** (feature-specific UI), and **lib** (data fetching, state management, utilities).\n\n## High-Level Data Flow\n\n```\nPages (Next.js) → Components (feature-specific) → Hooks (queries/mutations)\n                                                       ↓\n                          Stores (auth/modal state) → API module → Backend\n```\n\nUser interactions trigger mutations/queries via hooks, which communicate with the backend through the API module. Store state (auth, modals) flows back to components via hooks. Child CLAUDE.md files document specific modules in detail:\n\n- **`lib/api/CLAUDE.md`**: Axios client, FormData handling, interceptors\n- **`lib/hooks/CLAUDE.md`**: TanStack Query wrappers, SSE streaming, context building\n- **`lib/stores/CLAUDE.md`**: Zustand auth/modal state, localStorage persistence\n- **`lib/locales/CLAUDE.md`**: Internationalization (i18n) system, translation files\n- **`components/ui/CLAUDE.md`**: Radix UI primitives, CVA styling, accessibility\n\n## Architectural Layers\n\n### Pages (`src/app/`) — Next.js App Router\n- `(auth)/login`: Authentication entry point\n- `(dashboard)/`: Protected routes (notebooks, sources, search, models, etc.)\n- Directory-based routing; each `page.tsx` is a route endpoint\n- **Key pattern**: Pages call hooks to fetch data, render components with state\n- **Router groups** `(auth)`, `(dashboard)` organize routes by feature without affecting URL\n\n### Components (`src/components/`) — Feature-Specific UI\n- **layout**: `AppShell.tsx`, `AppSidebar.tsx` — main layout wrapper used by all pages\n- **providers**: `ThemeProvider`, `QueryProvider`, `ModalProvider` — app-wide context setup\n- **auth**: `LoginForm.tsx` — authentication UI\n- **common**: `CommandPalette`, `ErrorBoundary`, `ContextToggle`, `ModelSelector` — shared across pages\n- **ui**: Reusable Radix UI building blocks (see child CLAUDE.md)\n- **source**, **notebooks**, **search**, **podcasts**: Feature-specific components consuming hooks\n\n**Component composition pattern**: Pages → Feature components → UI components. Feature components handle page-level state (loading, error), UI components remain stateless and styled.\n\n### Lib (`src/lib/`) — Data & State Layer\n\n#### `lib/api/` — Backend Communication\n- **`client.ts`**: Central Axios instance with auth interceptor, FormData handling, 10-min timeout\n- **`query-client.ts`**: TanStack Query configuration\n- **Resource modules** (`sources.ts`, `chat.ts`, `notebooks.ts`, etc.): Endpoint-specific functions returning typed responses\n- **Pattern**: All requests go through `apiClient`; auth token auto-added from localStorage\n\n#### `lib/hooks/` — React Query + Custom Logic\n- **Query hooks**: `useNotebookSources`, `useSources`, `useSource` — TanStack Query wrappers with cache keys\n- **Mutation hooks**: `useCreateSource`, `useUpdateSource`, `useDeleteSource` — mutations with toast feedback + cache invalidation\n- **Complex hooks**: `useNotebookChat`, `useSourceChat` — session management, message streaming, context building\n- **SSE streaming**: `useAsk` — parses newline-delimited JSON from backend for multi-stage workflows\n- **Pattern**: Hooks return `{ data, isLoading, error, refetch }` + action functions; cache invalidation on mutations\n\n#### `lib/stores/` — Application State\n- **`auth-store.ts`**: Authentication state (token, isAuthenticated) with 30-second check caching\n- **Zustand + persist middleware**: Auto-syncs sensitive state to localStorage\n- **Pattern**: Store actions (`login()`, `logout()`, `checkAuth()`) update state; consumed via hooks in components\n\n#### `lib/types/` — TypeScript Definitions\n- API request/response shapes, domain models (Notebook, Source, Note, etc.)\n- Ensures type safety across API calls and store mutations\n\n#### `lib/locales/` — Internationalization (i18n)\n- **Locale files** (`en-US/`, `pt-BR/`, `zh-CN/`, `zh-TW/`, `ja-JP/`): Translation strings organized by feature\n- **`i18n.ts`**: i18next configuration with language detection\n- **`use-translation.ts`**: Custom hook with Proxy-based `t.section.key` access pattern\n- **Pattern**: Components call `useTranslation()` hook; access strings via `t.common.save`, `t.notebooks.title`\n\n## Data & Control Flow Walkthrough\n\n### Example: Notebook Chat\n1. **Page** (`notebooks/[id]/page.tsx`) fetches initial data, passes `notebookId` to `ChatColumn` component\n2. **Hook call** (`useNotebookChat()`):\n   - Queries sessions for notebook via TanStack Query\n   - Sets up message state + context building logic\n   - Returns `{ messages, sendMessage(), setModelOverride() }`\n3. **Component renders**: `ChatColumn` displays messages, text input\n4. **User sends message**: Component calls `sendMessage()` hook\n5. **Hook execution**:\n   - Builds context from selected sources/notes via `buildContext()` helper\n   - Calls `chatApi.sendMessage()` (from API module)\n   - Client-side optimistic update: adds message to local state before response\n6. **Backend response** arrives, TanStack Query updates cache\n7. **Cache invalidation** on other source/note mutations ensures stale UI refreshes\n\n### Example: File Upload with Source Creation\n1. **Component** (`SourceDialog`) renders form with file picker\n2. **Hook** (`useFileUpload`):\n   - Converts file to FormData (JSON fields stringified)\n   - Calls `sourcesApi.create()` with FormData\n   - API client interceptor deletes Content-Type header (lets browser set multipart boundary)\n3. **Toast notifications** show progress\n4. **Cache invalidation** on success: `queryClient.invalidateQueries(['sources'])`\n5. **Related queries** auto-refetch: notebooks, sources list, etc.\n\n## Key Patterns & Cross-Layer Coordination\n\n### Caching & Invalidation\n- **Query keys**: `QUERY_KEYS.notebook(id)`, `QUERY_KEYS.sources(notebookId)` — hierarchical structure\n- **Broad invalidation**: `['sources']` invalidates all source queries; trade-off between accuracy + performance\n- **Auto-refetch**: `refetchOnWindowFocus: true` on frequently-changing data (sources, notebooks)\n\n### Auth & Protected Routes\n- **Proxy** (`src/proxy.ts`): Redirects root `/` to `/notebooks`\n- **Auth store**: Validates token via `/notebooks` API call (actual validation, not JWT decode)\n- **Interceptor**: Adds `Bearer {token}` to all requests; 401 response clears auth and redirects to login\n\n### Modal State Management\n- **Modal hooks**: Components query modal state from stores\n- **Context**: Modals pass data (e.g., notebook ID) to child components\n- **Pattern**: One store per modal type; triggered by button clicks + data passing via hook arguments\n\n### Error Handling\n- **API errors**: All request failures propagate to consuming code; components show toast notifications\n- **Error message resolution** (`lib/utils/error-handler.ts`): `getApiErrorMessage()` tries i18n mapping first via `ERROR_MAP`, then falls back to displaying the backend's descriptive error message directly. This ensures user-friendly error messages from the error classification system are shown as-is.\n- **Toast feedback**: Mutations show success/error toasts (from `sonner` library)\n- **Error boundary**: App-level error boundary catches React render errors; shows fallback UI\n\n### FormData Handling\n- **JSON fields**: Nested objects (arrays, objects) must be JSON stringified before FormData\n- **Content-Type header**: Removed by interceptor for FormData requests (lets browser set boundary)\n- **Example**: `sources` array converted to string via `JSON.stringify()` before appending to FormData\n\n## Component Organization Within Features\n\n- **Feature folders** (`source/`, `notebooks/`, `podcasts/`): Group related components\n- **Composition**: Larger components nest smaller ones; no deep prop drilling (state lifted to hooks)\n- **Dialog patterns**: Features define dialog components for inline actions (edit, create, delete)\n- **Props**: Components accept data + action callbacks from parent or hooks\n\n## Providers & Context Setup\n\n**Root layout** (`app/layout.tsx`) wraps app with (outermost → innermost):\n1. `ErrorBoundary` — React error boundary (catches all render errors)\n2. `ThemeProvider` — next-themes for light/dark mode\n3. `QueryProvider` — TanStack Query client\n4. `I18nProvider` — i18next initialization and language loading overlay\n5. `ConnectionGuard` — checks backend connectivity on startup\n6. `Toaster` — sonner toast notification system (inside ConnectionGuard)\n\n## Important Gotchas & Design Decisions\n\n- **Token storage**: Stored in localStorage under `auth-storage` key (Zustand persist); consumed by API interceptor\n- **Base URL discovery**: API client fetches base URL from runtime config on first request (async; can be slow on startup)\n- **Optimistic updates**: Chat messages added to state before server confirmation; removed on error\n- **Modal lifecycle**: Dialogs not auto-reset; parent must clear form state after submit\n- **Focus management**: Dialog auto-focuses first input; can cause layout shifts if inputs are conditional\n- **Cache invalidation breadth**: Trade-off between precision + simplicity; broad invalidation simpler but may over-fetch\n\n## How to Add a New Feature\n\n1. **Create page**: `app/(dashboard)/feature/page.tsx` — calls hooks, renders components\n2. **Create feature components**: `components/feature/` — compose UI + business logic\n3. **Add hooks** (if data needed): `lib/hooks/useFeature.ts` — TanStack Query wrapper\n4. **Add API module** (if backend call needed): `lib/api/feature.ts` — resource-specific functions\n5. **Add types**: `lib/types/api.ts` — request/response shapes\n6. **Use UI components**: Import from `components/ui/` for consistent styling\n7. **Handle auth**: Middleware redirects unauthenticated users; no special handling needed in component\n\n## Testing\n\n- **Hooks**: Mock API functions, wrap in `QueryClientProvider`, assert query/mutation behavior\n- **Components**: Mock hooks via `vi.fn()`, test rendering + user interactions\n- **API calls**: Mock `axios` interceptors; test request/response shapes\n- **Stores**: Mock store state, test mutations via `act()`, assert state changes\n\nSee child CLAUDE.md files for module-specific testing patterns.\n"
  },
  {
    "path": "frontend/src/app/(auth)/login/page.tsx",
    "content": "import { LoginForm } from '@/components/auth/LoginForm'\nimport { ErrorBoundary } from '@/components/common/ErrorBoundary'\n\nexport default function LoginPage() {\n  return (\n    <ErrorBoundary>\n      <LoginForm />\n    </ErrorBoundary>\n  )\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/advanced/components/RebuildEmbeddings.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useCallback } from 'react'\nimport { useMutation } from '@tanstack/react-query'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Label } from '@/components/ui/label'\nimport { Alert, AlertDescription } from '@/components/ui/alert'\nimport { Progress } from '@/components/ui/progress'\nimport { Loader2, AlertCircle, CheckCircle2, XCircle, Clock } from 'lucide-react'\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from '@/components/ui/accordion'\nimport { embeddingApi } from '@/lib/api/embedding'\nimport type { RebuildEmbeddingsRequest, RebuildStatusResponse } from '@/lib/api/embedding'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport function RebuildEmbeddings() {\n  const { t } = useTranslation()\n  const [mode, setMode] = useState<'existing' | 'all'>('existing')\n  const [includeSources, setIncludeSources] = useState(true)\n  const [includeNotes, setIncludeNotes] = useState(true)\n  const [includeInsights, setIncludeInsights] = useState(true)\n  const [commandId, setCommandId] = useState<string | null>(null)\n  const [status, setStatus] = useState<RebuildStatusResponse | null>(null)\n  const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null)\n\n  // Rebuild mutation\n  const rebuildMutation = useMutation({\n    mutationFn: async (request: RebuildEmbeddingsRequest) => {\n      return embeddingApi.rebuildEmbeddings(request)\n    },\n    onSuccess: (data) => {\n      setCommandId(data.command_id)\n      // Start polling for status\n      startPolling(data.command_id)\n    }\n  })\n\n  // Start polling for rebuild status\n  const startPolling = (cmdId: string) => {\n    if (pollingInterval) {\n      clearInterval(pollingInterval)\n    }\n\n    const interval = setInterval(async () => {\n      try {\n        const statusData = await embeddingApi.getRebuildStatus(cmdId)\n        setStatus(statusData)\n\n        // Stop polling if completed or failed\n        if (statusData.status === 'completed' || statusData.status === 'failed') {\n          stopPolling()\n        }\n      } catch (error) {\n        console.error('Failed to fetch rebuild status:', error)\n      }\n    }, 5000) // Poll every 5 seconds\n\n    setPollingInterval(interval)\n  }\n\n  // Stop polling\n  const stopPolling = useCallback(() => {\n    if (pollingInterval) {\n      clearInterval(pollingInterval)\n      setPollingInterval(null)\n    }\n  }, [pollingInterval])\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => {\n      stopPolling()\n    }\n  }, [stopPolling])\n\n  const handleStartRebuild = () => {\n    const request: RebuildEmbeddingsRequest = {\n      mode,\n      include_sources: includeSources,\n      include_notes: includeNotes,\n      include_insights: includeInsights\n    }\n\n    rebuildMutation.mutate(request)\n  }\n\n  const handleReset = () => {\n    stopPolling()\n    setCommandId(null)\n    setStatus(null)\n    rebuildMutation.reset()\n  }\n\n  const isAnyTypeSelected = includeSources || includeNotes || includeInsights\n  const isRebuildActive = commandId && status && (status.status === 'queued' || status.status === 'running')\n\n  const progressData = status?.progress\n  const stats = status?.stats\n\n  const totalItems = progressData?.total_items ?? progressData?.total ?? 0\n  const processedItems = progressData?.processed_items ?? progressData?.processed ?? 0\n  const derivedProgressPercent = progressData?.percentage ?? (totalItems > 0 ? (processedItems / totalItems) * 100 : 0)\n  const progressPercent = Number.isFinite(derivedProgressPercent) ? derivedProgressPercent : 0\n\n  const sourcesProcessed = stats?.sources_processed ?? stats?.sources ?? 0\n  const notesProcessed = stats?.notes_processed ?? stats?.notes ?? 0\n  const insightsProcessed = stats?.insights_processed ?? stats?.insights ?? 0\n  const failedItems = stats?.failed_items ?? stats?.failed ?? 0\n\n  const computedDuration = status?.started_at && status?.completed_at\n    ? (new Date(status.completed_at).getTime() - new Date(status.started_at).getTime()) / 1000\n    : undefined\n  const processingTimeSeconds = stats?.processing_time ?? computedDuration\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          {t.advanced.rebuildEmbeddings}\n        </CardTitle>\n        <CardDescription>\n          {t.advanced.rebuildEmbeddingsDesc}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-6\">\n        {/* Configuration Form */}\n        {!isRebuildActive && (\n          <div className=\"space-y-6\">\n            <div className=\"space-y-3\">\n              <Label htmlFor=\"mode\">{t.advanced.rebuild.mode}</Label>\n              <Select value={mode} onValueChange={(value) => setMode(value as 'existing' | 'all')}>\n                <SelectTrigger id=\"mode\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"existing\">{t.advanced.rebuild.existing}</SelectItem>\n                  <SelectItem value=\"all\">{t.advanced.rebuild.all}</SelectItem>\n                </SelectContent>\n              </Select>\n              <p className=\"text-sm text-muted-foreground\">\n                {mode === 'existing'\n                  ? t.advanced.rebuild.existingDesc\n                  : t.advanced.rebuild.allDesc}\n              </p>\n            </div>\n\n            <div className=\"space-y-3\" role=\"group\" aria-labelledby=\"include-label\">\n              <span id=\"include-label\" className=\"text-sm font-medium leading-none\">{t.advanced.rebuild.include}</span>\n              <div className=\"space-y-3\">\n                <div className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"sources\"\n                    checked={includeSources}\n                    onCheckedChange={(checked) => setIncludeSources(checked === true)}\n                  />\n                  <Label htmlFor=\"sources\" className=\"font-normal cursor-pointer\">\n                    {t.navigation.sources}\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"notes\"\n                    checked={includeNotes}\n                    onCheckedChange={(checked) => setIncludeNotes(checked === true)}\n                  />\n                  <Label htmlFor=\"notes\" className=\"font-normal cursor-pointer\">\n                    {t.common.notes}\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"insights\"\n                    checked={includeInsights}\n                    onCheckedChange={(checked) => setIncludeInsights(checked === true)}\n                  />\n                  <Label htmlFor=\"insights\" className=\"font-normal cursor-pointer\">\n                    {t.common.insights}\n                  </Label>\n                </div>\n              </div>\n              {!isAnyTypeSelected && (\n                <Alert variant=\"destructive\">\n                  <AlertCircle className=\"h-4 w-4\" />\n                  <AlertDescription>\n                    {t.advanced.rebuild.selectOneError}\n                  </AlertDescription>\n                </Alert>\n              )}\n            </div>\n\n            <Button\n              onClick={handleStartRebuild}\n              disabled={!isAnyTypeSelected || rebuildMutation.isPending}\n              className=\"w-full\"\n            >\n              {rebuildMutation.isPending ? (\n                <>\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  {t.advanced.rebuild.starting}\n                </>\n              ) : (\n                t.advanced.rebuild.startBtn\n              )}\n            </Button>\n\n            {rebuildMutation.isError && (\n              <Alert variant=\"destructive\">\n                <AlertCircle className=\"h-4 w-4\" />\n                <AlertDescription>\n                  {t.advanced.rebuild.failed}: {(rebuildMutation.error as Error)?.message || t.common.error}\n                </AlertDescription>\n              </Alert>\n            )}\n          </div>\n        )}\n\n        {/* Status Display */}\n        {status && (\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                {status.status === 'queued' && <Clock className=\"h-5 w-5 text-yellow-500\" />}\n                {status.status === 'running' && <Loader2 className=\"h-5 w-5 text-blue-500 animate-spin\" />}\n                {status.status === 'completed' && <CheckCircle2 className=\"h-5 w-5 text-green-500\" />}\n                {status.status === 'failed' && <XCircle className=\"h-5 w-5 text-red-500\" />}\n                <div className=\"flex flex-col\">\n                  <span className=\"font-medium\">\n                    {status.status === 'queued' && t.advanced.rebuild.queued}\n                    {status.status === 'running' && t.advanced.rebuild.running}\n                    {status.status === 'completed' && t.advanced.rebuild.completed}\n                    {status.status === 'failed' && t.advanced.rebuild.failed}\n                  </span>\n                  {status.status === 'running' && (\n                    <span className=\"text-sm text-muted-foreground\">\n                      {t.advanced.rebuild.leavePageHint}\n                    </span>\n                  )}\n                </div>\n              </div>\n              {(status.status === 'completed' || status.status === 'failed') && (\n                <Button variant=\"outline\" size=\"sm\" onClick={handleReset}>\n                  {t.advanced.rebuild.startNew}\n                </Button>\n              )}\n            </div>\n\n            {progressData && (\n              <div className=\"space-y-2\">\n                <div className=\"flex justify-between text-sm\">\n                  <span>{t.common.progress}</span>\n                  <span className=\"font-medium\">\n                    {t.advanced.rebuild.itemsProcessed\n                      .replace('{processed}', processedItems.toString())\n                      .replace('{total}', totalItems.toString())\n                      .replace('{percent}', progressPercent.toFixed(1))}\n                  </span>\n                </div>\n                <Progress value={progressPercent} className=\"h-2\" />\n                {failedItems > 0 && (\n                  <p className=\"text-sm text-yellow-600\">\n                    ⚠️ {t.advanced.rebuild.failedItems.replace('{count}', failedItems.toString())}\n                  </p>\n                )}\n              </div>\n            )}\n\n             {stats && (\n              <div className=\"grid grid-cols-4 gap-4\">\n                <div className=\"space-y-1\">\n                  <p className=\"text-sm text-muted-foreground\">{t.navigation.sources}</p>\n                  <p className=\"text-2xl font-bold\">{sourcesProcessed}</p>\n                </div>\n                <div className=\"space-y-1\">\n                  <p className=\"text-sm text-muted-foreground\">{t.common.notes}</p>\n                  <p className=\"text-2xl font-bold\">{notesProcessed}</p>\n                </div>\n                <div className=\"space-y-1\">\n                  <p className=\"text-sm text-muted-foreground\">{t.common.insights}</p>\n                  <p className=\"text-2xl font-bold\">{insightsProcessed}</p>\n                </div>\n                <div className=\"space-y-1\">\n                  <p className=\"text-sm text-muted-foreground\">{t.advanced.rebuild.time}</p>\n                  <p className=\"text-2xl font-bold\">\n                    {processingTimeSeconds !== undefined ? `${processingTimeSeconds.toFixed(1)}s` : '—'}\n                  </p>\n                </div>\n              </div>\n            )}\n\n            {status.error_message && (\n              <Alert variant=\"destructive\">\n                <AlertCircle className=\"h-4 w-4\" />\n                <AlertDescription>{status.error_message}</AlertDescription>\n              </Alert>\n            )}\n\n            {status.started_at && (\n              <div className=\"text-sm text-muted-foreground space-y-1\">\n                <p>{t.common.created.replace('{time}', new Date(status.started_at).toLocaleString())}</p>\n                {status.completed_at && (\n                  <p>{t.notebooks.updated}: {new Date(status.completed_at).toLocaleString()}</p>\n                )}\n              </div>\n            )}\n          </div>\n        )}\n\n        {/* Help Section */}\n         <Accordion type=\"single\" collapsible className=\"w-full\">\n          <AccordionItem value=\"when\">\n            <AccordionTrigger>{t.advanced.rebuild.whenToRebuild}</AccordionTrigger>\n            <AccordionContent className=\"space-y-2 text-sm\">\n              <p>{t.advanced.rebuild.whenToRebuildAns}</p>\n            </AccordionContent>\n          </AccordionItem>\n\n          <AccordionItem value=\"time\">\n            <AccordionTrigger>{t.advanced.rebuild.howLong}</AccordionTrigger>\n            <AccordionContent className=\"space-y-2 text-sm\">\n              <p>{t.advanced.rebuild.howLongAns}</p>\n            </AccordionContent>\n          </AccordionItem>\n\n          <AccordionItem value=\"safe\">\n            <AccordionTrigger>{t.advanced.rebuild.isSafe}</AccordionTrigger>\n            <AccordionContent className=\"space-y-2 text-sm\">\n              <p>{t.advanced.rebuild.isSafeAns}</p>\n            </AccordionContent>\n          </AccordionItem>\n        </Accordion>\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/advanced/components/SystemInfo.tsx",
    "content": "'use client'\n\nimport { useEffect, useState } from 'react'\nimport { Card } from '@/components/ui/card'\nimport { getConfig } from '@/lib/config'\nimport { Badge } from '@/components/ui/badge'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport function SystemInfo() {\n  const { t } = useTranslation()\n  const [config, setConfig] = useState<{\n    version: string\n    latestVersion?: string | null\n    hasUpdate?: boolean\n  } | null>(null)\n  const [isLoading, setIsLoading] = useState(true)\n\n  useEffect(() => {\n    const loadConfig = async () => {\n      try {\n        const cfg = await getConfig()\n        setConfig(cfg)\n      } catch (error) {\n        console.error('Failed to load config:', error)\n      } finally {\n        setIsLoading(false)\n      }\n    }\n\n    loadConfig()\n  }, [])\n\n  if (isLoading) {\n    return (\n      <Card className=\"p-6\">\n        <div className=\"space-y-4\">\n          <h2 className=\"text-xl font-semibold\">{t.advanced.systemInfo}</h2>\n          <div className=\"text-sm text-muted-foreground\">{t.common.loading}</div>\n        </div>\n      </Card>\n    )\n  }\n\n  return (\n    <Card className=\"p-6\">\n      <div className=\"space-y-4\">\n        <h2 className=\"text-xl font-semibold\">{t.advanced.systemInfo}</h2>\n\n        <div className=\"space-y-3\">\n          {/* Current Version */}\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-sm font-medium\">{t.advanced.currentVersion}</span>\n            <Badge variant=\"outline\">{config?.version || t.advanced.unknown}</Badge>\n          </div>\n\n          {/* Latest Version */}\n          {config?.latestVersion && (\n            <div className=\"flex items-center justify-between\">\n              <span className=\"text-sm font-medium\">{t.advanced.latestVersion}</span>\n              <Badge variant=\"outline\">{config.latestVersion}</Badge>\n            </div>\n          )}\n\n          {/* Update Status */}\n          <div className=\"flex items-center justify-between\">\n            <span className=\"text-sm font-medium\">{t.advanced.status}</span>\n            {config?.hasUpdate ? (\n              <Badge variant=\"destructive\">\n                {t.advanced.updateAvailable.replace('{version}', config.latestVersion || '')}\n              </Badge>\n            ) : config?.latestVersion ? (\n              <Badge variant=\"outline\" className=\"text-green-600 border-green-600\">\n                {t.advanced.upToDate}\n              </Badge>\n            ) : (\n              <Badge variant=\"outline\" className=\"text-muted-foreground\">\n                {t.advanced.unknown}\n              </Badge>\n            )}\n          </div>\n\n          {/* GitHub Repository Link */}\n          {config?.hasUpdate && (\n            <div className=\"pt-2 border-t\">\n              <a\n                href=\"https://github.com/lfnovo/open-notebook\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"text-sm text-primary hover:underline inline-flex items-center gap-1\"\n              >\n                {t.advanced.viewOnGithub}\n                <svg\n                  className=\"w-4 h-4\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\"\n                  />\n                </svg>\n              </a>\n            </div>\n          )}\n\n          {/* Version Check Failed Message */}\n          {!config?.latestVersion && config?.version && (\n            <div className=\"pt-2 text-xs text-muted-foreground\">\n              {t.advanced.updateCheckFailed}\n            </div>\n          )}\n        </div>\n      </div>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/advanced/page.tsx",
    "content": "'use client'\n\nimport { AppShell } from '@/components/layout/AppShell'\nimport { RebuildEmbeddings } from './components/RebuildEmbeddings'\nimport { SystemInfo } from './components/SystemInfo'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport default function AdvancedPage() {\n  const { t } = useTranslation()\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6\">\n          <div className=\"max-w-4xl mx-auto space-y-6\">\n            <div>\n              <h1 className=\"text-3xl font-bold\">{t.advanced.title}</h1>\n              <p className=\"text-muted-foreground mt-2\">\n                {t.advanced.desc}\n              </p>\n            </div>\n\n            <SystemInfo />\n            <RebuildEmbeddings />\n          </div>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/layout.tsx",
    "content": "'use client'\n\nimport { useAuth } from '@/lib/hooks/use-auth'\nimport { useVersionCheck } from '@/lib/hooks/use-version-check'\nimport { useRouter } from 'next/navigation'\nimport { useEffect, useState } from 'react'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { ErrorBoundary } from '@/components/common/ErrorBoundary'\nimport { ModalProvider } from '@/components/providers/ModalProvider'\nimport { CreateDialogsProvider } from '@/lib/hooks/use-create-dialogs'\nimport { CommandPalette } from '@/components/common/CommandPalette'\n\nexport default function DashboardLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  const { isAuthenticated, isLoading } = useAuth()\n  const router = useRouter()\n  const [hasCheckedAuth, setHasCheckedAuth] = useState(false)\n\n  // Check for version updates once per session\n  useVersionCheck()\n\n  useEffect(() => {\n    // Mark that we've completed the initial auth check\n    if (!isLoading) {\n      setHasCheckedAuth(true)\n\n      // Redirect to login if not authenticated\n      if (!isAuthenticated) {\n        // Store the current path to redirect back after login\n        const currentPath = window.location.pathname + window.location.search\n        sessionStorage.setItem('redirectAfterLogin', currentPath)\n        router.push('/login')\n      }\n    }\n  }, [isAuthenticated, isLoading, router])\n\n  // Show loading spinner during initial auth check or while loading\n  if (isLoading || !hasCheckedAuth) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center\">\n        <LoadingSpinner />\n      </div>\n    )\n  }\n\n  // Don't render anything if not authenticated (during redirect)\n  if (!isAuthenticated) {\n    return null\n  }\n\n  return (\n    <ErrorBoundary>\n      <CreateDialogsProvider>\n        {children}\n        <ModalProvider />\n        <CommandPalette />\n      </CreateDialogsProvider>\n    </ErrorBoundary>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/[id]/page.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport { useParams } from 'next/navigation'\nimport { AppShell } from '@/components/layout/AppShell'\nimport { NotebookHeader } from '../components/NotebookHeader'\nimport { SourcesColumn } from '../components/SourcesColumn'\nimport { NotesColumn } from '../components/NotesColumn'\nimport { ChatColumn } from '../components/ChatColumn'\nimport { useNotebook } from '@/lib/hooks/use-notebooks'\nimport { useNotebookSources } from '@/lib/hooks/use-sources'\nimport { useNotes } from '@/lib/hooks/use-notes'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'\nimport { useIsDesktop } from '@/lib/hooks/use-media-query'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { cn } from '@/lib/utils'\nimport { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { FileText, StickyNote, MessageSquare } from 'lucide-react'\n\nexport type ContextMode = 'off' | 'insights' | 'full'\n\nexport interface ContextSelections {\n  sources: Record<string, ContextMode>\n  notes: Record<string, ContextMode>\n}\n\nexport default function NotebookPage() {\n  const { t } = useTranslation()\n  const params = useParams()\n\n  // Ensure the notebook ID is properly decoded from URL\n  const notebookId = params?.id ? decodeURIComponent(params.id as string) : ''\n\n  const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)\n  const {\n    sources,\n    isLoading: sourcesLoading,\n    refetch: refetchSources,\n    hasNextPage,\n    isFetchingNextPage,\n    fetchNextPage,\n  } = useNotebookSources(notebookId)\n  const { data: notes, isLoading: notesLoading } = useNotes(notebookId)\n\n  // Get collapse states for dynamic layout\n  const { sourcesCollapsed, notesCollapsed } = useNotebookColumnsStore()\n\n  // Detect desktop to avoid double-mounting ChatColumn\n  const isDesktop = useIsDesktop()\n\n  // Mobile tab state (Sources, Notes, or Chat)\n  const [mobileActiveTab, setMobileActiveTab] = useState<'sources' | 'notes' | 'chat'>('chat')\n\n  // Context selection state\n  const [contextSelections, setContextSelections] = useState<ContextSelections>({\n    sources: {},\n    notes: {}\n  })\n\n  // Initialize and update selections when sources load or change\n  useEffect(() => {\n    if (sources && sources.length > 0) {\n      setContextSelections(prev => {\n        const newSourceSelections = { ...prev.sources }\n        sources.forEach(source => {\n          const currentMode = newSourceSelections[source.id]\n          const hasInsights = source.insights_count > 0\n\n          if (currentMode === undefined) {\n            // Initial setup - default based on insights availability\n            newSourceSelections[source.id] = hasInsights ? 'insights' : 'full'\n          } else if (currentMode === 'full' && hasInsights) {\n            // Source gained insights while in 'full' mode - auto-switch to 'insights'\n            newSourceSelections[source.id] = 'insights'\n          }\n        })\n        return { ...prev, sources: newSourceSelections }\n      })\n    }\n  }, [sources])\n\n  useEffect(() => {\n    if (notes && notes.length > 0) {\n      setContextSelections(prev => {\n        const newNoteSelections = { ...prev.notes }\n        notes.forEach(note => {\n          // Only set default if not already set\n          if (!(note.id in newNoteSelections)) {\n            // Notes default to 'full'\n            newNoteSelections[note.id] = 'full'\n          }\n        })\n        return { ...prev, notes: newNoteSelections }\n      })\n    }\n  }, [notes])\n\n  // Handler to update context selection\n  const handleContextModeChange = (itemId: string, mode: ContextMode, type: 'source' | 'note') => {\n    setContextSelections(prev => ({\n      ...prev,\n      [type === 'source' ? 'sources' : 'notes']: {\n        ...(type === 'source' ? prev.sources : prev.notes),\n        [itemId]: mode\n      }\n    }))\n  }\n\n  if (notebookLoading) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center\">\n        <LoadingSpinner size=\"lg\" />\n      </div>\n    )\n  }\n\n  if (!notebook) {\n    return (\n      <AppShell>\n        <div className=\"p-6\">\n          <h1 className=\"text-2xl font-bold mb-4\">{t.notebooks.notFound}</h1>\n          <p className=\"text-muted-foreground\">{t.notebooks.notFoundDesc}</p>\n        </div>\n      </AppShell>\n    )\n  }\n\n  return (\n    <AppShell>\n      <div className=\"flex flex-col flex-1 min-h-0\">\n        <div className=\"flex-shrink-0 p-6 pb-0\">\n          <NotebookHeader notebook={notebook} />\n        </div>\n\n        <div className=\"flex-1 p-6 pt-6 overflow-x-auto flex flex-col\">\n          {/* Mobile: Tabbed interface - only render on mobile to avoid double-mounting */}\n          {!isDesktop && (\n            <>\n              <div className=\"lg:hidden mb-4\">\n                <Tabs value={mobileActiveTab} onValueChange={(value) => setMobileActiveTab(value as 'sources' | 'notes' | 'chat')}>\n                  <TabsList className=\"grid w-full grid-cols-3\">\n                    <TabsTrigger value=\"sources\" className=\"gap-2\">\n                      <FileText className=\"h-4 w-4\" />\n                      {t.navigation.sources}\n                    </TabsTrigger>\n                    <TabsTrigger value=\"notes\" className=\"gap-2\">\n                      <StickyNote className=\"h-4 w-4\" />\n                      {t.common.notes}\n                    </TabsTrigger>\n                    <TabsTrigger value=\"chat\" className=\"gap-2\">\n                      <MessageSquare className=\"h-4 w-4\" />\n                      {t.common.chat}\n                    </TabsTrigger>\n                  </TabsList>\n                </Tabs>\n              </div>\n\n              {/* Mobile: Show only active tab */}\n              <div className=\"flex-1 overflow-hidden lg:hidden\">\n                {mobileActiveTab === 'sources' && (\n                  <SourcesColumn\n                    sources={sources}\n                    isLoading={sourcesLoading}\n                    notebookId={notebookId}\n                    notebookName={notebook?.name}\n                    onRefresh={refetchSources}\n                    contextSelections={contextSelections.sources}\n                    onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}\n                    hasNextPage={hasNextPage}\n                    isFetchingNextPage={isFetchingNextPage}\n                    fetchNextPage={fetchNextPage}\n                  />\n                )}\n                {mobileActiveTab === 'notes' && (\n                  <NotesColumn\n                    notes={notes}\n                    isLoading={notesLoading}\n                    notebookId={notebookId}\n                    contextSelections={contextSelections.notes}\n                    onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}\n                  />\n                )}\n                {mobileActiveTab === 'chat' && (\n                  <ChatColumn\n                    notebookId={notebookId}\n                    contextSelections={contextSelections}\n                    sources={sources}\n                    sourcesLoading={sourcesLoading}\n                  />\n                )}\n              </div>\n            </>\n          )}\n\n          {/* Desktop: Collapsible columns layout */}\n          <div className={cn(\n            'hidden lg:flex h-full min-h-0 gap-6 transition-all duration-150',\n            'flex-row'\n          )}>\n            {/* Sources Column */}\n            <div className={cn(\n              'transition-all duration-150',\n              sourcesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'\n            )}>\n              <SourcesColumn\n                sources={sources}\n                isLoading={sourcesLoading}\n                notebookId={notebookId}\n                notebookName={notebook?.name}\n                onRefresh={refetchSources}\n                contextSelections={contextSelections.sources}\n                onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}\n                hasNextPage={hasNextPage}\n                isFetchingNextPage={isFetchingNextPage}\n                fetchNextPage={fetchNextPage}\n              />\n            </div>\n\n            {/* Notes Column */}\n            <div className={cn(\n              'transition-all duration-150',\n              notesCollapsed ? 'w-12 flex-shrink-0' : 'flex-none basis-1/3'\n            )}>\n              <NotesColumn\n                notes={notes}\n                isLoading={notesLoading}\n                notebookId={notebookId}\n                contextSelections={contextSelections.notes}\n                onContextModeChange={(noteId, mode) => handleContextModeChange(noteId, mode, 'note')}\n              />\n            </div>\n\n            {/* Chat Column - always expanded, takes remaining space */}\n            <div className=\"transition-all duration-150 flex-1 min-w-0 lg:pr-6 lg:-mr-6\">\n              <ChatColumn\n                notebookId={notebookId}\n                contextSelections={contextSelections}\n                sources={sources}\n                sourcesLoading={sourcesLoading}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/ChatColumn.test.tsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport { describe, it, expect, vi } from 'vitest'\nimport { ChatColumn } from './ChatColumn'\nimport { useNotes } from '@/lib/hooks/use-notes'\nimport { useNotebookChat } from '@/lib/hooks/useNotebookChat'\n\n// Mock the hooks\nvi.mock('@/lib/hooks/use-notes')\nvi.mock('@/lib/hooks/useNotebookChat')\nvi.mock('@/components/source/ChatPanel', () => ({\n  ChatPanel: () => <div data-testid=\"chat-panel\" />\n}))\n\n// Type-safe mock factory for useNotes hook\nfunction createNotesMock(overrides: { isLoading?: boolean } = {}) {\n  return {\n    data: [],\n    isLoading: overrides.isLoading ?? false,\n  } as unknown as ReturnType<typeof useNotes>\n}\n\n// Type-safe mock factory for useNotebookChat hook\nfunction createChatMock() {\n  return {\n    messages: [],\n    isSending: false,\n    tokenCount: 0,\n    charCount: 0,\n    sessions: [],\n    currentSessionId: null,\n  } as unknown as ReturnType<typeof useNotebookChat>\n}\n\ndescribe('ChatColumn', () => {\n  const baseProps = {\n    notebookId: 'test-notebook',\n    contextSelections: {\n      sources: {},\n      notes: {}\n    },\n    sources: [],\n  }\n\n  it('shows loading spinner when fetching data', () => {\n    vi.mocked(useNotes).mockReturnValue(createNotesMock({ isLoading: true }))\n    vi.mocked(useNotebookChat).mockReturnValue(createChatMock())\n\n    render(<ChatColumn {...baseProps} sourcesLoading={true} />)\n\n    // Should show loading spinner\n    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()\n  })\n\n  it('renders chat panel when data is loaded', () => {\n    vi.mocked(useNotes).mockReturnValue(createNotesMock({ isLoading: false }))\n    vi.mocked(useNotebookChat).mockReturnValue(createChatMock())\n\n    render(<ChatColumn {...baseProps} sourcesLoading={false} />)\n\n    // Should show chat panel\n    expect(screen.getByTestId('chat-panel')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/ChatColumn.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport { useNotebookChat } from '@/lib/hooks/useNotebookChat'\nimport { useNotes } from '@/lib/hooks/use-notes'\nimport { ChatPanel } from '@/components/source/ChatPanel'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { AlertCircle } from 'lucide-react'\nimport { ContextSelections } from '../[id]/page'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { SourceListResponse } from '@/lib/types/api'\n\ninterface ChatColumnProps {\n  notebookId: string\n  contextSelections: ContextSelections\n  sources: SourceListResponse[]\n  sourcesLoading: boolean\n}\n\nexport function ChatColumn({ notebookId, contextSelections, sources, sourcesLoading }: ChatColumnProps) {\n  const { t } = useTranslation()\n\n  // Fetch notes for this notebook\n  const { data: notes = [], isLoading: notesLoading } = useNotes(notebookId)\n\n  // Initialize notebook chat hook\n  const chat = useNotebookChat({\n    notebookId,\n    sources,\n    notes,\n    contextSelections\n  })\n\n  // Calculate context stats for indicator\n  const contextStats = useMemo(() => {\n    let sourcesInsights = 0\n    let sourcesFull = 0\n    let notesCount = 0\n\n    // Count sources by mode\n    sources.forEach(source => {\n      const mode = contextSelections.sources[source.id]\n      if (mode === 'insights') {\n        sourcesInsights++\n      } else if (mode === 'full') {\n        sourcesFull++\n      }\n    })\n\n    // Count notes that are included (not 'off')\n    notes.forEach(note => {\n      const mode = contextSelections.notes[note.id]\n      if (mode === 'full') {\n        notesCount++\n      }\n    })\n\n    return {\n      sourcesInsights,\n      sourcesFull,\n      notesCount,\n      tokenCount: chat.tokenCount,\n      charCount: chat.charCount\n    }\n  }, [sources, notes, contextSelections, chat.tokenCount, chat.charCount])\n\n  // Show loading state while sources/notes are being fetched\n  if (sourcesLoading || notesLoading) {\n    return (\n      <Card className=\"h-full flex flex-col\">\n        <CardContent className=\"flex-1 flex items-center justify-center\">\n          <LoadingSpinner size=\"lg\" />\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Show error state if data fetch failed (unlikely but good to handle)\n  if (!sources && !notes) {\n    return (\n      <Card className=\"h-full flex flex-col\">\n        <CardContent className=\"flex-1 flex items-center justify-center\">\n          <div className=\"text-center text-muted-foreground\">\n            <AlertCircle className=\"h-12 w-12 mx-auto mb-4 opacity-50\" />\n            <p className=\"text-sm\">{t.chat.unableToLoadChat}</p>\n            <p className=\"text-xs mt-2\">{t.common.refreshPage || 'Please try refreshing the page'}</p>\n          </div>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  return (\n    <ChatPanel\n      title={t.chat.chatWithNotebook}\n      contextType=\"notebook\"\n      messages={chat.messages}\n      isStreaming={chat.isSending}\n      contextIndicators={null}\n      onSendMessage={(message, modelOverride) => chat.sendMessage(message, modelOverride)}\n      modelOverride={chat.currentSession?.model_override ?? chat.pendingModelOverride ?? undefined}\n      onModelChange={(model) => chat.setModelOverride(model ?? null)}\n      sessions={chat.sessions}\n      currentSessionId={chat.currentSessionId}\n      onCreateSession={(title) => chat.createSession(title)}\n      onSelectSession={chat.switchSession}\n      onUpdateSession={(sessionId, title) => chat.updateSession(sessionId, { title })}\n      onDeleteSession={chat.deleteSession}\n      loadingSessions={chat.loadingSessions}\n      notebookContextStats={contextStats}\n      notebookId={notebookId}\n    />\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NoteEditorDialog.tsx",
    "content": "'use client'\n\nimport { Controller, useForm, useWatch } from 'react-hook-form'\nimport { useEffect, useState } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\nimport { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { useCreateNote, useUpdateNote, useNote } from '@/lib/hooks/use-notes'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { MarkdownEditor } from '@/components/ui/markdown-editor'\nimport { InlineEdit } from '@/components/common/InlineEdit'\nimport { cn } from \"@/lib/utils\";\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst createNoteSchema = z.object({\n  title: z.string().optional(),\n  content: z.string().min(1, 'Content is required'),\n})\n\ntype CreateNoteFormData = z.infer<typeof createNoteSchema>\n\ninterface NoteEditorDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  notebookId: string\n  note?: { id: string; title: string | null; content: string | null }\n}\n\nexport function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteEditorDialogProps) {\n  const { t } = useTranslation()\n  const createNote = useCreateNote()\n  const updateNote = useUpdateNote()\n  const queryClient = useQueryClient()\n  const isEditing = Boolean(note)\n\n  // Ensure note ID has 'note:' prefix for API calls\n  const noteIdWithPrefix = note?.id\n    ? (note.id.includes(':') ? note.id : `note:${note.id}`)\n    : ''\n\n  const { data: fetchedNote, isLoading: noteLoading } = useNote(noteIdWithPrefix, { enabled: open && !!note?.id })\n  const isSaving = isEditing ? updateNote.isPending : createNote.isPending\n  const {\n    handleSubmit,\n    control,\n    formState: { errors },\n    reset,\n    setValue,\n  } = useForm<CreateNoteFormData>({\n    resolver: zodResolver(createNoteSchema),\n    defaultValues: {\n      title: '',\n      content: '',\n    },\n  })\n  const watchTitle = useWatch({ control, name: 'title' })\n  const [isEditorFullscreen, setIsEditorFullscreen] = useState(false)\n\n  useEffect(() => {\n    if (!open) {\n      reset({ title: '', content: '' })\n      return\n    }\n\n    const source = fetchedNote ?? note\n    const title = source?.title ?? ''\n    const content = source?.content ?? ''\n\n    reset({ title, content })\n  }, [open, note, fetchedNote, reset])\n\n  useEffect(() => {\n    if (!open) return\n\n    const observer = new MutationObserver(() => {\n      setIsEditorFullscreen(!!document.querySelector('.w-md-editor-fullscreen'))\n    })\n    observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] })\n    return () => observer.disconnect()\n  }, [open])\n\n  const onSubmit = async (data: CreateNoteFormData) => {\n    if (note) {\n      await updateNote.mutateAsync({\n        id: noteIdWithPrefix,\n        data: {\n          title: data.title || undefined,\n          content: data.content,\n        },\n      })\n      // Only invalidate notebook-specific queries if we have a notebookId\n      if (notebookId) {\n        queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notes(notebookId) })\n      }\n    } else {\n      // Creating a note requires a notebookId\n      if (!notebookId) {\n        console.error('Cannot create note without notebook_id')\n        return\n      }\n      await createNote.mutateAsync({\n        title: data.title || undefined,\n        content: data.content,\n        note_type: 'human',\n        notebook_id: notebookId,\n      })\n    }\n    reset()\n    onOpenChange(false)\n  }\n\n  const handleClose = () => {\n    reset()\n    setIsEditorFullscreen(false)\n    onOpenChange(false)\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent className={cn(\n          \"sm:max-w-3xl w-full max-h-[90vh] overflow-hidden p-0\",\n          isEditorFullscreen && \"!max-w-screen !max-h-screen border-none w-screen h-screen\"\n      )}>\n        <DialogTitle className=\"sr-only\">\n          {isEditing ? t.sources.editNote : t.sources.createNote}\n        </DialogTitle>\n        <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col min-w-0\">\n          {isEditing && noteLoading ? (\n            <div className=\"flex-1 flex items-center justify-center py-10\">\n              <span className=\"text-sm text-muted-foreground\">{t.common.loading}</span>\n            </div>\n          ) : (\n            <>\n              <div className=\"border-b px-6 py-4\">\n                <InlineEdit\n                  id=\"note-title\"\n                  name=\"title\"\n                  value={watchTitle ?? ''}\n                  onSave={(value) => setValue('title', value || '')}\n                  placeholder={t.sources.addTitle}\n                  emptyText={t.sources.untitledNote}\n                  className=\"text-xl font-semibold\"\n                  inputClassName=\"text-xl font-semibold\"\n                />\n              </div>\n\n              <div className={cn(\n                  \"flex-1 overflow-y-auto\",\n                  !isEditorFullscreen && \"px-6 py-4\")\n              }>\n                <Controller\n                  control={control}\n                  name=\"content\"\n                  render={({ field }) => (\n                    <MarkdownEditor\n                      key={note?.id ?? 'new'}\n                      textareaId=\"note-content\"\n                      value={field.value}\n                      onChange={field.onChange}\n                      height={420}\n                      placeholder={t.sources.writeNotePlaceholder}\n                      className={cn(\n                          \"w-full h-full min-h-[420px] max-h-[500px] overflow-hidden [&_.w-md-editor]:!static [&_.w-md-editor]:!w-full [&_.w-md-editor]:!h-full [&_.w-md-editor-content]:overflow-y-auto\",\n                          !isEditorFullscreen && \"rounded-md border\"\n                      )}\n                    />\n                  )}\n                />\n                {errors.content && (\n                  <p className=\"text-sm text-red-600 mt-1\">{errors.content.message}</p>\n                )}\n              </div>\n            </>\n          )}\n\n          <div className=\"border-t px-6 py-4 flex justify-end gap-2\">\n            <Button type=\"button\" variant=\"outline\" onClick={handleClose}>\n              {t.common.cancel}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isSaving || (isEditing && noteLoading)}\n            >\n              {isSaving\n                ? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`\n                : isEditing\n                  ? t.sources.saveNote\n                  : t.sources.createNoteBtn}\n            </Button>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NotebookCard.tsx",
    "content": "'use client'\n\nimport { useRouter } from 'next/navigation'\nimport { NotebookResponse } from '@/lib/types/api'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { MoreHorizontal, Archive, ArchiveRestore, Trash2, FileText, StickyNote } from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { useUpdateNotebook } from '@/lib/hooks/use-notebooks'\nimport { NotebookDeleteDialog } from './NotebookDeleteDialog'\nimport { useState } from 'react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getDateLocale } from '@/lib/utils/date-locale'\ninterface NotebookCardProps {\n  notebook: NotebookResponse\n}\n\nexport function NotebookCard({ notebook }: NotebookCardProps) {\n  const { t, language } = useTranslation()\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false)\n  const router = useRouter()\n  const updateNotebook = useUpdateNotebook()\n\n  const handleArchiveToggle = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    updateNotebook.mutate({\n      id: notebook.id,\n      data: { archived: !notebook.archived }\n    })\n  }\n\n  const handleCardClick = () => {\n    router.push(`/notebooks/${encodeURIComponent(notebook.id)}`)\n  }\n\n  return (\n    <>\n      <Card \n        className=\"group card-hover\"\n        onClick={handleCardClick}\n        style={{ cursor: 'pointer' }}\n      >\n          <CardHeader className=\"pb-3\">\n            <div className=\"flex items-start justify-between\">\n              <div className=\"flex-1 min-w-0\">\n                <CardTitle className=\"text-base truncate group-hover:text-primary transition-colors\">\n                  {notebook.name}\n                </CardTitle>\n                {notebook.archived && (\n                  <Badge variant=\"secondary\" className=\"mt-1\">\n                    {t.notebooks.archived}\n                  </Badge>\n                )}\n              </div>\n              \n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"opacity-0 group-hover:opacity-100 transition-opacity\"\n                    onClick={(e) => e.stopPropagation()}\n                  >\n                    <MoreHorizontal className=\"h-4 w-4\" />\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\" onClick={(e) => e.stopPropagation()}>\n                  <DropdownMenuItem onClick={handleArchiveToggle}>\n                    {notebook.archived ? (\n                      <>\n                        <ArchiveRestore className=\"h-4 w-4 mr-2\" />\n                        {t.notebooks.unarchive}\n                      </>\n                    ) : (\n                      <>\n                        <Archive className=\"h-4 w-4 mr-2\" />\n                        {t.notebooks.archive}\n                      </>\n                    )}\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      setShowDeleteDialog(true)\n                    }}\n                    className=\"text-red-600\"\n                  >\n                    <Trash2 className=\"h-4 w-4 mr-2\" />\n                    {t.common.delete}\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n          </CardHeader>\n          \n          <CardContent>\n            <CardDescription className=\"line-clamp-2 text-sm\">\n              {notebook.description || t.chat.noDescription}\n            </CardDescription>\n\n            <div className=\"mt-3 text-xs text-muted-foreground\">\n              {t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { \n                addSuffix: true,\n                locale: getDateLocale(language)\n              }))}\n            </div>\n\n            {/* Item counts footer */}\n            <div className=\"mt-3 flex items-center gap-1.5 border-t pt-3\">\n              <Badge variant=\"outline\" className=\"text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50\">\n                <FileText className=\"h-3 w-3\" />\n                <span>{notebook.source_count}</span>\n              </Badge>\n              <Badge variant=\"outline\" className=\"text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50\">\n                <StickyNote className=\"h-3 w-3\" />\n                <span>{notebook.note_count}</span>\n              </Badge>\n            </div>\n          </CardContent>\n      </Card>\n\n      <NotebookDeleteDialog\n        open={showDeleteDialog}\n        onOpenChange={setShowDeleteDialog}\n        notebookId={notebook.id}\n        notebookName={notebook.name}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NotebookDeleteDialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'\nimport { Label } from '@/components/ui/label'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { useNotebookDeletePreview, useDeleteNotebook } from '@/lib/hooks/use-notebooks'\nimport { useRouter } from 'next/navigation'\n\ninterface NotebookDeleteDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  notebookId: string\n  notebookName: string\n  redirectAfterDelete?: boolean\n}\n\nexport function NotebookDeleteDialog({\n  open,\n  onOpenChange,\n  notebookId,\n  notebookName,\n  redirectAfterDelete = false,\n}: NotebookDeleteDialogProps) {\n  const { t } = useTranslation()\n  const router = useRouter()\n  const [sourceAction, setSourceAction] = useState<'keep' | 'delete'>('keep')\n\n  // Reset state when dialog opens\n  useEffect(() => {\n    if (open) {\n      setSourceAction('keep')\n    }\n  }, [open, notebookId])\n\n  // Fetch delete preview when dialog is open\n  const { data: preview, isLoading: isLoadingPreview, error: previewError } = useNotebookDeletePreview(\n    notebookId,\n    open\n  )\n\n  const deleteNotebook = useDeleteNotebook()\n\n  const handleConfirm = async () => {\n    await deleteNotebook.mutateAsync({\n      id: notebookId,\n      deleteExclusiveSources: sourceAction === 'delete',\n    })\n    onOpenChange(false)\n    if (redirectAfterDelete) {\n      router.push('/notebooks')\n    }\n  }\n\n  const isDeleting = deleteNotebook.isPending\n\n  return (\n    <AlertDialog open={open} onOpenChange={onOpenChange}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{t.notebooks.deleteNotebook}</AlertDialogTitle>\n          <AlertDialogDescription>\n            {t.notebooks.deleteNotebookDesc.replace('{name}', notebookName)}\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n\n        <div className=\"py-4 space-y-3\">\n          {isLoadingPreview ? (\n            <div className=\"flex items-center gap-2 text-muted-foreground\">\n              <LoadingSpinner size=\"sm\" />\n              <span>{t.notebooks.deleteNotebookLoading}</span>\n            </div>\n          ) : previewError ? (\n            <div className=\"text-sm text-destructive\">\n              {t.common.error}: {previewError.message || 'Failed to load preview'}\n            </div>\n          ) : preview ? (\n            <>\n              {/* Notes section */}\n              <div className=\"text-sm\">\n                {preview.note_count > 0 ? (\n                  <p className=\"text-destructive font-medium\">\n                    {t.notebooks.deleteNotebookNotes.replace(\n                      '{count}',\n                      String(preview.note_count)\n                    )}\n                  </p>\n                ) : (\n                  <p className=\"text-muted-foreground\">{t.notebooks.deleteNotebookNoNotes}</p>\n                )}\n              </div>\n\n              {/* Shared sources - always above the line */}\n              {preview.shared_source_count > 0 && (\n                <div className=\"text-sm\">\n                  <p className=\"text-muted-foreground\">\n                    {t.notebooks.deleteNotebookSharedSources.replace(\n                      '{count}',\n                      String(preview.shared_source_count)\n                    )}\n                  </p>\n                </div>\n              )}\n\n              {/* No sources message */}\n              {preview.exclusive_source_count === 0 && preview.shared_source_count === 0 && (\n                <div className=\"text-sm\">\n                  <p className=\"text-muted-foreground\">{t.notebooks.deleteNotebookNoSources}</p>\n                </div>\n              )}\n\n              {/* Exclusive sources section - below the line with radio buttons */}\n              {preview.exclusive_source_count > 0 && (\n                <div className=\"pt-3 border-t space-y-3\">\n                  <p className=\"text-sm text-destructive font-medium\">\n                    {t.notebooks.deleteNotebookExclusiveSources.replace(\n                      '{count}',\n                      String(preview.exclusive_source_count)\n                    )}\n                  </p>\n                  <RadioGroup\n                    value={sourceAction}\n                    onValueChange={(value) => setSourceAction(value as 'keep' | 'delete')}\n                    disabled={isDeleting}\n                  >\n                    <div className=\"flex items-center space-x-3\">\n                      <RadioGroupItem value=\"delete\" id=\"delete-sources\" />\n                      <Label htmlFor=\"delete-sources\" className=\"text-sm cursor-pointer\">\n                        {t.notebooks.deleteExclusiveSourcesLabel}\n                      </Label>\n                    </div>\n                    <div className=\"flex items-center space-x-3\">\n                      <RadioGroupItem value=\"keep\" id=\"keep-sources\" />\n                      <Label htmlFor=\"keep-sources\" className=\"text-sm cursor-pointer\">\n                        {t.notebooks.keepExclusiveSourcesLabel}\n                      </Label>\n                    </div>\n                  </RadioGroup>\n                </div>\n              )}\n            </>\n          ) : null}\n        </div>\n\n        <AlertDialogFooter>\n          <AlertDialogCancel disabled={isDeleting}>{t.common.cancel}</AlertDialogCancel>\n          <AlertDialogAction\n            onClick={handleConfirm}\n            disabled={isDeleting || isLoadingPreview}\n            className=\"bg-red-600 hover:bg-red-700\"\n          >\n            {isDeleting ? (\n              <>\n                <LoadingSpinner size=\"sm\" className=\"mr-2\" />\n                {t.common.deleting}\n              </>\n            ) : (\n              t.common.delete\n            )}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NotebookHeader.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { NotebookResponse } from '@/lib/types/api'\nimport { Button } from '@/components/ui/button'\nimport { Badge } from '@/components/ui/badge'\nimport { Archive, ArchiveRestore, Trash2 } from 'lucide-react'\nimport { useUpdateNotebook } from '@/lib/hooks/use-notebooks'\nimport { NotebookDeleteDialog } from './NotebookDeleteDialog'\nimport { formatDistanceToNow } from 'date-fns'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { InlineEdit } from '@/components/common/InlineEdit'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface NotebookHeaderProps {\n  notebook: NotebookResponse\n}\n\nexport function NotebookHeader({ notebook }: NotebookHeaderProps) {\n  const { t, language } = useTranslation()\n  const dfLocale = getDateLocale(language)\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false)\n  \n  const updateNotebook = useUpdateNotebook()\n\n  const handleUpdateName = async (name: string) => {\n    if (!name || name === notebook.name) return\n    \n    await updateNotebook.mutateAsync({\n      id: notebook.id,\n      data: { name }\n    })\n  }\n\n  const handleUpdateDescription = async (description: string) => {\n    if (description === notebook.description) return\n    \n    await updateNotebook.mutateAsync({\n      id: notebook.id,\n      data: { description: description || undefined }\n    })\n  }\n\n  const handleArchiveToggle = () => {\n    updateNotebook.mutate({\n      id: notebook.id,\n      data: { archived: !notebook.archived }\n    })\n  }\n\n  return (\n    <>\n      <div className=\"border-b pb-6\">\n        <div className=\"space-y-2\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-3 flex-1\">\n              <InlineEdit\n                id=\"notebook-name\"\n                name=\"notebook-name\"\n                value={notebook.name}\n                onSave={handleUpdateName}\n                className=\"text-2xl font-bold\"\n                inputClassName=\"text-2xl font-bold\"\n                placeholder={t.notebooks.namePlaceholder}\n              />\n              {notebook.archived && (\n                <Badge variant=\"secondary\">{t.notebooks.archived}</Badge>\n              )}\n            </div>\n            <div className=\"flex gap-2\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={handleArchiveToggle}\n              >\n                {notebook.archived ? (\n                  <>\n                    <ArchiveRestore className=\"h-4 w-4 mr-2\" />\n                    {t.notebooks.unarchive}\n                  </>\n                ) : (\n                  <>\n                    <Archive className=\"h-4 w-4 mr-2\" />\n                    {t.notebooks.archive}\n                  </>\n                )}\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => setShowDeleteDialog(true)}\n                className=\"text-red-600 hover:text-red-700\"\n              >\n                <Trash2 className=\"h-4 w-4 mr-2\" />\n                {t.common.delete}\n              </Button>\n            </div>\n          </div>\n          \n          <InlineEdit\n            id=\"notebook-description\"\n            name=\"notebook-description\"\n            value={notebook.description || ''}\n            onSave={handleUpdateDescription}\n            className=\"text-muted-foreground\"\n            inputClassName=\"text-muted-foreground\"\n            placeholder={t.notebooks.addDescription}\n            multiline\n            emptyText={t.notebooks.addDescription}\n          />\n          \n          <div className=\"text-sm text-muted-foreground\">\n            {t.common.created.replace('{time}', formatDistanceToNow(new Date(notebook.created), { addSuffix: true, locale: dfLocale }))} • \n            {t.common.updated.replace('{time}', formatDistanceToNow(new Date(notebook.updated), { addSuffix: true, locale: dfLocale }))}\n          </div>\n        </div>\n      </div>\n\n      <NotebookDeleteDialog\n        open={showDeleteDialog}\n        onOpenChange={setShowDeleteDialog}\n        notebookId={notebook.id}\n        notebookName={notebook.name}\n        redirectAfterDelete\n      />\n    </>\n  )\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NotebookList.tsx",
    "content": "'use client'\n\nimport { NotebookResponse } from '@/lib/types/api'\nimport { NotebookCard } from './NotebookCard'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { EmptyState } from '@/components/common/EmptyState'\nimport { Book, ChevronDown, ChevronRight, Plus } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { useState } from 'react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface NotebookListProps {\n  notebooks?: NotebookResponse[]\n  isLoading: boolean\n  title: string\n  collapsible?: boolean\n  emptyTitle?: string\n  emptyDescription?: string\n  onAction?: () => void\n  actionLabel?: string\n}\n\nexport function NotebookList({ \n  notebooks, \n  isLoading, \n  title, \n  collapsible = false,\n  emptyTitle,\n  emptyDescription,\n  onAction,\n  actionLabel,\n}: NotebookListProps) {\n  const { t } = useTranslation()\n  const [isExpanded, setIsExpanded] = useState(!collapsible)\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <LoadingSpinner size=\"lg\" />\n      </div>\n    )\n  }\n\n  if (!notebooks || notebooks.length === 0) {\n    return (\n      <EmptyState\n        icon={Book}\n        title={emptyTitle ?? t.common.noResults}\n        description={emptyDescription ?? t.chat.startByCreating}\n        action={onAction && actionLabel ? (\n          <Button onClick={onAction} variant=\"outline\" className=\"mt-4\">\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {actionLabel}\n          </Button>\n        ) : undefined}\n      />\n    )\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center gap-2\">\n        {collapsible && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => setIsExpanded(!isExpanded)}\n          >\n            {isExpanded ? (\n              <ChevronDown className=\"h-4 w-4\" />\n            ) : (\n              <ChevronRight className=\"h-4 w-4\" />\n            )}\n          </Button>\n        )}\n        <h2 className=\"text-lg font-semibold\">{title}</h2>\n        <span className=\"text-sm text-muted-foreground\">({notebooks.length})</span>\n      </div>\n\n      {isExpanded && (\n        <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n          {notebooks.map((notebook) => (\n            <NotebookCard key={notebook.id} notebook={notebook} />\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/NotesColumn.tsx",
    "content": "'use client'\n\nimport { useState, useMemo } from 'react'\nimport { NoteResponse } from '@/lib/types/api'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Plus, StickyNote, Bot, User, MoreVertical, Trash2 } from 'lucide-react'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { EmptyState } from '@/components/common/EmptyState'\nimport { Badge } from '@/components/ui/badge'\nimport { NoteEditorDialog } from './NoteEditorDialog'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { formatDistanceToNow } from 'date-fns'\nimport { ContextToggle } from '@/components/common/ContextToggle'\nimport { ContextMode } from '../[id]/page'\nimport { useDeleteNote } from '@/lib/hooks/use-notes'\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\nimport { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'\nimport { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface NotesColumnProps {\n  notes?: NoteResponse[]\n  isLoading: boolean\n  notebookId: string\n  contextSelections?: Record<string, ContextMode>\n  onContextModeChange?: (noteId: string, mode: ContextMode) => void\n}\n\nexport function NotesColumn({\n  notes,\n  isLoading,\n  notebookId,\n  contextSelections,\n  onContextModeChange\n}: NotesColumnProps) {\n  const { t, language } = useTranslation()\n  const [showAddDialog, setShowAddDialog] = useState(false)\n  const [editingNote, setEditingNote] = useState<NoteResponse | null>(null)\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n  const [noteToDelete, setNoteToDelete] = useState<string | null>(null)\n\n  const deleteNote = useDeleteNote()\n\n  // Collapsible column state\n  const { notesCollapsed, toggleNotes } = useNotebookColumnsStore()\n  const collapseButton = useMemo(\n    () => createCollapseButton(toggleNotes, t.common.notes),\n    [toggleNotes, t.common.notes]\n  )\n\n  const handleDeleteClick = (noteId: string) => {\n    setNoteToDelete(noteId)\n    setDeleteDialogOpen(true)\n  }\n\n  const handleDeleteConfirm = async () => {\n    if (!noteToDelete) return\n\n    try {\n      await deleteNote.mutateAsync(noteToDelete)\n      setDeleteDialogOpen(false)\n      setNoteToDelete(null)\n    } catch (error) {\n      console.error('Failed to delete note:', error)\n    }\n  }\n\n  return (\n    <>\n      <CollapsibleColumn\n        isCollapsed={notesCollapsed}\n        onToggle={toggleNotes}\n        collapsedIcon={StickyNote}\n        collapsedLabel={t.common.notes}\n      >\n        <Card className=\"h-full flex flex-col flex-1 overflow-hidden\">\n          <CardHeader className=\"pb-3 flex-shrink-0\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle className=\"text-lg\">{t.common.notes}</CardTitle>\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  onClick={() => {\n                    setEditingNote(null)\n                    setShowAddDialog(true)\n                  }}\n                >\n                  <Plus className=\"h-4 w-4 mr-2\" />\n                  {t.common.writeNote}\n                </Button>\n                {collapseButton}\n              </div>\n            </div>\n          </CardHeader>\n\n          <CardContent className=\"flex-1 overflow-y-auto min-h-0\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-8\">\n                <LoadingSpinner />\n              </div>\n            ) : !notes || notes.length === 0 ? (\n              <EmptyState\n                icon={StickyNote}\n                title={t.notebooks.noNotesYet}\n                description={t.sources.createFirstNote}\n              />\n            ) : (\n              <div className=\"space-y-3\">\n                {notes.map((note) => (\n                  <div\n                    key={note.id}\n                    className=\"p-3 border rounded-lg card-hover group relative cursor-pointer\"\n                    onClick={() => setEditingNote(note)}\n                  >\n                    <div className=\"flex items-start justify-between mb-2\">\n                      <div className=\"flex items-center gap-2\">\n                        {note.note_type === 'ai' ? (\n                          <Bot className=\"h-4 w-4 text-primary\" />\n                        ) : (\n                          <User className=\"h-4 w-4 text-muted-foreground\" />\n                        )}\n                        <Badge variant=\"secondary\" className=\"text-xs\">\n                          {note.note_type === 'ai' ? t.common.aiGenerated : t.common.human}\n                        </Badge>\n                      </div>\n\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"text-xs text-muted-foreground\">\n                          {formatDistanceToNow(new Date(note.updated), { \n                            addSuffix: true,\n                            locale: getDateLocale(language)\n                          })}\n                        </span>\n\n                        {/* Context toggle - only show if handler provided */}\n                        {onContextModeChange && contextSelections?.[note.id] && (\n                          <div onClick={(event) => event.stopPropagation()}>\n                            <ContextToggle\n                              mode={contextSelections[note.id]}\n                              hasInsights={false}\n                              onChange={(mode) => onContextModeChange(note.id, mode)}\n                            />\n                          </div>\n                        )}\n\n                        {/* Ellipsis menu for delete action */}\n                        <DropdownMenu>\n                          <DropdownMenuTrigger asChild>\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              className=\"h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity\"\n                              onClick={(e) => e.stopPropagation()}\n                            >\n                              <MoreVertical className=\"h-4 w-4\" />\n                            </Button>\n                          </DropdownMenuTrigger>\n                          <DropdownMenuContent align=\"end\" className=\"w-48\">\n                            <DropdownMenuItem\n                              onClick={(e) => {\n                                e.stopPropagation()\n                                handleDeleteClick(note.id)\n                              }}\n                              className=\"text-red-600 focus:text-red-600\"\n                            >\n                              <Trash2 className=\"h-4 w-4 mr-2\" />\n                              {t.notebooks.deleteNote}\n                            </DropdownMenuItem>\n                          </DropdownMenuContent>\n                        </DropdownMenu>\n                      </div>\n                    </div>\n\n                    {note.title && (\n                      <h4 className=\"text-sm font-medium mb-2 break-all\">{note.title}</h4>\n                    )}\n\n                    {note.content && (\n                      <p className=\"text-sm text-muted-foreground line-clamp-3 break-all\">\n                        {note.content}\n                      </p>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      </CollapsibleColumn>\n\n      <NoteEditorDialog\n        open={showAddDialog || Boolean(editingNote)}\n        onOpenChange={(open) => {\n          if (!open) {\n            setShowAddDialog(false)\n            setEditingNote(null)\n          } else {\n            setShowAddDialog(true)\n          }\n        }}\n        notebookId={notebookId}\n        note={editingNote ?? undefined}\n      />\n\n      <ConfirmDialog\n        open={deleteDialogOpen}\n        onOpenChange={setDeleteDialogOpen}\n        title={t.notebooks.deleteNote}\n        description={t.notebooks.deleteNoteConfirm}\n        confirmText={t.common.delete}\n        onConfirm={handleDeleteConfirm}\n        isLoading={deleteNote.isPending}\n        confirmVariant=\"destructive\"\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/components/SourcesColumn.tsx",
    "content": "'use client'\n\nimport { useState, useMemo, useRef, useCallback, useEffect } from 'react'\nimport { SourceListResponse } from '@/lib/types/api'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Plus, FileText, Link2, ChevronDown, Loader2 } from 'lucide-react'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { EmptyState } from '@/components/common/EmptyState'\nimport { AddSourceDialog } from '@/components/sources/AddSourceDialog'\nimport { AddExistingSourceDialog } from '@/components/sources/AddExistingSourceDialog'\nimport { SourceCard } from '@/components/sources/SourceCard'\nimport { useDeleteSource, useRetrySource, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { ContextMode } from '../[id]/page'\nimport { CollapsibleColumn, createCollapseButton } from '@/components/notebooks/CollapsibleColumn'\nimport { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface SourcesColumnProps {\n  sources?: SourceListResponse[]\n  isLoading: boolean\n  notebookId: string\n  notebookName?: string\n  onRefresh?: () => void\n  contextSelections?: Record<string, ContextMode>\n  onContextModeChange?: (sourceId: string, mode: ContextMode) => void\n  // Pagination props\n  hasNextPage?: boolean\n  isFetchingNextPage?: boolean\n  fetchNextPage?: () => void\n}\n\nexport function SourcesColumn({\n  sources,\n  isLoading,\n  notebookId,\n  onRefresh,\n  contextSelections,\n  onContextModeChange,\n  hasNextPage,\n  isFetchingNextPage,\n  fetchNextPage,\n}: SourcesColumnProps) {\n  const { t } = useTranslation()\n  const [dropdownOpen, setDropdownOpen] = useState(false)\n  const [addDialogOpen, setAddDialogOpen] = useState(false)\n  const [addExistingDialogOpen, setAddExistingDialogOpen] = useState(false)\n  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)\n  const [sourceToDelete, setSourceToDelete] = useState<string | null>(null)\n  const [removeDialogOpen, setRemoveDialogOpen] = useState(false)\n  const [sourceToRemove, setSourceToRemove] = useState<string | null>(null)\n\n  const { openModal } = useModalManager()\n  const deleteSource = useDeleteSource()\n  const retrySource = useRetrySource()\n  const removeFromNotebook = useRemoveSourceFromNotebook()\n\n  // Collapsible column state\n  const { sourcesCollapsed, toggleSources } = useNotebookColumnsStore()\n  const collapseButton = useMemo(\n    () => createCollapseButton(toggleSources, t.navigation.sources),\n    [toggleSources, t.navigation.sources]\n  )\n\n  // Scroll container ref for infinite scroll\n  const scrollContainerRef = useRef<HTMLDivElement>(null)\n\n  // Handle scroll for infinite loading\n  const handleScroll = useCallback(() => {\n    const container = scrollContainerRef.current\n    if (!container || !hasNextPage || isFetchingNextPage || !fetchNextPage) return\n\n    const { scrollTop, scrollHeight, clientHeight } = container\n    // Load more when user scrolls within 200px of the bottom\n    if (scrollHeight - scrollTop - clientHeight < 200) {\n      fetchNextPage()\n    }\n  }, [hasNextPage, isFetchingNextPage, fetchNextPage])\n\n  // Attach scroll listener\n  useEffect(() => {\n    const container = scrollContainerRef.current\n    if (!container) return\n\n    container.addEventListener('scroll', handleScroll)\n    return () => container.removeEventListener('scroll', handleScroll)\n  }, [handleScroll])\n  \n  const handleDeleteClick = (sourceId: string) => {\n    setSourceToDelete(sourceId)\n    setDeleteDialogOpen(true)\n  }\n\n  const handleDeleteConfirm = async () => {\n    if (!sourceToDelete) return\n\n    try {\n      await deleteSource.mutateAsync(sourceToDelete)\n      setDeleteDialogOpen(false)\n      setSourceToDelete(null)\n      onRefresh?.()\n    } catch (error) {\n      console.error('Failed to delete source:', error)\n    }\n  }\n\n  const handleRemoveFromNotebook = (sourceId: string) => {\n    setSourceToRemove(sourceId)\n    setRemoveDialogOpen(true)\n  }\n\n  const handleRemoveConfirm = async () => {\n    if (!sourceToRemove) return\n\n    try {\n      await removeFromNotebook.mutateAsync({\n        notebookId,\n        sourceId: sourceToRemove\n      })\n      setRemoveDialogOpen(false)\n      setSourceToRemove(null)\n    } catch (error) {\n      console.error('Failed to remove source from notebook:', error)\n      // Error toast is handled by the hook\n    }\n  }\n\n  const handleRetry = async (sourceId: string) => {\n    try {\n      await retrySource.mutateAsync(sourceId)\n    } catch (error) {\n      console.error('Failed to retry source:', error)\n    }\n  }\n\n  const handleSourceClick = (sourceId: string) => {\n    openModal('source', sourceId)\n  }\n\n  return (\n    <>\n      <CollapsibleColumn\n        isCollapsed={sourcesCollapsed}\n        onToggle={toggleSources}\n        collapsedIcon={FileText}\n        collapsedLabel={t.navigation.sources}\n      >\n        <Card className=\"h-full flex flex-col flex-1 overflow-hidden\">\n          <CardHeader className=\"pb-3 flex-shrink-0\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <CardTitle className=\"text-lg\">{t.navigation.sources}</CardTitle>\n              <div className=\"flex items-center gap-2\">\n                <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>\n                  <DropdownMenuTrigger asChild>\n                    <Button size=\"sm\">\n                      <Plus className=\"h-4 w-4 mr-2\" />\n                      {t.sources.addSource}\n                      <ChevronDown className=\"h-4 w-4 ml-2\" />\n                    </Button>\n                  </DropdownMenuTrigger>\n                  <DropdownMenuContent align=\"end\">\n                    <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddDialogOpen(true); }}>\n                      <Plus className=\"h-4 w-4 mr-2\" />\n                      {t.sources.addSource}\n                    </DropdownMenuItem>\n                    <DropdownMenuItem onClick={() => { setDropdownOpen(false); setAddExistingDialogOpen(true); }}>\n                      <Link2 className=\"h-4 w-4 mr-2\" />\n                      {t.sources.addExistingTitle}\n                    </DropdownMenuItem>\n                  </DropdownMenuContent>\n                </DropdownMenu>\n                {collapseButton}\n              </div>\n            </div>\n          </CardHeader>\n\n          <CardContent ref={scrollContainerRef} className=\"flex-1 overflow-y-auto min-h-0\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-8\">\n                <LoadingSpinner />\n              </div>\n            ) : !sources || sources.length === 0 ? (\n              <EmptyState\n                icon={FileText}\n                title={t.sources.noSourcesYet}\n                description={t.sources.createFirstSource}\n              />\n            ) : (\n              <div className=\"space-y-3\">\n                {sources.map((source) => (\n                  <SourceCard\n                    key={source.id}\n                    source={source}\n                    onClick={handleSourceClick}\n                    onDelete={handleDeleteClick}\n                    onRetry={handleRetry}\n                    onRemoveFromNotebook={handleRemoveFromNotebook}\n                    onRefresh={onRefresh}\n                    showRemoveFromNotebook={true}\n                    contextMode={contextSelections?.[source.id]}\n                    onContextModeChange={onContextModeChange\n                      ? (mode) => onContextModeChange(source.id, mode)\n                      : undefined\n                    }\n                  />\n                ))}\n                {/* Loading indicator for infinite scroll */}\n                {isFetchingNextPage && (\n                  <div className=\"flex items-center justify-center py-4\">\n                    <Loader2 className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n                  </div>\n                )}\n              </div>\n            )}\n          </CardContent>\n        </Card>\n      </CollapsibleColumn>\n\n      <AddSourceDialog\n        open={addDialogOpen}\n        onOpenChange={setAddDialogOpen}\n        defaultNotebookId={notebookId}\n      />\n\n      <AddExistingSourceDialog\n        open={addExistingDialogOpen}\n        onOpenChange={setAddExistingDialogOpen}\n        notebookId={notebookId}\n        onSuccess={onRefresh}\n      />\n\n      <ConfirmDialog\n        open={deleteDialogOpen}\n        onOpenChange={setDeleteDialogOpen}\n        title={t.sources.delete}\n        description={t.sources.deleteConfirm}\n        confirmText={t.common.delete}\n        onConfirm={handleDeleteConfirm}\n        isLoading={deleteSource.isPending}\n        confirmVariant=\"destructive\"\n      />\n\n      <ConfirmDialog\n        open={removeDialogOpen}\n        onOpenChange={setRemoveDialogOpen}\n        title={t.sources.removeFromNotebook}\n        description={t.sources.removeConfirm}\n        confirmText={t.common.remove}\n        onConfirm={handleRemoveConfirm}\n        isLoading={removeFromNotebook.isPending}\n        confirmVariant=\"default\"\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/notebooks/page.tsx",
    "content": "'use client'\n\nimport { useMemo, useState } from 'react'\n\nimport { AppShell } from '@/components/layout/AppShell'\nimport { NotebookList } from './components/NotebookList'\nimport { Button } from '@/components/ui/button'\nimport { Plus, RefreshCw } from 'lucide-react'\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'\nimport { Input } from '@/components/ui/input'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport default function NotebooksPage() {\n  const { t } = useTranslation()\n  const [createDialogOpen, setCreateDialogOpen] = useState(false)\n  const [searchTerm, setSearchTerm] = useState('')\n  const { data: notebooks, isLoading, refetch } = useNotebooks(false)\n  const { data: archivedNotebooks } = useNotebooks(true)\n\n  const normalizedQuery = searchTerm.trim().toLowerCase()\n\n  const filteredActive = useMemo(() => {\n    if (!notebooks) {\n      return undefined\n    }\n    if (!normalizedQuery) {\n      return notebooks\n    }\n    return notebooks.filter((notebook) =>\n      notebook.name.toLowerCase().includes(normalizedQuery)\n    )\n  }, [notebooks, normalizedQuery])\n\n  const filteredArchived = useMemo(() => {\n    if (!archivedNotebooks) {\n      return undefined\n    }\n    if (!normalizedQuery) {\n      return archivedNotebooks\n    }\n    return archivedNotebooks.filter((notebook) =>\n      notebook.name.toLowerCase().includes(normalizedQuery)\n    )\n  }, [archivedNotebooks, normalizedQuery])\n\n  const hasArchived = (archivedNotebooks?.length ?? 0) > 0\n  const isSearching = normalizedQuery.length > 0\n\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6 space-y-6\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-4\">\n            <h1 className=\"text-2xl font-bold\">{t.notebooks.title}</h1>\n            <Button variant=\"outline\" size=\"sm\" onClick={() => refetch()}>\n              <RefreshCw className=\"h-4 w-4\" />\n            </Button>\n          </div>\n          <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4\">\n            <Input\n              id=\"notebook-search\"\n              name=\"notebook-search\"\n              value={searchTerm}\n              onChange={(event) => setSearchTerm(event.target.value)}\n              placeholder={t.notebooks.searchPlaceholder}\n              autoComplete=\"off\"\n              aria-label={t.common.accessibility?.searchNotebooks || \"Search notebooks\"}\n              className=\"w-full sm:w-64\"\n            />\n            <Button onClick={() => setCreateDialogOpen(true)}>\n              <Plus className=\"h-4 w-4 mr-2\" />\n              {t.notebooks.newNotebook}\n            </Button>\n          </div>\n        </div>\n        \n        <div className=\"space-y-8\">\n          <NotebookList \n            notebooks={filteredActive} \n            isLoading={isLoading}\n            title={t.notebooks.activeNotebooks}\n            emptyTitle={isSearching ? t.common.noMatches : undefined}\n            emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}\n            onAction={!isSearching ? () => setCreateDialogOpen(true) : undefined}\n            actionLabel={!isSearching ? t.notebooks.newNotebook : undefined}\n          />\n          \n          {hasArchived && (\n            <NotebookList \n              notebooks={filteredArchived} \n              isLoading={false}\n              title={t.notebooks.archivedNotebooks}\n              collapsible\n              emptyTitle={isSearching ? t.common.noMatches : undefined}\n              emptyDescription={isSearching ? t.common.tryDifferentSearch : undefined}\n            />\n          )}\n        </div>\n        </div>\n      </div>\n\n      <CreateNotebookDialog\n        open={createDialogOpen}\n        onOpenChange={setCreateDialogOpen}\n      />\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/page.tsx",
    "content": "import { redirect } from 'next/navigation'\n\nexport default function DashboardPage() {\n  redirect('/notebooks')\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/podcasts/page.tsx",
    "content": "'use client'\n\nimport { useMemo, useState } from 'react'\nimport { AlertTriangle } from 'lucide-react'\n\nimport { AppShell } from '@/components/layout/AppShell'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'\nimport { EpisodesTab } from '@/components/podcasts/EpisodesTab'\nimport { TemplatesTab } from '@/components/podcasts/TemplatesTab'\nimport { Mic, LayoutTemplate } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'\nimport { needsModelSetup } from '@/lib/types/podcasts'\n\nexport default function PodcastsPage() {\n  const { t } = useTranslation()\n  const [activeTab, setActiveTab] = useState<'episodes' | 'templates'>('episodes')\n\n  const { episodeProfiles } = useEpisodeProfiles()\n  const { speakerProfiles } = useSpeakerProfiles(episodeProfiles)\n\n  const hasUnconfiguredProfiles = useMemo(() => {\n    return episodeProfiles.some(needsModelSetup) || speakerProfiles.some(needsModelSetup)\n  }, [episodeProfiles, speakerProfiles])\n\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"px-6 py-6 space-y-6\">\n          <header className=\"space-y-1\">\n            <h1 className=\"text-2xl font-semibold tracking-tight\">{t.podcasts.listTitle}</h1>\n            <p className=\"text-muted-foreground\">\n              {t.podcasts.listDesc}\n            </p>\n          </header>\n\n          {hasUnconfiguredProfiles ? (\n            <Alert className=\"bg-amber-50 text-amber-900 border-amber-200\">\n              <AlertTriangle className=\"h-4 w-4\" />\n              <AlertTitle>{t.podcasts.setupRequired}</AlertTitle>\n              <AlertDescription>\n                {t.podcasts.setupRequiredDesc}\n              </AlertDescription>\n            </Alert>\n          ) : null}\n\n          <Tabs\n            value={activeTab}\n            onValueChange={(value) => setActiveTab(value as 'episodes' | 'templates')}\n            className=\"space-y-6\"\n          >\n            <div className=\"space-y-2\">\n              <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">{t.podcasts.chooseAView}</p>\n              <TabsList aria-label={t.common.accessibility.podcastViews} className=\"w-full max-w-md\">\n                <TabsTrigger value=\"episodes\">\n                  <Mic className=\"h-4 w-4\" />\n                  {t.podcasts.episodesTab}\n                </TabsTrigger>\n                <TabsTrigger value=\"templates\">\n                  <LayoutTemplate className=\"h-4 w-4\" />\n                  {t.podcasts.templatesTab}\n                </TabsTrigger>\n              </TabsList>\n            </div>\n\n            <TabsContent value=\"episodes\">\n              <EpisodesTab />\n            </TabsContent>\n\n            <TabsContent value=\"templates\">\n              <TemplatesTab />\n            </TabsContent>\n          </Tabs>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/search/page.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { useSearchParams } from 'next/navigation'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { AppShell } from '@/components/layout/AppShell'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Button } from '@/components/ui/button'\nimport { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'\nimport { Label } from '@/components/ui/label'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { Search, ChevronDown, AlertCircle, Settings, Save, MessageCircleQuestion } from 'lucide-react'\nimport { useSearch } from '@/lib/hooks/use-search'\nimport { useAsk } from '@/lib/hooks/use-ask'\nimport { useModelDefaults, useModels } from '@/lib/hooks/use-models'\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { StreamingResponse } from '@/components/search/StreamingResponse'\nimport { AdvancedModelsDialog } from '@/components/search/AdvancedModelsDialog'\nimport { SaveToNotebooksDialog } from '@/components/search/SaveToNotebooksDialog'\n\nexport default function SearchPage() {\n  const { t } = useTranslation()\n  // URL params\n  const searchParams = useSearchParams()\n  const urlQuery = searchParams?.get('q') || ''\n  const rawMode = searchParams?.get('mode')\n  const urlMode = rawMode === 'search' ? 'search' : 'ask'\n\n  // Tab state (controlled)\n  const [activeTab, setActiveTab] = useState<'ask' | 'search'>(\n    urlMode === 'search' ? 'search' : 'ask'\n  )\n\n  // Search state\n  const [searchQuery, setSearchQuery] = useState(urlMode === 'search' ? urlQuery : '')\n  const [searchType, setSearchType] = useState<'text' | 'vector'>('text')\n  const [searchSources, setSearchSources] = useState(true)\n  const [searchNotes, setSearchNotes] = useState(true)\n\n  // Ask state\n  const [askQuestion, setAskQuestion] = useState(urlMode === 'ask' ? urlQuery : '')\n\n  // Advanced models dialog\n  const [showAdvancedModels, setShowAdvancedModels] = useState(false)\n  const [customModels, setCustomModels] = useState<{\n    strategy: string\n    answer: string\n    finalAnswer: string\n  } | null>(null)\n\n  // Save to notebooks dialog\n  const [showSaveDialog, setShowSaveDialog] = useState(false)\n\n  // Hooks\n  const searchMutation = useSearch()\n  const ask = useAsk()\n  const { data: modelDefaults, isLoading: modelsLoading } = useModelDefaults()\n  const { data: availableModels } = useModels()\n  const { openModal } = useModalManager()\n\n  const modelNameById = useMemo(() => {\n    if (!availableModels) {\n      return new Map<string, string>()\n    }\n    return new Map(availableModels.map((model) => [model.id, model.name]))\n  }, [availableModels])\n\n  const resolveModelName = (id?: string | null) => {\n    if (!id) return t.searchPage.notSet\n    return modelNameById.get(id) ?? id\n  }\n\n  const hasEmbeddingModel = !!modelDefaults?.default_embedding_model\n\n  // Track if we've already auto-triggered from URL params\n  const hasAutoTriggeredRef = useRef(false)\n  const lastUrlParamsRef = useRef({ q: '', mode: '' })\n\n  const handleSearch = useCallback(() => {\n    if (!searchQuery.trim()) return\n\n    searchMutation.mutate({\n      query: searchQuery,\n      type: searchType,\n      limit: 100,\n      search_sources: searchSources,\n      search_notes: searchNotes,\n      minimum_score: 0.2\n    })\n  }, [searchQuery, searchType, searchSources, searchNotes, searchMutation])\n\n  const handleKeyPress = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      handleSearch()\n    }\n  }\n\n  const handleAsk = useCallback(() => {\n    if (!askQuestion.trim() || !modelDefaults?.default_chat_model) return\n\n    const models = customModels || {\n      strategy: modelDefaults.default_chat_model,\n      answer: modelDefaults.default_chat_model,\n      finalAnswer: modelDefaults.default_chat_model\n    }\n\n    ask.sendAsk(askQuestion, models)\n  }, [askQuestion, modelDefaults, customModels, ask])\n\n  // Auto-trigger search/ask when arriving with URL params\n  useEffect(() => {\n    // Skip if already triggered or no query\n    if (hasAutoTriggeredRef.current || !urlQuery) return\n\n    // Wait for models to load before triggering ask\n    if (urlMode === 'ask' && modelsLoading) return\n\n    if (urlMode === 'search') {\n      handleSearch()\n      hasAutoTriggeredRef.current = true\n    } else if (urlMode === 'ask' && modelDefaults?.default_chat_model) {\n      handleAsk()\n      hasAutoTriggeredRef.current = true\n    }\n  }, [urlQuery, urlMode, modelsLoading, modelDefaults, handleSearch, handleAsk])\n\n  // Handle URL param changes while on page (e.g., from command palette again)\n  useEffect(() => {\n    const currentQ = searchParams?.get('q') || ''\n    const rawCurrentMode = searchParams?.get('mode')\n    const currentMode = rawCurrentMode === 'search' ? 'search' : 'ask'\n\n    // Check if URL params have changed\n    if (currentQ !== lastUrlParamsRef.current.q || currentMode !== lastUrlParamsRef.current.mode) {\n      lastUrlParamsRef.current = { q: currentQ, mode: currentMode }\n\n      if (currentQ) {\n        // Update state based on mode\n        if (currentMode === 'search') {\n          setSearchQuery(currentQ)\n          setActiveTab('search')\n          // Reset trigger flag so we auto-trigger with new params\n          hasAutoTriggeredRef.current = false\n        } else {\n          setAskQuestion(currentQ)\n          setActiveTab('ask')\n          hasAutoTriggeredRef.current = false\n        }\n      }\n    }\n  }, [searchParams])\n\n  return (\n    <AppShell>\n      <div className=\"p-4 md:p-6\">\n        <h1 className=\"text-xl md:text-2xl font-bold mb-4 md:mb-6\">{t.searchPage.askAndSearch}</h1>\n\n        <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'ask' | 'search')} className=\"w-full space-y-6\">\n          <div className=\"space-y-2\">\n            <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">{t.searchPage.chooseAMode}</p>\n            <TabsList aria-label={t.common.accessibility.searchKB} className=\"w-full max-w-xl\">\n              <TabsTrigger value=\"ask\">\n                <MessageCircleQuestion className=\"h-4 w-4\" />\n                {t.searchPage.askBeta}\n              </TabsTrigger>\n              <TabsTrigger value=\"search\">\n                <Search className=\"h-4 w-4\" />\n                {t.searchPage.search}\n              </TabsTrigger>\n            </TabsList>\n          </div>\n\n          <TabsContent value=\"ask\" className=\"mt-6\">\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"text-lg\">{t.searchPage.askYourKb}</CardTitle>\n                <p className=\"text-sm text-muted-foreground\">\n                  {t.searchPage.askYourKbDesc}\n                </p>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                {/* Question Input */}\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"ask-question\">{t.searchPage.question}</Label>\n                  <Textarea\n                    id=\"ask-question\"\n                    name=\"ask-question\"\n                    placeholder={t.searchPage.enterQuestionPlaceholder}\n                    value={askQuestion}\n                    onChange={(e) => setAskQuestion(e.target.value)}\n                    onKeyDown={(e) => {\n                      // Submit on Cmd/Ctrl+Enter\n                      if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !ask.isStreaming && askQuestion.trim()) {\n                        e.preventDefault()\n                        handleAsk()\n                      }\n                    }}\n                    disabled={ask.isStreaming}\n                    rows={3}\n                    aria-label={t.common.accessibility.enterQuestion}\n                  />\n                  <p className=\"text-xs text-muted-foreground\">{t.searchPage.pressToSubmit}</p>\n                </div>\n\n                {/* Models Display */}\n                {!hasEmbeddingModel ? (\n                  <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\">\n                    <AlertCircle className=\"h-4 w-4\" />\n                    <span>{t.searchPage.noEmbeddingModel}</span>\n                  </div>\n                ) : (\n                  <>\n                    <div className=\"space-y-2\">\n                      <div className=\"flex items-center justify-between\">\n                        <Label className=\"text-xs text-muted-foreground\">\n                          {customModels ? t.searchPage.usingCustomModels : t.searchPage.usingDefaultModels}\n                        </Label>\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() => setShowAdvancedModels(true)}\n                          disabled={ask.isStreaming}\n                          className=\"h-auto py-1 px-2\"\n                        >\n                          <Settings className=\"h-3 w-3 mr-1\" />\n                          {t.searchPage.advanced}\n                        </Button>\n                      </div>\n                      <div className=\"flex gap-2 text-xs flex-wrap\">\n                        <Badge variant=\"secondary\">\n                          {t.searchPage.strategy}: {resolveModelName(customModels?.strategy || modelDefaults?.default_chat_model)}\n                        </Badge>\n                        <Badge variant=\"secondary\">\n                          {t.searchPage.answer}: {resolveModelName(customModels?.answer || modelDefaults?.default_chat_model)}\n                        </Badge>\n                        <Badge variant=\"secondary\">\n                          {t.searchPage.final}: {resolveModelName(customModels?.finalAnswer || modelDefaults?.default_chat_model)}\n                        </Badge>\n                      </div>\n                    </div>\n\n                    <div className=\"flex flex-col sm:flex-row gap-2\">\n                      <Button\n                        onClick={handleAsk}\n                        disabled={ask.isStreaming || !askQuestion.trim()}\n                        className=\"w-full\"\n                      >\n                        {ask.isStreaming ? (\n                          <>\n                            <LoadingSpinner size=\"sm\" className=\"mr-2\" />\n                            {t.searchPage.processing}\n                          </>\n                        ) : (\n                          t.searchPage.ask\n                        )}\n                      </Button>\n\n                      {ask.finalAnswer && (\n                        <Button\n                          variant=\"outline\"\n                          onClick={() => setShowSaveDialog(true)}\n                          className=\"w-full\"\n                        >\n                          <Save className=\"h-4 w-4 mr-2\" />\n                          {t.searchPage.saveToNotebooks}\n                        </Button>\n                      )}\n                    </div>\n                  </>\n                )}\n\n                {/* Streaming Response */}\n                <StreamingResponse\n                  isStreaming={ask.isStreaming}\n                  strategy={ask.strategy}\n                  answers={ask.answers}\n                  finalAnswer={ask.finalAnswer}\n                />\n\n                {/* Advanced Models Dialog */}\n                <AdvancedModelsDialog\n                  open={showAdvancedModels}\n                  onOpenChange={setShowAdvancedModels}\n                  defaultModels={{\n                    strategy: customModels?.strategy || modelDefaults?.default_chat_model || '',\n                    answer: customModels?.answer || modelDefaults?.default_chat_model || '',\n                    finalAnswer: customModels?.finalAnswer || modelDefaults?.default_chat_model || ''\n                  }}\n                  onSave={setCustomModels}\n                />\n\n                {/* Save to Notebooks Dialog */}\n                {ask.finalAnswer && (\n                  <SaveToNotebooksDialog\n                    open={showSaveDialog}\n                    onOpenChange={setShowSaveDialog}\n                    question={askQuestion}\n                    answer={ask.finalAnswer}\n                  />\n                )}\n              </CardContent>\n            </Card>\n          </TabsContent>\n\n          <TabsContent value=\"search\" className=\"mt-6\">\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"text-lg\">{t.searchPage.search}</CardTitle>\n                <p className=\"text-sm text-muted-foreground\">\n                  {t.searchPage.searchDesc}\n                </p>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                {/* Search Input */}\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"search-query\" className=\"sr-only\">\n                    {t.searchPage.search}\n                  </Label>\n                  <div className=\"flex flex-col sm:flex-row gap-2\">\n                    <Input\n                      id=\"search-query\"\n                      name=\"search-query\"\n                      placeholder={t.searchPage.enterSearchPlaceholder}\n                      value={searchQuery}\n                      onChange={(e) => setSearchQuery(e.target.value)}\n                      onKeyPress={handleKeyPress}\n                      disabled={searchMutation.isPending}\n                      className=\"flex-1\"\n                      aria-label={t.common.accessibility.enterSearch}\n                      autoComplete=\"off\"\n                    />\n                    <Button\n                      onClick={handleSearch}\n                      disabled={searchMutation.isPending || !searchQuery.trim()}\n                      aria-label={t.common.accessibility.searchKBBtn}\n                      className=\"w-full sm:w-auto\"\n                    >\n                      {searchMutation.isPending ? (\n                        <LoadingSpinner size=\"sm\" />\n                      ) : (\n                        <Search className=\"h-4 w-4 mr-2\" />\n                      )}\n                      {t.searchPage.search}\n                    </Button>\n                  </div>\n                  <p className=\"text-xs text-muted-foreground\">{t.searchPage.pressToSearch}</p>\n                </div>\n\n                {/* Search Options */}\n                <div className=\"space-y-4\">\n                  {/* Search Type */}\n                  <div className=\"space-y-2\" role=\"group\" aria-labelledby=\"search-type-label\">\n                    <span id=\"search-type-label\" className=\"text-sm font-medium leading-none\">{t.searchPage.searchType}</span>\n                    {!hasEmbeddingModel && (\n                      <div className=\"flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500\">\n                        <AlertCircle className=\"h-4 w-4\" />\n                        <span>{t.searchPage.vectorSearchWarning}</span>\n                      </div>\n                    )}\n                    <RadioGroup\n                      name=\"search-type\"\n                      value={searchType}\n                      onValueChange={(value: 'text' | 'vector') => setSearchType(value)}\n                      disabled={modelsLoading || searchMutation.isPending}\n                    >\n                      <div className=\"flex items-center space-x-2\">\n                        <RadioGroupItem value=\"text\" id=\"text\" />\n                        <Label htmlFor=\"text\" className=\"font-normal cursor-pointer\">\n                          {t.searchPage.textSearch}\n                        </Label>\n                      </div>\n                      <div className=\"flex items-center space-x-2\">\n                        <RadioGroupItem\n                          value=\"vector\"\n                          id=\"vector\"\n                          disabled={!hasEmbeddingModel || searchMutation.isPending}\n                        />\n                        <Label\n                          htmlFor=\"vector\"\n                          className={`font-normal ${!hasEmbeddingModel ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'}`}\n                        >\n                          {t.searchPage.vectorSearch}\n                        </Label>\n                      </div>\n                    </RadioGroup>\n                  </div>\n\n                  {/* Search Locations */}\n                  <div className=\"space-y-2\" role=\"group\" aria-labelledby=\"search-in-label\">\n                    <span id=\"search-in-label\" className=\"text-sm font-medium leading-none\">{t.searchPage.searchIn}</span>\n                    <div className=\"space-y-2\">\n                      <div className=\"flex items-center space-x-2\">\n                        <Checkbox\n                          id=\"sources\"\n                          name=\"sources\"\n                          checked={searchSources}\n                          onCheckedChange={(checked) => setSearchSources(checked as boolean)}\n                          disabled={searchMutation.isPending}\n                        />\n                        <Label htmlFor=\"sources\" className=\"font-normal cursor-pointer\">\n                          {t.searchPage.searchSources}\n                        </Label>\n                      </div>\n                      <div className=\"flex items-center space-x-2\">\n                        <Checkbox\n                          id=\"notes\"\n                          name=\"notes\"\n                          checked={searchNotes}\n                          onCheckedChange={(checked) => setSearchNotes(checked as boolean)}\n                          disabled={searchMutation.isPending}\n                        />\n                        <Label htmlFor=\"notes\" className=\"font-normal cursor-pointer\">\n                          {t.searchPage.searchNotes}\n                        </Label>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n\n                {/* Search Results */}\n                {searchMutation.data && (\n                  <div className=\"mt-6 space-y-3\">\n                    <div className=\"flex items-center justify-between\">\n                      <h3 className=\"text-sm font-medium\">\n                        {t.searchPage.resultsFound.replace('{count}', searchMutation.data.total_count.toString())}\n                      </h3>\n                      <Badge variant=\"outline\">{searchMutation.data.search_type === 'text' ? t.searchPage.textSearch : t.searchPage.vectorSearch}</Badge>\n                    </div>\n\n                    {searchMutation.data.results.length === 0 ? (\n                      <Card>\n                        <CardContent className=\"pt-6 text-center text-muted-foreground\">\n                          {t.searchPage.noResultsFor.replace('{query}', searchQuery)}\n                        </CardContent>\n                      </Card>\n                    ) : (\n                      <div className=\"space-y-2 max-h-[60vh] overflow-y-auto pr-2\">\n                        {searchMutation.data.results.map((result, index) => {\n                          // Parse type from parent_id (format: \"source:id\" or \"note:id\" or \"source_insight:id\")\n                          // Handle null parent_id gracefully (orphaned records)\n                          if (!result.parent_id) {\n                            console.warn('Search result with null parent_id:', result)\n                            return null\n                          }\n                          const [type, id] = result.parent_id.split(':')\n                          const modalType = type === 'source_insight' ? 'insight' : type as 'source' | 'note' | 'insight'\n\n                          return (\n                          <Card key={index}>\n                            <CardContent className=\"pt-4\">\n                              <div className=\"flex items-start justify-between gap-4\">\n                                <div className=\"flex-1\">\n                                  <button\n                                    onClick={() => openModal(modalType, id)}\n                                    className=\"text-primary hover:underline font-medium\"\n                                  >\n                                    {result.title}\n                                  </button>\n                                  <Badge variant=\"secondary\" className=\"ml-2\">\n                                    {result.final_score.toFixed(2)}\n                                  </Badge>\n                                </div>\n                              </div>\n\n                              {result.matches && result.matches.length > 0 && (\n                                <Collapsible className=\"mt-3\">\n                                  <CollapsibleTrigger className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground\">\n                                    <ChevronDown className=\"h-4 w-4\" />\n                                    {t.searchPage.matches.replace('{count}', result.matches.length.toString())}\n                                  </CollapsibleTrigger>\n                                  <CollapsibleContent className=\"mt-2 space-y-1\">\n                                    {result.matches.map((match, i) => (\n                                      <div key={i} className=\"text-sm pl-6 py-1 border-l-2 border-muted\">\n                                        {match}\n                                      </div>\n                                    ))}\n                                  </CollapsibleContent>\n                                </Collapsible>\n                              )}\n                            </CardContent>\n                          </Card>\n                        )})}\n                      </div>\n                    )}\n                  </div>\n                )}\n              </CardContent>\n            </Card>\n          </TabsContent>\n        </Tabs>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/settings/api-keys/page.tsx",
    "content": "'use client'\n\nimport { useMemo, useState, useEffect, useId } from 'react'\nimport { useForm } from 'react-hook-form'\nimport { AppShell } from '@/components/layout/AppShell'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Label } from '@/components/ui/label'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport {\n  RefreshCw,\n  Key,\n  ShieldAlert,\n  Plus,\n  Edit,\n  Trash2,\n  Plug,\n  Loader2,\n  Check,\n  X,\n  AlertCircle,\n  Wand2,\n  MessageSquare,\n  Code,\n  Mic,\n  Volume2,\n  Bot,\n} from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { useModels, useDeleteModel, useModelDefaults, useUpdateModelDefaults, useAutoAssignDefaults, useTestModel } from '@/lib/hooks/use-models'\nimport {\n  useCredentials,\n  useCredential,\n  useCredentialStatus,\n  useEnvStatus,\n  useCreateCredential,\n  useUpdateCredential,\n  useDeleteCredential,\n  useTestCredential,\n  useDiscoverModels,\n  useRegisterModels,\n  useMigrateFromEnv,\n} from '@/lib/hooks/use-credentials'\nimport { Credential, CreateCredentialRequest, UpdateCredentialRequest, DiscoveredModel } from '@/lib/api/credentials'\nimport { Model, ModelDefaults } from '@/lib/types/models'\nimport { MigrationBanner, ModelTestResultDialog } from '@/components/settings'\nimport { EmbeddingModelChangeDialog } from '@/components/settings/EmbeddingModelChangeDialog'\n\ntype ModelType = 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'\n\n// Provider display names\nconst PROVIDER_DISPLAY_NAMES: Record<string, string> = {\n  openai: 'OpenAI',\n  anthropic: 'Anthropic',\n  google: 'Google AI',\n  groq: 'Groq',\n  mistral: 'Mistral AI',\n  deepseek: 'DeepSeek',\n  xai: 'xAI (Grok)',\n  openrouter: 'OpenRouter',\n  voyage: 'Voyage AI',\n  elevenlabs: 'ElevenLabs',\n  ollama: 'Ollama',\n  azure: 'Azure OpenAI',\n  vertex: 'Google Vertex AI',\n  openai_compatible: 'OpenAI Compatible',\n}\n\n// All providers in display order\nconst ALL_PROVIDERS = [\n  'openai', 'anthropic', 'google', 'groq', 'mistral', 'deepseek',\n  'xai', 'openrouter', 'voyage', 'elevenlabs', 'ollama',\n  'azure', 'vertex', 'openai_compatible',\n]\n\n// Default modalities per provider\nconst PROVIDER_MODALITIES: Record<string, ModelType[]> = {\n  openai: ['language', 'embedding', 'text_to_speech', 'speech_to_text'],\n  anthropic: ['language'],\n  google: ['language', 'embedding', 'text_to_speech', 'speech_to_text'],\n  groq: ['language', 'speech_to_text'],\n  mistral: ['language', 'embedding'],\n  deepseek: ['language'],\n  xai: ['language'],\n  openrouter: ['language', 'embedding'],\n  voyage: ['embedding'],\n  elevenlabs: ['text_to_speech', 'speech_to_text'],\n  ollama: ['language', 'embedding'],\n  azure: ['language', 'embedding', 'text_to_speech', 'speech_to_text'],\n  vertex: ['language', 'embedding', 'text_to_speech'],\n  openai_compatible: ['language', 'embedding', 'text_to_speech', 'speech_to_text'],\n}\n\n// Documentation links\nconst PROVIDER_DOCS: Record<string, string> = {\n  openai: 'https://platform.openai.com/api-keys',\n  anthropic: 'https://console.anthropic.com/settings/keys',\n  google: 'https://aistudio.google.com/app/apikey',\n  groq: 'https://console.groq.com/keys',\n  mistral: 'https://console.mistral.ai/api-keys/',\n  deepseek: 'https://platform.deepseek.com/api_keys',\n  xai: 'https://console.x.ai/',\n  openrouter: 'https://openrouter.ai/keys',\n  voyage: 'https://dash.voyageai.com/api-keys',\n  elevenlabs: 'https://elevenlabs.io/app/settings/api-keys',\n  azure: 'https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI',\n  vertex: 'https://cloud.google.com/vertex-ai/docs/start/cloud-environment',\n  openai_compatible: 'https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/openai-compatible.md',\n}\n\nconst TYPE_ICONS: Record<ModelType, React.ReactNode> = {\n  language: <MessageSquare className=\"h-3 w-3\" />,\n  embedding: <Code className=\"h-3 w-3\" />,\n  text_to_speech: <Volume2 className=\"h-3 w-3\" />,\n  speech_to_text: <Mic className=\"h-3 w-3\" />,\n}\n\nconst TYPE_COLORS: Record<ModelType, string> = {\n  language: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',\n  embedding: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',\n  text_to_speech: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',\n  speech_to_text: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300',\n}\n\nconst TYPE_COLOR_INACTIVE = 'bg-muted text-muted-foreground opacity-50'\n\nconst TYPE_LABELS: Record<ModelType, string> = {\n  language: 'Language',\n  embedding: 'Embedding',\n  text_to_speech: 'TTS',\n  speech_to_text: 'STT',\n}\n\n// =============================================================================\n// Credential Form Dialog\n// =============================================================================\n\nfunction CredentialFormDialog({\n  open,\n  onOpenChange,\n  provider,\n  credential,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  provider: string\n  credential?: Credential | null\n}) {\n  const { t } = useTranslation()\n  const createCredential = useCreateCredential()\n  const updateCredential = useUpdateCredential()\n  const isEditing = !!credential\n  const isSubmitting = createCredential.isPending || updateCredential.isPending\n\n  const isVertex = provider === 'vertex'\n  const isOllama = provider === 'ollama'\n  const isOpenAICompatible = provider === 'openai_compatible'\n  const requiresApiKey = !isVertex && !isOllama && !isOpenAICompatible\n\n  const [name, setName] = useState('')\n  const [apiKey, setApiKey] = useState('')\n  const [baseUrl, setBaseUrl] = useState('')\n  const [showApiKey, setShowApiKey] = useState(false)\n  const [project, setProject] = useState('')\n  const [location, setLocation] = useState('')\n  const [credentialsPath, setCredentialsPath] = useState('')\n  // Modalities\n  const [modalities, setModalities] = useState<string[]>([])\n\n  useEffect(() => {\n    if (credential) {\n      setName(credential.name || '')\n      setBaseUrl(credential.base_url || '')\n      setApiKey('')\n      setProject(credential.project || '')\n      setLocation(credential.location || '')\n      setCredentialsPath(credential.credentials_path || '')\n      setModalities(credential.modalities || [])\n    } else {\n      setName('')\n      setBaseUrl('')\n      setApiKey('')\n      setProject('')\n      setLocation('')\n      setCredentialsPath('')\n      setModalities(PROVIDER_MODALITIES[provider] || ['language'])\n    }\n  }, [credential, provider])\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault()\n\n    const onSuccess = () => {\n      onOpenChange(false)\n    }\n\n    if (isEditing && credential) {\n      const data: UpdateCredentialRequest = {}\n      if (name !== credential.name) data.name = name\n      if (apiKey.trim()) data.api_key = apiKey.trim()\n      if (baseUrl !== (credential.base_url || '')) data.base_url = baseUrl || undefined\n      if (JSON.stringify(modalities) !== JSON.stringify(credential.modalities)) data.modalities = modalities\n      if (isVertex) {\n        if (project !== (credential.project || '')) data.project = project.trim() || undefined\n        if (location !== (credential.location || '')) data.location = location.trim() || undefined\n        if (credentialsPath !== (credential.credentials_path || '')) data.credentials_path = credentialsPath.trim() || undefined\n      }\n      updateCredential.mutate({ credentialId: credential.id, data }, { onSuccess })\n    } else {\n      const data: CreateCredentialRequest = {\n        name: name || `${PROVIDER_DISPLAY_NAMES[provider] || provider} Config`,\n        provider,\n        modalities,\n        api_key: apiKey.trim() || undefined,\n        base_url: baseUrl || undefined,\n      }\n      if (isVertex) {\n        data.project = project.trim() || undefined\n        data.location = location.trim() || undefined\n        data.credentials_path = credentialsPath.trim() || undefined\n      }\n      createCredential.mutate(data, { onSuccess })\n    }\n  }\n\n  const isValid = isEditing\n    ? true\n    : isVertex\n      ? name.trim() !== '' && project.trim() !== '' && location.trim() !== ''\n      : name.trim() !== '' && (!requiresApiKey || apiKey.trim() !== '')\n\n  const docsUrl = PROVIDER_DOCS[provider]\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            {isEditing\n              ? t.apiKeys.editConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)\n              : t.apiKeys.addConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)}\n          </DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit} className=\"space-y-4\">\n          {/* Name */}\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"cred-name\">{t.apiKeys.configName}</Label>\n            <input\n              id=\"cred-name\"\n              className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder={`${PROVIDER_DISPLAY_NAMES[provider] || provider} Production`}\n              disabled={isSubmitting}\n            />\n            <p className=\"text-xs text-muted-foreground\">{t.apiKeys.configNameHint}</p>\n          </div>\n\n          {/* Vertex fields */}\n          {isVertex ? (\n            <>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"vertex-project\">{t.apiKeys.vertexProject}</Label>\n                <input\n                  id=\"vertex-project\"\n                  className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm\"\n                  value={project}\n                  onChange={(e) => setProject(e.target.value)}\n                  placeholder=\"my-gcp-project\"\n                  disabled={isSubmitting}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"vertex-location\">{t.apiKeys.vertexLocation}</Label>\n                <input\n                  id=\"vertex-location\"\n                  className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm\"\n                  value={location}\n                  onChange={(e) => setLocation(e.target.value)}\n                  placeholder=\"us-central1\"\n                  disabled={isSubmitting}\n                />\n              </div>\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"vertex-creds\">\n                  {t.apiKeys.vertexCredentials}\n                  <span className=\"text-muted-foreground font-normal ml-1\">({t.common.optional})</span>\n                </Label>\n                <input\n                  id=\"vertex-creds\"\n                  className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm\"\n                  value={credentialsPath}\n                  onChange={(e) => setCredentialsPath(e.target.value)}\n                  placeholder=\"/path/to/service-account.json\"\n                  disabled={isSubmitting}\n                />\n              </div>\n            </>\n          ) : (\n            /* API Key */\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"api-key\">\n                {t.models.apiKey}\n                {!requiresApiKey && <span className=\"text-muted-foreground font-normal ml-1\">({t.common.optional})</span>}\n              </Label>\n              <div className=\"relative\">\n                <input\n                  id=\"api-key\"\n                  type={showApiKey ? 'text' : 'password'}\n                  className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm pr-10\"\n                  value={apiKey}\n                  onChange={(e) => setApiKey(e.target.value)}\n                  placeholder={isEditing ? '••••••••••••' : 'sk-...'}\n                  disabled={isSubmitting}\n                  autoComplete=\"off\"\n                />\n                <button\n                  type=\"button\"\n                  onClick={() => setShowApiKey(!showApiKey)}\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs\"\n                  tabIndex={-1}\n                >\n                  {showApiKey ? 'Hide' : 'Show'}\n                </button>\n              </div>\n              {isEditing && <p className=\"text-xs text-muted-foreground\">{t.apiKeys.apiKeyEditHint}</p>}\n              {docsUrl && (\n                <a href={docsUrl} target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-xs text-primary hover:underline\">\n                  {t.apiKeys.getApiKey} &rarr;\n                </a>\n              )}\n            </div>\n          )}\n\n          {/* Base URL (non-Vertex) */}\n          {!isVertex && (\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"base-url\" className=\"text-muted-foreground\">{t.apiKeys.baseUrl}</Label>\n              <input\n                id=\"base-url\"\n                type=\"url\"\n                className=\"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm\"\n                value={baseUrl}\n                onChange={(e) => setBaseUrl(e.target.value)}\n                placeholder={isOllama ? 'http://localhost:11434' : 'https://api.example.com/v1'}\n                disabled={isSubmitting}\n              />\n              <p className=\"text-xs text-muted-foreground\">{t.apiKeys.baseUrlOverrideHint}</p>\n            </div>\n          )}\n\n          {/* Actions */}\n          <div className=\"flex justify-end gap-2 pt-4 border-t\">\n            <Button type=\"button\" variant=\"outline\" onClick={() => onOpenChange(false)} disabled={isSubmitting}>\n              {t.common.cancel}\n            </Button>\n            <Button type=\"submit\" disabled={!isValid || isSubmitting}>\n              {isSubmitting && <Loader2 className=\"h-4 w-4 animate-spin mr-2\" />}\n              {isEditing ? t.common.save : t.apiKeys.addConfig}\n            </Button>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n// =============================================================================\n// Model Discovery Dialog\n// =============================================================================\n\nfunction DiscoverModelsDialog({\n  open,\n  onOpenChange,\n  credential,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  credential: Credential\n}) {\n  const { t } = useTranslation()\n  const discoverModels = useDiscoverModels()\n  const registerModels = useRegisterModels()\n  const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([])\n  const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set())\n  const [hasDiscovered, setHasDiscovered] = useState(false)\n  const [discoveryError, setDiscoveryError] = useState<string | null>(null)\n  const [searchQuery, setSearchQuery] = useState('')\n  const [customModelSelected, setCustomModelSelected] = useState(false)\n  // Model type selector - default to credential's first modality\n  const [selectedType, setSelectedType] = useState<ModelType>(\n    (credential.modalities[0] as ModelType) || 'language'\n  )\n\n  useEffect(() => {\n    if (open && !hasDiscovered) {\n      setDiscoveryError(null)\n      discoverModels.mutate(credential.id, {\n        onSuccess: (result) => {\n          const seen = new Set<string>()\n          const unique = result.discovered.filter(m => {\n            if (seen.has(m.name)) return false\n            seen.add(m.name)\n            return true\n          })\n          setDiscoveredModels(unique)\n          setSelectedModels(new Set())\n          setHasDiscovered(true)\n        },\n        onError: (error: unknown) => {\n          setHasDiscovered(true)\n          const msg = error instanceof Error ? error.message : String(error)\n          setDiscoveryError(msg)\n        },\n      })\n    }\n    if (!open) {\n      setHasDiscovered(false)\n      setDiscoveredModels([])\n      setSelectedModels(new Set())\n      setDiscoveryError(null)\n      setSearchQuery('')\n      setCustomModelSelected(false)\n      setSelectedType((credential.modalities[0] as ModelType) || 'language')\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open/close\n  }, [open])\n\n  // Reset custom selection when search changes\n  useEffect(() => {\n    setCustomModelSelected(false)\n  }, [searchQuery])\n\n  // Filter discovered models by search query\n  const filteredModels = useMemo(() => {\n    if (!searchQuery.trim()) return discoveredModels\n    const q = searchQuery.toLowerCase()\n    return discoveredModels.filter(m => m.name.toLowerCase().includes(q))\n  }, [discoveredModels, searchQuery])\n\n  // Show custom model option when search doesn't exactly match any discovered model\n  const showCustomOption = useMemo(() => {\n    if (!searchQuery.trim()) return false\n    const q = searchQuery.trim().toLowerCase()\n    return !discoveredModels.some(m => m.name.toLowerCase() === q)\n  }, [discoveredModels, searchQuery])\n\n  const handleRegister = () => {\n    const selected = discoveredModels\n      .filter(m => selectedModels.has(m.name))\n      .map(m => ({\n        name: m.name,\n        provider: m.provider,\n        model_type: selectedType,\n      }))\n    if (customModelSelected && showCustomOption) {\n      selected.push({\n        name: searchQuery.trim(),\n        provider: credential.provider,\n        model_type: selectedType,\n      })\n    }\n    registerModels.mutate(\n      { credentialId: credential.id, models: selected },\n      { onSuccess: () => onOpenChange(false) }\n    )\n  }\n\n  const totalSelected = selectedModels.size + (customModelSelected && showCustomOption ? 1 : 0)\n\n  const toggleModel = (name: string) => {\n    setSelectedModels(prev => {\n      const next = new Set(prev)\n      if (next.has(name)) next.delete(name)\n      else next.add(name)\n      return next\n    })\n  }\n\n  const toggleAll = () => {\n    const filteredNames = filteredModels.map(m => m.name)\n    const allFilteredSelected = filteredNames.every(n => selectedModels.has(n))\n    if (allFilteredSelected) {\n      setSelectedModels(prev => {\n        const next = new Set(prev)\n        filteredNames.forEach(n => next.delete(n))\n        return next\n      })\n    } else {\n      setSelectedModels(prev => {\n        const next = new Set(prev)\n        filteredNames.forEach(n => next.add(n))\n        return next\n      })\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-lg max-h-[80vh] overflow-y-auto\">\n        <DialogHeader>\n          <DialogTitle>\n            {t.models.discoverModels} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider}\n          </DialogTitle>\n          <DialogDescription>\n            {credential.name}\n          </DialogDescription>\n        </DialogHeader>\n\n        {discoverModels.isPending ? (\n          <div className=\"flex items-center justify-center py-12\">\n            <LoadingSpinner size=\"lg\" />\n          </div>\n        ) : discoveryError ? (\n          <Alert variant=\"destructive\">\n            <AlertCircle className=\"h-4 w-4\" />\n            <AlertDescription>{discoveryError}</AlertDescription>\n          </Alert>\n        ) : (\n          <div className=\"space-y-4\">\n            {/* Model type selector */}\n            <div className=\"space-y-2\">\n              <Label>{t.models.modelType}</Label>\n              <Select value={selectedType} onValueChange={(v) => setSelectedType(v as ModelType)}>\n                <SelectTrigger>\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {(PROVIDER_MODALITIES[credential.provider] || credential.modalities as ModelType[]).map(type => (\n                    <SelectItem key={type} value={type}>\n                      <div className=\"flex items-center gap-2\">\n                        {TYPE_ICONS[type]}\n                        {TYPE_LABELS[type]}\n                      </div>\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              <p className=\"text-xs text-muted-foreground\">{t.models.modelTypeHint}</p>\n            </div>\n\n            {/* Search input */}\n            <input\n              type=\"text\"\n              className=\"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground\"\n              placeholder={t.models.searchOrAddModel}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n            />\n\n            {/* Select all / count (only when there are discovered models to select) */}\n            {filteredModels.length > 0 && (\n              <div className=\"flex items-center justify-between\">\n                <Button variant=\"outline\" size=\"sm\" onClick={toggleAll}>\n                  {filteredModels.every(m => selectedModels.has(m.name)) ? t.common.remove : t.common.addSelected}\n                  {' '}({selectedModels.size}/{filteredModels.length})\n                </Button>\n              </div>\n            )}\n\n            {/* Model list */}\n            <div className=\"space-y-1 max-h-60 overflow-y-auto\">\n              {filteredModels.map((model) => (\n                <label\n                  key={model.name}\n                  className=\"flex items-center gap-2 p-1.5 rounded hover:bg-muted cursor-pointer text-sm\"\n                >\n                  <input\n                    type=\"checkbox\"\n                    checked={selectedModels.has(model.name)}\n                    onChange={() => toggleModel(model.name)}\n                    className=\"rounded\"\n                  />\n                  <span className=\"truncate\">{model.name}</span>\n                  {model.description && model.description !== model.name && (\n                    <span className=\"text-xs text-muted-foreground truncate\">({model.description})</span>\n                  )}\n                </label>\n              ))}\n\n              {/* Custom model option */}\n              {showCustomOption && (\n                <label className={`flex items-center gap-2 p-1.5 rounded hover:bg-muted cursor-pointer text-sm${filteredModels.length > 0 ? ' border-t mt-1 pt-2' : ''}`}>\n                  <input\n                    type=\"checkbox\"\n                    checked={customModelSelected}\n                    onChange={() => setCustomModelSelected(prev => !prev)}\n                    className=\"rounded\"\n                  />\n                  <Plus className=\"h-3.5 w-3.5 text-muted-foreground shrink-0\" />\n                  <span className=\"truncate\">\n                    {t.models.addCustomModel.replace('{name}', searchQuery.trim())}\n                  </span>\n                </label>\n              )}\n\n              {filteredModels.length === 0 && !showCustomOption && (\n                <p className=\"text-center py-4 text-muted-foreground text-sm\">{t.models.noModelsFound}</p>\n              )}\n            </div>\n          </div>\n        )}\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t.common.cancel}\n          </Button>\n          <Button\n            onClick={handleRegister}\n            disabled={totalSelected === 0 || registerModels.isPending}\n          >\n            {registerModels.isPending && <Loader2 className=\"h-4 w-4 animate-spin mr-2\" />}\n            {t.common.add} ({totalSelected})\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n// =============================================================================\n// Delete Credential Dialog\n// =============================================================================\n\nfunction DeleteCredentialDialog({\n  open,\n  onOpenChange,\n  credential,\n  allCredentials,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  credential: Credential\n  allCredentials: Credential[]\n}) {\n  const { t } = useTranslation()\n  const deleteCredential = useDeleteCredential()\n  const [migrateToId, setMigrateToId] = useState<string>('')\n\n  const otherCredentials = allCredentials.filter(\n    c => c.id !== credential.id && c.provider === credential.provider\n  )\n\n  const handleDeleteWithModels = () => {\n    deleteCredential.mutate(\n      { credentialId: credential.id, options: { delete_models: true } },\n      { onSuccess: () => onOpenChange(false) }\n    )\n  }\n\n  const handleMigrate = () => {\n    if (!migrateToId) return\n    deleteCredential.mutate(\n      { credentialId: credential.id, options: { migrate_to: migrateToId } },\n      { onSuccess: () => onOpenChange(false) }\n    )\n  }\n\n  const handleDeleteOnly = () => {\n    deleteCredential.mutate(\n      { credentialId: credential.id },\n      { onSuccess: () => onOpenChange(false) }\n    )\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>{t.apiKeys.deleteConfig}</DialogTitle>\n          <DialogDescription>\n            {t.apiKeys.deleteConfigConfirm.replace('{name}', credential.name)}\n          </DialogDescription>\n        </DialogHeader>\n\n        {credential.model_count > 0 && (\n          <Alert>\n            <AlertCircle className=\"h-4 w-4\" />\n            <AlertDescription>\n              This credential has {credential.model_count} linked model(s).\n              {otherCredentials.length > 0 && (\n                <div className=\"mt-2\">\n                  <Label>Migrate models to:</Label>\n                  <Select value={migrateToId} onValueChange={setMigrateToId}>\n                    <SelectTrigger className=\"mt-1\">\n                      <SelectValue placeholder=\"Select credential\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                      {otherCredentials.map(c => (\n                        <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                </div>\n              )}\n            </AlertDescription>\n          </Alert>\n        )}\n\n        <DialogFooter className=\"flex-col sm:flex-row gap-2\">\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t.common.cancel}\n          </Button>\n          {credential.model_count > 0 && migrateToId && (\n            <Button onClick={handleMigrate} disabled={deleteCredential.isPending}>\n              {deleteCredential.isPending && <Loader2 className=\"h-4 w-4 animate-spin mr-2\" />}\n              Migrate & Delete\n            </Button>\n          )}\n          <Button\n            variant=\"destructive\"\n            onClick={credential.model_count > 0 ? handleDeleteWithModels : handleDeleteOnly}\n            disabled={deleteCredential.isPending}\n          >\n            {deleteCredential.isPending && <Loader2 className=\"h-4 w-4 animate-spin mr-2\" />}\n            {credential.model_count > 0 ? 'Delete with Models' : t.common.delete}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n// =============================================================================\n// Credential Card (shows credential + its models)\n// =============================================================================\n\nfunction CredentialItem({\n  credential,\n  models,\n  defaults,\n  allCredentials,\n}: {\n  credential: Credential\n  models: Model[]\n  defaults: ModelDefaults | null\n  allCredentials: Credential[]\n}) {\n  const { t } = useTranslation()\n  const { testCredential, isPending: isTestPending, testResults } = useTestCredential()\n  const { testModel, isPending: isModelTestPending, testingModelId, testResult: modelTestResult, testedModelName, clearResult: clearModelTestResult } = useTestModel()\n  const deleteModel = useDeleteModel()\n  const [editOpen, setEditOpen] = useState(false)\n  const [deleteOpen, setDeleteOpen] = useState(false)\n  const [discoverOpen, setDiscoverOpen] = useState(false)\n  // Full credential data needed for edit form\n  const { data: fullCredential } = useCredential(editOpen ? credential.id : '')\n\n  const linkedModels = models.filter(m => m.credential === credential.id)\n  const activeTypes = new Set(linkedModels.map(m => m.type))\n  const testResult = testResults[credential.id]\n\n  // Extract translations used in model badge loops to avoid excessive Proxy accesses\n  const testModelLabel = t.models.testModel\n  const deleteModelLabel = t.models.deleteModel\n\n  // Check which models are defaults\n  const defaultSlots: Record<string, string> = {}\n  if (defaults) {\n    const slotMap: Record<string, string | null | undefined> = {\n      'Chat': defaults.default_chat_model,\n      'Transform': defaults.default_transformation_model,\n      'Tools': defaults.default_tools_model,\n      'Large Ctx': defaults.large_context_model,\n      'Embedding': defaults.default_embedding_model,\n      'TTS': defaults.default_text_to_speech_model,\n      'STT': defaults.default_speech_to_text_model,\n    }\n    for (const [slot, modelId] of Object.entries(slotMap)) {\n      if (modelId) defaultSlots[modelId] = slot\n    }\n  }\n\n  return (\n    <>\n      <div className=\"border rounded-lg p-3 space-y-2\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2 min-w-0\">\n            <span className=\"font-medium truncate\">{credential.name}</span>\n            <div className=\"flex gap-1\">\n              {credential.modalities.map(mod => (\n                <Badge\n                  key={mod}\n                  variant=\"secondary\"\n                  className={`text-[10px] gap-0.5 px-1 py-0 ${activeTypes.has(mod as ModelType) ? (TYPE_COLORS[mod as ModelType] || '') : TYPE_COLOR_INACTIVE}`}\n                >\n                  {TYPE_ICONS[mod as ModelType]}\n                  <span className=\"hidden sm:inline\">{TYPE_LABELS[mod as ModelType] || mod}</span>\n                </Badge>\n              ))}\n            </div>\n            {credential.has_api_key && (\n              <Badge variant=\"outline\" className=\"text-[10px]\">\n                <Key className=\"h-2.5 w-2.5 mr-0.5\" />\n                Key\n              </Badge>\n            )}\n          </div>\n          <div className=\"flex items-center gap-1 shrink-0\">\n            {testResult && (\n              testResult.success\n                ? <Check className=\"h-4 w-4 text-emerald-500\" />\n                : <X className=\"h-4 w-4 text-destructive\" />\n            )}\n            <Button\n              variant=\"ghost\" size=\"sm\"\n              onClick={() => testCredential(credential.id)}\n              disabled={isTestPending}\n              title={t.apiKeys.testConnection}\n            >\n              {isTestPending ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <Plug className=\"h-4 w-4\" />}\n              <span className=\"hidden sm:inline text-xs\">Test</span>\n            </Button>\n            <Button\n              variant=\"ghost\" size=\"sm\"\n              onClick={() => setDiscoverOpen(true)}\n              title={t.apiKeys.syncModels}\n            >\n              <Bot className=\"h-4 w-4\" />\n              <span className=\"hidden sm:inline text-xs\">Models</span>\n            </Button>\n            <Button variant=\"ghost\" size=\"sm\" onClick={() => setEditOpen(true)} title={t.common.edit}>\n              <Edit className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"ghost\" size=\"sm\"\n              onClick={() => setDeleteOpen(true)}\n              className=\"text-destructive hover:text-destructive hover:bg-destructive/10\"\n              title={t.common.delete}\n            >\n              <Trash2 className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n\n        {/* Linked models grouped by type */}\n        {linkedModels.length > 0 && (\n          <div className=\"space-y-1.5 pt-1\">\n            {(['language', 'embedding', 'text_to_speech', 'speech_to_text'] as ModelType[])\n              .filter(type => linkedModels.some(m => m.type === type))\n              .map(type => (\n                <div key={type} className=\"flex items-start gap-1.5\">\n                  <Badge\n                    variant=\"outline\"\n                    className={`text-[10px] gap-0.5 px-1 py-0 shrink-0 mt-0.5 ${TYPE_COLORS[type]}`}\n                  >\n                    {TYPE_ICONS[type]}\n                    {TYPE_LABELS[type]}\n                  </Badge>\n                  <div className=\"flex flex-wrap gap-1\">\n                    {linkedModels.filter(m => m.type === type).map(model => {\n                      const defaultSlot = defaultSlots[model.id]\n                      return (\n                        <Badge\n                          key={model.id}\n                          variant={defaultSlot ? 'default' : 'secondary'}\n                          className=\"text-xs gap-1 pr-0.5 group/model\"\n                        >\n                          {model.name}\n                          {defaultSlot && <span className=\"ml-0.5 opacity-75\">({defaultSlot})</span>}\n                          <button\n                            className=\"ml-0.5 opacity-0 group-hover/model:opacity-60 hover:!opacity-100 transition-opacity\"\n                            onClick={() => testModel(model.id, model.name)}\n                            disabled={isModelTestPending && testingModelId === model.id}\n                            title={testModelLabel}\n                          >\n                            {isModelTestPending && testingModelId === model.id\n                              ? <Loader2 className=\"h-3 w-3 animate-spin\" />\n                              : <Plug className=\"h-3 w-3\" />\n                            }\n                          </button>\n                          <button\n                            className=\"opacity-0 group-hover/model:opacity-60 hover:!opacity-100 hover:text-destructive transition-opacity\"\n                            onClick={() => deleteModel.mutate(model.id)}\n                            title={deleteModelLabel}\n                          >\n                            <X className=\"h-3 w-3\" />\n                          </button>\n                        </Badge>\n                      )\n                    })}\n                  </div>\n                </div>\n              ))}\n          </div>\n        )}\n\n\n      </div>\n\n      {/* Edit dialog */}\n      {editOpen && (\n        <CredentialFormDialog\n          open={editOpen}\n          onOpenChange={setEditOpen}\n          provider={credential.provider}\n          credential={fullCredential || credential}\n        />\n      )}\n\n      {/* Delete dialog */}\n      {deleteOpen && (\n        <DeleteCredentialDialog\n          open={deleteOpen}\n          onOpenChange={setDeleteOpen}\n          credential={credential}\n          allCredentials={allCredentials}\n        />\n      )}\n\n      {/* Discover models dialog */}\n      {discoverOpen && (\n        <DiscoverModelsDialog\n          open={discoverOpen}\n          onOpenChange={setDiscoverOpen}\n          credential={credential}\n        />\n      )}\n\n      {/* Model test result dialog */}\n      <ModelTestResultDialog\n        open={modelTestResult !== null}\n        onOpenChange={(open) => { if (!open) clearModelTestResult() }}\n        result={modelTestResult}\n        modelName={testedModelName}\n      />\n    </>\n  )\n}\n\n// =============================================================================\n// Provider Section (shows all credentials for a provider)\n// =============================================================================\n\nfunction ProviderSection({\n  provider,\n  credentials,\n  models,\n  defaults,\n  allCredentials,\n  encryptionReady,\n}: {\n  provider: string\n  credentials: Credential[]\n  models: Model[]\n  defaults: ModelDefaults | null\n  allCredentials: Credential[]\n  encryptionReady: boolean\n}) {\n  const { t } = useTranslation()\n  const [addOpen, setAddOpen] = useState(false)\n\n  const displayName = PROVIDER_DISPLAY_NAMES[provider] || provider\n  const modalities = PROVIDER_MODALITIES[provider] || ['language']\n  const hasCredentials = credentials.length > 0\n\n  // Models linked to any credential of this provider\n  const providerModels = models.filter(m =>\n    credentials.some(c => c.id === m.credential)\n  )\n  const activeTypes = new Set(providerModels.map(m => m.type))\n\n  return (\n    <Card className={!hasCredentials ? 'opacity-80' : undefined}>\n      <CardHeader className=\"pb-3\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-3 flex-wrap\">\n            <CardTitle className=\"text-lg capitalize\">{displayName}</CardTitle>\n            <div className=\"flex items-center gap-1\">\n              {modalities.map((type) => (\n                <Badge\n                  key={type}\n                  variant=\"secondary\"\n                  className={`text-xs gap-1 ${activeTypes.has(type) ? TYPE_COLORS[type] : TYPE_COLOR_INACTIVE}`}\n                >\n                  {TYPE_ICONS[type]}\n                  <span className=\"hidden sm:inline\">{TYPE_LABELS[type]}</span>\n                </Badge>\n              ))}\n            </div>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            {hasCredentials ? (\n              <Badge className=\"bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-900/30 dark:text-emerald-300\">\n                <Check className=\"mr-1 h-3 w-3\" />\n                {t.apiKeys.configured}\n              </Badge>\n            ) : (\n              <Badge variant=\"outline\" className=\"text-muted-foreground border-dashed\">\n                <X className=\"mr-1 h-3 w-3\" />\n                {t.apiKeys.notConfigured}\n              </Badge>\n            )}\n          </div>\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-2\">\n        {credentials.map(cred => (\n          <CredentialItem\n            key={cred.id}\n            credential={cred}\n            models={models}\n            defaults={defaults}\n            allCredentials={allCredentials}\n          />\n        ))}\n\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          onClick={() => setAddOpen(true)}\n          className=\"w-full gap-2\"\n          disabled={!encryptionReady}\n        >\n          <Plus className=\"h-4 w-4\" />\n          {t.apiKeys.addConfig}\n        </Button>\n      </CardContent>\n\n      {addOpen && (\n        <CredentialFormDialog\n          open={addOpen}\n          onOpenChange={setAddOpen}\n          provider={provider}\n        />\n      )}\n    </Card>\n  )\n}\n\n// =============================================================================\n// Default Models Section\n// =============================================================================\n\nfunction DefaultModelSelectors({\n  models,\n  defaults,\n}: {\n  models: Model[]\n  defaults: ModelDefaults\n}) {\n  const { t } = useTranslation()\n  const updateDefaults = useUpdateModelDefaults()\n  const autoAssign = useAutoAssignDefaults()\n  const { setValue, watch } = useForm<ModelDefaults>({ defaultValues: defaults })\n  const generatedId = useId()\n\n  const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)\n  const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{\n    key: keyof ModelDefaults; value: string; oldModelId?: string; newModelId?: string\n  } | null>(null)\n\n  useEffect(() => {\n    if (defaults) {\n      Object.entries(defaults).forEach(([key, value]) => {\n        setValue(key as keyof ModelDefaults, value)\n      })\n    }\n  }, [defaults, setValue])\n\n  interface DefaultConfig {\n    key: keyof ModelDefaults\n    label: string\n    description: string\n    modelType: ModelType\n    required?: boolean\n    id: string\n  }\n\n  const primaryConfigs: DefaultConfig[] = [\n    { key: 'default_chat_model', label: t.models.chatModelLabel, description: t.models.chatModelDesc, modelType: 'language', required: true, id: `${generatedId}-chat` },\n    { key: 'default_embedding_model', label: t.models.embeddingModelLabel, description: t.models.embeddingModelDesc, modelType: 'embedding', required: true, id: `${generatedId}-embed` },\n    { key: 'default_text_to_speech_model', label: t.models.ttsModelLabel, description: t.models.ttsModelDesc, modelType: 'text_to_speech', id: `${generatedId}-tts` },\n    { key: 'default_speech_to_text_model', label: t.models.sttModelLabel, description: t.models.sttModelDesc, modelType: 'speech_to_text', id: `${generatedId}-stt` },\n  ]\n\n  const advancedConfigs: DefaultConfig[] = [\n    { key: 'default_transformation_model', label: t.models.transformationModelLabel, description: t.models.transformationModelDesc, modelType: 'language', required: true, id: `${generatedId}-transform` },\n    { key: 'default_tools_model', label: t.models.toolsModelLabel, description: t.models.toolsModelDesc, modelType: 'language', id: `${generatedId}-tools` },\n    { key: 'large_context_model', label: t.models.largeContextModelLabel, description: t.models.largeContextModelDesc, modelType: 'language', id: `${generatedId}-large` },\n  ]\n\n  const defaultConfigs = [...primaryConfigs, ...advancedConfigs]\n\n  const handleChange = (key: keyof ModelDefaults, value: string) => {\n    if (key === 'default_embedding_model') {\n      const current = defaults[key]\n      if (current && current !== value) {\n        setPendingEmbeddingChange({ key, value, oldModelId: current, newModelId: value })\n        setShowEmbeddingDialog(true)\n        return\n      }\n    }\n    updateDefaults.mutate({ [key]: value || null })\n  }\n\n  const handleConfirmEmbeddingChange = () => {\n    if (pendingEmbeddingChange) {\n      updateDefaults.mutate({ [pendingEmbeddingChange.key]: pendingEmbeddingChange.value || null })\n      setPendingEmbeddingChange(null)\n    }\n  }\n\n  const getModelsForType = (type: ModelType) => models.filter(m => m.type === type)\n\n  const missingRequired = defaultConfigs\n    .filter(c => {\n      if (!c.required) return false\n      const value = defaults[c.key]\n      if (!value) return true\n      return !models.filter(m => m.type === c.modelType).some(m => m.id === value)\n    })\n    .map(c => c.label)\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>{t.models.defaultAssignments}</CardTitle>\n        <CardDescription>{t.models.defaultAssignmentsDesc}</CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-6\">\n        {missingRequired.length > 0 && (\n          <Alert>\n            <AlertCircle className=\"h-4 w-4\" />\n            <AlertDescription className=\"flex items-center justify-between gap-4\">\n              <span>{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}</span>\n              <Button\n                variant=\"outline\" size=\"sm\"\n                onClick={() => autoAssign.mutate()}\n                disabled={autoAssign.isPending}\n                className=\"shrink-0 gap-1.5\"\n              >\n                {autoAssign.isPending ? <Loader2 className=\"h-3.5 w-3.5 animate-spin\" /> : <Wand2 className=\"h-3.5 w-3.5\" />}\n                {autoAssign.isPending ? t.models.autoAssigning : t.models.autoAssign}\n              </Button>\n            </AlertDescription>\n          </Alert>\n        )}\n\n        {/* Primary models: Chat, Embedding, TTS, STT */}\n        <div className=\"grid gap-3 sm:grid-cols-2 lg:grid-cols-4\">\n          {primaryConfigs.map(config => {\n            const available = getModelsForType(config.modelType)\n            const currentValue = watch(config.key) || undefined\n            const isValid = currentValue && available.some(m => m.id === currentValue)\n\n            return (\n              <div key={config.key} className=\"space-y-1\">\n                <Label htmlFor={config.id} className=\"text-xs\">\n                  {config.label}\n                  {config.required && <span className=\"text-destructive ml-0.5\">*</span>}\n                </Label>\n                <div className=\"flex gap-1\">\n                  <Select\n                    value={currentValue || \"\"}\n                    onValueChange={(v) => handleChange(config.key, v)}\n                  >\n                    <SelectTrigger\n                      id={config.id}\n                      className={`h-8 text-xs ${config.required && !isValid && available.length > 0 ? 'border-destructive' : ''}`}\n                    >\n                      <SelectValue placeholder={\n                        config.required && !isValid && available.length > 0\n                          ? t.models.requiredModelPlaceholder\n                          : t.models.selectModelPlaceholder\n                      } />\n                    </SelectTrigger>\n                    <SelectContent>\n                      {available.sort((a, b) => a.name.localeCompare(b.name)).map(model => (\n                        <SelectItem key={model.id} value={model.id}>\n                          <div className=\"flex items-center justify-between w-full\">\n                            <span>{model.name}</span>\n                            <span className=\"text-xs text-muted-foreground ml-2\">{model.provider}</span>\n                          </div>\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                  {!config.required && currentValue && (\n                    <Button variant=\"ghost\" size=\"icon\" onClick={() => handleChange(config.key, \"\")} className=\"h-8 w-8 shrink-0\">\n                      <X className=\"h-3 w-3\" />\n                    </Button>\n                  )}\n                </div>\n              </div>\n            )\n          })}\n        </div>\n\n        {/* Advanced models: Transformation, Tools, Large Context */}\n        <div className=\"border-t pt-3\">\n          <p className=\"text-xs text-muted-foreground mb-3\">{t.navigation.advanced}</p>\n            <div className=\"grid gap-3 sm:grid-cols-3\">\n              {advancedConfigs.map(config => {\n                const available = getModelsForType(config.modelType)\n                const currentValue = watch(config.key) || undefined\n                const isValid = currentValue && available.some(m => m.id === currentValue)\n\n                return (\n                  <div key={config.key} className=\"space-y-1\">\n                    <Label htmlFor={config.id} className=\"text-xs\">\n                      {config.label}\n                      {config.required && <span className=\"text-destructive ml-0.5\">*</span>}\n                    </Label>\n                    <div className=\"flex gap-1\">\n                      <Select\n                        value={currentValue || \"\"}\n                        onValueChange={(v) => handleChange(config.key, v)}\n                      >\n                        <SelectTrigger\n                          id={config.id}\n                          className={`h-8 text-xs ${config.required && !isValid && available.length > 0 ? 'border-destructive' : ''}`}\n                        >\n                          <SelectValue placeholder={\n                            config.required && !isValid && available.length > 0\n                              ? t.models.requiredModelPlaceholder\n                              : t.models.selectModelPlaceholder\n                          } />\n                        </SelectTrigger>\n                        <SelectContent>\n                          {available.sort((a, b) => a.name.localeCompare(b.name)).map(model => (\n                            <SelectItem key={model.id} value={model.id}>\n                              <div className=\"flex items-center justify-between w-full\">\n                                <span>{model.name}</span>\n                                <span className=\"text-xs text-muted-foreground ml-2\">{model.provider}</span>\n                              </div>\n                            </SelectItem>\n                          ))}\n                        </SelectContent>\n                      </Select>\n                      {!config.required && currentValue && (\n                        <Button variant=\"ghost\" size=\"icon\" onClick={() => handleChange(config.key, \"\")} className=\"h-8 w-8 shrink-0\">\n                          <X className=\"h-3 w-3\" />\n                        </Button>\n                      )}\n                    </div>\n                    <p className=\"text-[10px] text-muted-foreground leading-tight\">{config.description}</p>\n                  </div>\n                )\n              })}\n            </div>\n        </div>\n      </CardContent>\n\n      <EmbeddingModelChangeDialog\n        open={showEmbeddingDialog}\n        onOpenChange={(open) => { if (!open) { setPendingEmbeddingChange(null); setShowEmbeddingDialog(false) } }}\n        onConfirm={handleConfirmEmbeddingChange}\n        oldModelName={pendingEmbeddingChange?.oldModelId ? models.find(m => m.id === pendingEmbeddingChange.oldModelId)?.name : undefined}\n        newModelName={pendingEmbeddingChange?.newModelId ? models.find(m => m.id === pendingEmbeddingChange.newModelId)?.name : undefined}\n      />\n    </Card>\n  )\n}\n\n// =============================================================================\n// Main Page\n// =============================================================================\n\nexport default function ApiKeysPage() {\n  const { t } = useTranslation()\n\n  // Data\n  const { data: credentials, isLoading: credentialsLoading } = useCredentials()\n  const { data: models, isLoading: modelsLoading } = useModels()\n  const { data: defaults, isLoading: defaultsLoading } = useModelDefaults()\n  const { data: credentialStatus } = useCredentialStatus()\n  const { data: envStatus } = useEnvStatus()\n\n  const encryptionReady = credentialStatus?.encryption_configured ?? true\n\n  // Group credentials by provider\n  const credentialsByProvider = useMemo(() => {\n    const grouped: Record<string, Credential[]> = {}\n    for (const provider of ALL_PROVIDERS) {\n      grouped[provider] = []\n    }\n    if (credentials) {\n      for (const cred of credentials) {\n        if (!grouped[cred.provider]) grouped[cred.provider] = []\n        grouped[cred.provider].push(cred)\n      }\n    }\n    return grouped\n  }, [credentials])\n\n  // Providers needing migration\n  const providersToMigrate = useMemo(() => {\n    if (!envStatus || !credentialStatus) return []\n    const providers: string[] = []\n    for (const provider in envStatus) {\n      if (envStatus[provider] && credentialStatus.source[provider] === 'environment') {\n        providers.push(provider)\n      }\n    }\n    return providers\n  }, [envStatus, credentialStatus])\n\n  // Sort: configured providers first\n  const sortedProviders = useMemo(() => {\n    return [...ALL_PROVIDERS].sort((a, b) => {\n      const aHas = (credentialsByProvider[a]?.length || 0) > 0 ? 1 : 0\n      const bHas = (credentialsByProvider[b]?.length || 0) > 0 ? 1 : 0\n      return bHas - aHas\n    })\n  }, [credentialsByProvider])\n\n  const isLoading = credentialsLoading || modelsLoading || defaultsLoading\n\n  if (isLoading) {\n    return (\n      <AppShell>\n        <div className=\"flex items-center justify-center min-h-[60vh]\">\n          <LoadingSpinner size=\"lg\" />\n        </div>\n      </AppShell>\n    )\n  }\n\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6 space-y-6\">\n          {/* Header */}\n          <div>\n            <h1 className=\"text-2xl font-bold flex items-center gap-2\">\n              <Key className=\"h-6 w-6\" />\n              {t.apiKeys.title}\n            </h1>\n            <p className=\"text-muted-foreground mt-1\">{t.apiKeys.description}</p>\n          </div>\n\n          {/* Encryption warning */}\n          {!encryptionReady && (\n            <Alert className=\"border-red-500/50 bg-red-50 dark:bg-red-950/20\">\n              <ShieldAlert className=\"h-4 w-4 text-red-600 dark:text-red-400\" />\n              <AlertTitle className=\"text-red-800 dark:text-red-200\">{t.apiKeys.encryptionRequired}</AlertTitle>\n              <AlertDescription className=\"text-red-700 dark:text-red-300\">\n                <code className=\"text-xs bg-red-100 dark:bg-red-900/30 px-1 py-0.5 rounded\">\n                  {t.apiKeys.encryptionRequiredDescription}\n                </code>\n              </AlertDescription>\n            </Alert>\n          )}\n\n          {/* Migration banner */}\n          {encryptionReady && <MigrationBanner providersToMigrate={providersToMigrate} />}\n\n          {/* Default Model Selectors */}\n          {models && defaults && (\n            <DefaultModelSelectors models={models} defaults={defaults} />\n          )}\n\n          {/* Provider Cards */}\n          <div className=\"grid gap-4\">\n            {sortedProviders.map(provider => (\n              <ProviderSection\n                key={provider}\n                provider={provider}\n                credentials={credentialsByProvider[provider] || []}\n                models={models || []}\n                defaults={defaults || null}\n                allCredentials={credentials || []}\n                encryptionReady={encryptionReady}\n              />\n            ))}\n          </div>\n\n          {/* Help link */}\n          <div className=\"border-t pt-4\">\n            <a\n              href=\"https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-sm text-primary hover:underline\"\n            >\n              {t.apiKeys.learnMore}\n            </a>\n          </div>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/settings/components/SettingsForm.tsx",
    "content": "'use client'\n\nimport { useForm, Controller } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\nimport { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Label } from '@/components/ui/label'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'\nimport { useSettings, useUpdateSettings } from '@/lib/hooks/use-settings'\nimport { useEffect, useState } from 'react'\nimport { ChevronDownIcon } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst settingsSchema = z.object({\n  default_content_processing_engine_doc: z.enum(['auto', 'docling', 'simple']).optional(),\n  default_content_processing_engine_url: z.enum(['auto', 'firecrawl', 'jina', 'simple']).optional(),\n  default_embedding_option: z.enum(['ask', 'always', 'never']).optional(),\n  auto_delete_files: z.enum(['yes', 'no']).optional(),\n})\n\ntype SettingsFormData = z.infer<typeof settingsSchema>\n\nexport function SettingsForm() {\n  const { t } = useTranslation()\n  const { data: settings, isLoading, error } = useSettings()\n  const updateSettings = useUpdateSettings()\n  const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({\n    doc: false,\n    url: false,\n    embedding: false,\n    files: false\n  })\n  const [hasResetForm, setHasResetForm] = useState(false)\n  \n  \n  const {\n    control,\n    handleSubmit,\n    reset,\n    formState: { isDirty }\n  } = useForm<SettingsFormData>({\n    resolver: zodResolver(settingsSchema),\n    defaultValues: {\n      default_content_processing_engine_doc: undefined,\n      default_content_processing_engine_url: undefined,\n      default_embedding_option: undefined,\n      auto_delete_files: undefined,\n    }\n  })\n\n\n  const toggleSection = (section: string) => {\n    setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }))\n  }\n\n  useEffect(() => {\n    if (settings && settings.default_content_processing_engine_doc && !hasResetForm) {\n      const formData = {\n        default_content_processing_engine_doc: settings.default_content_processing_engine_doc as 'auto' | 'docling' | 'simple',\n        default_content_processing_engine_url: settings.default_content_processing_engine_url as 'auto' | 'firecrawl' | 'jina' | 'simple',\n        default_embedding_option: settings.default_embedding_option as 'ask' | 'always' | 'never',\n        auto_delete_files: settings.auto_delete_files as 'yes' | 'no',\n      }\n      reset(formData)\n      setHasResetForm(true)\n    }\n  }, [hasResetForm, reset, settings])\n\n  const onSubmit = async (data: SettingsFormData) => {\n    await updateSettings.mutateAsync(data)\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <LoadingSpinner size=\"lg\" />\n      </div>\n    )\n  }\n\n  if (error) {\n    return (\n      <Alert variant=\"destructive\">\n        <AlertTitle>{t.settings.loadFailed}</AlertTitle>\n        <AlertDescription>\n          {error instanceof Error ? error.message : t.common.error}\n        </AlertDescription>\n      </Alert>\n    )\n  }\n\n  return (\n    <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-6\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{t.settings.contentProcessing}</CardTitle>\n          <CardDescription>\n            {t.settings.contentProcessingDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n          <div className=\"space-y-3\">\n            <Label htmlFor=\"doc_engine\">{t.settings.docEngine}</Label>\n            <Controller\n              name=\"default_content_processing_engine_doc\"\n              control={control}\n              render={({ field }) => (\n                  <Select\n                    key={field.value}\n                    name={field.name}\n                    value={field.value || ''}\n                    onValueChange={field.onChange}\n                    disabled={field.disabled || isLoading}\n                  >\n                      <SelectTrigger id=\"doc_engine\" className=\"w-full\">\n                        <SelectValue placeholder={t.settings.docEnginePlaceholder} />\n                      </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"auto\">{t.settings.autoRecommended}</SelectItem>\n                      <SelectItem value=\"docling\">{t.settings.docling}</SelectItem>\n                      <SelectItem value=\"simple\">{t.settings.simple}</SelectItem>\n                    </SelectContent>\n                  </Select>\n              )}\n            />\n            <Collapsible open={expandedSections.doc} onOpenChange={() => toggleSection('doc')}>\n              <CollapsibleTrigger className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\">\n                <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.doc ? 'rotate-180' : ''}`} />\n                {t.settings.helpMeChoose}\n              </CollapsibleTrigger>\n              <CollapsibleContent className=\"mt-2 text-sm text-muted-foreground space-y-2\">\n                <p>{t.settings.docHelp}</p>\n              </CollapsibleContent>\n            </Collapsible>\n          </div>\n          \n          <div className=\"space-y-3\">\n            <Label htmlFor=\"url_engine\">{t.settings.urlEngine}</Label>\n            <Controller\n              name=\"default_content_processing_engine_url\"\n              control={control}\n              render={({ field }) => (\n                <Select\n                  key={field.value}\n                  name={field.name}\n                  value={field.value || ''}\n                  onValueChange={field.onChange}\n                  disabled={field.disabled || isLoading}\n                >\n                  <SelectTrigger id=\"url_engine\" className=\"w-full\">\n                    <SelectValue placeholder={t.settings.urlEnginePlaceholder} />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"auto\">{t.settings.autoRecommended}</SelectItem>\n                    <SelectItem value=\"firecrawl\">{t.settings.firecrawl}</SelectItem>\n                    <SelectItem value=\"jina\">{t.settings.jina}</SelectItem>\n                    <SelectItem value=\"simple\">{t.settings.simple}</SelectItem>\n                  </SelectContent>\n                </Select>\n              )}\n            />\n             <Collapsible open={expandedSections.url} onOpenChange={() => toggleSection('url')}>\n              <CollapsibleTrigger className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\">\n                <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.url ? 'rotate-180' : ''}`} />\n                {t.settings.helpMeChoose}\n              </CollapsibleTrigger>\n              <CollapsibleContent className=\"mt-2 text-sm text-muted-foreground space-y-2\">\n                <p>{t.settings.urlHelp}</p>\n              </CollapsibleContent>\n            </Collapsible>\n          </div>\n        </CardContent>\n      </Card>\n\n       <Card>\n        <CardHeader>\n          <CardTitle>{t.settings.embeddingAndSearch}</CardTitle>\n          <CardDescription>\n            {t.settings.embeddingAndSearchDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n           <div className=\"space-y-3\">\n            <Label htmlFor=\"embedding\">{t.settings.defaultEmbeddingOption}</Label>\n            <Controller\n              name=\"default_embedding_option\"\n              control={control}\n              render={({ field }) => (\n                <Select\n                  key={field.value}\n                  name={field.name}\n                  value={field.value || ''}\n                  onValueChange={field.onChange}\n                  disabled={field.disabled || isLoading}\n                >\n                  <SelectTrigger id=\"embedding\" className=\"w-full\">\n                    <SelectValue placeholder={t.settings.embeddingOptionPlaceholder} />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"ask\">{t.settings.ask}</SelectItem>\n                    <SelectItem value=\"always\">{t.settings.always}</SelectItem>\n                    <SelectItem value=\"never\">{t.settings.never}</SelectItem>\n                  </SelectContent>\n                </Select>\n              )}\n            />\n             <Collapsible open={expandedSections.embedding} onOpenChange={() => toggleSection('embedding')}>\n              <CollapsibleTrigger className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\">\n                <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.embedding ? 'rotate-180' : ''}`} />\n                {t.settings.helpMeChoose}\n              </CollapsibleTrigger>\n              <CollapsibleContent className=\"mt-2 text-sm text-muted-foreground space-y-2\">\n                <p>{t.settings.embeddingHelp}</p>\n              </CollapsibleContent>\n            </Collapsible>\n          </div>\n        </CardContent>\n      </Card>\n\n       <Card>\n        <CardHeader>\n          <CardTitle>{t.settings.fileManagement}</CardTitle>\n          <CardDescription>\n            {t.settings.fileManagementDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n           <div className=\"space-y-3\">\n            <Label htmlFor=\"auto_delete\">{t.settings.autoDeleteFiles}</Label>\n            <Controller\n              name=\"auto_delete_files\"\n              control={control}\n              render={({ field }) => (\n                <Select\n                  key={field.value}\n                  name={field.name}\n                  value={field.value || ''}\n                  onValueChange={field.onChange}\n                  disabled={field.disabled || isLoading}\n                >\n                  <SelectTrigger id=\"auto_delete\" className=\"w-full\">\n                    <SelectValue placeholder={t.settings.autoDeletePlaceholder} />\n                  </SelectTrigger>\n                   <SelectContent>\n                    <SelectItem value=\"yes\">{t.common.yes}</SelectItem>\n                    <SelectItem value=\"no\">{t.common.no}</SelectItem>\n                  </SelectContent>\n                </Select>\n              )}\n            />\n             <Collapsible open={expandedSections.files} onOpenChange={() => toggleSection('files')}>\n              <CollapsibleTrigger className=\"flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors\">\n                <ChevronDownIcon className={`h-4 w-4 transition-transform ${expandedSections.files ? 'rotate-180' : ''}`} />\n                {t.settings.helpMeChoose}\n              </CollapsibleTrigger>\n              <CollapsibleContent className=\"mt-2 text-sm text-muted-foreground space-y-2\">\n                <p>{t.settings.filesHelp}</p>\n              </CollapsibleContent>\n            </Collapsible>\n          </div>\n        </CardContent>\n      </Card>\n\n      <div className=\"flex justify-end\">\n         <Button \n          type=\"submit\" \n          disabled={!isDirty || updateSettings.isPending}\n        >\n          {updateSettings.isPending ? t.common.saving : t.navigation.settings}\n        </Button>\n      </div>\n    </form>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/settings/page.tsx",
    "content": "'use client'\n\nimport { AppShell } from '@/components/layout/AppShell'\nimport { SettingsForm } from './components/SettingsForm'\nimport { useSettings } from '@/lib/hooks/use-settings'\nimport { Button } from '@/components/ui/button'\nimport { RefreshCw } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport default function SettingsPage() {\n  const { t } = useTranslation()\n  const { refetch } = useSettings()\n\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6\">\n          <div className=\"max-w-4xl\">\n            <div className=\"flex items-center gap-4 mb-6\">\n              <h1 className=\"text-2xl font-bold\">{t.navigation.settings}</h1>\n              <Button variant=\"outline\" size=\"sm\" onClick={() => refetch()}>\n                <RefreshCw className=\"h-4 w-4\" />\n              </Button>\n            </div>\n\n            <SettingsForm />\n          </div>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/sources/[id]/page.tsx",
    "content": "'use client'\n\nimport { useRouter, useParams } from 'next/navigation'\nimport { useCallback } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { ArrowLeft } from 'lucide-react'\nimport { useSourceChat } from '@/lib/hooks/useSourceChat'\nimport { ChatPanel } from '@/components/source/ChatPanel'\nimport { useNavigation } from '@/lib/hooks/use-navigation'\nimport { SourceDetailContent } from '@/components/source/SourceDetailContent'\n\nexport default function SourceDetailPage() {\n  const router = useRouter()\n  const params = useParams()\n  const sourceId = params?.id ? decodeURIComponent(params.id as string) : ''\n  const navigation = useNavigation()\n\n  // Initialize source chat\n  const chat = useSourceChat(sourceId)\n\n  const handleBack = useCallback(() => {\n    const returnPath = navigation.getReturnPath()\n    router.push(returnPath)\n    navigation.clearReturnTo()\n  }, [navigation, router])\n\n  return (\n    <div className=\"flex flex-col h-screen\">\n      {/* Back button */}\n      <div className=\"pt-6 pb-4 px-6\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={handleBack}\n          className=\"mb-4\"\n        >\n          <ArrowLeft className=\"mr-2 h-4 w-4\" />\n          {navigation.getReturnLabel()}\n        </Button>\n      </div>\n\n      {/* Main content: Source detail + Chat */}\n      <div className=\"flex-1 grid gap-6 lg:grid-cols-[2fr_1fr] overflow-hidden px-6\">\n        {/* Left column - Source detail */}\n        <div className=\"overflow-y-auto px-4 pb-6\">\n          <SourceDetailContent\n            sourceId={sourceId}\n            showChatButton={false}\n            onClose={handleBack}\n          />\n        </div>\n\n        {/* Right column - Chat */}\n        <div className=\"overflow-y-auto px-4 pb-6\">\n          <ChatPanel\n            messages={chat.messages}\n            isStreaming={chat.isStreaming}\n            contextIndicators={chat.contextIndicators}\n            onSendMessage={(message, model) => chat.sendMessage(message, model)}\n            modelOverride={chat.currentSession?.model_override}\n            onModelChange={(model) => {\n              if (chat.currentSessionId) {\n                chat.updateSession(chat.currentSessionId, { model_override: model })\n              }\n            }}\n            sessions={chat.sessions}\n            currentSessionId={chat.currentSessionId}\n            onCreateSession={(title) => chat.createSession({ title })}\n            onSelectSession={chat.switchSession}\n            onUpdateSession={(sessionId, title) => chat.updateSession(sessionId, { title })}\n            onDeleteSession={chat.deleteSession}\n            loadingSessions={chat.loadingSessions}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/sources/page.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useCallback, useRef } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { sourcesApi } from '@/lib/api/sources'\nimport { SourceListResponse } from '@/lib/types/api'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { EmptyState } from '@/components/common/EmptyState'\nimport { AppShell } from '@/components/layout/AppShell'\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\nimport { FileText, Link as LinkIcon, Upload, AlignLeft, Trash2, ArrowUpDown } from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { cn } from '@/lib/utils'\nimport { toast } from 'sonner'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\n\nexport default function SourcesPage() {\n  const { t, language } = useTranslation()\n  const [sources, setSources] = useState<SourceListResponse[]>([])\n  const [loading, setLoading] = useState(true)\n  const [loadingMore, setLoadingMore] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const [sortBy, setSortBy] = useState<'created' | 'updated'>('updated')\n  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')\n  const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; source: SourceListResponse | null }>({\n    open: false,\n    source: null\n  })\n  const router = useRouter()\n  const tableRef = useRef<HTMLTableElement>(null)\n  const scrollContainerRef = useRef<HTMLDivElement>(null)\n  const offsetRef = useRef(0)\n  const loadingMoreRef = useRef(false)\n  const hasMoreRef = useRef(true)\n  const PAGE_SIZE = 30\n\n  const fetchSources = useCallback(async (reset = false) => {\n    try {\n      // Check flags before proceeding\n      if (!reset && (loadingMoreRef.current || !hasMoreRef.current)) {\n        return\n      }\n\n      if (reset) {\n        setLoading(true)\n        offsetRef.current = 0\n        setSources([])\n        hasMoreRef.current = true\n      } else {\n        loadingMoreRef.current = true\n        setLoadingMore(true)\n      }\n\n      const data = await sourcesApi.list({\n        limit: PAGE_SIZE,\n        offset: offsetRef.current,\n        sort_by: sortBy,\n        sort_order: sortOrder,\n      })\n\n      if (reset) {\n        setSources(data)\n      } else {\n        setSources(prev => [...prev, ...data])\n      }\n\n      // Check if we have more data\n      const hasMoreData = data.length === PAGE_SIZE\n      hasMoreRef.current = hasMoreData\n      offsetRef.current += data.length\n    } catch (err) {\n      console.error('Failed to fetch sources:', err)\n      setError(t.sources.failedToLoad)\n      toast.error(t.sources.failedToLoad)\n    } finally {\n      setLoading(false)\n      setLoadingMore(false)\n      loadingMoreRef.current = false\n    }\n  }, [sortBy, sortOrder, t.sources.failedToLoad])\n\n  // Initial load and when sort changes\n  useEffect(() => {\n    fetchSources(true)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sortBy, sortOrder])\n\n  useEffect(() => {\n    // Focus the table when component mounts or sources change\n    if (sources.length > 0 && tableRef.current) {\n      tableRef.current.focus()\n    }\n  }, [sources])\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (sources.length === 0) return\n\n      switch (e.key) {\n        case 'ArrowDown':\n          e.preventDefault()\n          setSelectedIndex((prev) => {\n            const newIndex = Math.min(prev + 1, sources.length - 1)\n            // Scroll to keep selected row visible\n            setTimeout(() => scrollToSelectedRow(newIndex), 0)\n            return newIndex\n          })\n          break\n        case 'ArrowUp':\n          e.preventDefault()\n          setSelectedIndex((prev) => {\n            const newIndex = Math.max(prev - 1, 0)\n            // Scroll to keep selected row visible\n            setTimeout(() => scrollToSelectedRow(newIndex), 0)\n            return newIndex\n          })\n          break\n        case 'Enter':\n          e.preventDefault()\n          if (sources[selectedIndex]) {\n            router.push(`/sources/${sources[selectedIndex].id}`)\n          }\n          break\n        case 'Home':\n          e.preventDefault()\n          setSelectedIndex(0)\n          setTimeout(() => scrollToSelectedRow(0), 0)\n          break\n        case 'End':\n          e.preventDefault()\n          const lastIndex = sources.length - 1\n          setSelectedIndex(lastIndex)\n          setTimeout(() => scrollToSelectedRow(lastIndex), 0)\n          break\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyDown)\n    return () => window.removeEventListener('keydown', handleKeyDown)\n  }, [sources, selectedIndex, router])\n\n  const scrollToSelectedRow = (index: number) => {\n    const scrollContainer = scrollContainerRef.current\n    if (!scrollContainer) return\n\n    // Find the selected row element\n    const rows = scrollContainer.querySelectorAll('tbody tr')\n    const selectedRow = rows[index] as HTMLElement\n    if (!selectedRow) return\n\n    const containerRect = scrollContainer.getBoundingClientRect()\n    const rowRect = selectedRow.getBoundingClientRect()\n\n    // Check if row is above visible area\n    if (rowRect.top < containerRect.top) {\n      selectedRow.scrollIntoView({ behavior: 'smooth', block: 'start' })\n    }\n    // Check if row is below visible area\n    else if (rowRect.bottom > containerRect.bottom) {\n      selectedRow.scrollIntoView({ behavior: 'smooth', block: 'end' })\n    }\n  }\n\n  // Set up scroll listener after sources are loaded\n  useEffect(() => {\n    const scrollContainer = scrollContainerRef.current\n    if (!scrollContainer) return\n\n    let scrollTimeout: NodeJS.Timeout | null = null\n\n    const handleScroll = () => {\n      if (scrollTimeout) {\n        clearTimeout(scrollTimeout)\n      }\n\n      scrollTimeout = setTimeout(() => {\n        if (!scrollContainerRef.current) return\n\n        const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current\n        const distanceFromBottom = scrollHeight - scrollTop - clientHeight\n\n        // Load more when within 200px of the bottom\n        if (distanceFromBottom < 200 && !loadingMoreRef.current && hasMoreRef.current) {\n          fetchSources(false)\n        }\n      }, 100)\n    }\n\n    scrollContainer.addEventListener('scroll', handleScroll)\n    handleScroll() // Check on mount\n\n    return () => {\n      scrollContainer.removeEventListener('scroll', handleScroll)\n      if (scrollTimeout) {\n        clearTimeout(scrollTimeout)\n      }\n    }\n  }, [fetchSources, sources.length])\n\n  const toggleSort = (field: 'created' | 'updated') => {\n    if (sortBy === field) {\n      // Toggle order if clicking the same field\n      setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')\n    } else {\n      // Switch to new field with default desc order\n      setSortBy(field)\n      setSortOrder('desc')\n    }\n  }\n\n  const getSourceIcon = (source: SourceListResponse) => {\n    if (source.asset?.url) return <LinkIcon className=\"h-4 w-4\" />\n    if (source.asset?.file_path) return <Upload className=\"h-4 w-4\" />\n    return <AlignLeft className=\"h-4 w-4\" />\n  }\n\n  const getSourceType = (source: SourceListResponse) => {\n    if (source.asset?.url) return t.sources.type.link\n    if (source.asset?.file_path) return t.sources.type.file\n    return t.sources.type.text\n  }\n\n  const handleRowClick = useCallback((index: number, sourceId: string) => {\n    setSelectedIndex(index)\n    router.push(`/sources/${sourceId}`)\n  }, [router])\n\n  const handleDeleteClick = useCallback((e: React.MouseEvent, source: SourceListResponse) => {\n    e.stopPropagation() // Prevent row click\n    setDeleteDialog({ open: true, source })\n  }, [])\n\n  const handleDeleteConfirm = async () => {\n    if (!deleteDialog.source) return\n\n    try {\n      await sourcesApi.delete(deleteDialog.source.id)\n      toast.success(t.sources.deleteSuccess)\n      // Remove the deleted source from the list\n      setSources(prev => prev.filter(s => s.id !== deleteDialog.source?.id))\n      setDeleteDialog({ open: false, source: null })\n    } catch (err: unknown) {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      console.error('Failed to delete source:', error)\n      toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message)))\n    }\n  }\n\n  if (loading) {\n    return (\n      <AppShell>\n        <div className=\"flex h-full items-center justify-center\">\n          <LoadingSpinner />\n        </div>\n      </AppShell>\n    )\n  }\n\n  if (error) {\n    return (\n      <AppShell>\n        <div className=\"flex h-full items-center justify-center\">\n          <p className=\"text-red-500\">{error}</p>\n        </div>\n      </AppShell>\n    )\n  }\n\n  if (sources.length === 0) {\n    return (\n      <AppShell>\n        <EmptyState\n          icon={FileText}\n          title={t.sources.noSourcesYet}\n          description={t.sources.allSourcesDescShort}\n        />\n      </AppShell>\n    )\n  }\n\n  return (\n    <AppShell>\n      <div className=\"flex flex-col h-full w-full max-w-none px-6 py-6\">\n        <div className=\"mb-6 flex-shrink-0\">\n          <h1 className=\"text-3xl font-bold\">{t.sources.allSources}</h1>\n          <p className=\"mt-2 text-muted-foreground\">\n            {t.sources.allSourcesDesc}\n          </p>\n        </div>\n\n        <div ref={scrollContainerRef} className=\"flex-1 rounded-md border overflow-auto\">\n          <table\n            ref={tableRef}\n            tabIndex={0}\n            className=\"w-full min-w-[800px] outline-none table-fixed\"\n          >\n            <colgroup>\n              <col className=\"w-[120px]\" />\n              <col className=\"w-auto\" />\n              <col className=\"w-[140px]\" />\n              <col className=\"w-[100px]\" />\n              <col className=\"w-[100px]\" />\n              <col className=\"w-[100px]\" />\n            </colgroup>\n            <thead className=\"sticky top-0 bg-background z-10\">\n              <tr className=\"border-b bg-muted/50\">\n                <th className=\"h-12 px-4 text-left align-middle font-medium text-muted-foreground\">\n                  {t.common.type}\n                </th>\n                <th className=\"h-12 px-4 text-left align-middle font-medium text-muted-foreground\">\n                  {t.common.title}\n                </th>\n                <th className=\"h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden sm:table-cell\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => toggleSort('created')}\n                    className=\"h-8 px-2 hover:bg-muted\"\n                  >\n                    {t.common.created_label}\n                    <ArrowUpDown className={cn(\n                      \"ml-2 h-3 w-3\",\n                      sortBy === 'created' ? 'opacity-100' : 'opacity-30'\n                    )} />\n                    {sortBy === 'created' && (\n                      <span className=\"ml-1 text-xs\">\n                        {sortOrder === 'asc' ? '↑' : '↓'}\n                      </span>\n                    )}\n                  </Button>\n                </th>\n                <th className=\"h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden md:table-cell\">\n                  {t.sources.insights}\n                </th>\n                <th className=\"h-12 px-4 text-center align-middle font-medium text-muted-foreground hidden lg:table-cell\">\n                  {t.sources.embedded}\n                </th>\n                <th className=\"h-12 px-4 text-right align-middle font-medium text-muted-foreground\">\n                  {t.common.actions}\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {sources.map((source, index) => (\n                <tr\n                  key={source.id}\n                  onClick={() => handleRowClick(index, source.id)}\n                  onMouseEnter={() => setSelectedIndex(index)}\n                  className={cn(\n                    \"border-b transition-colors cursor-pointer\",\n                    selectedIndex === index\n                      ? \"bg-accent\"\n                      : \"hover:bg-muted/50\"\n                  )}\n                >\n                  <td className=\"h-12 px-4\">\n                    <div className=\"flex items-center gap-2\">\n                      {getSourceIcon(source)}\n                      <Badge variant=\"secondary\" className=\"text-xs\">\n                        {getSourceType(source)}\n                      </Badge>\n                    </div>\n                  </td>\n                  <td className=\"h-12 px-4\">\n                    <div className=\"flex flex-col overflow-hidden\">\n                      <span className=\"font-medium truncate\">\n                        {source.title || t.sources.untitledSource}\n                      </span>\n                      {source.asset?.url && (\n                        <span className=\"text-xs text-muted-foreground truncate\">\n                          {source.asset.url}\n                        </span>\n                      )}\n                    </div>\n                  </td>\n                  <td className=\"h-12 px-4 text-muted-foreground text-sm hidden sm:table-cell\">\n                    {formatDistanceToNow(new Date(source.created), { \n                      addSuffix: true,\n                      locale: getDateLocale(language)\n                    })}\n                  </td>\n                  <td className=\"h-12 px-4 text-center hidden md:table-cell\">\n                    <span className=\"text-sm font-medium\">{source.insights_count || 0}</span>\n                  </td>\n                  <td className=\"h-12 px-4 text-center hidden lg:table-cell\">\n                    <Badge variant={source.embedded ? \"default\" : \"secondary\"} className=\"text-xs\">\n                      {source.embedded ? t.sources.yes : t.sources.no}\n                    </Badge>\n                  </td>\n                  <td className=\"h-12 px-4 text-right\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      onClick={(e) => handleDeleteClick(e, source)}\n                      className=\"text-destructive hover:text-destructive\"\n                    >\n                      <Trash2 className=\"h-4 w-4\" />\n                    </Button>\n                  </td>\n                </tr>\n              ))}\n              {loadingMore && (\n                <tr>\n                  <td colSpan={6} className=\"h-16 text-center\">\n                    <div className=\"flex items-center justify-center\">\n                      <LoadingSpinner />\n                      <span className=\"ml-2 text-muted-foreground\">{t.sources.loadingMore}</span>\n                    </div>\n                  </td>\n                </tr>\n              )}\n            </tbody>\n          </table>\n        </div>\n      </div>\n\n      <ConfirmDialog\n        open={deleteDialog.open}\n        onOpenChange={(open) => setDeleteDialog({ open, source: deleteDialog.source })}\n        title={t.sources.delete}\n        description={t.sources.deleteConfirmWithTitle.replace('{title}', deleteDialog.source?.title || t.sources.untitledSource)}\n        confirmText={t.common.delete}\n        confirmVariant=\"destructive\"\n        onConfirm={handleDeleteConfirm}\n      />\n    </AppShell>\n  )\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/components/DefaultPromptEditor.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useId } from 'react'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { ChevronDown, ChevronRight, Settings } from 'lucide-react'\nimport { useDefaultPrompt, useUpdateDefaultPrompt } from '@/lib/hooks/use-transformations'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport function DefaultPromptEditor() {\n  const [isOpen, setIsOpen] = useState(false)\n  const [prompt, setPrompt] = useState('')\n  const { data: defaultPrompt, isLoading } = useDefaultPrompt()\n  const updateDefaultPrompt = useUpdateDefaultPrompt()\n  const { t } = useTranslation()\n  const textareaId = useId()\n\n  useEffect(() => {\n    if (defaultPrompt) {\n      setPrompt(defaultPrompt.transformation_instructions || '')\n    }\n  }, [defaultPrompt])\n\n  const handleSave = () => {\n    updateDefaultPrompt.mutate({ transformation_instructions: prompt })\n  }\n\n  return (\n    <Collapsible open={isOpen} onOpenChange={setIsOpen}>\n      <Card>\n        <CollapsibleTrigger className=\"w-full\">\n          <CardHeader className=\"cursor-pointer\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-2\">\n                <Settings className=\"h-5 w-5\" />\n                <div className=\"text-left\">\n                  <CardTitle className=\"text-lg\">{t.transformations.defaultPrompt}</CardTitle>\n                  <CardDescription>\n                    {t.transformations.defaultPromptDesc}\n                  </CardDescription>\n                </div>\n              </div>\n              {isOpen ? (\n                <ChevronDown className=\"h-5 w-5\" />\n              ) : (\n                <ChevronRight className=\"h-5 w-5\" />\n              )}\n            </div>\n          </CardHeader>\n        </CollapsibleTrigger>\n        <CollapsibleContent>\n          <CardContent className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor={textareaId} className=\"sr-only\">\n                {t.transformations.defaultPrompt}\n              </Label>\n              <Textarea\n                id={textareaId}\n                name=\"default-prompt\"\n                value={prompt}\n                onChange={(e) => setPrompt(e.target.value)}\n                placeholder={t.transformations.defaultPromptPlaceholder}\n                className=\"min-h-[200px] font-mono text-sm\"\n                disabled={isLoading}\n              />\n            </div>\n            <div className=\"flex justify-end\">\n              <Button \n                onClick={handleSave}\n                disabled={isLoading || updateDefaultPrompt.isPending}\n              >\n                {t.common.save}\n              </Button>\n            </div>\n          </CardContent>\n        </CollapsibleContent>\n      </Card>\n    </Collapsible>\n  )\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/components/TransformationCard.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Card, CardContent, CardHeader } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { ChevronDown, ChevronRight, Trash2, Wand2, Edit } from 'lucide-react'\nimport { Transformation } from '@/lib/types/transformations'\nimport { useDeleteTransformation } from '@/lib/hooks/use-transformations'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { cn } from '@/lib/utils'\n\ninterface TransformationCardProps {\n  transformation: Transformation\n  onPlayground?: () => void\n  onEdit?: () => void\n}\n\nexport function TransformationCard({ transformation, onPlayground, onEdit }: TransformationCardProps) {\n  const { t } = useTranslation()\n  const [isExpanded, setIsExpanded] = useState(false)\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false)\n  const deleteTransformation = useDeleteTransformation()\n\n  const handleDelete = () => {\n    deleteTransformation.mutate(transformation.id)\n    setShowDeleteDialog(false)\n  }\n\n  return (\n    <>\n      <Collapsible open={isExpanded} onOpenChange={setIsExpanded}>\n        <Card>\n          <CardHeader>\n            <div className=\"flex items-start justify-between gap-4\">\n              <CollapsibleTrigger className=\"flex-1 text-left\">\n                <div className={cn('flex items-center gap-3', isExpanded ? 'mb-2' : '')}>\n                  {isExpanded ? (\n                    <ChevronDown className=\"h-5 w-5\" />\n                  ) : (\n                    <ChevronRight className=\"h-5 w-5\" />\n                  )}\n                  <div className=\"flex flex-col\">\n                    <span className=\"font-semibold\">{transformation.name}</span>\n                    {!isExpanded && transformation.description && (\n                      <span className=\"text-sm text-muted-foreground\">{transformation.description}</span>\n                    )}\n                  </div>\n                  {transformation.apply_default && (\n                    <Badge variant=\"secondary\">{t.common.default}</Badge>\n                  )}\n                </div>\n              </CollapsibleTrigger>\n\n              <div className=\"flex items-center gap-2\">\n                {onPlayground && (\n                  <Button variant=\"outline\" size=\"sm\" onClick={onPlayground}>\n                    <Wand2 className=\"h-4 w-4 mr-2\" />\n                    {t.transformations.playground}\n                  </Button>\n                )}\n                {onEdit && (\n                  <Button variant=\"outline\" size=\"sm\" onClick={onEdit}>\n                    <Edit className=\"h-4 w-4 mr-2\" />\n                    {t.common.edit}\n                  </Button>\n                )}\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"text-red-600 hover:text-red-700\"\n                  onClick={() => setShowDeleteDialog(true)}\n                >\n                  <Trash2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          </CardHeader>\n\n          <CollapsibleContent>\n            <CardContent className=\"space-y-4\">\n              <div>\n                <p className=\"text-sm text-muted-foreground\">{t.common.title}</p>\n                <p className=\"text-sm font-medium\">{transformation.title || t.sources.untitledSource}</p>\n              </div>\n\n              {transformation.description && (\n                <div>\n                  <p className=\"text-sm text-muted-foreground\">{t.common.description}</p>\n                  <p className=\"text-sm leading-6\">{transformation.description}</p>\n                </div>\n              )}\n\n              <div>\n                <p className=\"text-sm text-muted-foreground\">{t.transformations.systemPrompt}</p>\n                <pre className=\"mt-2 whitespace-pre-wrap rounded-md bg-muted p-3 text-sm font-mono\">\n                  {transformation.prompt}\n                </pre>\n              </div>\n            </CardContent>\n          </CollapsibleContent>\n        </Card>\n      </Collapsible>\n\n      <ConfirmDialog\n        open={showDeleteDialog}\n        onOpenChange={setShowDeleteDialog}\n        title={t.sources.delete}\n        description={t.transformations.deleteConfirm}\n        confirmText={t.common.delete}\n        confirmVariant=\"destructive\"\n        onConfirm={handleDelete}\n        isLoading={deleteTransformation.isPending}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/components/TransformationEditorDialog.tsx",
    "content": "'use client'\n\nimport { useEffect, useId } from 'react'\nimport { Controller, useForm } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\nimport { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Label } from '@/components/ui/label'\nimport { MarkdownEditor } from '@/components/ui/markdown-editor'\nimport { useCreateTransformation, useUpdateTransformation, useTransformation } from '@/lib/hooks/use-transformations'\nimport { Transformation } from '@/lib/types/transformations'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { TRANSFORMATION_QUERY_KEYS } from '@/lib/hooks/use-transformations'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst transformationSchema = z.object({\n  name: z.string().min(1),\n  title: z.string().min(1),\n  description: z.string().optional(),\n  prompt: z.string().min(1),\n  apply_default: z.boolean().optional(),\n})\n\ntype TransformationFormData = z.infer<typeof transformationSchema>\n\ninterface TransformationEditorDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  transformation?: Transformation\n}\n\nexport function TransformationEditorDialog({ open, onOpenChange, transformation }: TransformationEditorDialogProps) {\n  const { t } = useTranslation()\n  const nameId = useId()\n  const titleId = useId()\n  const defaultId = useId()\n  const descriptionId = useId()\n  const promptId = useId()\n  const isEditing = Boolean(transformation)\n  const { data: fetchedTransformation, isLoading } = useTransformation(transformation?.id ?? '', {\n    enabled: open && Boolean(transformation?.id),\n  })\n  const createTransformation = useCreateTransformation()\n  const updateTransformation = useUpdateTransformation()\n  const queryClient = useQueryClient()\n\n  const {\n    control,\n    handleSubmit,\n    formState: { errors },\n    reset,\n  } = useForm<TransformationFormData>({\n    resolver: zodResolver(transformationSchema),\n    defaultValues: {\n      name: '',\n      title: '',\n      description: '',\n      prompt: '',\n      apply_default: false,\n    },\n  })\n\n  useEffect(() => {\n    if (!open) {\n      reset({ name: '', title: '', description: '', prompt: '', apply_default: false })\n      return\n    }\n\n    const source = fetchedTransformation ?? transformation\n    reset({\n      name: source?.name ?? '',\n      title: source?.title ?? '',\n      description: source?.description ?? '',\n      prompt: source?.prompt ?? '',\n      apply_default: source?.apply_default ?? false,\n    })\n  }, [open, transformation, fetchedTransformation, reset])\n\n  const onSubmit = async (data: TransformationFormData) => {\n    if (transformation) {\n      await updateTransformation.mutateAsync({\n        id: transformation.id,\n        data: {\n          name: data.name,\n          title: data.title || undefined,\n          description: data.description || undefined,\n          prompt: data.prompt,\n          apply_default: Boolean(data.apply_default),\n        },\n      })\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformation(transformation.id) })\n    } else {\n      await createTransformation.mutateAsync({\n        name: data.name,\n        title: data.title || data.name,\n        description: data.description || '',\n        prompt: data.prompt,\n        apply_default: Boolean(data.apply_default),\n      })\n    }\n\n    reset()\n    onOpenChange(false)\n  }\n\n  const handleClose = () => {\n    reset()\n    onOpenChange(false)\n  }\n\n  const isSaving = transformation ? updateTransformation.isPending : createTransformation.isPending\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent className=\"sm:max-w-4xl w-full max-h-[90vh] overflow-hidden p-0\">\n        <DialogTitle className=\"sr-only\">\n          {isEditing ? t.common.edit : t.transformations.createNew}\n        </DialogTitle>\n        <DialogDescription className=\"sr-only\">\n           {isEditing ? t.common.editTransformation : t.transformations.createNew}\n        </DialogDescription>\n        <form onSubmit={handleSubmit(onSubmit)} className=\"flex h-full flex-col\">\n          {isEditing && isLoading ? (\n            <div className=\"flex-1 flex items-center justify-center py-10\">\n              <span className=\"text-sm text-muted-foreground\">{t.common.loading}</span>\n            </div>\n          ) : (\n            <>\n              <div className=\"border-b px-6 py-4 space-y-4\">\n                <div>\n                  <Label htmlFor={nameId} className=\"text-sm font-medium\">\n                    {t.transformations.name}\n                  </Label>\n                  <Controller\n                    control={control}\n                    name=\"name\"\n                    render={({ field }) => (\n                        <Input\n                        id={nameId}\n                        {...field}\n                        placeholder={t.transformations.namePlaceholder}\n                        autoComplete=\"off\"\n                      />\n                    )}\n                  />\n                  {errors.name && (\n                    <p className=\"text-sm text-red-600 mt-1\">{errors.name.message}</p>\n                  )}\n                </div>\n\n                <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n                  <div>\n                    <Label htmlFor={titleId} className=\"text-sm font-medium\">\n                      {t.common.title}\n                    </Label>\n                    <Controller\n                      control={control}\n                      name=\"title\"\n                      render={({ field }) => (\n                        <Input\n                           id={titleId}\n                           {...field}\n                           placeholder={t.transformations.titlePlaceholder}\n                           autoComplete=\"off\"\n                         />\n                      )}\n                    />\n                  </div>\n                  <div className=\"flex items-center gap-2 pt-6 md:pt-8\">\n                    <Controller\n                      control={control}\n                      name=\"apply_default\"\n                      render={({ field }) => (\n                        <Checkbox\n                          id={defaultId}\n                          checked={field.value}\n                          onCheckedChange={(checked) => field.onChange(Boolean(checked))}\n                        />\n                      )}\n                    />\n                     <Label htmlFor={defaultId} className=\"text-sm\">\n                       {t.transformations.suggestDefault}\n                     </Label>\n                  </div>\n                </div>\n\n                <div>\n                   <Label htmlFor={descriptionId} className=\"text-sm font-medium\">\n                     {t.notebooks.addDescription.replace('...', '')}\n                   </Label>\n                  <Controller\n                    control={control}\n                    name=\"description\"\n                    render={({ field }) => (\n                      <Textarea\n                         id={descriptionId}\n                         {...field}\n                         placeholder={t.transformations.descriptionPlaceholder}\n                         rows={2}\n                         autoComplete=\"off\"\n                      />\n                    )}\n                  />\n                </div>\n              </div>\n\n              <div className=\"flex-1 overflow-y-auto px-6 py-4\">\n                <Label htmlFor={promptId} className=\"text-sm font-medium\">{t.transformations.systemPrompt}</Label>\n                <Controller\n                  control={control}\n                  name=\"prompt\"\n                  render={({ field }) => (\n                    <MarkdownEditor\n                      key={transformation?.id ?? 'new-transformation'}\n                      value={field.value}\n                      onChange={field.onChange}\n                      height={420}\n                      placeholder={t.transformations.promptPlaceholder}\n                      className=\"rounded-md border\"\n                      textareaId={promptId}\n                      name={field.name}\n                    />\n                  )}\n                />\n                {errors.prompt && (\n                  <p className=\"text-sm text-red-600 mt-1\">{errors.prompt.message}</p>\n                )}\n                 <p className=\"text-xs text-muted-foreground mt-3\">\n                   {t.transformations.promptHint}\n                 </p>\n              </div>\n            </>\n          )}\n\n          <div className=\"border-t px-6 py-4 flex justify-end gap-2\">\n             <Button type=\"button\" variant=\"outline\" onClick={handleClose}>\n               {t.common.cancel}\n             </Button>\n              <Button type=\"submit\" disabled={isSaving || (isEditing && isLoading)}>\n                {isSaving\n                  ? isEditing ? `${t.common.saving}...` : `${t.common.creating}...`\n                  : isEditing\n                    ? t.common.editTransformation\n                    : t.transformations.createNew}\n              </Button>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/components/TransformationPlayground.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Play, Loader2 } from 'lucide-react'\nimport { Transformation } from '@/lib/types/transformations'\nimport { useExecuteTransformation } from '@/lib/hooks/use-transformations'\nimport { ModelSelector } from '@/components/common/ModelSelector'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\n\ninterface TransformationPlaygroundProps {\n  transformations: Transformation[] | undefined\n  selectedTransformation?: Transformation\n}\n\nexport function TransformationPlayground({ transformations, selectedTransformation }: TransformationPlaygroundProps) {\n  const { t } = useTranslation()\n  const [selectedId, setSelectedId] = useState(selectedTransformation?.id || '')\n  const [inputText, setInputText] = useState('')\n  const [modelId, setModelId] = useState('')\n  const [output, setOutput] = useState('')\n  \n  const executeTransformation = useExecuteTransformation()\n\n  const handleExecute = async () => {\n    if (!selectedId || !modelId || !inputText.trim()) {\n      return\n    }\n\n    const result = await executeTransformation.mutateAsync({\n      transformation_id: selectedId,\n      input_text: inputText,\n      model_id: modelId\n    })\n\n    setOutput(result.output)\n  }\n\n  const canExecute = selectedId && modelId && inputText.trim() && !executeTransformation.isPending\n\n  return (\n    <div className=\"space-y-6\">\n      <Card>\n        <CardHeader>\n          <CardTitle>{t.transformations.playground}</CardTitle>\n          <CardDescription>\n            {t.transformations.desc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent className=\"space-y-6\">\n          <div className=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n            <div>\n              <Label htmlFor=\"transformation\">{t.navigation.transformation}</Label>\n              <Select name=\"transformation\" value={selectedId} onValueChange={setSelectedId}>\n                <SelectTrigger id=\"transformation\">\n                  <SelectValue placeholder={t.transformations.selectToStart} />\n                </SelectTrigger>\n                <SelectContent>\n                  {transformations?.map((transformation) => (\n                    <SelectItem key={transformation.id} value={transformation.id}>\n                      {transformation.name}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n\n            <div>\n              <ModelSelector\n                label={t.transformations.model}\n                name=\"model\"\n                modelType=\"language\"\n                value={modelId}\n                onChange={setModelId}\n                placeholder={t.transformations.selectModel}\n              />\n            </div>\n          </div>\n\n          <div>\n            <Label htmlFor=\"input\">{t.transformations.inputLabel}</Label>\n            <Textarea\n              id=\"input\"\n              name=\"input\"\n              value={inputText}\n              onChange={(e) => setInputText(e.target.value)}\n              placeholder={t.transformations.inputPlaceholder}\n              rows={8}\n              className=\"font-mono text-sm\"\n            />\n          </div>\n\n          <div className=\"flex justify-center\">\n            <Button \n              onClick={handleExecute}\n              disabled={!canExecute}\n              size=\"lg\"\n            >\n              {executeTransformation.isPending ? (\n                <>\n                  <Loader2 className=\"h-4 w-4 mr-2 animate-spin\" />\n                  {t.transformations.running}\n                </>\n              ) : (\n                <>\n                  <Play className=\"h-4 w-4 mr-2\" />\n                  {t.transformations.runTest}\n                </>\n              )}\n            </Button>\n          </div>\n\n          {output && (\n            <div className=\"space-y-2\">\n              <span className=\"text-sm font-medium leading-none\">{t.transformations.outputLabel}</span>\n              <Card>\n                <ScrollArea className=\"h-[400px]\">\n                  <CardContent className=\"pt-6\">\n                    <div className=\"prose prose-sm max-w-none dark:prose-invert\">\n                      <ReactMarkdown\n                        remarkPlugins={[remarkGfm]}\n                        components={{\n                          table: ({ children }) => (\n                            <div className=\"my-4 overflow-x-auto\">\n                              <table className=\"min-w-full border-collapse border border-border\">{children}</table>\n                            </div>\n                          ),\n                          thead: ({ children }) => <thead className=\"bg-muted\">{children}</thead>,\n                          tbody: ({ children }) => <tbody>{children}</tbody>,\n                          tr: ({ children }) => <tr className=\"border-b border-border\">{children}</tr>,\n                          th: ({ children }) => <th className=\"border border-border px-3 py-2 text-left font-semibold\">{children}</th>,\n                          td: ({ children }) => <td className=\"border border-border px-3 py-2\">{children}</td>,\n                        }}\n                      >\n                        {output}\n                      </ReactMarkdown>\n                    </div>\n                  </CardContent>\n                </ScrollArea>\n              </Card>\n            </div>\n          )}\n        </CardContent>\n      </Card>\n    </div>\n  )\n}"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/components/TransformationsList.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Plus } from 'lucide-react'\nimport { TransformationCard } from './TransformationCard'\nimport { EmptyState } from '@/components/common/EmptyState'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { Wand2 } from 'lucide-react'\nimport { Transformation } from '@/lib/types/transformations'\nimport { TransformationEditorDialog } from './TransformationEditorDialog'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface TransformationsListProps {\n  transformations: Transformation[] | undefined\n  isLoading: boolean\n  onPlayground?: (transformation: Transformation) => void\n}\n\nexport function TransformationsList({ transformations, isLoading, onPlayground }: TransformationsListProps) {\n  const { t } = useTranslation()\n  const [editorOpen, setEditorOpen] = useState(false)\n  const [editingTransformation, setEditingTransformation] = useState<Transformation | undefined>()\n\n  const handleOpenEditor = (trans?: Transformation) => {\n    setEditingTransformation(trans)\n    setEditorOpen(true)\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex items-center justify-center py-12\">\n        <LoadingSpinner size=\"lg\" />\n      </div>\n    )\n  }\n\n  if (!transformations || transformations.length === 0) {\n    return (\n      <EmptyState\n        icon={Wand2}\n        title={t.transformations.noTransformations}\n        description={t.transformations.createOne}\n        action={\n          <Button onClick={() => handleOpenEditor()}>\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {t.transformations.createNew}\n          </Button>\n        }\n      />\n    )\n  }\n\n  return (\n    <>\n      <div className=\"space-y-6\">\n        <div className=\"flex justify-between items-center\">\n          <h2 className=\"text-lg font-semibold\">{t.transformations.listTitle}</h2>\n          <Button onClick={() => handleOpenEditor()}>\n            <Plus className=\"h-4 w-4 mr-2\" />\n            {t.transformations.createNew}\n          </Button>\n        </div>\n\n        <div className=\"space-y-4\">\n          {transformations.map((transformation) => (\n            <TransformationCard\n              key={transformation.id}\n              transformation={transformation}\n              onPlayground={onPlayground ? () => onPlayground(transformation) : undefined}\n              onEdit={() => handleOpenEditor(transformation)}\n            />\n          ))}\n        </div>\n      </div>\n\n      <TransformationEditorDialog\n        open={editorOpen}\n        onOpenChange={(open) => {\n          setEditorOpen(open)\n          if (!open) {\n            setEditingTransformation(undefined)\n          }\n        }}\n        transformation={editingTransformation}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/(dashboard)/transformations/page.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { AppShell } from '@/components/layout/AppShell'\nimport { Button } from '@/components/ui/button'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { DefaultPromptEditor } from './components/DefaultPromptEditor'\nimport { TransformationsList } from './components/TransformationsList'\nimport { TransformationPlayground } from './components/TransformationPlayground'\nimport { useTransformations } from '@/lib/hooks/use-transformations'\nimport { Transformation } from '@/lib/types/transformations'\nimport { Wand2, Play, RefreshCw } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport default function TransformationsPage() {\n  const { t } = useTranslation()\n  const [activeTab, setActiveTab] = useState('transformations')\n  const [selectedTransformation, setSelectedTransformation] = useState<Transformation | undefined>()\n  const { data: transformations, isLoading, refetch } = useTransformations()\n\n  const handlePlayground = (transformation: Transformation) => {\n    setSelectedTransformation(transformation)\n    setActiveTab('playground')\n  }\n\n  return (\n    <AppShell>\n      <div className=\"flex-1 overflow-y-auto\">\n        <div className=\"p-6 space-y-6\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"flex items-center gap-4\">\n              <h1 className=\"text-2xl font-bold\">{t.transformations.title}</h1>\n              <Button variant=\"outline\" size=\"sm\" onClick={() => refetch()}>\n                <RefreshCw className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n\n        <div className=\"max-w-5xl\">\n          <p className=\"text-muted-foreground\">\n            {t.transformations.desc}\n          </p>\n        </div>\n\n        <Tabs value={activeTab} onValueChange={setActiveTab} className=\"space-y-6\">\n          <div className=\"space-y-2\">\n            <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">{t.transformations.workspace}</p>\n            <TabsList aria-label={t.common.accessibility.transformationViews} className=\"w-full max-w-xl\">\n              <TabsTrigger value=\"transformations\" className=\"flex items-center gap-2\">\n                <Wand2 className=\"h-4 w-4\" />\n                {t.transformations.title}\n              </TabsTrigger>\n              <TabsTrigger value=\"playground\" className=\"flex items-center gap-2\">\n                <Play className=\"h-4 w-4\" />\n                {t.transformations.playground}\n              </TabsTrigger>\n            </TabsList>\n          </div>\n          \n          <TabsContent value=\"transformations\" className=\"space-y-6\">\n            <DefaultPromptEditor />\n            <TransformationsList \n              transformations={transformations} \n              isLoading={isLoading}\n              onPlayground={handlePlayground}\n            />\n          </TabsContent>\n          \n          <TabsContent value=\"playground\">\n            <TransformationPlayground \n              transformations={transformations}\n              selectedTransformation={selectedTransformation}\n            />\n          </TabsContent>\n        </Tabs>\n        </div>\n      </div>\n    </AppShell>\n  )\n}\n"
  },
  {
    "path": "frontend/src/app/config/route.ts",
    "content": "import { NextRequest, NextResponse } from 'next/server'\n\n/**\n * Runtime Configuration Endpoint\n *\n * This endpoint provides server-side environment variables to the client at runtime.\n * This solves the NEXT_PUBLIC_* limitation where variables are baked into the build.\n *\n * Environment Variables:\n * - API_URL: Where the browser/client should make API requests (public/external URL)\n * - INTERNAL_API_URL: Where Next.js server-side should proxy API requests (internal URL)\n *   Default: http://localhost:5055 (used by Next.js rewrites in next.config.ts)\n *\n * Why two different variables?\n * - API_URL: Used by browser clients, can be https://your-domain.com or http://server-ip:5055\n * - INTERNAL_API_URL: Used by Next.js rewrites for server-side proxying, typically http://localhost:5055\n *\n * Auto-detection logic for API_URL:\n * 1. If API_URL env var is set, use it (explicit override)\n * 2. Otherwise, detect from incoming HTTP request headers (zero-config)\n * 3. Fallback to localhost:5055 if detection fails\n *\n * This allows the same Docker image to work in different deployment scenarios.\n */\nexport async function GET(request: NextRequest) {\n  // Priority 1: Check if API_URL is explicitly set\n  const envApiUrl = process.env.API_URL || process.env.NEXT_PUBLIC_API_URL\n\n  if (envApiUrl) {\n    return NextResponse.json({\n      apiUrl: envApiUrl,\n    })\n  }\n\n  // Priority 2: Auto-detect from request headers\n  try {\n    // Get the protocol (http or https)\n    // Check X-Forwarded-Proto first (for reverse proxies), then fallback to request scheme\n    const proto = request.headers.get('x-forwarded-proto') ||\n                  request.nextUrl.protocol.replace(':', '') ||\n                  'http'\n\n    // Get the host header (includes port if non-standard)\n    const hostHeader = request.headers.get('host')\n\n    if (hostHeader) {\n      // Extract just the hostname (remove port if present)\n      const hostname = hostHeader.split(':')[0]\n\n      // Construct the API URL with port 5055\n      const apiUrl = `${proto}://${hostname}:5055`\n\n      console.log(`[runtime-config] Auto-detected API URL: ${apiUrl} (proto=${proto}, host=${hostHeader})`)\n\n      return NextResponse.json({\n        apiUrl,\n      })\n    }\n  } catch (error) {\n    console.error('[runtime-config] Auto-detection failed:', error)\n  }\n\n  // Priority 3: Fallback to localhost\n  console.log('[runtime-config] Using fallback: http://localhost:5055')\n  return NextResponse.json({\n    apiUrl: 'http://localhost:5055',\n  })\n}\n"
  },
  {
    "path": "frontend/src/app/globals.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@theme inline {\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --font-sans: var(--font-geist-sans);\n  --font-mono: var(--font-geist-mono);\n  --color-sidebar-ring: var(--sidebar-ring);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar: var(--sidebar);\n  --color-chart-5: var(--chart-5);\n  --color-chart-4: var(--chart-4);\n  --color-chart-3: var(--chart-3);\n  --color-chart-2: var(--chart-2);\n  --color-chart-1: var(--chart-1);\n  --color-ring: var(--ring);\n  --color-input: var(--input);\n  --color-border: var(--border);\n  --color-destructive: var(--destructive);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-accent: var(--accent);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-muted: var(--muted);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-secondary: var(--secondary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-primary: var(--primary);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-popover: var(--popover);\n  --color-card-foreground: var(--card-foreground);\n  --color-card: var(--card);\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n}\n\n:root {\n  --radius: 0.65rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.141 0.005 285.823);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.141 0.005 285.823);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.141 0.005 285.823);\n  --primary: oklch(0.623 0.214 259.815);\n  --primary-foreground: oklch(0.97 0.014 254.604);\n  --secondary: oklch(0.967 0.001 286.375);\n  --secondary-foreground: oklch(0.21 0.006 285.885);\n  --muted: oklch(0.967 0.001 286.375);\n  --muted-foreground: oklch(0.552 0.016 285.938);\n  --accent: oklch(0.967 0.001 286.375);\n  --accent-foreground: oklch(0.21 0.006 285.885);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.92 0.004 286.32);\n  --input: oklch(0.92 0.004 286.32);\n  --ring: oklch(0.623 0.214 259.815);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.141 0.005 285.823);\n  --sidebar-primary: oklch(0.623 0.214 259.815);\n  --sidebar-primary-foreground: oklch(0.97 0.014 254.604);\n  --sidebar-accent: oklch(0.92 0.01 286.375);\n  --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n  --sidebar-border: oklch(0.92 0.004 286.32);\n  --sidebar-ring: oklch(0.623 0.214 259.815);\n}\n\n.dark {\n  --background: oklch(0.141 0.005 285.823);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.21 0.006 285.885);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.21 0.006 285.885);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.546 0.245 262.881);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.274 0.006 286.033);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.274 0.006 286.033);\n  --muted-foreground: oklch(0.705 0.015 286.067);\n  --accent: oklch(0.274 0.006 286.033);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.488 0.243 264.376);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.21 0.006 285.885);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.546 0.245 262.881);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.35 0.01 286.033);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.488 0.243 264.376);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n\n  html {\n    @apply antialiased;\n  }\n\n  body {\n    @apply bg-background text-foreground transition-colors;\n  }\n\n  /* Ensure proper theme inheritance for popovers and dropdowns */\n  .dark {\n    color-scheme: dark;\n  }\n\n  :root {\n    color-scheme: light;\n  }\n\n  /* Ensure Radix UI components inherit theme properly */\n  [data-radix-popper-content-wrapper] {\n    @apply z-50;\n  }\n\n  /* Force theme inheritance for portaled content */\n  .dark [data-radix-popper-content-wrapper],\n  .dark [data-overlay-container] {\n    color-scheme: dark;\n  }\n\n  /* Ensure sidebar gets proper theme */\n  .app-sidebar {\n    background-color: var(--sidebar);\n    color: var(--sidebar-foreground);\n    border-color: var(--sidebar-border);\n  }\n\n  /* Enhanced sidebar menu item hover effects */\n  .sidebar-menu-item {\n    @apply transition-all duration-200 ease-out;\n  }\n\n  .sidebar-menu-item:hover {\n    @apply scale-[1.02];\n    background-color: var(--sidebar-accent) !important;\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);\n  }\n\n  .dark .sidebar-menu-item:hover {\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);\n  }\n\n  /* Enhanced hover effects for cards */\n  .card-hover {\n    @apply transition-all duration-200 cursor-pointer;\n  }\n\n  .card-hover:hover {\n    background-color: var(--muted) !important;\n    border-color: var(--border);\n    transform: translateY(-1px);\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n  }\n\n  .dark .card-hover:hover {\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n  }\n\n  /* Ensure clickable cards show pointer cursor */\n  .clickable-card {\n    cursor: pointer !important;\n  }\n\n  .clickable-card * {\n    cursor: pointer !important;\n  }\n}\n"
  },
  {
    "path": "frontend/src/app/layout.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { Inter } from \"next/font/google\";\nimport \"./globals.css\";\nimport { Toaster } from \"@/components/ui/sonner\";\nimport { QueryProvider } from \"@/components/providers/QueryProvider\";\nimport { ThemeProvider } from \"@/components/providers/ThemeProvider\";\nimport { ErrorBoundary } from \"@/components/common/ErrorBoundary\";\nimport { ConnectionGuard } from \"@/components/common/ConnectionGuard\";\nimport { themeScript } from \"@/lib/theme-script\";\nimport { I18nProvider } from \"@/components/providers/I18nProvider\";\n\nconst inter = Inter({ subsets: [\"latin\"] });\n\nexport const metadata: Metadata = {\n  title: \"Open Notebook\",\n  description: \"Privacy-focused research and knowledge management\",\n};\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"en\" suppressHydrationWarning>\n      <head>\n        <script dangerouslySetInnerHTML={{ __html: themeScript }} />\n      </head>\n      <body className={inter.className}>\n        <ErrorBoundary>\n          <ThemeProvider>\n            <QueryProvider>\n              <I18nProvider>\n                <ConnectionGuard>\n                  {children}\n                  <Toaster />\n                </ConnectionGuard>\n              </I18nProvider>\n            </QueryProvider>\n          </ThemeProvider>\n        </ErrorBoundary>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "frontend/src/app/page.tsx",
    "content": "import { redirect } from 'next/navigation'\n\nexport default function HomePage() {\n  redirect('/notebooks')\n}\n"
  },
  {
    "path": "frontend/src/components/auth/LoginForm.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useAuth } from '@/lib/hooks/use-auth'\nimport { useAuthStore } from '@/lib/stores/auth-store'\nimport { getConfig } from '@/lib/config'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { AlertCircle } from 'lucide-react'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport function LoginForm() {\n  const { t, language } = useTranslation()\n  const [password, setPassword] = useState('')\n  const { login, isLoading, error } = useAuth()\n  const { authRequired, checkAuthRequired, hasHydrated, isAuthenticated } = useAuthStore()\n  const [isCheckingAuth, setIsCheckingAuth] = useState(true)\n  const [configInfo, setConfigInfo] = useState<{ apiUrl: string; version: string; buildTime: string } | null>(null)\n  const router = useRouter()\n\n  // Load config info for debugging\n  useEffect(() => {\n    getConfig().then(cfg => {\n      setConfigInfo({\n        apiUrl: cfg.apiUrl,\n        version: cfg.version,\n        buildTime: cfg.buildTime,\n      })\n    }).catch(err => {\n      console.error('Failed to load config:', err)\n    })\n  }, [])\n\n  // Check if authentication is required on mount\n  useEffect(() => {\n    if (!hasHydrated) {\n      return\n    }\n\n    const checkAuth = async () => {\n      try {\n        const required = await checkAuthRequired()\n\n        // If auth is not required, redirect to notebooks\n        if (!required) {\n          router.push('/notebooks')\n        }\n      } catch (error) {\n        console.error('Error checking auth requirement:', error)\n        // On error, assume auth is required to be safe\n      } finally {\n        setIsCheckingAuth(false)\n      }\n    }\n\n    // If we already know auth status, use it\n    if (authRequired !== null) {\n      if (!authRequired && isAuthenticated) {\n        router.push('/notebooks')\n      } else {\n        setIsCheckingAuth(false)\n      }\n    } else {\n      void checkAuth()\n    }\n  }, [hasHydrated, authRequired, checkAuthRequired, router, isAuthenticated])\n\n  // Show loading while checking if auth is required\n  if (!hasHydrated || isCheckingAuth) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background\">\n        <LoadingSpinner />\n      </div>\n    )\n  }\n\n  // If we still don't know if auth is required (connection error), show error\n  if (authRequired === null) {\n    return (\n      <div className=\"min-h-screen flex items-center justify-center bg-background p-4\">\n        <Card className=\"w-full max-w-md\">\n          <CardHeader className=\"text-center\">\n            <CardTitle>{t.common.connectionError}</CardTitle>\n            <CardDescription>\n              {t.common.unableToConnect}\n            </CardDescription>\n          </CardHeader>\n          <CardContent>\n            <div className=\"space-y-4\">\n              <div className=\"flex items-start gap-2 text-red-600 text-sm\">\n                <AlertCircle className=\"h-4 w-4 mt-0.5 flex-shrink-0\" />\n                <div className=\"flex-1\">\n                  {error || t.auth.connectErrorHint}\n                </div>\n              </div>\n\n              {configInfo && (\n                <div className=\"space-y-2 text-xs text-muted-foreground border-t pt-3\">\n                  <div className=\"font-medium\">{t.common.diagnosticInfo}:</div>\n                  <div className=\"space-y-1 font-mono\">\n                    <div>{t.common.version}: {configInfo.version}</div>\n                    <div>{t.common.built}: {new Date(configInfo.buildTime).toLocaleString(language === 'zh-CN' ? 'zh-CN' : language === 'zh-TW' ? 'zh-TW' : 'en-US')}</div>\n                    <div className=\"break-all\">{t.common.apiUrl}: {configInfo.apiUrl}</div>\n                    <div className=\"break-all\">{t.common.frontendUrl}: {typeof window !== 'undefined' ? window.location.href : 'N/A'}</div>\n                  </div>\n                  <div className=\"text-xs pt-2\">\n                    {t.common.checkConsoleLogs}\n                  </div>\n                </div>\n              )}\n\n              <Button\n                onClick={() => window.location.reload()}\n                className=\"w-full\"\n              >\n                {t.common.retryConnection}\n              </Button>\n            </div>\n          </CardContent>\n        </Card>\n      </div>\n    )\n  }\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    if (password.trim()) {\n      try {\n        await login(password)\n      } catch (error) {\n        console.error('Unhandled error during login:', error)\n        // The auth store should handle most errors, but this catches any unhandled ones\n      }\n    }\n  }\n\n  return (\n    <div className=\"min-h-screen flex items-center justify-center bg-background p-4\">\n      <Card className=\"w-full max-w-md\">\n        <CardHeader className=\"text-center\">\n          <CardTitle>{t.auth.loginTitle}</CardTitle>\n          <CardDescription>\n            {t.auth.loginDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          <form onSubmit={handleSubmit} className=\"space-y-4\">\n            <div>\n              <Input\n                type=\"password\"\n                placeholder={t.auth.passwordPlaceholder}\n                value={password}\n                onChange={(e) => setPassword(e.target.value)}\n                disabled={isLoading}\n              />\n            </div>\n\n            {error && (\n              <div className=\"flex items-center gap-2 text-red-600 text-sm\">\n                <AlertCircle className=\"h-4 w-4\" />\n                {error}\n              </div>\n            )}\n\n            <Button\n              type=\"submit\"\n              className=\"w-full\"\n              disabled={isLoading || !password.trim()}\n            >\n              {isLoading ? t.auth.signingIn : t.auth.signIn}\n            </Button>\n\n            {configInfo && (\n              <div className=\"text-xs text-center text-muted-foreground pt-2 border-t\">\n                <div>{t.common.version} {configInfo.version}</div>\n                <div className=\"font-mono text-[10px]\">{configInfo.apiUrl}</div>\n              </div>\n            )}\n          </form>\n        </CardContent>\n      </Card>\n    </div>\n  )\n}"
  },
  {
    "path": "frontend/src/components/common/CommandPalette.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useMemo, useId } from 'react'\nimport { useRouter } from 'next/navigation'\nimport { useCreateDialogs } from '@/lib/hooks/use-create-dialogs'\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { useTheme } from '@/lib/stores/theme-store'\nimport {\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandGroup,\n  CommandItem,\n  CommandSeparator,\n} from '@/components/ui/command'\nimport {\n  Book,\n  Search,\n  Mic,\n  Bot,\n  Shuffle,\n  Settings,\n  FileText,\n  Wrench,\n  MessageCircleQuestion,\n  Plus,\n  Sun,\n  Moon,\n  Monitor,\n  Loader2,\n} from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { TranslationKeys } from '@/lib/locales'\n\nconst getNavigationItems = (t: TranslationKeys) => [\n  { name: t.navigation.sources, href: '/sources', icon: FileText, keywords: ['files', 'documents', 'upload'] },\n  { name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },\n  { name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] },\n  { name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },\n  { name: t.navigation.models, href: '/settings/api-keys', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },\n  { name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },\n  { name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },\n  { name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },\n]\n\nconst getCreateItems = (t: TranslationKeys) => [\n  { name: t.common.newSource, action: 'source', icon: FileText },\n  { name: t.common.newNotebook, action: 'notebook', icon: Book },\n  { name: t.common.newPodcast, action: 'podcast', icon: Mic },\n]\n\nconst getThemeItems = (t: TranslationKeys) => [\n  { name: t.common.light, value: 'light' as const, icon: Sun, keywords: ['bright', 'day'] },\n  { name: t.common.dark, value: 'dark' as const, icon: Moon, keywords: ['night'] },\n  { name: t.common.system, value: 'system' as const, icon: Monitor, keywords: ['auto', 'default'] },\n]\n\nexport function CommandPalette() {\n  const { t } = useTranslation()\n  const commandInputId = useId()\n  const navigationItems = useMemo(() => getNavigationItems(t), [t])\n  const createItems = useMemo(() => getCreateItems(t), [t])\n  const themeItems = useMemo(() => getThemeItems(t), [t])\n  \n  const [open, setOpen] = useState(false)\n  const [query, setQuery] = useState('')\n  const router = useRouter()\n  const { openSourceDialog, openNotebookDialog, openPodcastDialog } = useCreateDialogs()\n  const { setTheme } = useTheme()\n  const { data: notebooks, isLoading: notebooksLoading } = useNotebooks(false)\n\n  // Global keyboard listener for ⌘K / Ctrl+K\n  useEffect(() => {\n    const down = (e: KeyboardEvent) => {\n      // Skip if focus is inside editable elements\n      const target = e.target as HTMLElement | null\n      if (\n        target &&\n        (target.isContentEditable ||\n          ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))\n      ) {\n        return\n      }\n\n      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {\n        e.preventDefault()\n        e.stopPropagation()\n        setOpen((open) => !open)\n      }\n    }\n\n    // Use capture phase to intercept before other handlers\n    document.addEventListener('keydown', down, true)\n    return () => document.removeEventListener('keydown', down, true)\n  }, [])\n\n  // Reset query when dialog closes\n  useEffect(() => {\n    if (!open) {\n      setQuery('')\n    }\n  }, [open])\n\n  const handleSelect = useCallback((callback: () => void) => {\n    setOpen(false)\n    setQuery('')\n    // Use setTimeout to ensure dialog closes before action\n    setTimeout(callback, 0)\n  }, [])\n\n  const handleNavigate = useCallback((href: string) => {\n    handleSelect(() => router.push(href))\n  }, [handleSelect, router])\n\n  const handleSearch = useCallback(() => {\n    if (!query.trim()) return\n    handleSelect(() => router.push(`/search?q=${encodeURIComponent(query)}&mode=search`))\n  }, [handleSelect, router, query])\n\n  const handleAsk = useCallback(() => {\n    if (!query.trim()) return\n    handleSelect(() => router.push(`/search?q=${encodeURIComponent(query)}&mode=ask`))\n  }, [handleSelect, router, query])\n\n  const handleCreate = useCallback((action: string) => {\n    handleSelect(() => {\n      if (action === 'source') openSourceDialog()\n      else if (action === 'notebook') openNotebookDialog()\n      else if (action === 'podcast') openPodcastDialog()\n    })\n  }, [handleSelect, openSourceDialog, openNotebookDialog, openPodcastDialog])\n\n  const handleTheme = useCallback((theme: 'light' | 'dark' | 'system') => {\n    handleSelect(() => setTheme(theme))\n  }, [handleSelect, setTheme])\n\n  // Check if query matches any command (navigation, create, theme, or notebook)\n  const queryLower = query.toLowerCase().trim()\n  const hasCommandMatch = useMemo(() => {\n    if (!queryLower) return false\n    return (\n      navigationItems.some(item =>\n        item.name.toLowerCase().includes(queryLower) ||\n        item.keywords.some(k => k.includes(queryLower))\n      ) ||\n      createItems.some(item =>\n        item.name.toLowerCase().includes(queryLower)\n      ) ||\n      themeItems.some(item =>\n        item.name.toLowerCase().includes(queryLower) ||\n        item.keywords.some(k => k.includes(queryLower))\n      ) ||\n      (notebooks?.some(nb =>\n        nb.name.toLowerCase().includes(queryLower) ||\n        (nb.description && nb.description.toLowerCase().includes(queryLower))\n      ) ?? false)\n    )\n  }, [queryLower, notebooks, navigationItems, createItems, themeItems])\n\n  // Determine if we should show the Search/Ask section at the top\n  const showSearchFirst = query.trim() && !hasCommandMatch\n\n  return (\n    <CommandDialog\n      open={open}\n      onOpenChange={setOpen}\n      title={t.common.quickActions}\n      description={t.common.quickActionsDesc}\n      className=\"sm:max-w-lg\"\n    >\n      <CommandInput\n        id={commandInputId}\n        name=\"command-search\"\n        placeholder={t.searchPage.enterSearchPlaceholder}\n        value={query}\n        onValueChange={setQuery}\n        aria-label={t.common.search}\n        autoComplete=\"off\"\n      />\n      <CommandList>\n        {/* Search/Ask - show FIRST when there's a query with no command match */}\n        {showSearchFirst && (\n          <CommandGroup heading={t.searchPage.searchAndAsk} forceMount>\n            <CommandItem\n              value={`__search__ ${query}`}\n              onSelect={handleSearch}\n              forceMount\n            >\n              <Search className=\"h-4 w-4\" />\n              <span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>\n            </CommandItem>\n            <CommandItem\n              value={`__ask__ ${query}`}\n              onSelect={handleAsk}\n              forceMount\n            >\n              <MessageCircleQuestion className=\"h-4 w-4\" />\n              <span>{t.searchPage.askAbout.replace('{query}', query)}</span>\n            </CommandItem>\n          </CommandGroup>\n        )}\n\n        {/* Navigation */}\n        <CommandGroup heading={t.navigation.nav}>\n          {navigationItems.map((item) => (\n            <CommandItem\n              key={item.href}\n              value={`${item.name} ${item.keywords.join(' ')}`}\n              onSelect={() => handleNavigate(item.href)}\n            >\n              <item.icon className=\"h-4 w-4\" />\n              <span>{item.name}</span>\n            </CommandItem>\n          ))}\n        </CommandGroup>\n\n        {/* Notebooks */}\n        <CommandGroup heading={t.notebooks.title}>\n          {notebooksLoading ? (\n            <CommandItem disabled>\n              <Loader2 className=\"h-4 w-4 animate-spin\" />\n              <span>{t.common.loading}</span>\n            </CommandItem>\n          ) : notebooks && notebooks.length > 0 ? (\n            notebooks.map((notebook) => (\n              <CommandItem\n                key={notebook.id}\n                value={`notebook ${notebook.name} ${notebook.description || ''}`}\n                onSelect={() => handleNavigate(`/notebooks/${notebook.id}`)}\n              >\n                <Book className=\"h-4 w-4\" />\n                <span>{notebook.name}</span>\n              </CommandItem>\n            ))\n          ) : null}\n        </CommandGroup>\n\n        {/* Create */}\n        <CommandGroup heading={t.navigation.create}>\n          {createItems.map((item) => (\n            <CommandItem\n              key={item.action}\n              value={`create ${item.name}`}\n              onSelect={() => handleCreate(item.action)}\n            >\n              <Plus className=\"h-4 w-4\" />\n              <span>{item.name}</span>\n            </CommandItem>\n          ))}\n        </CommandGroup>\n\n        {/* Theme */}\n        <CommandGroup heading={t.navigation.theme}>\n          {themeItems.map((item) => (\n            <CommandItem\n              key={item.value}\n              value={`theme ${item.name} ${item.keywords.join(' ')}`}\n              onSelect={() => handleTheme(item.value)}\n            >\n              <item.icon className=\"h-4 w-4\" />\n              <span>{item.name}</span>\n            </CommandItem>\n          ))}\n        </CommandGroup>\n\n        {/* Search/Ask - show at bottom when there IS a command match */}\n        {query.trim() && hasCommandMatch && (\n          <>\n            <CommandSeparator />\n            <CommandGroup heading={t.searchPage.orSearchKb} forceMount>\n              <CommandItem\n                value={`__search__ ${query}`}\n                onSelect={handleSearch}\n                forceMount\n              >\n                <Search className=\"h-4 w-4\" />\n                <span>{t.searchPage.searchResultsFor.replace('{query}', query)}</span>\n              </CommandItem>\n              <CommandItem\n                value={`__ask__ ${query}`}\n                onSelect={handleAsk}\n                forceMount\n              >\n                <MessageCircleQuestion className=\"h-4 w-4\" />\n                <span>{t.searchPage.askAbout.replace('{query}', query)}</span>\n              </CommandItem>\n            </CommandGroup>\n          </>\n        )}\n      </CommandList>\n    </CommandDialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/ConfirmDialog.test.tsx",
    "content": "import { describe, it, expect, vi } from 'vitest'\nimport { render, screen, fireEvent } from '@testing-library/react'\nimport { ConfirmDialog } from './ConfirmDialog'\n\n// useTranslation is mocked globally in setup.ts\n\ndescribe('ConfirmDialog', () => {\n  const onConfirmMock = vi.fn()\n  const onOpenChangeMock = vi.fn()\n\n  const defaultProps = {\n    open: true,\n    onOpenChange: onOpenChangeMock,\n    title: 'Test Title',\n    description: 'Test Description',\n    onConfirm: onConfirmMock,\n  }\n\n  it('should render correct titles and descriptions', () => {\n    render(<ConfirmDialog {...defaultProps} />)\n    \n    expect(screen.getByText('Test Title')).toBeInTheDocument()\n    expect(screen.getByText('Test Description')).toBeInTheDocument()\n    // Localized text from our setup.ts mock should be visible\n    expect(screen.getByText('Confirm')).toBeInTheDocument()\n    expect(screen.getByText('Cancel')).toBeInTheDocument()\n  })\n\n  it('should call onConfirm when confirm button is clicked', () => {\n    render(<ConfirmDialog {...defaultProps} />)\n    \n    const confirmBtn = screen.getByText('Confirm')\n    fireEvent.click(confirmBtn)\n    \n    expect(onConfirmMock).toHaveBeenCalledTimes(1)\n  })\n\n  it('should show custom confirm text if provided', () => {\n    render(<ConfirmDialog {...defaultProps} confirmText=\"Delete Now\" />)\n    expect(screen.getByText('Delete Now')).toBeInTheDocument()\n  })\n\n  it('should show loading state and disable buttons', () => {\n    render(<ConfirmDialog {...defaultProps} isLoading={true} />)\n    \n    const confirmBtn = screen.getByText('Confirm').closest('button')\n    const cancelBtn = screen.getByText('Cancel').closest('button')\n    \n    expect(confirmBtn).toBeDisabled()\n    expect(cancelBtn).toBeDisabled()\n  })\n})\n"
  },
  {
    "path": "frontend/src/components/common/ConfirmDialog.tsx",
    "content": "'use client'\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\n\ninterface ConfirmDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  title: string\n  description: string\n  confirmText?: string\n  confirmVariant?: 'default' | 'destructive'\n  onConfirm: () => void\n  isLoading?: boolean\n}\n\nexport function ConfirmDialog({\n  open,\n  onOpenChange,\n  title,\n  description,\n  confirmText,\n  confirmVariant = 'default',\n  onConfirm,\n  isLoading = false,\n}: ConfirmDialogProps) {\n  const { t } = useTranslation()\n  const finalConfirmText = confirmText || t.common.confirm\n\n  return (\n    <AlertDialog open={open} onOpenChange={onOpenChange}>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{title}</AlertDialogTitle>\n          <AlertDialogDescription>{description}</AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel disabled={isLoading}>{t.common.cancel}</AlertDialogCancel>\n          <AlertDialogAction\n            onClick={onConfirm}\n            disabled={isLoading}\n            className={confirmVariant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}\n          >\n            {isLoading ? (\n              <>\n                <LoadingSpinner size=\"sm\" className=\"mr-2\" />\n                {finalConfirmText}\n              </>\n            ) : (\n              finalConfirmText\n            )}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}"
  },
  {
    "path": "frontend/src/components/common/ConnectionGuard.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useRef } from 'react'\nimport { ConnectionError } from '@/lib/types/config'\nimport { ConnectionErrorOverlay } from '@/components/errors/ConnectionErrorOverlay'\nimport { getConfig, resetConfig } from '@/lib/config'\n\ninterface ConnectionGuardProps {\n  children: React.ReactNode\n}\n\nexport function ConnectionGuard({ children }: ConnectionGuardProps) {\n  const [error, setError] = useState<ConnectionError | null>(null)\n  const [isChecking, setIsChecking] = useState(true)\n  // Use a ref to track checking status to avoid dependency cycles\n  const isCheckingRef = useRef(false)\n\n  const checkConnection = useCallback(async () => {\n    // Prevent re-entry if already checking\n    if (isCheckingRef.current) {\n       return\n    }\n    \n    isCheckingRef.current = true\n    setIsChecking(true)\n    \n    setError(null)\n\n    // Reset config cache to force a fresh fetch\n    resetConfig()\n\n    try {\n      const config = await getConfig()\n\n      // Check if database is offline\n      if (config.dbStatus === 'offline') {\n        const dbError: ConnectionError = {\n          type: 'database-offline',\n          details: {\n            message: 'Database is offline', // Fallback message, UI will translate\n            attemptedUrl: config.apiUrl,\n          },\n        }\n        setError(dbError)\n        isCheckingRef.current = false\n        setIsChecking(false)\n        return\n      }\n\n      // If we got here, connection is good\n      setError(null)\n      isCheckingRef.current = false\n      setIsChecking(false)\n    } catch (err) {\n      // API is unreachable\n      const errorMessage = err instanceof Error ? err.message : 'Unknown error'\n      const attemptedUrl =\n        typeof window !== 'undefined'\n          ? `${window.location.origin}/api/config`\n          : undefined\n\n      const apiError: ConnectionError = {\n        type: 'api-unreachable',\n        details: {\n          message: 'Unable to connect to API', // Fallback message\n          technicalMessage: errorMessage,\n          stack: err instanceof Error ? err.stack : undefined,\n          attemptedUrl,\n        },\n      }\n      \n      setError(apiError)\n      isCheckingRef.current = false\n      setIsChecking(false)\n    }\n  }, []) // Empty dependency array - stable callback\n\n  // Check connection on mount\n  useEffect(() => {\n    checkConnection()\n  }, [checkConnection])\n\n  // Add keyboard shortcut for retry (R key)\n  useEffect(() => {\n    const handleKeyPress = (e: KeyboardEvent) => {\n      if (error && (e.key === 'r' || e.key === 'R')) {\n        e.preventDefault()\n        checkConnection()\n      }\n    }\n\n    window.addEventListener('keydown', handleKeyPress)\n    return () => window.removeEventListener('keydown', handleKeyPress)\n  }, [error, checkConnection])\n\n  // Show overlay if there's an error\n  if (error) {\n    return <ConnectionErrorOverlay error={error} onRetry={checkConnection} />\n  }\n\n  // Show nothing while checking (prevents flash of content)\n  if (isChecking) {\n    return null\n  }\n\n  // Render children if connection is good\n  return <>{children}</>\n}\n"
  },
  {
    "path": "frontend/src/components/common/ContextIndicator.tsx",
    "content": "'use client'\n\nimport { FileText, Lightbulb, StickyNote } from 'lucide-react'\nimport { Badge } from '@/components/ui/badge'\nimport { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\n\ninterface ContextIndicatorProps {\n  sourcesInsights: number\n  sourcesFull: number\n  notesCount: number\n  tokenCount?: number\n  charCount?: number\n  className?: string\n}\n\n// Helper function to format large numbers with K/M suffixes\nfunction formatNumber(num: number): string {\n  if (num >= 1000000) {\n    return `${(num / 1000000).toFixed(1)}M`\n  }\n  if (num >= 1000) {\n    return `${(num / 1000).toFixed(1)}K`\n  }\n  return num.toString()\n}\n\nexport function ContextIndicator({\n  sourcesInsights,\n  sourcesFull,\n  notesCount,\n  tokenCount,\n  charCount,\n  className\n}: ContextIndicatorProps) {\n  const hasContext = (sourcesInsights + sourcesFull) > 0 || notesCount > 0\n\n  if (!hasContext) {\n    return (\n      <div className={cn('flex-shrink-0 text-xs text-muted-foreground py-2 px-3 border-t', className)}>\n        No sources or notes included in context. Toggle icons on cards to include them.\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn('flex-shrink-0 flex items-center justify-between gap-2 py-2 px-3 border-t bg-muted/30', className)}>\n      <div className=\"flex items-center gap-2\">\n        <span className=\"text-xs font-medium text-muted-foreground\">Context:</span>\n\n        <div className=\"flex items-center gap-1.5\">\n          {sourcesInsights > 0 && (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Badge variant=\"outline\" className=\"text-xs flex items-center gap-1 px-1.5 py-0.5 text-amber-600 border-amber-600/50 cursor-default\">\n                  <Lightbulb className=\"h-3 w-3\" />\n                  <span>{sourcesInsights}</span>\n                </Badge>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>Insights for {sourcesInsights} source{sourcesInsights !== 1 ? 's' : ''}</p>\n              </TooltipContent>\n            </Tooltip>\n          )}\n\n          {sourcesFull > 0 && (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Badge variant=\"outline\" className=\"text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50 cursor-default\">\n                  <FileText className=\"h-3 w-3\" />\n                  <span>{sourcesFull}</span>\n                </Badge>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{sourcesFull} full source{sourcesFull !== 1 ? 's' : ''}</p>\n              </TooltipContent>\n            </Tooltip>\n          )}\n        </div>\n\n        {notesCount > 0 && (\n          <>\n            {(sourcesInsights > 0 || sourcesFull > 0) && (\n              <span className=\"text-muted-foreground\">•</span>\n            )}\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Badge variant=\"outline\" className=\"text-xs flex items-center gap-1 px-1.5 py-0.5 text-primary border-primary/50 cursor-default\">\n                  <StickyNote className=\"h-3 w-3\" />\n                  <span>{notesCount}</span>\n                </Badge>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{notesCount} full note{notesCount !== 1 ? 's' : ''}</p>\n              </TooltipContent>\n            </Tooltip>\n          </>\n        )}\n      </div>\n\n      {(tokenCount !== undefined || charCount !== undefined) && (\n        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n          {tokenCount !== undefined && tokenCount > 0 && (\n            <span>{formatNumber(tokenCount)} tokens</span>\n          )}\n          {tokenCount !== undefined && charCount !== undefined && tokenCount > 0 && charCount > 0 && (\n            <span>/</span>\n          )}\n          {charCount !== undefined && charCount > 0 && (\n            <span>{formatNumber(charCount)} chars</span>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/ContextToggle.tsx",
    "content": "'use client'\n\nimport { EyeOff, Lightbulb, FileText } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@/components/ui/tooltip'\nimport { cn } from '@/lib/utils'\nimport { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface ContextToggleProps {\n  mode: ContextMode\n  hasInsights?: boolean // For sources - determines if 'insights' mode is available\n  onChange: (mode: ContextMode) => void\n  className?: string\n}\n\nexport function ContextToggle({ mode, hasInsights = false, onChange, className }: ContextToggleProps) {\n  const { t } = useTranslation()\n\n  const MODE_CONFIG = {\n    off: {\n      icon: EyeOff,\n      label: t.common.contextModes.off,\n      color: 'text-muted-foreground',\n      bgColor: 'hover:bg-muted'\n    },\n    insights: {\n      icon: Lightbulb,\n      label: t.common.contextModes.insights,\n      color: 'text-amber-600',\n      bgColor: 'hover:bg-amber-50'\n    },\n    full: {\n      icon: FileText,\n      label: t.common.contextModes.full,\n      color: 'text-primary',\n      bgColor: 'hover:bg-primary/10'\n    }\n  } as const\n  const config = MODE_CONFIG[mode]\n  const Icon = config.icon\n\n  // Determine available modes based on whether item has insights\n  const availableModes: ContextMode[] = hasInsights\n    ? ['off', 'insights', 'full']\n    : ['off', 'full']\n\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation() // Prevent card click\n\n    // Cycle to next mode\n    const currentIndex = availableModes.indexOf(mode)\n    const nextIndex = (currentIndex + 1) % availableModes.length\n    onChange(availableModes[nextIndex])\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className={cn(\n              'h-8 w-8 p-0 transition-colors',\n              config.bgColor,\n              className\n            )}\n            onClick={handleClick}\n          >\n            <Icon className={cn('h-4 w-4', config.color)} />\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p className=\"text-xs\">{config.label}</p>\n          <p className=\"text-[10px] text-muted-foreground mt-1\">\n            {t.common.contextModes.clickToCycle}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/EmptyState.tsx",
    "content": "import { LucideIcon } from 'lucide-react'\n\ninterface EmptyStateProps {\n  icon: LucideIcon\n  title: string\n  description: string\n  action?: React.ReactNode\n}\n\nexport function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {\n  return (\n    <div className=\"text-center py-12\">\n      <Icon className=\"h-12 w-12 mx-auto text-muted-foreground/60 mb-4\" />\n      <h3 className=\"text-lg font-medium text-foreground mb-2\">{title}</h3>\n      <p className=\"text-muted-foreground mb-4\">{description}</p>\n      {action}\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/ErrorBoundary.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { AlertTriangle, RefreshCw } from 'lucide-react'\nimport { enUS } from '@/lib/locales/en-US'\n\n// Use English as fallback for ErrorBoundary (class component cannot use hooks)\nconst t = enUS\n\ninterface ErrorBoundaryState {\n  hasError: boolean\n  error?: Error\n  errorInfo?: React.ErrorInfo\n}\n\ninterface ErrorBoundaryProps {\n  children: React.ReactNode\n  fallback?: React.ComponentType<{error?: Error; resetError: () => void}>\n}\n\nexport class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {\n  constructor(props: ErrorBoundaryProps) {\n    super(props)\n    this.state = { hasError: false }\n  }\n\n  static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n    return {\n      hasError: true,\n      error\n    }\n  }\n\n  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n    console.error('Error caught by boundary:', error, errorInfo)\n    this.setState({\n      error,\n      errorInfo\n    })\n  }\n\n  resetError = () => {\n    this.setState({ hasError: false, error: undefined, errorInfo: undefined })\n  }\n\n  render() {\n    if (this.state.hasError) {\n      if (this.props.fallback) {\n        const FallbackComponent = this.props.fallback\n        return <FallbackComponent error={this.state.error} resetError={this.resetError} />\n      }\n\n      return (\n        <div className=\"min-h-screen flex items-center justify-center bg-background p-4\">\n          <Card className=\"w-full max-w-md\">\n            <CardHeader className=\"text-center\">\n              <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\">\n                <AlertTriangle className=\"w-6 h-6 text-red-600 dark:text-red-400\" />\n              </div>\n              <CardTitle className=\"text-red-900 dark:text-red-100\">{t?.common?.error || 'Something went wrong'}</CardTitle>\n              <CardDescription>\n                {t?.common?.refreshPage || 'An unexpected error occurred. Please try refreshing the page.'}\n              </CardDescription>\n            </CardHeader>\n            <CardContent className=\"space-y-4\">\n              {process.env.NODE_ENV === 'development' && this.state.error && (\n                <details className=\"text-xs bg-muted p-3 rounded border\">\n                  <summary className=\"cursor-pointer font-medium\">{t?.common?.errorDetails || 'Error Details'}</summary>\n                  <pre className=\"mt-2 whitespace-pre-wrap break-all\">\n                    {this.state.error.toString()}\n                  </pre>\n                </details>\n              )}\n              <Button \n                onClick={this.resetError} \n                className=\"w-full\"\n                variant=\"outline\"\n              >\n                <RefreshCw className=\"w-4 h-4 mr-2\" />\n                {t?.common?.retry || 'Try Again'}\n              </Button>\n              <Button \n                onClick={() => window.location.reload()} \n                className=\"w-full\"\n              >\n                {t?.common?.refresh || 'Refresh Page'}\n              </Button>\n            </CardContent>\n          </Card>\n        </div>\n      )\n    }\n\n    return this.props.children\n  }\n}\n\n// Hook version for functional components\nexport function useErrorBoundary() {\n  return (error: Error) => {\n    throw error\n  }\n}"
  },
  {
    "path": "frontend/src/components/common/InlineEdit.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect, useId, type RefObject } from 'react'\nimport { cn } from '@/lib/utils'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface InlineEditProps {\n  value: string\n  onSave: (value: string) => void | Promise<void>\n  className?: string\n  inputClassName?: string\n  placeholder?: string\n  multiline?: boolean\n  emptyText?: string\n  id?: string\n  name?: string\n  autocomplete?: string\n}\n\nexport function InlineEdit({\n  value,\n  onSave,\n  className,\n  inputClassName,\n  placeholder,\n  multiline = false,\n  emptyText,\n  id: providedId,\n  name,\n  autocomplete = 'off'\n}: InlineEditProps) {\n  const generatedId = useId()\n  const id = providedId || generatedId\n  const { t } = useTranslation()\n  const defaultEmptyText = emptyText || t.common.clickToEdit\n  const [isEditing, setIsEditing] = useState(false)\n  const [editValue, setEditValue] = useState(value)\n  const [isSaving, setIsSaving] = useState(false)\n  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)\n\n  useEffect(() => {\n    if (isEditing && inputRef.current) {\n      inputRef.current.focus()\n      inputRef.current.select()\n    }\n  }, [isEditing])\n\n  useEffect(() => {\n    setEditValue(value)\n  }, [value])\n\n  const handleSave = async () => {\n    if (editValue.trim() === value.trim()) {\n      setIsEditing(false)\n      return\n    }\n\n    setIsSaving(true)\n    try {\n      await onSave(editValue.trim())\n      setIsEditing(false)\n    } catch {\n      // Reset on error\n      setEditValue(value)\n    } finally {\n      setIsSaving(false)\n    }\n  }\n\n  const handleCancel = () => {\n    setEditValue(value)\n    setIsEditing(false)\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter' && !multiline) {\n      e.preventDefault()\n      handleSave()\n    } else if (e.key === 'Escape') {\n      e.preventDefault()\n      handleCancel()\n    }\n  }\n\n  if (!isEditing) {\n    return (\n      <button\n        type=\"button\"\n        className={cn(\n          \"cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -mx-2 -my-1 transition-colors text-left w-full break-all\",\n          className\n        )}\n        onClick={(e) => {\n          e.preventDefault()\n          e.stopPropagation()\n          setIsEditing(true)\n        }}\n      >\n        {value || <span className=\"text-muted-foreground\">{defaultEmptyText}</span>}\n      </button>\n    )\n  }\n\n  if (multiline) {\n    return (\n      <textarea\n        ref={inputRef as RefObject<HTMLTextAreaElement>}\n        value={editValue}\n        onChange={(e) => setEditValue(e.target.value)}\n        onKeyDown={handleKeyDown}\n        onBlur={() => {\n          if (!isSaving && editValue.trim() !== value.trim()) {\n            handleSave()\n          } else if (editValue.trim() === value.trim()) {\n            setIsEditing(false)\n          }\n        }}\n        className={cn(\n          \"px-2 py-1 bg-background border rounded focus:outline-none focus:ring-2 focus:ring-primary w-full\",\n          \"min-h-[60px] resize-none\",\n          inputClassName\n        )}\n        placeholder={placeholder}\n        disabled={isSaving}\n        id={id}\n        name={name}\n        autoComplete={autocomplete}\n      />\n    )\n  }\n\n  return (\n    <input\n      ref={inputRef as RefObject<HTMLInputElement>}\n      value={editValue}\n      onChange={(e) => setEditValue(e.target.value)}\n      onKeyDown={handleKeyDown}\n      onBlur={() => {\n        if (!isSaving && editValue.trim() !== value.trim()) {\n          handleSave()\n        } else if (editValue.trim() === value.trim()) {\n          setIsEditing(false)\n        }\n      }}\n      className={cn(\n        \"px-2 py-1 bg-background border rounded focus:outline-none focus:ring-2 focus:ring-primary w-full\",\n        inputClassName\n      )}\n      placeholder={placeholder}\n      disabled={isSaving}\n      id={id}\n      name={name}\n      autoComplete={autocomplete}\n    />\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/LanguageLoadingOverlay.tsx",
    "content": "'use client'\n\nimport { useEffect, useState, useCallback, useRef } from 'react'\nimport { useTranslation as useI18nTranslation } from 'react-i18next'\nimport { Loader2 } from 'lucide-react'\nimport {\n  i18nEvents,\n  I18N_LANGUAGE_CHANGE_END,\n  I18N_LANGUAGE_CHANGE_START,\n} from '@/lib/i18n-events'\n\n/**\n * LanguageLoadingOverlay - Shows a brief loading overlay during language switches\n * to provide a smoother UX and hide the flash caused by re-rendering.\n * \n * IMPORTANT: This component intentionally uses react-i18next directly instead of\n * our custom useTranslation hook to avoid Proxy-related issues during the\n * language change transition period.\n */\nexport function LanguageLoadingOverlay() {\n  const { t } = useI18nTranslation()\n  const [isChanging, setIsChanging] = useState(false)\n\n  const isChangingRef = useRef(false)\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const handleLanguageChanging = useCallback(() => {\n    if (!isChangingRef.current) {\n      isChangingRef.current = true\n      setIsChanging(true)\n    }\n\n    // Safety timeout: ensure we don't get stuck forever.\n    if (!timerRef.current) {\n      timerRef.current = setTimeout(() => {\n        isChangingRef.current = false\n        setIsChanging(false)\n        timerRef.current = null\n      }, 1500)\n    }\n  }, [])\n\n  const handleLanguageChanged = useCallback(() => {\n    // Immediately hide the overlay on language change success\n    if (timerRef.current) {\n      clearTimeout(timerRef.current)\n      timerRef.current = null\n    }\n    if (isChangingRef.current) {\n      isChangingRef.current = false\n      setIsChanging(false)\n    }\n  }, [])\n\n  useEffect(() => {\n    return () => {\n      if (timerRef.current) clearTimeout(timerRef.current)\n    }\n  }, [])\n\n  useEffect(() => {\n    const onChangeStart = () => handleLanguageChanging()\n    const onChangeEnd = () => handleLanguageChanged()\n\n    i18nEvents.addEventListener(I18N_LANGUAGE_CHANGE_START, onChangeStart)\n    i18nEvents.addEventListener(I18N_LANGUAGE_CHANGE_END, onChangeEnd)\n\n    return () => {\n      i18nEvents.removeEventListener(I18N_LANGUAGE_CHANGE_START, onChangeStart)\n      i18nEvents.removeEventListener(I18N_LANGUAGE_CHANGE_END, onChangeEnd)\n    }\n  }, [handleLanguageChanging, handleLanguageChanged])\n\n  if (!isChanging) return null\n\n  // Use react-i18next's t() directly - this is safe during language transitions\n  // because react-i18next handles the loading state internally\n  const loadingText = t('common.loading', { defaultValue: '加载中...' })\n\n  return (\n    <div\n      className=\"fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200\"\n      style={{ opacity: isChanging ? 1 : 0 }}\n    >\n      <div className=\"flex flex-col items-center gap-3\">\n        <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n        <span className=\"text-sm text-muted-foreground\">{loadingText}</span>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/LanguageToggle.tsx",
    "content": "'use client'\n\nimport { Button } from '@/components/ui/button'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Languages } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface LanguageToggleProps {\n  iconOnly?: boolean\n}\n\nexport function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {\n  const { language, setLanguage, t } = useTranslation()\n  \n  // Keep the actual language code for proper comparison\n  const currentLang = language || 'en-US'\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button \n          variant={iconOnly ? \"ghost\" : \"outline\"} \n          size={iconOnly ? \"icon\" : \"default\"} \n          className={iconOnly ? \"h-9 w-full sidebar-menu-item\" : \"w-full justify-start gap-2 sidebar-menu-item\"}\n        >\n          <Languages className=\"h-[1.2rem] w-[1.2rem]\" />\n          {!iconOnly && <span>{t.common.language}</span>}\n          <span className=\"sr-only\">{t.navigation.language}</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem \n          onClick={() => setLanguage('en-US')}\n          className={currentLang === 'en-US' || currentLang.startsWith('en') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.english}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem \n          onClick={() => setLanguage('zh-CN')}\n          className={currentLang === 'zh-CN' || currentLang.startsWith('zh-Hans') || currentLang === 'zh' ? 'bg-accent' : ''}\n        >\n          <span>{t.common.chinese}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('zh-TW')}\n          className={currentLang === 'zh-TW' || currentLang.startsWith('zh-Hant') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.traditionalChinese}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('pt-BR')}\n          className={currentLang === 'pt-BR' || currentLang.startsWith('pt') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.portuguese}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('ja-JP')}\n          className={currentLang === 'ja-JP' || currentLang.startsWith('ja') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.japanese}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('fr-FR')}\n          className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.french}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('ru-RU')}\n          className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.russian}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          onClick={() => setLanguage('bn-IN')}\n          className={currentLang === 'bn-IN' || currentLang.startsWith('bn') ? 'bg-accent' : ''}\n        >\n          <span>{t.common.bengali}</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/LoadingSpinner.tsx",
    "content": "import { Loader2 } from 'lucide-react'\nimport { cn } from '@/lib/utils'\n\ninterface LoadingSpinnerProps {\n  className?: string\n  size?: 'sm' | 'md' | 'lg'\n}\n\nexport function LoadingSpinner({ className, size = 'md' }: LoadingSpinnerProps) {\n  const sizeClasses = {\n    sm: 'h-4 w-4',\n    md: 'h-6 w-6',\n    lg: 'h-8 w-8'\n  }\n\n  return (\n    <Loader2 \n      data-testid=\"loading-spinner\"\n      className={cn('animate-spin', sizeClasses[size], className)} \n    />\n  )\n}"
  },
  {
    "path": "frontend/src/components/common/ModelSelector.tsx",
    "content": "import { useId } from 'react'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Label } from '@/components/ui/label'\nimport { useModels } from '@/lib/hooks/use-models'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface ModelSelectorProps {\n  id?: string\n  name?: string\n  label?: string\n  modelType: 'language' | 'embedding' | 'speech_to_text' | 'text_to_speech'\n  value: string\n  onChange: (value: string) => void\n  placeholder?: string\n  disabled?: boolean\n}\n\nexport function ModelSelector({\n  id,\n  name,\n  label,\n  modelType,\n  value,\n  onChange,\n  placeholder,\n  disabled = false\n}: ModelSelectorProps) {\n  const { t } = useTranslation()\n  const { data: models, isLoading } = useModels()\n  const derivedId = useId()\n  const selectId = id || derivedId\n\n  // Filter models by type\n  const filteredModels = models?.filter(model => model.type === modelType) || []\n  return (\n    <div className=\"space-y-2\">\n      {label && <Label htmlFor={selectId}>{label}</Label>}\n      <Select name={name} value={value} onValueChange={onChange} disabled={disabled || isLoading}>\n        <SelectTrigger id={selectId}>\n          <SelectValue placeholder={placeholder || t.settings.embeddingOptionPlaceholder} />\n        </SelectTrigger>\n        <SelectContent>\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-2\">\n              <LoadingSpinner size=\"sm\" />\n            </div>\n          ) : filteredModels.length === 0 ? (\n            <div className=\"text-sm text-muted-foreground py-2 px-2\">\n              {t.common.noResults}\n            </div>\n          ) : (\n            filteredModels.map((model) => (\n              <SelectItem key={model.id} value={model.id}>\n                <div className=\"flex items-center justify-between w-full\">\n                  <span>{model.name}</span>\n                  <span className=\"text-xs text-muted-foreground ml-2\">{model.provider}</span>\n                </div>\n              </SelectItem>\n            ))\n          )}\n        </SelectContent>\n      </Select>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/common/ThemeToggle.tsx",
    "content": "'use client'\n\nimport { useTheme } from '@/lib/stores/theme-store'\nimport { Button } from '@/components/ui/button'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { Sun, Moon, Monitor } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface ThemeToggleProps {\n  iconOnly?: boolean\n}\n\nexport function ThemeToggle({ iconOnly = false }: ThemeToggleProps) {\n  const { theme, setTheme } = useTheme()\n  const { t } = useTranslation()\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button \n          variant={iconOnly ? \"ghost\" : \"outline\"} \n          size={iconOnly ? \"icon\" : \"default\"} \n          className={iconOnly ? \"h-9 w-full sidebar-menu-item\" : \"w-full justify-start gap-2 sidebar-menu-item\"}\n        >\n          <div className=\"relative h-[1.2rem] w-[1.2rem]\">\n            <Sun className=\"absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n            <Moon className=\"absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n          </div>\n          {!iconOnly && <span>{t.common.theme}</span>}\n          <span className=\"sr-only\">{t.navigation.theme}</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem \n          onClick={() => setTheme('light')}\n          className={theme === 'light' ? 'bg-accent' : ''}\n        >\n          <Sun className=\"mr-2 h-4 w-4\" />\n          <span>{t.common.light}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem \n          onClick={() => setTheme('dark')}\n          className={theme === 'dark' ? 'bg-accent' : ''}\n        >\n          <Moon className=\"mr-2 h-4 w-4\" />\n          <span>{t.common.dark}</span>\n        </DropdownMenuItem>\n        <DropdownMenuItem \n          onClick={() => setTheme('system')}\n          className={theme === 'system' ? 'bg-accent' : ''}\n        >\n          <Monitor className=\"mr-2 h-4 w-4\" />\n          <span>{t.common.system}</span>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  )\n}"
  },
  {
    "path": "frontend/src/components/errors/ConnectionErrorOverlay.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Card } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from '@/components/ui/collapsible'\nimport { Database, Server, ChevronDown, ExternalLink } from 'lucide-react'\nimport { ConnectionError } from '@/lib/types/config'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface ConnectionErrorOverlayProps {\n  error: ConnectionError\n  onRetry: () => void\n}\n\nexport function ConnectionErrorOverlay({\n  error,\n  onRetry,\n}: ConnectionErrorOverlayProps) {\n  const { t } = useTranslation()\n  const [showDetails, setShowDetails] = useState(false)\n  const isApiError = error.type === 'api-unreachable'\n\n  return (\n    <div\n      className=\"fixed inset-0 bg-background z-50 flex items-center justify-center p-4\"\n      role=\"alert\"\n      aria-live=\"assertive\"\n      aria-atomic=\"true\"\n    >\n      <Card className=\"max-w-2xl w-full p-8 space-y-6\">\n        {/* Error icon and title */}\n        <div className=\"flex items-center gap-4\">\n          {isApiError ? (\n            <Server className=\"w-12 h-12 text-destructive\" aria-hidden=\"true\" />\n          ) : (\n            <Database className=\"w-12 h-12 text-destructive\" aria-hidden=\"true\" />\n          )}\n          <div>\n            <h1 className=\"text-2xl font-bold\" id=\"error-title\">\n              {isApiError\n                ? t.connectionErrors.apiTitle\n                : t.connectionErrors.dbTitle}\n            </h1>\n            <p className=\"text-muted-foreground\">\n              {isApiError\n                ? t.connectionErrors.apiDesc\n                : t.connectionErrors.dbDesc}\n            </p>\n          </div>\n        </div>\n\n        {/* Troubleshooting instructions */}\n        <div className=\"space-y-4 border-l-4 border-primary pl-4\">\n          <h2 className=\"font-semibold\">{t.connectionErrors.troubleshooting}</h2>\n          <ul className=\"list-disc list-inside space-y-2 text-sm\">\n            {isApiError ? (\n              <>\n                <li>{t.connectionErrors.apiUnreachable1}</li>\n                <li>{t.connectionErrors.apiUnreachable2}</li>\n                <li>{t.connectionErrors.apiUnreachable3}</li>\n              </>\n            ) : (\n              <>\n                <li>{t.connectionErrors.dbFailed1}</li>\n                <li>{t.connectionErrors.dbFailed2}</li>\n                <li>{t.connectionErrors.dbFailed3}</li>\n              </>\n            )}\n          </ul>\n\n          <h2 className=\"font-semibold mt-4\">{t.connectionErrors.quickFixes}</h2>\n          {isApiError ? (\n            <div className=\"space-y-2 text-sm bg-muted p-4 rounded\">\n              <p className=\"font-medium\">{t.connectionErrors.setApiUrl}</p>\n              <code className=\"block bg-background p-2 rounded text-xs\">\n                # {t.connectionErrors.dockerLabel}:\n                <br />\n                docker run -e API_URL=http://your-host:5055 ...\n                <br />\n                <br />\n                # {t.connectionErrors.localDevLabel}:\n                <br />\n                API_URL=http://localhost:5055\n              </code>\n            </div>\n          ) : (\n            <div className=\"space-y-2 text-sm bg-muted p-4 rounded\">\n              <p className=\"font-medium\">{t.connectionErrors.checkSurreal}</p>\n              <code className=\"block bg-background p-2 rounded text-xs\">\n                # {t.connectionErrors.dockerLabel}:\n                <br />\n                docker compose ps | grep surrealdb\n                <br />\n                docker compose logs surrealdb\n              </code>\n            </div>\n          )}\n        </div>\n\n        {/* Documentation link */}\n        <div className=\"text-sm\">\n          <p>{t.connectionErrors.seeDocumentation}</p>\n          <a\n            href=\"https://github.com/lfnovo/open-notebook\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"text-primary hover:underline inline-flex items-center gap-1\"\n          >\n            {t.connectionErrors.docLink}\n            <ExternalLink className=\"w-4 h-4\" />\n          </a>\n        </div>\n\n        {/* Collapsible technical details */}\n        {error.details && (\n          <Collapsible open={showDetails} onOpenChange={setShowDetails}>\n            <CollapsibleTrigger asChild>\n              <Button variant=\"ghost\" size=\"sm\" className=\"w-full justify-between\">\n                <span>{t.connectionErrors.showTechnical}</span>\n                <ChevronDown\n                  className={`w-4 h-4 transition-transform ${\n                    showDetails ? 'rotate-180' : ''\n                  }`}\n                />\n              </Button>\n            </CollapsibleTrigger>\n            <CollapsibleContent className=\"pt-4\">\n              <div className=\"space-y-2 text-sm bg-muted p-4 rounded font-mono\">\n                {error.details.attemptedUrl && (\n                  <div>\n                    <strong>{t.connectionErrors.attemptedUrl}:</strong> {error.details.attemptedUrl}\n                  </div>\n                )}\n                {error.details.message && (\n                  <div>\n                    <strong>{t.connectionErrors.message}:</strong> {error.details.message}\n                  </div>\n                )}\n                {error.details.technicalMessage && (\n                  <div>\n                    <strong>{t.connectionErrors.technicalDetails}:</strong>{' '}\n                    {error.details.technicalMessage}\n                  </div>\n                )}\n                {error.details.stack && (\n                  <div>\n                    <strong>{t.connectionErrors.stackTrace}:</strong>\n                    <pre className=\"mt-2 overflow-x-auto text-xs\">\n                      {error.details.stack}\n                    </pre>\n                  </div>\n                )}\n              </div>\n            </CollapsibleContent>\n          </Collapsible>\n        )}\n\n        {/* Retry button */}\n        <div className=\"pt-4 border-t\">\n          <Button onClick={onRetry} className=\"w-full\" size=\"lg\">\n            {t.connectionErrors.retryLabel}\n          </Button>\n          <p className=\"text-xs text-muted-foreground text-center mt-2\">\n            {t.connectionErrors.retryHint}\n          </p>\n        </div>\n      </Card>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/layout/AppShell.tsx",
    "content": "'use client'\n\nimport { AppSidebar } from './AppSidebar'\nimport { SetupBanner } from './SetupBanner'\n\ninterface AppShellProps {\n  children: React.ReactNode\n}\n\nexport function AppShell({ children }: AppShellProps) {\n  return (\n    <div className=\"flex h-screen overflow-hidden\">\n      <AppSidebar />\n      <main className=\"flex-1 flex flex-col min-h-0 overflow-hidden\">\n        <SetupBanner />\n        {children}\n      </main>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/layout/AppSidebar.test.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { render, screen, fireEvent } from '@testing-library/react'\nimport { describe, it, expect, vi } from 'vitest'\nimport { AppSidebar } from './AppSidebar'\nimport { useSidebarStore } from '@/lib/stores/sidebar-store'\n\n// Mock Tooltip components to avoid Radix UI async issues in tests\nvi.mock('@/components/ui/tooltip', () => ({\n  TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,\n  Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,\n  TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,\n  TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,\n}))\n// But setup.ts has some basic mocks, let's see.\n\ndescribe('AppSidebar', () => {\n  it('renders correctly when expanded', () => {\n    render(<AppSidebar />)\n    \n    // Check for logo or app name (using actual locale value)\n    expect(screen.getByText(/Open Notebook/i)).toBeDefined()\n    \n    // Check for navigation items (using actual locale values)\n    expect(screen.getByText(/Sources/i)).toBeDefined()\n    expect(screen.getByText(/Notebooks/i)).toBeDefined()\n  })\n\n  it('toggles collapse state when clicking handle', () => {\n    const toggleCollapse = vi.fn()\n    vi.mocked(useSidebarStore).mockReturnValue({\n      isCollapsed: false,\n      toggleCollapse,\n    } as any)\n\n    render(<AppSidebar />)\n    \n    // The collapse button has ChevronLeft icon when expanded\n    // The collapse button has ChevronLeft icon when expanded\n    // const toggleButton = screen.getAllByRole('button')[0]\n    // Let's use more specific selector if possible, but AppSidebar has many buttons\n    // Actually, line 147 has the button\n    \n    // Use data-testid for reliable selection\n    fireEvent.click(screen.getByTestId('sidebar-toggle'))\n    \n    expect(toggleCollapse).toHaveBeenCalled()\n  })\n\n  it('shows collapsed view when isCollapsed is true', () => {\n    vi.mocked(useSidebarStore).mockReturnValue({\n      isCollapsed: true,\n      toggleCollapse: vi.fn(),\n    } as any)\n\n    render(<AppSidebar />)\n    \n    // In collapsed mode, app name shouldn't be visible (as text)\n    expect(screen.queryByText(/Open Notebook/i)).toBeNull()\n  })\n})\n"
  },
  {
    "path": "frontend/src/components/layout/AppSidebar.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport Link from 'next/link'\nimport Image from 'next/image'\nimport { usePathname } from 'next/navigation'\n\nimport { cn } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\nimport { useAuth } from '@/lib/hooks/use-auth'\nimport { useSidebarStore } from '@/lib/stores/sidebar-store'\nimport { useCreateDialogs } from '@/lib/hooks/use-create-dialogs'\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from '@/components/ui/tooltip'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { ThemeToggle } from '@/components/common/ThemeToggle'\nimport { LanguageToggle } from '@/components/common/LanguageToggle'\nimport { TranslationKeys } from '@/lib/locales'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { Separator } from '@/components/ui/separator'\nimport {\n  Book,\n  Search,\n  Mic,\n  Bot,\n  Shuffle,\n  Settings,\n  LogOut,\n  ChevronLeft,\n  Menu,\n  FileText,\n  Plus,\n  Wrench,\n  Command,\n} from 'lucide-react'\n\nconst getNavigation = (t: TranslationKeys) => [\n  {\n    title: t.navigation.collect,\n    items: [\n      { name: t.navigation.sources, href: '/sources', icon: FileText },\n    ],\n  },\n  {\n    title: t.navigation.process,\n    items: [\n      { name: t.navigation.notebooks, href: '/notebooks', icon: Book },\n      { name: t.navigation.askAndSearch, href: '/search', icon: Search },\n    ],\n  },\n  {\n    title: t.navigation.create,\n    items: [\n      { name: t.navigation.podcasts, href: '/podcasts', icon: Mic },\n    ],\n  },\n  {\n    title: t.navigation.manage,\n    items: [\n      { name: t.navigation.models, href: '/settings/api-keys', icon: Bot },\n      { name: t.navigation.transformations, href: '/transformations', icon: Shuffle },\n      { name: t.navigation.settings, href: '/settings', icon: Settings },\n      { name: t.navigation.advanced, href: '/advanced', icon: Wrench },\n    ],\n  },\n] as const\n\ntype CreateTarget = 'source' | 'notebook' | 'podcast'\n\nexport function AppSidebar() {\n  const { t } = useTranslation()\n  const navigation = getNavigation(t)\n  const pathname = usePathname()\n  const { logout } = useAuth()\n  const { isCollapsed, toggleCollapse } = useSidebarStore()\n  const { openSourceDialog, openNotebookDialog, openPodcastDialog } = useCreateDialogs()\n\n  const [createMenuOpen, setCreateMenuOpen] = useState(false)\n  const [isMac, setIsMac] = useState(true) // Default to Mac for SSR\n\n  // Detect platform for keyboard shortcut display\n  useEffect(() => {\n    setIsMac(navigator.platform.toLowerCase().includes('mac'))\n  }, [])\n\n  const handleCreateSelection = (target: CreateTarget) => {\n    setCreateMenuOpen(false)\n\n    if (target === 'source') {\n      openSourceDialog()\n    } else if (target === 'notebook') {\n      openNotebookDialog()\n    } else if (target === 'podcast') {\n      openPodcastDialog()\n    }\n  }\n\n  return (\n    <TooltipProvider delayDuration={0}>\n      <div\n        className={cn(\n          'app-sidebar flex h-full flex-col bg-sidebar border-sidebar-border border-r transition-all duration-300',\n          isCollapsed ? 'w-16' : 'w-64'\n        )}\n      >\n        <div\n          className={cn(\n            'flex h-16 items-center group',\n            isCollapsed ? 'justify-center px-2' : 'justify-between px-4'\n          )}\n        >\n          {isCollapsed ? (\n            <div className=\"relative flex items-center justify-center w-full\">\n              <Image\n                src=\"/logo.svg\"\n                alt=\"Open Notebook\"\n                width={32}\n                height={32}\n                className=\"transition-opacity group-hover:opacity-0\"\n              />\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={toggleCollapse}\n                className=\"absolute text-sidebar-foreground hover:bg-sidebar-accent opacity-0 group-hover:opacity-100 transition-opacity\"\n              >\n                <Menu className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ) : (\n            <>\n              <div className=\"flex items-center gap-2\">\n                <Image src=\"/logo.svg\" alt={t.common.appName} width={32} height={32} />\n                <span className=\"text-base font-medium text-sidebar-foreground\">\n                  {t.common.appName}\n                </span>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={toggleCollapse}\n                className=\"text-sidebar-foreground hover:bg-sidebar-accent\"\n                data-testid=\"sidebar-toggle\"\n              >\n                <ChevronLeft className=\"h-4 w-4\" />\n              </Button>\n            </>\n          )}\n        </div>\n\n        <nav\n          className={cn(\n            'flex-1 space-y-1 py-4',\n            isCollapsed ? 'px-2' : 'px-3'\n          )}\n        >\n          <div\n            className={cn(\n              'mb-4',\n              isCollapsed ? 'px-0' : 'px-3'\n            )}\n          >\n            <DropdownMenu open={createMenuOpen} onOpenChange={setCreateMenuOpen}>\n              {isCollapsed ? (\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <DropdownMenuTrigger asChild>\n                      <Button\n                        onClick={() => setCreateMenuOpen(true)}\n                        variant=\"default\"\n                        size=\"sm\"\n                        className=\"w-full justify-center px-2 bg-primary hover:bg-primary/90 text-primary-foreground border-0\"\n                        aria-label={t.common.create}\n                      >\n                        <Plus className=\"h-4 w-4\" />\n                      </Button>\n                    </DropdownMenuTrigger>\n                  </TooltipTrigger>\n                   <TooltipContent side=\"right\">{t.common.create}</TooltipContent>\n                </Tooltip>\n              ) : (\n                <DropdownMenuTrigger asChild>\n                  <Button\n                    onClick={() => setCreateMenuOpen(true)}\n                    variant=\"default\"\n                    size=\"sm\"\n                    className=\"w-full justify-start bg-primary hover:bg-primary/90 text-primary-foreground border-0\"\n                   >\n                    <Plus className=\"h-4 w-4 mr-2\" />\n                    {t.common.create}\n                  </Button>\n                </DropdownMenuTrigger>\n              )}\n\n              <DropdownMenuContent\n                align={isCollapsed ? 'end' : 'start'}\n                side={isCollapsed ? 'right' : 'bottom'}\n                className=\"w-48\"\n              >\n                <DropdownMenuItem\n                  onSelect={(event) => {\n                    event.preventDefault()\n                    handleCreateSelection('source')\n                  }}\n                  className=\"gap-2\"\n                >\n                   <FileText className=\"h-4 w-4\" />\n                  {t.common.source}\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onSelect={(event) => {\n                    event.preventDefault()\n                    handleCreateSelection('notebook')\n                  }}\n                  className=\"gap-2\"\n                >\n                   <Book className=\"h-4 w-4\" />\n                  {t.common.notebook}\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onSelect={(event) => {\n                    event.preventDefault()\n                    handleCreateSelection('podcast')\n                  }}\n                  className=\"gap-2\"\n                >\n                   <Mic className=\"h-4 w-4\" />\n                  {t.common.podcast}\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n\n          {navigation.map((section, index) => (\n            <div key={section.title}>\n              {index > 0 && (\n                <Separator className=\"my-3\" />\n              )}\n              <div className=\"space-y-1\">\n                {!isCollapsed && (\n                  <h3 className=\"mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-sidebar-foreground/60\">\n                    {section.title}\n                  </h3>\n                )}\n\n                {section.items.map((item) => {\n                  const isActive = pathname?.startsWith(item.href) || false\n                  const button = (\n                    <Button\n                      variant={isActive ? 'secondary' : 'ghost'}\n                      className={cn(\n                        'w-full gap-3 text-sidebar-foreground sidebar-menu-item',\n                        isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',\n                        isCollapsed ? 'justify-center px-2' : 'justify-start'\n                      )}\n                    >\n                      <item.icon className=\"h-4 w-4\" />\n                      {!isCollapsed && <span>{item.name}</span>}\n                    </Button>\n                  )\n\n                  if (isCollapsed) {\n                    return (\n                      <Tooltip key={item.name}>\n                        <TooltipTrigger asChild>\n                          <Link href={item.href}>\n                            {button}\n                          </Link>\n                        </TooltipTrigger>\n                        <TooltipContent side=\"right\">{item.name}</TooltipContent>\n                      </Tooltip>\n                    )\n                  }\n\n                  return (\n                    <Link key={item.name} href={item.href}>\n                      {button}\n                    </Link>\n                  )\n                })}\n              </div>\n            </div>\n          ))}\n        </nav>\n\n        <div\n          className={cn(\n            'border-t border-sidebar-border p-3 space-y-2',\n            isCollapsed && 'px-2'\n          )}\n        >\n          {/* Command Palette hint */}\n          {!isCollapsed && (\n            <div className=\"px-3 py-1.5 text-xs text-sidebar-foreground/60\">\n              <div className=\"flex items-center justify-between\">\n                 <span className=\"flex items-center gap-1.5\">\n                  <Command className=\"h-3 w-3\" />\n                  {t.common.quickActions}\n                </span>\n                <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\">\n                  {isMac ? <span className=\"text-xs\">⌘</span> : <span>Ctrl+</span>}K\n                </kbd>\n              </div>\n               <p className=\"mt-1 text-[10px] text-sidebar-foreground/40\">\n                {t.common.quickActionsDesc}\n              </p>\n            </div>\n          )}\n\n           <div\n            className={cn(\n              'flex flex-col gap-2',\n              isCollapsed ? 'items-center' : 'items-stretch'\n            )}\n          >\n            {isCollapsed ? (\n              <>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div>\n                      <ThemeToggle iconOnly />\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"right\">{t.common.theme}</TooltipContent>\n                </Tooltip>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div>\n                      <LanguageToggle iconOnly />\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent side=\"right\">{t.common.language}</TooltipContent>\n                </Tooltip>\n              </>\n            ) : (\n              <>\n                <ThemeToggle />\n                <LanguageToggle />\n              </>\n            )}\n          </div>\n\n          {isCollapsed ? (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"outline\"\n                  className=\"w-full justify-center sidebar-menu-item\"\n                  onClick={logout}\n                  aria-label={t.common.signOut}\n                >\n                  <LogOut className=\"h-4 w-4\" />\n                </Button>\n              </TooltipTrigger>\n               <TooltipContent side=\"right\">{t.common.signOut}</TooltipContent>\n            </Tooltip>\n          ) : (\n            <Button\n              variant=\"outline\"\n              className=\"w-full justify-start gap-3 sidebar-menu-item\"\n              onClick={logout}\n              aria-label={t.common.signOut}\n             >\n              <LogOut className=\"h-4 w-4\" />\n              {t.common.signOut}\n            </Button>\n          )}\n        </div>\n      </div>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/layout/SetupBanner.tsx",
    "content": "'use client'\n\nimport { useMemo } from 'react'\nimport Link from 'next/link'\nimport { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'\nimport { Button } from '@/components/ui/button'\nimport { ShieldAlert, AlertTriangle, ArrowRight, ExternalLink } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { useCredentialStatus, useEnvStatus } from '@/lib/hooks/use-credentials'\n\nexport function SetupBanner() {\n  const { t } = useTranslation()\n  const { data: credentialStatus } = useCredentialStatus()\n  const { data: envStatus } = useEnvStatus()\n\n  const encryptionReady = credentialStatus?.encryption_configured ?? true\n\n  const providersToMigrate = useMemo(() => {\n    if (!envStatus || !credentialStatus) return []\n    const providers: string[] = []\n    for (const provider in envStatus) {\n      if (envStatus[provider] && credentialStatus.source[provider] === 'environment') {\n        providers.push(provider)\n      }\n    }\n    return providers\n  }, [envStatus, credentialStatus])\n\n  if (encryptionReady && providersToMigrate.length === 0) {\n    return null\n  }\n\n  if (!encryptionReady) {\n    return (\n      <div className=\"px-4 pt-3\">\n        <Alert className=\"border-red-500/50 bg-red-50 dark:bg-red-950/20\">\n          <ShieldAlert className=\"h-4 w-4 text-red-600 dark:text-red-400\" />\n          <AlertTitle className=\"text-red-800 dark:text-red-200\">\n            {t.setupBanner.encryptionRequired}\n          </AlertTitle>\n          <AlertDescription className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-red-700 dark:text-red-300\">\n            <span>{t.setupBanner.encryptionRequiredDescription}</span>\n            <a\n              href=\"https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center shrink-0 text-sm font-medium underline underline-offset-2 hover:text-red-900 dark:hover:text-red-100\"\n            >\n              {t.setupBanner.viewDocs}\n              <ExternalLink className=\"ml-1 h-3 w-3\" />\n            </a>\n          </AlertDescription>\n        </Alert>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"px-4 pt-3\">\n      <Alert className=\"border-amber-500/50 bg-amber-50 dark:bg-amber-950/20\">\n        <AlertTriangle className=\"h-4 w-4 text-amber-600 dark:text-amber-400\" />\n        <AlertTitle className=\"text-amber-800 dark:text-amber-200\">\n          {t.setupBanner.migrationAvailable}\n        </AlertTitle>\n        <AlertDescription className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n          <span className=\"text-amber-700 dark:text-amber-300\">\n            {t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())}\n          </span>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            asChild\n            className=\"shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30\"\n          >\n            <Link href=\"/settings/api-keys\">\n              {t.setupBanner.goToSettings}\n              <ArrowRight className=\"ml-2 h-4 w-4\" />\n            </Link>\n          </Button>\n        </AlertDescription>\n      </Alert>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/notebooks/CollapsibleColumn.tsx",
    "content": "'use client'\n\nimport { ReactNode } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { ChevronLeft, LucideIcon } from 'lucide-react'\nimport { cn } from '@/lib/utils'\n\ninterface CollapsibleColumnProps {\n  isCollapsed: boolean\n  onToggle: () => void\n  collapsedIcon: LucideIcon\n  collapsedLabel: string\n  children: ReactNode\n}\n\nexport function CollapsibleColumn({\n  isCollapsed,\n  onToggle,\n  collapsedIcon: CollapsedIcon,\n  collapsedLabel,\n  children,\n}: CollapsibleColumnProps) {\n  const isCJK = /[\\u4e00-\\u9fa5\\u3040-\\u30ff\\uac00-\\ud7af]/.test(collapsedLabel);\n\n  if (isCollapsed) {\n    return (\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <button\n              onClick={onToggle}\n              className={cn(\n                'flex flex-col items-center justify-center gap-3',\n                'w-12 h-full min-h-0',\n                'border rounded-lg',\n                'bg-card hover:bg-accent/50',\n                'transition-all duration-150',\n                'cursor-pointer group',\n                'py-6'\n              )}\n              aria-label={`Expand ${collapsedLabel}`}\n            >\n              <CollapsedIcon className=\"h-5 w-5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0\" />\n              <div\n                className=\"text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors whitespace-nowrap\"\n                style={{ writingMode: 'vertical-rl', transform: isCJK ? 'none' : 'rotate(180deg)', textOrientation: 'mixed' }}\n              >\n                {collapsedLabel}\n              </div>\n            </button>\n          </TooltipTrigger>\n          <TooltipContent side=\"right\">\n            <p>Expand {collapsedLabel}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    )\n  }\n\n  return (\n    <div className=\"h-full min-h-0 transition-all duration-150\">\n      {children}\n    </div>\n  )\n}\n\n// Factory function to create a collapse button for card headers\nexport function createCollapseButton(onToggle: () => void, label: string) {\n  return (\n    <div className=\"hidden lg:block\">\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={(e) => {\n                e.stopPropagation()\n                onToggle()\n              }}\n              className=\"h-7 w-7 hover:bg-accent\"\n              aria-label={`Collapse ${label}`}\n            >\n              <ChevronLeft className=\"h-4 w-4\" />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>Collapse {label}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/notebooks/CreateNotebookDialog.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useForm } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Label } from '@/components/ui/label'\nimport { useCreateNotebook } from '@/lib/hooks/use-notebooks'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst createNotebookSchema = z.object({\n  name: z.string().min(1, 'Name is required'),\n  description: z.string().optional(),\n})\n\ntype CreateNotebookFormData = z.infer<typeof createNotebookSchema>\n\ninterface CreateNotebookDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {\n  const { t } = useTranslation()\n  const createNotebook = useCreateNotebook()\n  const {\n    register,\n    handleSubmit,\n    formState: { errors, isValid },\n    reset,\n  } = useForm<CreateNotebookFormData>({\n    resolver: zodResolver(createNotebookSchema),\n    mode: 'onChange',\n    defaultValues: {\n      name: '',\n      description: '',\n    },\n  })\n\n  const closeDialog = () => onOpenChange(false)\n\n  const onSubmit = async (data: CreateNotebookFormData) => {\n    await createNotebook.mutateAsync(data)\n    closeDialog()\n    reset()\n  }\n\n  useEffect(() => {\n    if (!open) {\n      reset()\n    }\n  }, [open, reset])\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[480px]\">\n        <DialogHeader>\n          <DialogTitle>{t.notebooks.createNew}</DialogTitle>\n          <DialogDescription>\n            {t.notebooks.createNewDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"notebook-name\">{t.common.name} *</Label>\n            <Input\n              id=\"notebook-name\"\n              {...register('name')}\n              placeholder={t.notebooks.namePlaceholder}\n              autoComplete=\"off\"\n            />\n            {errors.name && (\n              <p className=\"text-sm text-destructive\">{errors.name.message}</p>\n            )}\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"notebook-description\">{t.common.description}</Label>\n            <Textarea\n              id=\"notebook-description\"\n              {...register('description')}\n              placeholder={t.notebooks.descPlaceholder}\n              rows={4}\n            />\n          </div>\n\n          <DialogFooter className=\"gap-2 sm:gap-0\">\n            <Button type=\"button\" variant=\"outline\" onClick={closeDialog}>\n              {t.common.cancel}\n            </Button>\n            <Button type=\"submit\" disabled={!isValid || createNotebook.isPending}>\n              {createNotebook.isPending ? t.common.creating : t.notebooks.createNew}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/EpisodeCard.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useState } from 'react'\nimport { formatDistanceToNow } from 'date-fns'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { InfoIcon, RefreshCcw, Trash2 } from 'lucide-react'\n\nimport { resolvePodcastAssetUrl } from '@/lib/api/podcasts'\nimport { EpisodeStatus, FAILED_EPISODE_STATUSES, PodcastEpisode } from '@/lib/types/podcasts'\nimport { cn } from '@/lib/utils'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent } from '@/components/ui/card'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { TranslationKeys } from '@/lib/locales'\n\ninterface EpisodeCardProps {\n  episode: PodcastEpisode\n  onDelete: (episodeId: string) => Promise<void> | void\n  deleting?: boolean\n  onRetry?: (episodeId: string) => Promise<void> | void\n  retrying?: boolean\n}\n\nconst getSTATUS_META = (t: TranslationKeys): Record<\n  EpisodeStatus | 'unknown',\n  { label: string; className: string }\n> => ({\n  running: {\n    label: t.podcasts.processingLabel,\n    className: 'bg-amber-100 text-amber-800 border-amber-200',\n  },\n  processing: {\n    label: t.podcasts.processingLabel,\n    className: 'bg-amber-100 text-amber-800 border-amber-200',\n  },\n  completed: {\n    label: t.podcasts.completedLabel,\n    className: 'bg-emerald-100 text-emerald-800 border-emerald-200',\n  },\n  failed: {\n    label: t.podcasts.failedLabel,\n    className: 'bg-red-100 text-red-800 border-red-200',\n  },\n  error: {\n    label: t.podcasts.failedLabel,\n    className: 'bg-red-100 text-red-800 border-red-200',\n  },\n  pending: {\n    label: t.podcasts.pendingLabel,\n    className: 'bg-sky-100 text-sky-800 border-sky-200',\n  },\n  submitted: {\n    label: t.podcasts.pendingLabel,\n    className: 'bg-sky-100 text-sky-800 border-sky-200',\n  },\n  unknown: {\n    label: t.common.unknown,\n    className: 'bg-muted text-muted-foreground border-transparent',\n  },\n})\n\nfunction StatusBadge({ status }: { status?: EpisodeStatus | null }) {\n  const { t } = useTranslation()\n  // Don't show badge for completed episodes\n  if (status === 'completed') {\n    return null\n  }\n\n  const meta = getSTATUS_META(t)[status ?? 'unknown']\n  return (\n    <Badge\n      variant=\"outline\"\n      className={cn('uppercase tracking-wide text-xs', meta.className)}\n    >\n      {meta.label}\n    </Badge>\n  )\n}\n\ntype OutlineSegment = {\n  name?: string\n  description?: string\n  size?: string\n}\n\ntype OutlineData = {\n  segments?: OutlineSegment[]\n}\n\ntype TranscriptEntry = {\n  speaker?: string\n  dialogue?: string\n}\n\ntype TranscriptData = {\n  transcript?: TranscriptEntry[]\n}\n\nfunction extractOutlineSegments(outline: unknown): OutlineSegment[] {\n  if (outline && typeof outline === 'object' && 'segments' in outline) {\n    const data = outline as OutlineData\n    if (Array.isArray(data.segments)) {\n      return data.segments\n    }\n  }\n  return []\n}\n\nfunction extractTranscriptEntries(transcript: unknown): TranscriptEntry[] {\n  if (transcript && typeof transcript === 'object' && 'transcript' in transcript) {\n    const data = transcript as TranscriptData\n    if (Array.isArray(data.transcript)) {\n      return data.transcript\n    }\n  }\n  return []\n}\n\nexport function EpisodeCard({ episode, onDelete, deleting, onRetry, retrying }: EpisodeCardProps) {\n  const { t, language } = useTranslation()\n  const [audioSrc, setAudioSrc] = useState<string | undefined>()\n  const [audioError, setAudioError] = useState<string | null>(null)\n  const [detailsOpen, setDetailsOpen] = useState(false)\n\n  const outlineSegments = useMemo(() => extractOutlineSegments(episode.outline), [episode.outline])\n  const transcriptEntries = useMemo(() => extractTranscriptEntries(episode.transcript), [episode.transcript])\n\n  useEffect(() => {\n    let revokeUrl: string | undefined\n    setAudioError(null)\n\n    // If backend exposed a protected endpoint, fetch it with auth headers\n    const loadProtectedAudio = async () => {\n      // First resolve the audio URL\n      const directAudioUrl = await resolvePodcastAssetUrl(episode.audio_url ?? episode.audio_file)\n\n      if (!directAudioUrl || !episode.audio_url) {\n        setAudioSrc(directAudioUrl)\n        return\n      }\n\n      try {\n        let token: string | undefined\n        if (typeof window !== 'undefined') {\n          const raw = window.localStorage.getItem('auth-storage')\n          if (raw) {\n            try {\n              const parsed = JSON.parse(raw)\n              token = parsed?.state?.token\n            } catch (error) {\n              console.error('Failed to parse auth storage', error)\n            }\n          }\n        }\n\n        const headers: HeadersInit = {}\n        if (token) {\n          headers.Authorization = `Bearer ${token}`\n        }\n\n        const response = await fetch(directAudioUrl, { headers })\n        if (!response.ok) {\n          throw new Error(`Audio request failed with status ${response.status}`)\n        }\n\n        const blob = await response.blob()\n        revokeUrl = URL.createObjectURL(blob)\n        setAudioSrc(revokeUrl)\n      } catch (error) {\n        console.error('Unable to load podcast audio', error)\n        setAudioError(t.podcasts.audioUnavailable)\n        setAudioSrc(undefined)\n      }\n    }\n\n    void loadProtectedAudio()\n\n    return () => {\n      if (revokeUrl) {\n        URL.revokeObjectURL(revokeUrl)\n      }\n    }\n  }, [episode.audio_url, episode.audio_file, t])\n\n  const distance = episode.created\n    ? formatDistanceToNow(new Date(episode.created), {\n        addSuffix: true,\n        locale: getDateLocale(language),\n      })\n    : null\n\n  const createdLabel = distance\n    ? t.podcasts.created.replace('{time}', distance)\n    : null\n\n  const handleDelete = () => {\n    void onDelete(episode.id)\n  }\n\n  const handleRetry = () => {\n    if (onRetry) {\n      void onRetry(episode.id)\n    }\n  }\n\n  const isFailed = FAILED_EPISODE_STATUSES.includes(episode.job_status as EpisodeStatus)\n\n  return (\n    <Card className=\"shadow-sm\">\n      <CardContent className=\"space-y-4 p-4\">\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n          <div className=\"space-y-1\">\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <h3 className=\"text-base font-semibold text-foreground\">\n                {episode.name}\n              </h3>\n              <StatusBadge status={episode.job_status} />\n            </div>\n            <p className=\"text-xs text-muted-foreground\">\n              {t.podcasts.profile}: {episode.episode_profile?.name || t.common.unknown}\n              {createdLabel ? ` • ${createdLabel}` : ''}\n            </p>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>\n              <DialogTrigger asChild>\n                <Button variant=\"outline\" size=\"sm\">\n                  <InfoIcon className=\"mr-2 h-4 w-4\" /> {t.podcasts.details}\n                </Button>\n              </DialogTrigger>\n              <DialogContent className=\"w-[min(90vw,720px)] max-h-[85vh] overflow-hidden\">\n                <DialogHeader>\n                  <DialogTitle>{episode.name}</DialogTitle>\n                  <DialogDescription>\n                    {episode.episode_profile?.name || t.common.unknown}\n                    {createdLabel ? ` • ${createdLabel}` : ''}\n                  </DialogDescription>\n                </DialogHeader>\n                <div className=\"space-y-4 overflow-hidden\">\n                  {audioSrc ? (\n                    <audio controls preload=\"none\" src={audioSrc} className=\"w-full\" />\n                  ) : audioError ? (\n                    <p className=\"text-sm text-destructive\">{audioError}</p>\n                  ) : null}\n\n                  <Tabs defaultValue=\"summary\" className=\"h-[60vh] flex flex-col\">\n                    <TabsList className=\"grid w-full grid-cols-3\">\n                      <TabsTrigger value=\"summary\">{t.podcasts.summaryTab}</TabsTrigger>\n                      <TabsTrigger value=\"outline\">{t.podcasts.outlineTab}</TabsTrigger>\n                      <TabsTrigger value=\"transcript\">{t.podcasts.transcriptTab}</TabsTrigger>\n                    </TabsList>\n\n                    <TabsContent value=\"summary\" className=\"flex-1 overflow-hidden\">\n                      <ScrollArea className=\"h-full pr-4\">\n                        <div className=\"space-y-6\">\n                          <section className=\"space-y-2\">\n                            <h4 className=\"text-sm font-semibold text-foreground\">{t.podcasts.episodeProfile}</h4>\n                            <div className=\"grid gap-2 text-sm md:grid-cols-2\">\n                              <div>\n                                <p className=\"text-muted-foreground\">{t.podcasts.outlineModel}</p>\n                                <p>\n                                  {episode.episode_profile?.outline_provider ?? '—'} /\n                                  {' '}\n                                  {episode.episode_profile?.outline_model ?? '—'}\n                                </p>\n                              </div>\n                              <div>\n                                <p className=\"text-muted-foreground\">{t.podcasts.transcriptModel}</p>\n                                <p>\n                                  {episode.episode_profile?.transcript_provider ?? '—'} /\n                                  {' '}\n                                  {episode.episode_profile?.transcript_model ?? '—'}\n                                </p>\n                              </div>\n                              <div>\n                                <p className=\"text-muted-foreground\">{t.podcasts.segments}</p>\n                                <p>{episode.episode_profile?.num_segments ?? '—'}</p>\n                              </div>\n                            </div>\n                            {episode.episode_profile?.default_briefing ? (\n                              <div className=\"rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap\">\n                                {episode.episode_profile.default_briefing}\n                              </div>\n                            ) : null}\n                          </section>\n\n                          <section className=\"space-y-2\">\n                            <h4 className=\"text-sm font-semibold text-foreground\">{t.podcasts.speakerProfile}</h4>\n                            <p className=\"text-xs text-muted-foreground\">\n                              {episode.speaker_profile?.tts_provider ?? '—'} /{' '}\n                              {episode.speaker_profile?.tts_model ?? '—'}\n                            </p>\n                            {episode.speaker_profile?.speakers?.map((speaker, index) => (\n                              <div\n                                key={`${speaker.name}-${index}`}\n                                className=\"rounded-md border bg-muted/20 p-3 text-xs\"\n                              >\n                                <p className=\"font-semibold text-foreground\">{speaker.name}</p>\n                                <p className=\"text-muted-foreground\">{t.podcasts.voiceId}: {speaker.voice_id}</p>\n                                <p className=\"mt-2 whitespace-pre-wrap text-muted-foreground\">\n                                  <span className=\"font-semibold\">{t.podcasts.backstory}:</span> {speaker.backstory}\n                                </p>\n                                <p className=\"mt-2 whitespace-pre-wrap text-muted-foreground\">\n                                  <span className=\"font-semibold\">{t.podcasts.personality}:</span> {speaker.personality}\n                                </p>\n                              </div>\n                            ))}\n                          </section>\n\n                          {episode.briefing ? (\n                            <section className=\"space-y-2\">\n                              <h4 className=\"text-sm font-semibold text-foreground\">{t.podcasts.briefing}</h4>\n                              <div className=\"rounded border bg-muted/30 p-3 text-xs whitespace-pre-wrap\">\n                                {episode.briefing}\n                              </div>\n                            </section>\n                          ) : null}\n                        </div>\n                      </ScrollArea>\n                    </TabsContent>\n\n                    <TabsContent value=\"outline\" className=\"flex-1 overflow-hidden\">\n                      <ScrollArea className=\"h-full pr-4\">\n                        {outlineSegments.length > 0 ? (\n                          <div className=\"space-y-3\">\n                            {outlineSegments.map((segment, index) => (\n                              <div key={index} className=\"rounded border bg-muted/20 p-3 text-xs space-y-1\">\n                                <div className=\"flex items-center justify-between gap-2\">\n                                  <p className=\"font-semibold text-foreground\">{segment.name ?? `${t.podcasts.segment} ${index + 1}`}</p>\n                                  {segment.size ? (\n                                    <Badge variant=\"outline\" className=\"text-[10px] uppercase tracking-wide\">{segment.size}</Badge>\n                                  ) : null}\n                                </div>\n                                <p className=\"text-muted-foreground whitespace-pre-wrap\">{segment.description ?? t.podcasts.noDescription}</p>\n                              </div>\n                            ))}\n                          </div>\n                        ) : (\n                          <p className=\"text-xs text-muted-foreground\">{t.podcasts.noOutline}</p>\n                        )}\n                      </ScrollArea>\n                    </TabsContent>\n\n                    <TabsContent value=\"transcript\" className=\"flex-1 overflow-hidden\">\n                      <ScrollArea className=\"h-full pr-4 space-y-3\">\n                        {transcriptEntries.length > 0 ? (\n                          transcriptEntries.map((entry, index) => (\n                            <div key={index} className=\"rounded border bg-muted/20 p-3 text-xs space-y-1\">\n                              <p className=\"font-semibold text-foreground\">{entry.speaker ?? t.podcasts.speaker}</p>\n                              <p className=\"text-muted-foreground whitespace-pre-wrap\">{entry.dialogue ?? ''}</p>\n                            </div>\n                          ))\n                        ) : (\n                          <p className=\"text-xs text-muted-foreground\">{t.podcasts.noTranscript}</p>\n                        )}\n                      </ScrollArea>\n                    </TabsContent>\n                  </Tabs>\n                </div>\n              </DialogContent>\n            </Dialog>\n            {isFailed && onRetry ? (\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={handleRetry}\n                disabled={retrying}\n              >\n                <RefreshCcw className={cn('mr-2 h-4 w-4', retrying && 'animate-spin')} />\n                {retrying ? t.podcasts.retrying : t.podcasts.retry}\n              </Button>\n            ) : null}\n            <AlertDialog>\n              <AlertDialogTrigger asChild>\n                <Button variant=\"ghost\" size=\"sm\" className=\"text-destructive\">\n                  <Trash2 className=\"mr-2 h-4 w-4\" />\n                  {t.podcasts.delete}\n                </Button>\n              </AlertDialogTrigger>\n              <AlertDialogContent>\n                <AlertDialogHeader>\n                  <AlertDialogTitle>{t.podcasts.deleteEpisodeTitle}</AlertDialogTitle>\n                  <AlertDialogDescription>\n                    {t.podcasts.deleteEpisodeDesc.replace('{name}', episode.name)}\n                  </AlertDialogDescription>\n                </AlertDialogHeader>\n                <AlertDialogFooter>\n                  <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>\n                  <AlertDialogAction onClick={handleDelete} disabled={deleting}>\n                    {deleting ? t.podcasts.deleting : t.podcasts.delete}\n                  </AlertDialogAction>\n                </AlertDialogFooter>\n              </AlertDialogContent>\n            </AlertDialog>\n          </div>\n        </div>\n\n        {audioSrc ? (\n          <audio controls preload=\"none\" src={audioSrc} className=\"w-full\" />\n        ) : audioError ? (\n          <p className=\"text-sm text-destructive\">{audioError}</p>\n        ) : null}\n\n        {isFailed && episode.error_message ? (\n          <div className=\"rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950/30\">\n            <p className=\"text-xs font-medium text-red-800 dark:text-red-300\">{t.podcasts.errorDetails}</p>\n            <p className=\"mt-1 text-xs whitespace-pre-wrap text-red-700 dark:text-red-400\">{episode.error_message}</p>\n          </div>\n        ) : null}\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/EpisodeProfilesPanel.tsx",
    "content": "'use client'\n\nimport { useMemo, useState } from 'react'\nimport { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Users } from 'lucide-react'\n\nimport { EpisodeProfile, SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'\nimport {\n  useDeleteEpisodeProfile,\n  useDuplicateEpisodeProfile,\n} from '@/lib/hooks/use-podcasts'\nimport { useModels } from '@/lib/hooks/use-models'\nimport { EpisodeProfileFormDialog } from '@/components/podcasts/forms/EpisodeProfileFormDialog'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface EpisodeProfilesPanelProps {\n  episodeProfiles: EpisodeProfile[]\n  speakerProfiles: SpeakerProfile[]\n}\n\nfunction findSpeakerSummary(\n  speakerProfiles: SpeakerProfile[],\n  speakerName: string\n) {\n  return speakerProfiles.find((profile) => profile.name === speakerName)\n}\n\nexport function EpisodeProfilesPanel({\n  episodeProfiles,\n  speakerProfiles,\n}: EpisodeProfilesPanelProps) {\n  const { t } = useTranslation()\n  const [createOpen, setCreateOpen] = useState(false)\n  const [editProfile, setEditProfile] = useState<EpisodeProfile | null>(null)\n\n  const deleteProfile = useDeleteEpisodeProfile()\n  const duplicateProfile = useDuplicateEpisodeProfile()\n  const { data: models = [] } = useModels()\n\n  const modelNameMap = useMemo(() => {\n    const map: Record<string, string> = {}\n    for (const m of models) {\n      map[m.id] = `${m.provider} / ${m.name}`\n    }\n    return map\n  }, [models])\n\n  const sortedProfiles = useMemo(\n    () =>\n      [...episodeProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),\n    [episodeProfiles]\n  )\n\n  const disableCreate = speakerProfiles.length === 0\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h2 className=\"text-lg font-semibold\">{t.podcasts.episodeProfilesTitle}</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            {t.podcasts.episodeProfilesDesc}\n          </p>\n        </div>\n        <Button onClick={() => setCreateOpen(true)} disabled={disableCreate}>\n          {t.podcasts.createProfile}\n        </Button>\n      </div>\n\n      {disableCreate ? (\n        <p className=\"rounded-lg border border-dashed bg-amber-50 p-4 text-sm text-amber-900\">\n          {t.podcasts.createSpeakerFirst}\n        </p>\n      ) : null}\n\n      {sortedProfiles.length === 0 ? (\n        <div className=\"rounded-lg border border-dashed bg-muted/30 p-10 text-center text-sm text-muted-foreground\">\n          {t.podcasts.noEpisodeProfiles}\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {sortedProfiles.map((profile) => {\n            const speakerSummary = findSpeakerSummary(\n              speakerProfiles,\n              profile.speaker_config\n            )\n            const unconfigured = needsModelSetup(profile)\n\n            return (\n              <Card key={profile.id} className=\"shadow-sm\">\n                <CardHeader className=\"flex flex-col gap-2 md:flex-row md:items-start md:justify-between\">\n                  <div>\n                    <div className=\"flex items-center gap-2\">\n                      <CardTitle className=\"text-lg font-semibold\">\n                        {profile.name}\n                      </CardTitle>\n                      {unconfigured ? (\n                        <Badge variant=\"outline\" className=\"text-amber-600 border-amber-300 text-xs\">\n                          <AlertTriangle className=\"h-3 w-3 mr-1\" />\n                          {t.podcasts.setupRequired}\n                        </Badge>\n                      ) : null}\n                    </div>\n                    <CardDescription className=\"text-sm text-muted-foreground\">\n                      {profile.description || t.podcasts.noDescription}\n                    </CardDescription>\n                  </div>\n                  <div className=\"flex items-center gap-1\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => setEditProfile(profile)}\n                    >\n                      <Edit3 className=\"mr-2 h-4 w-4\" /> {t.podcasts.edit}\n                    </Button>\n                    <AlertDialog>\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"h-8 w-8\"\n                            onClick={(e) => e.stopPropagation()}\n                          >\n                            <MoreVertical className=\"h-4 w-4\" />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent\n                          align=\"end\"\n                          className=\"w-44\"\n                          onClick={(e) => e.stopPropagation()}\n                        >\n                          <DropdownMenuItem\n                            onClick={() => duplicateProfile.mutate(profile.id)}\n                            disabled={duplicateProfile.isPending}\n                          >\n                            <Copy className=\"h-4 w-4 mr-2\" />\n                            {t.podcasts.duplicate}\n                          </DropdownMenuItem>\n                          <DropdownMenuSeparator />\n                          <AlertDialogTrigger asChild>\n                            <DropdownMenuItem className=\"text-destructive focus:text-destructive\">\n                              <Trash2 className=\"h-4 w-4 mr-2\" />\n                              {t.podcasts.delete}\n                            </DropdownMenuItem>\n                          </AlertDialogTrigger>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                      <AlertDialogContent>\n                        <AlertDialogHeader>\n                          <AlertDialogTitle>{t.podcasts.deleteProfileTitle}</AlertDialogTitle>\n                          <AlertDialogDescription>\n                            {t.podcasts.deleteProfileDesc.replace('{name}', profile.name)}\n                          </AlertDialogDescription>\n                        </AlertDialogHeader>\n                        <AlertDialogFooter>\n                          <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>\n                          <AlertDialogAction\n                            onClick={() => deleteProfile.mutate(profile.id)}\n                            disabled={deleteProfile.isPending}\n                          >\n                            {deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}\n                          </AlertDialogAction>\n                        </AlertDialogFooter>\n                      </AlertDialogContent>\n                    </AlertDialog>\n                  </div>\n                </CardHeader>\n\n                <CardContent className=\"space-y-4 text-sm\">\n                  <div className=\"grid gap-3 md:grid-cols-2\">\n                    <div>\n                      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                        {t.podcasts.outlineModel}\n                      </p>\n                      <p className=\"text-foreground\">\n                        {profile.outline_llm\n                          ? (modelNameMap[profile.outline_llm] ?? profile.outline_llm)\n                          : (profile.outline_provider && profile.outline_model\n                            ? `${profile.outline_provider} / ${profile.outline_model}`\n                            : t.podcasts.notConfigured)}\n                      </p>\n                    </div>\n                    <div>\n                      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                        {t.podcasts.transcriptModel}\n                      </p>\n                      <p className=\"text-foreground\">\n                        {profile.transcript_llm\n                          ? (modelNameMap[profile.transcript_llm] ?? profile.transcript_llm)\n                          : (profile.transcript_provider && profile.transcript_model\n                            ? `${profile.transcript_provider} / ${profile.transcript_model}`\n                            : t.podcasts.notConfigured)}\n                      </p>\n                    </div>\n                    <div>\n                      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                        {t.podcasts.segments}\n                      </p>\n                      <p className=\"text-foreground\">{profile.num_segments}</p>\n                    </div>\n                    {profile.language ? (\n                      <div>\n                        <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                          {t.podcasts.language}\n                        </p>\n                        <p className=\"text-foreground\">{profile.language}</p>\n                      </div>\n                    ) : null}\n                    <div>\n                      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                        {t.podcasts.speakerProfile}\n                      </p>\n                      <div className=\"flex items-center gap-2 text-foreground\">\n                        <Users className=\"h-4 w-4\" />\n                        <span>{profile.speaker_config}</span>\n                        {speakerSummary?.voice_model ? (\n                          <Badge variant=\"outline\" className=\"text-xs\">\n                            {modelNameMap[speakerSummary.voice_model] ?? speakerSummary.voice_model}\n                          </Badge>\n                        ) : speakerSummary?.tts_provider ? (\n                          <Badge variant=\"outline\" className=\"text-xs\">\n                            {speakerSummary.tts_provider} / {speakerSummary.tts_model}\n                          </Badge>\n                        ) : null}\n                      </div>\n                    </div>\n                  </div>\n\n                  {profile.default_briefing ? (\n                    <div>\n                      <p className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                        {t.podcasts.defaultBriefingTitle}\n                      </p>\n                      <p className=\"mt-1 whitespace-pre-wrap text-muted-foreground\">\n                        {profile.default_briefing}\n                      </p>\n                    </div>\n                  ) : null}\n                </CardContent>\n              </Card>\n            )\n          })}\n        </div>\n      )}\n\n      <EpisodeProfileFormDialog\n        mode=\"create\"\n        open={createOpen}\n        onOpenChange={setCreateOpen}\n        speakerProfiles={speakerProfiles}\n      />\n\n      <EpisodeProfileFormDialog\n        mode=\"edit\"\n        open={Boolean(editProfile)}\n        onOpenChange={(open) => {\n          if (!open) {\n            setEditProfile(null)\n          }\n        }}\n        speakerProfiles={speakerProfiles}\n        initialData={editProfile ?? undefined}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/EpisodesTab.tsx",
    "content": "'use client'\n\nimport { useCallback, useState } from 'react'\nimport { AlertCircle, Loader2, RefreshCcw } from 'lucide-react'\n\nimport { useDeletePodcastEpisode, usePodcastEpisodes, useRetryPodcastEpisode } from '@/lib/hooks/use-podcasts'\nimport { EpisodeCard } from '@/components/podcasts/EpisodeCard'\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Separator } from '@/components/ui/separator'\nimport { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { TranslationKeys } from '@/lib/locales'\n\nconst getSTATUS_ORDER = (t: TranslationKeys): Array<{\n  key: 'running' | 'completed' | 'failed' | 'pending'\n  title: string\n  description?: string\n}> => [\n  {\n    key: 'running',\n    title: t.podcasts.statusRunningTitle,\n    description: t.podcasts.statusRunningDesc,\n  },\n  {\n    key: 'pending',\n    title: t.podcasts.statusPendingTitle,\n    description: t.podcasts.statusPendingDesc,\n  },\n  {\n    key: 'completed',\n    title: t.podcasts.statusCompletedTitle,\n    description: t.podcasts.statusCompletedDesc,\n  },\n  {\n    key: 'failed',\n    title: t.podcasts.statusFailedTitle,\n    description: t.podcasts.statusFailedDesc,\n  },\n]\n\nfunction SummaryBadge({ label, value }: { label: string; value: number }) {\n  return (\n    <Badge variant=\"outline\" className=\"font-medium\">\n      <span className=\"text-muted-foreground mr-1.5\">{label}</span>\n      <span className=\"text-foreground\">{value}</span>\n    </Badge>\n  )\n}\n\nexport function EpisodesTab() {\n  const { t } = useTranslation()\n  const [showGenerateDialog, setShowGenerateDialog] = useState(false)\n  const {\n    episodes,\n    statusGroups,\n    statusCounts,\n    isLoading,\n    isError,\n    refetch,\n    isFetching,\n  } = usePodcastEpisodes()\n  const deleteEpisode = useDeletePodcastEpisode()\n  const retryEpisode = useRetryPodcastEpisode()\n\n  const handleRefresh = useCallback(() => {\n    void refetch()\n  }, [refetch])\n\n  const handleDelete = useCallback(\n    (episodeId: string) => deleteEpisode.mutateAsync(episodeId),\n    [deleteEpisode]\n  )\n\n  const handleRetry = useCallback(\n    async (episodeId: string) => { await retryEpisode.mutateAsync(episodeId) },\n    [retryEpisode]\n  )\n\n  const emptyState = !isLoading && episodes.length === 0\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div className=\"space-y-1\">\n          <h2 className=\"text-xl font-semibold\">{t.podcasts.overviewTitle}</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            {t.podcasts.overviewDesc}\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Button onClick={() => setShowGenerateDialog(true)}>\n            {t.podcasts.generateBtn}\n          </Button>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={handleRefresh}\n            disabled={isFetching}\n          >\n            {isFetching ? (\n              <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n            ) : (\n              <RefreshCcw className=\"mr-2 h-4 w-4\" />\n            )}\n            {t.common.refresh}\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap gap-2\">\n        <SummaryBadge label={t.podcasts.total} value={statusCounts.total} />\n        <SummaryBadge label={t.podcasts.processingLabel} value={statusCounts.running} />\n        <SummaryBadge label={t.podcasts.completedLabel} value={statusCounts.completed} />\n        <SummaryBadge label={t.podcasts.failedLabel} value={statusCounts.failed} />\n        <SummaryBadge label={t.podcasts.pendingLabel} value={statusCounts.pending} />\n      </div>\n\n      {isError ? (\n        <Alert variant=\"destructive\">\n          <AlertCircle className=\"h-4 w-4\" />\n          <AlertTitle>{t.podcasts.loadErrorTitle}</AlertTitle>\n          <AlertDescription>\n            {t.podcasts.loadErrorDesc}\n          </AlertDescription>\n        </Alert>\n      ) : null}\n\n      {isLoading ? (\n        <div className=\"flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground\">\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n          {t.podcasts.loadingEpisodes}\n        </div>\n      ) : null}\n\n      {emptyState ? (\n        <div className=\"rounded-lg border border-dashed bg-muted/30 p-10 text-center\">\n          <p className=\"text-sm text-muted-foreground\">\n            {t.podcasts.noEpisodesYet}\n          </p>\n        </div>\n      ) : null}\n\n      {getSTATUS_ORDER(t).map(({ key, title, description }) => {\n        const data = statusGroups[key]\n        if (!data || data.length === 0) {\n          return null\n        }\n\n        return (\n          <section key={key} className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-lg font-semibold leading-tight\">{title}</h3>\n              {description ? (\n                <p className=\"text-sm text-muted-foreground\">{description}</p>\n              ) : null}\n            </div>\n            <Separator />\n            <div className=\"space-y-4\">\n              {data.map((episode) => (\n                <EpisodeCard\n                  key={episode.id}\n                  episode={episode}\n                  onDelete={handleDelete}\n                  deleting={deleteEpisode.isPending}\n                  onRetry={handleRetry}\n                  retrying={retryEpisode.isPending}\n                />\n              ))}\n            </div>\n          </section>\n        )\n      })}\n\n      <GeneratePodcastDialog\n        open={showGenerateDialog}\n        onOpenChange={setShowGenerateDialog}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/GeneratePodcastDialog.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { Loader2 } from 'lucide-react'\nimport { useQueries, useQueryClient } from '@tanstack/react-query'\n\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { useEpisodeProfiles, useGeneratePodcast } from '@/lib/hooks/use-podcasts'\nimport { chatApi } from '@/lib/api/chat'\nimport { sourcesApi } from '@/lib/api/sources'\nimport { notesApi } from '@/lib/api/notes'\nimport { BuildContextRequest, NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api'\nimport type { QueryClient } from '@tanstack/react-query'\nimport { PodcastGenerationRequest } from '@/lib/types/podcasts'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Badge } from '@/components/ui/badge'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'\nimport { Input } from '@/components/ui/input'\nimport { Textarea } from '@/components/ui/textarea'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Label } from '@/components/ui/label'\nimport { Separator } from '@/components/ui/separator'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'\n\ntype SourceMode = 'off' | 'insights' | 'full'\n\ninterface NotebookSelection {\n  sources: Record<string, SourceMode>\n  notes: Record<string, SourceMode>\n}\n\n// Helper function to format large numbers with K/M suffixes\nfunction formatNumber(num: number): string {\n  if (num >= 1000000) {\n    return `${(num / 1000000).toFixed(1)}M`\n  }\n  if (num >= 1000) {\n    return `${(num / 1000).toFixed(1)}K`\n  }\n  return num.toString()\n}\n\nfunction hasSelections(selection?: NotebookSelection): boolean {\n  if (!selection) {\n    return false\n  }\n  return (\n    Object.values(selection.sources).some((mode) => mode !== 'off') ||\n    Object.values(selection.notes).some((mode) => mode !== 'off')\n  )\n}\n\nfunction getSourceDefaultMode(source: SourceListResponse): SourceMode {\n  return source.insights_count && source.insights_count > 0 ? 'insights' : 'full'\n}\n\ninterface GeneratePodcastDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\ninterface NotebookSummary {\n  notebookId: string\n  sources: number\n  notes: number\n}\n\ninterface ContentSelectionPanelProps {\n  notebooks: NotebookResponse[]\n  isLoading: boolean\n  selectedNotebookSummaries: NotebookSummary[]\n  tokenCount: number\n  charCount: number\n  expandedNotebooks: string[]\n  setExpandedNotebooks: (notebooks: string[]) => void\n  selections: Record<string, NotebookSelection>\n  sourcesByNotebook: Record<string, SourceListResponse[]>\n  notesByNotebook: Record<string, NoteResponse[]>\n  fetchingNotebookIds: Set<string>\n  handleNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void\n  handleSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void\n  handleNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void\n  queryClient: QueryClient\n}\n\n// Extracted component for content selection panel\nfunction ContentSelectionPanel({\n  notebooks,\n  isLoading,\n  selectedNotebookSummaries,\n  tokenCount,\n  charCount,\n  expandedNotebooks,\n  setExpandedNotebooks,\n  selections,\n  sourcesByNotebook,\n  notesByNotebook,\n  fetchingNotebookIds,\n  handleNotebookToggle,\n  handleSourceModeChange,\n  handleNoteToggle,\n  queryClient,\n}: ContentSelectionPanelProps) {\n  const { t, language } = useTranslation()\n\n  // Cache all translation strings at render time to avoid repeated Proxy accesses in loops\n  // This prevents the infinite loop detection from triggering\n  const tr = {\n    content: t.podcasts.content,\n    contentDesc: t.podcasts.contentDesc,\n    itemsSelected: t.podcasts.itemsSelected,\n    tokens: t.podcasts.tokens,\n    chars: t.podcasts.chars,\n    loadingNotebooks: t.podcasts.loadingNotebooks,\n    noNotebooksFoundInPodcasts: t.podcasts.noNotebooksFoundInPodcasts,\n    sources: t.podcasts.sources,\n    notes: t.podcasts.notes,\n    noContentSelected: t.podcasts.noContentSelected,\n    noSources: t.podcasts.noSources,\n    untitledSource: t.podcasts.untitledSource,\n    link: t.podcasts.link,\n    file: t.podcasts.file,\n    embedded: t.podcasts.embedded,\n    notEmbedded: t.podcasts.notEmbedded,\n    selectMode: t.podcasts.selectMode,\n    noNotes: t.podcasts.noNotes,\n    untitledNote: t.podcasts.untitledNote,\n    commonUpdated: t.common.updated,\n    summary: t.podcasts.summary,\n    fullContent: t.podcasts.fullContent,\n  }\n\n  // Pre-compute source modes once to avoid repeated t.podcasts access in loops\n  const sourceModes = [\n    { value: 'insights', label: tr.summary },\n    { value: 'full', label: tr.fullContent },\n  ] as const\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n            {tr.content}\n          </h3>\n          <p className=\"text-xs text-muted-foreground\">\n            {tr.contentDesc}\n          </p>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <Badge variant=\"outline\">\n            {tr.itemsSelected.replace(\n              '{count}',\n              selectedNotebookSummaries.reduce(\n                (acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,\n                0\n              ).toString()\n            )}\n          </Badge>\n          {(tokenCount > 0 || charCount > 0) && (\n            <span className=\"text-xs text-muted-foreground\">\n              {tokenCount > 0 && tr.tokens.replace('{count}', formatNumber(tokenCount))}\n              {tokenCount > 0 && charCount > 0 && ' / '}\n              {charCount > 0 && tr.chars.replace('{count}', formatNumber(charCount))}\n            </span>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-lg border bg-muted/30\">\n        {isLoading ? (\n          <div className=\"flex items-center justify-center py-16 text-sm text-muted-foreground\">\n            <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> {tr.loadingNotebooks}\n          </div>\n        ) : notebooks.length === 0 ? (\n          <div className=\"p-6 text-sm text-muted-foreground\">\n            {tr.noNotebooksFoundInPodcasts}\n          </div>\n        ) : (\n          <ScrollArea className=\"h-[60vh]\">\n            <Accordion\n              type=\"multiple\"\n              value={expandedNotebooks}\n              onValueChange={(value) => setExpandedNotebooks(value as string[])}\n              className=\"w-full\"\n            >\n              {notebooks.map((notebook: NotebookResponse, index: number) => {\n                const sources = sourcesByNotebook[notebook.id] ?? []\n                const notes = notesByNotebook[notebook.id] ?? []\n                const selection = selections[notebook.id]\n                const summary = selectedNotebookSummaries[index]\n                const notebookChecked = summary.sources + summary.notes > 0\n                const totalItems = sources.length + notes.length\n                const isIndeterminate =\n                  notebookChecked &&\n                  summary.sources + summary.notes > 0 &&\n                  summary.sources + summary.notes < totalItems\n\n                return (\n                  <AccordionItem key={notebook.id} value={notebook.id}>\n                    <div className=\"flex items-start gap-3 px-4 pt-3\">\n                      <Checkbox\n                        id={`notebook-toggle-${notebook.id}`}\n                        checked={isIndeterminate ? 'indeterminate' : notebookChecked}\n                        onCheckedChange={(checked) => {\n                          handleNotebookToggle(notebook.id, checked)\n                          queryClient.prefetchQuery({\n                            queryKey: QUERY_KEYS.sources(notebook.id),\n                            queryFn: () => sourcesApi.list({ notebook_id: notebook.id }),\n                          })\n                          queryClient.prefetchQuery({\n                            queryKey: QUERY_KEYS.notes(notebook.id),\n                            queryFn: () => notesApi.list({ notebook_id: notebook.id }),\n                          })\n                        }}\n                        onClick={(event) => event.stopPropagation()}\n                      />\n                      <AccordionTrigger className=\"flex-1 px-0 py-0 hover:no-underline\">\n                        <Label\n                          htmlFor={`notebook-toggle-${notebook.id}`}\n                          className=\"flex w-full items-center justify-between gap-3 pointer-events-none\"\n                        >\n                          <div className=\"text-left\">\n                            <p className=\"font-medium text-sm text-foreground\">\n                              {notebook.name}\n                            </p>\n                            <p className=\"text-xs text-muted-foreground\">\n                              {summary.sources + summary.notes > 0\n                                ? `${summary.sources} ${tr.sources}, ${summary.notes} ${tr.notes}`\n                                : tr.noContentSelected}\n                            </p>\n                          </div>\n                          <Badge variant=\"outline\" className=\"text-xs\">\n                            {sources.length} {tr.sources} · {notes.length} {tr.notes}\n                          </Badge>\n                        </Label>\n                      </AccordionTrigger>\n                    </div>\n                    <AccordionContent>\n                      <div className=\"space-y-4 px-4 pb-4\">\n                        <div className=\"space-y-2\">\n                          <div className=\"flex items-center justify-between\">\n                            <h4 className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                              {tr.sources}\n                            </h4>\n                            {fetchingNotebookIds.has(notebook.id) && (\n                              <Loader2 className=\"h-3 w-3 animate-spin text-muted-foreground\" />\n                            )}\n                          </div>\n                          {sources.length === 0 ? (\n                            <p className=\"text-xs text-muted-foreground\">\n                              {tr.noSources}\n                            </p>\n                          ) : (\n                            <div className=\"space-y-2\">\n                              {sources.map((source: SourceListResponse) => {\n                                const mode = selection?.sources?.[source.id] ?? 'off'\n                                return (\n                                  <div\n                                    key={source.id}\n                                    className=\"flex items-center gap-3 rounded border bg-background px-3 py-2\"\n                                  >\n                                    <Checkbox\n                                      id={`source-selection-${source.id}`}\n                                      checked={mode !== 'off'}\n                                      onCheckedChange={(checked) =>\n                                        handleSourceModeChange(\n                                          notebook.id,\n                                          source.id,\n                                          checked ? getSourceDefaultMode(source) : 'off'\n                                        )\n                                      }\n                                    />\n                                    <Label\n                                      htmlFor={`source-selection-${source.id}`}\n                                      className=\"flex flex-1 flex-col gap-1 cursor-pointer\"\n                                    >\n                                      <span className=\"text-sm font-medium text-foreground\">\n                                        {source.title || tr.untitledSource}\n                                      </span>\n                                      <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                                        <span>{source.asset?.url ? tr.link : tr.file}</span>\n                                        <span>•</span>\n                                        <span>{source.embedded ? tr.embedded : tr.notEmbedded}</span>\n                                      </div>\n                                    </Label>\n                                    <Select\n                                      value={mode === 'off' ? 'off' : mode}\n                                      onValueChange={(value) =>\n                                        handleSourceModeChange(\n                                          notebook.id,\n                                          source.id,\n                                          value as SourceMode\n                                        )\n                                      }\n                                      disabled={mode === 'off'}\n                                    >\n                                      <SelectTrigger className=\"w-[140px]\">\n                                        <SelectValue placeholder={tr.selectMode} />\n                                      </SelectTrigger>\n                                      <SelectContent>\n                                        {sourceModes.map((option) => (\n                                          <SelectItem\n                                            key={option.value}\n                                            value={option.value}\n                                            disabled={\n                                              option.value === 'insights' &&\n                                              (!source.insights_count || source.insights_count === 0)\n                                            }\n                                          >\n                                            {option.label}\n                                          </SelectItem>\n                                        ))}\n                                      </SelectContent>\n                                    </Select>\n                                  </div>\n                                )\n                              })}\n                            </div>\n                          )}\n                        </div>\n\n                        <Separator />\n\n                        <div className=\"space-y-2\">\n                          <h4 className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                            {tr.notes}\n                          </h4>\n                          {notes.length === 0 ? (\n                            <p className=\"text-xs text-muted-foreground\">\n                              {tr.noNotes}\n                            </p>\n                          ) : (\n                            <div className=\"space-y-2\">\n                              {notes.map((note: NoteResponse) => {\n                                const mode = selection?.notes?.[note.id] ?? 'off'\n                                return (\n                                  <div\n                                    key={note.id}\n                                    className=\"flex items-center gap-3 rounded border bg-background px-3 py-2\"\n                                  >\n                                    <Checkbox\n                                      id={`note-selection-${note.id}`}\n                                      checked={mode !== 'off'}\n                                      onCheckedChange={(checked) =>\n                                        handleNoteToggle(\n                                          notebook.id,\n                                          note.id,\n                                          Boolean(checked)\n                                        )\n                                      }\n                                    />\n                                    <Label\n                                      htmlFor={`note-selection-${note.id}`}\n                                      className=\"flex flex-1 flex-col cursor-pointer\"\n                                    >\n                                      <span className=\"text-sm font-medium text-foreground\">\n                                        {note.title || tr.untitledNote}\n                                      </span>\n                                      <span className=\"text-xs text-muted-foreground\">\n                                        {tr.commonUpdated}{' '}\n                                        {new Date(note.updated).toLocaleString(\n                                          language.startsWith('zh') ? language : 'en-US'\n                                        )}\n                                      </span>\n                                    </Label>\n                                  </div>\n                                )\n                              })}\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    </AccordionContent>\n                  </AccordionItem>\n                )\n              })}\n            </Accordion>\n          </ScrollArea>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {\n  const { t } = useTranslation()\n  const { toast } = useToast()\n  const queryClient = useQueryClient()\n  const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])\n  const [selections, setSelections] = useState<Record<string, NotebookSelection>>({})\n  const [episodeProfileId, setEpisodeProfileId] = useState<string>('')\n  const [episodeName, setEpisodeName] = useState('')\n  const [instructions, setInstructions] = useState('')\n\n  const [isBuildingContext, setIsBuildingContext] = useState(false)\n  const [tokenCount, setTokenCount] = useState<number>(0)\n  const [charCount, setCharCount] = useState<number>(0)\n\n  const notebooksQuery = useNotebooks()\n  const episodeProfilesQuery = useEpisodeProfiles()\n  const generatePodcast = useGeneratePodcast()\n\n  const notebooks = useMemo(\n    () => notebooksQuery.data ?? [],\n    [notebooksQuery.data]\n  )\n  const episodeProfiles = useMemo(\n    () => episodeProfilesQuery.episodeProfiles ?? [],\n    [episodeProfilesQuery.episodeProfiles]\n  )\n\n  // Fetch sources and notes for notebooks using useQueries\n  const sourcesQueries = useQueries({\n    queries: notebooks.map((notebook) => ({\n      queryKey: QUERY_KEYS.sources(notebook.id),\n      queryFn: () => sourcesApi.list({ notebook_id: notebook.id }),\n      enabled:\n        open &&\n        (expandedNotebooks.includes(notebook.id) || hasSelections(selections[notebook.id])),\n    })),\n  })\n\n  const notesQueries = useQueries({\n    queries: notebooks.map((notebook) => ({\n      queryKey: QUERY_KEYS.notes(notebook.id),\n      queryFn: () => notesApi.list({ notebook_id: notebook.id }),\n      enabled:\n        open &&\n        (expandedNotebooks.includes(notebook.id) || hasSelections(selections[notebook.id])),\n    })),\n  })\n\n  const sourcesByNotebook = useMemo<Record<string, SourceListResponse[]>>(() => {\n    const map: Record<string, SourceListResponse[]> = {}\n    notebooks.forEach((notebook, index) => {\n      map[notebook.id] = sourcesQueries[index]?.data ?? []\n    })\n    return map\n  }, [notebooks, sourcesQueries])\n\n  const notesByNotebook = useMemo<Record<string, NoteResponse[]>>(() => {\n    const map: Record<string, NoteResponse[]> = {}\n    notebooks.forEach((notebook, index) => {\n      map[notebook.id] = notesQueries[index]?.data ?? []\n    })\n    return map\n  }, [notebooks, notesQueries])\n\n  // Stable key for fetching state - only changes when actual fetching states change\n  const fetchingKey = useMemo(\n    () => sourcesQueries.map((q) => q.isFetching ? '1' : '0').join(''),\n    [sourcesQueries]\n  )\n\n  // Stable set of notebook IDs that are currently fetching sources\n  const fetchingNotebookIds = useMemo(() => {\n    const ids = new Set<string>()\n    notebooks.forEach((notebook, index) => {\n      if (sourcesQueries[index]?.isFetching) {\n        ids.add(notebook.id)\n      }\n    })\n    return ids\n  }, [notebooks, fetchingKey])\n\n  // Create a stable key based on actual data to prevent effect running on every render\n  // Only changes when actual source/note IDs change, not on every useQueries reference change\n  const dataKey = useMemo(() => {\n    const sourceIds = sourcesQueries\n      .map((q) => q.data?.map((s) => s.id)?.join(',') ?? '')\n      .join('|')\n    const noteIds = notesQueries\n      .map((q) => q.data?.map((n) => n.id)?.join(',') ?? '')\n      .join('|')\n    return `${sourceIds}::${noteIds}`\n  }, [sourcesQueries, notesQueries])\n\n  // Initialise selection defaults when content loads\n  // Using dataKey instead of sourcesQueries/notesQueries to prevent running on every render\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n\n    setSelections((prev) => {\n      let changed = false\n      const next = { ...prev }\n\n      notebooks.forEach((notebook, index) => {\n        const sources = sourcesQueries[index]?.data\n        const notes = notesQueries[index]?.data\n\n        if (!sources && !notes) {\n          return\n        }\n\n        if (!next[notebook.id]) {\n          next[notebook.id] = { sources: {}, notes: {} }\n          changed = true\n        }\n\n        if (sources) {\n          const currentSources = next[notebook.id].sources\n          sources.forEach((source) => {\n            if (!(source.id in currentSources)) {\n              currentSources[source.id] = getSourceDefaultMode(source)\n              changed = true\n            }\n          })\n        }\n\n        if (notes) {\n          const currentNotes = next[notebook.id].notes\n          notes.forEach((note) => {\n            if (!(note.id in currentNotes)) {\n              currentNotes[note.id] = 'full'\n              changed = true\n            }\n          })\n        }\n      })\n\n      return changed ? next : prev\n    })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, notebooks, dataKey])\n\n  const resetState = useCallback(() => {\n    setExpandedNotebooks([])\n    setSelections({})\n    setEpisodeProfileId('')\n    setEpisodeName('')\n    setInstructions('')\n    setTokenCount(0)\n    setCharCount(0)\n  }, [])\n\n  useEffect(() => {\n    if (!open) {\n      resetState()\n    }\n  }, [open, resetState])\n\n  // Update token/char counts when selections change\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n\n    const updateContextCounts = async () => {\n      // Check if there are any selections\n      const hasAnySelections = Object.values(selections).some((selection) =>\n        Object.values(selection.sources).some((mode) => mode !== 'off') ||\n        Object.values(selection.notes).some((mode) => mode !== 'off')\n      )\n\n      if (!hasAnySelections) {\n        setTokenCount(0)\n        setCharCount(0)\n        return\n      }\n\n      try {\n        let totalTokens = 0\n        let totalChars = 0\n\n        // Build context for each notebook and sum up counts\n        for (const [notebookId, selection] of Object.entries(selections)) {\n          const sourcesConfig = Object.entries(selection.sources)\n            .filter(([, mode]) => mode !== 'off')\n            .reduce<Record<string, string>>((acc, [sourceId, mode]) => {\n              const normalizedId = sourceId.replace(/^source:/, '')\n              acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content'\n              return acc\n            }, {})\n\n          const notesConfig = Object.entries(selection.notes)\n            .filter(([, mode]) => mode !== 'off')\n            .reduce<Record<string, string>>((acc, [noteId]) => {\n              const normalizedId = noteId.replace(/^note:/, '')\n              acc[normalizedId] = 'full content'\n              return acc\n            }, {})\n\n          if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) {\n            continue\n          }\n\n          const response = await chatApi.buildContext({\n            notebook_id: notebookId,\n            context_config: {\n              sources: sourcesConfig,\n              notes: notesConfig,\n            },\n          })\n\n          totalTokens += response.token_count\n          totalChars += response.char_count\n        }\n\n        setTokenCount(totalTokens)\n        setCharCount(totalChars)\n      } catch (error) {\n        console.error('Error updating context counts:', error)\n        // Don't reset counts on error, keep previous values\n      }\n    }\n\n    updateContextCounts()\n  }, [open, selections])\n\n  const selectedEpisodeProfile = useMemo(() => {\n    if (!episodeProfileId) {\n      return undefined\n    }\n    return episodeProfiles.find((profile) => profile.id === episodeProfileId)\n  }, [episodeProfileId, episodeProfiles])\n\n  const selectedNotebookSummaries = useMemo(() => {\n    return notebooks.map((notebook) => {\n      const selection = selections[notebook.id]\n      if (!selection) {\n        return { notebookId: notebook.id, sources: 0, notes: 0 }\n      }\n      const sourcesCount = Object.values(selection.sources).filter(\n        (mode) => mode !== 'off'\n      ).length\n      const notesCount = Object.values(selection.notes).filter(\n        (mode) => mode !== 'off'\n      ).length\n      return { notebookId: notebook.id, sources: sourcesCount, notes: notesCount }\n    })\n  }, [notebooks, selections])\n\n  const handleNotebookToggle = useCallback(\n    (notebookId: string, checked: boolean | 'indeterminate') => {\n      const shouldCheck = checked === 'indeterminate' ? true : checked\n      const sources = sourcesByNotebook[notebookId] ?? []\n      const notes = notesByNotebook[notebookId] ?? []\n      setSelections((prev) => {\n        if (shouldCheck) {\n          const nextSources: Record<string, SourceMode> = {}\n          sources.forEach((source) => {\n            nextSources[source.id] = getSourceDefaultMode(source)\n          })\n          const nextNotes: Record<string, SourceMode> = {}\n          notes.forEach((note) => {\n            nextNotes[note.id] = 'full'\n          })\n          return {\n            ...prev,\n            [notebookId]: {\n              sources: nextSources,\n              notes: nextNotes,\n            },\n          }\n        }\n\n        const clearedSources: Record<string, SourceMode> = {}\n        sources.forEach((source) => {\n          clearedSources[source.id] = 'off'\n        })\n        const clearedNotes: Record<string, SourceMode> = {}\n        notes.forEach((note) => {\n          clearedNotes[note.id] = 'off'\n        })\n\n        return {\n          ...prev,\n          [notebookId]: {\n            sources: clearedSources,\n            notes: clearedNotes,\n          },\n        }\n      })\n    },\n    [notesByNotebook, sourcesByNotebook]\n  )\n\n  const handleSourceModeChange = useCallback(\n    (notebookId: string, sourceId: string, mode: SourceMode) => {\n      setSelections((prev) => ({\n        ...prev,\n        [notebookId]: {\n          sources: {\n            ...(prev[notebookId]?.sources ?? {}),\n            [sourceId]: mode,\n          },\n          notes: prev[notebookId]?.notes ?? {},\n        },\n      }))\n    },\n    []\n  )\n\n  const handleNoteToggle = useCallback(\n    (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => {\n      setSelections((prev) => ({\n        ...prev,\n        [notebookId]: {\n          sources: prev[notebookId]?.sources ?? {},\n          notes: {\n            ...(prev[notebookId]?.notes ?? {}),\n            [noteId]: checked ? 'full' : 'off',\n          },\n        },\n      }))\n    },\n    []\n  )\n\n  const buildContentFromSelections = useCallback(async () => {\n    const parts: string[] = []\n\n    const tasks: Array<{ notebookId: string; payload: BuildContextRequest }> = []\n\n    Object.entries(selections).forEach(([notebookId, selection]) => {\n      const sourcesConfig = Object.entries(selection.sources)\n        .filter(([, mode]) => mode !== 'off')\n        .reduce<Record<string, string>>((acc, [sourceId, mode]) => {\n          const normalizedId = sourceId.replace(/^source:/, '')\n          acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content'\n          return acc\n        }, {})\n\n      const notesConfig = Object.entries(selection.notes)\n        .filter(([, mode]) => mode !== 'off')\n        .reduce<Record<string, string>>((acc, [noteId]) => {\n          const normalizedId = noteId.replace(/^note:/, '')\n          acc[normalizedId] = 'full content'\n          return acc\n        }, {})\n\n      if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) {\n        return\n      }\n\n      tasks.push({\n        notebookId,\n        payload: {\n          notebook_id: notebookId,\n          context_config: {\n            sources: sourcesConfig,\n            notes: notesConfig,\n          },\n        },\n      })\n    })\n\n    if (tasks.length === 0) {\n      return ''\n    }\n\n    for (const task of tasks) {\n      try {\n        const response = await chatApi.buildContext(task.payload)\n        const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId\n        const contextString = JSON.stringify(response.context, null, 2)\n        const snippet = `${t.common.notebookLabel.replace('{name}', notebookName)}\\n${contextString}`\n        parts.push(snippet)\n      } catch (error) {\n        console.error('Failed to build context for notebook', task.notebookId, error)\n        throw new Error(t.podcasts.buildContextFailed)\n      }\n    }\n\n    return parts.join('\\n\\n')\n  }, [notebooks, selections, t])\n\n  const handleSubmit = useCallback(async () => {\n    if (!selectedEpisodeProfile) {\n      toast({\n        title: t.podcasts.profileRequired,\n        description: t.podcasts.profileRequiredDesc,\n        variant: 'destructive',\n      })\n      return\n    }\n\n    if (!episodeName.trim()) {\n      toast({\n        title: t.podcasts.nameRequired,\n        description: t.podcasts.nameRequiredDesc,\n        variant: 'destructive',\n      })\n      return\n    }\n\n    setIsBuildingContext(true)\n    try {\n      const content = await buildContentFromSelections()\n      if (!content.trim()) {\n        toast({\n          title: t.podcasts.addContext,\n          description: t.podcasts.addContextDesc,\n          variant: 'destructive',\n        })\n        return\n      }\n\n      const payload: PodcastGenerationRequest = {\n        episode_profile: selectedEpisodeProfile.name,\n        speaker_profile: selectedEpisodeProfile.speaker_config,\n        episode_name: episodeName.trim(),\n        content,\n        briefing_suffix: instructions.trim() ? instructions.trim() : undefined,\n      }\n\n      await generatePodcast.mutateAsync(payload)\n\n      toast({\n        title: t.common.success,\n        description: t.podcasts.podcastTaskStarted,\n      })\n\n      // Delay closing dialog slightly to ensure refetch completes\n      setTimeout(() => {\n        onOpenChange(false)\n        resetState()\n      }, 500)\n    } catch (error) {\n      console.error('Failed to generate podcast', error)\n      toast({\n        title: t.podcasts.generationFailed,\n        description: error instanceof Error ? error.message : t.common.refreshPage,\n        variant: 'destructive',\n      })\n    } finally {\n      setIsBuildingContext(false)\n    }\n  }, [\n    buildContentFromSelections,\n    episodeName,\n    generatePodcast,\n    instructions,\n    onOpenChange,\n    resetState,\n    selectedEpisodeProfile,\n    toast,\n    t,\n  ])\n\n  const isSubmitting = generatePodcast.isPending || isBuildingContext\n\n  return (\n    <Dialog open={open} onOpenChange={(value) => {\n      onOpenChange(value)\n      if (!value) {\n        resetState()\n      }\n    }}>\n      <DialogContent className=\"w-[80vw] max-w-[1080px] max-h-[90vh] overflow-hidden\">\n        <DialogHeader>\n          <DialogTitle>{t.podcasts.generateEpisode}</DialogTitle>\n          <DialogDescription>\n            {t.podcasts.generateEpisodeDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"grid gap-6 md:grid-cols-[2fr_1fr] xl:grid-cols-[3fr_1fr]\">\n          <ContentSelectionPanel\n            notebooks={notebooks}\n            isLoading={notebooksQuery.isLoading}\n            selectedNotebookSummaries={selectedNotebookSummaries}\n            tokenCount={tokenCount}\n            charCount={charCount}\n            expandedNotebooks={expandedNotebooks}\n            setExpandedNotebooks={setExpandedNotebooks}\n            selections={selections}\n            sourcesByNotebook={sourcesByNotebook}\n            notesByNotebook={notesByNotebook}\n            fetchingNotebookIds={fetchingNotebookIds}\n            handleNotebookToggle={handleNotebookToggle}\n            handleSourceModeChange={handleSourceModeChange}\n            handleNoteToggle={handleNoteToggle}\n            queryClient={queryClient}\n          />\n\n          <div className=\"space-y-6\">\n            <div className=\"space-y-3\">\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.episodeSettings}\n              </h3>\n              {episodeProfilesQuery.isLoading ? (\n                <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                  <Loader2 className=\"h-4 w-4 animate-spin\" /> {t.podcasts.loadingProfiles}\n                </div>\n              ) : episodeProfiles.length === 0 ? (\n                <div className=\"rounded-lg border border-dashed bg-muted/30 p-4 text-sm text-muted-foreground\">\n                  {t.podcasts.noProfilesFound}\n                </div>\n              ) : (\n                <div className=\"space-y-4\">\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"episode_profile\">{t.podcasts.episodeProfile}</Label>\n                    <Select\n                      value={episodeProfileId}\n                      onValueChange={setEpisodeProfileId}\n                      disabled={episodeProfiles.length === 0}\n                    >\n                      <SelectTrigger id=\"episode_profile\">\n                        <SelectValue placeholder={t.podcasts.episodeProfilePlaceholder} />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {episodeProfiles.map((profile) => (\n                          <SelectItem key={profile.id} value={profile.id}>\n                            {profile.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    {selectedEpisodeProfile && (\n                      <p className=\"text-xs text-muted-foreground\">\n                        {t.podcasts.usesSpeakerProfile}{' '}\n                        <strong>{selectedEpisodeProfile.speaker_config}</strong>\n                      </p>\n                    )}\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"episode_name\">{t.podcasts.episodeName}</Label>\n                    <Input\n                      id=\"episode_name\"\n                      name=\"episode_name\"\n                      value={episodeName}\n                      onChange={(event) => setEpisodeName(event.target.value)}\n                      placeholder={t.podcasts.episodeNamePlaceholder}\n                      autoComplete=\"off\"\n                    />\n                  </div>\n\n                   <div className=\"space-y-2\">\n                    <Label htmlFor=\"instructions\">{t.podcasts.additionalInstructions}</Label>\n                    <Textarea\n                      id=\"instructions\"\n                      name=\"instructions\"\n                      placeholder={t.podcasts.instructionsPlaceholder}\n                      value={instructions}\n                      onChange={(event) => setInstructions(event.target.value)}\n                      className=\"min-h-[100px] text-xs\"\n                      autoComplete=\"off\"\n                    />\n                  </div>\n                </div>\n              )}\n            </div>\n\n            <div className=\"flex flex-col gap-3\">\n              <Button\n                onClick={handleSubmit}\n                disabled={isSubmitting}\n                className=\"w-full\"\n              >\n                {isSubmitting && <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />}\n                {isSubmitting ? t.podcasts.generating : t.podcasts.generate}\n              </Button>\n              <Button\n                variant=\"outline\"\n                onClick={() => onOpenChange(false)}\n                disabled={isSubmitting}\n                className=\"w-full\"\n              >\n                {t.common.cancel}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/SpeakerProfilesPanel.tsx",
    "content": "'use client'\n\nimport { useMemo, useState } from 'react'\nimport { AlertTriangle, Copy, Edit3, MoreVertical, Trash2, Volume2 } from 'lucide-react'\n\nimport { SpeakerProfile, needsModelSetup } from '@/lib/types/podcasts'\nimport {\n  useDeleteSpeakerProfile,\n  useDuplicateSpeakerProfile,\n} from '@/lib/hooks/use-podcasts'\nimport { useModels } from '@/lib/hooks/use-models'\nimport { SpeakerProfileFormDialog } from '@/components/podcasts/forms/SpeakerProfileFormDialog'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from '@/components/ui/alert-dialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from '@/components/ui/card'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface SpeakerProfilesPanelProps {\n  speakerProfiles: SpeakerProfile[]\n  usage: Record<string, number>\n}\n\nexport function SpeakerProfilesPanel({\n  speakerProfiles,\n  usage,\n}: SpeakerProfilesPanelProps) {\n  const { t } = useTranslation()\n  const [createOpen, setCreateOpen] = useState(false)\n  const [editProfile, setEditProfile] = useState<SpeakerProfile | null>(null)\n\n  const deleteProfile = useDeleteSpeakerProfile()\n  const duplicateProfile = useDuplicateSpeakerProfile()\n  const { data: models = [] } = useModels()\n\n  const modelNameMap = useMemo(() => {\n    const map: Record<string, string> = {}\n    for (const m of models) {\n      map[m.id] = `${m.provider} / ${m.name}`\n    }\n    return map\n  }, [models])\n\n  const sortedProfiles = useMemo(\n    () =>\n      [...speakerProfiles].sort((a, b) => a.name.localeCompare(b.name, 'en')),\n    [speakerProfiles]\n  )\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h2 className=\"text-lg font-semibold\">{t.podcasts.speakerProfilesTitle}</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            {t.podcasts.speakerProfilesDesc}\n          </p>\n        </div>\n        <Button onClick={() => setCreateOpen(true)}>{t.podcasts.createSpeaker}</Button>\n      </div>\n\n      {sortedProfiles.length === 0 ? (\n        <div className=\"rounded-lg border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground\">\n          {t.podcasts.noSpeakerProfiles}\n        </div>\n      ) : (\n        <div className=\"space-y-4\">\n          {sortedProfiles.map((profile) => {\n            const usageCount = usage[profile.name] ?? 0\n            const deleteDisabled = usageCount > 0\n            const unconfigured = needsModelSetup(profile)\n\n            return (\n              <Card key={profile.id} className=\"shadow-sm\">\n                <CardHeader className=\"flex flex-col gap-2\">\n                  <div className=\"flex items-center justify-between gap-2\">\n                    <div>\n                      <div className=\"flex items-center gap-2\">\n                        <CardTitle className=\"text-lg font-semibold\">\n                          {profile.name}\n                        </CardTitle>\n                        {unconfigured ? (\n                          <Badge variant=\"outline\" className=\"text-amber-600 border-amber-300 text-xs\">\n                            <AlertTriangle className=\"h-3 w-3 mr-1\" />\n                            {t.podcasts.setupRequired}\n                          </Badge>\n                        ) : null}\n                      </div>\n                      <CardDescription className=\"text-sm text-muted-foreground\">\n                        {profile.description || t.podcasts.noDescription}\n                      </CardDescription>\n                    </div>\n                    <Badge variant=\"outline\" className=\"text-xs\">\n                      {profile.voice_model\n                        ? (modelNameMap[profile.voice_model] ?? profile.voice_model)\n                        : (profile.tts_provider\n                          ? `${profile.tts_provider} / ${profile.tts_model}`\n                          : t.podcasts.notConfigured)}\n                    </Badge>\n                  </div>\n                  <div className=\"flex flex-wrap gap-2\">\n                    <Badge\n                      variant={usageCount > 0 ? 'secondary' : 'outline'}\n                      className=\"text-xs\"\n                    >\n                      {usageCount > 0\n                        ? (usageCount === 1 ? t.podcasts.usedByCount_one : t.podcasts.usedByCount_other.replace('{count}', usageCount.toString()))\n                        : t.podcasts.unused}\n                    </Badge>\n                  </div>\n                </CardHeader>\n\n                <CardContent className=\"space-y-4 text-sm\">\n                  <div className=\"space-y-3\">\n                    {profile.speakers.map((speaker) => (\n                      <div\n                        key={speaker.name}\n                        className=\"rounded-md border bg-muted/30 p-3\"\n                      >\n                        <div className=\"flex items-center justify-between\">\n                          <div className=\"flex items-center gap-2\">\n                            <Volume2 className=\"h-4 w-4\" />\n                            <span className=\"font-medium text-foreground\">\n                              {speaker.name}\n                            </span>\n                          </div>\n                          <div className=\"flex items-center gap-2\">\n                            <span className=\"text-xs text-muted-foreground\">\n                              {t.podcasts.voiceId}: {speaker.voice_id}\n                            </span>\n                            {speaker.voice_model ? (\n                              <Badge variant=\"secondary\" className=\"text-xs\">\n                                {modelNameMap[speaker.voice_model] ?? speaker.voice_model}\n                              </Badge>\n                            ) : null}\n                          </div>\n                        </div>\n                        <p className=\"mt-2 text-xs text-muted-foreground whitespace-pre-wrap\">\n                          <span className=\"font-semibold\">{t.podcasts.backstory}:</span> {speaker.backstory}\n                        </p>\n                        <p className=\"mt-2 text-xs text-muted-foreground whitespace-pre-wrap\">\n                          <span className=\"font-semibold\">{t.podcasts.personality}:</span> {speaker.personality}\n                        </p>\n                      </div>\n                    ))}\n                  </div>\n\n                  <div className=\"flex flex-wrap items-center justify-end gap-2\">\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => setEditProfile(profile)}\n                    >\n                      <Edit3 className=\"mr-2 h-4 w-4\" /> {t.podcasts.edit}\n                    </Button>\n                    <AlertDialog>\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            className=\"h-8 w-8\"\n                            onClick={(e) => e.stopPropagation()}\n                          >\n                            <MoreVertical className=\"h-4 w-4\" />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent\n                          align=\"end\"\n                          className=\"w-48\"\n                          onClick={(e) => e.stopPropagation()}\n                        >\n                          <DropdownMenuItem\n                            onClick={() => duplicateProfile.mutate(profile.id)}\n                            disabled={duplicateProfile.isPending}\n                          >\n                            <Copy className=\"h-4 w-4 mr-2\" />\n                            {t.podcasts.duplicate}\n                          </DropdownMenuItem>\n                          <DropdownMenuSeparator />\n                          <AlertDialogTrigger asChild>\n                            <DropdownMenuItem\n                              className=\"text-destructive focus:text-destructive\"\n                              disabled={deleteDisabled}\n                            >\n                              <Trash2 className=\"h-4 w-4 mr-2\" />\n                              {t.podcasts.delete}\n                            </DropdownMenuItem>\n                          </AlertDialogTrigger>\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                      <AlertDialogContent>\n                        <AlertDialogHeader>\n                          <AlertDialogTitle>{t.podcasts.deleteSpeakerProfileTitle}</AlertDialogTitle>\n                          <AlertDialogDescription>\n                            {t.podcasts.deleteSpeakerProfileDesc.replace('{name}', profile.name)}\n                          </AlertDialogDescription>\n                          {deleteDisabled ? (\n                            <p className=\"mt-2 text-sm text-muted-foreground\">\n                              {t.podcasts.deleteSpeakerDisabledHint}\n                            </p>\n                          ) : null}\n                        </AlertDialogHeader>\n                        <AlertDialogFooter>\n                          <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>\n                          <AlertDialogAction\n                            onClick={() => deleteProfile.mutate(profile.id)}\n                            disabled={deleteDisabled || deleteProfile.isPending}\n                          >\n                            {deleteProfile.isPending ? t.podcasts.deleting : t.podcasts.delete}\n                          </AlertDialogAction>\n                        </AlertDialogFooter>\n                      </AlertDialogContent>\n                    </AlertDialog>\n                  </div>\n                </CardContent>\n              </Card>\n            )\n          })}\n        </div>\n      )}\n\n      <SpeakerProfileFormDialog\n        mode=\"create\"\n        open={createOpen}\n        onOpenChange={setCreateOpen}\n      />\n\n      <SpeakerProfileFormDialog\n        mode=\"edit\"\n        open={Boolean(editProfile)}\n        onOpenChange={(open) => {\n          if (!open) {\n            setEditProfile(null)\n          }\n        }}\n        initialData={editProfile ?? undefined}\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/TemplatesTab.tsx",
    "content": "'use client'\n\nimport { AlertCircle, Lightbulb, Loader2 } from 'lucide-react'\n\nimport { EpisodeProfilesPanel } from '@/components/podcasts/EpisodeProfilesPanel'\nimport { SpeakerProfilesPanel } from '@/components/podcasts/SpeakerProfilesPanel'\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'\nimport { useEpisodeProfiles, useSpeakerProfiles } from '@/lib/hooks/use-podcasts'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nexport function TemplatesTab() {\n  const { t } = useTranslation()\n  const {\n    episodeProfiles,\n    isLoading: loadingEpisodeProfiles,\n    error: episodeProfilesError,\n  } = useEpisodeProfiles()\n\n  const {\n    speakerProfiles,\n    usage,\n    isLoading: loadingSpeakerProfiles,\n    error: speakerProfilesError,\n  } = useSpeakerProfiles(episodeProfiles)\n\n  const isLoading = loadingEpisodeProfiles || loadingSpeakerProfiles\n  const hasError = episodeProfilesError || speakerProfilesError\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-1\">\n        <h2 className=\"text-xl font-semibold\">{t.podcasts.templatesWorkspaceTitle}</h2>\n        <p className=\"text-sm text-muted-foreground\">\n          {t.podcasts.templatesWorkspaceDesc}\n        </p>\n      </div>\n\n      <Accordion type=\"single\" collapsible className=\"w-full\">\n        <AccordionItem\n          value=\"overview\"\n          className=\"overflow-hidden rounded-xl border border-border bg-muted/40 px-4\"\n        >\n          <AccordionTrigger className=\"gap-2 py-4 text-left text-sm font-semibold\">\n            <div className=\"flex items-center gap-2\">\n              <Lightbulb className=\"h-4 w-4 text-primary\" />\n              {t.podcasts.howTemplatesPowerTitle}\n            </div>\n          </AccordionTrigger>\n          <AccordionContent className=\"text-sm text-muted-foreground\">\n            <div className=\"space-y-4\">\n              <p className=\"text-muted-foreground/90\">\n                {t.podcasts.howTemplatesPowerDesc}\n              </p>\n\n              <div className=\"space-y-2\">\n                <h4 className=\"font-medium text-foreground\">{t.podcasts.episodeProfilesSetFormat}</h4>\n                <ul className=\"list-disc space-y-1 pl-5\">\n                  <li>{t.podcasts.episodeProfilesList1}</li>\n                  <li>{t.podcasts.episodeProfilesList2}</li>\n                  <li>{t.podcasts.episodeProfilesList3}</li>\n                </ul>\n              </div>\n\n              <div className=\"space-y-2\">\n                <h4 className=\"font-medium text-foreground\">{t.podcasts.speakerProfilesBringVoices}</h4>\n                <ul className=\"list-disc space-y-1 pl-5\">\n                  <li>{t.podcasts.speakerProfilesList1}</li>\n                  <li>{t.podcasts.speakerProfilesList2}</li>\n                  <li>{t.podcasts.speakerProfilesList3}</li>\n                </ul>\n              </div>\n\n              <div className=\"space-y-2\">\n                <h4 className=\"font-medium text-foreground\">{t.podcasts.recommendedWorkflow}</h4>\n                <ol className=\"list-decimal space-y-1 pl-5\">\n                  <li>{t.podcasts.workflowStep1}</li>\n                  <li>{t.podcasts.workflowStep2}</li>\n                  <li>{t.podcasts.workflowStep3}</li>\n                </ol>\n                <p className=\"text-xs text-muted-foreground/80\">\n                  {t.podcasts.workflowHint}\n                </p>\n              </div>\n            </div>\n          </AccordionContent>\n        </AccordionItem>\n      </Accordion>\n\n      {hasError ? (\n        <Alert variant=\"destructive\">\n          <AlertCircle className=\"h-4 w-4\" />\n          <AlertTitle>{t.podcasts.failedToLoadTemplates}</AlertTitle>\n          <AlertDescription>\n            {t.podcasts.failedToLoadTemplatesDesc}\n          </AlertDescription>\n        </Alert>\n      ) : null}\n\n      {isLoading ? (\n        <div className=\"flex items-center gap-3 rounded-lg border border-dashed p-6 text-sm text-muted-foreground\">\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n          {t.podcasts.loadingTemplates}\n        </div>\n      ) : (\n        <div className=\"grid gap-6 lg:grid-cols-2\">\n          <SpeakerProfilesPanel\n            speakerProfiles={speakerProfiles}\n            usage={usage}\n          />\n          <EpisodeProfilesPanel\n            episodeProfiles={episodeProfiles}\n            speakerProfiles={speakerProfiles}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/forms/EpisodeProfileFormDialog.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect } from 'react'\nimport { Controller, useForm } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\n\nimport { EpisodeProfile, SpeakerProfile } from '@/lib/types/podcasts'\nimport {\n  useCreateEpisodeProfile,\n  useUpdateEpisodeProfile,\n  useLanguages,\n} from '@/lib/hooks/use-podcasts'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Separator } from '@/components/ui/separator'\nimport { ModelSelector } from '@/components/common/ModelSelector'\nimport { TranslationKeys } from '@/lib/locales'\n\nconst episodeProfileSchema = (t: TranslationKeys) => z.object({\n  name: z.string().min(1, t.podcasts.nameRequired || 'Name is required'),\n  description: z.string().optional(),\n  speaker_config: z.string().min(1, t.podcasts.profileRequired || 'Speaker profile is required'),\n  outline_llm: z.string().min(1, t.podcasts.outlineModelRequired || 'Outline model is required'),\n  transcript_llm: z.string().min(1, t.podcasts.transcriptModelRequired || 'Transcript model is required'),\n  language: z.string().nullable().optional(),\n  default_briefing: z.string().min(1, t.podcasts.defaultBriefingRequired || 'Default briefing is required'),\n  num_segments: z.number()\n    .int(t.podcasts.segmentsInteger || 'Must be an integer')\n    .min(3, t.podcasts.segmentsMin || 'At least 3 segments')\n    .max(20, t.podcasts.segmentsMax || 'Maximum 20 segments'),\n})\n\nexport type EpisodeProfileFormValues = z.infer<ReturnType<typeof episodeProfileSchema>>\n\ninterface EpisodeProfileFormDialogProps {\n  mode: 'create' | 'edit'\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  speakerProfiles: SpeakerProfile[]\n  initialData?: EpisodeProfile\n}\n\nexport function EpisodeProfileFormDialog({\n  mode,\n  open,\n  onOpenChange,\n  speakerProfiles,\n  initialData,\n}: EpisodeProfileFormDialogProps) {\n  const { t } = useTranslation()\n  const createProfile = useCreateEpisodeProfile()\n  const updateProfile = useUpdateEpisodeProfile()\n  const { data: languages = [] } = useLanguages()\n\n  const getDefaults = useCallback((): EpisodeProfileFormValues => {\n    const firstSpeaker = speakerProfiles[0]?.name ?? ''\n\n    if (initialData) {\n      return {\n        name: initialData.name,\n        description: initialData.description ?? '',\n        speaker_config: initialData.speaker_config,\n        outline_llm: initialData.outline_llm ?? '',\n        transcript_llm: initialData.transcript_llm ?? '',\n        language: initialData.language ?? null,\n        default_briefing: initialData.default_briefing,\n        num_segments: initialData.num_segments,\n      }\n    }\n\n    return {\n      name: '',\n      description: '',\n      speaker_config: firstSpeaker,\n      outline_llm: '',\n      transcript_llm: '',\n      language: null,\n      default_briefing: '',\n      num_segments: 5,\n    }\n  }, [initialData, speakerProfiles])\n\n  const {\n    control,\n    register,\n    handleSubmit,\n    reset,\n    formState: { errors },\n  } = useForm<EpisodeProfileFormValues>({\n    resolver: zodResolver(episodeProfileSchema(t)),\n    defaultValues: getDefaults(),\n  })\n\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n    reset(getDefaults())\n  }, [open, reset, getDefaults])\n\n  const onSubmit = async (values: EpisodeProfileFormValues) => {\n    const payload = {\n      ...values,\n      description: values.description ?? '',\n      language: values.language || null,\n    }\n\n    if (mode === 'create') {\n      await createProfile.mutateAsync(payload)\n    } else if (initialData) {\n      await updateProfile.mutateAsync({\n        profileId: initialData.id,\n        payload,\n      })\n    }\n\n    onOpenChange(false)\n  }\n\n  const isSubmitting = createProfile.isPending || updateProfile.isPending\n  const disableSubmit = isSubmitting || speakerProfiles.length === 0\n  const isEdit = mode === 'edit'\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-h-[90vh] overflow-y-auto sm:max-w-2xl\">\n        <DialogHeader>\n          <DialogTitle>\n            {isEdit ? t.podcasts.editEpisodeProfile : t.podcasts.createEpisodeProfile}\n          </DialogTitle>\n          <DialogDescription>\n            {t.podcasts.episodeProfileFormDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        {speakerProfiles.length === 0 ? (\n          <Alert className=\"bg-amber-50 text-amber-900 border-amber-200\">\n            <AlertTitle>{t.podcasts.noSpeakerProfilesAvailable}</AlertTitle>\n            <AlertDescription>\n              {t.podcasts.noSpeakerProfilesDesc}\n            </AlertDescription>\n          </Alert>\n        ) : null}\n\n        <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-6 pt-2\">\n          <div className=\"grid gap-4 md:grid-cols-2\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"name\">{t.podcasts.profileName} *</Label>\n              <Input id=\"name\" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} />\n              {errors.name ? (\n                <p className=\"text-xs text-red-600\">{errors.name.message}</p>\n              ) : null}\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"num_segments\">{t.podcasts.segments} *</Label>\n              <Input\n                id=\"num_segments\"\n                type=\"number\"\n                min={3}\n                max={20}\n                {...register('num_segments', { valueAsNumber: true })}\n                autoComplete=\"off\"\n              />\n              {errors.num_segments ? (\n                <p className=\"text-xs text-red-600\">{errors.num_segments.message}</p>\n              ) : null}\n            </div>\n\n            <div className=\"md:col-span-2 space-y-2\">\n              <Label htmlFor=\"description\">{t.common.description}</Label>\n              <Textarea\n                id=\"description\"\n                rows={3}\n                placeholder={t.podcasts.descriptionPlaceholder}\n                {...register('description')}\n                autoComplete=\"off\"\n              />\n            </div>\n          </div>\n\n          <div className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.speakerConfig}\n              </h3>\n              <Separator className=\"mt-2\" />\n            </div>\n            <Controller\n              control={control}\n              name=\"speaker_config\"\n              render={({ field }) => (\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"speaker_config\">{t.podcasts.speakerProfile} *</Label>\n                  <Select value={field.value} onValueChange={field.onChange}>\n                    <SelectTrigger id=\"speaker_config\">\n                      <SelectValue placeholder={t.podcasts.selectSpeakerProfile} />\n                    </SelectTrigger>\n                    <SelectContent title={t.podcasts.speakerProfile}>\n                      {speakerProfiles.map((profile) => (\n                        <SelectItem key={profile.id} value={profile.name}>\n                          {profile.name}\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                  {errors.speaker_config ? (\n                    <p className=\"text-xs text-red-600\">\n                      {errors.speaker_config.message}\n                    </p>\n                  ) : null}\n                </div>\n              )}\n            />\n          </div>\n\n          <div className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.outlineGeneration}\n              </h3>\n              <Separator className=\"mt-2\" />\n            </div>\n            <Controller\n              control={control}\n              name=\"outline_llm\"\n              render={({ field }) => (\n                <div>\n                  <ModelSelector\n                    label={`${t.podcasts.outlineModel} *`}\n                    modelType=\"language\"\n                    value={field.value}\n                    onChange={field.onChange}\n                    placeholder={t.podcasts.selectOutlineModel}\n                  />\n                  {errors.outline_llm ? (\n                    <p className=\"text-xs text-red-600 mt-1\">\n                      {errors.outline_llm.message}\n                    </p>\n                  ) : null}\n                </div>\n              )}\n            />\n          </div>\n\n          <div className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.transcriptGeneration}\n              </h3>\n              <Separator className=\"mt-2\" />\n            </div>\n            <Controller\n              control={control}\n              name=\"transcript_llm\"\n              render={({ field }) => (\n                <div>\n                  <ModelSelector\n                    label={`${t.podcasts.transcriptModel} *`}\n                    modelType=\"language\"\n                    value={field.value}\n                    onChange={field.onChange}\n                    placeholder={t.podcasts.selectTranscriptModel}\n                  />\n                  {errors.transcript_llm ? (\n                    <p className=\"text-xs text-red-600 mt-1\">\n                      {errors.transcript_llm.message}\n                    </p>\n                  ) : null}\n                </div>\n              )}\n            />\n          </div>\n\n          <div className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.podcastLanguage}\n              </h3>\n              <Separator className=\"mt-2\" />\n            </div>\n            <Controller\n              control={control}\n              name=\"language\"\n              render={({ field }) => (\n                <div className=\"space-y-2\">\n                  <Label htmlFor=\"language\">{t.podcasts.language}</Label>\n                  <Select\n                    value={field.value ?? ''}\n                    onValueChange={(v) => field.onChange(v || null)}\n                  >\n                    <SelectTrigger id=\"language\">\n                      <SelectValue placeholder={t.podcasts.languagePlaceholder} />\n                    </SelectTrigger>\n                    <SelectContent title={t.podcasts.language}>\n                      {languages.map((lang) => (\n                        <SelectItem key={lang.code} value={lang.code}>\n                          {lang.name} ({lang.code})\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                </div>\n              )}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"default_briefing\">{t.podcasts.defaultBriefingTitle} *</Label>\n            <Textarea\n              id=\"default_briefing\"\n              rows={6}\n              placeholder={t.podcasts.defaultBriefingPlaceholder}\n              {...register('default_briefing')}\n            />\n            {errors.default_briefing ? (\n              <p className=\"text-xs text-red-600\">\n                {errors.default_briefing.message}\n              </p>\n            ) : null}\n          </div>\n\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => onOpenChange(false)}\n            >\n              {t.common.cancel}\n            </Button>\n            <Button type=\"submit\" disabled={disableSubmit}>\n              {isSubmitting\n                ? t.common.saving\n                : isEdit\n                  ? t.common.saveChanges\n                  : t.podcasts.createProfile}\n            </Button>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/podcasts/forms/SpeakerProfileFormDialog.tsx",
    "content": "'use client'\n\nimport { useCallback, useEffect } from 'react'\nimport { Controller, useFieldArray, useForm } from 'react-hook-form'\nimport type { FieldErrorsImpl } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\nimport { Plus, Trash2 } from 'lucide-react'\n\nimport { SpeakerProfile } from '@/lib/types/podcasts'\nimport {\n  useCreateSpeakerProfile,\n  useUpdateSpeakerProfile,\n} from '@/lib/hooks/use-podcasts'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Label } from '@/components/ui/label'\nimport { Textarea } from '@/components/ui/textarea'\nimport { Separator } from '@/components/ui/separator'\nimport { ModelSelector } from '@/components/common/ModelSelector'\n\nimport { TranslationKeys } from '@/lib/locales'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst speakerConfigSchema = (t: TranslationKeys) => z.object({\n  name: z.string().min(1, t.common.nameRequired || 'Name is required'),\n  voice_id: z.string().min(1, t.podcasts.voiceIdRequired || 'Voice ID is required'),\n  backstory: z.string().min(1, t.podcasts.backstoryRequired || 'Backstory is required'),\n  personality: z.string().min(1, t.podcasts.personalityRequired || 'Personality is required'),\n  voice_model: z.string().nullable().optional(),\n})\n\nconst speakerProfileSchema = (t: TranslationKeys) => z.object({\n  name: z.string().min(1, t.common.nameRequired || 'Name is required'),\n  description: z.string().optional(),\n  voice_model: z.string().min(1, t.podcasts.voiceModelRequired || 'Voice model is required'),\n  speakers: z\n    .array(speakerConfigSchema(t))\n    .min(1, t.podcasts.speakerCountMin || 'At least one speaker is required')\n    .max(4, t.podcasts.speakerCountMax || 'You can configure up to 4 speakers'),\n})\n\nexport type SpeakerProfileFormValues = z.infer<ReturnType<typeof speakerProfileSchema>>\n\ninterface SpeakerProfileFormDialogProps {\n  mode: 'create' | 'edit'\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  initialData?: SpeakerProfile\n}\n\nconst EMPTY_SPEAKER = {\n  name: '',\n  voice_id: '',\n  backstory: '',\n  personality: '',\n  voice_model: null as string | null,\n}\n\nexport function SpeakerProfileFormDialog({\n  mode,\n  open,\n  onOpenChange,\n  initialData,\n}: SpeakerProfileFormDialogProps) {\n  const { t } = useTranslation()\n  const createProfile = useCreateSpeakerProfile()\n  const updateProfile = useUpdateSpeakerProfile()\n\n  const getDefaults = useCallback((): SpeakerProfileFormValues => {\n    if (initialData) {\n      return {\n        name: initialData.name,\n        description: initialData.description ?? '',\n        voice_model: initialData.voice_model ?? '',\n        speakers: initialData.speakers?.map((speaker) => ({\n          ...speaker,\n          voice_model: speaker.voice_model ?? null,\n        })) ?? [{ ...EMPTY_SPEAKER }],\n      }\n    }\n\n    return {\n      name: '',\n      description: '',\n      voice_model: '',\n      speakers: [{ ...EMPTY_SPEAKER }],\n    }\n  }, [initialData])\n\n  const {\n    control,\n    register,\n    handleSubmit,\n    reset,\n    formState: { errors },\n  } = useForm<SpeakerProfileFormValues>({\n    resolver: zodResolver(speakerProfileSchema(t)),\n    defaultValues: getDefaults(),\n  })\n\n  const {\n    fields,\n    append,\n    remove,\n  } = useFieldArray({\n    control,\n    name: 'speakers',\n  })\n\n  const speakersArrayError = (\n    errors.speakers as FieldErrorsImpl<{ root?: { message?: string } }> | undefined\n  )?.root?.message\n\n  useEffect(() => {\n    if (!open) {\n      return\n    }\n    reset(getDefaults())\n  }, [open, reset, getDefaults])\n\n  const onSubmit = async (values: SpeakerProfileFormValues) => {\n    const payload = {\n      ...values,\n      description: values.description ?? '',\n      speakers: values.speakers.map((s) => ({\n        ...s,\n        voice_model: s.voice_model || null,\n      })),\n    }\n\n    if (mode === 'create') {\n      await createProfile.mutateAsync(payload)\n    } else if (initialData) {\n      await updateProfile.mutateAsync({\n        profileId: initialData.id,\n        payload,\n      })\n    }\n\n    onOpenChange(false)\n  }\n\n  const isSubmitting = createProfile.isPending || updateProfile.isPending\n  const disableSubmit = isSubmitting\n  const isEdit = mode === 'edit'\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-h-[90vh] overflow-y-auto sm:max-w-2xl\">\n        <DialogHeader>\n          <DialogTitle>\n            {isEdit ? t.podcasts.editSpeakerProfile : t.podcasts.createSpeakerProfile}\n          </DialogTitle>\n          <DialogDescription>\n            {t.podcasts.speakerProfileFormDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleSubmit(onSubmit)} className=\"space-y-6 pt-2\">\n          <div className=\"grid gap-4 md:grid-cols-2\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"name\">{t.podcasts.profileName} *</Label>\n              <Input id=\"name\" placeholder={t.podcasts.profileNamePlaceholder} {...register('name')} />\n              {errors.name ? (\n                <p className=\"text-xs text-red-600\">{errors.name.message}</p>\n              ) : null}\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"description\">{t.common.description}</Label>\n              <Textarea\n                id=\"description\"\n                rows={3}\n                placeholder={t.podcasts.descriptionPlaceholder}\n                {...register('description')}\n              />\n            </div>\n          </div>\n\n          <div className=\"space-y-4\">\n            <div>\n              <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                {t.podcasts.voiceModel}\n              </h3>\n              <Separator className=\"mt-2\" />\n            </div>\n            <Controller\n              control={control}\n              name=\"voice_model\"\n              render={({ field }) => (\n                <div>\n                  <ModelSelector\n                    label={`${t.podcasts.voiceModel} *`}\n                    modelType=\"text_to_speech\"\n                    value={field.value}\n                    onChange={field.onChange}\n                    placeholder={t.podcasts.selectVoiceModel}\n                  />\n                  {errors.voice_model ? (\n                    <p className=\"text-xs text-red-600 mt-1\">\n                      {errors.voice_model.message}\n                    </p>\n                  ) : null}\n                </div>\n              )}\n            />\n          </div>\n\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center justify-between\">\n              <div>\n                <h3 className=\"text-sm font-semibold uppercase tracking-wide text-muted-foreground\">\n                  {t.podcasts.speakers}\n                </h3>\n                <p className=\"text-xs text-muted-foreground\">\n                  {t.podcasts.speakersDesc}\n                </p>\n              </div>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => append({ ...EMPTY_SPEAKER })}\n                disabled={fields.length >= 4}\n              >\n                <Plus className=\"mr-2 h-4 w-4\" /> {t.podcasts.addSpeaker}\n              </Button>\n            </div>\n            <Separator />\n\n            {fields.map((field, index) => (\n              <div key={field.id} className=\"rounded-lg border p-4 space-y-4\">\n                <div className=\"flex items-center justify-between\">\n                  <p className=\"text-sm font-semibold\">\n                    {t.podcasts.speakerNumber.replace('{number}', (index + 1).toString())}\n                  </p>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => remove(index)}\n                    disabled={fields.length <= 1}\n                    className=\"text-destructive\"\n                  >\n                    <Trash2 className=\"mr-2 h-4 w-4\" /> {t.common.remove}\n                  </Button>\n                </div>\n                <div className=\"grid gap-4 md:grid-cols-2\">\n                  <div className=\"space-y-2\">\n                    <Label htmlFor={`speaker-name-${index}`}>{t.common.name} *</Label>\n                    <Input\n                      id={`speaker-name-${index}`}\n                      {...register(`speakers.${index}.name` as const)}\n                      placeholder={t.podcasts.hostPlaceholder.replace('{number}', (index + 1).toString())}\n                      autoComplete=\"off\"\n                    />\n                    {errors.speakers?.[index]?.name ? (\n                      <p className=\"text-xs text-red-600\">\n                        {errors.speakers[index]?.name?.message}\n                      </p>\n                    ) : null}\n                  </div>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor={`speaker-voice-${index}`}>{t.podcasts.voiceId} *</Label>\n                    <Input\n                      id={`speaker-voice-${index}`}\n                      {...register(`speakers.${index}.voice_id` as const)}\n                      placeholder=\"voice_123\"\n                      autoComplete=\"off\"\n                    />\n                    {errors.speakers?.[index]?.voice_id ? (\n                      <p className=\"text-xs text-red-600\">\n                        {errors.speakers[index]?.voice_id?.message}\n                      </p>\n                    ) : null}\n                  </div>\n                </div>\n                <div className=\"space-y-2\">\n                  <Label htmlFor={`speaker-backstory-${index}`}>{t.podcasts.backstory} *</Label>\n                  <Textarea\n                    id={`speaker-backstory-${index}`}\n                    rows={3}\n                    placeholder={t.podcasts.backstoryPlaceholder}\n                    {...register(`speakers.${index}.backstory` as const)}\n                    autoComplete=\"off\"\n                  />\n                  {errors.speakers?.[index]?.backstory ? (\n                    <p className=\"text-xs text-red-600\">\n                      {errors.speakers[index]?.backstory?.message}\n                    </p>\n                  ) : null}\n                </div>\n                <div className=\"space-y-2\">\n                  <Label htmlFor={`speaker-personality-${index}`}>{t.podcasts.personality} *</Label>\n                  <Textarea\n                    id={`speaker-personality-${index}`}\n                    rows={3}\n                    placeholder={t.podcasts.personalityPlaceholder}\n                    {...register(`speakers.${index}.personality` as const)}\n                    autoComplete=\"off\"\n                  />\n                  {errors.speakers?.[index]?.personality ? (\n                    <p className=\"text-xs text-red-600\">\n                      {errors.speakers[index]?.personality?.message}\n                    </p>\n                  ) : null}\n                </div>\n                <Controller\n                  control={control}\n                  name={`speakers.${index}.voice_model` as const}\n                  render={({ field: vmField }) => (\n                    <div>\n                      <ModelSelector\n                        label={t.podcasts.perSpeakerTtsOverride}\n                        modelType=\"text_to_speech\"\n                        value={vmField.value ?? ''}\n                        onChange={(v) => vmField.onChange(v || null)}\n                        placeholder={t.podcasts.useProfileDefault}\n                      />\n                    </div>\n                  )}\n                />\n              </div>\n            ))}\n\n            {speakersArrayError ? (\n              <p className=\"text-xs text-red-600\">{speakersArrayError}</p>\n            ) : null}\n          </div>\n\n          <div className=\"flex justify-end gap-2 pt-2\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => onOpenChange(false)}\n            >\n              {t.common.cancel}\n            </Button>\n            <Button type=\"submit\" disabled={disableSubmit}>\n              {isSubmitting\n                ? t.common.saving\n                : isEdit\n                  ? t.common.saveChanges\n                  : t.podcasts.createProfile}\n            </Button>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/providers/I18nProvider.tsx",
    "content": "'use client'\n\nimport React, { useEffect, useState } from 'react'\nimport '@/lib/i18n'\nimport { LanguageLoadingOverlay } from '@/components/common/LanguageLoadingOverlay'\n\nexport function I18nProvider({ children }: { children: React.ReactNode }) {\n  const [mounted, setMounted] = useState(false)\n\n  useEffect(() => {\n    setMounted(true)\n  }, [])\n\n  // Avoid hydration mismatch by waiting for mount\n  if (!mounted) {\n    return <div style={{ visibility: 'hidden' }}>{children}</div>\n  }\n\n  return (\n    <>\n      <LanguageLoadingOverlay />\n      {children}\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/providers/ModalProvider.tsx",
    "content": "'use client'\n\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { NoteEditorDialog } from '@/app/(dashboard)/notebooks/components/NoteEditorDialog'\nimport { SourceInsightDialog } from '@/components/source/SourceInsightDialog'\nimport { SourceDialog } from '@/components/source/SourceDialog'\n\n/**\n * Modal Provider Component\n *\n * Renders modals based on URL query parameters (?modal=type&id=xxx)\n * Manages modal state through the useModalManager hook\n *\n * Supported modal types:\n * - source: Source detail modal\n * - note: Note editor modal\n * - insight: Source insight modal\n */\nexport function ModalProvider() {\n  const { modalType, modalId, closeModal } = useModalManager()\n\n  return (\n    <>\n      {/* Source Modal */}\n      <SourceDialog\n        open={modalType === 'source'}\n        onOpenChange={(open) => {\n          if (!open) closeModal()\n        }}\n        sourceId={modalId}\n      />\n\n      {/* Note Modal */}\n      <NoteEditorDialog\n        open={modalType === 'note'}\n        onOpenChange={(open) => {\n          if (!open) closeModal()\n        }}\n        notebookId=\"\" // Will need to be fetched or handled in Phase 9\n        note={modalId ? { id: modalId, title: null, content: null } : undefined}\n      />\n\n      {/* Source Insight Modal */}\n      <SourceInsightDialog\n        open={modalType === 'insight'}\n        onOpenChange={(open) => {\n          if (!open) closeModal()\n        }}\n        insight={modalId ? { id: modalId, insight_type: '', content: '' } : undefined}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/providers/QueryProvider.tsx",
    "content": "'use client'\n\nimport { QueryClientProvider } from '@tanstack/react-query'\nimport { queryClient } from '@/lib/api/query-client'\n\ninterface QueryProviderProps {\n  children: React.ReactNode\n}\n\nexport function QueryProvider({ children }: QueryProviderProps) {\n  return (\n    <QueryClientProvider client={queryClient}>\n      {children}\n    </QueryClientProvider>\n  )\n}"
  },
  {
    "path": "frontend/src/components/providers/ThemeProvider.tsx",
    "content": "'use client'\n\nimport { useEffect } from 'react'\nimport { useThemeStore } from '@/lib/stores/theme-store'\n\ninterface ThemeProviderProps {\n  children: React.ReactNode\n}\n\nexport function ThemeProvider({ children }: ThemeProviderProps) {\n  const { theme, getSystemTheme, getEffectiveTheme } = useThemeStore()\n\n  useEffect(() => {\n    // Initialize theme on mount\n    const root = window.document.documentElement\n    const effectiveTheme = getEffectiveTheme()\n    \n    // Remove all possible theme classes first\n    root.classList.remove('light', 'dark')\n    \n    // Add the effective theme class\n    root.classList.add(effectiveTheme)\n    \n    // Set the data attribute as well for better component compatibility\n    root.setAttribute('data-theme', effectiveTheme)\n\n    // Listen for system theme changes when using system preference\n    if (theme === 'system') {\n      const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\n      \n      const handleChange = () => {\n        const newSystemTheme = getSystemTheme()\n        root.classList.remove('light', 'dark')\n        root.classList.add(newSystemTheme)\n        root.setAttribute('data-theme', newSystemTheme)\n      }\n\n      mediaQuery.addEventListener('change', handleChange)\n      return () => mediaQuery.removeEventListener('change', handleChange)\n    }\n  }, [theme, getSystemTheme, getEffectiveTheme])\n\n  return <>{children}</>\n}\n"
  },
  {
    "path": "frontend/src/components/search/AdvancedModelsDialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { ModelSelector } from '@/components/common/ModelSelector'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface AdvancedModelsDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  defaultModels: {\n    strategy: string\n    answer: string\n    finalAnswer: string\n  }\n  onSave: (models: {\n    strategy: string\n    answer: string\n    finalAnswer: string\n  }) => void\n}\n\nexport function AdvancedModelsDialog({\n  open,\n  onOpenChange,\n  defaultModels,\n  onSave\n}: AdvancedModelsDialogProps) {\n  const { t } = useTranslation()\n  const [strategyModel, setStrategyModel] = useState(defaultModels.strategy)\n  const [answerModel, setAnswerModel] = useState(defaultModels.answer)\n  const [finalAnswerModel, setFinalAnswerModel] = useState(defaultModels.finalAnswer)\n\n  // Update local state when defaultModels change\n  useEffect(() => {\n    setStrategyModel(defaultModels.strategy)\n    setAnswerModel(defaultModels.answer)\n    setFinalAnswerModel(defaultModels.finalAnswer)\n  }, [defaultModels])\n\n  const handleSave = () => {\n    onSave({\n      strategy: strategyModel,\n      answer: answerModel,\n      finalAnswer: finalAnswerModel\n    })\n    onOpenChange(false)\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[500px]\">\n        <DialogHeader>\n          <DialogTitle>{t.searchPage.advancedModelTitle}</DialogTitle>\n          <DialogDescription>\n            {t.searchPage.advancedModelDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4 py-4\">\n          <ModelSelector\n            label={t.searchPage.strategyModel}\n            modelType=\"language\"\n            value={strategyModel}\n            onChange={setStrategyModel}\n            placeholder={t.searchPage.selectStrategyPlaceholder}\n          />\n\n          <ModelSelector\n            label={t.searchPage.answerModel}\n            modelType=\"language\"\n            value={answerModel}\n            onChange={setAnswerModel}\n            placeholder={t.searchPage.selectAnswerPlaceholder}\n          />\n\n          <ModelSelector\n            label={t.searchPage.finalAnswerModel}\n            modelType=\"language\"\n            value={finalAnswerModel}\n            onChange={setFinalAnswerModel}\n            placeholder={t.searchPage.selectFinalPlaceholder}\n          />\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t.common.cancel}\n          </Button>\n          <Button onClick={handleSave}>\n            {t.searchPage.saveChanges}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/search/SaveToNotebooksDialog.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { CheckboxList } from '@/components/ui/checkbox-list'\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { useCreateNote } from '@/lib/hooks/use-notes'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface SaveToNotebooksDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  question: string\n  answer: string\n}\n\nexport function SaveToNotebooksDialog({\n  open,\n  onOpenChange,\n  question,\n  answer\n}: SaveToNotebooksDialogProps) {\n  const { t } = useTranslation()\n  const [selectedNotebooks, setSelectedNotebooks] = useState<string[]>([])\n  const { data: notebooks, isLoading } = useNotebooks(false) // false = not archived\n  const createNote = useCreateNote()\n\n  const handleToggle = (notebookId: string) => {\n    setSelectedNotebooks(prev =>\n      prev.includes(notebookId)\n        ? prev.filter(id => id !== notebookId)\n        : [...prev, notebookId]\n    )\n  }\n\n  const handleSave = async () => {\n    if (selectedNotebooks.length === 0) {\n      toast.error(t.searchPage.selectNotebook)\n      return\n    }\n\n    try {\n      // Create note in each selected notebook\n      for (const notebookId of selectedNotebooks) {\n        await createNote.mutateAsync({\n          title: question,\n          content: answer,\n          note_type: 'ai',\n          notebook_id: notebookId\n        })\n      }\n\n      toast.success(t.searchPage.saveSuccess)\n      setSelectedNotebooks([])\n      onOpenChange(false)\n    } catch {\n      toast.error(t.searchPage.saveError)\n    }\n  }\n\n  const notebookItems = notebooks?.map(nb => ({\n    id: nb.id,\n    title: nb.name,\n    description: nb.description || undefined\n  })) || []\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-[500px]\">\n        <DialogHeader>\n          <DialogTitle>{t.searchPage.saveToNotebooks}</DialogTitle>\n          <DialogDescription>\n            {t.searchPage.selectNotebook}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"py-4\">\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-8\">\n              <LoadingSpinner />\n            </div>\n          ) : (\n            <CheckboxList\n              items={notebookItems}\n              selectedIds={selectedNotebooks}\n              onToggle={handleToggle}\n              emptyMessage={t.sources.noNotebooksFound}\n            />\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t.common.cancel}\n          </Button>\n          <Button\n            onClick={handleSave}\n            disabled={selectedNotebooks.length === 0 || createNote.isPending}\n          >\n            {createNote.isPending ? (\n              <>\n                <LoadingSpinner size=\"sm\" className=\"mr-2\" />\n                {t.searchPage.saving}\n              </>\n            ) : (\n              t.searchPage.saveToNotebook\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/search/StreamingResponse.tsx",
    "content": "'use client'\n\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'\nimport { CheckCircle, Sparkles, Lightbulb, ChevronDown } from 'lucide-react'\nimport { useState } from 'react'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { convertReferencesToMarkdownLinks, createReferenceLinkComponent } from '@/lib/utils/source-references'\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { toast } from 'sonner'\n\ninterface StrategyData {\n  reasoning: string\n  searches: Array<{ term: string; instructions: string }>\n}\n\ninterface StreamingResponseProps {\n  isStreaming: boolean\n  strategy: StrategyData | null\n  answers: string[]\n  finalAnswer: string | null\n}\n\nexport function StreamingResponse({\n  isStreaming,\n  strategy,\n  answers,\n  finalAnswer\n}: StreamingResponseProps) {\n  const [strategyOpen, setStrategyOpen] = useState(false)\n  const [answersOpen, setAnswersOpen] = useState(false)\n  const { openModal } = useModalManager()\n  const { t } = useTranslation()\n\n  const handleReferenceClick = (type: string, id: string) => {\n    const modalType = type === 'source_insight' ? 'insight' : type as 'source' | 'note' | 'insight'\n\n    try {\n      openModal(modalType, id)\n      // Note: The modal system uses URL parameters and doesn't throw errors for missing items.\n      // The modal component itself will handle displaying \"not found\" states.\n      // This try-catch is here for future enhancements or unexpected errors.\n    } catch {\n      const typeLabel = type === 'source_insight' ? 'insight' : type\n      toast.error(t.common.itemNotFound.replace('{type}', typeLabel))\n    }\n  }\n\n  if (!strategy && !answers.length && !finalAnswer && !isStreaming) {\n    return null\n  }\n\n  return (\n    <div\n      className=\"space-y-4 mt-6 max-h-[60vh] overflow-y-auto pr-2\"\n      role=\"region\"\n      aria-label={t.common.accessibility.askResponse}\n      aria-live=\"polite\"\n      aria-busy={isStreaming}\n    >\n      {/* Strategy Section - Collapsible */}\n      {strategy && (\n        <Collapsible open={strategyOpen} onOpenChange={setStrategyOpen}>\n          <Card>\n            <CardHeader>\n              <CollapsibleTrigger className=\"flex items-center justify-between w-full hover:opacity-80\">\n                <CardTitle className=\"text-base flex items-center gap-2\">\n                  <Sparkles className=\"h-4 w-4 text-primary\" />\n                  {t.common.strategy}\n                </CardTitle>\n                <ChevronDown className={`h-4 w-4 transition-transform ${strategyOpen ? 'rotate-180' : ''}`} />\n              </CollapsibleTrigger>\n            </CardHeader>\n            <CollapsibleContent>\n              <CardContent className=\"space-y-3 pt-0\">\n                <div>\n                  <p className=\"text-sm text-muted-foreground mb-2\">{t.common.reasoning}:</p>\n                  <p className=\"text-sm\">{strategy.reasoning}</p>\n                </div>\n                {strategy.searches.length > 0 && (\n                  <div>\n                    <p className=\"text-sm text-muted-foreground mb-2\">{t.common.searchTerms}:</p>\n                    <div className=\"space-y-2\">\n                      {strategy.searches.map((search, i) => (\n                        <div key={i} className=\"flex items-start gap-2\">\n                          <Badge variant=\"outline\" className=\"mt-0.5\">{i + 1}</Badge>\n                          <div className=\"flex-1\">\n                            <p className=\"text-sm font-medium\">{search.term}</p>\n                            <p className=\"text-xs text-muted-foreground\">{search.instructions}</p>\n                          </div>\n                        </div>\n                      ))}\n                    </div>\n                  </div>\n                )}\n              </CardContent>\n            </CollapsibleContent>\n          </Card>\n        </Collapsible>\n      )}\n\n      {/* Individual Answers Section - Collapsible */}\n      {answers.length > 0 && (\n        <Collapsible open={answersOpen} onOpenChange={setAnswersOpen}>\n          <Card>\n            <CardHeader>\n              <CollapsibleTrigger className=\"flex items-center justify-between w-full hover:opacity-80\">\n                <CardTitle className=\"text-base flex items-center gap-2\">\n                  <Lightbulb className=\"h-4 w-4 text-primary\" />\n                  {t.common.individualAnswers.replace('{count}', answers.length.toString())}\n                </CardTitle>\n                <ChevronDown className={`h-4 w-4 transition-transform ${answersOpen ? 'rotate-180' : ''}`} />\n              </CollapsibleTrigger>\n            </CardHeader>\n            <CollapsibleContent>\n              <CardContent className=\"space-y-2 pt-0\">\n                {answers.map((answer, i) => (\n                  <div key={i} className=\"p-3 rounded-md bg-muted\">\n                    <p className=\"text-sm\">{answer}</p>\n                  </div>\n                ))}\n              </CardContent>\n            </CollapsibleContent>\n          </Card>\n        </Collapsible>\n      )}\n\n      {/* Final Answer Section - Always Open */}\n      {finalAnswer && (\n        <Card className=\"border-primary\">\n          <CardHeader>\n            <CardTitle className=\"text-base flex items-center gap-2\">\n              <CheckCircle className=\"h-4 w-4 text-primary\" />\n              {t.common.finalAnswer}\n            </CardTitle>\n          </CardHeader>\n          <CardContent>\n            <FinalAnswerContent\n              content={finalAnswer}\n              onReferenceClick={handleReferenceClick}\n            />\n          </CardContent>\n        </Card>\n      )}\n\n      {/* Loading Indicator */}\n      {isStreaming && !finalAnswer && (\n        <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n          <LoadingSpinner size=\"sm\" />\n          <span>{t.searchPage.processingQuestion}</span>\n        </div>\n      )}\n    </div>\n  )\n}\n\n// Helper component to render final answer with clickable references\nfunction FinalAnswerContent({\n  content,\n  onReferenceClick\n}: {\n  content: string\n  onReferenceClick: (type: string, id: string) => void\n}) {\n  // Convert references to markdown links\n  const markdownWithLinks = convertReferencesToMarkdownLinks(content)\n\n  // Create custom link component\n  const LinkComponent = createReferenceLinkComponent(onReferenceClick)\n\n  return (\n    <div className=\"prose prose-sm max-w-none dark:prose-invert break-words prose-a:break-all prose-p:leading-relaxed prose-headings:mt-4 prose-headings:mb-2\">\n      <ReactMarkdown\n        remarkPlugins={[remarkGfm]}\n        components={{\n          a: LinkComponent,\n          table: ({ children }) => (\n            <div className=\"my-4 overflow-x-auto\">\n              <table className=\"min-w-full border-collapse border border-border\">{children}</table>\n            </div>\n          ),\n          thead: ({ children }) => <thead className=\"bg-muted\">{children}</thead>,\n          tbody: ({ children }) => <tbody>{children}</tbody>,\n          tr: ({ children }) => <tr className=\"border-b border-border\">{children}</tr>,\n          th: ({ children }) => <th className=\"border border-border px-3 py-2 text-left font-semibold\">{children}</th>,\n          td: ({ children }) => <td className=\"border border-border px-3 py-2\">{children}</td>,\n        }}\n      >\n        {markdownWithLinks}\n      </ReactMarkdown>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/settings/EmbeddingModelChangeDialog.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { useRouter } from 'next/navigation'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { Button } from '@/components/ui/button'\nimport { AlertTriangle, ExternalLink } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface EmbeddingModelChangeDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  onConfirm: () => void\n  oldModelName?: string\n  newModelName?: string\n}\n\nexport function EmbeddingModelChangeDialog({\n  open,\n  onOpenChange,\n  onConfirm,\n  oldModelName,\n  newModelName\n}: EmbeddingModelChangeDialogProps) {\n  const { t } = useTranslation()\n  const router = useRouter()\n  const [isConfirming, setIsConfirming] = useState(false)\n\n  const handleConfirmAndRebuild = () => {\n    setIsConfirming(true)\n    onConfirm()\n    // Give a moment for the model to update, then redirect\n    setTimeout(() => {\n      router.push('/advanced')\n      onOpenChange(false)\n      setIsConfirming(false)\n    }, 500)\n  }\n\n  const handleConfirmOnly = () => {\n    onConfirm()\n    onOpenChange(false)\n  }\n\n  return (\n    <AlertDialog open={open} onOpenChange={onOpenChange}>\n      <AlertDialogContent className=\"max-w-lg\">\n        <AlertDialogHeader>\n          <div className=\"flex items-center gap-2 mb-2\">\n            <AlertTriangle className=\"h-5 w-5 text-yellow-500\" />\n            <AlertDialogTitle>{t.models.embeddingChangeTitle}</AlertDialogTitle>\n          </div>\n          <AlertDialogDescription asChild>\n            <div className=\"space-y-3 text-base text-muted-foreground\">\n              <p>\n                {t.models.embeddingChangeConfirm\n                  .replace('{from}', oldModelName || '...')\n                  .replace('{to}', newModelName || '...')}\n              </p>\n\n              <div className=\"bg-muted p-4 rounded-md space-y-2\">\n                <p className=\"font-semibold text-foreground\">{t.models.rebuildRequired}</p>\n                <p className=\"text-sm\">\n                  {t.models.rebuildReason}\n                </p>\n              </div>\n\n              <div className=\"space-y-2 text-sm\">\n                <p className=\"font-medium text-foreground\">{t.models.whatHappensNext}</p>\n                <ul className=\"list-disc list-inside space-y-1 ml-2\">\n                  <li>{t.models.step1}</li>\n                  <li>{t.models.step2}</li>\n                  <li>{t.models.step3}</li>\n                  <li>{t.models.step4}</li>\n                </ul>\n              </div>\n\n              <p className=\"text-sm font-medium text-foreground\">\n                {t.models.proceedToRebuildPrompt}\n              </p>\n            </div>\n          </AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter className=\"flex-col sm:flex-row gap-2\">\n          <AlertDialogCancel disabled={isConfirming}>\n            {t.common.cancel}\n          </AlertDialogCancel>\n          <Button\n            variant=\"outline\"\n            onClick={handleConfirmOnly}\n            disabled={isConfirming}\n          >\n            {t.models.changeModelOnly}\n          </Button>\n          <AlertDialogAction\n            onClick={handleConfirmAndRebuild}\n            disabled={isConfirming}\n            className=\"bg-primary\"\n          >\n            <ExternalLink className=\"mr-2 h-4 w-4\" />\n            {t.models.changeAndRebuild}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/settings/MigrationBanner.tsx",
    "content": "'use client'\n\nimport { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'\nimport { Button } from '@/components/ui/button'\nimport { AlertTriangle, ArrowRight, Loader2 } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { useMigrateFromEnv } from '@/lib/hooks/use-credentials'\n\ninterface MigrationBannerProps {\n  providersToMigrate: string[]\n}\n\nexport function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {\n  const { t } = useTranslation()\n  const migrate = useMigrateFromEnv()\n\n  if (providersToMigrate.length === 0) {\n    return null\n  }\n\n  return (\n    <Alert className=\"border-amber-500/50 bg-amber-50 dark:bg-amber-950/20\">\n      <AlertTriangle className=\"h-4 w-4 text-amber-600 dark:text-amber-400\" />\n      <AlertTitle className=\"text-amber-800 dark:text-amber-200\">\n        {t.apiKeys.migrationAvailable}\n      </AlertTitle>\n      <AlertDescription className=\"flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n        <span className=\"text-amber-700 dark:text-amber-300\">\n          {t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())}\n        </span>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          onClick={() => migrate.mutate()}\n          disabled={migrate.isPending}\n          className=\"shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30\"\n        >\n          {migrate.isPending ? (\n            <>\n              <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n              {t.apiKeys.migrating}\n            </>\n          ) : (\n            <>\n              {t.apiKeys.migrateToDatabase}\n              <ArrowRight className=\"ml-2 h-4 w-4\" />\n            </>\n          )}\n        </Button>\n      </AlertDescription>\n    </Alert>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/settings/ModelTestResultDialog.tsx",
    "content": "'use client'\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Check, X } from 'lucide-react'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { ModelTestResult } from '@/lib/types/models'\n\nexport function ModelTestResultDialog({\n  open,\n  onOpenChange,\n  result,\n  modelName,\n}: {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  result: ModelTestResult | null\n  modelName: string\n}) {\n  const { t } = useTranslation()\n\n  if (!result) return null\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            {result.success ? (\n              <Check className=\"h-5 w-5 text-emerald-500\" />\n            ) : (\n              <X className=\"h-5 w-5 text-destructive\" />\n            )}\n            {result.success ? t.models.testModelSuccess : t.models.testModelFailed}\n          </DialogTitle>\n        </DialogHeader>\n\n        <div className=\"space-y-3\">\n          <p className=\"text-sm text-muted-foreground\">{modelName}</p>\n          <p className=\"text-sm\">{result.message}</p>\n\n          {result.details && (\n            <pre className=\"text-xs bg-muted p-3 rounded-md overflow-auto max-h-60 whitespace-pre-wrap break-words\">\n              {result.details}\n            </pre>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            {t.common.done}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/settings/index.ts",
    "content": "export { MigrationBanner } from './MigrationBanner'\nexport { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'\nexport { ModelTestResultDialog } from './ModelTestResultDialog'\n"
  },
  {
    "path": "frontend/src/components/source/ChatPanel.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect, useId } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Textarea } from '@/components/ui/textarea'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'\nimport { Bot, User, Send, Loader2, FileText, Lightbulb, StickyNote, Clock } from 'lucide-react'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport {\n  SourceChatMessage,\n  SourceChatContextIndicator,\n  BaseChatSession\n} from '@/lib/types/api'\nimport { ModelSelector } from './ModelSelector'\nimport { ContextIndicator } from '@/components/common/ContextIndicator'\nimport { SessionManager } from '@/components/source/SessionManager'\nimport { MessageActions } from '@/components/source/MessageActions'\nimport { convertReferencesToCompactMarkdown, createCompactReferenceLinkComponent } from '@/lib/utils/source-references'\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface NotebookContextStats {\n  sourcesInsights: number\n  sourcesFull: number\n  notesCount: number\n  tokenCount?: number\n  charCount?: number\n}\n\ninterface ChatPanelProps {\n  messages: SourceChatMessage[]\n  isStreaming: boolean\n  contextIndicators: SourceChatContextIndicator | null\n  onSendMessage: (message: string, modelOverride?: string) => void\n  modelOverride?: string\n  onModelChange?: (model?: string) => void\n  // Session management props\n  sessions?: BaseChatSession[]\n  currentSessionId?: string | null\n  onCreateSession?: (title: string) => void\n  onSelectSession?: (sessionId: string) => void\n  onDeleteSession?: (sessionId: string) => void\n  onUpdateSession?: (sessionId: string, title: string) => void\n  loadingSessions?: boolean\n  // Generic props for reusability\n  title?: string\n  contextType?: 'source' | 'notebook'\n  // Notebook context stats (for notebook chat)\n  notebookContextStats?: NotebookContextStats\n  // Notebook ID for saving notes\n  notebookId?: string\n}\n\nexport function ChatPanel({\n  messages,\n  isStreaming,\n  contextIndicators,\n  onSendMessage,\n  modelOverride,\n  onModelChange,\n  sessions = [],\n  currentSessionId,\n  onCreateSession,\n  onSelectSession,\n  onDeleteSession,\n  onUpdateSession,\n  loadingSessions = false,\n  title,\n  contextType = 'source',\n  notebookContextStats,\n  notebookId\n}: ChatPanelProps) {\n  const { t } = useTranslation()\n  const chatInputId = useId()\n  const [input, setInput] = useState('')\n  const [sessionManagerOpen, setSessionManagerOpen] = useState(false)\n  const scrollAreaRef = useRef<HTMLDivElement>(null)\n  const messagesEndRef = useRef<HTMLDivElement>(null)\n  const { openModal } = useModalManager()\n\n  const handleReferenceClick = (type: string, id: string) => {\n    const modalType = type === 'source_insight' ? 'insight' : type as 'source' | 'note' | 'insight'\n\n    try {\n      openModal(modalType, id)\n      // Note: The modal system uses URL parameters and doesn't throw errors for missing items.\n      // The modal component itself will handle displaying \"not found\" states.\n      // This try-catch is here for future enhancements or unexpected errors.\n    } catch {\n      toast.error(t.common.noResults)\n    }\n  }\n\n  // Auto-scroll to bottom when new messages arrive\n  useEffect(() => {\n    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })\n  }, [messages])\n\n  const handleSend = () => {\n    if (input.trim() && !isStreaming) {\n      onSendMessage(input.trim(), modelOverride)\n      setInput('')\n    }\n  }\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    // Detect platform for correct modifier key\n    const isMac = typeof navigator !== 'undefined' && navigator.userAgent.toUpperCase().indexOf('MAC') >= 0\n    const isModifierPressed = isMac ? e.metaKey : e.ctrlKey\n\n    if (e.key === 'Enter' && isModifierPressed) {\n      e.preventDefault()\n      handleSend()\n    }\n  }\n\n  // Detect platform for placeholder text\n  const isMac = typeof navigator !== 'undefined' && navigator.userAgent.toUpperCase().indexOf('MAC') >= 0\n  const keyHint = isMac ? '⌘+Enter' : 'Ctrl+Enter'\n\n  return (\n    <>\n    <Card className=\"flex flex-col h-full flex-1 overflow-hidden\">\n      <CardHeader className=\"pb-3 flex-shrink-0\">\n        <div className=\"flex items-center justify-between\">\n          <CardTitle className=\"flex items-center gap-2\">\n            <Bot className=\"h-5 w-5\" />\n            {title || (contextType === 'source' ? t.chat.chatWith.replace('{name}', t.navigation.sources) : t.chat.chatWith.replace('{name}', t.common.notebook))}\n          </CardTitle>\n          {onSelectSession && onCreateSession && onDeleteSession && (\n            <Dialog open={sessionManagerOpen} onOpenChange={setSessionManagerOpen}>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"gap-2\"\n                onClick={() => setSessionManagerOpen(true)}\n                disabled={loadingSessions}\n              >\n                <Clock className=\"h-4 w-4\" />\n                <span className=\"text-xs\">{t.chat.sessions}</span>\n              </Button>\n              <DialogContent className=\"sm:max-w-[420px] p-0 overflow-hidden\">\n                <DialogTitle className=\"sr-only\">{t.chat.sessionsTitle}</DialogTitle>\n                <SessionManager\n                  sessions={sessions}\n                  currentSessionId={currentSessionId ?? null}\n                  onCreateSession={(title) => onCreateSession?.(title)}\n                  onSelectSession={(sessionId) => {\n                    onSelectSession(sessionId)\n                    setSessionManagerOpen(false)\n                  }}\n                  onUpdateSession={(sessionId, title) => onUpdateSession?.(sessionId, title)}\n                  onDeleteSession={(sessionId) => onDeleteSession?.(sessionId)}\n                  loadingSessions={loadingSessions}\n                />\n              </DialogContent>\n            </Dialog>\n          )}\n        </div>\n      </CardHeader>\n      <CardContent className=\"flex-1 flex flex-col min-h-0 p-0\">\n        <ScrollArea className=\"flex-1 min-h-0 px-4\" ref={scrollAreaRef}>\n          <div className=\"space-y-4 py-4\">\n            {messages.length === 0 ? (\n              <div className=\"text-center text-muted-foreground py-8\">\n                <Bot className=\"h-12 w-12 mx-auto mb-4 opacity-50\" />\n                <p className=\"text-sm\">\n                  {t.chat.startConversation.replace('{type}', contextType === 'source' ? t.navigation.sources : t.common.notebook)}\n                </p>\n                <p className=\"text-xs mt-2\">{t.chat.askQuestions}</p>\n              </div>\n            ) : (\n              messages.map((message) => (\n                <div\n                  key={message.id}\n                  className={`flex gap-3 ${\n                    message.type === 'human' ? 'justify-end' : 'justify-start'\n                  }`}\n                >\n                  {message.type === 'ai' && (\n                    <div className=\"flex-shrink-0\">\n                      <div className=\"h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center\">\n                        <Bot className=\"h-4 w-4\" />\n                      </div>\n                    </div>\n                  )}\n                  <div className=\"flex flex-col gap-2 max-w-[80%]\">\n                    <div\n                      className={`rounded-lg px-4 py-2 ${\n                        message.type === 'human'\n                          ? 'bg-primary text-primary-foreground'\n                          : 'bg-muted'\n                      }`}\n                    >\n                      {message.type === 'ai' ? (\n                        <AIMessageContent\n                          content={message.content}\n                          onReferenceClick={handleReferenceClick}\n                        />\n                      ) : (\n                        <p className=\"text-sm break-all\">{message.content}</p>\n                      )}\n                    </div>\n                    {message.type === 'ai' && (\n                      <MessageActions\n                        content={message.content}\n                        notebookId={notebookId}\n                      />\n                    )}\n                  </div>\n                  {message.type === 'human' && (\n                    <div className=\"flex-shrink-0\">\n                      <div className=\"h-8 w-8 rounded-full bg-primary flex items-center justify-center\">\n                        <User className=\"h-4 w-4 text-primary-foreground\" />\n                      </div>\n                    </div>\n                  )}\n                </div>\n              ))\n            )}\n            {isStreaming && (\n              <div className=\"flex gap-3 justify-start\">\n                <div className=\"flex-shrink-0\">\n                  <div className=\"h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center\">\n                    <Bot className=\"h-4 w-4\" />\n                  </div>\n                </div>\n                <div className=\"rounded-lg px-4 py-2 bg-muted\">\n                  <Loader2 className=\"h-4 w-4 animate-spin\" />\n                </div>\n              </div>\n            )}\n            <div ref={messagesEndRef} />\n          </div>\n        </ScrollArea>\n\n        {/* Context Indicators */}\n        {contextIndicators && (\n          <div className=\"border-t px-4 py-2\">\n            <div className=\"flex flex-wrap gap-2 text-xs\">\n              {contextIndicators.sources?.length > 0 && (\n                <Badge variant=\"outline\" className=\"gap-1\">\n                  <FileText className=\"h-3 w-3\" />\n                  {contextIndicators.sources.length} {t.navigation.sources}\n                </Badge>\n              )}\n              {contextIndicators.insights?.length > 0 && (\n                <Badge variant=\"outline\" className=\"gap-1\">\n                  <Lightbulb className=\"h-3 w-3\" />\n                  {contextIndicators.insights.length} {contextIndicators.insights.length === 1 ? t.common.insight : t.common.insights}\n                </Badge>\n              )}\n              {contextIndicators.notes?.length > 0 && (\n                <Badge variant=\"outline\" className=\"gap-1\">\n                  <StickyNote className=\"h-3 w-3\" />\n                  {contextIndicators.notes.length} {contextIndicators.notes.length === 1 ? t.common.note : t.common.notes}\n                </Badge>\n              )}\n            </div>\n          </div>\n        )}\n\n        {/* Notebook Context Indicator */}\n        {notebookContextStats && (\n          <ContextIndicator\n            sourcesInsights={notebookContextStats.sourcesInsights}\n            sourcesFull={notebookContextStats.sourcesFull}\n            notesCount={notebookContextStats.notesCount}\n            tokenCount={notebookContextStats.tokenCount}\n            charCount={notebookContextStats.charCount}\n          />\n        )}\n\n        {/* Input Area */}\n        <div className=\"flex-shrink-0 p-4 space-y-3 border-t\">\n          {/* Model selector */}\n          {onModelChange && (\n            <div className=\"flex items-center justify-between\">\n              <span className=\"text-xs text-muted-foreground\">{t.chat.model}</span>\n              <ModelSelector\n                currentModel={modelOverride}\n                onModelChange={onModelChange}\n                disabled={isStreaming}\n              />\n            </div>\n          )}\n\n          <div className=\"flex gap-2 items-end min-w-0\">\n            <Textarea\n              id={chatInputId}\n              name=\"chat-message\"\n              autoComplete=\"off\"\n              value={input}\n              onChange={(e) => setInput(e.target.value)}\n              onKeyDown={handleKeyDown}\n              placeholder={`${t.chat.sendPlaceholder} (${t.chat.pressToSend.replace('{key}', keyHint)})`}\n              disabled={isStreaming}\n              className=\"flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3 min-w-0\"\n              rows={1}\n            />\n            <Button\n              onClick={handleSend}\n              disabled={!input.trim() || isStreaming}\n              size=\"icon\"\n              className=\"h-[40px] w-[40px] flex-shrink-0\"\n            >\n              {isStreaming ? (\n                <Loader2 className=\"h-4 w-4 animate-spin\" />\n              ) : (\n                <Send className=\"h-4 w-4\" />\n              )}\n            </Button>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n\n    </>\n  )\n}\n\n// Helper component to render AI messages with clickable references\nfunction AIMessageContent({\n  content,\n  onReferenceClick\n}: {\n  content: string\n  onReferenceClick: (type: string, id: string) => void\n}) {\n  const { t } = useTranslation()\n  // Convert references to compact markdown with numbered citations\n  const markdownWithCompactRefs = convertReferencesToCompactMarkdown(content, t.common.references)\n\n  // Create custom link component for compact references\n  const LinkComponent = createCompactReferenceLinkComponent(onReferenceClick)\n\n  return (\n    <div className=\"prose prose-sm prose-neutral dark:prose-invert max-w-none break-words prose-headings:font-semibold prose-a:text-blue-600 prose-a:break-all prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2\">\n      <ReactMarkdown\n        remarkPlugins={[remarkGfm]}\n        components={{\n          a: LinkComponent,\n          p: ({ children }) => <p className=\"mb-4\">{children}</p>,\n          h1: ({ children }) => <h1 className=\"mb-4 mt-6\">{children}</h1>,\n          h2: ({ children }) => <h2 className=\"mb-3 mt-5\">{children}</h2>,\n          h3: ({ children }) => <h3 className=\"mb-3 mt-4\">{children}</h3>,\n          h4: ({ children }) => <h4 className=\"mb-2 mt-4\">{children}</h4>,\n          h5: ({ children }) => <h5 className=\"mb-2 mt-3\">{children}</h5>,\n          h6: ({ children }) => <h6 className=\"mb-2 mt-3\">{children}</h6>,\n          li: ({ children }) => <li className=\"mb-1\">{children}</li>,\n          ul: ({ children }) => <ul className=\"mb-4 space-y-1\">{children}</ul>,\n          ol: ({ children }) => <ol className=\"mb-4 space-y-1\">{children}</ol>,\n          table: ({ children }) => (\n            <div className=\"my-4 overflow-x-auto\">\n              <table className=\"min-w-full border-collapse border border-border\">{children}</table>\n            </div>\n          ),\n          thead: ({ children }) => <thead className=\"bg-muted\">{children}</thead>,\n          tbody: ({ children }) => <tbody>{children}</tbody>,\n          tr: ({ children }) => <tr className=\"border-b border-border\">{children}</tr>,\n          th: ({ children }) => <th className=\"border border-border px-3 py-2 text-left font-semibold\">{children}</th>,\n          td: ({ children }) => <td className=\"border border-border px-3 py-2\">{children}</td>,\n        }}\n      >\n        {markdownWithCompactRefs}\n      </ReactMarkdown>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/MessageActions.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'\nimport { Save, Copy, Loader2, Check } from 'lucide-react'\nimport { useCreateNote } from '@/lib/hooks/use-notes'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface MessageActionsProps {\n  content: string\n  notebookId?: string\n}\n\nexport function MessageActions({ content, notebookId }: MessageActionsProps) {\n  const { t } = useTranslation()\n  const [copySuccess, setCopySuccess] = useState(false)\n  const createNote = useCreateNote()\n\n  const handleSaveToNote = () => {\n    if (!notebookId) {\n      toast.error(t.sources.cannotSaveNoteNoNotebook)\n      return\n    }\n\n    createNote.mutate({\n      content,\n      note_type: 'ai',\n      notebook_id: notebookId,\n      // Title will be auto-generated by the API for AI notes\n    })\n  }\n\n  const handleCopyToClipboard = async () => {\n    try {\n      // Try modern clipboard API first\n      if (navigator.clipboard && navigator.clipboard.writeText) {\n        await navigator.clipboard.writeText(content)\n        toast.success(t.common.copyToClipboard)\n        setCopySuccess(true)\n        setTimeout(() => setCopySuccess(false), 2000)\n      } else {\n        // Fallback for older browsers\n        const textArea = document.createElement('textarea')\n        textArea.value = content\n        textArea.style.position = 'fixed'\n        textArea.style.left = '-999999px'\n        textArea.style.top = '-999999px'\n        document.body.appendChild(textArea)\n        textArea.focus()\n        textArea.select()\n\n        try {\n          document.execCommand('copy')\n          toast.success(t.common.copyToClipboard)\n          setCopySuccess(true)\n          setTimeout(() => setCopySuccess(false), 2000)\n        } catch {\n          toast.error(t.common.error)\n        }\n\n        document.body.removeChild(textArea)\n      }\n    } catch (err) {\n      console.error('Failed to copy to clipboard:', err)\n      toast.error(t.common.error)\n    }\n  }\n\n  return (\n    <TooltipProvider>\n      <div className=\"flex gap-1\">\n        {notebookId && (\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"h-7 px-2\"\n                onClick={handleSaveToNote}\n                disabled={createNote.isPending}\n              >\n                {createNote.isPending ? (\n                  <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n                ) : (\n                  <Save className=\"h-3.5 w-3.5\" />\n                )}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{t.common.saveToNote}</p>\n            </TooltipContent>\n          </Tooltip>\n        )}\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 px-2\"\n              onClick={handleCopyToClipboard}\n              disabled={createNote.isPending}\n            >\n              {copySuccess ? (\n                <Check className=\"h-3.5 w-3.5 text-green-500\" />\n              ) : (\n                <Copy className=\"h-3.5 w-3.5\" />\n              )}\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{t.common.copyToClipboard}</p>\n          </TooltipContent>\n        </Tooltip>\n      </div>\n    </TooltipProvider>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/ModelSelector.tsx",
    "content": "'use client'\n\nimport { useEffect, useMemo, useState } from 'react'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport { Button } from '@/components/ui/button'\nimport { Label } from '@/components/ui/label'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from '@/components/ui/dialog'\nimport { Settings2, Sparkles } from 'lucide-react'\nimport { useModelDefaults, useModels } from '@/lib/hooks/use-models'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\n\ninterface ModelSelectorProps {\n  currentModel?: string\n  onModelChange: (model?: string) => void\n  disabled?: boolean\n}\n\nexport function ModelSelector({ \n  currentModel, \n  onModelChange,\n  disabled = false \n}: ModelSelectorProps) {\n  const { t } = useTranslation()\n  const [open, setOpen] = useState(false)\n  const [selectedModel, setSelectedModel] = useState(currentModel || 'default')\n  const { data: models, isLoading } = useModels()\n  const { data: defaults } = useModelDefaults()\n\n  useEffect(() => {\n    setSelectedModel(currentModel || 'default')\n  }, [currentModel])\n\n  // Filter for language models only and sort by name\n  const languageModels = useMemo(() => {\n    if (!models) {\n      return []\n    }\n    return [...models]\n      .filter((model) => model.type === 'language')\n      .sort((a, b) => a.name.localeCompare(b.name))\n  }, [models])\n\n  const defaultModel = useMemo(() => {\n    if (!defaults?.default_chat_model) return undefined\n    return languageModels.find(model => model.id === defaults.default_chat_model)\n  }, [defaults?.default_chat_model, languageModels])\n\n  const currentModelName = useMemo(() => {\n    if (currentModel) {\n      return languageModels.find(model => model.id === currentModel)?.name || currentModel\n    }\n    if (defaultModel) {\n      return defaultModel.name\n    }\n    return t.common.default\n  }, [currentModel, languageModels, defaultModel, t.common.default])\n\n  const handleSave = () => {\n    onModelChange(selectedModel === 'default' ? undefined : selectedModel)\n    setOpen(false)\n  }\n\n  const handleReset = () => {\n    setSelectedModel('default')\n    onModelChange(undefined)\n    setOpen(false)\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <Button \n          variant=\"outline\" \n          size=\"sm\"\n          disabled={disabled}\n          className=\"gap-2\"\n        >\n          <Settings2 className=\"h-4 w-4\" />\n          <span className=\"text-xs\">\n            {currentModelName}\n          </span>\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <Sparkles className=\"h-5 w-5\" />\n            {t.common.modelConfiguration}\n          </DialogTitle>\n          <DialogDescription>\n            {t.transformations.overrideModelDesc}\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"grid gap-4 py-4\">\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"model\">{t.common.model}</Label>\n            <Select value={selectedModel} onValueChange={setSelectedModel}>\n              <SelectTrigger id=\"model\">\n                <SelectValue placeholder={t.models.selectModelPlaceholder} />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"default\">\n                  <div className=\"flex items-center justify-between w-full\">\n                    <span>\n                      {defaultModel \n                        ? `${t.common.default} (${defaultModel.name})` \n                        : t.transformations.systemDefault}\n                    </span>\n                    {defaultModel?.provider && (\n                      <span className=\"text-xs text-muted-foreground ml-2\">\n                        {defaultModel.provider}\n                      </span>\n                    )}\n                  </div>\n                </SelectItem>\n                {isLoading ? (\n                  <div className=\"flex items-center justify-center py-2\">\n                    <LoadingSpinner size=\"sm\" />\n                  </div>\n                ) : (\n                  languageModels.map((model) => (\n                    <SelectItem key={model.id} value={model.id}>\n                      <div className=\"flex items-center justify-between w-full\">\n                        <span>{model.name}</span>\n                        <span className=\"text-xs text-muted-foreground ml-2\">\n                          {model.provider}\n                        </span>\n                      </div>\n                    </SelectItem>\n                  ))\n                )}\n              </SelectContent>\n            </Select>\n          </div>\n          {selectedModel && selectedModel !== 'default' && (\n            <div className=\"rounded-lg bg-muted p-3\">\n              <p className=\"text-sm text-muted-foreground\">\n                {t.transformations.sessionUseReplacement.replace(\n                  '{name}', \n                  languageModels.find(m => m.id === selectedModel)?.name || selectedModel\n                )}\n              </p>\n            </div>\n          )}\n        </div>\n        <DialogFooter className=\"flex justify-between\">\n          <Button variant=\"outline\" onClick={handleReset}>\n            {t.common.resetToDefault}\n          </Button>\n          <Button onClick={handleSave}>\n            {t.common.saveChanges}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/NotebookAssociations.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useMemo } from 'react'\nimport { LoaderIcon, BookOpen, Check } from 'lucide-react'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { useAddSourcesToNotebook, useRemoveSourceFromNotebook } from '@/lib/hooks/use-sources'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface NotebookAssociationsProps {\n  sourceId: string\n  currentNotebookIds: string[]\n  onSave?: () => void\n}\n\nexport function NotebookAssociations({\n  sourceId,\n  currentNotebookIds,\n  onSave,\n}: NotebookAssociationsProps) {\n  const { t } = useTranslation()\n  const [selectedNotebookIds, setSelectedNotebookIds] = useState<string[]>(currentNotebookIds)\n  const [isSaving, setIsSaving] = useState(false)\n\n  const { data: notebooks, isLoading } = useNotebooks()\n  const addSources = useAddSourcesToNotebook()\n  const removeFromNotebook = useRemoveSourceFromNotebook()\n\n  // Update selected notebooks when current changes (after save)\n  useEffect(() => {\n    setSelectedNotebookIds(currentNotebookIds)\n  }, [currentNotebookIds])\n\n  const hasChanges = useMemo(() => {\n    const current = new Set(currentNotebookIds)\n    const selected = new Set(selectedNotebookIds)\n\n    if (current.size !== selected.size) return true\n\n    for (const id of current) {\n      if (!selected.has(id)) return true\n    }\n\n    return false\n  }, [currentNotebookIds, selectedNotebookIds])\n\n  const handleToggleNotebook = (notebookId: string) => {\n    setSelectedNotebookIds(prev =>\n      prev.includes(notebookId)\n        ? prev.filter(id => id !== notebookId)\n        : [...prev, notebookId]\n    )\n  }\n\n  const handleSave = async () => {\n    if (!hasChanges) return\n\n    try {\n      setIsSaving(true)\n\n      const current = new Set(currentNotebookIds)\n      const selected = new Set(selectedNotebookIds)\n\n      // Determine which notebooks to add and remove\n      const toAdd = selectedNotebookIds.filter(id => !current.has(id))\n      const toRemove = currentNotebookIds.filter(id => !selected.has(id))\n\n      // Execute additions\n      if (toAdd.length > 0) {\n        await Promise.allSettled(\n          toAdd.map(notebookId =>\n            addSources.mutateAsync({\n              notebookId,\n              sourceIds: [sourceId],\n            })\n          )\n        )\n      }\n\n      // Execute removals\n      if (toRemove.length > 0) {\n        await Promise.allSettled(\n          toRemove.map(notebookId =>\n            removeFromNotebook.mutateAsync({\n              notebookId,\n              sourceId,\n            })\n          )\n        )\n      }\n\n      onSave?.()\n    } catch (error) {\n      console.error('Error saving notebook associations:', error)\n    } finally {\n      setIsSaving(false)\n    }\n  }\n\n  const handleCancel = () => {\n    setSelectedNotebookIds(currentNotebookIds)\n  }\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            <BookOpen className=\"h-5 w-5\" />\n            {t.sources.manageNotebooks}\n          </CardTitle>\n          <CardDescription>\n            {t.sources.manageNotebooksDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          <div className=\"flex items-center justify-center py-8\">\n            <LoaderIcon className=\"h-6 w-6 animate-spin text-muted-foreground\" />\n          </div>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  if (!notebooks || notebooks.length === 0) {\n    return (\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            <BookOpen className=\"h-5 w-5\" />\n            {t.sources.manageNotebooks}\n          </CardTitle>\n          <CardDescription>\n            {t.sources.manageNotebooksDesc}\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          <p className=\"text-sm text-muted-foreground\">{t.sources.noNotebooksAvailable}</p>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          <BookOpen className=\"h-5 w-5\" />\n          {t.sources.manageNotebooks}\n        </CardTitle>\n        <CardDescription>\n          {t.sources.manageNotebooksDesc}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <ScrollArea className=\"h-[300px] border rounded-md p-4\">\n          <div className=\"space-y-3\">\n            {notebooks\n              .filter(nb => !nb.archived)\n              .map((notebook) => {\n                const isSelected = selectedNotebookIds.includes(notebook.id)\n                const isCurrentlyLinked = currentNotebookIds.includes(notebook.id)\n\n                return (\n                  <div\n                    key={notebook.id}\n                    className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${\n                      isSelected ? 'bg-accent border-accent-foreground/20' : 'hover:bg-accent/50'\n                    }`}\n                  >\n                    <Checkbox\n                      checked={isSelected}\n                      onCheckedChange={() => handleToggleNotebook(notebook.id)}\n                      className=\"mt-0.5\"\n                    />\n                    <div className=\"flex-1 min-w-0\">\n                      <div className=\"flex items-center gap-2\">\n                        <h4 className=\"font-medium text-sm truncate\">\n                          {notebook.name}\n                        </h4>\n                        {isCurrentlyLinked && !hasChanges && (\n                          <Check className=\"h-4 w-4 text-green-600\" />\n                        )}\n                      </div>\n                      {notebook.description && (\n                        <p className=\"text-xs text-muted-foreground line-clamp-1\">\n                          {notebook.description}\n                        </p>\n                      )}\n                    </div>\n                  </div>\n                )\n              })}\n          </div>\n        </ScrollArea>\n\n        {hasChanges && (\n          <div className=\"flex items-center justify-end gap-2 pt-2 border-t\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleCancel}\n              disabled={isSaving}\n            >\n              {t.common.cancel}\n            </Button>\n            <Button\n              size=\"sm\"\n              onClick={handleSave}\n              disabled={isSaving}\n            >\n              {isSaving ? (\n                <>\n                  <LoaderIcon className=\"mr-2 h-4 w-4 animate-spin\" />\n                  {t.common.saving}...\n                </>\n              ) : (\n                t.common.saveChanges\n              )}\n            </Button>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/SessionManager.tsx",
    "content": "'use client'\n\nimport { useState, useMemo } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { Badge } from '@/components/ui/badge'\nimport {\n  MessageSquare,\n  Plus,\n  Trash2,\n  Edit2,\n  Check,\n  X,\n  Clock\n} from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport { BaseChatSession } from '@/lib/types/api'\nimport { useModels } from '@/lib/hooks/use-models'\n\ninterface SessionManagerProps {\n  sessions: BaseChatSession[]\n  currentSessionId: string | null\n  onCreateSession: (title: string) => void\n  onSelectSession: (sessionId: string) => void\n  onUpdateSession: (sessionId: string, title: string) => void\n  onDeleteSession: (sessionId: string) => void\n  loadingSessions: boolean\n}\n\nexport function SessionManager({\n  sessions,\n  currentSessionId,\n  onCreateSession,\n  onSelectSession,\n  onUpdateSession,\n  onDeleteSession,\n  loadingSessions\n}: SessionManagerProps) {\n  const { t, language } = useTranslation()\n  const [isCreating, setIsCreating] = useState(false)\n  const [newSessionTitle, setNewSessionTitle] = useState('')\n  const [editingId, setEditingId] = useState<string | null>(null)\n  const [editTitle, setEditTitle] = useState('')\n  const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)\n\n  const { data: models } = useModels()\n\n  // Helper to get model name from ID\n  const customModelLabel = t.common.customModel\n  const getModelName = useMemo(() => {\n    return (modelId: string) => {\n      const model = models?.find(m => m.id === modelId)\n      return model?.name || customModelLabel\n    }\n  }, [models, customModelLabel])\n\n  const handleCreateSession = () => {\n    if (newSessionTitle.trim()) {\n      onCreateSession(newSessionTitle.trim())\n      setNewSessionTitle('')\n      setIsCreating(false)\n    }\n  }\n\n  const handleStartEdit = (session: BaseChatSession) => {\n    setEditingId(session.id)\n    setEditTitle(session.title)\n  }\n\n  const handleSaveEdit = () => {\n    if (editingId && editTitle.trim()) {\n      onUpdateSession(editingId, editTitle.trim())\n      setEditingId(null)\n      setEditTitle('')\n    }\n  }\n\n  const handleCancelEdit = () => {\n    setEditingId(null)\n    setEditTitle('')\n  }\n\n  const handleDeleteConfirm = () => {\n    if (deleteConfirmId) {\n      onDeleteSession(deleteConfirmId)\n      setDeleteConfirmId(null)\n    }\n  }\n\n  return (\n    <>\n      <Card className=\"h-full flex flex-col\">\n        <CardHeader className=\"pb-3\">\n          <CardTitle className=\"flex items-center justify-between\">\n            <span className=\"flex items-center gap-2\">\n              <MessageSquare className=\"h-5 w-5\" />\n              {t.chat.sessions}\n            </span>\n            <Button\n              size=\"sm\"\n              variant=\"outline\"\n              onClick={() => setIsCreating(true)}\n            >\n              <Plus className=\"h-4 w-4\" />\n            </Button>\n          </CardTitle>\n        </CardHeader>\n        <CardContent className=\"flex-1 p-0 min-h-0\">\n          <ScrollArea className=\"h-full px-4\">\n            {isCreating && (\n              <div className=\"p-3 border rounded-lg mb-3\">\n                <Input\n                  value={newSessionTitle}\n                  onChange={(e) => setNewSessionTitle(e.target.value)}\n                  placeholder={t.chat.sessionTitlePlaceholder}\n                  className=\"mb-2\"\n                  autoFocus\n                  onKeyPress={(e) => {\n                    if (e.key === 'Enter') handleCreateSession()\n                  }}\n                />\n                <div className=\"flex gap-2\">\n                  <Button size=\"sm\" onClick={handleCreateSession}>\n                    {t.common.create}\n                  </Button>\n                  <Button\n                    size=\"sm\"\n                    variant=\"outline\"\n                    onClick={() => {\n                      setIsCreating(false)\n                      setNewSessionTitle('')\n                    }}\n                  >\n                    {t.common.cancel}\n                  </Button>\n                </div>\n              </div>\n            )}\n\n            {loadingSessions ? (\n              <div className=\"text-center py-8 text-muted-foreground\">\n                {t.common.loading}\n              </div>\n            ) : sessions.length === 0 ? (\n              <div className=\"text-center py-8 text-muted-foreground\">\n                <MessageSquare className=\"h-12 w-12 mx-auto mb-4 opacity-50\" />\n                <p className=\"text-sm\">{t.chat.noSessions}</p>\n                <p className=\"text-xs mt-2\">{t.chat.createToStart}</p>\n              </div>\n            ) : (\n              <div className=\"space-y-2 pb-4\">\n                {sessions.map((session) => (\n                  <div\n                    key={session.id}\n                    className={`p-3 rounded-lg border cursor-pointer transition-colors ${\n                      currentSessionId === session.id\n                        ? 'bg-primary/10 border-primary'\n                        : 'hover:bg-muted'\n                    }`}\n                    onClick={() => onSelectSession(session.id)}\n                  >\n                    {editingId === session.id ? (\n                      <div className=\"space-y-2\" onClick={(e) => e.stopPropagation()}>\n                        <Input\n                          value={editTitle}\n                          onChange={(e) => setEditTitle(e.target.value)}\n                          onKeyPress={(e) => {\n                            if (e.key === 'Enter') handleSaveEdit()\n                            if (e.key === 'Escape') handleCancelEdit()\n                          }}\n                          autoFocus\n                        />\n                        <div className=\"flex gap-2\">\n                          <Button size=\"sm\" onClick={handleSaveEdit}>\n                            <Check className=\"h-3 w-3\" />\n                          </Button>\n                          <Button\n                            size=\"sm\"\n                            variant=\"outline\"\n                            onClick={handleCancelEdit}\n                          >\n                            <X className=\"h-3 w-3\" />\n                          </Button>\n                        </div>\n                      </div>\n                    ) : (\n                      <>\n                        <div className=\"flex items-start justify-between mb-1\">\n                          <h4 className=\"font-medium text-sm\">\n                            {session.title}\n                          </h4>\n                          <div className=\"flex gap-1\" onClick={(e) => e.stopPropagation()}>\n                            <Button\n                              size=\"sm\"\n                              variant=\"ghost\"\n                              className=\"h-6 w-6 p-0\"\n                              onClick={() => handleStartEdit(session)}\n                            >\n                              <Edit2 className=\"h-3 w-3\" />\n                            </Button>\n                            <Button\n                              size=\"sm\"\n                              variant=\"ghost\"\n                              className=\"h-6 w-6 p-0\"\n                              onClick={() => setDeleteConfirmId(session.id)}\n                            >\n                              <Trash2 className=\"h-3 w-3\" />\n                            </Button>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                          <Clock className=\"h-3 w-3\" />\n                          {formatDistanceToNow(new Date(session.created), {\n                            addSuffix: true,\n                            locale: getDateLocale(language)\n                          })}\n                        </div>\n                        {session.message_count != null && session.message_count > 0 && (\n                          <Badge variant=\"secondary\" className=\"mt-2 text-xs\">\n                            {t.chat.messagesCount.replace('{count}', session.message_count.toString())}\n                          </Badge>\n                        )}\n                        {session.model_override && (\n                          <Badge variant=\"outline\" className=\"mt-2 ml-2 text-xs\">\n                            {getModelName(session.model_override)}\n                          </Badge>\n                        )}\n                      </>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n          </ScrollArea>\n        </CardContent>\n      </Card>\n\n      <AlertDialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t.chat.deleteSession}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {t.chat.deleteSessionDesc}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>\n            <AlertDialogAction onClick={handleDeleteConfirm}>\n              {t.common.delete}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  )\n}"
  },
  {
    "path": "frontend/src/components/source/SourceDetailContent.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useCallback, useMemo } from 'react'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { isAxiosError } from 'axios'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { sourcesApi } from '@/lib/api/sources'\nimport { insightsApi, SourceInsightResponse } from '@/lib/api/insights'\nimport { transformationsApi } from '@/lib/api/transformations'\nimport { embeddingApi } from '@/lib/api/embedding'\nimport { SourceDetailResponse } from '@/lib/types/api'\nimport { Transformation } from '@/lib/types/transformations'\nimport { LoadingSpinner } from '@/components/common/LoadingSpinner'\nimport { InlineEdit } from '@/components/common/InlineEdit'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Label } from '@/components/ui/label'\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/components/ui/dropdown-menu'\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from '@/components/ui/alert-dialog'\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from '@/components/ui/select'\nimport {\n  Link as LinkIcon,\n  Upload,\n  AlignLeft,\n  ExternalLink,\n  Download,\n  Copy,\n  CheckCircle,\n  Youtube,\n  MoreVertical,\n  Trash2,\n  Sparkles,\n  Plus,\n  Lightbulb,\n  Database,\n  AlertCircle,\n  MessageSquare,\n} from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport { getDateLocale } from '@/lib/utils/date-locale'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { SourceInsightDialog } from '@/components/source/SourceInsightDialog'\nimport { NotebookAssociations } from '@/components/source/NotebookAssociations'\n\ninterface SourceDetailContentProps {\n  sourceId: string\n  showChatButton?: boolean\n  onChatClick?: () => void\n  onClose?: () => void\n}\n\nexport function SourceDetailContent({\n  sourceId,\n  showChatButton = false,\n  onChatClick,\n  onClose\n}: SourceDetailContentProps) {\n  const { t, language } = useTranslation()\n  const queryClient = useQueryClient()\n  const [source, setSource] = useState<SourceDetailResponse | null>(null)\n  const [insights, setInsights] = useState<SourceInsightResponse[]>([])\n  const [transformations, setTransformations] = useState<Transformation[]>([])\n  const [selectedTransformation, setSelectedTransformation] = useState<string>('')\n  const [loading, setLoading] = useState(true)\n  const [loadingInsights, setLoadingInsights] = useState(false)\n  const [creatingInsight, setCreatingInsight] = useState(false)\n  const [error, setError] = useState<string | null>(null)\n  const [copied, setCopied] = useState(false)\n  const [isEmbedding, setIsEmbedding] = useState(false)\n  const [isDownloadingFile, setIsDownloadingFile] = useState(false)\n  const [fileAvailable, setFileAvailable] = useState<boolean | null>(null)\n  const [selectedInsight, setSelectedInsight] = useState<SourceInsightResponse | null>(null)\n  const [insightToDelete, setInsightToDelete] = useState<string | null>(null)\n  const [deletingInsight, setDeletingInsight] = useState(false)\n\n  const fetchSource = useCallback(async () => {\n    try {\n      setLoading(true)\n      const data = await sourcesApi.get(sourceId)\n      setSource(data)\n      if (typeof data.file_available === 'boolean') {\n        setFileAvailable(data.file_available)\n      } else if (!data.asset?.file_path) {\n        setFileAvailable(null)\n      } else {\n        setFileAvailable(null)\n      }\n    } catch (err) {\n      console.error('Failed to fetch source:', err)\n      setError(t.sources.loadFailed)\n    } finally {\n      setLoading(false)\n    }\n  }, [sourceId, t])\n\n  const fetchInsights = useCallback(async () => {\n    try {\n      setLoadingInsights(true)\n      const data = await insightsApi.listForSource(sourceId)\n      setInsights(data)\n    } catch (err) {\n      console.error('Failed to fetch insights:', err)\n    } finally {\n      setLoadingInsights(false)\n    }\n  }, [sourceId])\n\n  const fetchTransformations = useCallback(async () => {\n    try {\n      const data = await transformationsApi.list()\n      setTransformations(data)\n    } catch (err) {\n      console.error('Failed to fetch transformations:', err)\n    }\n  }, [])\n\n  useEffect(() => {\n    if (sourceId) {\n      void fetchSource()\n      void fetchInsights()\n      void fetchTransformations()\n    }\n  }, [fetchInsights, fetchSource, fetchTransformations, sourceId])\n\n  const createInsight = async () => {\n    if (!selectedTransformation) {\n      toast.error(t.sources.selectTransformation)\n      return\n    }\n\n    try {\n      setCreatingInsight(true)\n      const response = await insightsApi.create(sourceId, {\n        transformation_id: selectedTransformation\n      })\n      // Show toast for async operation\n      toast.success(t.sources.insightGenerationStarted)\n      setSelectedTransformation('')\n\n      // Poll for command completion if we have a command_id\n      if (response.command_id) {\n        // Poll in background (don't block UI)\n        insightsApi.waitForCommand(response.command_id, {\n          maxAttempts: 120, // Up to 4 minutes (120 * 2s)\n          intervalMs: 2000\n        }).then(success => {\n          if (success) {\n            void fetchInsights()\n            // Invalidate sources queries so notebook page refreshes with updated insights_count\n            queryClient.invalidateQueries({ queryKey: ['sources'] })\n          }\n        }).catch(err => {\n          console.error('Error waiting for insight command:', err)\n        })\n      } else {\n        // Fallback: refresh after delay if no command_id\n        setTimeout(() => {\n          void fetchInsights()\n          // Also invalidate sources queries\n          queryClient.invalidateQueries({ queryKey: ['sources'] })\n        }, 5000)\n      }\n    } catch (err) {\n      console.error('Failed to create insight:', err)\n      toast.error(t.common.error)\n    } finally {\n      setCreatingInsight(false)\n    }\n  }\n\n  const handleDeleteInsight = async (e?: React.MouseEvent) => {\n    e?.preventDefault()\n    if (!insightToDelete) return\n\n    try {\n      setDeletingInsight(true)\n      await insightsApi.delete(insightToDelete)\n      toast.success(t.common.success)\n      setInsightToDelete(null)\n      await fetchInsights()\n    } catch (err) {\n      console.error('Failed to delete insight:', err)\n      toast.error(t.common.error)\n    } finally {\n      setDeletingInsight(false)\n    }\n  }\n\n  const handleUpdateTitle = async (title: string) => {\n    if (!source || title === source.title) return\n\n    try {\n      await sourcesApi.update(sourceId, { title })\n      toast.success(t.common.success)\n      setSource({ ...source, title })\n    } catch (err) {\n      console.error('Failed to update source title:', err)\n      toast.error(t.common.error)\n      await fetchSource()\n    }\n  }\n\n  const handleEmbedContent = async () => {\n    if (!source) return\n\n    try {\n      setIsEmbedding(true)\n      const response = await embeddingApi.embedContent(sourceId, 'source')\n      toast.success(response.message || t.common.success)\n      await fetchSource()\n    } catch (err) {\n      console.error('Failed to embed content:', err)\n      toast.error(t.common.error)\n    } finally {\n      setIsEmbedding(false)\n    }\n  }\n\n  const extractFilename = (pathOrUrl: string | undefined, fallback: string) => {\n    if (!pathOrUrl) {\n      return fallback\n    }\n    const segments = pathOrUrl.split(/[/\\\\]/)\n    return segments.pop() || fallback\n  }\n\n  const parseContentDisposition = (header?: string | null) => {\n    if (!header) {\n      return null\n    }\n    const match = header.match(/filename\\*?=([^;]+)/i)\n    if (!match) {\n      return null\n    }\n    const value = match[1].trim()\n    if (value.toLowerCase().startsWith(\"utf-8''\")) {\n      return decodeURIComponent(value.slice(7))\n    }\n    return value.replace(/^[\"']|[\"']$/g, '')\n  }\n\n  const handleDownloadFile = async () => {\n    if (!source?.asset?.file_path || isDownloadingFile || fileAvailable === false) {\n      return\n    }\n\n    try {\n      setIsDownloadingFile(true)\n      const response = await sourcesApi.downloadFile(source.id)\n      const filenameFromHeader = parseContentDisposition(\n        response.headers?.['content-disposition'] as string | undefined\n      )\n      const fallbackName = extractFilename(source.asset.file_path, `source-${source.id}`)\n      const filename = filenameFromHeader || fallbackName\n\n      const blobUrl = window.URL.createObjectURL(response.data)\n      const link = document.createElement('a')\n      link.href = blobUrl\n      link.download = filename\n      document.body.appendChild(link)\n      link.click()\n      document.body.removeChild(link)\n      window.URL.revokeObjectURL(blobUrl)\n      setFileAvailable(true)\n      toast.success(t.common.success)\n    } catch (err) {\n      console.error('Failed to download file:', err)\n      if (isAxiosError(err) && err.response?.status === 404) {\n        setFileAvailable(false)\n        toast.error(t.sources.fileUnavailable)\n      } else {\n        toast.error(t.common.error)\n      }\n    } finally {\n      setIsDownloadingFile(false)\n    }\n  }\n\n  const getSourceIcon = () => {\n    if (!source) return null\n    if (source.asset?.url) return <LinkIcon className=\"h-5 w-5\" />\n    if (source.asset?.file_path) return <Upload className=\"h-5 w-5\" />\n    return <AlignLeft className=\"h-5 w-5\" />\n  }\n\n  const getSourceType = () => {\n    if (!source) return 'unknown'\n    if (source.asset?.url) return 'link'\n    if (source.asset?.file_path) return 'file'\n    return 'text'\n  }\n\n  const handleCopyUrl = useCallback(() => {\n    if (source?.asset?.url) {\n      navigator.clipboard.writeText(source.asset.url)\n      setCopied(true)\n      toast.success(t.sources.urlCopied)\n      setTimeout(() => setCopied(false), 2000)\n    }\n  }, [source, t])\n\n  const handleOpenExternal = useCallback(() => {\n    if (source?.asset?.url) {\n      window.open(source.asset.url, '_blank')\n    }\n  }, [source])\n\n  const getYouTubeVideoId = (url: string): string | null => {\n    const patterns = [\n      /(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)([^&\\n?#]+)/,\n      /youtube\\.com\\/watch\\?.*v=([^&\\n?#]+)/\n    ]\n\n    for (const pattern of patterns) {\n      const match = url.match(pattern)\n      if (match) return match[1]\n    }\n    return null\n  }\n\n  const isYouTubeUrl = useMemo(() => {\n    if (!source?.asset?.url) return false\n    return !!(getYouTubeVideoId(source.asset.url))\n  }, [source?.asset?.url])\n\n  const youTubeVideoId = useMemo(() => {\n    if (!source?.asset?.url) return null\n    return getYouTubeVideoId(source.asset.url)\n  }, [source?.asset?.url])\n\n  const handleDelete = async () => {\n    if (!source) return\n\n    if (confirm(t.sources.deleteSourceConfirm || t.common.confirm)) {\n      try {\n        await sourcesApi.delete(source.id)\n        toast.success(t.common.success)\n        onClose?.()\n      } catch (error) {\n        console.error('Failed to delete source:', error)\n        toast.error(t.common.error)\n      }\n    }\n  }\n\n  if (loading) {\n    return (\n      <div className=\"flex h-full items-center justify-center p-8\">\n        <LoadingSpinner />\n      </div>\n    )\n  }\n\n  if (error || !source) {\n    return (\n      <div className=\"flex h-full flex-col items-center justify-center gap-4 p-8\">\n        <p className=\"text-red-500\">{error || t.sources.notFound}</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className=\"flex flex-col h-full\">\n      {/* Header */}\n      <div className=\"pb-4 px-2\">\n        <div className=\"flex items-start justify-between\">\n          <div className=\"flex-1\">\n            <InlineEdit\n              value={source.title || ''}\n              onSave={handleUpdateTitle}\n              className=\"text-2xl font-bold\"\n              inputClassName=\"text-2xl font-bold\"\n              placeholder={t.sources.titlePlaceholder}\n              emptyText={t.sources.untitledSource}\n            />\n            <p className=\"mt-1 text-sm text-muted-foreground\">\n              {t.sources.id}: {source.id}\n            </p>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            {getSourceIcon()}\n            <Badge variant=\"secondary\" className=\"text-sm\">\n              {getSourceType()}\n            </Badge>\n\n            {/* Chat with source button - only in modal */}\n            {showChatButton && onChatClick && (\n              <Button variant=\"outline\" size=\"sm\" onClick={onChatClick}>\n                <MessageSquare className=\"h-4 w-4 mr-2\" />\n                {t.chat.chatWith.replace('{name}', t.navigation.sources)}\n              </Button>\n            )}\n\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button variant=\"ghost\" size=\"icon\">\n                  <MoreVertical className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                {source.asset?.file_path && (\n                  <>\n                    <DropdownMenuItem\n                      onClick={handleDownloadFile}\n                      disabled={isDownloadingFile || fileAvailable === false}\n                    >\n                      <Download className=\"mr-2 h-4 w-4\" />\n                      {fileAvailable === false\n                        ? t.sources.fileUnavailable\n                        : isDownloadingFile\n                          ? t.sources.preparing\n                          : t.sources.downloadFile}\n                    </DropdownMenuItem>\n                    <DropdownMenuSeparator />\n                  </>\n                )}\n                <DropdownMenuItem\n                  onClick={handleEmbedContent}\n                  disabled={isEmbedding || source.embedded}\n                >\n                  <Database className=\"mr-2 h-4 w-4\" />\n                  {isEmbedding ? t.sources.embedding : source.embedded ? t.sources.alreadyEmbedded : t.sources.embedContent}\n                </DropdownMenuItem>\n                <DropdownMenuSeparator />\n                <DropdownMenuItem\n                  className=\"text-destructive\"\n                  onClick={handleDelete}\n                >\n                  <Trash2 className=\"mr-2 h-4 w-4\" />\n                  {t.sources.deleteSource}\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </div>\n      </div>\n\n      {/* Tabs Content */}\n      <div className=\"flex-1 overflow-y-auto px-2\">\n        <Tabs defaultValue=\"content\" className=\"w-full\">\n          <TabsList className=\"grid w-full grid-cols-3 sticky top-0 z-10\">\n            <TabsTrigger value=\"content\">{t.sources.content}</TabsTrigger>\n            <TabsTrigger value=\"insights\">\n              {t.common.insights} {insights.length > 0 && `(${insights.length})`}\n            </TabsTrigger>\n            <TabsTrigger value=\"details\">{t.sources.details}</TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"content\" className=\"mt-6\">\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"flex items-center gap-2\">\n                  {isYouTubeUrl && <Youtube className=\"h-5 w-5\" />}\n                  {t.sources.content}\n                </CardTitle>\n                {source.asset?.url && !isYouTubeUrl && (\n                  <CardDescription className=\"flex items-center gap-2\">\n                    <LinkIcon className=\"h-4 w-4\" />\n                    <a\n                      href={source.asset.url}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className=\"hover:underline text-blue-600\"\n                    >\n                      {source.asset.url}\n                    </a>\n                  </CardDescription>\n                )}\n              </CardHeader>\n              <CardContent>\n                {isYouTubeUrl && youTubeVideoId && (\n                  <div className=\"mb-6\">\n                    <div className=\"aspect-video rounded-lg overflow-hidden bg-black\">\n                      <iframe\n                        src={`https://www.youtube.com/embed/${youTubeVideoId}`}\n                        title={t.common.accessibility.ytVideo}\n                        className=\"w-full h-full\"\n                        allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n                        allowFullScreen\n                      />\n                    </div>\n                    {source.asset?.url && (\n                      <div className=\"mt-2\">\n                        <a\n                          href={source.asset.url}\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"text-sm text-muted-foreground hover:underline inline-flex items-center gap-1\"\n                        >\n                          <ExternalLink className=\"h-3 w-3\" />\n                          {t.sources.openOnYoutube}\n                        </a>\n                      </div>\n                    )}\n                  </div>\n                )}\n                <div className=\"prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-p:mb-4 prose-p:leading-7 prose-li:mb-2\">\n                  <ReactMarkdown\n                    remarkPlugins={[remarkGfm]}\n                    components={{\n                      p: ({ children }) => <p className=\"mb-4\">{children}</p>,\n                      h1: ({ children }) => <h1 className=\"text-2xl font-bold mt-6 mb-4\">{children}</h1>,\n                      h2: ({ children }) => <h2 className=\"text-xl font-bold mt-5 mb-3\">{children}</h2>,\n                      h3: ({ children }) => <h3 className=\"text-lg font-semibold mt-4 mb-2\">{children}</h3>,\n                      ul: ({ children }) => <ul className=\"mb-4 list-disc pl-6\">{children}</ul>,\n                      ol: ({ children }) => <ol className=\"mb-4 list-decimal pl-6\">{children}</ol>,\n                      li: ({ children }) => <li className=\"mb-1\">{children}</li>,\n                      table: ({ children }) => (\n                        <div className=\"my-4 overflow-x-auto\">\n                          <table className=\"min-w-full border-collapse border border-border\">{children}</table>\n                        </div>\n                      ),\n                      thead: ({ children }) => <thead className=\"bg-muted\">{children}</thead>,\n                      tbody: ({ children }) => <tbody>{children}</tbody>,\n                      tr: ({ children }) => <tr className=\"border-b border-border\">{children}</tr>,\n                      th: ({ children }) => <th className=\"border border-border px-3 py-2 text-left font-semibold\">{children}</th>,\n                      td: ({ children }) => <td className=\"border border-border px-3 py-2\">{children}</td>,\n                    }}\n                  >\n                    {source.full_text || t.sources.noContent}\n                  </ReactMarkdown>\n                </div>\n              </CardContent>\n            </Card>\n          </TabsContent>\n\n          <TabsContent value=\"insights\" className=\"mt-6\">\n            <Card>\n              <CardHeader>\n                <CardTitle className=\"flex items-center justify-between\">\n                  <span className=\"flex items-center gap-2\">\n                    <Lightbulb className=\"h-5 w-5\" />\n                    {t.common.insights}\n                  </span>\n                  <Badge variant=\"secondary\">{insights.length}</Badge>\n                </CardTitle>\n                <CardDescription>\n                  {t.sources.insightsDesc}\n                </CardDescription>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                {/* Create New Insight */}\n                <div className=\"rounded-lg border bg-muted/30 p-4\">\n                  <Label \n                    htmlFor=\"transformation-select\"\n                    className=\"mb-3 text-sm font-semibold flex items-center gap-2\"\n                  >\n                    <Sparkles className=\"h-4 w-4\" />\n                    {t.sources.generateNewInsight}\n                  </Label>\n                  <div className=\"flex gap-2\">\n                    <Select\n                      name=\"transformation\"\n                      value={selectedTransformation}\n                      onValueChange={setSelectedTransformation}\n                      disabled={creatingInsight}\n                    >\n                      <SelectTrigger id=\"transformation-select\" className=\"flex-1\">\n                        <SelectValue placeholder={t.sources.selectTransformation} />\n                      </SelectTrigger>\n                      <SelectContent>\n                        {transformations.map((trans) => (\n                          <SelectItem key={trans.id} value={trans.id}>\n                            {trans.title || trans.name}\n                          </SelectItem>\n                        ))}\n                      </SelectContent>\n                    </Select>\n                    <Button\n                      size=\"sm\"\n                      onClick={createInsight}\n                      disabled={!selectedTransformation || creatingInsight}\n                    >\n                      {creatingInsight ? (\n                        <>\n                          <LoadingSpinner className=\"mr-2 h-3 w-3\" />\n                          {t.common.creating}\n                        </>\n                      ) : (\n                        <>\n                          <Plus className=\"mr-2 h-4 w-4\" />\n                          {t.common.create}\n                        </>\n                      )}\n                    </Button>\n                  </div>\n                </div>\n\n                {/* Insights List */}\n                {loadingInsights ? (\n                  <div className=\"flex items-center justify-center py-8\">\n                    <LoadingSpinner />\n                  </div>\n                ) : insights.length === 0 ? (\n                  <div className=\"text-center py-8 text-muted-foreground\">\n                    <Lightbulb className=\"h-12 w-12 mx-auto mb-3 opacity-50\" />\n                    <p className=\"text-sm\">{t.sources.noInsightsYet}</p>\n                    <p className=\"text-xs mt-1\">{t.sources.createFirstInsight}</p>\n                  </div>\n                ) : (\n                  <div className=\"space-y-3\">\n                    {insights.map((insight) => (\n                      <div key={insight.id} className=\"rounded-lg border bg-background p-4\">\n                        <div className=\"flex items-start justify-between\">\n                          <div className=\"flex items-center gap-2\">\n                            <Badge variant=\"outline\" className=\"text-xs uppercase\">\n                              {insight.insight_type}\n                            </Badge>\n                          </div>\n                        </div>\n                        <p className=\"mt-2 text-sm text-muted-foreground\">\n                          {insight.content.slice(0, 180)}{insight.content.length > 180 ? '…' : ''}\n                        </p>\n                        <div className=\"mt-3 flex justify-end gap-2\">\n                          <Button size=\"sm\" variant=\"outline\" onClick={() => setSelectedInsight(insight)}>\n                            {t.sources.viewInsight}\n                          </Button>\n                          <Button\n                            size=\"sm\"\n                            variant=\"outline\"\n                            onClick={() => setInsightToDelete(insight.id)}\n                            className=\"text-destructive hover:text-destructive\"\n                          >\n                            <Trash2 className=\"h-4 w-4\" />\n                          </Button>\n                        </div>\n                      </div>\n                    ))}\n                  </div>\n                )}\n              </CardContent>\n            </Card>\n          </TabsContent>\n\n          <TabsContent value=\"details\" className=\"mt-6\">\n            <Card>\n              <CardHeader>\n                <CardTitle>{t.sources.details}</CardTitle>\n              </CardHeader>\n              <CardContent className=\"space-y-6\">\n                {/* Embedding Alert */}\n                {!source.embedded && (\n                  <Alert>\n                    <AlertCircle className=\"h-4 w-4\" />\n                    <AlertTitle>\n                      {t.sources.notEmbeddedAlert}\n                    </AlertTitle>\n                    <AlertDescription>\n                      {t.sources.notEmbeddedDesc}\n                      <div className=\"mt-3\">\n                        <Button\n                          onClick={handleEmbedContent}\n                          disabled={isEmbedding}\n                          size=\"sm\"\n                        >\n                          <Database className=\"mr-2 h-4 w-4\" />\n                          {isEmbedding ? t.sources.embedding : t.sources.embedContent}\n                        </Button>\n                      </div>\n                    </AlertDescription>\n                  </Alert>\n                )}\n\n                {/* Source Information */}\n                <div className=\"space-y-4\">\n                  {source.asset?.url && (\n                    <div>\n                      <h3 className=\"mb-2 text-sm font-semibold\">{t.common.url}</h3>\n                      <div className=\"flex items-center gap-2\">\n                        <code className=\"flex-1 rounded bg-muted px-2 py-1 text-sm\">\n                          {source.asset.url}\n                        </code>\n                        <Button\n                          size=\"sm\"\n                          variant=\"outline\"\n                          onClick={handleCopyUrl}\n                        >\n                          {copied ? (\n                            <CheckCircle className=\"h-4 w-4\" />\n                          ) : (\n                            <Copy className=\"h-4 w-4\" />\n                          )}\n                        </Button>\n                        <Button\n                          size=\"sm\"\n                          variant=\"outline\"\n                          onClick={handleOpenExternal}\n                        >\n                          <ExternalLink className=\"h-4 w-4\" />\n                        </Button>\n                      </div>\n                    </div>\n                  )}\n\n                  {source.asset?.file_path && (\n                    <div className=\"space-y-2\">\n                      <h3 className=\"text-sm font-semibold\">{t.sources.uploadedFile}</h3>\n                      <div className=\"flex flex-wrap items-center gap-2\">\n                        <code className=\"rounded bg-muted px-2 py-1 text-sm\">\n                          {source.asset.file_path}\n                        </code>\n                        <Button\n                          size=\"sm\"\n                          variant=\"outline\"\n                          onClick={handleDownloadFile}\n                          disabled={isDownloadingFile || fileAvailable === false}\n                        >\n                          <Download className=\"mr-2 h-4 w-4\" />\n                          {fileAvailable === false\n                            ? t.sources.fileUnavailable\n                            : isDownloadingFile\n                              ? t.sources.preparing\n                              : t.common.download}\n                        </Button>\n                      </div>\n                      {fileAvailable === false ? (\n                        <p className=\"text-xs text-muted-foreground\">\n                          {t.sources.fileUnavailableDesc}\n                        </p>\n                      ) : null}\n                    </div>\n                  )}\n\n                  {source.topics && source.topics.length > 0 && (\n                    <div>\n                      <h3 className=\"mb-2 text-sm font-semibold\">{t.sources.topics}</h3>\n                      <div className=\"flex flex-wrap gap-2\">\n                        {source.topics.map((topic, idx) => (\n                          <Badge key={idx} variant=\"outline\">\n                            {topic}\n                          </Badge>\n                        ))}\n                      </div>\n                    </div>\n                  )}\n                </div>\n\n                {/* Metadata */}\n                <div>\n                  <div className=\"flex items-center justify-between mb-3\">\n                    <h3 className=\"text-sm font-semibold\">{t.sources.metadata}</h3>\n                    <div className=\"flex items-center gap-2\">\n                      <Database className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                      <Badge variant={source.embedded ? \"default\" : \"secondary\"} className=\"text-xs\">\n                        {source.embedded ? t.sources.embedded : t.sources.notEmbedded}\n                      </Badge>\n                    </div>\n                  </div>\n                  <div className=\"grid gap-4 sm:grid-cols-2\">\n                    <div>\n                      <p className=\"text-xs font-medium text-muted-foreground\">{t.common.created_label}</p>\n                      <p className=\"text-sm\">\n                        {formatDistanceToNow(new Date(source.created), {\n                          addSuffix: true,\n                          locale: getDateLocale(language)\n                        })}\n                      </p>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {new Date(source.created).toLocaleString()}\n                      </p>\n                    </div>\n                    <div>\n                      <p className=\"text-xs font-medium text-muted-foreground\">{t.common.updated_label}</p>\n                      <p className=\"text-sm\">\n                        {formatDistanceToNow(new Date(source.updated), {\n                          addSuffix: true,\n                          locale: getDateLocale(language)\n                        })}\n                      </p>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {new Date(source.updated).toLocaleString()}\n                      </p>\n                    </div>\n                  </div>\n                </div>\n              </CardContent>\n            </Card>\n\n            {/* Notebook Associations */}\n            <NotebookAssociations\n              sourceId={sourceId}\n              currentNotebookIds={source.notebooks || []}\n              onSave={fetchSource}\n            />\n          </TabsContent>\n        </Tabs>\n      </div>\n\n      <SourceInsightDialog\n        open={Boolean(selectedInsight)}\n        onOpenChange={(open) => {\n          if (!open) {\n            setSelectedInsight(null)\n          }\n        }}\n        insight={selectedInsight ?? undefined}\n        onDelete={async (insightId) => {\n          try {\n            await insightsApi.delete(insightId)\n            toast.success(t.common.success)\n            setSelectedInsight(null)\n            await fetchInsights()\n          } catch (err) {\n            console.error('Failed to delete insight:', err)\n            toast.error(t.common.error)\n          }\n        }}\n      />\n\n      <AlertDialog open={!!insightToDelete} onOpenChange={() => setInsightToDelete(null)}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>{t.sources.deleteInsight}</AlertDialogTitle>\n            <AlertDialogDescription>\n              {t.sources.deleteInsightConfirm}\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={deletingInsight}>{t.common.cancel}</AlertDialogCancel>\n            <AlertDialogAction asChild>\n              <Button\n                onClick={handleDeleteInsight}\n                disabled={deletingInsight}\n                variant=\"destructive\"\n              >\n                {deletingInsight ? t.common.deleting : t.common.delete}\n              </Button>\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/SourceDialog.tsx",
    "content": "'use client'\n\nimport { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'\nimport { SourceDetailContent } from './SourceDetailContent'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface SourceDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  sourceId: string | null\n}\n\n/**\n * Source Dialog Component\n *\n * Displays source details in a modal dialog.\n * Includes a \"Chat with source\" button that opens the full source page in a new tab.\n */\nexport function SourceDialog({ open, onOpenChange, sourceId }: SourceDialogProps) {\n  const { t } = useTranslation()\n  // Ensure source ID has 'source:' prefix for API calls and routing\n  const sourceIdWithPrefix = sourceId\n    ? (sourceId.includes(':') ? sourceId : `source:${sourceId}`)\n    : null\n\n  const handleChatClick = () => {\n    if (sourceIdWithPrefix) {\n      window.open(`/sources/${sourceIdWithPrefix}`, '_blank')\n      // Modal stays open after opening chat\n    }\n  }\n\n  const handleClose = () => {\n    onOpenChange(false)\n  }\n\n  if (!sourceIdWithPrefix) {\n    return null\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-5xl max-h-[90vh] flex flex-col p-0\">\n        {/* Accessibility title (hidden visually but read by screen readers) */}\n        <DialogTitle className=\"sr-only\">{t.sources.detailsTitle}</DialogTitle>\n\n        {/* Source detail content */}\n        <div className=\"flex-1 overflow-y-auto min-h-0\">\n          <SourceDetailContent\n            sourceId={sourceIdWithPrefix}\n            showChatButton={true}\n            onChatClick={handleChatClick}\n            onClose={handleClose}\n          />\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/source/SourceInsightDialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { FileText } from 'lucide-react'\nimport ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { useInsight } from '@/lib/hooks/use-insights'\nimport { useModalManager } from '@/lib/hooks/use-modal-manager'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface SourceInsightDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  insight?: {\n    id: string\n    insight_type?: string\n    content?: string\n    created?: string\n    source_id?: string\n  }\n  onDelete?: (insightId: string) => Promise<void>\n}\n\nexport function SourceInsightDialog({ open, onOpenChange, insight, onDelete }: SourceInsightDialogProps) {\n  const { t } = useTranslation()\n  const { openModal } = useModalManager()\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n  const [isDeleting, setIsDeleting] = useState(false)\n\n  // Ensure insight ID has 'source_insight:' prefix for API calls\n  const insightIdWithPrefix = insight?.id\n    ? (insight.id.includes(':') ? insight.id : `source_insight:${insight.id}`)\n    : ''\n\n  const { data: fetchedInsight, isLoading } = useInsight(insightIdWithPrefix, { enabled: open && !!insight?.id })\n\n  // Use fetched data if available, otherwise fall back to passed-in insight\n  const displayInsight = fetchedInsight ?? insight\n\n  // Get source_id from fetched data (preferred) or passed-in insight\n  const sourceId = fetchedInsight?.source_id ?? insight?.source_id\n\n  const handleViewSource = () => {\n    if (sourceId) {\n      openModal('source', sourceId)\n    }\n  }\n\n  const handleDelete = async () => {\n    if (!insight?.id || !onDelete) return\n    setIsDeleting(true)\n    try {\n      await onDelete(insight.id)\n      onOpenChange(false)\n    } finally {\n      setIsDeleting(false)\n      setShowDeleteConfirm(false)\n    }\n  }\n\n  // Reset delete confirmation when dialog closes\n  useEffect(() => {\n    if (!open) {\n      setShowDeleteConfirm(false)\n    }\n  }, [open])\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-3xl max-h-[90vh] flex flex-col\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center justify-between gap-2\">\n            <span>{t.sources.sourceInsight}</span>\n            <div className=\"flex items-center gap-2\">\n              {displayInsight?.insight_type && (\n                <Badge variant=\"outline\" className=\"text-xs uppercase\">\n                  {displayInsight.insight_type}\n                </Badge>\n              )}\n              {sourceId && (\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={handleViewSource}\n                  className=\"gap-1\"\n                >\n                  <FileText className=\"h-3 w-3\" />\n                  {t.sources.viewSource}\n                </Button>\n              )}\n            </div>\n          </DialogTitle>\n        </DialogHeader>\n\n        {showDeleteConfirm ? (\n          <div className=\"flex flex-col items-center justify-center py-8 gap-4\">\n            <p className=\"text-center text-muted-foreground\">\n              {t.sources.deleteInsightConfirm.split(/[?？]/)[0]}?<br />\n              <span className=\"text-sm\">{t.sources.deleteInsightConfirm.split(/[?？]/)[1]?.trim() || t.common.deleteForever}</span>\n            </p>\n            <div className=\"flex gap-2\">\n              <Button\n                variant=\"outline\"\n                onClick={() => setShowDeleteConfirm(false)}\n                disabled={isDeleting}\n              >\n                {t.common.cancel}\n              </Button>\n              <Button\n                variant=\"destructive\"\n                onClick={handleDelete}\n                disabled={isDeleting}\n              >\n                {isDeleting ? t.common.deleting : t.common.delete}\n              </Button>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex-1 overflow-y-auto min-h-0\">\n            {isLoading ? (\n              <div className=\"flex items-center justify-center py-10\">\n                <span className=\"text-sm text-muted-foreground\">{t.common.loading}</span>\n              </div>\n            ) : displayInsight ? (\n              <div className=\"prose prose-sm prose-neutral dark:prose-invert max-w-none\">\n                <ReactMarkdown\n                  remarkPlugins={[remarkGfm]}\n                  components={{\n                    table: ({ children }) => (\n                      <div className=\"my-4 overflow-x-auto\">\n                        <table className=\"min-w-full border-collapse border border-border\">{children}</table>\n                      </div>\n                    ),\n                    thead: ({ children }) => <thead className=\"bg-muted\">{children}</thead>,\n                    tbody: ({ children }) => <tbody>{children}</tbody>,\n                    tr: ({ children }) => <tr className=\"border-b border-border\">{children}</tr>,\n                    th: ({ children }) => <th className=\"border border-border px-3 py-2 text-left font-semibold\">{children}</th>,\n                    td: ({ children }) => <td className=\"border border-border px-3 py-2\">{children}</td>,\n                  }}\n                >\n                  {displayInsight.content}\n                </ReactMarkdown>\n              </div>\n            ) : (\n              <p className=\"text-sm text-muted-foreground\">{t.sources.noInsightSelected}</p>\n            )}\n          </div>\n        )}\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/sources/AddExistingSourceDialog.tsx",
    "content": "'use client'\n\nimport { useState, useEffect, useMemo, useCallback } from 'react'\nimport { useDebounce } from 'use-debounce'\nimport { Search, Link2, LoaderIcon, FileText, Link as LinkIcon, Upload } from 'lucide-react'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogFooter,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { Input } from '@/components/ui/input'\nimport { Checkbox } from '@/components/ui/checkbox'\nimport { Badge } from '@/components/ui/badge'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { searchApi } from '@/lib/api/search'\nimport { sourcesApi } from '@/lib/api/sources'\nimport { useSources, useAddSourcesToNotebook } from '@/lib/hooks/use-sources'\nimport { SourceListResponse } from '@/lib/types/api'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ninterface AddExistingSourceDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  notebookId: string\n  onSuccess?: () => void\n}\n\nexport function AddExistingSourceDialog({\n  open,\n  onOpenChange,\n  notebookId,\n  onSuccess,\n}: AddExistingSourceDialogProps) {\n  const { t } = useTranslation()\n  const [searchQuery, setSearchQuery] = useState('')\n  const [debouncedSearchQuery] = useDebounce(searchQuery, 300)\n  const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([])\n  const [allSources, setAllSources] = useState<SourceListResponse[]>([])\n  const [filteredSources, setFilteredSources] = useState<SourceListResponse[]>([])\n  const [isSearching, setIsSearching] = useState(false)\n\n  // Get sources already in this notebook\n  const { data: currentNotebookSources } = useSources(notebookId)\n  const currentSourceIds = useMemo(\n    () => new Set(currentNotebookSources?.map(s => s.id) || []),\n    [currentNotebookSources]\n  )\n\n  const addSources = useAddSourcesToNotebook()\n\n  const loadAllSources = useCallback(async () => {\n    try {\n      setIsSearching(true)\n      // Use sources API directly to get all sources (max 100 per API limit)\n      const sources = await sourcesApi.list({\n        limit: 100,\n        offset: 0,\n        sort_by: 'created',\n        sort_order: 'desc',\n      })\n\n      setAllSources(sources)\n      setFilteredSources(sources)\n    } catch (error) {\n      console.error('Error loading sources:', error)\n    } finally {\n      setIsSearching(false)\n    }\n  }, [])\n\n  const performSearch = useCallback(async () => {\n    if (!debouncedSearchQuery.trim()) {\n      // Empty query - show all sources\n      setFilteredSources(allSources)\n      setIsSearching(false)\n      return\n    }\n\n    try {\n      setIsSearching(true)\n      const response = await searchApi.search({\n        query: debouncedSearchQuery,\n        type: 'text',\n        search_sources: true,\n        search_notes: false,\n        limit: 100,\n        minimum_score: 0.01,\n      })\n\n      // Since we set search_sources=true and search_notes=false,\n      // the API only returns sources, no need to filter\n      const sources = response.results.map(r => ({\n        id: r.parent_id,\n        title: r.title || 'Untitled',\n        topics: [],\n        asset: null,\n        embedded: false,\n        embedded_chunks: 0,\n        insights_count: 0,\n        created: r.created,\n        updated: r.updated,\n      })) as SourceListResponse[]\n\n      setFilteredSources(sources)\n    } catch (error) {\n      console.error('Error searching sources:', error)\n      // On error, fall back to showing all sources\n      setFilteredSources(allSources)\n    } finally {\n      setIsSearching(false)\n    }\n  }, [debouncedSearchQuery, allSources])\n\n  // Load all sources initially\n  useEffect(() => {\n    if (open) {\n      loadAllSources()\n    }\n  }, [open, loadAllSources])\n\n  // Filter sources when search query changes\n  useEffect(() => {\n    if (!debouncedSearchQuery) {\n      setFilteredSources(allSources)\n      setIsSearching(false)\n      return\n    }\n\n    performSearch()\n  }, [debouncedSearchQuery, allSources, performSearch])\n\n  const handleToggleSource = (sourceId: string) => {\n    setSelectedSourceIds(prev =>\n      prev.includes(sourceId)\n        ? prev.filter(id => id !== sourceId)\n        : [...prev, sourceId]\n    )\n  }\n\n  const handleAddSelected = async () => {\n    if (selectedSourceIds.length === 0) return\n\n    try {\n      await addSources.mutateAsync({\n        notebookId,\n        sourceIds: selectedSourceIds,\n      })\n\n      // Reset state\n      setSelectedSourceIds([])\n      setSearchQuery('')\n      onOpenChange(false)\n      onSuccess?.()\n    } catch (error) {\n      // Error handled by the hook's onError\n      console.error('Error adding sources:', error)\n    }\n  }\n\n  const getSourceIcon = (source: SourceListResponse) => {\n    // Derive type from asset\n    if (source.asset?.url) {\n      return <LinkIcon className=\"h-4 w-4\" />\n    }\n    if (source.asset?.file_path) {\n      return <Upload className=\"h-4 w-4\" />\n    }\n    return <FileText className=\"h-4 w-4\" />\n  }\n\n  const formatDate = (dateString: string) => {\n    try {\n      return new Date(dateString).toLocaleDateString()\n    } catch {\n      return ''\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"max-w-2xl sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <Link2 className=\"h-5 w-5\" />\n            {t.sources.addExistingTitle}\n          </DialogTitle>\n          <DialogDescription>\n            {t.sources.addExistingDesc}\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4 flex-1 overflow-hidden flex flex-col\">\n          {/* Search Input */}\n          <div className=\"relative\">\n            <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground\" />\n            <Input\n              placeholder={t.sources.searchPlaceholder}\n              value={searchQuery}\n              onChange={(e) => setSearchQuery(e.target.value)}\n              className=\"pl-10\"\n            />\n            {isSearching && (\n              <LoaderIcon className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\n            )}\n          </div>\n\n          {/* Source List */}\n          <ScrollArea className=\"h-[400px] border rounded-md\">\n            {isSearching && filteredSources.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center h-[200px] text-muted-foreground\">\n                <LoaderIcon className=\"h-12 w-12 mb-2 animate-spin\" />\n                <p>{t.common.loading}</p>\n              </div>\n            ) : filteredSources.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center h-[200px] text-muted-foreground\">\n                <FileText className=\"h-12 w-12 mb-2 opacity-50\" />\n                <p>{t.sources.noNotebooksFound}</p>\n              </div>\n            ) : (\n              <div className=\"space-y-2 p-4\">\n                {filteredSources.map((source) => {\n                  const isAlreadyLinked = currentSourceIds.has(source.id)\n                  const isSelected = selectedSourceIds.includes(source.id)\n\n                  return (\n                    <div\n                      key={source.id}\n                      className={`flex items-start gap-3 p-3 rounded-lg border transition-colors min-w-0 ${\n                        isSelected ? 'bg-accent border-accent-foreground/20' : 'hover:bg-accent/50'\n                      }`}\n                    >\n                      <Checkbox\n                        checked={isSelected}\n                        onCheckedChange={() => handleToggleSource(source.id)}\n                        disabled={isAlreadyLinked}\n                        className=\"mt-1\"\n                      />\n                      <div className=\"flex-1 min-w-0\">\n                        <div className=\"flex items-start gap-2 mb-1\">\n                          <div className=\"shrink-0 mt-0.5\">\n                            {getSourceIcon(source)}\n                          </div>\n                          <h4 className=\"font-medium text-sm break-words line-clamp-2 flex-1 min-w-0\">\n                            {source.title}\n                          </h4>\n                          {isAlreadyLinked && (\n                            <Badge variant=\"secondary\" className=\"text-xs shrink-0\">\n                              {t.common.linked}\n                            </Badge>\n                          )}\n                        </div>\n                        <p className=\"text-xs text-muted-foreground truncate\">\n                          {t.sources.added.replace('{date}', formatDate(source.created))}\n                        </p>\n                      </div>\n                    </div>\n                  )\n                })}\n              </div>\n            )}\n          </ScrollArea>\n\n          {/* Truncation Warning */}\n          {allSources.length >= 100 && !debouncedSearchQuery && (\n            <div className=\"text-xs text-muted-foreground bg-muted/50 p-2 rounded-md\">\n              {t.sources.showingFirst100}\n            </div>\n          )}\n\n          {/* Selection Summary */}\n          {selectedSourceIds.length > 0 && (\n            <div className=\"text-sm text-muted-foreground\">\n              {t.sources.selectedCount.replace('{count}', selectedSourceIds.length.toString())}\n            </div>\n          )}\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"outline\"\n            onClick={() => onOpenChange(false)}\n            disabled={addSources.isPending}\n          >\n            {t.common.cancel}\n          </Button>\n          <Button\n            onClick={handleAddSelected}\n            disabled={selectedSourceIds.length === 0 || addSources.isPending}\n          >\n            {addSources.isPending ? (\n              <>\n                <LoaderIcon className=\"mr-2 h-4 w-4 animate-spin\" />\n                {t.common.adding}\n              </>\n            ) : (\n              <>{t.common.addSelected}</>\n            )}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/sources/AddSourceButton.tsx",
    "content": "'use client'\n\nimport { useState } from 'react'\nimport { PlusIcon } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { AddSourceDialog } from './AddSourceDialog'\n\ninterface AddSourceButtonProps {\n  defaultNotebookId?: string\n  variant?: 'default' | 'outline' | 'ghost'\n  size?: 'sm' | 'default' | 'lg'\n  className?: string\n  iconOnly?: boolean\n}\n\nexport function AddSourceButton({ \n  defaultNotebookId, \n  variant = 'default',\n  size = 'default',\n  className,\n  iconOnly = false\n}: AddSourceButtonProps) {\n  const [dialogOpen, setDialogOpen] = useState(false)\n\n  return (\n    <>\n      <Button\n        onClick={() => setDialogOpen(true)}\n        variant={variant}\n        size={size}\n        className={className}\n      >\n        <PlusIcon className={iconOnly ? \"h-4 w-4\" : \"h-4 w-4 mr-2\"} />\n        {!iconOnly && \"Add Source\"}\n      </Button>\n\n      <AddSourceDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n        defaultNotebookId={defaultNotebookId}\n      />\n    </>\n  )\n}"
  },
  {
    "path": "frontend/src/components/sources/AddSourceDialog.tsx",
    "content": "'use client'\n\nimport { useState, useRef, useEffect, useMemo } from 'react'\nimport { useForm } from 'react-hook-form'\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { z } from 'zod'\nimport { LoaderIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react'\nimport { toast } from 'sonner'\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from '@/components/ui/dialog'\nimport { Button } from '@/components/ui/button'\nimport { WizardContainer, WizardStep } from '@/components/ui/wizard-container'\nimport { SourceTypeStep, parseAndValidateUrls } from './steps/SourceTypeStep'\nimport { NotebooksStep } from './steps/NotebooksStep'\nimport { ProcessingStep } from './steps/ProcessingStep'\nimport { useNotebooks } from '@/lib/hooks/use-notebooks'\nimport { useTransformations } from '@/lib/hooks/use-transformations'\nimport { useCreateSource } from '@/lib/hooks/use-sources'\nimport { useSettings } from '@/lib/hooks/use-settings'\nimport { CreateSourceRequest } from '@/lib/types/api'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nconst MAX_BATCH_SIZE = 50\n\nconst createSourceSchema = z.object({\n  type: z.enum(['link', 'upload', 'text']),\n  title: z.string().optional(),\n  url: z.string().optional(),\n  content: z.string().optional(),\n  file: z.any().optional(),\n  notebooks: z.array(z.string()).optional(),\n  transformations: z.array(z.string()).optional(),\n  embed: z.boolean(),\n  async_processing: z.boolean(),\n}).refine((data) => {\n  if (data.type === 'link') {\n    return !!data.url && data.url.trim() !== ''\n  }\n  if (data.type === 'text') {\n    return !!data.content && data.content.trim() !== ''\n  }\n  if (data.type === 'upload') {\n    if (data.file instanceof FileList) {\n      return data.file.length > 0\n    }\n    return !!data.file\n  }\n  return true\n}, {\n  message: 'Please provide the required content for the selected source type',\n  path: ['type'],\n}).refine((data) => {\n  // Make title mandatory for text sources\n  if (data.type === 'text') {\n    return !!data.title && data.title.trim() !== ''\n  }\n  return true\n}, {\n  message: 'Title is required for text sources',\n  path: ['title'],\n})\n\ntype CreateSourceFormData = z.infer<typeof createSourceSchema>\n\ninterface AddSourceDialogProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  defaultNotebookId?: string\n}\n\ninterface ProcessingState {\n  message: string\n  progress?: number\n}\n\ninterface BatchProgress {\n  total: number\n  completed: number\n  failed: number\n  currentItem?: string\n}\n\nexport function AddSourceDialog({ \n  open, \n  onOpenChange, \n  defaultNotebookId \n}: AddSourceDialogProps) {\n  const { t } = useTranslation()\n\n  const WIZARD_STEPS: readonly WizardStep[] = [\n    { number: 1, title: t.sources.addSource, description: t.sources.processDescription },\n    { number: 2, title: t.navigation.notebooks, description: t.notebooks.searchPlaceholder },\n    { number: 3, title: t.navigation.process, description: t.sources.processDescription },\n  ]\n\n  // Simplified state management\n  const [currentStep, setCurrentStep] = useState(1)\n  const [processing, setProcessing] = useState(false)\n  const [processingStatus, setProcessingStatus] = useState<ProcessingState | null>(null)\n  const [selectedNotebooks, setSelectedNotebooks] = useState<string[]>(\n    defaultNotebookId ? [defaultNotebookId] : []\n  )\n  const [selectedTransformations, setSelectedTransformations] = useState<string[]>([])\n\n  // Batch-specific state\n  const [urlValidationErrors, setUrlValidationErrors] = useState<{ url: string; line: number }[]>([])\n  const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)\n\n  // Cleanup timeouts to prevent memory leaks\n  const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n  // API hooks\n  const createSource = useCreateSource()\n  const { data: notebooks = [], isLoading: notebooksLoading } = useNotebooks()\n  const { data: transformations = [], isLoading: transformationsLoading } = useTransformations()\n  const { data: settings } = useSettings()\n\n  // Form setup\n  const {\n    register,\n    handleSubmit,\n    control,\n    watch,\n    setValue,\n    formState: { errors },\n    reset,\n  } = useForm<CreateSourceFormData>({\n    resolver: zodResolver(createSourceSchema),\n    defaultValues: {\n      notebooks: defaultNotebookId ? [defaultNotebookId] : [],\n      embed: settings?.default_embedding_option === 'always' || settings?.default_embedding_option === 'ask',\n      async_processing: true,\n      transformations: [],\n    },\n  })\n\n  // Initialize form values when settings and transformations are loaded\n  useEffect(() => {\n    if (settings && transformations.length > 0) {\n      const defaultTransformations = transformations\n        .filter(t => t.apply_default)\n        .map(t => t.id)\n\n      setSelectedTransformations(defaultTransformations)\n\n      // Reset form with proper embed value based on settings\n      const embedValue = settings.default_embedding_option === 'always' ||\n                         (settings.default_embedding_option === 'ask')\n\n      reset({\n        notebooks: defaultNotebookId ? [defaultNotebookId] : [],\n        embed: embedValue,\n        async_processing: true,\n        transformations: [],\n      })\n    }\n  }, [settings, transformations, defaultNotebookId, reset])\n\n  // Cleanup effect\n  useEffect(() => {\n    return () => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current)\n      }\n    }\n  }, [])\n\n  const selectedType = watch('type')\n  const watchedUrl = watch('url')\n  const watchedContent = watch('content')\n  const watchedFile = watch('file')\n  const watchedTitle = watch('title')\n\n  // Batch mode detection\n  const { isBatchMode, itemCount, parsedUrls, parsedFiles } = useMemo(() => {\n    let urlCount = 0\n    let fileCount = 0\n    let parsedUrls: string[] = []\n    let parsedFiles: File[] = []\n\n    if (selectedType === 'link' && watchedUrl) {\n      const { valid } = parseAndValidateUrls(watchedUrl)\n      parsedUrls = valid\n      urlCount = valid.length\n    }\n\n    if (selectedType === 'upload' && watchedFile) {\n      const fileList = watchedFile as FileList\n      if (fileList?.length) {\n        parsedFiles = Array.from(fileList)\n        fileCount = parsedFiles.length\n      }\n    }\n\n    const isBatchMode = urlCount > 1 || fileCount > 1\n    const itemCount = selectedType === 'link' ? urlCount : fileCount\n\n    return { isBatchMode, itemCount, parsedUrls, parsedFiles }\n  }, [selectedType, watchedUrl, watchedFile])\n\n  // Check for batch size limit\n  const isOverLimit = itemCount > MAX_BATCH_SIZE\n\n  // Step validation - now reactive with watched values\n  const isStepValid = (step: number): boolean => {\n    switch (step) {\n      case 1:\n        if (!selectedType) return false\n        // Check batch size limit\n        if (isOverLimit) return false\n        // Check for URL validation errors\n        if (urlValidationErrors.length > 0) return false\n\n        if (selectedType === 'link') {\n          // In batch mode, check that we have at least one valid URL\n          if (isBatchMode) {\n            return parsedUrls.length > 0\n          }\n          return !!watchedUrl && watchedUrl.trim() !== ''\n        }\n        if (selectedType === 'text') {\n          return !!watchedContent && watchedContent.trim() !== '' &&\n                 !!watchedTitle && watchedTitle.trim() !== ''\n        }\n        if (selectedType === 'upload') {\n          if (watchedFile instanceof FileList) {\n            return watchedFile.length > 0 && watchedFile.length <= MAX_BATCH_SIZE\n          }\n          return !!watchedFile\n        }\n        return true\n      case 2:\n      case 3:\n        return true\n      default:\n        return false\n    }\n  }\n\n  // Navigation\n  const handleNextStep = (e?: React.MouseEvent) => {\n    e?.preventDefault()\n    e?.stopPropagation()\n\n    // Validate URLs when leaving step 1 in link mode\n    if (currentStep === 1 && selectedType === 'link' && watchedUrl) {\n      const { invalid } = parseAndValidateUrls(watchedUrl)\n      if (invalid.length > 0) {\n        setUrlValidationErrors(invalid)\n        return\n      }\n      setUrlValidationErrors([])\n    }\n\n    if (currentStep < 3 && isStepValid(currentStep)) {\n      setCurrentStep(currentStep + 1)\n    }\n  }\n\n  // Clear URL validation errors when user edits\n  const handleClearUrlErrors = () => {\n    setUrlValidationErrors([])\n  }\n\n  const handlePrevStep = (e?: React.MouseEvent) => {\n    e?.preventDefault()\n    e?.stopPropagation()\n    if (currentStep > 1) {\n      setCurrentStep(currentStep - 1)\n    }\n  }\n\n  const handleStepClick = (step: number) => {\n    if (step <= currentStep || (step === currentStep + 1 && isStepValid(currentStep))) {\n      setCurrentStep(step)\n    }\n  }\n\n  // Selection handlers\n  const handleNotebookToggle = (notebookId: string) => {\n    const updated = selectedNotebooks.includes(notebookId)\n      ? selectedNotebooks.filter(id => id !== notebookId)\n      : [...selectedNotebooks, notebookId]\n    setSelectedNotebooks(updated)\n  }\n\n  const handleTransformationToggle = (transformationId: string) => {\n    const updated = selectedTransformations.includes(transformationId)\n      ? selectedTransformations.filter(id => id !== transformationId)\n      : [...selectedTransformations, transformationId]\n    setSelectedTransformations(updated)\n  }\n\n  // Single source submission\n  const submitSingleSource = async (data: CreateSourceFormData): Promise<void> => {\n    const createRequest: CreateSourceRequest = {\n      type: data.type,\n      notebooks: selectedNotebooks,\n      url: data.type === 'link' ? data.url : undefined,\n      content: data.type === 'text' ? data.content : undefined,\n      title: data.title,\n      transformations: selectedTransformations,\n      embed: data.embed,\n      delete_source: false,\n      async_processing: true,\n    }\n\n    if (data.type === 'upload' && data.file) {\n      const file = data.file instanceof FileList ? data.file[0] : data.file\n      const requestWithFile = createRequest as CreateSourceRequest & { file?: File }\n      requestWithFile.file = file\n    }\n\n    await createSource.mutateAsync(createRequest)\n  }\n\n  // Batch submission\n  const submitBatch = async (data: CreateSourceFormData): Promise<{ success: number; failed: number }> => {\n    const results = { success: 0, failed: 0 }\n    const items: { type: 'url' | 'file'; value: string | File }[] = []\n\n    // Collect items to process\n    if (data.type === 'link' && parsedUrls.length > 0) {\n      parsedUrls.forEach(url => items.push({ type: 'url', value: url }))\n    } else if (data.type === 'upload' && parsedFiles.length > 0) {\n      parsedFiles.forEach(file => items.push({ type: 'file', value: file }))\n    }\n\n    setBatchProgress({\n      total: items.length,\n      completed: 0,\n      failed: 0,\n    })\n\n    // Process each item sequentially\n    for (let i = 0; i < items.length; i++) {\n      const item = items[i]\n      const itemLabel = item.type === 'url'\n        ? (item.value as string).substring(0, 50) + '...'\n        : (item.value as File).name\n\n      setBatchProgress(prev => prev ? {\n        ...prev,\n        currentItem: itemLabel,\n      } : null)\n\n      try {\n        const createRequest: CreateSourceRequest = {\n          type: item.type === 'url' ? 'link' : 'upload',\n          notebooks: selectedNotebooks,\n          url: item.type === 'url' ? item.value as string : undefined,\n          transformations: selectedTransformations,\n          embed: data.embed,\n          delete_source: false,\n          async_processing: true,\n        }\n\n        if (item.type === 'file') {\n          const requestWithFile = createRequest as CreateSourceRequest & { file?: File }\n          requestWithFile.file = item.value as File\n        }\n\n        await createSource.mutateAsync(createRequest)\n        results.success++\n      } catch (error) {\n        console.error(`Error creating source for ${itemLabel}:`, error)\n        results.failed++\n      }\n\n      setBatchProgress(prev => prev ? {\n        ...prev,\n        completed: results.success,\n        failed: results.failed,\n      } : null)\n    }\n\n    return results\n  }\n\n  // Form submission\n  const onSubmit = async (data: CreateSourceFormData) => {\n    try {\n      setProcessing(true)\n\n      if (isBatchMode) {\n        // Batch submission\n        setProcessingStatus({ message: t.sources.processingFiles })\n        const results = await submitBatch(data)\n\n        // Show summary toast\n        if (results.failed === 0) {\n          toast.success(t.sources.batchSuccess.replace('{count}', results.success.toString()))\n        } else if (results.success === 0) {\n          toast.error(t.sources.batchFailed.replace('{count}', results.failed.toString()))\n        } else {\n          toast.warning(t.sources.batchPartial.replace('{success}', results.success.toString()).replace('{failed}', results.failed.toString()))\n        }\n\n        handleClose()\n      } else {\n        // Single source submission\n        setProcessingStatus({ message: t.sources.submittingSource })\n        await submitSingleSource(data)\n        handleClose()\n      }\n    } catch (error) {\n      console.error('Error creating source:', error)\n      setProcessingStatus({\n        message: t.common.error,\n      })\n      timeoutRef.current = setTimeout(() => {\n        setProcessing(false)\n        setProcessingStatus(null)\n        setBatchProgress(null)\n      }, 3000)\n    }\n  }\n\n  // Dialog management\n  const handleClose = () => {\n    // Clear any pending timeouts\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current)\n      timeoutRef.current = null\n    }\n\n    reset()\n    setCurrentStep(1)\n    setProcessing(false)\n    setProcessingStatus(null)\n    setSelectedNotebooks(defaultNotebookId ? [defaultNotebookId] : [])\n    setUrlValidationErrors([])\n    setBatchProgress(null)\n\n    // Reset to default transformations\n    if (transformations.length > 0) {\n      const defaultTransformations = transformations\n        .filter(t => t.apply_default)\n        .map(t => t.id)\n      setSelectedTransformations(defaultTransformations)\n    } else {\n      setSelectedTransformations([])\n    }\n\n    onOpenChange(false)\n  }\n\n  // Processing view\n  if (processing) {\n    const progressPercent = batchProgress\n      ? Math.round(((batchProgress.completed + batchProgress.failed) / batchProgress.total) * 100)\n      : undefined\n\n    return (\n      <Dialog open={open} onOpenChange={handleClose}>\n        <DialogContent className=\"sm:max-w-[500px]\" showCloseButton={true}>\n          <DialogHeader>\n            <DialogTitle>\n              {batchProgress ? t.sources.processingFiles : t.sources.statusProcessing}\n            </DialogTitle>\n            <DialogDescription>\n              {batchProgress\n                ? t.sources.processingBatchSources.replace('{count}', batchProgress.total.toString())\n                : t.sources.processingSource\n              }\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4 py-4\">\n            <div className=\"flex items-center gap-3\">\n              <LoaderIcon className=\"h-5 w-5 animate-spin text-primary\" />\n              <span className=\"text-sm text-muted-foreground\">\n                {processingStatus?.message || t.common.processing}\n              </span>\n            </div>\n\n            {/* Batch progress */}\n            {batchProgress && (\n              <>\n                <div className=\"w-full bg-muted rounded-full h-2\">\n                  <div\n                    className=\"bg-primary h-2 rounded-full transition-all duration-300\"\n                    style={{ width: `${progressPercent}%` }}\n                  />\n                </div>\n\n                <div className=\"flex items-center justify-between text-sm\">\n                  <div className=\"flex items-center gap-4\">\n                    <span className=\"flex items-center gap-1.5 text-green-600\">\n                      <CheckCircleIcon className=\"h-4 w-4\" />\n                      {batchProgress.completed} {t.common.completed}\n                    </span>\n                    {batchProgress.failed > 0 && (\n                      <span className=\"flex items-center gap-1.5 text-destructive\">\n                        <XCircleIcon className=\"h-4 w-4\" />\n                        {batchProgress.failed} {t.common.failed}\n                      </span>\n                    )}\n                  </div>\n                   <span className=\"text-muted-foreground\">\n                    {batchProgress.completed + batchProgress.failed} / {batchProgress.total}\n                  </span>\n                </div>\n\n                {batchProgress.currentItem && (\n                  <p className=\"text-xs text-muted-foreground truncate\">\n                    {t.common.current}: {batchProgress.currentItem}\n                  </p>\n                )}\n              </>\n            )}\n\n            {/* Single source progress */}\n            {!batchProgress && processingStatus?.progress && (\n              <div className=\"w-full bg-muted rounded-full h-2\">\n                <div\n                  className=\"bg-primary h-2 rounded-full transition-all duration-300\"\n                  style={{ width: `${processingStatus.progress}%` }}\n                />\n              </div>\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n    )\n  }\n\n  const currentStepValid = isStepValid(currentStep)\n\n  return (\n    <Dialog open={open} onOpenChange={handleClose}>\n      <DialogContent className=\"sm:max-w-[700px] p-0\">\n        <DialogHeader className=\"px-6 pt-6 pb-0\">\n          <DialogTitle>{t.sources.addNew}</DialogTitle>\n          <DialogDescription>\n            {t.sources.processDescription}\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleSubmit(onSubmit)} className=\"min-w-0\">\n          <WizardContainer\n            currentStep={currentStep}\n            steps={WIZARD_STEPS}\n            onStepClick={handleStepClick}\n            className=\"border-0\"\n          >\n            {currentStep === 1 && (\n              <SourceTypeStep\n                // @ts-expect-error - Type inference issue with zod schema\n                control={control}\n                register={register}\n                setValue={setValue}\n                // @ts-expect-error - Type inference issue with zod schema\n                errors={errors}\n                urlValidationErrors={urlValidationErrors}\n                onClearUrlErrors={handleClearUrlErrors}\n              />\n            )}\n            \n            {currentStep === 2 && (\n              <NotebooksStep\n                notebooks={notebooks}\n                selectedNotebooks={selectedNotebooks}\n                onToggleNotebook={handleNotebookToggle}\n                loading={notebooksLoading}\n              />\n            )}\n            \n            {currentStep === 3 && (\n              <ProcessingStep\n                // @ts-expect-error - Type inference issue with zod schema\n                control={control}\n                transformations={transformations}\n                selectedTransformations={selectedTransformations}\n                onToggleTransformation={handleTransformationToggle}\n                loading={transformationsLoading}\n                settings={settings}\n              />\n            )}\n          </WizardContainer>\n\n          {/* Navigation */}\n          <div className=\"flex justify-between items-center px-6 py-4 border-t border-border bg-muted\">\n            <Button \n              type=\"button\" \n              variant=\"outline\" \n              onClick={handleClose}\n            >\n              {t.common.cancel}\n            </Button>\n\n            <div className=\"flex gap-2\">\n              {currentStep > 1 && (\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={handlePrevStep}\n                >\n                  {t.common.back}\n                </Button>\n              )}\n\n              {/* Show Next button on steps 1 and 2, styled as outline/secondary */}\n              {currentStep < 3 && (\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  onClick={(e) => handleNextStep(e)}\n                  disabled={!currentStepValid}\n                >\n                  {t.common.next}\n                </Button>\n              )}\n\n              {/* Show Done button on all steps, styled as primary */}\n              <Button\n                type=\"submit\"\n                disabled={!currentStepValid || createSource.isPending}\n                className=\"min-w-[120px]\"\n              >\n                {createSource.isPending ? t.common.adding : t.common.done}\n              </Button>\n            </div>\n          </div>\n        </form>\n      </DialogContent>\n    </Dialog>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/sources/README.md",
    "content": "# AddSourceDialog Component\n\nThe `AddSourceDialog` component provides a comprehensive interface for adding new sources to notebooks with async processing support.\n\n## Features\n\n- **Multi-type source support**: Links, file uploads, and text content\n- **Multi-notebook selection**: Add sources to multiple notebooks simultaneously  \n- **Transformations**: Apply transformations during source processing\n- **Async processing**: Background processing with status monitoring\n- **Form validation**: Comprehensive validation with Zod and React Hook Form\n- **File upload support**: Handle file uploads with progress indicators\n- **Responsive design**: Works well on desktop and mobile\n\n## Usage\n\n### Basic Usage\n\n```tsx\nimport { AddSourceDialog } from '@/components/sources'\n\nfunction MyComponent() {\n  const [dialogOpen, setDialogOpen] = useState(false)\n\n  return (\n    <>\n      <button onClick={() => setDialogOpen(true)}>\n        Add Source\n      </button>\n      \n      <AddSourceDialog\n        open={dialogOpen}\n        onOpenChange={setDialogOpen}\n      />\n    </>\n  )\n}\n```\n\n### With Default Notebook\n\n```tsx\n<AddSourceDialog\n  open={dialogOpen}\n  onOpenChange={setDialogOpen}\n  defaultNotebookId=\"notebook:123\"\n/>\n```\n\n### Using the Button Component\n\n```tsx\nimport { AddSourceButton } from '@/components/sources'\n\nfunction MyComponent() {\n  return (\n    <AddSourceButton \n      defaultNotebookId=\"notebook:123\"\n      variant=\"outline\"\n      size=\"sm\"\n    />\n  )\n}\n```\n\n## Props\n\n### AddSourceDialog\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `open` | `boolean` | - | Controls dialog visibility |\n| `onOpenChange` | `(open: boolean) => void` | - | Called when dialog should open/close |\n| `defaultNotebookId` | `string` | - | Pre-select a notebook |\n\n### AddSourceButton\n\n| Prop | Type | Default | Description |\n|------|------|---------|-------------|\n| `defaultNotebookId` | `string` | - | Pre-select a notebook in dialog |\n| `variant` | `'default' \\| 'outline' \\| 'ghost'` | `'default'` | Button styling variant |\n| `size` | `'sm' \\| 'default' \\| 'lg'` | `'default'` | Button size |\n| `className` | `string` | - | Additional CSS classes |\n\n## Source Types\n\n### Link Sources\n- Requires a valid URL\n- Automatically extracts content from web pages\n- Supports most web content formats\n\n### File Upload Sources  \n- Supports: PDF, DOC, DOCX, TXT, MD, EPUB\n- Handles large files with async processing\n- Shows upload progress\n\n### Text Sources\n- Direct text input\n- Useful for pasting content\n- Supports markdown formatting\n\n## Processing Options\n\n### Embedding\n- **Enabled by default**: Makes sources searchable via vector search\n- **Disable for**: Sources you don't want in search results\n\n### Async Processing (Recommended)\n- **Default**: Background processing for better UX\n- **Benefits**: Non-blocking, handles large files, progress monitoring\n- **Disable for**: Small sources that need immediate processing\n\n## Integration with Hooks\n\nThe component integrates with several custom hooks:\n\n- `useNotebooks()` - Fetches available notebooks\n- `useTransformations()` - Fetches available transformations  \n- `useCreateSource()` - Handles source creation\n- `useSourceStatus()` - Monitors processing status\n\n## Error Handling\n\nThe component includes comprehensive error handling:\n\n- Form validation errors are shown inline\n- Network errors show toast notifications\n- File upload errors are handled gracefully\n- Processing errors are displayed with retry options\n\n## Accessibility\n\n- Full keyboard navigation support\n- Screen reader friendly\n- ARIA labels and descriptions\n- Focus management\n\n## Dependencies\n\n- React Hook Form for form handling\n- Zod for validation\n- TanStack Query for data fetching\n- shadcn/ui for components\n- Lucide React for icons"
  },
  {
    "path": "frontend/src/components/sources/SourceCard.tsx",
    "content": "'use client'\n\nimport React, { useState, useEffect } from 'react'\nimport { SourceListResponse } from '@/lib/types/api'\nimport { Badge } from '@/components/ui/badge'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Button } from '@/components/ui/button'\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n  DropdownMenuSeparator\n} from '@/components/ui/dropdown-menu'\nimport {\n  FileText,\n  ExternalLink,\n  Upload,\n  MoreVertical,\n  Trash2,\n  RefreshCw,\n  Clock,\n  CheckCircle,\n  AlertTriangle,\n  Loader2,\n  Unlink\n} from 'lucide-react'\nimport { useSourceStatus } from '@/lib/hooks/use-sources'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { TranslationKeys } from '@/lib/locales'\nimport { cn } from '@/lib/utils'\nimport { ContextToggle } from '@/components/common/ContextToggle'\nimport { ContextMode } from '@/app/(dashboard)/notebooks/[id]/page'\n\ninterface SourceCardProps {\n  source: SourceListResponse\n  onDelete?: (sourceId: string) => void\n  onRetry?: (sourceId: string) => void\n  onRemoveFromNotebook?: (sourceId: string) => void\n  onClick?: (sourceId: string) => void\n  onRefresh?: () => void\n  className?: string\n  showRemoveFromNotebook?: boolean\n  contextMode?: ContextMode\n  onContextModeChange?: (mode: ContextMode) => void\n}\n\nconst SOURCE_TYPE_ICONS = {\n  link: ExternalLink,\n  upload: Upload,\n  text: FileText,\n} as const\n\nconst getStatusConfig = (t: TranslationKeys) => ({\n  new: {\n    icon: Clock,\n    color: 'text-blue-600',\n    bgColor: 'bg-blue-50',\n    borderColor: 'border-blue-200',\n    label: t.sources.statusProcessing,\n    description: t.sources.statusPreparingDesc\n  },\n  queued: {\n    icon: Clock,\n    color: 'text-blue-600',\n    bgColor: 'bg-blue-50',\n    borderColor: 'border-blue-200',\n    label: t.sources.statusQueued,\n    description: t.sources.statusQueuedDesc\n  },\n  running: {\n    icon: Loader2,\n    color: 'text-blue-600',\n    bgColor: 'bg-blue-50',\n    borderColor: 'border-blue-200',\n    label: t.sources.statusProcessing,\n    description: t.sources.statusProcessingDesc\n  },\n  completed: {\n    icon: CheckCircle,\n    color: 'text-green-600',\n    bgColor: 'bg-green-50',\n    borderColor: 'border-green-200',\n    label: t.sources.statusCompleted,\n    description: t.sources.statusCompletedDesc\n  },\n  failed: {\n    icon: AlertTriangle,\n    color: 'text-red-600',\n    bgColor: 'bg-red-50',\n    borderColor: 'border-red-200',\n    label: t.sources.statusFailed,\n    description: t.sources.statusFailedDesc\n  }\n} as const)\n\ntype SourceStatus = 'new' | 'queued' | 'running' | 'completed' | 'failed'\n\nfunction isSourceStatus(status: unknown): status is SourceStatus {\n  return typeof status === 'string' && ['new', 'queued', 'running', 'completed', 'failed'].includes(status)\n}\n\nfunction getSourceType(source: SourceListResponse): 'link' | 'upload' | 'text' {\n  // Determine type based on asset information\n  if (source.asset?.url) return 'link'\n  if (source.asset?.file_path) return 'upload'\n  return 'text'\n}\n\nexport function SourceCard({\n  source,\n  onClick,\n  onDelete,\n  onRetry,\n  onRemoveFromNotebook,\n  onRefresh,\n  className,\n  showRemoveFromNotebook = false,\n  contextMode,\n  onContextModeChange\n}: SourceCardProps) {\n  const { t } = useTranslation()\n  const statusConfigMap = getStatusConfig(t)\n  \n  // Only fetch status for sources that might have async processing\n  const sourceWithStatus = source as SourceListResponse & { command_id?: string; status?: string }\n\n  // Track processing state to continue polling until we detect completion\n  const [wasProcessing, setWasProcessing] = useState(false)\n\n  const shouldFetchStatus = !!sourceWithStatus.command_id ||\n    sourceWithStatus.status === 'new' ||\n    sourceWithStatus.status === 'queued' ||\n    sourceWithStatus.status === 'running' ||\n    wasProcessing // Keep polling if we were processing to catch the completion\n\n  const { data: statusData, isLoading: statusLoading } = useSourceStatus(\n    source.id,\n    shouldFetchStatus\n  )\n\n  // Determine current status\n  // If source has a command_id but no status, treat as \"new\" (just created)\n  const rawStatus = statusData?.status || sourceWithStatus.status\n  const currentStatus: SourceStatus = isSourceStatus(rawStatus)\n    ? rawStatus\n    : (sourceWithStatus.command_id ? 'new' : 'completed')\n\n\n  // Track processing state and detect completion\n  useEffect(() => {\n    const currentStatusFromData = statusData?.status || sourceWithStatus.status\n\n    // If we're currently processing, mark that we were processing\n    if (currentStatusFromData === 'new' || currentStatusFromData === 'running' || currentStatusFromData === 'queued') {\n      setWasProcessing(true)\n    }\n\n    // If we were processing and now completed/failed, trigger refresh and stop polling\n    if (wasProcessing &&\n        (currentStatusFromData === 'completed' || currentStatusFromData === 'failed')) {\n      setWasProcessing(false) // Stop polling\n\n      if (onRefresh) {\n        setTimeout(() => onRefresh(), 500) // Small delay to ensure API is updated\n      }\n    }\n  }, [statusData, sourceWithStatus.status, wasProcessing, onRefresh, source.id])\n  \n  const statusConfig = statusConfigMap[currentStatus] || statusConfigMap.completed\n  const StatusIcon = statusConfig.icon\n  const sourceType = getSourceType(source)\n  const SourceTypeIcon = SOURCE_TYPE_ICONS[sourceType]\n  \n   const title = source.title || t.sources.untitledSource\n\n  const handleRetry = () => {\n    if (onRetry) {\n      onRetry(source.id)\n    }\n  }\n\n  const handleDelete = () => {\n    if (onDelete) {\n      onDelete(source.id)\n    }\n  }\n\n  const handleRemoveFromNotebook = () => {\n    if (onRemoveFromNotebook) {\n      onRemoveFromNotebook(source.id)\n    }\n  }\n\n  const handleCardClick = () => {\n    if (onClick) {\n      onClick(source.id)\n    }\n  }\n\n  const isProcessing: boolean = currentStatus === 'new' || currentStatus === 'running' || currentStatus === 'queued'\n  const isFailed: boolean = currentStatus === 'failed'\n  const isCompleted: boolean = currentStatus === 'completed'\n\n  return (\n    <Card\n      className={cn(\n        'transition-all duration-200 hover:shadow-md group relative cursor-pointer border border-border/60 dark:border-border/40',\n        className\n      )}\n      onClick={handleCardClick}\n    >\n      <CardContent className=\"px-3 py-1\">\n        {/* Header with status indicator */}\n        <div className=\"flex items-start justify-between gap-3 mb-1\">\n          <div className=\"flex-1 min-w-0\">\n            {/* Status badge - only show if not completed */}\n            {!isCompleted && (\n              <div className=\"flex items-center gap-2 mb-2\">\n                <div className={cn(\n                  'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',\n                  statusConfig.bgColor,\n                  statusConfig.color\n                )}>\n                  <StatusIcon className={cn(\n                    'h-3 w-3',\n                    isProcessing && 'animate-spin'\n                  )} />\n                  {statusLoading && shouldFetchStatus ? t.sources.checking : statusConfig.label}\n                </div>\n\n                {/* Source type indicator */}\n                <div className=\"flex items-center gap-1 text-gray-500\">\n                  <SourceTypeIcon className=\"h-3 w-3\" />\n                  <span className=\"text-xs capitalize\">{t.common.source}</span>\n                </div>\n              </div>\n            )}\n\n            {/* Title */}\n            <div className={cn('mb-1.5', !isCompleted && 'mb-1')}>\n              <h4\n                className=\"text-sm font-medium leading-tight line-clamp-2 break-all\"\n                title={title}\n              >\n                {title}\n              </h4>\n            </div>\n\n            {/* Processing message for active statuses */}\n            {statusData?.message && (isProcessing || isFailed) && (\n              <p className=\"text-xs text-gray-600 mb-2 italic\">\n                {statusData.message}\n              </p>\n            )}\n\n            {/* Metadata badges */}\n            <div className=\"flex items-center gap-2 flex-wrap\">\n              {/* Source type badge */}\n              <Badge variant=\"secondary\" className=\"text-xs flex items-center gap-1\">\n                <SourceTypeIcon className=\"h-3 w-3\" />\n                {sourceType === 'link' ? t.sources.addUrl : sourceType === 'upload' ? t.sources.uploadFile : t.sources.enterText}\n              </Badge>\n\n              {isCompleted && source.insights_count > 0 && (\n                <Badge variant=\"outline\" className=\"text-xs\">\n                  {t.sources.insightsCount.replace('{count}', source.insights_count.toString())}\n                </Badge>\n              )}\n              {source.topics && source.topics.length > 0 && isCompleted && (\n                <>\n                  {source.topics.slice(0, 2).map((topic, index) => (\n                    <Badge key={index} variant=\"outline\" className=\"text-xs\">\n                      {topic}\n                    </Badge>\n                  ))}\n                  {source.topics.length > 2 && (\n                    <Badge variant=\"outline\" className=\"text-xs\">\n                      +{source.topics.length - 2}\n                    </Badge>\n                  )}\n                </>\n              )}\n            </div>\n          </div>\n\n          {/* Context toggle and actions */}\n          <div className=\"flex items-center gap-1\">\n            {/* Context toggle - only show if handler provided */}\n            {onContextModeChange && contextMode && (\n              <ContextToggle\n                mode={contextMode}\n                hasInsights={source.insights_count > 0}\n                onChange={onContextModeChange}\n              />\n            )}\n\n            {/* Actions dropdown */}\n            <DropdownMenu>\n              <DropdownMenuTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity\"\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  <MoreVertical className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" className=\"w-48\">\n              {showRemoveFromNotebook && (\n                <>\n                  <DropdownMenuItem\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      handleRemoveFromNotebook()\n                    }}\n                    disabled={!onRemoveFromNotebook}\n                  >\n                    <Unlink className=\"h-4 w-4 mr-2\" />\n                    {t.sources.removeFromNotebook}\n                  </DropdownMenuItem>\n                  <DropdownMenuSeparator />\n                </>\n              )}\n\n              {isFailed && (\n                <>\n                  <DropdownMenuItem\n                    onClick={(e) => {\n                      e.stopPropagation()\n                      handleRetry()\n                    }}\n                    disabled={!onRetry}\n                  >\n                    <RefreshCw className=\"h-4 w-4 mr-2\" />\n                    {t.sources.retryProcessing}\n                  </DropdownMenuItem>\n                  <DropdownMenuSeparator />\n                </>\n              )}\n\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.stopPropagation()\n                  handleDelete()\n                }}\n                disabled={!onDelete}\n                className=\"text-red-600 focus:text-red-600\"\n              >\n                <Trash2 className=\"h-4 w-4 mr-2\" />\n                {t.sources.deleteSource}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n          </div>\n        </div>\n        {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}\n        {(isFailed as any) && (\n          <div className=\"flex gap-2 pt-2 border-t\">\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={handleRetry}\n              disabled={!onRetry}\n              className=\"h-7 text-xs\"\n            >\n              <RefreshCw className=\"h-3 w-3 mr-1\" />\n              {t.sources.retry}\n            </Button>\n          </div>\n        )}\n\n        {/* Processing progress indicator */}\n        {isProcessing && statusData?.processing_info?.progress && (\n          <div className=\"mt-3 pt-2 border-t\">\n            <div className=\"flex justify-between items-center mb-1\">\n            <span className=\"text-xs text-gray-600\">{t.common.progress}</span>\n              <span className=\"text-xs text-gray-600\">\n                {Math.round(statusData.processing_info.progress as number)}%\n              </span>\n            </div>\n            <div className=\"w-full bg-gray-200 rounded-full h-1.5\">\n              <div\n                className=\"bg-blue-600 h-1.5 rounded-full transition-all duration-300\"\n                style={{ width: `${statusData.processing_info.progress as number}%` }}\n              />\n            </div>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/sources/index.ts",
    "content": "export { AddSourceDialog } from './AddSourceDialog'\nexport { AddSourceButton } from './AddSourceButton'\nexport { SourceCard } from './SourceCard'"
  },
  {
    "path": "frontend/src/components/sources/steps/NotebooksStep.tsx",
    "content": "\"use client\"\n\nimport { FormSection } from \"@/components/ui/form-section\"\nimport { useTranslation } from \"@/lib/hooks/use-translation\"\nimport { CheckboxList } from \"@/components/ui/checkbox-list\"\nimport { NotebookResponse } from \"@/lib/types/api\"\n\ninterface NotebooksStepProps {\n  notebooks: NotebookResponse[]\n  selectedNotebooks: string[]\n  onToggleNotebook: (notebookId: string) => void\n  loading?: boolean\n}\n\nexport function NotebooksStep({\n  notebooks,\n  selectedNotebooks,\n  onToggleNotebook,\n  loading = false\n}: NotebooksStepProps) {\n  const { t } = useTranslation()\n  const notebookItems = notebooks.map((notebook) => ({\n    id: notebook.id,\n    title: notebook.name,\n    description: notebook.description || undefined\n  }))\n\n  return (\n    <div className=\"space-y-6\">\n      <FormSection\n        title={`${t.notebooks.title} (${t.common.optional})`}\n        description={t.sources.addExistingDesc}\n      >\n        <CheckboxList\n          items={notebookItems}\n          selectedIds={selectedNotebooks}\n          onToggle={onToggleNotebook}\n          loading={loading}\n          emptyMessage={t.sources.noNotebooksFound}\n        />\n      </FormSection>\n    </div>\n  )\n}"
  },
  {
    "path": "frontend/src/components/sources/steps/ProcessingStep.tsx",
    "content": "\"use client\"\n\nimport { Control, Controller } from \"react-hook-form\"\nimport { useTranslation } from \"@/lib/hooks/use-translation\"\nimport { FormSection } from \"@/components/ui/form-section\"\nimport { CheckboxList } from \"@/components/ui/checkbox-list\"\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { Transformation } from \"@/lib/types/transformations\"\nimport { SettingsResponse } from \"@/lib/types/api\"\n\ninterface CreateSourceFormData {\n  type: 'link' | 'upload' | 'text'\n  title?: string\n  url?: string\n  content?: string\n  file?: FileList | File\n  notebooks?: string[]\n  transformations?: string[]\n  embed: boolean\n  async_processing: boolean\n}\n\ninterface ProcessingStepProps {\n  control: Control<CreateSourceFormData>\n  transformations: Transformation[]\n  selectedTransformations: string[]\n  onToggleTransformation: (transformationId: string) => void\n  loading?: boolean\n  settings?: SettingsResponse\n}\n\nexport function ProcessingStep({\n  control,\n  transformations,\n  selectedTransformations,\n  onToggleTransformation,\n  loading = false,\n  settings\n}: ProcessingStepProps) {\n  const { t } = useTranslation()\n  const transformationItems = transformations.map((transformation) => ({\n    id: transformation.id,\n    title: transformation.title,\n    description: transformation.description\n  }))\n\n  return (\n    <div className=\"space-y-8\">\n      <FormSection\n        title={`${t.navigation.transformations} (${t.common.optional})`}\n        description={t.sources.processDescription}\n      >\n        <CheckboxList\n          items={transformationItems}\n          selectedIds={selectedTransformations}\n          onToggle={onToggleTransformation}\n          loading={loading}\n          emptyMessage={t.common.noMatches}\n        />\n      </FormSection>\n\n      <FormSection\n        title={t.navigation.settings}\n        description={t.sources.processDescription}\n      >\n        <div className=\"space-y-4\">\n          {settings?.default_embedding_option === 'ask' && (\n            <Controller\n              control={control}\n              name=\"embed\"\n              render={({ field }) => (\n                <label \n                  htmlFor=\"enable-embedding\"\n                  className=\"flex items-start gap-3 cursor-pointer p-3 rounded-md hover:bg-muted\"\n                >\n                  <Checkbox\n                    id=\"enable-embedding\"\n                    checked={field.value}\n                    onCheckedChange={field.onChange}\n                    className=\"mt-0.5\"\n                  />\n                  <div className=\"flex-1\">\n                    <span className=\"text-sm font-medium block\">{t.sources.enableEmbedding}</span>\n                    <p className=\"text-xs text-muted-foreground mt-1\">\n                      {t.sources.embeddingDesc}\n                    </p>\n                  </div>\n                </label>\n              )}\n            />\n          )}\n\n          {settings?.default_embedding_option === 'always' && (\n            <div className=\"p-3 rounded-md bg-primary/10 border border-primary/30\">\n              <div className=\"flex items-start gap-3\">\n                <div className=\"w-4 h-4 bg-primary rounded-full mt-0.5 flex-shrink-0\"></div>\n                <div className=\"flex-1\">\n                  <span className=\"text-sm font-medium block text-primary\">{t.sources.embeddingAlways}</span>\n                  <p className=\"text-xs text-primary mt-1\">\n                    {t.sources.embeddingAlwaysDesc}\n                    {t.sources.changeInSettings} <span className=\"font-medium\">{t.navigation.settings}</span>.\n                  </p>\n                </div>\n              </div>\n            </div>\n          )}\n\n          {settings?.default_embedding_option === 'never' && (\n            <div className=\"p-3 rounded-md bg-muted border border-border\">\n              <div className=\"flex items-start gap-3\">\n                <div className=\"w-4 h-4 bg-muted-foreground rounded-full mt-0.5 flex-shrink-0\"></div>\n                <div className=\"flex-1\">\n                  <span className=\"text-sm font-medium block text-foreground\">{t.sources.embeddingNever}</span>\n                  <p className=\"text-xs text-muted-foreground mt-1\">\n                    {t.sources.embeddingNeverDesc}\n                    {t.sources.changeInSettings} <span className=\"font-medium\">{t.navigation.settings}</span>.\n                  </p>\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      </FormSection>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/sources/steps/SourceTypeStep.tsx",
    "content": "\"use client\"\n\nimport { useMemo, useState } from \"react\"\nimport { Control, FieldErrors, UseFormRegister, UseFormSetValue, useWatch } from \"react-hook-form\"\nimport { FileIcon, LinkIcon, FileTextIcon } from \"lucide-react\"\nimport { useTranslation } from \"@/lib/hooks/use-translation\"\nimport { FormSection } from \"@/components/ui/form-section\"\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\nimport { Label } from \"@/components/ui/label\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Controller } from \"react-hook-form\"\n\ninterface CreateSourceFormData {\n  type: 'link' | 'upload' | 'text'\n  title?: string\n  url?: string\n  content?: string\n  file?: FileList | File\n  notebooks?: string[]\n  transformations?: string[]\n  embed: boolean\n  async_processing: boolean\n}\n\n// Helper functions for batch URL parsing\nfunction parseUrls(text: string): string[] {\n  return text\n    .split('\\n')\n    .map(line => line.trim())\n    .filter(line => line.length > 0)\n}\n\nfunction validateUrl(url: string): boolean {\n  try {\n    new URL(url)\n    return true\n  } catch {\n    return false\n  }\n}\n\nexport function parseAndValidateUrls(text: string): {\n  valid: string[]\n  invalid: { url: string; line: number }[]\n} {\n  const lines = text.split('\\n')\n  const valid: string[] = []\n  const invalid: { url: string; line: number }[] = []\n\n  lines.forEach((line, index) => {\n    const trimmed = line.trim()\n    if (trimmed.length === 0) return // skip empty lines\n\n    if (validateUrl(trimmed)) {\n      valid.push(trimmed)\n    } else {\n      invalid.push({ url: trimmed, line: index + 1 })\n    }\n  })\n\n  return { valid, invalid }\n}\n\nimport { TranslationKeys } from '@/lib/locales'\n\nconst getSourceTypes = (t: TranslationKeys) => [\n  {\n    value: 'link' as const,\n    label: t.sources.addUrl,\n    icon: LinkIcon,\n    description: t.sources.processDescription,\n  },\n  {\n    value: 'upload' as const,\n    label: t.sources.uploadFile,\n    icon: FileIcon,\n    description: t.sources.processDescription,\n  },\n  {\n    value: 'text' as const,\n    label: t.sources.enterText,\n    icon: FileTextIcon,\n    description: t.sources.processDescription,\n  },\n]\n\ninterface SourceTypeStepProps {\n  control: Control<CreateSourceFormData>\n  register: UseFormRegister<CreateSourceFormData>\n  setValue: UseFormSetValue<CreateSourceFormData>\n  errors: FieldErrors<CreateSourceFormData>\n  urlValidationErrors?: { url: string; line: number }[]\n  onClearUrlErrors?: () => void\n}\n\nconst MAX_BATCH_SIZE = 50\n\nexport function SourceTypeStep({ control, register, setValue, errors, urlValidationErrors, onClearUrlErrors }: SourceTypeStepProps) {\n  const { t } = useTranslation()\n  // Watch the selected type and inputs to detect batch mode\n  const selectedType = useWatch({ control, name: 'type' })\n  const urlInput = useWatch({ control, name: 'url' })\n  const fileInput = useWatch({ control, name: 'file' })\n\n  // Track if HTML content was pasted\n  const [hasHtmlContent, setHasHtmlContent] = useState(false)\n\n  // Handle paste event to check for HTML content in clipboard\n  const handleTextPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {\n    const htmlContent = event.clipboardData.getData('text/html')\n\n    // If HTML content is available, use it instead of plain text\n    if (htmlContent) {\n      event.preventDefault()\n      // Get current content and cursor position\n      const textarea = event.currentTarget\n      const start = textarea.selectionStart\n      const end = textarea.selectionEnd\n      const currentValue = textarea.value\n\n      // Insert HTML content at cursor position (replacing selection if any)\n      const newValue = currentValue.substring(0, start) + htmlContent + currentValue.substring(end)\n      setValue('content', newValue, { shouldValidate: true })\n      setHasHtmlContent(true)\n    } else {\n      // Plain text paste - clear the HTML indicator\n      setHasHtmlContent(false)\n    }\n  }\n\n  // Batch mode detection\n  const { isBatchMode, itemCount, urlCount, fileCount } = useMemo(() => {\n    let urlCount = 0\n    let fileCount = 0\n\n    if (selectedType === 'link' && urlInput) {\n      const urls = parseUrls(urlInput)\n      urlCount = urls.length\n    }\n\n    if (selectedType === 'upload' && fileInput) {\n      const fileList = fileInput as FileList\n      fileCount = fileList?.length || 0\n    }\n\n    const isBatchMode = urlCount > 1 || fileCount > 1\n    const itemCount = selectedType === 'link' ? urlCount : fileCount\n\n    return { isBatchMode, itemCount, urlCount, fileCount }\n  }, [selectedType, urlInput, fileInput])\n\n  // Check for batch size limit\n  const isOverLimit = itemCount > MAX_BATCH_SIZE\n  return (\n    <div className=\"space-y-6\">\n      <FormSection\n        title={t.sources.title}\n        description={t.sources.processDescription}\n      >\n        <Controller\n          control={control}\n          name=\"type\"\n          render={({ field }) => (\n            <Tabs \n              value={field.value || ''} \n              onValueChange={(value) => field.onChange(value as 'link' | 'upload' | 'text')}\n              className=\"w-full\"\n            >\n              <TabsList className=\"grid w-full grid-cols-3\">\n                {getSourceTypes(t).map((type) => {\n                  const Icon = type.icon\n                  return (\n                    <TabsTrigger key={type.value} value={type.value} className=\"gap-2\">\n                      <Icon className=\"h-4 w-4\" />\n                      {type.label}\n                    </TabsTrigger>\n                  )\n                })}\n              </TabsList>\n              \n              {getSourceTypes(t).map((type) => (\n                <TabsContent key={type.value} value={type.value} className=\"mt-4\">\n                  <p className=\"text-sm text-muted-foreground mb-4\">{type.description}</p>\n                  \n                  {/* Type-specific fields */}\n                  {type.value === 'link' && (\n                    <div>\n                      <div className=\"flex items-center justify-between mb-2\">\n                        <Label htmlFor=\"url\">{t.sources.urlLabel}</Label>\n                        {urlCount > 0 && (\n                          <Badge variant={isOverLimit ? \"destructive\" : \"secondary\"}>\n                            {t.sources.urlsCount.replace('{count}', urlCount.toString())}\n                            {isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`}\n                          </Badge>\n                        )}\n                      </div>\n                      <Textarea\n                        id=\"url\"\n                        {...register('url', {\n                          onChange: () => onClearUrlErrors?.()\n                        })}\n                        placeholder={t.sources.enterUrlsPlaceholder}\n                        rows={urlCount > 1 ? 6 : 2}\n                        className=\"font-mono text-sm\"\n                      />\n                      <p className=\"text-xs text-muted-foreground mt-1\">\n                        {t.sources.batchUrlHint}\n                      </p>\n                      {errors.url && (\n                        <p className=\"text-sm text-destructive mt-1\">{errors.url.message}</p>\n                      )}\n                      {urlValidationErrors && urlValidationErrors.length > 0 && (\n                        <div className=\"mt-2 p-3 bg-destructive/10 rounded-md border border-destructive/20\">\n                          <p className=\"text-sm font-medium text-destructive mb-2\">\n                            {t.sources.invalidUrlsDetected}\n                          </p>\n                          <ul className=\"space-y-1\">\n                            {urlValidationErrors.map((error, idx) => (\n                              <li key={idx} className=\"text-xs text-destructive flex items-start gap-2\">\n                                <span className=\"font-mono bg-destructive/20 px-1 rounded\">\n                                  {t.sources.lineLabel.replace('{line}', error.line.toString())}\n                                </span>\n                                <span className=\"truncate\">{error.url}</span>\n                              </li>\n                            ))}\n                          </ul>\n                          <p className=\"text-xs text-muted-foreground mt-2\">\n                            {t.sources.fixInvalidUrls}\n                          </p>\n                        </div>\n                      )}\n                    </div>\n                  )}\n                  \n                  {type.value === 'upload' && (\n                    <div>\n                      <div className=\"flex items-center justify-between mb-2\">\n                        <Label htmlFor=\"file\">{t.sources.fileLabel}</Label>\n                        {fileCount > 0 && (\n                          <Badge variant={isOverLimit ? \"destructive\" : \"secondary\"}>\n                            {t.sources.filesCount.replace('{count}', fileCount.toString())}\n                            {isOverLimit && ` (${t.sources.maxItems.replace('{count}', MAX_BATCH_SIZE.toString())})`}\n                          </Badge>\n                        )}\n                      </div>\n                      <Input\n                        id=\"file\"\n                        type=\"file\"\n                        multiple\n                        {...register('file')}\n                        accept=\".pdf,.doc,.docx,.pptx,.ppt,.xlsx,.xls,.txt,.md,.epub,.mp4,.avi,.mov,.wmv,.mp3,.wav,.m4a,.aac,.jpg,.jpeg,.png,.tiff,.zip,.tar,.gz,.html\"\n                      />\n                      <p className=\"text-xs text-muted-foreground mt-1\">\n                        {t.sources.selectMultipleFilesHint}\n                      </p>\n                      {fileCount > 1 && fileInput instanceof FileList && (\n                        <div className=\"mt-2 p-3 bg-muted rounded-md\">\n                          <p className=\"text-xs font-medium mb-2\">{t.sources.selectedFiles}</p>\n                          <ul className=\"space-y-1 max-h-32 overflow-y-auto\">\n                            {Array.from(fileInput).map((file, idx) => (\n                              <li key={idx} className=\"text-xs text-muted-foreground flex items-center gap-2\">\n                                <FileIcon className=\"h-3 w-3\" />\n                                <span className=\"truncate\">{file.name}</span>\n                                <span className=\"text-muted-foreground/50\">\n                                  ({(file.size / 1024).toFixed(1)} KB)\n                                </span>\n                              </li>\n                            ))}\n                          </ul>\n                        </div>\n                      )}\n                      {errors.file && (\n                        <p className=\"text-sm text-destructive mt-1\">{errors.file.message}</p>\n                      )}\n                      {isOverLimit && selectedType === 'upload' && (\n                        <p className=\"text-sm text-destructive mt-1\">\n                          {t.sources.maxFilesAllowed.replace('{count}', MAX_BATCH_SIZE.toString())}\n                        </p>\n                      )}\n                    </div>\n                  )}\n                  \n                  {type.value === 'text' && (\n                    <div>\n                      <Label htmlFor=\"content\" className=\"mb-2 block\">{t.sources.textContentLabel}</Label>\n                      {hasHtmlContent && (\n                        <div className=\"mb-2 p-2 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md\">\n                          <p className=\"text-sm text-blue-700 dark:text-blue-300\">\n                            {t.sources.htmlDetected}\n                          </p>\n                        </div>\n                      )}\n                      <Textarea\n                        id=\"content\"\n                        {...register('content')}\n                        placeholder={t.sources.textPlaceholder}\n                        rows={6}\n                        onPaste={handleTextPaste}\n                      />\n                      {errors.content && (\n                        <p className=\"text-sm text-destructive mt-1\">{errors.content.message}</p>\n                      )}\n                    </div>\n                  )}\n                </TabsContent>\n              ))}\n            </Tabs>\n          )}\n        />\n        {errors.type && (\n          <p className=\"text-sm text-destructive mt-1\">{errors.type.message}</p>\n        )}\n      </FormSection>\n\n      {/* Hide title field in batch mode - titles will be auto-generated */}\n      {!isBatchMode && (\n        <FormSection\n          htmlFor=\"source-title\"\n          title={selectedType === 'text' ? `${t.common.title} *` : `${t.common.title} (${t.common.optional})`}\n          description={selectedType === 'text'\n            ? t.sources.titleRequired\n            : t.sources.titleGenerated\n          }\n        >\n          <Input\n            id=\"source-title\"\n            {...register('title')}\n            placeholder={t.sources.titlePlaceholder}\n            autoComplete=\"off\"\n          />\n          {errors.title && (\n            <p className=\"text-sm text-destructive mt-1\">{errors.title.message}</p>\n          )}\n        </FormSection>\n      )}\n\n      {/* Batch mode indicator */}\n      {isBatchMode && (\n        <div className=\"p-4 bg-primary/5 border border-primary/20 rounded-lg\">\n          <div className=\"flex items-center gap-2 mb-2\">\n            <Badge variant=\"default\">{t.common.batchMode}</Badge>\n            <span className=\"text-sm font-medium\">\n              {t.sources.batchCount.replace('{count}', itemCount.toString()).replace('{type}', selectedType === 'link' ? t.sources.addUrl : t.sources.uploadFile)}\n            </span>\n          </div>\n          <p className=\"text-xs text-muted-foreground\">\n            {t.sources.batchTitlesAuto}\n            {t.sources.batchCommonSettings}\n          </p>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/ui/CLAUDE.md",
    "content": "# UI Components Module\n\nRadix UI-based accessible component library with CVA styling, composed building blocks, and theming support.\n\n## Key Components\n\n- **Primitives** (`button.tsx`, `dialog.tsx`, `select.tsx`, `dropdown-menu.tsx`): Radix UI wrappers with Tailwind styling\n- **Composite components** (`checkbox-list.tsx`, `wizard-container.tsx`, `command.tsx`): Multi-part patterns combining primitives\n- **Form components** (`input.tsx`, `textarea.tsx`, `label.tsx`, `form-section.tsx`): Input handling with accessibility\n- **Feedback** (`alert.tsx`, `alert-dialog.tsx`, `sonner.tsx`, `progress.tsx`): User notifications and status\n- **Layout** (`card.tsx`, `accordion.tsx`, `tabs.tsx`, `scroll-area.tsx`): Structural wrappers\n- **Utilities** (`badge.tsx`, `separator.tsx`, `tooltip.tsx`, `popover.tsx`, `collapsible.tsx`): Small focused components\n\n## Important Patterns\n\n- **Radix UI wrappers**: Components delegate to Radix primitives; apply Tailwind classes via `cn()` utility\n- **CVA (Class Variance Authority)**: `button.tsx` and similar use CVA for variant/size combinations\n- **Composition via Slot**: `Button` uses `asChild` prop + `Slot` from radix to render as any element type\n- **Data slots**: All components have `data-slot` attributes for testing/styling isolation\n- **Controlled styling**: Classes hardcoded in components; use `className` prop to override/extend\n- **Animations**: Radix `data-[state]` selectors for open/close animations (fade-in, zoom-in)\n- **Accessibility first**: ARIA attributes from Radix (aria-invalid, sr-only labels, focus rings)\n- **Dark mode support**: Uses Tailwind dark: prefix for color scheme (e.g., `dark:border-input`)\n\n## Key Dependencies\n\n- `@radix-ui/*`: Unstyled accessible primitives (dialog, select, dropdown-menu, etc.)\n- `class-variance-authority`: CVA for variant patterns\n- `lucide-react`: Icon library (XIcon in dialog close button)\n- `@/lib/utils`: `cn()` utility for class merging\n\n## How to Add New Components\n\n1. Create `.tsx` file wrapping Radix primitive or composing existing components\n2. Add `data-slot=\"component-name\"` to root element\n3. Use `cn()` to merge default classes with `className` prop\n4. Export both component and variants (if using CVA)\n5. Document prop shape and usage in JSDoc\n\n## Important Quirks & Gotchas\n\n- **Slot forwarding**: `asChild={true}` on Button passes all props to child; ensure child accepts them\n- **FormData in dialogs**: Dialog not reset automatically; parent must manually clear form state\n- **Focus management**: Dialog auto-focuses first input; can cause layout shifts if inputs conditionally rendered\n- **Z-index stacking**: Fixed elements (Dialog overlay, dropdown menus) use z-50; be careful with other fixed elements\n- **Click outside closes dropdown**: Radix dropdowns auto-close on outside click; may conflict with hover-triggered actions\n- **SVG size inference**: Button uses `[&_svg:not([class*='size-'])]:size-4` to default unlabeled icons to 4x4; be explicit if different size needed\n- **CSS-in-JS conflicts**: Hardcoded Tailwind classes may conflict with global CSS; specificity matters\n- **Dark mode class**: Requires `dark` class on document root; not automatic with prefers-color-scheme alone\n\n## Testing Patterns\n\n```typescript\n// Test component rendering with props\nrender(<Button variant=\"destructive\" size=\"sm\">Delete</Button>)\nexpect(screen.getByRole('button')).toHaveClass('bg-destructive')\n\n// Test Dialog interaction\nrender(<Dialog open={true}><DialogContent>Content</DialogContent></Dialog>)\nexpect(screen.getByText('Content')).toBeInTheDocument()\n\n// Test accessibility\nexpect(screen.getByRole('dialog')).toHaveAttribute('role', 'dialog')\n```\n"
  },
  {
    "path": "frontend/src/components/ui/accordion.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Accordion = AccordionPrimitive.Root\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n))\nAccordionItem.displayName = AccordionPrimitive.Item.displayName\n\nconst AccordionHeader = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Header>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Header>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Header\n    ref={ref}\n    className={cn(\"flex\", className)}\n    {...props}\n  />\n))\nAccordionHeader.displayName = AccordionPrimitive.Header.displayName\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <svg\n      className=\"h-4 w-4 shrink-0 transition-transform duration-200\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M6 9l6 6 6-6\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  </AccordionPrimitive.Trigger>\n))\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\",\n      className\n    )}\n    {...props}\n  >\n    <div className=\"pb-4 pt-0\">{children}</div>\n  </AccordionPrimitive.Content>\n))\nAccordionContent.displayName = AccordionPrimitive.Content.displayName\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent }\n"
  },
  {
    "path": "frontend/src/components/ui/alert-dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimport { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"@/components/ui/button\"\n\nfunction AlertDialog({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />\n}\n\nfunction AlertDialogTrigger({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  )\n}\n\nfunction AlertDialogPortal({\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  )\n}\n\nfunction AlertDialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  )\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return (\n    <AlertDialogPrimitive.Action\n      className={cn(buttonVariants(), className)}\n      {...props}\n    />\n  )\n}\n\nfunction AlertDialogCancel({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return (\n    <AlertDialogPrimitive.Cancel\n      className={cn(buttonVariants({ variant: \"outline\" }), className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/alert.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n))\nAlert.displayName = \"Alert\"\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n))\nAlertTitle.displayName = \"AlertTitle\"\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n))\nAlertDescription.displayName = \"AlertDescription\"\n\nexport { Alert, AlertTitle, AlertDescription }"
  },
  {
    "path": "frontend/src/components/ui/badge.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst badgeVariants = cva(\n  \"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90\",\n        destructive:\n          \"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n)\n\nfunction Badge({\n  className,\n  variant,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"span\"> &\n  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"span\"\n\n  return (\n    <Comp\n      data-slot=\"badge\"\n      className={cn(badgeVariants({ variant }), className)}\n      {...props}\n    />\n  )\n}\n\nexport { Badge, badgeVariants }\n"
  },
  {
    "path": "frontend/src/components/ui/button.tsx",
    "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean\n  }) {\n  const Comp = asChild ? Slot : \"button\"\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  )\n}\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "frontend/src/components/ui/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/checkbox-list.tsx",
    "content": "\"use client\"\n\nimport { Checkbox } from \"@/components/ui/checkbox\"\nimport { cn } from \"@/lib/utils\"\n\ninterface CheckboxListItem {\n  id: string\n  title: string\n  description?: string\n}\n\ninterface CheckboxListProps {\n  items: CheckboxListItem[]\n  selectedIds: string[]\n  onToggle: (id: string) => void\n  loading?: boolean\n  emptyMessage?: string\n  className?: string\n}\n\nexport function CheckboxList({\n  items,\n  selectedIds,\n  onToggle,\n  loading = false,\n  emptyMessage = \"No items found.\",\n  className\n}: CheckboxListProps) {\n  if (loading) {\n    return (\n      <div className={cn('border border-border rounded-md p-4 bg-card', className)}>\n        <div className=\"animate-pulse space-y-3\">\n          {[...Array(3)].map((_, i) => (\n            <div key={i} className=\"flex items-center gap-3\">\n              <div className=\"w-4 h-4 bg-muted rounded\" />\n              <div className=\"flex-1\">\n                <div className=\"h-4 bg-muted rounded w-3/4 mb-1\" />\n                <div className=\"h-3 bg-muted rounded w-1/2\" />\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n    )\n  }\n\n  if (items.length === 0) {\n    return (\n      <div className={cn('border border-border rounded-md p-4 bg-card', className)}>\n        <p className=\"text-sm text-muted-foreground\">{emptyMessage}</p>\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn('border border-border rounded-md bg-card', className)}>\n      <div className=\"max-h-48 overflow-y-auto p-4\">\n        <div className=\"space-y-3\">\n          {items.map((item) => (\n            <label\n              key={item.id}\n              htmlFor={`checkbox-${item.id}`}\n              className=\"flex items-start gap-3 cursor-pointer hover:bg-muted p-2 rounded-md -m-2 transition-colors\"\n            >\n              <Checkbox\n                id={`checkbox-${item.id}`}\n                name={`checkbox-${item.id}`}\n                checked={selectedIds.includes(item.id)}\n                onCheckedChange={() => onToggle(item.id)}\n                className=\"mt-0.5\"\n              />\n              <div className=\"flex-1 min-w-0\">\n                <span className=\"text-sm font-medium block\">\n                  {item.title}\n                </span>\n                {item.description && (\n                  <p className=\"text-xs text-muted-foreground mt-1 line-clamp-2\">\n                    {item.description}\n                  </p>\n                )}\n              </div>\n            </label>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/ui/checkbox.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { CheckIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Checkbox({\n  className,\n  ...props\n}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        \"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"flex items-center justify-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  )\n}\n\nexport { Checkbox }\n"
  },
  {
    "path": "frontend/src/components/ui/collapsible.tsx",
    "content": "\"use client\"\n\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\"\n\nfunction Collapsible({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />\n}\n\nfunction CollapsibleTrigger({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleTrigger\n      data-slot=\"collapsible-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction CollapsibleContent({\n  ...props\n}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      {...props}\n    />\n  )\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent }\n"
  },
  {
    "path": "frontend/src/components/ui/command.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Command as CommandPrimitive } from \"cmdk\"\nimport { SearchIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\n\nfunction Command({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string\n  description?: string\n  className?: string\n  showCloseButton?: boolean\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent\n        className={cn(\"overflow-hidden p-0\", className)}\n        showCloseButton={showCloseButton}\n      >\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div\n      data-slot=\"command-input-wrapper\"\n      className=\"flex h-9 items-center gap-2 border-b px-3\"\n    >\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction CommandList({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn(\n        \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandEmpty({\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  )\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CommandItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/dialog.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from \"lucide-react\"\nimport { useTranslation } from \"@/lib/hooks/use-translation\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Dialog({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />\n}\n\nfunction DialogTrigger({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />\n}\n\nfunction DialogPortal({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />\n}\n\nfunction DialogClose({\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />\n}\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:pointer-events-none fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst DialogContent = ({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean\n}) => {\n  const { t } = useTranslation()\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        aria-describedby={undefined}\n        className={cn(\n            \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[calc(100%-2rem)] overflow-hidden\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n            <X className=\"h-4 w-4\" />\n            <span className=\"sr-only\">{t?.common?.close || 'Close'}</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  )\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\"text-lg leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/dropdown-menu.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  )\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  )\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/form-section.tsx",
    "content": "\"use client\"\n\nimport { ReactNode } from \"react\"\nimport { Label } from \"@/components/ui/label\"\nimport { cn } from \"@/lib/utils\"\n\ninterface FormSectionProps {\n  title: string\n  description?: string\n  children: ReactNode\n  className?: string\n  htmlFor?: string\n}\n\nexport function FormSection({\n  title,\n  description,\n  children,\n  className,\n  htmlFor\n}: FormSectionProps) {\n  return (\n    <div className={cn(\"mb-6 last:mb-0\", className)}>\n      <div className=\"mb-4\">\n        {htmlFor ? (\n          <Label htmlFor={htmlFor} className=\"text-base font-medium block mb-1\">\n            {title}\n          </Label>\n        ) : (\n          <h3 className=\"text-base font-medium block mb-1\">\n            {title}\n          </h3>\n        )}\n        {description && (\n          <p className=\"text-sm text-muted-foreground\">\n            {description}\n          </p>\n        )}\n      </div>\n      <div className=\"space-y-3\">\n        {children}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "frontend/src/components/ui/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "frontend/src/components/ui/label.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Label }\n"
  },
  {
    "path": "frontend/src/components/ui/markdown-editor.tsx",
    "content": "'use client'\n\nimport dynamic from 'next/dynamic'\nimport { forwardRef } from 'react'\n\nconst MDEditor = dynamic(\n  () => import('@uiw/react-md-editor').then((mod) => mod.default),\n  { ssr: false }\n)\n\nexport interface MarkdownEditorProps {\n  value?: string\n  onChange?: (value?: string) => void\n  placeholder?: string\n  height?: number\n  preview?: 'live' | 'edit' | 'preview'\n  hideToolbar?: boolean\n  textareaId?: string\n  name?: string\n  className?: string\n}\n\nexport const MarkdownEditor = forwardRef<HTMLDivElement, MarkdownEditorProps>(\n  ({ value = '', onChange, placeholder, height = 300, preview = 'live', hideToolbar = false, className, textareaId, name }, ref) => {\n    return (\n      <div className={className} ref={ref}>\n        <MDEditor\n          value={value}\n          onChange={onChange}\n          preview={preview}\n          height={height}\n          hideToolbar={hideToolbar}\n          textareaProps={{\n            placeholder: placeholder || 'Enter markdown...',\n            id: textareaId,\n            name: name,\n          }}\n          data-color-mode=\"light\"\n        />\n      </div>\n    )\n  }\n)\n\nMarkdownEditor.displayName = 'MarkdownEditor'"
  },
  {
    "path": "frontend/src/components/ui/popover.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Popover({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />\n}\n\nfunction PopoverTrigger({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />\n}\n\nfunction PopoverContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden\",\n          className\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  )\n}\n\nfunction PopoverAnchor({\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />\n}\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }\n"
  },
  {
    "path": "frontend/src/components/ui/progress.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Progress({\n  className,\n  value,\n  ...props\n}: React.ComponentProps<typeof ProgressPrimitive.Root>) {\n  return (\n    <ProgressPrimitive.Root\n      data-slot=\"progress\"\n      className={cn(\n        \"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full\",\n        className\n      )}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        data-slot=\"progress-indicator\"\n        className=\"bg-primary h-full w-full flex-1 transition-all\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n    </ProgressPrimitive.Root>\n  )\n}\n\nexport { Progress }\n"
  },
  {
    "path": "frontend/src/components/ui/radio-group.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport { CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction RadioGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {\n  return (\n    <RadioGroupPrimitive.Root\n      data-slot=\"radio-group\"\n      className={cn(\"grid gap-3\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {\n  return (\n    <RadioGroupPrimitive.Item\n      data-slot=\"radio-group-item\"\n      className={cn(\n        \"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator\n        data-slot=\"radio-group-indicator\"\n        className=\"relative flex items-center justify-center\"\n      >\n        <CircleIcon className=\"fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  )\n}\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "frontend/src/components/ui/scroll-area.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {\n  return (\n    <ScrollAreaPrimitive.Root\n      data-slot=\"scroll-area\"\n      className={cn(\"relative\", className)}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        data-slot=\"scroll-area-viewport\"\n        className=\"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  )\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {\n  return (\n    <ScrollAreaPrimitive.ScrollAreaScrollbar\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      className={cn(\n        \"flex touch-none p-px transition-colors select-none\",\n        orientation === \"vertical\" &&\n          \"h-full w-2.5 border-l border-l-transparent\",\n        orientation === \"horizontal\" &&\n          \"h-2.5 flex-col border-t border-t-transparent\",\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.ScrollAreaThumb\n        data-slot=\"scroll-area-thumb\"\n        className=\"bg-border relative flex-1 rounded-full\"\n      />\n    </ScrollAreaPrimitive.ScrollAreaScrollbar>\n  )\n}\n\nexport { ScrollArea, ScrollBar }\n"
  },
  {
    "path": "frontend/src/components/ui/select.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\"\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  )\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className\n        )}\n        position={position}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\"\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  )\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  )\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  )\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  )\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/separator.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Separator }\n"
  },
  {
    "path": "frontend/src/components/ui/sonner.tsx",
    "content": "\"use client\"\n\nimport { useThemeStore } from \"@/lib/stores/theme-store\"\nimport { Toaster as Sonner, ToasterProps } from \"sonner\"\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const theme = useThemeStore((state) => state.theme)\n  const systemTheme = useThemeStore((state) => state.getSystemTheme())\n  const effectiveTheme = theme === 'system' ? systemTheme : theme\n\n  return (\n    <Sonner\n      theme={effectiveTheme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n          \"--success-bg\": \"var(--popover)\",\n          \"--success-text\": \"var(--popover-foreground)\",\n          \"--success-border\": \"var(--border)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "frontend/src/components/ui/tabs.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Tabs({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      className={cn(\"flex flex-col gap-2\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TabsList({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        \"inline-flex w-fit items-center justify-center gap-1 rounded-xl border border-border bg-muted/80 p-1 text-muted-foreground shadow-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TabsTrigger({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      data-slot=\"tabs-trigger\"\n      className={cn(\n        \"inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border border-transparent px-4 text-sm font-medium text-muted-foreground transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TabsContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return (\n    <TabsPrimitive.Content\n      data-slot=\"tabs-content\"\n      className={cn(\"flex-1 outline-none\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n"
  },
  {
    "path": "frontend/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 min-w-0 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "frontend/src/components/ui/tooltip.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "frontend/src/components/ui/wizard-container.tsx",
    "content": "\"use client\"\n\nimport { ReactNode } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\ninterface WizardStep {\n  number: number\n  title: string\n  description: string\n}\n\ninterface WizardContainerProps {\n  children: ReactNode\n  currentStep: number\n  steps: readonly WizardStep[]\n  onStepClick?: (step: number) => void\n  className?: string\n}\n\nfunction StepIndicator({ currentStep, steps, onStepClick }: {\n  currentStep: number\n  steps: readonly WizardStep[]\n  onStepClick?: (step: number) => void\n}) {\n  return (\n    <div className=\"flex items-center justify-between px-6 py-4 border-b border-border bg-muted\">\n      {steps.map((step, index) => {\n        const isCompleted = currentStep > step.number\n        const isCurrent = currentStep === step.number\n        const isClickable = step.number <= currentStep && onStepClick\n        \n        return (\n          <div key={step.number} className=\"flex items-center flex-1\">\n            <div \n              className={cn('flex items-center', isClickable && 'cursor-pointer')}\n              onClick={isClickable ? () => onStepClick(step.number) : undefined}\n            >\n              <div\n                className={cn(\n                  'flex items-center justify-center w-8 h-8 rounded-full border-2 text-sm font-medium transition-colors',\n                  isCompleted \n                    ? 'bg-primary border-primary text-primary-foreground' \n                    : isCurrent \n                      ? 'border-primary text-primary bg-primary/10'\n                      : 'border-border text-muted-foreground bg-card'\n                )}\n              >\n                {isCompleted ? \"✓\" : step.number}\n              </div>\n              <div className=\"ml-3 min-w-0\">\n                <p className={cn(\n                  'text-sm font-medium',\n                  isCurrent ? 'text-foreground' : 'text-muted-foreground'\n                )}>\n                  {step.title}\n                </p>\n                <p className={cn(\n                  'text-xs',\n                  isCurrent ? 'text-muted-foreground' : 'text-muted-foreground/80'\n                )}>\n                  {step.description}\n                </p>\n              </div>\n            </div>\n            {index < steps.length - 1 && (\n              <div \n                className={cn(\n                  'flex-1 border-t-2 mx-4 transition-colors',\n                  isCompleted ? 'border-primary' : 'border-border/60'\n                )} \n              />\n            )}\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\nexport function WizardContainer({\n  children,\n  currentStep,\n  steps,\n  onStepClick,\n  className\n}: WizardContainerProps) {\n  return (\n    <div className={cn('flex flex-col h-[500px] min-w-0 overflow-hidden bg-card rounded-lg border border-border', className)}>\n      <StepIndicator\n        currentStep={currentStep}\n        steps={steps}\n        onStepClick={onStepClick}\n      />\n\n      <div className=\"flex-1 min-w-0 overflow-hidden\">\n        <div className=\"h-full min-w-0 overflow-y-auto px-6 py-4\">\n          {children}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport type { WizardStep }\n"
  },
  {
    "path": "frontend/src/lib/api/CLAUDE.md",
    "content": "# API Module\n\nAxios-based client and resource-specific API modules for backend communication with auth, FormData handling, and error recovery.\n\n## Key Components\n\n- **`client.ts`**: Central Axios instance with request/response interceptors, auth headers, base URL resolution\n- **Resource modules** (`sources.ts`, `notebooks.ts`, `chat.ts`, `search.ts`, `podcasts.ts`, etc.): Endpoint-specific functions returning typed responses\n- **`query-client.ts`**: TanStack Query client configuration with default options\n- **`models.ts`, `notes.ts`, `embeddings.ts`, `settings.ts`**: Additional resource APIs\n\n## Important Patterns\n\n- **Single axios instance**: `apiClient` with 10-minute timeout (for slow LLM operations)\n- **Request interceptor**: Auto-fetches base URL from config, adds Bearer auth from localStorage `auth-storage`\n- **FormData handling**: Auto-removes Content-Type header for FormData to let browser set multipart boundary\n- **Response interceptor**: 401 clears auth and redirects to `/login`\n- **Async base URL resolution**: `getApiUrl()` fetches from runtime config on first request\n- **Error propagation**: All functions return typed responses via `response.data`\n- **Method chaining**: Resource modules export namespaced objects (e.g., `sourcesApi.list()`, `sourcesApi.create()`)\n\n## Key Dependencies\n\n- `axios`: HTTP client library\n- `@/lib/config`: `getApiUrl()` for dynamic base URL\n- `@/lib/types/api`: TypeScript types for request/response shapes\n\n## How to Add New API Modules\n\n1. Create new file (e.g., `transforms.ts`)\n2. Import `apiClient`\n3. Export namespaced object with methods:\n   ```typescript\n   export const transformsApi = {\n     list: async () => { const response = await apiClient.get('/transforms'); return response.data }\n   }\n   ```\n4. Add types to `@/lib/types/api` if new response shapes needed\n\n## Important Quirks & Gotchas\n\n- **Base URL delay**: First request waits for `getApiUrl()` to resolve; can be slow on startup\n- **FormData fields as JSON strings**: Nested objects (arrays, objects) must be JSON stringified in FormData (e.g., `notebooks`, `transformations`)\n- **Timeout for streaming**: 10-minute timeout may not cover very long-running LLM operations; consider extending if needed\n- **Auth token management**: Token stored in localStorage `auth-storage` key; uses Zustand persist middleware\n- **Headers mutation in interceptor**: Mutating `config.headers` directly; be careful with middleware order\n- **No automatic retry logic**: Failed requests not automatically retried; must be handled in consuming code. Podcast episodes have explicit retry via `retryEpisode()` in `podcasts.ts` and `useRetryPodcastEpisode()` hook\n- **Content-Type header precedence**: FormData interceptor deletes Content-Type after checking; subsequent interceptors won't re-add it\n\n## Usage Example\n\n```typescript\n// Basic list\nconst sources = await sourcesApi.list({ notebook_id: notebookId })\n\n// File upload with FormData\nconst response = await sourcesApi.create({\n  type: 'upload',\n  file: fileObj,\n  notebook_id: notebookId,\n  async_processing: true\n})\n\n// With auth token (auto-added by interceptor)\nconst notes = await notesApi.list()\n```\n\n## Credentials Module (`credentials.ts`)\n\nClient functions for managing AI provider credentials (API keys, base URLs, endpoints) stored encrypted in SurrealDB.\n\n### Type Definitions\n\n```typescript\n// Full credential object (api_key never exposed)\ninterface Credential {\n  id: string\n  name: string\n  provider: string\n  modalities: string[]\n  has_api_key: boolean\n  model_count: number\n  base_url?: string\n  endpoint?: string\n  api_version?: string\n  // ... endpoint_llm, endpoint_embedding, endpoint_stt, endpoint_tts, project, location, credentials_path\n}\n\n// Request payload for creating/updating credential\ninterface CreateCredentialRequest {\n  name: string\n  provider: string\n  modalities: string[]\n  api_key?: string\n  base_url?: string\n  // ... other provider-specific fields\n}\n\n// Model discovery and registration\ninterface DiscoverModelsResponse { provider: string; models: DiscoveredModel[]; credential_id: string }\ninterface RegisterModelsRequest { models: RegisterModelData[] }\n\n// Status and migration\ninterface CredentialStatus { configured: Record<string, boolean>; source: Record<string, string>; encryption_configured: boolean }\ninterface EnvStatus { [provider: string]: boolean }\ninterface MigrationResult { message: string; migrated: string[]; skipped: string[]; errors: string[] }\ninterface TestConnectionResult { provider: string; success: boolean; message: string }\n```\n\n### API Functions\n\n| Function | Description | Endpoint |\n|----------|-------------|----------|\n| `getStatus()` | Get configuration status of all providers | `GET /credentials/status` |\n| `getEnvStatus()` | Get which providers have env vars set | `GET /credentials/env-status` |\n| `list(provider?)` | List all credentials (optional filter) | `GET /credentials` |\n| `listByProvider(provider)` | List credentials for a provider | `GET /credentials/by-provider/{provider}` |\n| `get(credentialId)` | Get a specific credential | `GET /credentials/{credentialId}` |\n| `create(data)` | Create a new credential | `POST /credentials` |\n| `update(credentialId, data)` | Update a credential | `PUT /credentials/{credentialId}` |\n| `delete(credentialId, options?)` | Delete a credential | `DELETE /credentials/{credentialId}` |\n| `test(credentialId)` | Test connection using credential | `POST /credentials/{credentialId}/test` |\n| `discover(credentialId)` | Discover available models | `POST /credentials/{credentialId}/discover` |\n| `registerModels(credentialId, data)` | Register discovered models | `POST /credentials/{credentialId}/register-models` |\n| `migrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `POST /credentials/migrate-from-provider-config` |\n| `migrateFromEnv()` | Migrate from env vars | `POST /credentials/migrate-from-env` |\n\n### Usage Example\n\n```typescript\nimport { credentialsApi } from '@/lib/api/credentials'\n\n// Check which providers are configured\nconst status = await credentialsApi.getStatus()\nif (status.configured['openai']) {\n  console.log(`OpenAI configured via ${status.source['openai']}`)\n}\n\n// Create a new credential\nconst cred = await credentialsApi.create({\n  name: 'My OpenAI Key',\n  provider: 'openai',\n  modalities: ['language', 'embedding'],\n  api_key: 'sk-proj-...'\n})\n\n// Test the connection\nconst result = await credentialsApi.test(cred.id)\nif (result.success) {\n  console.log('Connection successful!')\n}\n\n// Discover and register models\nconst discovered = await credentialsApi.discover(cred.id)\nawait credentialsApi.registerModels(cred.id, {\n  models: discovered.models.map(m => ({ model_id: m.model_id, name: m.name, type: 'language' }))\n})\n```\n"
  },
  {
    "path": "frontend/src/lib/api/chat.ts",
    "content": "import apiClient from './client'\nimport {\n  NotebookChatSession,\n  NotebookChatSessionWithMessages,\n  CreateNotebookChatSessionRequest,\n  UpdateNotebookChatSessionRequest,\n  SendNotebookChatMessageRequest,\n  NotebookChatMessage,\n  BuildContextRequest,\n  BuildContextResponse,\n} from '@/lib/types/api'\n\nexport const chatApi = {\n  // Session management\n  listSessions: async (notebookId: string) => {\n    const response = await apiClient.get<NotebookChatSession[]>(\n      `/chat/sessions`,\n      { params: { notebook_id: notebookId } }\n    )\n    return response.data\n  },\n\n  createSession: async (data: CreateNotebookChatSessionRequest) => {\n    const response = await apiClient.post<NotebookChatSession>(\n      `/chat/sessions`,\n      data\n    )\n    return response.data\n  },\n\n  getSession: async (sessionId: string) => {\n    const response = await apiClient.get<NotebookChatSessionWithMessages>(\n      `/chat/sessions/${sessionId}`\n    )\n    return response.data\n  },\n\n  updateSession: async (sessionId: string, data: UpdateNotebookChatSessionRequest) => {\n    const response = await apiClient.put<NotebookChatSession>(\n      `/chat/sessions/${sessionId}`,\n      data\n    )\n    return response.data\n  },\n\n  deleteSession: async (sessionId: string) => {\n    await apiClient.delete(`/chat/sessions/${sessionId}`)\n  },\n\n  // Messaging (synchronous, no streaming)\n  sendMessage: async (data: SendNotebookChatMessageRequest) => {\n    const response = await apiClient.post<{\n      session_id: string\n      messages: NotebookChatMessage[]\n    }>(\n      `/chat/execute`,\n      data\n    )\n    return response.data\n  },\n\n  buildContext: async (data: BuildContextRequest) => {\n    const response = await apiClient.post<BuildContextResponse>(\n      `/chat/context`,\n      data\n    )\n    return response.data\n  },\n}\n\nexport default chatApi\n"
  },
  {
    "path": "frontend/src/lib/api/client.ts",
    "content": "import axios, { AxiosResponse } from 'axios'\nimport { getApiUrl } from '@/lib/config'\n\n// API client with runtime-configurable base URL\n// The base URL is fetched from the API config endpoint on first request\n// Timeout increased to 10 minutes (600000ms = 600s) to accommodate slow LLM operations\n// (transformations, insights generation, chat) especially on slower hardware (Ollama, LM Studio)\n// Note: Frontend uses milliseconds, backend uses seconds\n// Local LLMs can take several minutes for complex questions with large contexts\nexport const apiClient = axios.create({\n  timeout: 600000, // 600 seconds = 10 minutes\n  headers: {\n    'Content-Type': 'application/json',\n  },\n  withCredentials: false,\n})\n\n// Request interceptor to add base URL and auth header\napiClient.interceptors.request.use(async (config) => {\n  // Set the base URL dynamically from runtime config\n  if (!config.baseURL) {\n    const apiUrl = await getApiUrl()\n    config.baseURL = `${apiUrl}/api`\n  }\n\n  if (typeof window !== 'undefined') {\n    const authStorage = localStorage.getItem('auth-storage')\n    if (authStorage) {\n      try {\n        const { state } = JSON.parse(authStorage)\n        if (state?.token) {\n          config.headers.Authorization = `Bearer ${state.token}`\n        }\n      } catch (error) {\n        console.error('Error parsing auth storage:', error)\n      }\n    }\n  }\n\n  // Handle FormData vs JSON content types\n  if (config.data instanceof FormData) {\n    // Remove any Content-Type header to let browser set multipart boundary\n    delete config.headers['Content-Type']\n  } else if (config.method && ['post', 'put', 'patch'].includes(config.method.toLowerCase())) {\n    config.headers['Content-Type'] = 'application/json'\n  }\n\n  return config\n})\n\n// Response interceptor for error handling\napiClient.interceptors.response.use(\n  (response: AxiosResponse) => response,\n  (error) => {\n    if (error.response?.status === 401) {\n      // Clear auth and redirect to login\n      if (typeof window !== 'undefined') {\n        localStorage.removeItem('auth-storage')\n        window.location.href = '/login'\n      }\n    }\n    return Promise.reject(error)\n  }\n)\n\nexport default apiClient"
  },
  {
    "path": "frontend/src/lib/api/credentials.ts",
    "content": "import apiClient from './client'\n\n// Types for credentials API\nexport interface Credential {\n  id: string\n  name: string\n  provider: string\n  modalities: string[]\n  base_url?: string | null\n  endpoint?: string | null\n  api_version?: string | null\n  endpoint_llm?: string | null\n  endpoint_embedding?: string | null\n  endpoint_stt?: string | null\n  endpoint_tts?: string | null\n  project?: string | null\n  location?: string | null\n  credentials_path?: string | null\n  has_api_key: boolean\n  created: string\n  updated: string\n  model_count: number\n}\n\nexport interface CreateCredentialRequest {\n  name: string\n  provider: string\n  modalities: string[]\n  api_key?: string\n  base_url?: string\n  endpoint?: string\n  api_version?: string\n  endpoint_llm?: string\n  endpoint_embedding?: string\n  endpoint_stt?: string\n  endpoint_tts?: string\n  project?: string\n  location?: string\n  credentials_path?: string\n}\n\nexport interface UpdateCredentialRequest {\n  name?: string\n  modalities?: string[]\n  api_key?: string\n  base_url?: string\n  endpoint?: string\n  api_version?: string\n  endpoint_llm?: string\n  endpoint_embedding?: string\n  endpoint_stt?: string\n  endpoint_tts?: string\n  project?: string\n  location?: string\n  credentials_path?: string\n}\n\nexport interface DiscoveredModel {\n  name: string\n  provider: string\n  model_type?: string\n  description?: string\n}\n\nexport interface RegisterModelData {\n  name: string\n  provider: string\n  model_type: string\n}\n\nexport interface DiscoverModelsResponse {\n  credential_id: string\n  provider: string\n  discovered: DiscoveredModel[]\n}\n\nexport interface RegisterModelsRequest {\n  models: RegisterModelData[]\n}\n\nexport interface RegisterModelsResponse {\n  created: number\n  existing: number\n}\n\nexport interface TestConnectionResult {\n  provider: string\n  success: boolean\n  message: string\n}\n\nexport interface CredentialDeleteResponse {\n  message: string\n  deleted_models: number\n}\n\nexport interface MigrationResult {\n  message: string\n  migrated: string[]\n  skipped: string[]\n  not_configured?: string[]\n  errors: string[]\n}\n\nexport interface CredentialStatus {\n  configured: Record<string, boolean>\n  source: Record<string, string>\n  encryption_configured: boolean\n}\n\nexport type EnvStatus = Record<string, boolean>\n\nexport const credentialsApi = {\n  /**\n   * Get configuration status for all providers\n   */\n  getStatus: async (): Promise<CredentialStatus> => {\n    const response = await apiClient.get<CredentialStatus>('/credentials/status')\n    return response.data\n  },\n\n  /**\n   * Get environment variable status for all providers\n   */\n  getEnvStatus: async (): Promise<EnvStatus> => {\n    const response = await apiClient.get<EnvStatus>('/credentials/env-status')\n    return response.data\n  },\n\n  /**\n   * List all credentials, optionally filtered by provider\n   */\n  list: async (provider?: string): Promise<Credential[]> => {\n    const params = provider ? { provider } : {}\n    const response = await apiClient.get<Credential[]>('/credentials', { params })\n    return response.data\n  },\n\n  /**\n   * List credentials for a specific provider\n   */\n  listByProvider: async (provider: string): Promise<Credential[]> => {\n    const response = await apiClient.get<Credential[]>(`/credentials/by-provider/${provider}`)\n    return response.data\n  },\n\n  /**\n   * Get a specific credential by ID\n   */\n  get: async (credentialId: string): Promise<Credential> => {\n    const response = await apiClient.get<Credential>(`/credentials/${credentialId}`)\n    return response.data\n  },\n\n  /**\n   * Create a new credential\n   */\n  create: async (data: CreateCredentialRequest): Promise<Credential> => {\n    const response = await apiClient.post<Credential>('/credentials', data)\n    return response.data\n  },\n\n  /**\n   * Update an existing credential\n   */\n  update: async (credentialId: string, data: UpdateCredentialRequest): Promise<Credential> => {\n    const response = await apiClient.put<Credential>(`/credentials/${credentialId}`, data)\n    return response.data\n  },\n\n  /**\n   * Delete a credential\n   */\n  delete: async (\n    credentialId: string,\n    options?: { delete_models?: boolean; migrate_to?: string }\n  ): Promise<CredentialDeleteResponse> => {\n    const params: Record<string, string | boolean> = {}\n    if (options?.delete_models) params.delete_models = true\n    if (options?.migrate_to) params.migrate_to = options.migrate_to\n    const response = await apiClient.delete<CredentialDeleteResponse>(\n      `/credentials/${credentialId}`,\n      { params }\n    )\n    return response.data\n  },\n\n  /**\n   * Test connection for a credential\n   */\n  test: async (credentialId: string): Promise<TestConnectionResult> => {\n    const response = await apiClient.post<TestConnectionResult>(\n      `/credentials/${credentialId}/test`\n    )\n    return response.data\n  },\n\n  /**\n   * Discover models using a credential's API key\n   */\n  discover: async (credentialId: string): Promise<DiscoverModelsResponse> => {\n    const response = await apiClient.post<DiscoverModelsResponse>(\n      `/credentials/${credentialId}/discover`\n    )\n    return response.data\n  },\n\n  /**\n   * Register discovered models and link them to a credential\n   */\n  registerModels: async (\n    credentialId: string,\n    data: RegisterModelsRequest\n  ): Promise<RegisterModelsResponse> => {\n    const response = await apiClient.post<RegisterModelsResponse>(\n      `/credentials/${credentialId}/register-models`,\n      data\n    )\n    return response.data\n  },\n\n  /**\n   * Migrate from ProviderConfig to individual credentials\n   */\n  migrateFromProviderConfig: async (): Promise<MigrationResult> => {\n    const response = await apiClient.post<MigrationResult>(\n      '/credentials/migrate-from-provider-config'\n    )\n    return response.data\n  },\n\n  /**\n   * Migrate from environment variables to credentials\n   */\n  migrateFromEnv: async (): Promise<MigrationResult> => {\n    const response = await apiClient.post<MigrationResult>('/credentials/migrate-from-env')\n    return response.data\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/api/embedding.ts",
    "content": "import apiClient from './client'\n\nexport interface EmbedContentRequest {\n  item_id: string\n  item_type: 'source' | 'note'\n  async_processing?: boolean\n}\n\nexport interface EmbedContentResponse {\n  success: boolean\n  message: string\n  chunks_created?: number\n  command_id?: string\n}\n\nexport interface RebuildEmbeddingsRequest {\n  mode: 'existing' | 'all'\n  include_sources?: boolean\n  include_notes?: boolean\n  include_insights?: boolean\n}\n\nexport interface RebuildEmbeddingsResponse {\n  command_id: string\n  message: string\n  estimated_items: number\n}\n\nexport interface RebuildProgress {\n  total_items?: number\n  processed_items?: number\n  failed_items?: number\n  total?: number\n  processed?: number\n  percentage?: number\n}\n\nexport interface RebuildStats {\n  sources_processed?: number\n  notes_processed?: number\n  insights_processed?: number\n  sources?: number\n  notes?: number\n  insights?: number\n  failed?: number\n  failed_items?: number\n  processing_time?: number\n}\n\nexport interface RebuildStatusResponse {\n  command_id: string\n  status: 'queued' | 'running' | 'completed' | 'failed'\n  progress?: RebuildProgress\n  stats?: RebuildStats\n  started_at?: string\n  completed_at?: string\n  error_message?: string\n}\n\nexport const embeddingApi = {\n  embedContent: async (itemId: string, itemType: 'source' | 'note', asyncProcessing = false): Promise<EmbedContentResponse> => {\n    const response = await apiClient.post<EmbedContentResponse>('/embed', {\n      item_id: itemId,\n      item_type: itemType,\n      async_processing: asyncProcessing\n    })\n    return response.data\n  },\n\n  rebuildEmbeddings: async (request: RebuildEmbeddingsRequest): Promise<RebuildEmbeddingsResponse> => {\n    const response = await apiClient.post<RebuildEmbeddingsResponse>('/embeddings/rebuild', request)\n    return response.data\n  },\n\n  getRebuildStatus: async (commandId: string): Promise<RebuildStatusResponse> => {\n    const response = await apiClient.get<RebuildStatusResponse>(`/embeddings/rebuild/${commandId}/status`)\n    return response.data\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/api/insights.ts",
    "content": "import apiClient from './client'\n\nexport interface SourceInsightResponse {\n  id: string\n  source_id: string\n  insight_type: string\n  content: string\n  created: string\n  updated: string\n}\n\nexport interface CreateSourceInsightRequest {\n  transformation_id: string\n}\n\nexport interface InsightCreationResponse {\n  status: 'pending'\n  message: string\n  source_id: string\n  transformation_id: string\n  command_id?: string\n}\n\nexport interface CommandJobStatusResponse {\n  job_id: string\n  status: string\n  result?: Record<string, unknown>\n  error_message?: string\n}\n\nexport const insightsApi = {\n  listForSource: async (sourceId: string) => {\n    const response = await apiClient.get<SourceInsightResponse[]>(`/sources/${sourceId}/insights`)\n    return response.data\n  },\n\n  get: async (insightId: string) => {\n    const response = await apiClient.get<SourceInsightResponse>(`/insights/${insightId}`)\n    return response.data\n  },\n\n  create: async (sourceId: string, data: CreateSourceInsightRequest) => {\n    const response = await apiClient.post<InsightCreationResponse>(\n      `/sources/${sourceId}/insights`,\n      data\n    )\n    return response.data\n  },\n\n  delete: async (insightId: string) => {\n    await apiClient.delete(`/insights/${insightId}`)\n  },\n\n  getCommandStatus: async (commandId: string) => {\n    const response = await apiClient.get<CommandJobStatusResponse>(\n      `/commands/jobs/${commandId}`\n    )\n    return response.data\n  },\n\n  /**\n   * Poll command status until completed or failed.\n   * Returns true if completed successfully, false if failed.\n   */\n  waitForCommand: async (\n    commandId: string,\n    options?: { maxAttempts?: number; intervalMs?: number }\n  ): Promise<boolean> => {\n    const maxAttempts = options?.maxAttempts ?? 60 // Default 60 attempts\n    const intervalMs = options?.intervalMs ?? 2000 // Default 2 seconds\n\n    for (let i = 0; i < maxAttempts; i++) {\n      try {\n        const status = await insightsApi.getCommandStatus(commandId)\n        if (status.status === 'completed') {\n          return true\n        }\n        if (status.status === 'failed' || status.status === 'canceled') {\n          console.error('Command failed:', status.error_message)\n          return false\n        }\n        // Still running, wait and retry\n        await new Promise(resolve => setTimeout(resolve, intervalMs))\n      } catch (error) {\n        console.error('Error checking command status:', error)\n        // Continue polling on error\n        await new Promise(resolve => setTimeout(resolve, intervalMs))\n      }\n    }\n    // Timeout\n    console.warn('Command polling timed out')\n    return false\n  }\n}"
  },
  {
    "path": "frontend/src/lib/api/models.ts",
    "content": "import apiClient from './client'\nimport {\n  Model,\n  CreateModelRequest,\n  ModelDefaults,\n  ProviderAvailability,\n  DiscoveredModel,\n  ProviderSyncResult,\n  AllProvidersSyncResult,\n  ProviderModelCount,\n  AutoAssignResult,\n  ModelTestResult,\n} from '@/lib/types/models'\n\nexport const modelsApi = {\n  list: async () => {\n    const response = await apiClient.get<Model[]>('/models')\n    return response.data\n  },\n\n  get: async (id: string) => {\n    const response = await apiClient.get<Model>(`/models/${id}`)\n    return response.data\n  },\n\n  create: async (data: CreateModelRequest) => {\n    const response = await apiClient.post<Model>('/models', data)\n    return response.data\n  },\n\n  delete: async (id: string) => {\n    await apiClient.delete(`/models/${id}`)\n  },\n\n  getDefaults: async () => {\n    const response = await apiClient.get<ModelDefaults>('/models/defaults')\n    return response.data\n  },\n\n  updateDefaults: async (data: Partial<ModelDefaults>) => {\n    const response = await apiClient.put<ModelDefaults>('/models/defaults', data)\n    return response.data\n  },\n\n  getProviders: async () => {\n    const response = await apiClient.get<ProviderAvailability>('/models/providers')\n    return response.data\n  },\n\n  // Model Discovery API\n  /**\n   * Discover available models from a provider without registering them\n   */\n  discoverModels: async (provider: string) => {\n    const response = await apiClient.get<DiscoveredModel[]>(`/models/discover/${provider}`)\n    return response.data\n  },\n\n  /**\n   * Sync models for a specific provider (discover and register)\n   */\n  syncProvider: async (provider: string) => {\n    const response = await apiClient.post<ProviderSyncResult>(`/models/sync/${provider}`)\n    return response.data\n  },\n\n  /**\n   * Sync models for all configured providers\n   */\n  syncAll: async () => {\n    const response = await apiClient.post<AllProvidersSyncResult>('/models/sync')\n    return response.data\n  },\n\n  /**\n   * Get count of registered models for a provider\n   */\n  getProviderModelCount: async (provider: string) => {\n    const response = await apiClient.get<ProviderModelCount>(`/models/count/${provider}`)\n    return response.data\n  },\n\n  /**\n   * Get all models for a specific provider\n   */\n  getByProvider: async (provider: string) => {\n    const response = await apiClient.get<Model[]>(`/models/by-provider/${provider}`)\n    return response.data\n  },\n\n  /**\n   * Auto-assign default models based on available models\n   */\n  autoAssign: async () => {\n    const response = await apiClient.post<AutoAssignResult>('/models/auto-assign')\n    return response.data\n  },\n\n  /**\n   * Test an individual model configuration\n   */\n  testModel: async (modelId: string): Promise<ModelTestResult> => {\n    const response = await apiClient.post<ModelTestResult>(`/models/${modelId}/test`)\n    return response.data\n  },\n}"
  },
  {
    "path": "frontend/src/lib/api/notebooks.ts",
    "content": "import apiClient from './client'\nimport {\n  NotebookResponse,\n  CreateNotebookRequest,\n  UpdateNotebookRequest,\n  NotebookDeletePreview,\n  NotebookDeleteResponse,\n} from '@/lib/types/api'\n\nexport const notebooksApi = {\n  list: async (params?: { archived?: boolean; order_by?: string }) => {\n    const response = await apiClient.get<NotebookResponse[]>('/notebooks', { params })\n    return response.data\n  },\n\n  get: async (id: string) => {\n    const response = await apiClient.get<NotebookResponse>(`/notebooks/${id}`)\n    return response.data\n  },\n\n  create: async (data: CreateNotebookRequest) => {\n    const response = await apiClient.post<NotebookResponse>('/notebooks', data)\n    return response.data\n  },\n\n  update: async (id: string, data: UpdateNotebookRequest) => {\n    const response = await apiClient.put<NotebookResponse>(`/notebooks/${id}`, data)\n    return response.data\n  },\n\n  deletePreview: async (id: string) => {\n    const response = await apiClient.get<NotebookDeletePreview>(\n      `/notebooks/${id}/delete-preview`\n    )\n    return response.data\n  },\n\n  delete: async (id: string, deleteExclusiveSources: boolean = false) => {\n    const response = await apiClient.delete<NotebookDeleteResponse>(`/notebooks/${id}`, {\n      params: { delete_exclusive_sources: deleteExclusiveSources },\n    })\n    return response.data\n  },\n\n  addSource: async (notebookId: string, sourceId: string) => {\n    const response = await apiClient.post(`/notebooks/${notebookId}/sources/${sourceId}`)\n    return response.data\n  },\n\n  removeSource: async (notebookId: string, sourceId: string) => {\n    const response = await apiClient.delete(`/notebooks/${notebookId}/sources/${sourceId}`)\n    return response.data\n  },\n}"
  },
  {
    "path": "frontend/src/lib/api/notes.ts",
    "content": "import apiClient from './client'\nimport { NoteResponse, CreateNoteRequest, UpdateNoteRequest } from '@/lib/types/api'\n\nexport const notesApi = {\n  list: async (params?: { notebook_id?: string }) => {\n    const response = await apiClient.get<NoteResponse[]>('/notes', { params })\n    return response.data\n  },\n\n  get: async (id: string) => {\n    const response = await apiClient.get<NoteResponse>(`/notes/${id}`)\n    return response.data\n  },\n\n  create: async (data: CreateNoteRequest) => {\n    const response = await apiClient.post<NoteResponse>('/notes', data)\n    return response.data\n  },\n\n  update: async (id: string, data: UpdateNoteRequest) => {\n    const response = await apiClient.put<NoteResponse>(`/notes/${id}`, data)\n    return response.data\n  },\n\n  delete: async (id: string) => {\n    await apiClient.delete(`/notes/${id}`)\n  }\n}"
  },
  {
    "path": "frontend/src/lib/api/podcasts.ts",
    "content": "import apiClient from './client'\nimport { getApiUrl } from '@/lib/config'\nimport {\n  PodcastEpisode,\n  EpisodeProfile,\n  SpeakerProfile,\n  Language,\n  PodcastGenerationRequest,\n  PodcastGenerationResponse,\n} from '@/lib/types/podcasts'\n\nexport type EpisodeProfileInput = Omit<EpisodeProfile, 'id'>\nexport type SpeakerProfileInput = Omit<SpeakerProfile, 'id'>\n\nexport async function resolvePodcastAssetUrl(path?: string | null): Promise<string | undefined> {\n  if (!path) {\n    return undefined\n  }\n\n  if (/^https?:\\/\\//i.test(path)) {\n    return path\n  }\n\n  const base = await getApiUrl()\n\n  if (path.startsWith('/')) {\n    return `${base}${path}`\n  }\n\n  return `${base}/${path}`\n}\n\nexport const podcastsApi = {\n  listEpisodes: async () => {\n    const response = await apiClient.get<PodcastEpisode[]>('/podcasts/episodes')\n    return response.data\n  },\n\n  deleteEpisode: async (episodeId: string) => {\n    await apiClient.delete(`/podcasts/episodes/${episodeId}`)\n  },\n\n  retryEpisode: async (episodeId: string) => {\n    const response = await apiClient.post<{ job_id: string; message: string }>(\n      `/podcasts/episodes/${episodeId}/retry`\n    )\n    return response.data\n  },\n\n  listEpisodeProfiles: async () => {\n    const response = await apiClient.get<EpisodeProfile[]>('/episode-profiles')\n    return response.data\n  },\n\n  createEpisodeProfile: async (payload: EpisodeProfileInput) => {\n    const response = await apiClient.post<EpisodeProfile>(\n      '/episode-profiles',\n      payload\n    )\n    return response.data\n  },\n\n  updateEpisodeProfile: async (profileId: string, payload: EpisodeProfileInput) => {\n    const response = await apiClient.put<EpisodeProfile>(\n      `/episode-profiles/${profileId}`,\n      payload\n    )\n    return response.data\n  },\n\n  deleteEpisodeProfile: async (profileId: string) => {\n    await apiClient.delete(`/episode-profiles/${profileId}`)\n  },\n\n  duplicateEpisodeProfile: async (profileId: string) => {\n    const response = await apiClient.post<EpisodeProfile>(\n      `/episode-profiles/${profileId}/duplicate`\n    )\n    return response.data\n  },\n\n  listSpeakerProfiles: async () => {\n    const response = await apiClient.get<SpeakerProfile[]>('/speaker-profiles')\n    return response.data\n  },\n\n  createSpeakerProfile: async (payload: SpeakerProfileInput) => {\n    const response = await apiClient.post<SpeakerProfile>(\n      '/speaker-profiles',\n      payload\n    )\n    return response.data\n  },\n\n  updateSpeakerProfile: async (profileId: string, payload: SpeakerProfileInput) => {\n    const response = await apiClient.put<SpeakerProfile>(\n      `/speaker-profiles/${profileId}`,\n      payload\n    )\n    return response.data\n  },\n\n  deleteSpeakerProfile: async (profileId: string) => {\n    await apiClient.delete(`/speaker-profiles/${profileId}`)\n  },\n\n  duplicateSpeakerProfile: async (profileId: string) => {\n    const response = await apiClient.post<SpeakerProfile>(\n      `/speaker-profiles/${profileId}/duplicate`\n    )\n    return response.data\n  },\n\n  generatePodcast: async (payload: PodcastGenerationRequest) => {\n    const response = await apiClient.post<PodcastGenerationResponse>(\n      '/podcasts/generate',\n      payload\n    )\n    return response.data\n  },\n\n  listLanguages: async () => {\n    const response = await apiClient.get<Language[]>('/languages')\n    return response.data\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/api/query-client.ts",
    "content": "import { QueryClient } from '@tanstack/react-query'\n\nexport const queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 5 * 60 * 1000, // 5 minutes\n      gcTime: 10 * 60 * 1000, // 10 minutes\n      retry: 2,\n      refetchOnWindowFocus: false,\n    },\n    mutations: {\n      retry: 1,\n    },\n  },\n})\n\nexport const QUERY_KEYS = {\n  notebooks: ['notebooks'] as const,\n  notebook: (id: string) => ['notebooks', id] as const,\n  notes: (notebookId?: string) => ['notes', notebookId] as const,\n  note: (id: string) => ['notes', id] as const,\n  sources: (notebookId?: string) => ['sources', notebookId] as const,\n  sourcesInfinite: (notebookId: string) => ['sources', 'infinite', notebookId] as const,\n  source: (id: string) => ['sources', id] as const,\n  settings: ['settings'] as const,\n  sourceChatSessions: (sourceId: string) => ['source-chat', sourceId, 'sessions'] as const,\n  sourceChatSession: (sourceId: string, sessionId: string) => ['source-chat', sourceId, 'sessions', sessionId] as const,\n  notebookChatSessions: (notebookId: string) => ['notebook-chat', notebookId, 'sessions'] as const,\n  notebookChatSession: (sessionId: string) => ['notebook-chat', 'sessions', sessionId] as const,\n  podcastEpisodes: ['podcasts', 'episodes'] as const,\n  podcastEpisode: (episodeId: string) => ['podcasts', 'episodes', episodeId] as const,\n  episodeProfiles: ['podcasts', 'episode-profiles'] as const,\n  speakerProfiles: ['podcasts', 'speaker-profiles'] as const,\n  languages: ['languages'] as const,\n}\n"
  },
  {
    "path": "frontend/src/lib/api/search.ts",
    "content": "import apiClient from './client'\nimport { SearchRequest, SearchResponse, AskRequest } from '@/lib/types/search'\n\nexport const searchApi = {\n  // Standard search (non-streaming)\n  search: async (params: SearchRequest) => {\n    const response = await apiClient.post<SearchResponse>('/search', params)\n    return response.data\n  },\n\n  // Ask with streaming (uses relative URL for Docker compatibility)\n  askKnowledgeBase: async (params: AskRequest) => {\n    // Get auth token using the same logic as apiClient interceptor\n    let token = null\n    if (typeof window !== 'undefined') {\n      const authStorage = localStorage.getItem('auth-storage')\n      if (authStorage) {\n        try {\n          const { state } = JSON.parse(authStorage)\n          if (state?.token) {\n            token = state.token\n          }\n        } catch (error) {\n          console.error('Error parsing auth storage:', error)\n        }\n      }\n    }\n\n    // Use relative URL to leverage Next.js rewrites\n    // This works both in dev (Next.js proxy) and production (Docker network)\n    const url = '/api/search/ask'\n\n    // Use fetch with ReadableStream for SSE\n    const response = await fetch(url, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        ...(token && { Authorization: `Bearer ${token}` })\n      },\n      body: JSON.stringify(params)\n    })\n\n    if (!response.ok) {\n      // Try to extract error message from response\n      let errorMessage = `HTTP error! status: ${response.status}`\n      try {\n        const errorData = await response.json()\n        errorMessage = errorData.detail || errorData.message || errorMessage\n      } catch {\n        // If response isn't JSON, use status text\n        errorMessage = response.statusText || errorMessage\n      }\n      throw new Error(errorMessage)\n    }\n\n    if (!response.body) {\n      throw new Error('No response body received')\n    }\n\n    return response.body\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/api/settings.ts",
    "content": "import apiClient from './client'\nimport { SettingsResponse } from '@/lib/types/api'\n\nexport const settingsApi = {\n  get: async () => {\n    const response = await apiClient.get<SettingsResponse>('/settings')\n    return response.data\n  },\n\n  update: async (data: Partial<SettingsResponse>) => {\n    const response = await apiClient.put<SettingsResponse>('/settings', data)\n    return response.data\n  }\n}"
  },
  {
    "path": "frontend/src/lib/api/source-chat.ts",
    "content": "import apiClient from './client'\nimport {\n  SourceChatSession,\n  SourceChatSessionWithMessages,\n  CreateSourceChatSessionRequest,\n  UpdateSourceChatSessionRequest,\n  SendMessageRequest\n} from '@/lib/types/api'\n\nexport const sourceChatApi = {\n  // Session management\n  createSession: async (sourceId: string, data: Omit<CreateSourceChatSessionRequest, 'source_id'>) => {\n    // Extract clean ID without \"source:\" prefix for the request body\n    const cleanId = sourceId.startsWith('source:') ? sourceId.slice(7) : sourceId\n    const response = await apiClient.post<SourceChatSession>(\n      `/sources/${sourceId}/chat/sessions`,\n      { ...data, source_id: cleanId }  // Include source_id in the request body\n    )\n    return response.data\n  },\n\n  listSessions: async (sourceId: string) => {\n    const response = await apiClient.get<SourceChatSession[]>(\n      `/sources/${sourceId}/chat/sessions`\n    )\n    return response.data\n  },\n\n  getSession: async (sourceId: string, sessionId: string) => {\n    const response = await apiClient.get<SourceChatSessionWithMessages>(\n      `/sources/${sourceId}/chat/sessions/${sessionId}`\n    )\n    return response.data\n  },\n\n  updateSession: async (sourceId: string, sessionId: string, data: UpdateSourceChatSessionRequest) => {\n    const response = await apiClient.put<SourceChatSession>(\n      `/sources/${sourceId}/chat/sessions/${sessionId}`,\n      data\n    )\n    return response.data\n  },\n\n  deleteSession: async (sourceId: string, sessionId: string) => {\n    await apiClient.delete(`/sources/${sourceId}/chat/sessions/${sessionId}`)\n  },\n\n  // Messaging with streaming\n  sendMessage: (sourceId: string, sessionId: string, data: SendMessageRequest) => {\n    // Get auth token using the same logic as apiClient interceptor\n    let token = null\n    if (typeof window !== 'undefined') {\n      const authStorage = localStorage.getItem('auth-storage')\n      if (authStorage) {\n        try {\n          const { state } = JSON.parse(authStorage)\n          if (state?.token) {\n            token = state.token\n          }\n        } catch (error) {\n          console.error('Error parsing auth storage:', error)\n        }\n      }\n    }\n\n    // Use relative URL to leverage Next.js rewrites\n    // This works both in dev (Next.js proxy) and production (Docker network)\n    const url = `/api/sources/${sourceId}/chat/sessions/${sessionId}/messages`\n\n    // Use fetch with ReadableStream for SSE\n    return fetch(url, {\n      method: 'POST',\n      headers: {\n        'Content-Type': 'application/json',\n        ...(token && { 'Authorization': `Bearer ${token}` })\n      },\n      body: JSON.stringify(data)\n    }).then(response => {\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`)\n      }\n      return response.body\n    })\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/api/sources.ts",
    "content": "import type { AxiosResponse } from 'axios'\n\nimport apiClient from './client'\nimport { \n  SourceListResponse, \n  SourceDetailResponse, \n  SourceResponse,\n  SourceStatusResponse,\n  CreateSourceRequest, \n  UpdateSourceRequest \n} from '@/lib/types/api'\n\nexport const sourcesApi = {\n  list: async (params?: {\n    notebook_id?: string\n    limit?: number\n    offset?: number\n    sort_by?: 'created' | 'updated'\n    sort_order?: 'asc' | 'desc'\n  }) => {\n    const response = await apiClient.get<SourceListResponse[]>('/sources', { params })\n    return response.data\n  },\n\n  get: async (id: string) => {\n    const response = await apiClient.get<SourceDetailResponse>(`/sources/${id}`)\n    return response.data\n  },\n\n  create: async (data: CreateSourceRequest & { file?: File }) => {\n    // Always use FormData to match backend expectations\n    const formData = new FormData()\n    \n    // Add basic fields\n    formData.append('type', data.type)\n    \n    if (data.notebooks !== undefined) {\n      formData.append('notebooks', JSON.stringify(data.notebooks))\n    }\n    if (data.notebook_id) {\n      formData.append('notebook_id', data.notebook_id)\n    }\n    if (data.title) {\n      formData.append('title', data.title)\n    }\n    if (data.url) {\n      formData.append('url', data.url)\n    }\n    if (data.content) {\n      formData.append('content', data.content)\n    }\n    if (data.transformations !== undefined) {\n      formData.append('transformations', JSON.stringify(data.transformations))\n    }\n    \n    const dataWithFile = data as CreateSourceRequest & { file?: File }\n    if (dataWithFile.file instanceof File) {\n      formData.append('file', dataWithFile.file)\n    }\n    \n    formData.append('embed', String(data.embed ?? false))\n    formData.append('delete_source', String(data.delete_source ?? false))\n    formData.append('async_processing', String(data.async_processing ?? false))\n    \n    const response = await apiClient.post<SourceResponse>('/sources', formData)\n    return response.data\n  },\n\n  update: async (id: string, data: UpdateSourceRequest) => {\n    const response = await apiClient.put<SourceListResponse>(`/sources/${id}`, data)\n    return response.data\n  },\n\n  delete: async (id: string) => {\n    await apiClient.delete(`/sources/${id}`)\n  },\n\n  status: async (id: string) => {\n    const response = await apiClient.get<SourceStatusResponse>(`/sources/${id}/status`)\n    return response.data\n  },\n\n  upload: async (file: File, notebook_id: string) => {\n    const formData = new FormData()\n    formData.append('file', file)\n    formData.append('notebook_id', notebook_id)\n    formData.append('type', 'upload')\n    formData.append('async_processing', 'true')\n    \n    const response = await apiClient.post<SourceResponse>('/sources', formData, {\n      headers: {\n        'Content-Type': 'multipart/form-data',\n      },\n    })\n    return response.data\n  },\n\n  retry: async (id: string) => {\n    const response = await apiClient.post<SourceResponse>(`/sources/${id}/retry`)\n    return response.data\n  },\n\n  downloadFile: async (id: string): Promise<AxiosResponse<Blob>> => {\n    return apiClient.get(`/sources/${id}/download`, {\n      responseType: 'blob',\n    })\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/api/transformations.ts",
    "content": "import apiClient from './client'\nimport {\n  Transformation,\n  CreateTransformationRequest,\n  UpdateTransformationRequest,\n  ExecuteTransformationRequest,\n  ExecuteTransformationResponse,\n  DefaultPrompt\n} from '@/lib/types/transformations'\n\nexport const transformationsApi = {\n  list: async () => {\n    const response = await apiClient.get<Transformation[]>('/transformations')\n    return response.data\n  },\n\n  get: async (id: string) => {\n    const response = await apiClient.get<Transformation>(`/transformations/${id}`)\n    return response.data\n  },\n\n  create: async (data: CreateTransformationRequest) => {\n    const response = await apiClient.post<Transformation>('/transformations', data)\n    return response.data\n  },\n\n  update: async (id: string, data: UpdateTransformationRequest) => {\n    const response = await apiClient.put<Transformation>(`/transformations/${id}`, data)\n    return response.data\n  },\n\n  delete: async (id: string) => {\n    await apiClient.delete(`/transformations/${id}`)\n  },\n\n  execute: async (data: ExecuteTransformationRequest) => {\n    const response = await apiClient.post<ExecuteTransformationResponse>('/transformations/execute', data)\n    return response.data\n  },\n\n  getDefaultPrompt: async () => {\n    const response = await apiClient.get<DefaultPrompt>('/transformations/default-prompt')\n    return response.data\n  },\n\n  updateDefaultPrompt: async (prompt: { transformation_instructions: string }) => {\n    const response = await apiClient.put<DefaultPrompt>('/transformations/default-prompt', prompt)\n    return response.data\n  }\n}"
  },
  {
    "path": "frontend/src/lib/config.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'\nimport { getApiUrl, resetConfig } from './config'\n\ndescribe('Config Priority', () => {\n  const originalEnv = process.env\n  const originalFetch = global.fetch\n  const fetchMock = vi.fn()\n\n  beforeEach(() => {\n    vi.resetModules()\n    resetConfig()\n    process.env = { ...originalEnv }\n    fetchMock.mockReset()\n    global.fetch = fetchMock\n  })\n\n  afterEach(() => {\n    process.env = originalEnv\n    global.fetch = originalFetch\n  })\n\n  it('should prioritize runtime config over everything else', async () => {\n    // Setup: Env var set, Runtime config returns explicit value\n    process.env.NEXT_PUBLIC_API_URL = 'http://env-url.com'\n    \n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ apiUrl: 'http://runtime-url.com' }),\n    } as Response)\n\n    // Mock the second fetch call (api/config check)\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ version: '1.0.0' }),\n    } as Response)\n\n    const url = await getApiUrl()\n    expect(url).toBe('http://runtime-url.com')\n  })\n\n  it('should fall back to env var if runtime config returns empty/null', async () => {\n    // Setup: Env var set, Runtime config returns empty string (simulating not set)\n    process.env.NEXT_PUBLIC_API_URL = 'http://env-url.com'\n    \n    // First fetch: /config returns empty apiUrl\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ apiUrl: '' }),\n    } as Response)\n\n    // Second fetch: api/config check using env url\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ version: '1.0.0' }),\n    } as Response)\n\n    const url = await getApiUrl()\n    expect(url).toBe('http://env-url.com')\n  })\n\n  it('should fall back to env var if runtime config returns empty object', async () => {\n    // Setup: Env var set, Runtime config returns empty object\n    process.env.NEXT_PUBLIC_API_URL = 'http://env-url.com'\n    \n    // First fetch: /config returns {}\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({}), // Missing apiUrl\n    } as Response)\n\n    // Second fetch: api/config check using env url\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ version: '1.0.0' }),\n    } as Response)\n\n    const url = await getApiUrl()\n    expect(url).toBe('http://env-url.com')\n  })\n\n  it('should use default (relative path) if both runtime and env are missing', async () => {\n    // Setup: Env var NOT set, Runtime config returns empty\n    delete process.env.NEXT_PUBLIC_API_URL\n    \n    // First fetch: /config returns empty\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ apiUrl: '' }),\n    } as Response)\n\n    // Second fetch: api/config check using default relative path\n    fetchMock.mockResolvedValueOnce({\n      ok: true,\n      json: async () => ({ version: '1.0.0' }),\n    } as Response)\n\n    const url = await getApiUrl()\n    expect(url).toBe('')\n  })\n})\n"
  },
  {
    "path": "frontend/src/lib/config.ts",
    "content": "/**\n * Runtime configuration for the frontend.\n * This allows the same Docker image to work in different environments.\n */\n\nimport { AppConfig, BackendConfigResponse } from '@/lib/types/config'\n\n// Build timestamp for debugging - set at build time\nconst BUILD_TIME = new Date().toISOString()\n\nlet config: AppConfig | null = null\nlet configPromise: Promise<AppConfig> | null = null\n\n/**\n * Get the API URL to use for requests.\n *\n * Priority:\n * 1. Runtime config from API server (/api/config endpoint)\n * 2. Environment variable (NEXT_PUBLIC_API_URL)\n * 3. Default fallback (http://localhost:5055)\n */\nexport async function getApiUrl(): Promise<string> {\n  // If we already have config, return it\n  if (config) {\n    return config.apiUrl\n  }\n\n  // If we're already fetching, wait for that\n  if (configPromise) {\n    const cfg = await configPromise\n    return cfg.apiUrl\n  }\n\n  // Start fetching config\n  configPromise = fetchConfig()\n  const cfg = await configPromise\n  return cfg.apiUrl\n}\n\n/**\n * Get the full configuration.\n */\nexport async function getConfig(): Promise<AppConfig> {\n  if (config) {\n    return config\n  }\n\n  if (configPromise) {\n    return await configPromise\n  }\n\n  configPromise = fetchConfig()\n  return await configPromise\n}\n\n/**\n * Fetch configuration from the API or use defaults.\n */\nasync function fetchConfig(): Promise<AppConfig> {\n  const isDev = process.env.NODE_ENV === 'development'\n  \n  if (isDev) {\n    console.log('🔧 [Config] Starting configuration detection...')\n    console.log('🔧 [Config] Build time:', BUILD_TIME)\n  }\n\n  // STEP 1: Try to get runtime config from Next.js server-side endpoint\n  // This allows API_URL to be set at runtime (not baked into build)\n  // Note: Endpoint is at /config (not /api/config) to avoid reverse proxy conflicts\n  let runtimeApiUrl: string | null = null\n  try {\n    if (isDev) console.log('🔧 [Config] Attempting to fetch runtime config from /config endpoint...')\n    const runtimeResponse = await fetch('/config', {\n      cache: 'no-store',\n    })\n    if (runtimeResponse.ok) {\n      const runtimeData = await runtimeResponse.json()\n      runtimeApiUrl = runtimeData.apiUrl\n      // Treat empty string as \"not set\" to allow fallback to env var or default\n      if (runtimeApiUrl === '') {\n        runtimeApiUrl = null\n      }\n      if (isDev) console.log('✅ [Config] Runtime API URL from server:', runtimeApiUrl)\n    } else {\n      if (isDev) console.log('⚠️ [Config] Runtime config endpoint returned status:', runtimeResponse.status)\n    }\n  } catch (error) {\n    if (isDev) console.log('⚠️ [Config] Could not fetch runtime config:', error)\n  }\n\n  // STEP 2: Fallback to build-time environment variable\n  const envApiUrl = process.env.NEXT_PUBLIC_API_URL\n  if (isDev) console.log('🔧 [Config] NEXT_PUBLIC_API_URL from build:', envApiUrl || '(not set)')\n\n  // STEP 3: Smart default - prefer relative path to use Next.js Rewrites\n  // This avoids CORS issues and port mapping complexities by proxying through Next.js\n  const defaultApiUrl = ''\n\n  if (typeof window !== 'undefined' && isDev) {\n      console.log('🔧 [Config] Using relative path (rewrites) as default')\n  }\n\n  // Priority: Runtime config > Build-time env var > Smart default\n  // Note: runtimeApiUrl must be checked against null explicitly as empty string might be valid if intended (though we treat '' as null above)\n  const baseUrl = runtimeApiUrl !== null && runtimeApiUrl !== undefined ? runtimeApiUrl : (envApiUrl || defaultApiUrl)\n  if (isDev) {\n    console.log('🔧 [Config] Final base URL to try:', baseUrl)\n    console.log('🔧 [Config] Selection priority: runtime=' + (runtimeApiUrl ? '✅' : '❌') +\n                ', build-time=' + (envApiUrl ? '✅' : '❌') +\n                ', smart-default=' + (!runtimeApiUrl && !envApiUrl ? '✅' : '❌'))\n  }\n\n  try {\n    if (isDev) console.log('🔧 [Config] Fetching backend config from:', `${baseUrl}/api/config`)\n    // Try to fetch runtime config from backend API\n    const response = await fetch(`${baseUrl}/api/config`, {\n      cache: 'no-store',\n    })\n\n    if (response.ok) {\n      const data: BackendConfigResponse = await response.json()\n      config = {\n        apiUrl: baseUrl, // Use baseUrl from runtime-config (Python no longer returns this)\n        version: data.version || 'unknown',\n        buildTime: BUILD_TIME,\n        latestVersion: data.latestVersion || null,\n        hasUpdate: data.hasUpdate || false,\n        dbStatus: data.dbStatus, // Can be undefined for old backends\n      }\n      if (isDev) console.log('✅ [Config] Successfully loaded API config:', config)\n      return config\n    } else {\n      // Don't log error here - ConnectionGuard will display it\n      throw new Error(`API config endpoint returned status ${response.status}`)\n    }\n  } catch (error) {\n    // Don't log error here - ConnectionGuard will display it with proper UI\n    throw error\n  }\n}\n\n/**\n * Reset the configuration cache (useful for testing).\n */\nexport function resetConfig(): void {\n  config = null\n  configPromise = null\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/CLAUDE.md",
    "content": "# Hooks Module\n\nReact hooks for API data fetching, state management, and complex workflows (chat, streaming, file handling).\n\n## Key Components\n\n- **Query hooks** (`useNotebookSources`, `useSource`, `useSources`): TanStack Query wrappers for source data with infinite scroll and refetch strategies\n- **Mutation hooks** (`useCreateSource`, `useUpdateSource`, `useDeleteSource`, `useFileUpload`, `useRetrySource`): Server mutations with toast notifications and cache invalidation\n- **Chat hooks** (`useNotebookChat`, `useSourceChat`): Complex session management, context building, and message streaming\n- **Streaming hooks** (`useAsk`): SSE parsing for multi-stage Ask workflows (strategy → answers → final answer)\n- **Model/config hooks** (`useModels`, `useSettings`, `useTransformations`): Application-level settings and model management\n- **Utility hooks** (`useMediaQuery`, `useToast`, `useNavigation`, `useAuth`): UI state and auth checking\n- **i18n hook** (`useTranslation`): Proxy-based translation access with `t.section.key` pattern and language switching\n\n## Important Patterns\n\n- **TanStack Query integration**: All data hooks use `useQuery`/`useMutation` with `QUERY_KEYS` for cache consistency\n- **Optimistic updates**: Mutations add local state before server response (e.g., notebook chat messages)\n- **Cache invalidation**: Broad invalidation of query keys on mutations (e.g., `['sources']` catches all source queries)\n- **Auto-refetch on return**: `refetchOnWindowFocus: true` on frequently-changing data (sources, notebooks)\n- **Manual refetch controls**: Hooks return `refetch()` for parent components to trigger refresh\n- **SSE streaming pattern**: `useAsk` manually parses newline-delimited JSON from `/api/search/ask`; handles incomplete buffers\n- **Status polling**: `useSourceStatus` auto-refetches every 2s while `status === 'running' | 'queued' | 'new'`\n- **Context building**: `useNotebookChat.buildContext()` assembles selected sources + notes with token/char counts\n- **i18n Proxy pattern**: `useTranslation` returns `t` object with Proxy; access `t.section.key` instead of `t('section.key')`\n\n## Key Dependencies\n\n- `@tanstack/react-query`: Data fetching and caching\n- `sonner`: Toast notifications\n- `@/lib/api/*`: API module exports (sourcesApi, chatApi, searchApi, etc.)\n- `@/lib/types/api`: TypeScript response types\n- Zustand stores: `useAuthStore`, modal managers\n\n## How to Add New Hooks\n\n1. **Data queries**: Create `useQuery` hook wrapping API call; use `QUERY_KEYS.entityName(id)` for cache key\n2. **Mutations**: Create `useMutation` hook with `onSuccess` cache invalidation + toast feedback\n3. **Complex state**: Use `useState` + callbacks for local state (see `useAsk`, `useNotebookChat`)\n4. **Return shape**: Export object with both state and action functions for composability\n\n## Important Quirks & Gotchas\n\n- **Cache invalidation breadth**: Invalidating `['sources']` affects ALL source queries; be precise if performance matters\n- **Optimistic updates + error handling**: `useNotebookChat` removes optimistic messages on error; ensure cleanup\n- **SSE buffer handling**: `useAsk` keeps incomplete lines in buffer between reads; incomplete JSON silently skipped\n- **Model override timing**: `useNotebookChat` stores pending model override if no session exists; applied on session creation\n- **Pagination cursor**: `useNotebookSources` uses offset-based pagination; `nextOffset` calculated from page size\n- **Status polling race**: `useSourceStatus` may refetch stale data before server catches up; retry logic has 3-attempt limit\n- **Keyboard trap in dialogs**: Some hooks manage modal state; ensure Dialog/Modal components handle escape key properly\n- **Form data handling**: `useFileUpload` and source creation convert JSON fields to strings in FormData\n- **useTranslation depth limit**: Proxy limits nesting to 4 levels; deeper access returns path string as fallback\n- **useTranslation loop detection**: >1000 accesses to same key in 1s triggers error and breaks recursion\n\n## Testing Patterns\n\n```typescript\n// Mock API\nconst mockApi = {\n  list: vi.fn().mockResolvedValue([...])\n}\n\n// Test hook with QueryClientProvider + wrapper\nrender(<Component />, { wrapper: QueryClientProvider })\n\n// Assert mutations trigger cache invalidation\nawait waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalled())\n```\n\n## Credentials Hooks (`use-credentials.ts`)\n\nHooks for managing AI provider credentials with TanStack Query integration, toast notifications, and cache invalidation.\n\n### Query Keys\n\n```typescript\nexport const CREDENTIAL_QUERY_KEYS = {\n  all: ['credentials'] as const,\n  status: ['credentials', 'status'] as const,\n  envStatus: ['credentials', 'env-status'] as const,\n  byProvider: (provider: string) => ['credentials', 'provider', provider] as const,\n  detail: (id: string) => ['credentials', id] as const,\n}\n```\n\n### Query Hooks\n\n| Hook | Description | Returns |\n|------|-------------|---------|\n| `useCredentialStatus()` | Get configuration status of all providers | `{ configured, source, encryption_configured }` |\n| `useEnvStatus()` | Get which providers have env vars set | `{ [provider]: boolean }` |\n| `useCredentials(provider?)` | List all credentials (optional filter) | `Credential[]` |\n| `useCredentialsByProvider(provider)` | List credentials for a specific provider | `Credential[]` |\n| `useCredential(credentialId)` | Get a specific credential | `Credential` |\n\n### Mutation Hooks\n\n| Hook | Description | Cache Invalidation |\n|------|-------------|-------------------|\n| `useCreateCredential()` | Create new credential | `all`, `providers` |\n| `useUpdateCredential()` | Update credential | `all`, `providers` |\n| `useDeleteCredential()` | Delete credential | `all`, `models`, `providers` |\n| `useTestCredential()` | Test credential connection | None (stores result locally) |\n| `useDiscoverModels()` | Discover models for credential | None |\n| `useRegisterModels()` | Register discovered models | `models`, `all` |\n| `useMigrateFromEnv()` | Migrate from env vars | `status`, `envStatus`, `models`, `providers` |\n| `useMigrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `status`, `envStatus`, `models`, `providers` |\n\n### useTestCredential Details\n\nReturns extended interface with local state management for test results:\n\n```typescript\nconst {\n  testCredential,        // (credentialId: string) => void\n  testCredentialAsync,   // (credentialId: string) => Promise<TestConnectionResult>\n  isPending,             // boolean\n  testResults,           // Record<string, TestConnectionResult>\n  clearResult,           // (credentialId: string) => void\n} = useTestCredential()\n```\n\n### Cache Invalidation Strategy\n\nAll mutation hooks invalidate:\n- `CREDENTIAL_QUERY_KEYS.all` — refreshes all credential queries (cascades to filtered queries)\n- `MODEL_QUERY_KEYS.providers` — refreshes provider list\n\nDelete hook additionally invalidates:\n- `MODEL_QUERY_KEYS.models` — refreshes full model list (linked models may be deleted)\n\nMigration hooks additionally invalidate:\n- `CREDENTIAL_QUERY_KEYS.status` — refreshes configured/source info\n- `CREDENTIAL_QUERY_KEYS.envStatus` — refreshes env var status\n\n### Usage Example\n\n```typescript\nimport {\n  useCredentialStatus,\n  useCredentials,\n  useCreateCredential,\n  useTestCredential,\n  useMigrateFromEnv\n} from '@/lib/hooks/use-credentials'\n\nfunction CredentialSettings() {\n  const { data: status, isLoading } = useCredentialStatus()\n  const { data: credentials } = useCredentials()\n  const createCredential = useCreateCredential()\n  const { testCredential, testResults, isPending } = useTestCredential()\n  const migrateFromEnv = useMigrateFromEnv()\n\n  const handleCreate = () => {\n    createCredential.mutate({\n      name: 'My OpenAI Key',\n      provider: 'openai',\n      modalities: ['language', 'embedding'],\n      api_key: 'sk-...'\n    })\n  }\n\n  const handleTest = (credentialId: string) => {\n    testCredential(credentialId)\n  }\n\n  const handleMigrate = () => {\n    migrateFromEnv.mutate()\n  }\n\n  return (\n    <div>\n      {credentials?.map(cred => (\n        <div key={cred.id}>\n          <span>{cred.name} ({cred.provider})</span>\n          <button onClick={() => handleTest(cred.id)} disabled={isPending}>Test</button>\n          {testResults[cred.id]?.success && <span>Connected!</span>}\n        </div>\n      ))}\n      <button onClick={handleCreate}>Add Credential</button>\n      <button onClick={handleMigrate}>Migrate from .env</button>\n    </div>\n  )\n}\n```\n\n### Important Notes\n\n- **Toast notifications**: All mutations show success/error toasts automatically\n- **i18n integration**: Toast messages use translation keys from `t.apiKeys.*` and `t.common.*`\n- **Error handling**: Uses `getApiErrorKey()` utility to extract error messages from API responses\n- **Local test results**: `useTestCredential` stores results in local state (not cached in TanStack Query)\n- **Migration feedback**: Migration hooks show different toasts based on migrated/skipped/error counts\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-ask.ts",
    "content": "'use client'\n\nimport { useState, useCallback } from 'react'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorMessage } from '@/lib/utils/error-handler'\nimport { searchApi } from '@/lib/api/search'\nimport { AskStreamEvent } from '@/lib/types/search'\n\ninterface AskModels {\n  strategy: string\n  answer: string\n  finalAnswer: string\n}\n\ninterface StrategyData {\n  reasoning: string\n  searches: Array<{ term: string; instructions: string }>\n}\n\ninterface AskState {\n  isStreaming: boolean\n  strategy: StrategyData | null\n  answers: string[]\n  finalAnswer: string | null\n  error: string | null\n}\n\nexport function useAsk() {\n  const { t } = useTranslation()\n  const [state, setState] = useState<AskState>({\n    isStreaming: false,\n    strategy: null,\n    answers: [],\n    finalAnswer: null,\n    error: null\n  })\n\n  const sendAsk = useCallback(async (question: string, models: AskModels) => {\n    // Validate inputs\n    if (!question.trim()) {\n      toast.error(t('apiErrors.pleaseEnterQuestion'))\n      return\n    }\n\n    if (!models.strategy || !models.answer || !models.finalAnswer) {\n      toast.error(t('apiErrors.pleaseConfigureModels'))\n      return\n    }\n\n    // Reset state\n    setState({\n      isStreaming: true,\n      strategy: null,\n      answers: [],\n      finalAnswer: null,\n      error: null\n    })\n\n    try {\n      const response = await searchApi.askKnowledgeBase({\n        question,\n        strategy_model: models.strategy,\n        answer_model: models.answer,\n        final_answer_model: models.finalAnswer\n      })\n\n      if (!response) {\n        throw new Error('No response body received from server')\n      }\n\n      const reader = response.getReader()\n      const decoder = new TextDecoder()\n      let buffer = ''\n\n      while (true) {\n        const { done, value } = await reader.read()\n\n        if (done) {\n          break\n        }\n\n        buffer += decoder.decode(value, { stream: true })\n        const lines = buffer.split('\\n')\n\n        // Keep the last incomplete line in buffer\n        buffer = lines.pop() || ''\n\n        for (const line of lines) {\n          if (line.startsWith('data: ')) {\n            try {\n              const jsonStr = line.slice(6).trim()\n              if (!jsonStr) continue\n\n              const data: AskStreamEvent = JSON.parse(jsonStr)\n\n              if (data.type === 'strategy') {\n                setState(prev => ({\n                  ...prev,\n                  strategy: {\n                    reasoning: data.reasoning || '',\n                    searches: data.searches || []\n                  }\n                }))\n              } else if (data.type === 'answer') {\n                setState(prev => ({\n                  ...prev,\n                  answers: [...prev.answers, data.content || '']\n                }))\n              } else if (data.type === 'final_answer') {\n                setState(prev => ({\n                  ...prev,\n                  finalAnswer: data.content || '',\n                  isStreaming: false\n                }))\n              } else if (data.type === 'complete') {\n                setState(prev => ({\n                  ...prev,\n                  isStreaming: false\n                }))\n              } else if (data.type === 'error') {\n                throw new Error(data.message || 'Stream error occurred')\n              }\n            } catch (e) {\n              if (e instanceof SyntaxError) {\n                console.error('Error parsing SSE data:', e, 'Line:', line)\n                // Don't throw - continue processing other lines\n              } else {\n                throw e\n              }\n            }\n          }\n        }\n      }\n\n      // Ensure streaming is stopped\n      setState(prev => ({ ...prev, isStreaming: false }))\n\n    } catch (error) {\n      const err = error as { message?: string }\n      const errorMessage = err.message || 'An unexpected error occurred'\n      console.error('Ask error:', error)\n\n      setState(prev => ({\n        ...prev,\n        isStreaming: false,\n        error: errorMessage\n      }))\n\n      toast.error(t('apiErrors.askFailed'), {\n        description: getApiErrorMessage(errorMessage, (key) => t(key))\n      })\n    }\n  }, [t])\n\n  const reset = useCallback(() => {\n    setState({\n      isStreaming: false,\n      strategy: null,\n      answers: [],\n      finalAnswer: null,\n      error: null\n    })\n  }, [])\n\n  return {\n    ...state,\n    sendAsk,\n    reset\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-auth.ts",
    "content": "'use client'\n\nimport { useAuthStore } from '@/lib/stores/auth-store'\nimport { useRouter } from 'next/navigation'\nimport { useEffect } from 'react'\n\nexport function useAuth() {\n  const router = useRouter()\n  const {\n    isAuthenticated,\n    isLoading,\n    login,\n    logout,\n    checkAuth,\n    checkAuthRequired,\n    error,\n    hasHydrated,\n    authRequired\n  } = useAuthStore()\n\n  useEffect(() => {\n    // Only check auth after the store has hydrated from localStorage\n    if (hasHydrated) {\n      // First check if auth is required\n      if (authRequired === null) {\n        checkAuthRequired().then((required) => {\n          // If auth is required, check if we have valid credentials\n          if (required) {\n            checkAuth()\n          }\n        })\n      } else if (authRequired) {\n        // Auth is required, check credentials\n        checkAuth()\n      }\n      // If authRequired === false, we're already authenticated (set in checkAuthRequired)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [hasHydrated, authRequired])\n\n  const handleLogin = async (password: string) => {\n    const success = await login(password)\n    if (success) {\n      // Check if there's a stored redirect path\n      const redirectPath = sessionStorage.getItem('redirectAfterLogin')\n      if (redirectPath) {\n        sessionStorage.removeItem('redirectAfterLogin')\n        router.push(redirectPath)\n      } else {\n        router.push('/notebooks')\n      }\n    }\n    return success\n  }\n\n  const handleLogout = () => {\n    logout()\n    router.push('/login')\n  }\n\n  return {\n    isAuthenticated,\n    isLoading: isLoading || !hasHydrated, // Treat lack of hydration as loading\n    error,\n    login: handleLogin,\n    logout: handleLogout\n  }\n}"
  },
  {
    "path": "frontend/src/lib/hooks/use-create-dialogs.tsx",
    "content": "'use client'\n\nimport { createContext, useContext, useState, useCallback, ReactNode } from 'react'\nimport { AddSourceDialog } from '@/components/sources/AddSourceDialog'\nimport { CreateNotebookDialog } from '@/components/notebooks/CreateNotebookDialog'\nimport { GeneratePodcastDialog } from '@/components/podcasts/GeneratePodcastDialog'\n\ninterface CreateDialogsContextType {\n  openSourceDialog: () => void\n  openNotebookDialog: () => void\n  openPodcastDialog: () => void\n}\n\nconst CreateDialogsContext = createContext<CreateDialogsContextType | null>(null)\n\nexport function CreateDialogsProvider({ children }: { children: ReactNode }) {\n  const [sourceDialogOpen, setSourceDialogOpen] = useState(false)\n  const [notebookDialogOpen, setNotebookDialogOpen] = useState(false)\n  const [podcastDialogOpen, setPodcastDialogOpen] = useState(false)\n\n  const openSourceDialog = useCallback(() => setSourceDialogOpen(true), [])\n  const openNotebookDialog = useCallback(() => setNotebookDialogOpen(true), [])\n  const openPodcastDialog = useCallback(() => setPodcastDialogOpen(true), [])\n\n  return (\n    <CreateDialogsContext.Provider\n      value={{\n        openSourceDialog,\n        openNotebookDialog,\n        openPodcastDialog,\n      }}\n    >\n      {children}\n      <AddSourceDialog open={sourceDialogOpen} onOpenChange={setSourceDialogOpen} />\n      <CreateNotebookDialog open={notebookDialogOpen} onOpenChange={setNotebookDialogOpen} />\n      <GeneratePodcastDialog open={podcastDialogOpen} onOpenChange={setPodcastDialogOpen} />\n    </CreateDialogsContext.Provider>\n  )\n}\n\nexport function useCreateDialogs() {\n  const context = useContext(CreateDialogsContext)\n  if (!context) {\n    throw new Error('useCreateDialogs must be used within a CreateDialogsProvider')\n  }\n  return context\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-credentials.ts",
    "content": "import { useState } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport {\n  credentialsApi,\n  CreateCredentialRequest,\n  UpdateCredentialRequest,\n  TestConnectionResult,\n  RegisterModelData,\n} from '@/lib/api/credentials'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { MODEL_QUERY_KEYS } from '@/lib/hooks/use-models'\n\nexport const CREDENTIAL_QUERY_KEYS = {\n  all: ['credentials'] as const,\n  status: ['credentials', 'status'] as const,\n  envStatus: ['credentials', 'env-status'] as const,\n  byProvider: (provider: string) => ['credentials', 'provider', provider] as const,\n  detail: (id: string) => ['credentials', id] as const,\n}\n\n/**\n * Hook to get the configuration status of all providers\n */\nexport function useCredentialStatus() {\n  return useQuery({\n    queryKey: CREDENTIAL_QUERY_KEYS.status,\n    queryFn: () => credentialsApi.getStatus(),\n  })\n}\n\n/**\n * Hook to get the environment variable status\n */\nexport function useEnvStatus() {\n  return useQuery({\n    queryKey: CREDENTIAL_QUERY_KEYS.envStatus,\n    queryFn: () => credentialsApi.getEnvStatus(),\n  })\n}\n\n/**\n * Hook to list all credentials\n */\nexport function useCredentials(provider?: string) {\n  return useQuery({\n    queryKey: provider ? CREDENTIAL_QUERY_KEYS.byProvider(provider) : CREDENTIAL_QUERY_KEYS.all,\n    queryFn: () => credentialsApi.list(provider),\n  })\n}\n\n/**\n * Hook to list credentials for a specific provider.\n * Uses the same list endpoint with provider filter for cache consistency.\n */\nexport function useCredentialsByProvider(provider: string) {\n  return useQuery({\n    queryKey: CREDENTIAL_QUERY_KEYS.byProvider(provider),\n    queryFn: () => credentialsApi.list(provider),\n    enabled: !!provider,\n  })\n}\n\n/**\n * Hook to get a specific credential\n */\nexport function useCredential(credentialId: string) {\n  return useQuery({\n    queryKey: CREDENTIAL_QUERY_KEYS.detail(credentialId),\n    queryFn: () => credentialsApi.get(credentialId),\n    enabled: !!credentialId,\n  })\n}\n\n/**\n * Hook to create a new credential\n */\nexport function useCreateCredential() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateCredentialRequest) => credentialsApi.create(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })\n      toast({\n        title: t.common.success,\n        description: t.apiKeys.configSaveSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to update a credential\n */\nexport function useUpdateCredential() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      credentialId,\n      data,\n    }: {\n      credentialId: string\n      data: UpdateCredentialRequest\n    }) => credentialsApi.update(credentialId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })\n      toast({\n        title: t.common.success,\n        description: t.apiKeys.configUpdateSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to delete a credential\n */\nexport function useDeleteCredential() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      credentialId,\n      options,\n    }: {\n      credentialId: string\n      options?: { delete_models?: boolean; migrate_to?: string }\n    }) => credentialsApi.delete(credentialId, options),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })\n      toast({\n        title: t.common.success,\n        description: t.apiKeys.configDeleteSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to test a credential's connection\n */\nexport function useTestCredential() {\n  const { toast } = useToast()\n  const { t } = useTranslation()\n  const [testResults, setTestResults] = useState<Record<string, TestConnectionResult>>({})\n\n  const mutation = useMutation({\n    mutationFn: (credentialId: string) => credentialsApi.test(credentialId),\n    onSuccess: (result, credentialId) => {\n      setTestResults(prev => ({ ...prev, [credentialId]: result }))\n      if (result.success) {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.testSuccess,\n        })\n      } else {\n        toast({\n          title: t.common.error,\n          description: result.message || t.apiKeys.testFailed,\n          variant: 'destructive',\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.apiKeys.testFailed),\n        variant: 'destructive',\n      })\n    },\n  })\n\n  return {\n    testCredential: mutation.mutate,\n    testCredentialAsync: mutation.mutateAsync,\n    isPending: mutation.isPending,\n    testResults,\n    clearResult: (credentialId: string) => {\n      setTestResults(prev => {\n        const { [credentialId]: _removed, ...rest } = prev\n        return rest\n      })\n    },\n  }\n}\n\n/**\n * Hook to discover models for a credential\n */\nexport function useDiscoverModels() {\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (credentialId: string) => credentialsApi.discover(credentialId),\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.apiKeys.syncFailed),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to register discovered models\n */\nexport function useRegisterModels() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      credentialId,\n      models,\n    }: {\n      credentialId: string\n      models: RegisterModelData[]\n    }) => credentialsApi.registerModels(credentialId, { models }),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n\n      if (result.created > 0) {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.syncSuccess\n            .replace('{discovered}', (result.created + result.existing).toString())\n            .replace('{new}', result.created.toString()),\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.syncNoNew.replace('{count}', result.existing.toString()),\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.apiKeys.syncFailed),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to migrate from environment variables\n */\nexport function useMigrateFromEnv() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: () => credentialsApi.migrateFromEnv(),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })\n\n      const migratedCount = result.migrated.length\n      const errorCount = result.errors?.length ?? 0\n\n      if (errorCount > 0 && migratedCount === 0) {\n        toast({\n          title: t.common.error,\n          description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),\n          variant: 'destructive',\n        })\n      } else if (migratedCount > 0 && errorCount > 0) {\n        toast({\n          title: t.common.success,\n          description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,\n        })\n      } else if (migratedCount > 0) {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.migrationNothingToMigrate,\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\n/**\n * Hook to migrate from ProviderConfig\n */\nexport function useMigrateFromProviderConfig() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: () => credentialsApi.migrateFromProviderConfig(),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })\n      queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })\n\n      const migratedCount = result.migrated.length\n      const errorCount = result.errors?.length ?? 0\n\n      if (errorCount > 0 && migratedCount === 0) {\n        toast({\n          title: t.common.error,\n          description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),\n          variant: 'destructive',\n        })\n      } else if (migratedCount > 0 && errorCount > 0) {\n        toast({\n          title: t.common.success,\n          description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,\n        })\n      } else if (migratedCount > 0) {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.apiKeys.migrationNothingToMigrate,\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-insights.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\nimport { insightsApi } from '@/lib/api/insights'\n\nexport function useInsight(id: string, options?: { enabled?: boolean }) {\n  return useQuery({\n    queryKey: ['insights', id],\n    queryFn: () => insightsApi.get(id),\n    enabled: options?.enabled !== false && !!id,\n    staleTime: 30 * 1000, // 30 seconds\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-media-query.ts",
    "content": "'use client'\n\nimport { useState, useEffect } from 'react'\n\n/**\n * Hook to detect if viewport matches a media query.\n * Returns false during SSR to avoid hydration mismatches.\n */\nexport function useMediaQuery(query: string): boolean {\n  const [matches, setMatches] = useState(false)\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(query)\n    setMatches(mediaQuery.matches)\n\n    const handler = (event: MediaQueryListEvent) => {\n      setMatches(event.matches)\n    }\n\n    mediaQuery.addEventListener('change', handler)\n    return () => mediaQuery.removeEventListener('change', handler)\n  }, [query])\n\n  return matches\n}\n\n/**\n * Returns true if viewport is >= 1024px (Tailwind's 'lg' breakpoint)\n */\nexport function useIsDesktop(): boolean {\n  return useMediaQuery('(min-width: 1024px)')\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-modal-manager.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\nimport { renderHook, act } from '@testing-library/react'\nimport { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { useModalManager } from './use-modal-manager'\nimport { useRouter, useSearchParams, usePathname } from 'next/navigation'\n\n// Mock next/navigation\nvi.mock('next/navigation', () => ({\n  useRouter: vi.fn(),\n  useSearchParams: vi.fn(),\n  usePathname: vi.fn(),\n}))\n\ndescribe('useModalManager', () => {\n  const pushMock = vi.fn()\n  const pathnameMock = '/test-path'\n  \n  beforeEach(() => {\n    vi.mocked(useRouter).mockReturnValue({ push: pushMock } as any)\n    vi.mocked(usePathname).mockReturnValue(pathnameMock)\n    pushMock.mockClear()\n  })\n\n  it('should return null modal state when no params present', () => {\n    vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams() as any)\n    const { result } = renderHook(() => useModalManager())\n    \n    expect(result.current.modalType).toBeNull()\n    expect(result.current.modalId).toBeNull()\n    expect(result.current.isOpen).toBe(false)\n  })\n\n  it('should read modal state from URL params', () => {\n    vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('modal=note&id=123') as any)\n    const { result } = renderHook(() => useModalManager())\n    \n    expect(result.current.modalType).toBe('note')\n    expect(result.current.modalId).toBe('123')\n    expect(result.current.isOpen).toBe(true)\n  })\n\n  it('should call router.push when opening a modal', () => {\n    vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams() as any)\n    const { result } = renderHook(() => useModalManager())\n    \n    act(() => {\n      result.current.openModal('source', 'abc')\n    })\n    \n    expect(pushMock).toHaveBeenCalledWith('/test-path?modal=source&id=abc', { scroll: false })\n  })\n\n  it('should call router.push when closing a modal', () => {\n    vi.mocked(useSearchParams).mockReturnValue(new URLSearchParams('modal=note&id=123') as any)\n    const { result } = renderHook(() => useModalManager())\n    \n    act(() => {\n      result.current.closeModal()\n    })\n    \n    expect(pushMock).toHaveBeenCalledWith('/test-path?', { scroll: false })\n  })\n})\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-modal-manager.ts",
    "content": "'use client'\n\nimport { useRouter, useSearchParams, usePathname } from 'next/navigation'\n\nexport type ModalType = 'source' | 'note' | 'insight'\n\nexport function useModalManager() {\n  const router = useRouter()\n  const searchParams = useSearchParams()\n  const pathname = usePathname()\n\n  // Read current modal state from URL params\n  const modalType = searchParams?.get('modal') as ModalType | null\n  const modalId = searchParams?.get('id')\n\n  /**\n   * Open a modal by updating URL params without navigation\n   * @param type - Type of modal to open (source, note, insight)\n   * @param id - ID of the content to display\n   */\n  const openModal = (type: ModalType, id: string) => {\n    const params = new URLSearchParams(searchParams?.toString() || '')\n    params.set('modal', type)\n    params.set('id', id)\n    // Use scroll: false to prevent page from scrolling when modal state changes\n    router.push(`${pathname}?${params.toString()}`, { scroll: false })\n  }\n\n  /**\n   * Close the currently open modal by removing modal params from URL\n   */\n  const closeModal = () => {\n    const params = new URLSearchParams(searchParams?.toString() || '')\n    params.delete('modal')\n    params.delete('id')\n    router.push(`${pathname}?${params.toString()}`, { scroll: false })\n  }\n\n  return {\n    modalType,\n    modalId,\n    openModal,\n    closeModal,\n    isOpen: !!modalType && !!modalId\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-models.ts",
    "content": "import { useState, useCallback } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { modelsApi } from '@/lib/api/models'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { CreateModelRequest, ModelDefaults, ModelTestResult } from '@/lib/types/models'\n\nexport const MODEL_QUERY_KEYS = {\n  models: ['models'] as const,\n  model: (id: string) => ['models', id] as const,\n  defaults: ['models', 'defaults'] as const,\n  providers: ['models', 'providers'] as const,\n}\n\nexport function useModels() {\n  return useQuery({\n    queryKey: MODEL_QUERY_KEYS.models,\n    queryFn: () => modelsApi.list(),\n  })\n}\n\nexport function useModel(id: string) {\n  return useQuery({\n    queryKey: MODEL_QUERY_KEYS.model(id),\n    queryFn: () => modelsApi.get(id),\n    enabled: !!id,\n  })\n}\n\nexport function useCreateModel() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateModelRequest) => modelsApi.create(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      toast({\n        title: t.common.success,\n        description: t.models.saveSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteModel() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (id: string) => modelsApi.delete(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })\n      queryClient.invalidateQueries({ queryKey: ['credentials'] })\n      toast({\n        title: t.common.success,\n        description: t.models.deleteSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useModelDefaults() {\n  return useQuery({\n    queryKey: MODEL_QUERY_KEYS.defaults,\n    queryFn: () => modelsApi.getDefaults(),\n  })\n}\n\nexport function useUpdateModelDefaults() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: Partial<ModelDefaults>) => modelsApi.updateDefaults(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })\n      toast({\n        title: t.common.success,\n        description: t.models.saveSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useProviders() {\n  return useQuery({\n    queryKey: MODEL_QUERY_KEYS.providers,\n    queryFn: () => modelsApi.getProviders(),\n  })\n}\n\nexport function useAutoAssignDefaults() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: () => modelsApi.autoAssign(),\n    onSuccess: (result) => {\n      queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })\n\n      const assignedCount = Object.keys(result.assigned).length\n      const missingCount = result.missing.length\n\n      if (assignedCount > 0) {\n        toast({\n          title: t.common.success,\n          description: t.models.autoAssignSuccess.replace('{count}', assignedCount.toString()),\n        })\n      } else if (missingCount > 0) {\n        toast({\n          title: t.common.warning,\n          description: t.models.autoAssignNoModels,\n          variant: 'destructive',\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.models.autoAssignAlreadySet,\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useTestModel() {\n  const [testResult, setTestResult] = useState<ModelTestResult | null>(null)\n  const [testedModelName, setTestedModelName] = useState('')\n  const [testingModelId, setTestingModelId] = useState<string | null>(null)\n\n  const mutation = useMutation({\n    mutationFn: (modelId: string) => modelsApi.testModel(modelId),\n    onSuccess: (result) => {\n      setTestResult(result)\n      setTestingModelId(null)\n    },\n    onError: (error: unknown) => {\n      const msg = error instanceof Error ? error.message : String(error)\n      setTestResult({ success: false, message: msg })\n      setTestingModelId(null)\n    },\n  })\n\n  const testModel = useCallback((modelId: string, modelName: string) => {\n    setTestedModelName(modelName)\n    setTestingModelId(modelId)\n    setTestResult(null)\n    mutation.mutate(modelId)\n  }, [mutation])\n\n  const clearResult = useCallback(() => {\n    setTestResult(null)\n    setTestedModelName('')\n    setTestingModelId(null)\n  }, [])\n\n  return {\n    testModel,\n    isPending: mutation.isPending,\n    testingModelId,\n    testResult,\n    testedModelName,\n    clearResult,\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-navigation.ts",
    "content": "import { useNavigationStore } from '@/lib/stores/navigation-store'\n\nexport function useNavigation() {\n  const store = useNavigationStore()\n\n  return {\n    setReturnTo: store.setReturnTo,\n    clearReturnTo: store.clearReturnTo,\n    getReturnPath: store.getReturnPath,\n    getReturnLabel: store.getReturnLabel,\n    returnTo: store.returnTo\n  }\n}"
  },
  {
    "path": "frontend/src/lib/hooks/use-notebooks.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { notebooksApi } from '@/lib/api/notebooks'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { CreateNotebookRequest, UpdateNotebookRequest } from '@/lib/types/api'\n\nexport function useNotebooks(archived?: boolean) {\n  return useQuery({\n    queryKey: [...QUERY_KEYS.notebooks, { archived }],\n    queryFn: () => notebooksApi.list({ archived, order_by: 'updated desc' }),\n  })\n}\n\nexport function useNotebook(id: string) {\n  return useQuery({\n    queryKey: QUERY_KEYS.notebook(id),\n    queryFn: () => notebooksApi.get(id),\n    enabled: !!id,\n  })\n}\n\nexport function useCreateNotebook() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateNotebookRequest) => notebooksApi.create(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.createSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: t(getApiErrorKey(error, t.common.error)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateNotebook() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: UpdateNotebookRequest }) =>\n      notebooksApi.update(id, data),\n    onSuccess: (_, { id }) => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebook(id) })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.updateSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: t(getApiErrorKey(error, t.common.error)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useNotebookDeletePreview(id: string, enabled: boolean = false) {\n  return useQuery({\n    queryKey: [...QUERY_KEYS.notebook(id), 'delete-preview'],\n    queryFn: () => notebooksApi.deletePreview(id),\n    enabled: !!id && enabled,\n  })\n}\n\nexport function useDeleteNotebook() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      id,\n      deleteExclusiveSources = false,\n    }: {\n      id: string\n      deleteExclusiveSources?: boolean\n    }) => notebooksApi.delete(id, deleteExclusiveSources),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })\n      // Also invalidate sources since some may have been deleted\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.deleteSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: t(getApiErrorKey(error, t.common.error)),\n        variant: 'destructive',\n      })\n    },\n  })\n}"
  },
  {
    "path": "frontend/src/lib/hooks/use-notes.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { notesApi } from '@/lib/api/notes'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { CreateNoteRequest, UpdateNoteRequest } from '@/lib/types/api'\n\nexport function useNotes(notebookId?: string) {\n  return useQuery({\n    queryKey: QUERY_KEYS.notes(notebookId),\n    queryFn: () => notesApi.list({ notebook_id: notebookId }),\n    enabled: !!notebookId,\n  })\n}\n\nexport function useNote(id?: string, options?: { enabled?: boolean }) {\n  const noteId = id ?? ''\n  return useQuery({\n    queryKey: QUERY_KEYS.note(noteId),\n    queryFn: () => notesApi.get(noteId),\n    enabled: !!noteId && (options?.enabled ?? true),\n  })\n}\n\nexport function useCreateNote() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateNoteRequest) => notesApi.create(data),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({ \n        queryKey: QUERY_KEYS.notes(variables.notebook_id) \n      })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.noteCreatedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.notebooks.failedToCreateNote),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateNote() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: UpdateNoteRequest }) =>\n      notesApi.update(id, data),\n    onSuccess: (_, { id }) => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notes() })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.note(id) })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.noteUpdatedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.notebooks.failedToUpdateNote),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteNote() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (id: string) => notesApi.delete(id),\n    onSuccess: () => {\n      // Invalidate all notes queries (with and without notebook IDs)\n      queryClient.invalidateQueries({ queryKey: ['notes'] })\n      toast({\n        title: t.common.success,\n        description: t.notebooks.noteDeletedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.notebooks.failedToDeleteNote),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-podcasts.ts",
    "content": "import { useMemo } from 'react'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\n\nimport { podcastsApi, EpisodeProfileInput, SpeakerProfileInput } from '@/lib/api/podcasts'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport {\n  ACTIVE_EPISODE_STATUSES,\n  EpisodeProfile,\n  EpisodeStatusGroups,\n  PodcastEpisode,\n  PodcastGenerationRequest,\n  groupEpisodesByStatus,\n  speakerUsageMap,\n} from '@/lib/types/podcasts'\n\nexport function useLanguages() {\n  return useQuery({\n    queryKey: QUERY_KEYS.languages,\n    queryFn: podcastsApi.listLanguages,\n    staleTime: Infinity,\n  })\n}\n\ninterface EpisodeStatusCounts {\n  total: number\n  running: number\n  completed: number\n  failed: number\n  pending: number\n}\n\nfunction hasActiveEpisodes(episodes: PodcastEpisode[]) {\n  return episodes.some((episode) => {\n    const status = episode.job_status ?? 'unknown'\n    return ACTIVE_EPISODE_STATUSES.includes(status)\n  })\n}\n\nexport function usePodcastEpisodes(options?: { autoRefresh?: boolean }) {\n  const { autoRefresh = true } = options ?? {}\n\n  const query = useQuery({\n    queryKey: QUERY_KEYS.podcastEpisodes,\n    queryFn: podcastsApi.listEpisodes,\n    refetchInterval: (current) => {\n      if (!autoRefresh) {\n        return false\n      }\n\n      const data = current.state.data as PodcastEpisode[] | undefined\n      if (!data || data.length === 0) {\n        return false\n      }\n\n      return hasActiveEpisodes(data) ? 15_000 : false\n    },\n  })\n\n  const episodes = useMemo(() => query.data ?? [], [query.data])\n\n  const statusGroups = useMemo<EpisodeStatusGroups>(\n    () => groupEpisodesByStatus(episodes),\n    [episodes]\n  )\n\n  const statusCounts = useMemo<EpisodeStatusCounts>(\n    () => ({\n      total: episodes.length,\n      running: statusGroups.running.length,\n      completed: statusGroups.completed.length,\n      failed: statusGroups.failed.length,\n      pending: statusGroups.pending.length,\n    }),\n    [episodes.length, statusGroups]\n  )\n\n  const active = useMemo(() => hasActiveEpisodes(episodes), [episodes])\n\n  return {\n    ...query,\n    episodes,\n    statusGroups,\n    statusCounts,\n    hasActiveEpisodes: active,\n  }\n}\n\nexport function useRetryPodcastEpisode() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (episodeId: string) => podcastsApi.retryEpisode(episodeId),\n    onSuccess: async () => {\n      await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.retryStarted,\n        description: t.podcasts.retryStartedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToRetry,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeletePodcastEpisode() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (episodeId: string) => podcastsApi.deleteEpisode(episodeId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.episodeDeleted,\n        description: t.podcasts.episodeDeletedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToDeleteEpisode,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useEpisodeProfiles() {\n  const query = useQuery({\n    queryKey: QUERY_KEYS.episodeProfiles,\n    queryFn: podcastsApi.listEpisodeProfiles,\n  })\n\n  return {\n    ...query,\n    episodeProfiles: query.data ?? [],\n  }\n}\n\nexport function useCreateEpisodeProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (payload: EpisodeProfileInput) =>\n      podcastsApi.createEpisodeProfile(payload),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.profileCreated,\n        description: t.podcasts.profileCreatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToCreateProfile,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateEpisodeProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      profileId,\n      payload,\n    }: {\n      profileId: string\n      payload: EpisodeProfileInput\n    }) => podcastsApi.updateEpisodeProfile(profileId, payload),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.profileUpdated,\n        description: t.podcasts.profileUpdatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToUpdateProfile,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteEpisodeProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (profileId: string) => podcastsApi.deleteEpisodeProfile(profileId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.profileDeleted,\n        description: t.podcasts.profileDeletedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToDeleteProfile,\n        description: getApiErrorKey(error, t.podcasts.failedToDeleteProfileDesc),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDuplicateEpisodeProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (profileId: string) =>\n      podcastsApi.duplicateEpisodeProfile(profileId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.profileDuplicated,\n        description: t.podcasts.profileDuplicatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToDuplicateProfile,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useSpeakerProfiles(episodeProfiles?: EpisodeProfile[]) {\n  const query = useQuery({\n    queryKey: QUERY_KEYS.speakerProfiles,\n    queryFn: podcastsApi.listSpeakerProfiles,\n  })\n\n  const speakerProfiles = useMemo(() => query.data ?? [], [query.data])\n\n  const usage = useMemo(\n    () => speakerUsageMap(speakerProfiles, episodeProfiles),\n    [speakerProfiles, episodeProfiles]\n  )\n\n  return {\n    ...query,\n    speakerProfiles,\n    usage,\n  }\n}\n\nexport function useCreateSpeakerProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (payload: SpeakerProfileInput) =>\n      podcastsApi.createSpeakerProfile(payload),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.speakerCreated,\n        description: t.podcasts.speakerCreatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToCreateSpeaker,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateSpeakerProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({\n      profileId,\n      payload,\n    }: {\n      profileId: string\n      payload: SpeakerProfileInput\n    }) => podcastsApi.updateSpeakerProfile(profileId, payload),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.speakerUpdated,\n        description: t.podcasts.speakerUpdatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToUpdateSpeaker,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteSpeakerProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (profileId: string) => podcastsApi.deleteSpeakerProfile(profileId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.episodeProfiles })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.speakerDeleted,\n        description: t.podcasts.speakerDeletedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToDeleteSpeaker,\n        description: getApiErrorKey(error, t.podcasts.failedToDeleteSpeakerDesc),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDuplicateSpeakerProfile() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (profileId: string) =>\n      podcastsApi.duplicateSpeakerProfile(profileId),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.speakerProfiles })\n      toast({\n        title: t.podcasts.speakerDuplicated,\n        description: t.podcasts.speakerDuplicatedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToDuplicateSpeaker,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useGeneratePodcast() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (payload: PodcastGenerationRequest) =>\n      podcastsApi.generatePodcast(payload),\n    onSuccess: async (response) => {\n      // Immediately refetch to show the new episode\n      await queryClient.refetchQueries({ queryKey: QUERY_KEYS.podcastEpisodes })\n      toast({\n        title: t.podcasts.generationStarted,\n        description: t.podcasts.generationStartedDesc.replace('{name}', response.episode_name),\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.podcasts.failedToStartGeneration,\n        description: getApiErrorKey(error, t.podcasts.tryAgainMoment),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-search.ts",
    "content": "import { useMutation } from '@tanstack/react-query'\nimport { toast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { searchApi } from '@/lib/api/search'\nimport { SearchRequest } from '@/lib/types/search'\n\nexport function useSearch() {\n  const { t } = useTranslation()\n  return useMutation({\n    mutationFn: async (params: SearchRequest) => {\n      const response = await searchApi.search(params)\n\n      // Process results to add final_score\n      const processedResults = response.results.map(result => ({\n        ...result,\n        final_score: result.relevance ?? result.similarity ?? result.score ?? 0\n      }))\n\n      // Sort by final_score descending\n      processedResults.sort((a, b) => b.final_score - a.final_score)\n\n      return {\n        ...response,\n        results: processedResults\n      }\n    },\n    onError: (error: Error) => {\n      toast.error(t('apiErrors.searchFailed'), {\n        description: t(getApiErrorKey(error.message))\n      })\n    }\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-settings.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { settingsApi } from '@/lib/api/settings'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorKey } from '@/lib/utils/error-handler'\nimport { SettingsResponse } from '@/lib/types/api'\n\nexport function useSettings() {\n  return useQuery({\n    queryKey: QUERY_KEYS.settings,\n    queryFn: () => settingsApi.get(),\n  })\n}\n\nexport function useUpdateSettings() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: Partial<SettingsResponse>) => settingsApi.update(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.settings })\n      toast({\n        title: t.common.success,\n        description: t.common.saveSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorKey(error, t.common.error),\n        variant: 'destructive',\n      })\n    },\n  })\n}"
  },
  {
    "path": "frontend/src/lib/hooks/use-sources.ts",
    "content": "import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'\nimport { useCallback, useMemo } from 'react'\nimport { sourcesApi } from '@/lib/api/sources'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorMessage } from '@/lib/utils/error-handler'\nimport {\n  CreateSourceRequest,\n  UpdateSourceRequest,\n  SourceResponse,\n  SourceStatusResponse,\n  SourceListResponse\n} from '@/lib/types/api'\n\nconst NOTEBOOK_SOURCES_PAGE_SIZE = 30\n\nexport function useSources(notebookId?: string) {\n  return useQuery({\n    queryKey: QUERY_KEYS.sources(notebookId),\n    queryFn: () => sourcesApi.list({ notebook_id: notebookId }),\n    enabled: !!notebookId,\n    staleTime: 5 * 1000, // 5 seconds - more responsive for real-time source updates\n    refetchOnWindowFocus: true, // Refetch when user comes back to the tab\n  })\n}\n\n/**\n * Hook for fetching notebook sources with infinite scroll pagination.\n * Returns flattened sources array and pagination controls.\n */\nexport function useNotebookSources(notebookId: string) {\n  const queryClient = useQueryClient()\n\n  const query = useInfiniteQuery({\n    queryKey: QUERY_KEYS.sourcesInfinite(notebookId),\n    queryFn: async ({ pageParam = 0 }) => {\n      const data = await sourcesApi.list({\n        notebook_id: notebookId,\n        limit: NOTEBOOK_SOURCES_PAGE_SIZE,\n        offset: pageParam,\n        sort_by: 'updated',\n        sort_order: 'desc',\n      })\n      return {\n        sources: data,\n        nextOffset: data.length === NOTEBOOK_SOURCES_PAGE_SIZE ? pageParam + data.length : undefined,\n      }\n    },\n    initialPageParam: 0,\n    getNextPageParam: (lastPage) => lastPage.nextOffset,\n    enabled: !!notebookId,\n    staleTime: 5 * 1000,\n    refetchOnWindowFocus: true,\n  })\n\n  // Flatten all pages into a single array (memoized to prevent infinite re-renders)\n  const sources: SourceListResponse[] = useMemo(\n    () => query.data?.pages.flatMap(page => page.sources) ?? [],\n    [query.data?.pages]\n  )\n\n  // Refetch function that resets to first page\n  const refetch = useCallback(() => {\n    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sourcesInfinite(notebookId) })\n  }, [queryClient, notebookId])\n\n  return {\n    sources,\n    isLoading: query.isLoading,\n    isFetchingNextPage: query.isFetchingNextPage,\n    hasNextPage: query.hasNextPage,\n    fetchNextPage: query.fetchNextPage,\n    refetch,\n    error: query.error,\n  }\n}\n\nexport function useSource(id: string) {\n  return useQuery({\n    queryKey: QUERY_KEYS.source(id),\n    queryFn: () => sourcesApi.get(id),\n    enabled: !!id,\n    staleTime: 30 * 1000, // 30 seconds - shorter stale time for more responsive updates\n    refetchOnWindowFocus: true, // Refetch when user comes back to the tab\n  })\n}\n\nexport function useCreateSource() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateSourceRequest) => sourcesApi.create(data),\n    onSuccess: (result: SourceResponse, variables) => {\n      // Invalidate queries for all relevant notebooks with immediate refetch\n      if (variables.notebooks) {\n        variables.notebooks.forEach(notebookId => {\n          queryClient.invalidateQueries({\n            queryKey: QUERY_KEYS.sources(notebookId),\n            refetchType: 'active' // Refetch active queries immediately\n          })\n        })\n      } else if (variables.notebook_id) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.sources(variables.notebook_id),\n          refetchType: 'active'\n        })\n      }\n\n      // Invalidate general sources query too with immediate refetch\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.sources(),\n        refetchType: 'active'\n      })\n\n      // Show different messages based on processing mode\n      if (variables.async_processing) {\n        toast({\n          title: t.sources.sourceQueued,\n          description: t.sources.sourceQueuedDesc,\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.sources.sourceAddedSuccess,\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateSource() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: UpdateSourceRequest }) =>\n      sourcesApi.update(id, data),\n    onSuccess: (_, { id }) => {\n      // Invalidate ALL sources queries (both general and notebook-specific)\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) })\n      toast({\n        title: t.common.success,\n        description: t.sources.sourceUpdatedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteSource() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (id: string) => sourcesApi.delete(id),\n    onSuccess: (_, id) => {\n      // Invalidate ALL sources queries (both general and notebook-specific)\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      // Also invalidate the specific source\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(id) })\n      toast({\n        title: t.common.success,\n        description: t.sources.sourceDeletedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useFileUpload() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({ file, notebookId }: { file: File; notebookId: string }) =>\n      sourcesApi.upload(file, notebookId),\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({ \n        queryKey: QUERY_KEYS.sources(variables.notebookId) \n      })\n      toast({\n        title: t.common.success,\n        description: t.sources.fileUploadedSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useSourceStatus(sourceId: string, enabled = true) {\n  return useQuery({\n    queryKey: ['sources', sourceId, 'status'],\n    queryFn: () => sourcesApi.status(sourceId),\n    enabled: !!sourceId && enabled,\n    refetchInterval: (query) => {\n      // Auto-refresh every 2 seconds if processing\n      // The query.state.data contains the SourceStatusResponse\n      const data = query.state.data as SourceStatusResponse | undefined\n      if (data?.status === 'running' || data?.status === 'queued' || data?.status === 'new') {\n        return 2000\n      }\n      // No auto-refresh if completed, failed, or unknown\n      return false\n    },\n    staleTime: 0, // Always consider status data stale for real-time updates\n    retry: (failureCount, error) => {\n      // Don't retry on 404 (source not found)\n      const axiosError = error as { response?: { status?: number } }\n      if (axiosError?.response?.status === 404) {\n        return false\n      }\n      return failureCount < 3\n    },\n  })\n}\n\nexport function useRetrySource() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (sourceId: string) => sourcesApi.retry(sourceId),\n    onSuccess: (result, sourceId) => {\n      // Invalidate status query to refetch latest status\n      queryClient.invalidateQueries({\n        queryKey: ['sources', sourceId, 'status']\n      })\n      // Invalidate ALL sources queries to refresh the UI\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) })\n\n      toast({\n        title: t.sources.sourceRequeued,\n        description: t.sources.sourceRequeuedDesc,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useAddSourcesToNotebook() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: async ({ notebookId, sourceIds }: { notebookId: string; sourceIds: string[] }) => {\n      const { notebooksApi } = await import('@/lib/api/notebooks')\n\n      // Use Promise.allSettled to handle partial failures gracefully\n      const results = await Promise.allSettled(\n        sourceIds.map(sourceId => notebooksApi.addSource(notebookId, sourceId))\n      )\n\n      // Count successes and failures\n      const successes = results.filter(r => r.status === 'fulfilled').length\n      const failures = results.filter(r => r.status === 'rejected').length\n\n      return { successes, failures, total: sourceIds.length }\n    },\n    onSuccess: (result, { notebookId, sourceIds }) => {\n      // Invalidate ALL sources queries to refresh all lists\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      // Specifically invalidate the notebook's sources\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sources(notebookId) })\n      // Invalidate each affected source\n      sourceIds.forEach(sourceId => {\n        queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) })\n      })\n\n      // Show appropriate toast based on results\n      if (result.failures === 0) {\n        toast({\n          title: t.common.success,\n          description: t.sources.sourcesAddedToNotebook.replace('{count}', result.successes.toString()),\n        })\n      } else if (result.successes === 0) {\n        toast({\n          title: t.common.error,\n          description: t.sources.failedToAddSourcesToNotebook,\n          variant: 'destructive',\n        })\n      } else {\n        toast({\n          title: t.common.success,\n          description: t.sources.partialAddSuccess\n            .replace('{success}', result.successes.toString())\n            .replace('{failed}', result.failures.toString()),\n          variant: 'default',\n        })\n      }\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useRemoveSourceFromNotebook() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: async ({ notebookId, sourceId }: { notebookId: string; sourceId: string }) => {\n      // This will call the API we created\n      const { notebooksApi } = await import('@/lib/api/notebooks')\n      return notebooksApi.removeSource(notebookId, sourceId)\n    },\n    onSuccess: (_, { notebookId, sourceId }) => {\n      // Invalidate ALL sources queries to refresh all lists\n      queryClient.invalidateQueries({ queryKey: ['sources'] })\n      // Specifically invalidate the notebook's sources\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.sources(notebookId) })\n      // Also invalidate the specific source\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) })\n\n      toast({\n        title: t.common.success,\n        description: t.sources.sourceRemovedFromNotebook,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-toast.ts",
    "content": "import { toast as sonnerToast } from 'sonner'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\ntype ToastProps = {\n  title?: string\n  description?: string\n  variant?: 'default' | 'destructive'\n}\n\nexport function useToast() {\n  const { t } = useTranslation()\n\n  return {\n    toast: ({ title, description, variant = 'default' }: ToastProps) => {\n      if (variant === 'destructive') {\n        sonnerToast.error(title || t.common.error, {\n          description,\n        })\n      } else {\n        sonnerToast.success(title || t.common.success, {\n          description,\n        })\n      }\n    }\n  }\n}"
  },
  {
    "path": "frontend/src/lib/hooks/use-transformations.ts",
    "content": "import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { transformationsApi } from '@/lib/api/transformations'\nimport { useToast } from '@/lib/hooks/use-toast'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { getApiErrorMessage } from '@/lib/utils/error-handler'\nimport {\n  CreateTransformationRequest,\n  UpdateTransformationRequest,\n  ExecuteTransformationRequest\n} from '@/lib/types/transformations'\n\n// Add to QUERY_KEYS in query-client.ts\nexport const TRANSFORMATION_QUERY_KEYS = {\n  transformations: ['transformations'] as const,\n  transformation: (id: string) => ['transformations', id] as const,\n  defaultPrompt: ['transformations', 'default-prompt'] as const,\n}\n\nexport function useTransformations() {\n  return useQuery({\n    queryKey: TRANSFORMATION_QUERY_KEYS.transformations,\n    queryFn: () => transformationsApi.list(),\n  })\n}\n\nexport function useTransformation(id?: string, options?: { enabled?: boolean }) {\n  const transformationId = id ?? ''\n  return useQuery({\n    queryKey: TRANSFORMATION_QUERY_KEYS.transformation(transformationId),\n    queryFn: () => transformationsApi.get(transformationId),\n    enabled: !!transformationId && (options?.enabled ?? true),\n  })\n}\n\nexport function useCreateTransformation() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: CreateTransformationRequest) => transformationsApi.create(data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })\n      toast({\n        title: t.common.success,\n        description: t.transformations.createSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useUpdateTransformation() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: UpdateTransformationRequest }) =>\n      transformationsApi.update(id, data),\n    onSuccess: (_, { id }) => {\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformation(id) })\n      toast({\n        title: t.common.success,\n        description: t.transformations.updateSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDeleteTransformation() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (id: string) => transformationsApi.delete(id),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.transformations })\n      toast({\n        title: t.common.success,\n        description: t.transformations.deleteSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useExecuteTransformation() {\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (data: ExecuteTransformationRequest) => transformationsApi.execute(data),\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n\nexport function useDefaultPrompt() {\n  return useQuery({\n    queryKey: TRANSFORMATION_QUERY_KEYS.defaultPrompt,\n    queryFn: () => transformationsApi.getDefaultPrompt(),\n  })\n}\n\nexport function useUpdateDefaultPrompt() {\n  const queryClient = useQueryClient()\n  const { toast } = useToast()\n  const { t } = useTranslation()\n\n  return useMutation({\n    mutationFn: (prompt: { transformation_instructions: string }) => transformationsApi.updateDefaultPrompt(prompt),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: TRANSFORMATION_QUERY_KEYS.defaultPrompt })\n      toast({\n        title: t.common.success,\n        description: t.transformations.updateSuccess,\n      })\n    },\n    onError: (error: unknown) => {\n      toast({\n        title: t.common.error,\n        description: getApiErrorMessage(error, (key) => t(key)),\n        variant: 'destructive',\n      })\n    },\n  })\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-translation.test.ts",
    "content": "import { describe, it, expect, vi, beforeEach } from 'vitest'\nimport { renderHook, act } from '@testing-library/react'\n// Ensure we are testing the real implementation\nvi.unmock('@/lib/hooks/use-translation') \nimport { useTranslation } from './use-translation'\nimport { useTranslation as useI18nTranslation } from 'react-i18next'\n\n// Mock react-i18next is already done in setup.ts, \n// but we might need to control it per test\nvi.mock('react-i18next', () => ({\n  useTranslation: vi.fn()\n}))\n\ndescribe('useTranslation Hook', () => {\n  const changeLanguageMock = vi.fn()\n  \n  beforeEach(() => {\n    vi.clearAllMocks()\n    ;(useI18nTranslation as unknown as { mockReturnValue: (v: unknown) => void }).mockReturnValue({\n      t: (key: string) => {\n        if (key === 'common') return { appName: 'Open Notebook' }\n        if (key === 'common.appName') return 'Open Notebook'\n        return key\n      },\n      i18n: {\n        language: 'en-US',\n        changeLanguage: changeLanguageMock,\n      },\n    })\n  })\n\n  it('should return initial translations via proxy', () => {\n    const { result } = renderHook(() => useTranslation())\n    expect(result.current.language).toBe('en-US')\n    // Test the proxy behavior t.common.appName -> t(\"common.appName\")\n    expect(result.current.t.common.appName).toBe('Open Notebook')\n  })\n\n  it('should allow changing language via i18n.changeLanguage', () => {\n    const { result } = renderHook(() => useTranslation())\n    \n    act(() => {\n      result.current.setLanguage('zh-CN')\n    })\n    \n    expect(changeLanguageMock).toHaveBeenCalledWith('zh-CN')\n  })\n})\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-translation.ts",
    "content": "import { useTranslation as useI18nTranslation } from 'react-i18next'\nimport { useMemo, useCallback, useRef } from 'react'\nimport { emitLanguageChangeEnd, emitLanguageChangeStart } from '@/lib/i18n-events'\n\n/**\n * Custom useTranslation hook that provides a Proxy-based API for accessing translations.\n * \n * CRITICAL: The Proxy implementation must be carefully designed to avoid infinite loops\n * during language switching. Key safeguards:\n * 1. Strict depth limit (max 4 levels)\n * 2. Blocked properties list to prevent React/JS internals from triggering recursion\n * 3. Early return for missing keys\n * 4. Memoization with stable dependencies\n */\nexport function useTranslation() {\n  const { t: i18nTranslate, i18n } = useI18nTranslation()\n  \n  // Use a ref to track the current language to avoid unnecessary Proxy recreation\n  const languageRef = useRef(i18n.language)\n  languageRef.current = i18n.language\n  \n  // Loop detection\n  const accessCounts = useRef<Record<string, number>>({})\n  const lastResetTime = useRef(Date.now())\n\n  // High-performance Recursive Proxy with strict safety limits\n  const t = useMemo(() => {\n    const i18nTranslateCopy = i18nTranslate;\n    \n    // Set of properties to completely block from Proxy traversal\n    const BLOCKED_PROPS = new Set([\n      '__proto__', '__esModule', '$$typeof', 'toJSON', 'constructor',\n      'valueOf', 'toString', 'inspect', 'nodeType', 'tagName',\n      'then', 'catch', 'finally', // Promise methods\n      'prototype', 'caller', 'callee', 'arguments', // Function props\n      'Symbol(Symbol.toStringTag)', 'Symbol(Symbol.iterator)',\n    ]);\n    \n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    const createProxy = (path: string, depth: number = 0): any => {\n      // SAFETY: Strict depth limit to prevent stack overflow\n      if (depth > 3) {\n        return path; // Return the path string as fallback\n      }\n      \n      // Base function for t('key') or t.path({ options })\n      const proxyTarget = (keyOrOptions?: string | unknown, options?: unknown) => {\n        if (typeof keyOrOptions === 'string') {\n          const fullPath = path ? `${path}.${keyOrOptions}` : keyOrOptions;\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          return i18nTranslateCopy(fullPath, options as any);\n        }\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        return i18nTranslateCopy(path, keyOrOptions as any);\n      };\n\n      return new Proxy(proxyTarget, {\n        get(target, prop) {\n          // Reset counters every 1s\n          const now = Date.now()\n          if (now - lastResetTime.current > 1000) {\n            accessCounts.current = {}\n            lastResetTime.current = now\n          }\n\n          if (typeof prop === 'string') {\n             const key = path ? `${path}.${prop}` : prop;\n             accessCounts.current[key] = (accessCounts.current[key] || 0) + 1;\n             \n             if (accessCounts.current[key] > 1000) {\n               console.error(`[useTranslation] INFINITE LOOP DETECTED on key: \"${key}\". Breaking recursion.`);\n               return key; // Force break\n             }\n          }\n\n          // Handle Symbol properties immediately\n          if (typeof prop === 'symbol') {\n            // eslint-disable-next-line @typescript-eslint/no-explicit-any\n            return (target as any)[prop];\n          }\n\n          if (typeof prop !== 'string') return undefined;\n\n          // Block React internals and JS built-ins\n          if (prop.startsWith('__') || prop.startsWith('@@') || BLOCKED_PROPS.has(prop)) {\n            return undefined;\n          }\n\n          const currentPath = path ? `${path}.${prop}` : prop;\n\n          // Try to get the translation first (before checking target properties,\n          // since target is a function and has built-in properties like 'name'\n          // that would shadow translation keys)\n          const result = i18nTranslateCopy(currentPath, { returnObjects: true });\n\n          // If it's a leaf string, return it directly\n          if (typeof result === 'string') {\n            return result;\n          }\n\n          // Handle String.prototype methods on the current path\n          if (prop === 'replace' || prop === 'split' || prop === 'length' ||\n              prop === 'trim' || prop === 'toLowerCase' || prop === 'toUpperCase') {\n            const translated = i18nTranslateCopy(path);\n            if (typeof translated === 'string') {\n              // eslint-disable-next-line @typescript-eslint/no-explicit-any\n              const val = (translated as any)[prop];\n              return typeof val === 'function' ? val.bind(translated) : val;\n            }\n          }\n\n          // If i18n returned the key itself (meaning not found), stop recursion\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          if ((result as any) === currentPath || result === undefined || result === null) {\n            return currentPath; // Return path as fallback instead of continuing\n          }\n\n          // If it's an object (nested structure), continue with depth limit\n          if (typeof result === 'object') {\n            return createProxy(currentPath, depth + 1);\n          }\n\n          return result;\n        }\n      });\n    };\n\n    return createProxy('', 0);\n  }, [i18nTranslate])\n\n  const setLanguage = useCallback(async (lang: string) => {\n    if (lang === i18n.language) {\n      return i18n.language\n    }\n\n    emitLanguageChangeStart(lang)\n\n    try {\n      await i18n.changeLanguage(lang)\n      return i18n.language\n    } finally {\n      emitLanguageChangeEnd(lang)\n    }\n  }, [i18n])\n\n  return useMemo(() => ({ \n    // eslint-disable-next-line @typescript-eslint/no-explicit-any\n    t: t as any,\n    i18n,\n    language: i18n.language, \n    setLanguage \n  }), [t, i18n, setLanguage])\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/use-version-check.ts",
    "content": "import { useEffect, useRef } from 'react'\nimport { toast } from 'sonner'\nimport { getConfig } from '@/lib/config'\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\n/**\n * Hook to check for version updates and display notification.\n * Should be called once per session in the dashboard layout.\n * \n * Simplified implementation using a single useEffect with a ref guard.\n * The toast is displayed once when an update is available and the user\n * hasn't dismissed it in this session.\n */\nexport function useVersionCheck() {\n  const { t } = useTranslation()\n  const hasChecked = useRef(false)\n\n  useEffect(() => {\n    if (hasChecked.current) return\n    hasChecked.current = true\n\n    getConfig()\n      .then(config => {\n        if (!config.hasUpdate || !config.latestVersion) return\n\n        const dismissKey = `version_notification_dismissed_${config.latestVersion}`\n        if (sessionStorage.getItem(dismissKey)) return\n\n        toast.info(t.advanced.updateAvailable.replace('{version}', config.latestVersion), {\n          description: t.advanced.updateAvailableDesc,\n          duration: Infinity,\n          closeButton: true,\n          action: {\n            label: t.advanced.viewOnGithub,\n            onClick: () => window.open('https://github.com/lfnovo/open-notebook', '_blank'),\n          },\n          onDismiss: () => sessionStorage.setItem(dismissKey, 'true'),\n        })\n      })\n      .catch(() => {\n        // Silently fail - version check is non-critical\n      })\n  }, [t]) // t is still a dependency but only executes once due to ref guard\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/useNotebookChat.ts",
    "content": "'use client'\n\nimport { useState, useCallback, useEffect } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { toast } from 'sonner'\nimport { getApiErrorMessage } from '@/lib/utils/error-handler'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { chatApi } from '@/lib/api/chat'\nimport { QUERY_KEYS } from '@/lib/api/query-client'\nimport {\n  NotebookChatMessage,\n  CreateNotebookChatSessionRequest,\n  UpdateNotebookChatSessionRequest,\n  SourceListResponse,\n  NoteResponse\n} from '@/lib/types/api'\nimport { ContextSelections } from '@/app/(dashboard)/notebooks/[id]/page'\n\ninterface UseNotebookChatParams {\n  notebookId: string\n  sources: SourceListResponse[]\n  notes: NoteResponse[]\n  contextSelections: ContextSelections\n}\n\nexport function useNotebookChat({ notebookId, sources, notes, contextSelections }: UseNotebookChatParams) {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n  const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)\n  const [messages, setMessages] = useState<NotebookChatMessage[]>([])\n  const [isSending, setIsSending] = useState(false)\n  const [tokenCount, setTokenCount] = useState<number>(0)\n  const [charCount, setCharCount] = useState<number>(0)\n  // Pending model override for when user changes model before a session exists\n  const [pendingModelOverride, setPendingModelOverride] = useState<string | null>(null)\n\n  // Fetch sessions for this notebook\n  const {\n    data: sessions = [],\n    isLoading: loadingSessions,\n    refetch: refetchSessions\n  } = useQuery({\n    queryKey: QUERY_KEYS.notebookChatSessions(notebookId),\n    queryFn: () => chatApi.listSessions(notebookId),\n    enabled: !!notebookId\n  })\n\n  // Fetch current session with messages\n  const {\n    data: currentSession,\n    refetch: refetchCurrentSession\n  } = useQuery({\n    queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!),\n    queryFn: () => chatApi.getSession(currentSessionId!),\n    enabled: !!notebookId && !!currentSessionId\n  })\n\n  // Update messages when current session changes\n  useEffect(() => {\n    if (currentSession?.messages) {\n      setMessages(currentSession.messages)\n    }\n  }, [currentSession])\n\n  // Auto-select most recent session when sessions are loaded\n  useEffect(() => {\n    if (sessions.length > 0 && !currentSessionId) {\n      // Sessions are sorted by created date desc from API\n      const mostRecentSession = sessions[0]\n      setCurrentSessionId(mostRecentSession.id)\n    }\n  }, [sessions, currentSessionId])\n\n  // Create session mutation\n  const createSessionMutation = useMutation({\n    mutationFn: (data: CreateNotebookChatSessionRequest) =>\n      chatApi.createSession(data),\n    onSuccess: (newSession) => {\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.notebookChatSessions(notebookId)\n      })\n      setCurrentSessionId(newSession.id)\n      toast.success(t.chat.sessionCreated)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))\n    }\n  })\n\n  // Update session mutation\n  const updateSessionMutation = useMutation({\n    mutationFn: ({ sessionId, data }: {\n      sessionId: string\n      data: UpdateNotebookChatSessionRequest\n    }) => chatApi.updateSession(sessionId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.notebookChatSessions(notebookId)\n      })\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.notebookChatSession(currentSessionId!)\n      })\n      toast.success(t.chat.sessionUpdated)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))\n    }\n  })\n\n  // Delete session mutation\n  const deleteSessionMutation = useMutation({\n    mutationFn: (sessionId: string) =>\n      chatApi.deleteSession(sessionId),\n    onSuccess: (_, deletedId) => {\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.notebookChatSessions(notebookId)\n      })\n      if (currentSessionId === deletedId) {\n        setCurrentSessionId(null)\n        setMessages([])\n      }\n      toast.success(t.chat.sessionDeleted)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))\n    }\n  })\n\n  // Build context from sources and notes based on user selections\n  const buildContext = useCallback(async () => {\n    // Build context_config mapping IDs to selection modes\n    const context_config: { sources: Record<string, string>, notes: Record<string, string> } = {\n      sources: {},\n      notes: {}\n    }\n\n    // Map source selections\n    sources.forEach(source => {\n      const mode = contextSelections.sources[source.id]\n      if (mode === 'insights') {\n        context_config.sources[source.id] = 'insights'\n      } else if (mode === 'full') {\n        context_config.sources[source.id] = 'full content'\n      } else {\n        context_config.sources[source.id] = 'not in'\n      }\n    })\n\n    // Map note selections\n    notes.forEach(note => {\n      const mode = contextSelections.notes[note.id]\n      if (mode === 'full') {\n        context_config.notes[note.id] = 'full content'\n      } else {\n        context_config.notes[note.id] = 'not in'\n      }\n    })\n\n    // Call API to build context with actual content\n    const response = await chatApi.buildContext({\n      notebook_id: notebookId,\n      context_config\n    })\n\n    // Store token and char counts\n    setTokenCount(response.token_count)\n    setCharCount(response.char_count)\n\n    return response.context\n  }, [notebookId, sources, notes, contextSelections])\n\n  // Send message (synchronous, no streaming)\n  const sendMessage = useCallback(async (message: string, modelOverride?: string) => {\n    let sessionId = currentSessionId\n\n    // Auto-create session if none exists\n    if (!sessionId) {\n      try {\n        const defaultTitle = message.length > 30\n          ? `${message.substring(0, 30)}...`\n          : message\n        const newSession = await chatApi.createSession({\n          notebook_id: notebookId,\n          title: defaultTitle,\n          // Include pending model override when creating session\n          model_override: pendingModelOverride ?? undefined\n        })\n        sessionId = newSession.id\n        setCurrentSessionId(sessionId)\n        // Clear pending model override now that it's applied to the session\n        setPendingModelOverride(null)\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.notebookChatSessions(notebookId)\n        })\n      } catch (err: unknown) {\n        const error = err as { response?: { data?: { detail?: string } }, message?: string };\n        toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))\n        return\n      }\n    }\n\n    // Add user message optimistically\n    const userMessage: NotebookChatMessage = {\n      id: `temp-${Date.now()}`,\n      type: 'human',\n      content: message,\n      timestamp: new Date().toISOString()\n    }\n    setMessages(prev => [...prev, userMessage])\n    setIsSending(true)\n\n    try {\n      // Build context and send message\n      const context = await buildContext()\n      const response = await chatApi.sendMessage({\n        session_id: sessionId,\n        message,\n        context,\n        model_override: modelOverride ?? (currentSession?.model_override ?? undefined)\n      })\n\n      // Update messages with API response\n      setMessages(response.messages)\n\n      // Refetch current session to get updated data\n      await refetchCurrentSession()\n    } catch (err: unknown) {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      console.error('Error sending message:', error)\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))\n      // Remove optimistic message on error\n      setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))\n    } finally {\n      setIsSending(false)\n    }\n  }, [\n    notebookId,\n    currentSessionId,\n    currentSession,\n    pendingModelOverride,\n    buildContext,\n    refetchCurrentSession,\n    queryClient,\n    t\n  ])\n\n  // Switch session\n  const switchSession = useCallback((sessionId: string) => {\n    setCurrentSessionId(sessionId)\n  }, [])\n\n  // Create session\n  const createSession = useCallback((title?: string) => {\n    return createSessionMutation.mutate({\n      notebook_id: notebookId,\n      title\n    })\n  }, [createSessionMutation, notebookId])\n\n  // Update session\n  const updateSession = useCallback((sessionId: string, data: UpdateNotebookChatSessionRequest) => {\n    return updateSessionMutation.mutate({\n      sessionId,\n      data\n    })\n  }, [updateSessionMutation])\n\n  // Delete session\n  const deleteSession = useCallback((sessionId: string) => {\n    return deleteSessionMutation.mutate(sessionId)\n  }, [deleteSessionMutation])\n\n  // Set model override - handles both existing sessions and pending state\n  const setModelOverride = useCallback((model: string | null) => {\n    if (currentSessionId) {\n      // Session exists - update it directly\n      updateSessionMutation.mutate({\n        sessionId: currentSessionId,\n        data: { model_override: model }\n      })\n    } else {\n      // No session yet - store as pending\n      setPendingModelOverride(model)\n    }\n  }, [currentSessionId, updateSessionMutation])\n\n  // Update token/char counts when context selections change\n  useEffect(() => {\n    const updateContextCounts = async () => {\n      try {\n        await buildContext()\n      } catch (error) {\n        console.error('Error updating context counts:', error)\n      }\n    }\n    updateContextCounts()\n  }, [buildContext])\n\n  return {\n    // State\n    sessions,\n    currentSession: currentSession || sessions.find(s => s.id === currentSessionId),\n    currentSessionId,\n    messages,\n    isSending,\n    loadingSessions,\n    tokenCount,\n    charCount,\n    pendingModelOverride,\n\n    // Actions\n    createSession,\n    updateSession,\n    deleteSession,\n    switchSession,\n    sendMessage,\n    setModelOverride,\n    refetchSessions\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/useSourceChat.ts",
    "content": "'use client'\n\nimport { useState, useCallback, useRef, useEffect } from 'react'\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'\nimport { toast } from 'sonner'\nimport { getApiErrorMessage } from '@/lib/utils/error-handler'\nimport { useTranslation } from '@/lib/hooks/use-translation'\nimport { sourceChatApi } from '@/lib/api/source-chat'\nimport {\n  SourceChatSession,\n  SourceChatMessage,\n  SourceChatContextIndicator,\n  CreateSourceChatSessionRequest,\n  UpdateSourceChatSessionRequest\n} from '@/lib/types/api'\n\nexport function useSourceChat(sourceId: string) {\n  const { t } = useTranslation()\n  const queryClient = useQueryClient()\n  const [currentSessionId, setCurrentSessionId] = useState<string | null>(null)\n  const [messages, setMessages] = useState<SourceChatMessage[]>([])\n  const [isStreaming, setIsStreaming] = useState(false)\n  const [contextIndicators, setContextIndicators] = useState<SourceChatContextIndicator | null>(null)\n  const abortControllerRef = useRef<AbortController | null>(null)\n\n  // Fetch sessions\n  const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useQuery<SourceChatSession[]>({\n    queryKey: ['sourceChatSessions', sourceId],\n    queryFn: () => sourceChatApi.listSessions(sourceId),\n    enabled: !!sourceId\n  })\n\n  // Fetch current session with messages\n  const { data: currentSession, refetch: refetchCurrentSession } = useQuery({\n    queryKey: ['sourceChatSession', sourceId, currentSessionId],\n    queryFn: () => sourceChatApi.getSession(sourceId, currentSessionId!),\n    enabled: !!sourceId && !!currentSessionId\n  })\n\n  // Update messages when session changes\n  useEffect(() => {\n    if (currentSession?.messages) {\n      setMessages(currentSession.messages)\n    }\n  }, [currentSession])\n\n  // Auto-select most recent session when sessions are loaded\n  useEffect(() => {\n    if (sessions.length > 0 && !currentSessionId) {\n      // Find most recent session (sessions are sorted by created date desc from API)\n      const mostRecentSession = sessions[0]\n      setCurrentSessionId(mostRecentSession.id)\n    }\n  }, [sessions, currentSessionId])\n\n  // Create session mutation\n  const createSessionMutation = useMutation({\n    mutationFn: (data: Omit<CreateSourceChatSessionRequest, 'source_id'>) => \n      sourceChatApi.createSession(sourceId, data),\n    onSuccess: (newSession) => {\n      queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })\n      setCurrentSessionId(newSession.id)\n      toast.success(t.chat.sessionCreated)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))\n    }\n  })\n\n  // Update session mutation\n  const updateSessionMutation = useMutation({\n    mutationFn: ({ sessionId, data }: { sessionId: string, data: UpdateSourceChatSessionRequest }) =>\n      sourceChatApi.updateSession(sourceId, sessionId, data),\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })\n      queryClient.invalidateQueries({ queryKey: ['sourceChatSession', sourceId, currentSessionId] })\n      toast.success(t.chat.sessionUpdated)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))\n    }\n  })\n\n  // Delete session mutation\n  const deleteSessionMutation = useMutation({\n    mutationFn: (sessionId: string) => \n      sourceChatApi.deleteSession(sourceId, sessionId),\n    onSuccess: (_, deletedId) => {\n      queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })\n      if (currentSessionId === deletedId) {\n        setCurrentSessionId(null)\n        setMessages([])\n      }\n      toast.success(t.chat.sessionDeleted)\n    },\n    onError: (err: unknown) => {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))\n    }\n  })\n\n  // Send message with streaming\n  const sendMessage = useCallback(async (message: string, modelOverride?: string) => {\n    let sessionId = currentSessionId\n\n    // Auto-create session if none exists\n    if (!sessionId) {\n      try {\n        const defaultTitle = message.length > 30 ? `${message.substring(0, 30)}...` : message\n        const newSession = await sourceChatApi.createSession(sourceId, { title: defaultTitle })\n        sessionId = newSession.id\n        setCurrentSessionId(sessionId)\n        queryClient.invalidateQueries({ queryKey: ['sourceChatSessions', sourceId] })\n      } catch (err: unknown) {\n        const error = err as { response?: { data?: { detail?: string } }, message?: string };\n        console.error('Failed to create chat session:', error)\n        toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))\n        return\n      }\n    }\n\n    // Add user message optimistically\n    const userMessage: SourceChatMessage = {\n      id: `temp-${Date.now()}`,\n      type: 'human',\n      content: message,\n      timestamp: new Date().toISOString()\n    }\n    setMessages(prev => [...prev, userMessage])\n    setIsStreaming(true)\n\n    try {\n      const response = await sourceChatApi.sendMessage(sourceId, sessionId, {\n        message,\n        model_override: modelOverride\n      })\n\n      if (!response) {\n        throw new Error('No response body')\n      }\n\n      const reader = response.getReader()\n      const decoder = new TextDecoder()\n      let aiMessage: SourceChatMessage | null = null\n\n      while (true) {\n        const { done, value } = await reader.read()\n        if (done) break\n\n        const text = decoder.decode(value)\n        const lines = text.split('\\n')\n\n        for (const line of lines) {\n          if (line.startsWith('data: ')) {\n            try {\n              const data = JSON.parse(line.slice(6))\n              \n              if (data.type === 'ai_message') {\n                // Create AI message on first content chunk to avoid empty bubble\n                if (!aiMessage) {\n                  aiMessage = {\n                    id: `ai-${Date.now()}`,\n                    type: 'ai',\n                    content: data.content || '',\n                    timestamp: new Date().toISOString()\n                  }\n                  setMessages(prev => [...prev, aiMessage!])\n                } else {\n                  aiMessage.content += data.content || ''\n                  setMessages(prev =>\n                    prev.map(msg => msg.id === aiMessage!.id\n                      ? { ...msg, content: aiMessage!.content }\n                      : msg\n                    )\n                  )\n                }\n              } else if (data.type === 'context_indicators') {\n                setContextIndicators(data.data)\n              } else if (data.type === 'error') {\n                throw new Error(data.message || 'Stream error')\n              }\n            } catch (e) {\n              if (e instanceof SyntaxError) {\n                console.error('Error parsing SSE data:', e)\n              } else {\n                throw e\n              }\n            }\n          }\n        }\n      }\n    } catch (err: unknown) {\n      const error = err as { response?: { data?: { detail?: string } }, message?: string };\n      console.error('Error sending message:', error)\n      toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))\n      // Remove optimistic messages on error\n      setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))\n    } finally {\n      setIsStreaming(false)\n      // Refetch session to get persisted messages\n      refetchCurrentSession()\n    }\n  }, [sourceId, currentSessionId, refetchCurrentSession, queryClient, t])\n\n  // Cancel streaming\n  const cancelStreaming = useCallback(() => {\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort()\n      setIsStreaming(false)\n    }\n  }, [])\n\n  // Switch session\n  const switchSession = useCallback((sessionId: string) => {\n    setCurrentSessionId(sessionId)\n    setContextIndicators(null)\n  }, [])\n\n  // Create session\n  const createSession = useCallback((data: Omit<CreateSourceChatSessionRequest, 'source_id'>) => {\n    return createSessionMutation.mutate(data)\n  }, [createSessionMutation])\n\n  // Update session\n  const updateSession = useCallback((sessionId: string, data: UpdateSourceChatSessionRequest) => {\n    return updateSessionMutation.mutate({ sessionId, data })\n  }, [updateSessionMutation])\n\n  // Delete session\n  const deleteSession = useCallback((sessionId: string) => {\n    return deleteSessionMutation.mutate(sessionId)\n  }, [deleteSessionMutation])\n\n  return {\n    // State\n    sessions,\n    currentSession: sessions.find(s => s.id === currentSessionId),\n    currentSessionId,\n    messages,\n    isStreaming,\n    contextIndicators,\n    loadingSessions,\n    \n    // Actions\n    createSession,\n    updateSession,\n    deleteSession,\n    switchSession,\n    sendMessage,\n    cancelStreaming,\n    refetchSessions\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n-events.ts",
    "content": "export const I18N_LANGUAGE_CHANGE_START = 'i18n:language-change-start'\nexport const I18N_LANGUAGE_CHANGE_END = 'i18n:language-change-end'\n\ntype LanguageChangeDetail = {\n  language: string\n}\n\nexport const i18nEvents = new EventTarget()\n\nexport function emitLanguageChangeStart(language: string) {\n  i18nEvents.dispatchEvent(\n    new CustomEvent<LanguageChangeDetail>(I18N_LANGUAGE_CHANGE_START, {\n      detail: { language },\n    })\n  )\n}\n\nexport function emitLanguageChangeEnd(language: string) {\n  i18nEvents.dispatchEvent(\n    new CustomEvent<LanguageChangeDetail>(I18N_LANGUAGE_CHANGE_END, {\n      detail: { language },\n    })\n  )\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n.ts",
    "content": "import i18n from 'i18next'\nimport { initReactI18next } from 'react-i18next'\nimport LanguageDetector from 'i18next-browser-languagedetector'\nimport { resources } from './locales'\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .init({\n    resources,\n    fallbackLng: 'en-US',\n    interpolation: {\n      escapeValue: false, // react already safes from xss\n    },\n    react: {\n      useSuspense: false,\n    },\n    detection: {\n      order: ['localStorage', 'navigator'],\n      caches: ['localStorage'],\n    },\n  })\n\nexport default i18n\n"
  },
  {
    "path": "frontend/src/lib/locales/CLAUDE.md",
    "content": "# Locales Module (i18n)\n\nInternationalization system providing multi-language UI support using i18next with type-safe translation access.\n\n## Architecture\n\n```\nlib/\n├── i18n.ts              # i18next initialization and configuration\n├── i18n-events.ts       # Language change event emitters\n├── hooks/\n│   └── use-translation.ts  # Custom hook with Proxy-based API\n├── utils/\n│   └── date-locale.ts   # date-fns locale mapping\n└── locales/\n    ├── index.ts         # Locale registry and type exports\n    ├── en-US/index.ts   # English translations\n    ├── pt-BR/index.ts   # Brazilian Portuguese translations\n    ├── zh-CN/index.ts   # Simplified Chinese translations\n    ├── zh-TW/index.ts   # Traditional Chinese translations\n    ├── ja-JP/index.ts   # Japanese translations\n    ├── ru-RU/index.ts   # Russian translations\n    └── bn-IN/index.ts   # Bengali translations\n```\n\n## Key Components\n\n- **`i18n.ts`**: i18next initialization with language detection (localStorage → browser)\n- **`i18n-events.ts`**: Event emitters for language change start/end (used by loading overlay)\n- **`locales/index.ts`**: Central registry exporting all locales and `LanguageCode` type\n- **`use-translation.ts`**: Custom hook providing `t` object with nested property access\n\n## Translation Structure\n\nEach locale file exports a flat object with nested keys:\n\n```typescript\nexport const enUS = {\n  common: {\n    save: 'Save',\n    cancel: 'Cancel',\n    delete: 'Delete',\n    // ...\n  },\n  notebooks: {\n    title: 'Notebooks',\n    createNew: 'Create Notebook',\n    // ...\n  },\n  // ... other sections\n}\n```\n\n**Sections**:\n- `common`: Shared UI elements (buttons, labels, actions)\n- `notebooks`, `sources`, `notes`: Feature-specific strings\n- `chat`, `search`, `podcasts`: Module-specific strings\n- `models`, `transformations`, `settings`: Configuration UI\n- `advanced`: System administration strings\n- `apiErrors`: Backend error message translations\n\n## Usage Pattern\n\n```typescript\nimport { useTranslation } from '@/lib/hooks/use-translation'\n\nfunction MyComponent() {\n  const { t, language, setLanguage } = useTranslation()\n\n  // Nested property access (Proxy-based)\n  return <h1>{t.notebooks.title}</h1>\n\n  // With interpolation\n  return <p>{t.common.updated.replace('{time}', timeAgo)}</p>\n\n  // Change language\n  await setLanguage('zh-CN')\n}\n```\n\n## Important Patterns\n\n- **Proxy-based access**: `t.section.key` instead of `t('section.key')` for better DX\n- **Type safety**: `TranslationKeys` type derived from `enUS` locale\n- **Language persistence**: Saved to localStorage, auto-detected on load\n- **Fallback**: Falls back to `en-US` if key missing in current locale\n- **Date localization**: Use `getDateLocale(language)` from `utils/date-locale.ts`\n\n## Key Dependencies\n\n- `i18next`: Core internationalization framework\n- `react-i18next`: React bindings for i18next\n- `i18next-browser-languagedetector`: Auto-detect browser language\n- `date-fns/locale`: Date formatting locales\n\n## How to Add a New Language\n\n1. Create locale folder: `locales/pt-BR/index.ts`\n2. Copy structure from `en-US/index.ts` and translate all strings\n3. Register in `locales/index.ts`:\n   ```typescript\n   import { ptBR } from './pt-BR'\n   export const resources = {\n     // ...existing\n     'pt-BR': { translation: ptBR },\n   }\n   export const languages: Language[] = [\n     // ...existing\n     { code: 'pt-BR', label: 'Português' },\n   ]\n   ```\n4. Add to `utils/date-locale.ts`:\n   ```typescript\n   import { ptBR } from 'date-fns/locale'\n   const LOCALE_MAP = { ...existing, 'pt-BR': ptBR }\n   ```\n\n## Important Quirks & Gotchas\n\n- **Proxy depth limit**: `useTranslation` limits nesting to 4 levels to prevent infinite loops\n- **Blocked properties**: React internals (`__proto__`, `$$typeof`, etc.) are blocked from Proxy traversal\n- **Loop detection**: Access counts reset every 1s; >1000 accesses triggers error and breaks recursion\n- **String methods**: `.replace()`, `.split()` work on translated strings via Proxy magic\n- **Language change events**: `emitLanguageChangeStart/End` used by `LanguageLoadingOverlay` for UX\n- **No SSR**: `useSuspense: false` disables React Suspense for i18next (avoids hydration issues)\n- **All keys required**: Missing keys in non-English locales fall back to English; keep locales in sync\n\n## Testing Patterns\n\n```typescript\n// Mock useTranslation in tests (see test/setup.ts)\nvi.mock('@/lib/hooks/use-translation', () => ({\n  useTranslation: () => ({\n    t: enUS,  // Use English locale directly\n    language: 'en-US',\n    setLanguage: vi.fn(),\n  }),\n}))\n\n// Test locale completeness\nimport { enUS, zhCN } from '@/lib/locales'\nconst enKeys = Object.keys(flatten(enUS))\nconst zhKeys = Object.keys(flatten(zhCN))\nexpect(zhKeys).toEqual(enKeys)  // All keys present\n```\n"
  },
  {
    "path": "frontend/src/lib/locales/bn-IN/index.ts",
    "content": "export const bnIN = {\n  common: {\n    search: \"অনুসন্ধান...\",\n    create: \"নতুন\",\n    new: \"নতুন\",\n    cancel: \"বাতিল\",\n    delete: \"মুছে ফেলুন\",\n    edit: \"সম্পাদনা\",\n    theme: \"থিম\",\n    signOut: \"সাইন আউট\",\n    noMatches: \"কোনো মিল পাওয়া যায়নি\",\n    tryDifferentSearch: \"একটি ভিন্ন অনুসন্ধান শব্দ ব্যবহার করার চেষ্টা করুন।\",\n    light: \"হালকা\",\n    dark: \"অন্ধকার\",\n    system: \"সিস্টেম\",\n    loading: \"লোড হচ্ছে...\",\n    note: \"নোট\",\n    insight: \"অন্তর্দৃষ্টি\",\n    newSource: \"নতুন উৎস\",\n    newNotebook: \"নতুন নোটবুক\",\n    newPodcast: \"নতুন পডকাস্ট\",\n    language: \"ভাষা\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"উৎস\",\n    notebook: \"নোটবুক\",\n    podcast: \"পডকাস্ট\",\n    quickActions: \"দ্রুত ক্রিয়া\",\n    quickActionsDesc: \"নেভিগেশন, অনুসন্ধান, প্রশ্ন, থিম\",\n    appName: \"ওপেন নোটবুক\",\n    add: \"যোগ করুন\",\n    remove: \"সরান\",\n    confirm: \"নিশ্চিত করুন\",\n    warning: \"সতর্কতা\",\n    error: \"ত্রুটি\",\n    success: \"সফল\",\n    model: \"মডেল\",\n    back: \"পিছনে\",\n    next: \"পরবর্তী\",\n    done: \"সম্পন্ন\",\n    processing: \"প্রক্রিয়াকরণ...\",\n    creating: \"তৈরি করা হচ্ছে...\",\n    linked: \"সংযুক্ত\",\n    adding: \"যোগ করা হচ্ছে...\",\n    addSelected: \"নির্বাচিত যোগ করুন\",\n    customModel: \"কাস্টম মডেল\",\n    failed: \"ব্যর্থ\",\n    current: \"বর্তমান\",\n    save: \"সংরক্ষণ\",\n    writeNote: \"নোট লিখুন\",\n    batchMode: \"ব্যাচ মোড\",\n    optional: \"ঐচ্ছিক\",\n    type: \"ধরন\",\n    title: \"শিরোনাম\",\n    created: \"তৈরি করা হয়েছে {time}\",\n    updated: \"আপডেট করা হয়েছে {time}\",\n    actions: \"ক্রিয়া\",\n    noResults: \"কোনো ফলাফল নেই\",\n    references: \"রেফারেন্স\",\n    refreshPage: \"অনুগ্রহ করে পৃষ্ঠাটি রিফ্রেশ করার চেষ্টা করুন\",\n    refresh: \"রিফ্রেশ\",\n    aiGenerated: \"AI দ্বারা তৈরি\",\n    human: \"মানুষ\",\n    unknown: \"অজানা\",\n    notes: \"নোটগুলি\",\n    chat: \"চ্যাট\",\n    deleteForever: \"চিরতরে মুছে ফেলুন\",\n    connectionError: \"সংযোগ ত্রুটি\",\n    unableToConnect: \"API সার্ভারে সংযোগ করতে অক্ষম\",\n    retryConnection: \"সংযোগ পুনরায় চেষ্টা করুন\",\n    diagnosticInfo: \"ডায়াগনস্টিক তথ্য\",\n    version: \"সংস্করণ\",\n    built: \"নির্মিত\",\n    apiUrl: \"API URL\",\n    frontendUrl: \"ফ্রন্টএন্ড URL\",\n    checkConsoleLogs: \"বিস্তারিত লগের জন্য ব্রাউজার কনসোল চেক করুন (🔧 [Config] বার্তা দেখুন)\",\n    yes: \"হ্যাঁ\",\n    no: \"না\",\n    saving: \"সংরক্ষণ করা হচ্ছে...\",\n    description: \"বিবরণ\",\n    saveToNote: \"নোটে সংরক্ষণ করুন\",\n    copyToClipboard: \"ক্লিপবোর্ডে কপি করুন\",\n    close: \"বন্ধ\",\n    insights: \"অন্তর্দৃষ্টি\",\n    progress: \"অগ্রগতি\",\n    deleting: \"মুছে ফেলা হচ্ছে...\",\n    created_label: \"তৈরি\",\n    updated_label: \"আপডেট\",\n    download: \"ডাউনলোড\",\n    saveChanges: \"পরিবর্তন সংরক্ষণ\",\n    name: \"নাম\",\n    default: \"ডিফল্ট\",\n    nameRequired: \"নাম প্রয়োজন\",\n    modelConfiguration: \"মডেল কনফিগারেশন\",\n    resetToDefault: \"ডিফল্টে রিসেট\",\n    reasoning: \"যুক্তি\",\n    searchTerms: \"অনুসন্ধান শব্দ\",\n    strategy: \"কৌশল\",\n    individualAnswers: \"ব্যক্তিগত উত্তর ({count})\",\n    finalAnswer: \"চূড়ান্ত উত্তর\",\n    notebookLabel: \"নোটবুক: {name}\",\n    itemNotFound: \"এই {type} খুঁজে পাওয়া যায়নি\",\n    accessibility: {\n      transformationViews: \"ট্রান্সফরমেশন ভিউ\",\n      searchKB: \"আপনার জ্ঞানভান্ডার জিজ্ঞাসা বা অনুসন্ধান করুন\",\n      enterQuestion: \"জ্ঞানভান্ডার জিজ্ঞাসা করতে আপনার প্রশ্ন লিখুন\",\n      enterSearch: \"অনুসন্ধান ক্যোয়ারি লিখুন\",\n      searchKBBtn: \"জ্ঞানভান্ডার অনুসন্ধান\",\n      podcastViews: \"পডকাস্ট ভিউ\",\n      ytVideo: \"YouTube ভিডিও\",\n      askResponse: \"জিজ্ঞাসার উত্তর\",\n      searchNotebooks: \"নোটবুক অনুসন্ধান\",\n    },\n    url: \"URL\",\n    errorDetails: \"ত্রুটির বিবরণ\",\n    editTransformation: \"ট্রান্সফরমেশন সম্পাদনা\",\n    retry: \"আবার চেষ্টা করুন\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"সম্পন্ন\",\n    saveSuccess: \"সফলভাবে সংরক্ষিত\",\n    contextModes: {\n      off: \"চ্যাটে অন্তর্ভুক্ত নয়\",\n      insights: \"শুধুমাত্র অন্তর্দৃষ্টি\",\n      full: \"সম্পূর্ণ কন্টেন্ট\",\n      clickToCycle: \"সাইকেল করতে ক্লিক করুন\",\n    },\n    clickToEdit: \"সম্পাদনা করতে ক্লিক করুন\",\n  },\n  apiErrors: {\n    notebookNotFound: \"নোটবুক খুঁজে পাওয়া যায়নি\",\n    sourceNotFound: \"উৎস খুঁজে পাওয়া যায়নি\",\n    transformationNotFound: \"ট্রান্সফরমেশন খুঁজে পাওয়া যায়নি\",\n    fileUploadFailed: \"ফাইল আপলোড ব্যর্থ\",\n    urlRequired: \"লিন্ক টাইপের জন্য URL প্রয়োজন\",\n    contentRequired: \"টেক্সট টাইপের জন্য কন্টেন্ট প্রয়োজন\",\n    invalidSourceType: \"অবৈধ উৎস ধরন\",\n    processingFailed: \"প্রক্রিয়াকরণ ব্যর্থ\",\n    failedToQueue: \"প্রক্রিয়াকরণ কিউ করতে ব্যর্থ\",\n    invalidSortBy: \"সাজানোর ফিল্ড 'created' বা 'updated' হতে হবে\",\n    invalidSortOrder: \"সাজানোর ক্রম 'asc' বা 'desc' হতে হবে\",\n    accessDenied: \"ফাইলের অ্যাক্সেস অস্বীকৃত\",\n    fileNotFoundOnServer: \"সার্ভারে ফাইল খুঁজে পাওয়া যায়নি\",\n    searchFailed: \"অনুসন্ধান ব্যর্থ\",\n    askFailed: \"প্রশ্ন করা ব্যর্থ\",\n    pleaseEnterQuestion: \"অনুগ্রহ করে একটি প্রশ্ন লিখুন\",\n    pleaseConfigureModels: \"অনুগ্রহ করে সব প্রয়োজনীয় মডেল কনফিগার করুন\",\n    failedToCreateSession: \"সেশন তৈরি করতে ব্যর্থ\",\n    failedToUpdateSession: \"সেশন আপডেট করতে ব্যর্থ\",\n    failedToDeleteSession: \"সেশন মুছে ফেলতে ব্যর্থ\",\n    failedToSendMessage: \"বার্তা পাঠাতে ব্যর্থ\",\n    unauthorized: \"অননুমোদিত অ্যাক্সেস, অনুগ্রহ করে আপনার পাসওয়ার্ড চেক করুন\",\n    invalidPassword: \"অবৈধ পাসওয়ার্ড\",\n    embeddingModelRequired: \"এই ফিচারের জন্য একটি embedding মডেল প্রয়োজন। অনুগ্রহ করে Models সেকশনে একটি কনফিগার করুন।\",\n    strategyModelNotFound: \"Strategy মডেল খুঁজে পাওয়া যায়নি\",\n    answerModelNotFound: \"Answer মডেল খুঁজে পাওয়া যায়নি\",\n    finalAnswerModelNotFound: \"Final answer মডেল খুঁজে পাওয়া যায়নি\",\n    noAnswerGenerated: \"কোনো উত্তর তৈরি করা যায়নি\",\n    genericError: \"একটি অপ্রত্যাশিত ত্রুটি ঘটেছে\",\n  },\n  connectionErrors: {\n    apiTitle: \"API সার্ভারে সংযোগ করতে অক্ষম\",\n    apiDesc: \"ওপেন নোটবুক API সার্ভারে পৌঁছানো যায়নি\",\n    dbTitle: \"ডেটাবেস সংযোগ ব্যর্থ\",\n    dbDesc: \"API সার্ভার চালু আছে, কিন্তু ডেটাবেস অ্যাক্সেসযোগ্য নয়\",\n    troubleshooting: \"এটি সাধারণত মানে:\",\n    apiUnreachable1: \"API সার্ভার চালু নেই\",\n    apiUnreachable2: \"API সার্ভার ভিন্ন ঠিকানায় চালু আছে\",\n    apiUnreachable3: \"নেটওয়ার্ক সংযোগ সমস্যা\",\n    dbFailed1: \"SurrealDB চালু নেই\",\n    dbFailed2: \"ডেটাবেস সংযোগ সেটিংস ভুল\",\n    dbFailed3: \"API ও ডেটাবেসের মধ্যে নেটওয়ার্ক সমস্যা\",\n    quickFixes: \"দ্রুত সমাধান:\",\n    setApiUrl: \"API_URL environment variable সেট করুন:\",\n    checkSurreal: \"SurrealDB চালু আছে কিনা চেক করুন:\",\n    seeDocumentation: \"বিস্তারিত সেটআপ নির্দেশনার জন্য দেখুন:\",\n    docLink: \"ওপেন নোটবুক ডকুমেন্টেশন\",\n    showTechnical: \"টেকনিক্যাল বিবরণ দেখুন\",\n    attemptedUrl: \"চেষ্টা করা URL\",\n    message: \"বার্তা\",\n    technicalDetails: \"টেকনিক্যাল বিবরণ\",\n    stackTrace: \"Stack Trace\",\n    retryLabel: \"সংযোগ পুনরায় চেষ্টা\",\n    retryHint: \"R চাপুন বা বোতামে ক্লিক করে পুনরায় চেষ্টা করুন\",\n    dockerLabel: \"Docker এর জন্য\",\n    localDevLabel: \"স্থানীয় ডেভেলপমেন্টের জন্য\",\n  },\n  auth: {\n    loginTitle: \"ওপেন নোটবুক\",\n    loginDesc: \"অ্যাপ্লিকেশন অ্যাক্সেস করতে আপনার পাসওয়ার্ড লিখুন\",\n    passwordPlaceholder: \"পাসওয়ার্ড\",\n    signingIn: \"সাইন ইন করা হচ্ছে...\",\n    signIn: \"সাইন ইন\",\n    connectErrorHint: \"সার্ভারে সংযোগ করতে অক্ষম। API চালু আছে কিনা চেক করুন।\",\n  },\n  navigation: {\n    collect: \"সংগ্রহ\",\n    process: \"প্রক্রিয়া\",\n    create: \"তৈরি\",\n    manage: \"ব্যবস্থাপনা\",\n    sources: \"উৎসগুলি\",\n    notebooks: \"নোটবুকগুলি\",\n    askAndSearch: \"জিজ্ঞাসা ও অনুসন্ধান\",\n    podcasts: \"পডকাস্ট\",\n    models: \"মডেলগুলি\",\n    transformations: \"ট্রান্সফরমেশনস\",\n    transformation: \"ট্রান্সফরমেশন\",\n    settings: \"সেটিংস\",\n    advanced: \"উন্নত\",\n    nav: \"নেভিগেশন\",\n    language: \"ভাষা টগল\",\n    theme: \"থিম\",\n    ask: \"জিজ্ঞাসা\",\n  },\n  notebooks: {\n    title: \"নোটবুকগুলি\",\n    newNotebook: \"নতুন নোটবুক\",\n    searchPlaceholder: \"নোটবুক অনুসন্ধান...\",\n    archived: \"আর্কাইভ করা\",\n    archive: \"আর্কাইভ\",\n    unarchive: \"আর্কাইভ বাতিল\",\n    deleteNotebook: \"নোটবুক মুছে ফেলুন\",\n    deleteNotebookDesc: \"আপনি কি নিশ্চিত \\\"{name}\\\" মুছে ফেলতে চান? এই কাজটি পুনরায় করা যাবে না।\",\n    deleteNotebookLoading: \"মুছে ফেলার প্রিভিউ লোড হচ্ছে...\",\n    deleteNotebookNotes: \"{count}টি নোট চিরতরে মুছে যাবে।\",\n    deleteNotebookNoNotes: \"মুছার জন্য কোন নোট নেই।\",\n    deleteNotebookExclusiveSources: \"{count}টি উৎস শুধুমাত্র এই নোটবুকে আছে।\",\n    deleteNotebookSharedSources: \"{count}টি উৎস অন্য নোটবুকের সাথে সাঝা করা এবং আনলিংক হবে।\",\n    deleteNotebookNoSources: \"এই নোটবুকে কোন উৎস নেই।\",\n    deleteExclusiveSourcesLabel: \"এক্সক্লুসিভ উৎসগুলি মুছে ফেলুন\",\n    keepExclusiveSourcesLabel: \"আনলিংক করে রাখুন\",\n    activeNotebooks: \"সক্রিয় নোটবুক\",\n    archivedNotebooks: \"আর্কাইভ নোটবুক\",\n    notFound: \"নোটবুক খুঁজে পাওয়া যায়নি\",\n    notFoundDesc: \"অনুরোধ করা নোটবুক অস্তিত্ব নেই।\",\n    updated: \"আপডেট করা\",\n    namePlaceholder: \"নোটবুকের নাম\",\n    addDescription: \"বিবরণ যোগ করুন...\",\n    noNotesYet: \"এখনও কোন নোট নেই\",\n    deleteNote: \"নোট মুছে ফেলুন\",\n    deleteNoteConfirm: \"আপনি কি নিশ্চিত এই নোটটি মুছে ফেলতে চান? এই কাজটি পুনরায় করা যাবে না।\",\n    noteCreatedSuccess: \"নোট সফলভাবে তৈরি হয়েছে\",\n    failedToCreateNote: \"নোট তৈরি করতে ব্যর্থ\",\n    noteUpdatedSuccess: \"নোট সফলভাবে আপডেট হয়েছে\",\n    failedToUpdateNote: \"নোট আপডেট করতে ব্যর্থ\",\n    noteDeletedSuccess: \"নোট সফলভাবে মুছে ফেলা হয়েছে\",\n    failedToDeleteNote: \"নোট মুছে ফেলতে ব্যর্থ\",\n    createNew: \"নতুন নোটবুক তৈরি করুন\",\n    createNewDesc: \"শুরু করতে একটি নাম ও একটি ঐচ্ছিক বিবরণ লিখুন।\",\n    descPlaceholder: \"এই নোটবুক সম্পর্কে আরো তথ্য এখানে যোগ করুন...\",\n    createSuccess: \"নোটবুক সফলভাবে তৈরি হয়েছে\",\n    updateSuccess: \"নোটবুক সফলভাবে আপডেট হয়েছে\",\n    deleteSuccess: \"নোটবুক সফলভাবে মুছে ফেলা হয়েছে\",\n  },\n  sources: {\n    title: \"উৎসগুলি\",\n    add: \"উৎস যোগ করুন\",\n    addNew: \"নতুন উৎস যোগ করুন\",\n    addExisting: \"বিদ্যমান উৎস যোগ করুন\",\n    delete: \"উৎস মুছে ফেলুন\",\n    statusPreparing: \"প্রস্তুত করা হচ্ছে\",\n    statusQueued: \"কিউ করা\",\n    statusProcessing: \"প্রক্রিয়াকরণ\",\n    statusCompleted: \"সম্পন্ন\",\n    statusFailed: \"ব্যর্থ\",\n    statusPreparingDesc: \"প্রক্রিয়াকরণের জন্য প্রস্তুত করা হচ্ছে\",\n    statusQueuedDesc: \"প্রক্রিয়াকরণের জন্য অপেক্ষা করছে\",\n    statusProcessingDesc: \"প্রক্রিয়াকরণ হচ্ছে\",\n    statusCompletedDesc: \"সফলভাবে প্রক্রিয়াকরণ করা হয়েছে\",\n    statusFailedDesc: \"প্রক্রিয়াকরণ ব্যর্থ\",\n    failedToLoad: \"উৎস লোড করতে ব্যর্থ\",\n    allSourcesDesc: \"এখানে আপনার সব উৎস দেখুন। আপনি নতুন উৎস যোগ করতে বা বিদ্যমান উৎস পরিচালনা করতে পারেন।\",\n    allSources: \"সব উৎস\",\n    insights: \"অন্তর্দৃষ্টি\",\n    yes: \"হ্যাঁ\",\n    no: \"না\",\n    loadingMore: \"আরো লোড হচ্ছে...\",\n    noSourcesYet: \"এখনও কোন উৎস নেই\",\n    allSourcesDescShort: \"এখানে আপনার সব উৎস দেখুন।\",\n    cannotSaveNoteNoNotebook: \"নোট সংরক্ষণ করতে পারা যায়নি: নোটবুক ID উপলব্ধ নয়\",\n    createFirstSource: \"আপনার জ্ঞানভান্ডার তৈরি শুরু করতে আপনার প্রথম উৎস যোগ করুন।\",\n    deleteSourceConfirm: \"আপনি কি নিশ্চিত এই উৎসটি মুছে ফেলতে চান?\",\n    deleteConfirm: \"আপনি কি নিশ্চিত এটি মুছে ফেলতে চান?\",\n    deleteConfirmWithTitle: \"আপনি কি নিশ্চিত \\\"{title}\\\" মুছে ফেলতে চান?\",\n    deleteSuccess: \"উৎস সফলভাবে মুছে ফেলা হয়েছে। নোট: স্টোরেজ থেকে ফাইল মুছে ফেলতে, আপনাকে অবশ্যই সেটিংস পেজে \\\"ফাইল মুছে ফেলুন\\\" অপশনটি সক্ষম করতে হবে।\",\n    failedToDelete: \"উৎস মুছে ফেলতে ব্যর্থ\",\n    sourceQueued: \"উৎস কিউ করা হয়েছে\",\n    sourceQueuedDesc: \"ব্যাকগ্রাউন্ড প্রক্রিয়াকরণের জন্য উৎস জমা দেওয়া হয়েছে। আপনি উৎসের তালিকায় অগ্রগতি পর্যবেক্ষণ করতে পারেন।\",\n    sourceAddedSuccess: \"উৎস সফলভাবে যোগ করা হয়েছে\",\n    failedToAddSource: \"উৎস যোগ করতে ব্যর্থ\",\n    sourceUpdatedSuccess: \"উৎস সফলভাবে আপডেট করা হয়েছে\",\n    failedToUpdateSource: \"উৎস আপডেট করতে ব্যর্থ\",\n    sourceDeletedSuccess: \"উৎস সফলভাবে মুছে ফেলা হয়েছে\",\n    failedToDeleteSource: \"উৎস মুছে ফেলতে ব্যর্থ\",\n    fileUploadedSuccess: \"ফাইল সফলভাবে আপলোড হয়েছে\",\n    failedToUploadFile: \"ফাইল আপলোড করতে ব্যর্থ\",\n    sourceRequeued: \"উৎস পুনরায় কিউ করা হয়েছে\",\n    sourceRequeuedDesc: \"উৎসটি প্রক্রিয়াকরণের জন্য পুনরায় কিউ করা হয়েছে।\",\n    failedToRetry: \"পুনরায় চেষ্টা ব্যর্থ\",\n    sourcesAddedToNotebook: \"{count}টি উৎস নোটবুকে যোগ করা হয়েছে\",\n    failedToAddSourcesToNotebook: \"নোটবুকে উৎস যোগ করতে ব্যর্থ\",\n    partialAddSuccess: \"{success}টি উৎস যোগ হয়েছে, {failed}টি ব্যর্থ\",\n    sourceRemovedFromNotebook: \"নোটবুক থেকে উৎস সফলভাবে সরানো হয়েছে\",\n    failedToRemoveSourceFromNotebook: \"নোটবুক থেকে উৎস সরাতে ব্যর্থ\",\n    removeConfirm: \"আপনি কি নিশ্চিত নোটবুক থেকে এটি সরাতে চান?\",\n    checking: \"চেক করা হচ্ছে...\",\n    untitledSource: \"শিরোনামহীন উৎস\",\n    maxItems: \"সর্বোচ্চ {count}\",\n    insightsCount: \"{count}টি অন্তর্দৃষ্টি\",\n    details: \"বিবরণ\",\n    detailsTitle: \"উৎসের বিবরণ\",\n    content: \"কন্টেন্ট\",\n    metadata: \"মেটাডেটা\",\n    type: {\n      link: \"লিংক\",\n      file: \"ফাইল\",\n      text: \"টেক্সট\",\n    },\n    id: \"উৎস ID\",\n    topics: \"বিষয়বস্তু\",\n    embedded: \"এমবেড করা\",\n    notEmbedded: \"এমবেড করা হয়নি\",\n    embedContent: \"কন্টেন্ট এমবেড করুন\",\n    embedding: \"এমবেড করা হচ্ছে...\",\n    alreadyEmbedded: \"ইতিমধ্যে এমবেড করা\",\n    downloadFile: \"ফাইল ডাউনলোড করুন\",\n    fileUnavailable: \"ফাইল উপলব্ধ নয়\",\n    preparing: \"প্রস্তুত করা হচ্ছে...\",\n    generateNewInsight: \"নতুন অন্তর্দৃষ্টি তৈরি করুন\",\n    selectTransformation: \"একটি ট্রান্সফরমেশন নির্বাচন করুন...\",\n    noInsightsYet: \"এখনও কোন অন্তর্দৃষ্টি নেই\",\n    createFirstInsight: \"উপরের ট্রান্সফরমেশন ব্যবহার করে আপনার প্রথম অন্তর্দৃষ্টি তৈরি করুন\",\n    viewInsight: \"অন্তর্দৃষ্টি দেখুন\",\n    deleteInsight: \"অন্তর্দৃষ্টি মুছে ফেলুন\",\n    deleteInsightConfirm: \"আপনি কি নিশ্চিত এই অন্তর্দৃষ্টি মুছে ফেলতে চান? এই কাজটি পুনরায় করা যাবে না।\",\n    insightGenerationStarted: \"অন্তর্দৃষ্টি তৈরি শুরু হয়েছে। শীঘ্রই এটি দেখা যাবে।\",\n    editNote: \"নোট সম্পাদনা করুন\",\n    createNote: \"নোট তৈরি করুন\",\n    addTitle: \"একটি শিরোনাম যোগ করুন...\",\n    untitledNote: \"শিরোনামহীন নোট\",\n    writeNotePlaceholder: \"এখানে আপনার নোটের কন্টেন্ট লিখুন...\",\n    saveNote: \"নোট সংরক্ষণ\",\n    createNoteBtn: \"নোট তৈরি করুন\",\n    createFirstNote: \"অন্তর্দৃষ্টি ও পর্যবেক্ষণ ক্যাপচার করতে আপনার প্রথম নোট তৈরি করুন।\",\n    urlLabel: \"URL(গুলি) *\",\n    fileLabel: \"ফাইল(গুলি) *\",\n    textContentLabel: \"টেক্সট কন্টেন্ট *\",\n    enterUrlsPlaceholder: \"URL লিখুন, প্রতি লাইনে একটি\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"ব্যাচ ইমপোর্টের জন্য একাধিক URL পেস্ট করুন (প্রতি লাইনে একটি)\",\n    invalidUrlsDetected: \"অবৈধ URL সনাক্ত করা হয়েছে:\",\n    lineLabel: \"লাইন {line}\",\n    fixInvalidUrls: \"অবৈধ URL ঠিক করুন বা সরান\",\n    selectMultipleFilesHint: \"ব্যাচ ইমপোর্টের জন্য একাধিক ফাইল নির্বাচন করুন। সমর্থিত: ডকুমেন্ট (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), মিডিয়া (MP4, MP3, WAV, M4A), ছবি (JPG, PNG), আর্কাইভ (ZIP)\",\n    selectedFiles: \"নির্বাচিত ফাইল:\",\n    textPlaceholder: \"এখানে আপনার কন্টেন্ট পেস্ট বা টাইপ করুন...\",\n    htmlDetected: \"HTML কন্টেন্ট সনাক্ত করা হয়েছে। প্রক্রিয়াকরণের পর এটি Markdown এ রূপান্তরিত হবে।\",\n    titlePlaceholder: \"আপনার উৎসের একটি বর্ণনামূলক শিরোনাম দিন\",\n    batchTitlesAuto: \"প্রতিটি উৎসের জন্য শিরোনাম স্বয়ংক্রিয়ভাবে তৈরি হবে।\",\n    batchCommonSettings: \"একই নোটবুক এবং ট্রান্সফরমেশন সব আইটেমে প্রয়োগ হবে।\",\n    urlsCount: \"{count}টি URL\",\n    filesCount: \"{count}টি ফাইল\",\n    addSource: \"উৎস যোগ করুন\",\n    notEmbeddedAlert: \"কন্টেন্ট এমবেড করা হয়নি\",\n    notEmbeddedDesc: \"এই কন্টেন্ট ভেক্টর সার্চের জন্য এমবেড করা হয়নি। এমবেডিং উন্নত সার্চ ক্ষমতা এবং ভাল কন্টেন্ট আবিষ্কার সক্ষম করে।\",\n    openOnYoutube: \"YouTube এ খুলুন\",\n    urlCopied: \"URL ক্লিপবোর্ডে কপি করা হয়েছে\",\n    viewSource: \"উৎস দেখুন\",\n    noInsightSelected: \"কোন অন্তর্দৃষ্টি নির্বাচিত নয়\",\n    sourceInsight: \"উৎসের অন্তর্দৃষ্টি\",\n    manageNotebooks: \"নোটবুক পরিচালনা করুন\",\n    manageNotebooksDesc: \"এই উৎস কোন নোটবুকে রয়েছে তা পরিচালনা করুন\",\n    noNotebooksAvailable: \"কোন নোটবুক উপলব্ধ নয়\",\n    loadFailed: \"উৎসের বিবরণ লোড করতে ব্যর্থ\",\n    removeFromNotebook: \"নোটবুক থেকে সরান\",\n    retryProcessing: \"প্রক্রিয়াকরণ পুনরায় চেষ্টা করুন\",\n    deleteSource: \"উৎস মুছে ফেলুন\",\n    retry: \"আবার চেষ্টা\",\n    addExistingTitle: \"বিদ্যমান উৎস যোগ করুন\",\n    addExistingDesc: \"বর্তমানে যোগ করতে আপনার সব নোটবুক থেকে বিদ্যমান উৎস নির্বাচন করুন।\",\n    searchPlaceholder: \"নাম বা URL দিয়ে উৎস অনুসন্ধান করুন...\",\n    noNotebooksFound: \"কোন নোটবুক পাওয়া যায়নি।\",\n    showingFirst100: \"প্রথম ১০০টি উৎস দেখানো হচ্ছে। নির্দিষ্ট খুঁজতে অনুসন্ধান ব্যবহার করুন।\",\n    selectedCount: \"{count}টি উৎস নির্বাচিত\",\n    added: \"{date} তারিখে যোগ করা হয়েছে\",\n    addUrl: \"URL যোগ করুন\",\n    uploadFile: \"ফাইল আপলোড করুন\",\n    enterText: \"টেক্সট লিখুন\",\n    processDescription: \"কন্টেন্ট প্রক্রিয়াকৃত এবং AI দ্বারা বিশ্লেষিত হবে।\",\n    processingFiles: \"আপনার ফাইলগুলি প্রক্রিয়াকরণ করা হচ্ছে...\",\n    titleRequired: \"টেক্সট কন্টেন্টের জন্য একটি শিরোনাম প্রয়োজন\",\n    titleGenerated: \"খালি রাখলে, কন্টেন্ট থেকে একটি শিরোনাম তৈরি হবে\",\n    batchCount: \"{count}টি {type} প্রক্রিয়াকরণ করা হবে\",\n    enableEmbedding: \"অনুসন্ধানের জন্য এমবেডিং সক্ষম করুন\",\n    embeddingDesc: \"এই উৎসকে ভেক্টর সার্চ এবং AI ক্যোয়ারিতে খুঁজে পাওয়ার অনুমতি দেয়\",\n    embeddingAlways: \"এমবেডিং স্বয়ংক্রিয়ভাবে সক্ষম\",\n    embeddingAlwaysDesc: \"আপনার সেটিংস ভেক্টর সার্চের জন্য সবসময় কন্টেন্ট এমবেড করার জন্য কনফিগার করা।\",\n    embeddingNever: \"এমবেডিং অক্ষম\",\n    embeddingNeverDesc: \"আপনার সেটিংস এমবেডিং এড়িয়ে যাওয়ার জন্য কনফিগার করা। এই উৎসের জন্য ভেক্টর সার্চ উপলব্ধ থাকবে না।\",\n    changeInSettings: \"আপনি এটি সেটিংসে পরিবর্তন করতে পারেন\",\n    notFound: \"উৎস পাওয়া যায়নি\",\n    noContent: \"কোন কন্টেন্ট উপলব্ধ নয়\",\n    insightsDesc: \"মডেল বিশ্লেষণ থেকে তৈরি অন্তর্দৃষ্টি\",\n    uploadedFile: \"আপলোড করা ফাইল\",\n    fileUnavailableDesc: \"স্টোরেজ সিস্টেমের কারণে এই ফাইল বর্তমানে উপলব্ধ নয়।\",\n    batchSuccess: \"{count}টি উৎস সফলভাবে তৈরি হয়েছে\",\n    batchFailed: \"সব {count}টি উৎস তৈরি করতে ব্যর্থ\",\n    batchPartial: \"{success}টি সফল, {failed}টি ব্যর্থ\",\n    submittingSource: \"প্রক্রিয়াকরণের জন্য উৎস জমা দেওয়া হচ্ছে...\",\n    processingBatchSources: \"{count}টি উৎস প্রক্রিয়াকরণ করা হচ্ছে। এটি কিছু মুহূর্ত সময় নিতে পারে।\",\n    processingSource: \"আপনার উৎস প্রক্রিয়াকরণ করা হচ্ছে। এটি কিছু মুহূর্ত সময় নিতে পারে।\",\n    maxFilesAllowed: \"ব্যাচে সর্বোচ্চ {count}টি ফাইল অনুমোদিত\",\n  },\n  chat: {\n    sessions: \"সেশনগুলি\",\n    sessionTitlePlaceholder: \"এখানে একটি শিরোনাম লিখুন...\",\n    noSessions: \"এখনও কোন চ্যাট সেশন নেই\",\n    deleteSession: \"সেশন মুছে ফেলুন\",\n    deleteSessionDesc: \"আপনি কি নিশ্চিত এই চ্যাট সেশন মুছে ফেলতে চান? এই কাজটি পুনরায় করা যাবে না।\",\n    sendPlaceholder: \"আপনার উৎসগুলি সম্পর্কে যেকোন কিছু জিজ্ঞাসা করুন...\",\n    sessionsTitle: \"চ্যাট সেশনগুলি\",\n    chatWith: \"{name} এর সাথে চ্যাট করুন\",\n    startConversation: \"এই {type} সম্পর্কে কথোপকথন শুরু করুন\",\n    askQuestions: \"বিষয়বস্তু ভাল ভাবে বুঝতে প্রশ্ন করুন\",\n    pressToSend: \"পাঠাতে {key} চাপুন\",\n    model: \"মডেল\",\n    createToStart: \"শুরু করতে একটি সেশন তৈরি করুন।\",\n    chatWithNotebook: \"নোটবুকের সাথে চ্যাট করুন\",\n    unableToLoadChat: \"চ্যাট লোড করতে অক্ষম\",\n    noDescription: \"কোন বিবরণ নেই\",\n    startByCreating: \"আপনার ঠৈ সাজানোর জন্য প্রথম নোটবুক তৈরি করে শুরু করুন।\",\n    messagesCount: \"{count}টি বার্তা\",\n    sessionCreated: \"চ্যাট সেশন তৈরি হয়েছে\",\n    sessionUpdated: \"সেশন আপডেট হয়েছে\",\n    sessionDeleted: \"সেশন মুছে ফেলা হয়েছে\",\n  },\n  searchPage: {\n    askAndSearch: \"জিজ্ঞাসা ও অনুসন্ধান\",\n    chooseAMode: \"একটি মোড বেছে নিন\",\n    askBeta: \"জিজ্ঞাসা (বেটা)\",\n    search: \"অনুসন্ধান\",\n    askYourKb: \"আপনার জ্ঞানভান্ডার জিজ্ঞাসা করুন (বেটা)\",\n    askYourKbDesc: \"LLM আপনার জ্ঞানভান্ডারের ভিত্তিতে আপনার প্রশ্নের উত্তর দেবে।\",\n    question: \"প্রশ্ন\",\n    enterQuestionPlaceholder: \"আপনার প্রশ্ন লিখুন...\",\n    pressToSubmit: \"জমা দিতে Cmd/Ctrl+Enter চাপুন\",\n    noEmbeddingModel: \"আপনি এই ফিচারটি ব্যবহার করতে পারবেন না কারণ কোনো এমবেডিং মডেল নির্বাচিত নেই। অনুগ্রহ করে Models পেজে একটি সেট করুন।\",\n    usingCustomModels: \"কাস্টম মডেল ব্যবহার করা হচ্ছে\",\n    usingDefaultModels: \"ডিফল্ট মডেল ব্যবহার করা হচ্ছে\",\n    advanced: \"উন্নত\",\n    strategy: \"কৌশল\",\n    answer: \"উত্তর\",\n    final: \"চূড়ান্ত\",\n    ask: \"জিজ্ঞাসা\",\n    processing: \"প্রক্রিয়াকরণ...\",\n    saveToNotebooks: \"নোটবুকে সংরক্ষণ করুন\",\n    searchDesc: \"নির্দিষ্ট কীওয়ার্ড বা ধারণার জন্য আপনার জ্ঞানভান্ডার অনুসন্ধান করুন\",\n    enterSearchPlaceholder: \"অনুসন্ধান ক্যোয়ারি লিখুন...\",\n    pressToSearch: \"অনুসন্ধান করতে Enter চাপুন\",\n    searchType: \"অনুসন্ধানের ধরন\",\n    vectorSearchWarning: \"ভেক্টর অনুসন্ধানের জন্য একটি এমবেডিং মডেল প্রয়োজন। শুধুমাত্র টেক্সট অনুসন্ধান উপলব্ধ।\",\n    textSearch: \"টেক্সট অনুসন্ধান\",\n    vectorSearch: \"ভেক্টর অনুসন্ধান\",\n    searchIn: \"যেখানে অনুসন্ধান করবেন\",\n    searchSources: \"উৎস অনুসন্ধান\",\n    searchNotes: \"নোট অনুসন্ধান\",\n    resultsFound: \"{count}টি ফলাফল পাওয়া গেছে\",\n    matches: \"মিল ({count})\",\n    noResultsFor: \"\\\"{query}\\\" এর জন্য কোনো ফলাফল পাওয়া যায়নি\",\n    notSet: \"সেট করা হয়নি\",\n    saveToNotebook: \"নোটবুকে সংরক্ষণ করুন\",\n    saveSuccess: \"সফলভাবে নোটবুকে সংরক্ষণ করা হয়েছে\",\n    saveError: \"নোটবুকে সংরক্ষণ করতে ব্যর্থ\",\n    selectNotebook: \"নোটবুক নির্বাচন করুন\",\n    searchAndAsk: \"অনুসন্ধান ও জিজ্ঞাসা\",\n    searchResultsFor: \"\\\"{query}\\\" এর জন্য অনুসন্ধান ফলাফল\",\n    askAbout: \"\\\"{query}\\\" সম্পর্কে জিজ্ঞাসা করুন\",\n    orSearchKb: \"অথবা আপনার জ্ঞানভান্ডার অনুসন্ধান করুন\",\n    saving: \"সংরক্ষণ করা হচ্ছে...\",\n    advancedModelTitle: \"উন্নত মডেল নির্বাচন\",\n    advancedModelDesc: \"জিজ্ঞাসা প্রক্রিয়ার প্রতিটি পর্যায়ের জন্য নির্দিষ্ট মডেল বেছে নিন\",\n    strategyModel: \"কৌশল মডেল\",\n    answerModel: \"উত্তর মডেল\",\n    finalAnswerModel: \"চূড়ান্ত উত্তর মডেল\",\n    selectStrategyPlaceholder: \"কৌশল মডেল নির্বাচন করুন\",\n    selectAnswerPlaceholder: \"উত্তর মডেল নির্বাচন করুন\",\n    selectFinalPlaceholder: \"চূড়ান্ত উত্তর মডেল নির্বাচন করুন\",\n    saveChanges: \"পরিবর্তন সংরক্ষণ করুন\",\n    processingQuestion: \"আপনার প্রশ্ন প্রক্রিয়া করা হচ্ছে...\",\n  },\n  podcasts: {\n    generateEpisode: \"পডকাস্ট এপিসোড তৈরি করুন\",\n    generateEpisodeDesc: \"নতুন পডকাস্ট এপিসোড তৈরি করার আগে অন্তর্ভুক্ত করার জন্য কন্টেন্ট নির্বাচন করুন এবং এপিসোডের বিবরণ কনফিগার করুন।\",\n    content: \"কন্টেন্ট\",\n    contentDesc: \"এই এপিসোডে অন্তর্ভুক্ত করতে নোটবুক, উৎস এবং নোট বেছে নিন।\",\n    itemsSelected: \"{count}টি আইটেম নির্বাচিত\",\n    tokens: \"{count} টোকেন\",\n    chars: \"{count} অক্ষর\",\n    loadingNotebooks: \"নোটবুক লোড হচ্ছে...\",\n    noNotebooksFoundInPodcasts: \"কোন নোটবুক পাওয়া যায়নি। পডকাস্ট তৈরির আগে একটি নোটবুক তৈরি করে কন্টেন্ট যোগ করুন।\",\n    noContentSelected: \"কোন কন্টেন্ট নির্বাচিত নয়\",\n    summary: \"সারাংশ\",\n    fullContent: \"সম্পূর্ণ কন্টেন্ট\",\n    untitledSource: \"শিরোনামহীন উৎস\",\n    untitledNote: \"শিরোনামহীন নোট\",\n    episodeSettings: \"এপিসোড সেটিংস\",\n    episodeProfile: \"এপিসোড প্রোফাইল\",\n    episodeProfilePlaceholder: \"একটি এপিসোড প্রোফাইল নির্বাচন করুন\",\n    episodeName: \"এপিসোডের নাম\",\n    episodeNamePlaceholder: \"যেমন, AI এবং কাজের ভবিষ্যৎ\",\n    additionalInstructions: \"অতিরিক্ত নির্দেশনা\",\n    instructionsPlaceholder: \"এপিসোড ব্রিফিং-এ যোগ করার জন্য কোন অতিরিক্ত পরামর্শ...\",\n    generating: \"তৈরি করা হচ্ছে...\",\n    generate: \"তৈরি করুন\",\n    hostPlaceholder: \"হোস্ট {number}\",\n    profileRequired: \"এপিসোড প্রোফাইল প্রয়োজন\",\n    profileRequiredDesc: \"পডকাস্ট তৈরির আগে একটি এপিসোড প্রোফাইল নির্বাচন করুন।\",\n    nameRequired: \"এপিসোডের নাম প্রয়োজন\",\n    nameRequiredDesc: \"এপিসোডের জন্য একটি নাম দিন।\",\n    addContext: \"কন্টেক্সট যোগ করুন\",\n    addContextDesc: \"এপিসোডে অন্তর্ভুক্ত করতে কমপক্ষে একটি উৎস বা নোট নির্বাচন করুন।\",\n    generationFailed: \"পডকাস্ট তৈরি ব্যর্থ\",\n    speakerProfile: \"স্পিকার প্রোফাইল\",\n    usesSpeakerProfile: \"স্পিকার প্রোফাইল ব্যবহার করে\",\n    sources: \"উৎসগুলি\",\n    notes: \"নোটগুলি\",\n    noSources: \"এই নোটবুকে কোন উৎস উপলব্ধ নয়।\",\n    noNotes: \"এই নোটবুকে কোন নোট উপলব্ধ নয়।\",\n    selectMode: \"মোড নির্বাচন করুন\",\n    buildContextFailed: \"কন্টেক্সট তৈরি করতে ব্যর্থ। অনুগ্রহ করে আপনার নির্বাচন পর্যালোচনা করুন।\",\n    podcastTaskStarted: \"পডকাস্ট কাজ শুরু হয়েছে\",\n    loadingProfiles: \"এপিসোড প্রোফাইল লোড হচ্ছে...\",\n    noProfilesFound: \"কোন এপিসোড প্রোফাইল পাওয়া যায়নি। পডকাস্ট তৈরির আগে একটি এপিসোড প্রোফাইল তৈরি করুন।\",\n    listTitle: \"পডকাস্ট\",\n    listDesc: \"তৈরি করা এপিসোড ট্র্যাক করুন এবং পুনঃব্যবহারযোগ্য প্রোফাইল পরিচালনা করুন।\",\n    chooseAView: \"একটি ভিউ বেছে নিন\",\n    episodesTab: \"এপিসোড\",\n    templatesTab: \"প্রোফাইল\",\n    overviewTitle: \"এপিসোড ওভারভিউ\",\n    overviewDesc: \"পডকাস্ট তৈরি কাজ মনিটর করুন এবং চূড়ান্ত ফলাফল পর্যালোচনা করুন।\",\n    generateBtn: \"পডকাস্ট তৈরি করুন\",\n    total: \"মোট\",\n    processingLabel: \"প্রক্রিয়াকরণ\",\n    completedLabel: \"সম্পন্ন\",\n    failedLabel: \"ব্যর্থ\",\n    pendingLabel: \"অপেক্ষমাণ\",\n    loadErrorTitle: \"এপিসোড লোড করতে ব্যর্থ\",\n    loadErrorDesc: \"আমরা সর্বশেষ পডকাস্ট এপিসোড আনতে পারিনি। শীঘ্রই আবার চেষ্টা করুন।\",\n    loadingEpisodes: \"এপিসোড লোড হচ্ছে...\",\n    noEpisodesYet: \"এখনও কোন পডকাস্ট এপিসোড নেই। নোটবুক বা সোর্স চ্যাট ইন্টারফেস থেকে আপনার প্রথমটি তৈরি করুন।\",\n    statusRunningTitle: \"বর্তমানে প্রক্রিয়াকরণ\",\n    statusRunningDesc: \"এপিসোড যা সক্রিয়ভাবে সম্পদ তৈরি করছে।\",\n    statusPendingTitle: \"কিউ / অপেক্ষমাণ\",\n    statusPendingDesc: \"জমা দেওয়া এপিসোড যা প্রক্রিয়াকরণ শুরুর অপেক্ষায়।\",\n    statusCompletedTitle: \"সম্পন্ন এপিসোড\",\n    statusCompletedDesc: \"পর্যালোচনা, ডাউনলোড বা প্রকাশের জন্য প্রস্তুত।\",\n    statusFailedTitle: \"ব্যর্থ এপিসোড\",\n    statusFailedDesc: \"তৈরির সময় সমস্যায় পড়া এপিসোড।\",\n    templatesWorkspaceTitle: \"প্রোফাইল ওয়ার্কস্পেস\",\n    templatesWorkspaceDesc: \"দ্রুত পডকাস্ট উৎপাদনের জন্য পুনঃব্যবহারযোগ্য এপিসোড এবং স্পিকার কনফিগারেশন তৈরি করুন।\",\n    howTemplatesPowerTitle: \"প্রোফাইল কিভাবে পডকাস্ট তৈরিকে শক্তিশালী করে\",\n    howTemplatesPowerDesc: \"প্রোফাইল পডকাস্ট ওয়ার্কফ্লোকে দুটি পুনঃব্যবহারযোগ্য বিল্ডিং ব্লকে বিভক্ত করে। নতুন এপিসোড তৈরি করার সময় এগুলো মিশ্রিত এবং মিল করুন।\",\n    episodeProfilesSetFormat: \"এপিসোড প্রোফাইল ফর্ম্যাট সেট করে\",\n    episodeProfilesList1: \"সেগমেন্টের সংখ্যা এবং গল্পের প্রবাহ রূপরেখা দিন\",\n    episodeProfilesList2: \"ব্রিফিং, রূপরেখা এবং স্ক্রিপ্ট লেখার জন্য ব্যবহৃত ভাষা মডেল বেছে নিন\",\n    episodeProfilesList3: \"ডিফল্ট ব্রিফিং সংরক্ষণ করুন যাতে প্রতিটি এপিসোড সুসংগত টোন দিয়ে শুরু হয়\",\n    speakerProfilesBringVoices: \"স্পিকার প্রোফাইল ভয়েসকে জীবন্ত করে তোলে\",\n    speakerProfilesList1: \"টেক্সট-টু-স্পিচ প্রোভাইডার এবং মডেল বেছে নিন\",\n    speakerProfilesList2: \"প্রতি স্পিকারের ব্যক্তিত্ব, পটভূমি এবং উচ্চারণ নোট ক্যাপচার করুন\",\n    speakerProfilesList3: \"বিভিন্ন এপিসোড ফর্ম্যাটে একই হোস্ট বা গেস্ট ভয়েস পুনঃব্যবহার করুন\",\n    recommendedWorkflow: \"প্রস্তাবিত ওয়ার্কফ্লো\",\n    workflowStep1: \"আপনার প্রয়োজনীয় প্রতিটি ভয়েসের জন্য স্পিকার প্রোফাইল তৈরি করুন\",\n    workflowStep2: \"নাম দিয়ে সেই স্পিকারদের রেফারেন্স করে এপিসোড প্রোফাইল তৈরি করুন\",\n    workflowStep3: \"গল্পের সাথে মানানসই এপিসোড প্রোফাইল নির্বাচন করে পডকাস্ট তৈরি করুন\",\n    workflowHint: \"এপিসোড প্রোফাইল নাম দিয়ে স্পিকার প্রোফাইল রেফারেন্স করে, তাই স্পিকার দিয়ে শুরু করলে পরে ভয়েস অ্যাসাইনমেন্ট মিস করার সমস্যা এড়ানো যায়।\",\n    failedToLoadTemplates: \"প্রোফাইল ডেটা লোড করতে ব্যর্থ\",\n    failedToLoadTemplatesDesc: \"নিশ্চিত করুন API চলছে এবং আবার চেষ্টা করুন। কিছু সেকশন অসম্পূর্ণ হতে পারে।\",\n    loadingTemplates: \"প্রোফাইল লোড হচ্ছে…\",\n    speakerProfilesTitle: \"স্পিকার প্রোফাইল\",\n    speakerProfilesDesc: \"তৈরি করা এপিসোডের জন্য ভয়েস এবং ব্যক্তিত্ব কনফিগার করুন।\",\n    createSpeaker: \"স্পিকার তৈরি করুন\",\n    noSpeakerProfiles: \"এখনও কোন স্পিকার প্রোফাইল নেই। এপিসোড প্রোফাইল উপলব্ধ করতে একটি তৈরি করুন।\",\n    noDescription: \"কোন বিবরণ প্রদান করা হয়নি।\",\n    usedByCount_one: \"১টি এপিসোড দ্বারা ব্যবহৃত\",\n    usedByCount_other: \"{count}টি এপিসোড দ্বারা ব্যবহৃত\",\n    usedByCount: \"{count}টি এপিসোড দ্বারা ব্যবহৃত\",\n    unused: \"অব্যবহৃত\",\n    voiceId: \"ভয়েস ID\",\n    backstory: \"পটভূমির গল্প\",\n    personality: \"ব্যক্তিত্ব\",\n    edit: \"সম্পাদনা\",\n    duplicate: \"ডুপ্লিকেট\",\n    deleteSpeakerProfileTitle: \"স্পিকার প্রোফাইল মুছে ফেলবেন?\",\n    deleteSpeakerProfileDesc: \"\\\"{name}\\\" মুছে ফেলা পূর্বাবস্থায় ফেরানো যাবে না।\",\n    deleteSpeakerDisabledHint: \"এটি মুছে ফেলার আগে এই স্পিকারকে এপিসোড প্রোফাইল থেকে সরান।\",\n    deleting: \"মুছে ফেলা হচ্ছে…\",\n    episodeProfilesTitle: \"এপিসোড প্রোফাইল\",\n    episodeProfilesDesc: \"আপনার শোগুলির জন্য পুনঃব্যবহারযোগ্য তৈরি সেটিংস সংজ্ঞায়িত করুন।\",\n    createProfile: \"প্রোফাইল তৈরি করুন\",\n    createSpeakerFirst: \"এপিসোড প্রোফাইল যোগ করার আগে একটি স্পিকার প্রোফাইল তৈরি করুন।\",\n    noEpisodeProfiles: \"এখনও কোন এপিসোড প্রোফাইল নেই। পডকাস্ট তৈরি শুরু করতে একটি তৈরি করুন।\",\n    speakerCreated: \"স্পিকার তৈরি হয়েছে\",\n    speakerCreatedDesc: \"স্পিকার \\\"{name}\\\" সফলভাবে যোগ করা হয়েছে।\",\n    failedToCreateSpeaker: \"স্পিকার প্রোফাইল তৈরি করতে ব্যর্থ\",\n    speakerUpdated: \"স্পিকার আপডেট হয়েছে\",\n    speakerUpdatedDesc: \"স্পিকার \\\"{name}\\\" সফলভাবে আপডেট করা হয়েছে।\",\n    failedToUpdateSpeaker: \"স্পিকার প্রোফাইল আপডেট করতে ব্যর্থ\",\n    speakerDeleted: \"স্পিকার মুছে ফেলা হয়েছে\",\n    speakerDeletedDesc: \"স্পিকার \\\"{name}\\\" সফলভাবে সরানো হয়েছে।\",\n    failedToDeleteSpeaker: \"স্পিকার প্রোফাইল মুছে ফেলতে ব্যর্থ\",\n    speakerDuplicated: \"স্পিকার ডুপ্লিকেট করা হয়েছে\",\n    speakerDuplicatedDesc: \"স্পিকার \\\"{name}\\\" সফলভাবে ডুপ্লিকেট করা হয়েছে।\",\n    failedToDuplicateSpeaker: \"স্পিকার প্রোফাইল ডুপ্লিকেট করতে ব্যর্থ\",\n    generationStarted: \"তৈরি শুরু হয়েছে\",\n    generationStartedDesc: \"পডকাস্ট তৈরি কিউ করা হয়েছে।\",\n    failedToStartGeneration: \"তৈরি শুরু করতে ব্যর্থ\",\n    tryAgainMoment: \"একটু পরে আবার চেষ্টা করুন।\",\n    deleteProfileTitle: \"প্রোফাইল মুছে ফেলবেন?\",\n    deleteProfileDesc: \"এটি \\\"{name}\\\" সরিয়ে দেবে। বিদ্যমান এপিসোড তাদের ডেটা রাখবে, কিন্তু নতুনগুলি আর এই কনফিগারেশন ব্যবহার করবে না।\",\n    profileCreated: \"প্রোফাইল তৈরি হয়েছে\",\n    profileCreatedDesc: \"এপিসোড প্রোফাইল \\\"{name}\\\" সফলভাবে তৈরি করা হয়েছে।\",\n    failedToCreateProfile: \"প্রোফাইল তৈরি করতে ব্যর্থ\",\n    profileUpdated: \"প্রোফাইল আপডেট হয়েছে\",\n    profileUpdatedDesc: \"এপিসোড প্রোফাইল \\\"{name}\\\" সফলভাবে আপডেট করা হয়েছে।\",\n    failedToUpdateProfile: \"প্রোফাইল আপডেট করতে ব্যর্থ\",\n    profileDeleted: \"প্রোফাইল মুছে ফেলা হয়েছে\",\n    profileDeletedDesc: \"এপিসোড প্রোফাইল \\\"{name}\\\" সফলভাবে সরানো হয়েছে।\",\n    failedToDeleteProfile: \"প্রোফাইল মুছে ফেলতে ব্যর্থ\",\n    failedToDeleteProfileDesc: \"এপিসোড প্রোফাইল সরাতে ব্যর্থ।\",\n    profileDuplicated: \"প্রোফাইল ডুপ্লিকেট করা হয়েছে\",\n    profileDuplicatedDesc: \"এপিসোড প্রোফাইল \\\"{name}\\\" সফলভাবে ডুপ্লিকেট করা হয়েছে।\",\n    failedToDuplicateProfile: \"প্রোফাইল ডুপ্লিকেট করতে ব্যর্থ\",\n    episodeDeleted: \"এপিসোড মুছে ফেলা হয়েছে\",\n    episodeDeletedDesc: \"এপিসোড সফলভাবে মুছে ফেলা হয়েছে।\",\n    failedToDeleteEpisode: \"এপিসোড মুছে ফেলতে ব্যর্থ\",\n    failedToDeleteSpeakerDesc: \"স্পিকার প্রোফাইল সরাতে ব্যর্থ।\",\n    outlineModel: \"রূপরেখা মডেল\",\n    transcriptModel: \"ট্রান্সক্রিপ্ট মডেল\",\n    segments: \"সেগমেন্ট\",\n    defaultBriefingTitle: \"ডিফল্ট ব্রিফিং\",\n    created: \"{time} এ তৈরি\",\n    details: \"বিবরণ\",\n    summaryTab: \"সারাংশ\",\n    outlineTab: \"রূপরেখা\",\n    transcriptTab: \"ট্রান্সক্রিপ্ট\",\n    briefing: \"ব্রিফিং\",\n    noOutline: \"কোন রূপরেখা উপলব্ধ নয়।\",\n    noTranscript: \"কোন ট্রান্সক্রিপ্ট উপলব্ধ নয়।\",\n    deleteEpisodeTitle: \"এপিসোড মুছে ফেলবেন?\",\n    deleteEpisodeDesc: \"এটি \\\"{name}\\\" এবং এর অডিও ফাইল স্থায়ীভাবে সরিয়ে দেবে।\",\n    audioUnavailable: \"অডিও উপলব্ধ নয়\",\n    segment: \"সেগমেন্ট\",\n    speaker: \"স্পিকার\",\n    profile: \"প্রোফাইল\",\n    link: \"লিঙ্ক\",\n    file: \"ফাইল\",\n    embedded: \"এমবেডেড\",\n    notEmbedded: \"এমবেডেড নয়\",\n    noSpeakerProfilesAvailable: \"কোন স্পিকার প্রোফাইল উপলব্ধ নয়\",\n    editEpisodeProfile: \"এপিসোড প্রোফাইল সম্পাদনা করুন\",\n    createEpisodeProfile: \"এপিসোড প্রোফাইল তৈরি করুন\",\n    episodeProfileFormDesc: \"ডিফল্টভাবে এপিসোডগুলি কীভাবে তৈরি হবে এবং তারা কোন স্পিকার কনফিগারেশন ব্যবহার করবে তা নির্ধারণ করুন।\",\n    noSpeakerProfilesDesc: \"এপিসোড প্রোফাইল কনফিগার করার আগে একটি স্পিকার প্রোফাইল তৈরি করুন।\",\n    profileName: \"প্রোফাইল নাম\",\n    profileNamePlaceholder: \"যেমন, প্রযুক্তি আলোচনা\",\n    descriptionPlaceholder: \"এই প্রোফাইলটি কখন ব্যবহার করবেন তার সংক্ষিপ্ত সারাংশ\",\n    speakerConfig: \"স্পিকার কনফিগারেশন\",\n    selectSpeakerProfile: \"একটি স্পিকার প্রোফাইল নির্বাচন করুন\",\n    outlineGeneration: \"রূপরেখা তৈরি\",\n    transcriptGeneration: \"ট্রান্সক্রিপ্ট তৈরি\",\n    defaultBriefingPlaceholder: \"এই এপিসোড ফর্ম্যাটের জন্য কাঠামো, টোন এবং লক্ষ্যগুলি বর্ণনা করুন\",\n    editSpeakerProfile: \"স্পিকার প্রোফাইল সম্পাদনা করুন\",\n    createSpeakerProfile: \"স্পিকার প্রোফাইল তৈরি করুন\",\n    speakerProfileFormDesc: \"টেক্সট-টু-স্পিচ সেটিংস কনফিগার করুন এবং চারটি পর্যন্ত স্পিকার নির্ধারণ করুন।\",\n    speakers: \"স্পিকার\",\n    speakersDesc: \"এই প্রোফাইলের জন্য এক থেকে চারটি কণ্ঠস্বর কনফিগার করুন।\",\n    addSpeaker: \"স্পিকার যোগ করুন\",\n    speakerNumber: \"স্পিকার {number}\",\n    backstoryPlaceholder: \"স্পিকারের সংক্ষিপ্ত জীবনী বা প্রেক্ষাপট\",\n    personalityPlaceholder: \"শৈলী এবং টোন বর্ণনা করুন\",\n    outlineModelRequired: \"রূপরেখা মডেল প্রয়োজন\",\n    transcriptModelRequired: \"ট্রান্সক্রিপ্ট মডেল প্রয়োজন\",\n    defaultBriefingRequired: \"ডিফল্ট ব্রিফিং প্রয়োজন\",\n    segmentsInteger: \"অবশ্যই একটি পূর্ণসংখ্যা হতে হবে\",\n    segmentsMin: \"কমপক্ষে ৩টি সেগমেন্ট\",\n    segmentsMax: \"সর্বোচ্চ ২০টি সেগমেন্ট\",\n    voiceIdRequired: \"ভয়েস আইডি প্রয়োজন\",\n    backstoryRequired: \"পটভূমি প্রয়োজন\",\n    personalityRequired: \"ব্যক্তিত্ব প্রয়োজন\",\n    speakerCountMin: \"কমপক্ষে একজন স্পিকার প্রয়োজন\",\n    speakerCountMax: \"আপনি সর্বোচ্চ ৪জন স্পিকার কনফিগার করতে পারেন\",\n    delete: \"মুছে ফেলুন\",\n    failedToDelete: \"পডকাস্ট মুছে ফেলতে ব্যর্থ\",\n    retry: \"পুনঃচেষ্টা\",\n    retrying: \"পুনঃচেষ্টা করছে…\",\n    retryStarted: \"পুনঃচেষ্টা শুরু হয়েছে\",\n    retryStartedDesc: \"একটি নতুন পডকাস্ট তৈরির কাজ জমা দেওয়া হয়েছে।\",\n    failedToRetry: \"এপিসোড পুনঃচেষ্টা করতে ব্যর্থ\",\n    errorDetails: \"ত্রুটির বিবরণ\",\n    language: \"ভাষা\",\n    languagePlaceholder: \"একটি ভাষা নির্বাচন করুন (ঐচ্ছিক)\",\n    podcastLanguage: \"পডকাস্টের ভাষা\",\n    selectOutlineModel: \"রূপরেখা মডেল নির্বাচন করুন\",\n    selectTranscriptModel: \"ট্রান্সক্রিপ্ট মডেল নির্বাচন করুন\",\n    voiceModel: \"ভয়েস মডেল\",\n    voiceModelRequired: \"ভয়েস মডেল প্রয়োজন\",\n    selectVoiceModel: \"ভয়েস মডেল নির্বাচন করুন\",\n    perSpeakerTtsOverride: \"প্রতি স্পিকার TTS ওভাররাইড (ঐচ্ছিক)\",\n    useProfileDefault: \"প্রোফাইল ডিফল্ট ব্যবহার করুন\",\n    setupRequired: \"সেটআপ প্রয়োজন\",\n    setupRequiredDesc:\n      \"কিছু প্রোফাইলে এখনও মডেল কনফিগার করা হয়নি। পডকাস্ট তৈরির আগে মডেল নির্বাচন করতে সেগুলি সম্পাদনা করুন।\",\n    notConfigured: \"কনফিগার করা হয়নি\",\n  },\n  settings: {\n    contentProcessing: \"কন্টেন্ট প্রক্রিয়াকরণ\",\n    contentProcessingDesc: \"ডকুমেন্ট এবং URL কিভাবে প্রক্রিয়া করা হবে তা কনফিগার করুন\",\n    docEngine: \"ডকুমেন্ট প্রক্রিয়াকরণ ইঞ্জিন\",\n    docEnginePlaceholder: \"ডকুমেন্ট প্রক্রিয়াকরণ ইঞ্জিন নির্বাচন করুন\",\n    urlEngine: \"URL প্রক্রিয়াকরণ ইঞ্জিন\",\n    urlEnginePlaceholder: \"URL প্রক্রিয়াকরণ ইঞ্জিন নির্বাচন করুন\",\n    autoRecommended: \"স্বয়ংক্রিয় (প্রস্তাবিত)\",\n    simple: \"সাধারণ\",\n    docling: \"ডকলিং\",\n    helpMeChoose: \"আমাকে বেছে নিতে সাহায্য করুন\",\n    docHelp: \"· ডকলিং একটু ধীর কিন্তু আরো নির্ভুল, বিশেষ করে যদি ডকুমেন্টে টেবিল এবং ছবি থাকে। · সাধারণ ফর্ম্যাটিং ছাড়াই ডকুমেন্ট থেকে যেকোন কন্টেন্ট এক্সট্রাক্ট করবে। · স্বয়ংক্রিয় (প্রস্তাবিত) ডকলিং দিয়ে প্রক্রিয়া করার চেষ্টা করবে এবং সাধারণে ডিফল্ট হবে।\",\n    firecrawl: \"ফায়ারক্রল\",\n    jina: \"জিনা\",\n    urlHelp: \"· ফায়ারক্রল একটি পেইড সার্ভিস (ফ্রি টিয়ার সহ), এবং খুব শক্তিশালী। · জিনাও একটি ভাল অপশন এবং এর ফ্রি টিয়ার আছে। · সাধারণ মৌলিক HTTP এক্সট্রাকশন ব্যবহার করবে এবং জাভাস্ক্রিপ্ট-ভিত্তিক ওয়েবসাইটে কন্টেন্ট মিস করবে। · স্বয়ংক্রিয় (প্রস্তাবিত) প্রথমে ফায়ারক্রল তারপর জিনা ব্যবহার করার চেষ্টা করবে, সবশেষে সাধারণে ফলব্যাক করবে।\",\n    embeddingAndSearch: \"এমবেডিং এবং অনুসন্ধান\",\n    embeddingAndSearchDesc: \"অনুসন্ধান এবং এমবেডিং অপশন কনফিগার করুন\",\n    defaultEmbeddingOption: \"ডিফল্ট এমবেডিং অপশন\",\n    embeddingOptionPlaceholder: \"এমবেডিং অপশন নির্বাচন করুন\",\n    ask: \"জিজ্ঞাসা\",\n    always: \"সর্বদা\",\n    never: \"কখনো না\",\n    embeddingHelp: \"কন্টেন্ট এমবেড করলে আপনার এবং আপনার AI এজেন্টদের জন্য খুঁজে পাওয়া সহজ হবে। যদি আপনি একটি স্থানীয় এমবেডিং মডেল (যেমন Ollama) চালান, তাহলে খরচ নিয়ে চিন্তা না করে সবকিছু এমবেড করুন।\",\n    fileManagement: \"ফাইল ব্যবস্থাপনা\",\n    fileManagementDesc: \"ফাইল হ্যান্ডলিং এবং স্টোরেজ অপশন কনফিগার করুন\",\n    autoDeleteFiles: \"স্বয়ংক্রিয় ফাইল মুছে ফেলা\",\n    autoDeletePlaceholder: \"স্বয়ংক্রিয় মুছে ফেলার অপশন নির্বাচন করুন\",\n    filesHelp: \"একবার আপনার ফাইল আপলোড এবং প্রক্রিয়া হওয়ার পর, সেগুলি আর প্রয়োজন নেই। বেশিরভাগ ব্যবহারকারী Open Notebook কে আপলোড ফোল্ডার থেকে আপলোড করা ফাইল স্বয়ংক্রিয়ভাবে মুছে ফেলার অনুমতি দিতে পারেন।\",\n    loadFailed: \"সেটিংস লোড করতে ব্যর্থ\",\n  },\n  advanced: {\n    title: \"উন্নত টুলস\",\n    desc: \"পাওয়ার ব্যবহারকারীদের জন্য উন্নত টুল এবং ইউটিলিটি\",\n    systemInfo: \"সিস্টেম তথ্য\",\n    rebuildEmbeddings: \"এমবেডিং পুনর্নির্মাণ\",\n    rebuildEmbeddingsDesc: \"সব উৎসের জন্য ভেক্টর সার্চ ইনডেক্স পুনর্নির্মাণ\",\n    currentVersion: \"বর্তমান সংস্করণ\",\n    latestVersion: \"সর্বশেষ সংস্করণ\",\n    status: \"অবস্থা\",\n    updateAvailable: \"সংস্করণ {version} উপলব্ধ\",\n    updateAvailableDesc: \"Open Notebook এর একটি নতুন সংস্করণ উপলব্ধ।\",\n    upToDate: \"আপ টু ডেট\",\n    unknown: \"অজানা\",\n    viewOnGithub: \"GitHub এ দেখুন\",\n    updateCheckFailed: \"আপডেট চেক করতে অক্ষম। GitHub অপৌঁছানীয় হতে পারে।\",\n    rebuild: {\n      mode: \"পুনর্নির্মাণ মোড\",\n      existing: \"বিদ্যমান\",\n      all: \"সব\",\n      existingDesc: \"শুধুমাত্র এমবেডিং আছে এমন আইটেম পুনরায় এমবেড করুন (দ্রুততর, মডেল পরিবর্তনের জন্য)\",\n      allDesc: \"বিদ্যমান আইটেম পুনরায় এমবেড + কোন এমবেডিং নেই এমন আইটেমের জন্য এমবেডিং তৈরি (ধীর, ব্যাপক)\",\n      include: \"পুনর্নির্মাণে অন্তর্ভুক্ত\",\n      selectOneError: \"অনুগ্রহ করে পুনর্নির্মাণের জন্য কমপক্ষে একটি আইটেম ধরন নির্বাচন করুন\",\n      starting: \"পুনর্নির্মাণ শুরু করা হচ্ছে...\",\n      startBtn: \"🚀 পুনর্নির্মাণ শুরু করুন\",\n      queued: \"কিউ করা\",\n      running: \"কাজ জমা দেওয়া হচ্ছে...\",\n      completed: \"কাজ জমা দেওয়া হয়েছে!\",\n      failed: \"ব্যর্থ\",\n      leavePageHint: \"আপনি এই পৃষ্ঠা ছেড়ে যেতে পারেন কারণ এটি ব্যাকগ্রাউন্ডে চলবে\",\n      startNew: \"নতুন পুনর্নির্মাণ শুরু করুন\",\n      itemsProcessed: \"{processed}/{total} কাজ জমা দেওয়া হয়েছে ({percent}%)\",\n      failedItems: \"{count} কাজ জমা দিতে ব্যর্থ\",\n      time: \"সময়\",\n      whenToRebuild: \"কখন এমবেডিং পুনর্নির্মাণ করা উচিত?\",\n      whenToRebuildAns: \"মডেল পরিবর্তন, সংস্করণ আপগ্রেড, দুর্নীতি ঠিক করা বা বাল্ক ইমপোর্টের পরে আপনার পুনর্নির্মাণ করা উচিত।\",\n      howLong: \"পুনর্নির্মাণে কত সময় লাগে?\",\n      howLongAns: \"প্রক্রিয়াকরণের সময় আইটেম সংখ্যা, মডেলের গতি এবং API রেট লিমিটের উপর নির্ভর করে। স্থানীয় মডেল সাধারণত খুব দ্রুত।\",\n      isSafe: \"অ্যাপ ব্যবহার করার সময় পুনর্নির্মাণ নিরাপদ?\",\n      isSafeAns: \"হ্যাঁ, পুনর্নির্মাণ নিরাপদ! এটি কন্টেন্ট মুছে ফেলে না, শুধুমাত্র এমবেডিং প্রতিস্থাপন করে, এবং ত্রুটি সুন্দরভাবে পরিচালনা করে।\",\n    },\n  },\n  transformations: {\n    title: \"ট্রান্সফরমেশন\",\n    desc: \"ট্রান্সফরমেশনগুলি হল প্রম্পট যা LLM দ্বারা একটি উৎস প্রক্রিয়া করতে এবং অন্তর্দৃষ্টি, সারাংশ ইত্যাদি বের করতে ব্যবহৃত হবে।\",\n    workspace: \"একটি ওয়ার্কস্পেস বেছে নিন\",\n    playground: \"খেলার জায়গা\",\n    defaultPrompt: \"ডিফল্ট ট্রান্সফরমেশন প্রম্পট\",\n    defaultPromptDesc: \"এটি আপনার সব ট্রান্সফরমেশন প্রম্পটে যোগ করা হবে\",\n    defaultPromptPlaceholder: \"আপনার ডিফল্ট ট্রান্সফরমেশন নির্দেশনা লিখুন...\",\n    listTitle: \"কাস্টম ট্রান্সফরমেশন\",\n    createNew: \"নতুন তৈরি করুন\",\n    inputLabel: \"ইনপুট টেক্সট\",\n    inputPlaceholder: \"ট্রান্সফর্ম করার জন্য কিছু টেক্সট লিখুন...\",\n    outputLabel: \"আউটপুট\",\n    runTest: \"ট্রান্সফরমেশন চালান\",\n    running: \"চালানো হচ্ছে...\",\n    selectToStart: \"শুরু করতে একটি ট্রান্সফরমেশন নির্বাচন করুন\",\n    name: \"নাম\",\n    namePlaceholder: \"ইউনিক আইডেন্টিফায়ার, যেমন key_topics\",\n    titlePlaceholder: \"প্রদর্শিত শিরোনাম, নামে ডিফল্ট\",\n    promptPlaceholder: \"এই ট্রান্সফরমেশন চালানোর প্রম্পট লিখুন...\",\n    descriptionPlaceholder: \"এই ট্রান্সফরমেশন কি করে তার বর্ণনা দিন।\",\n    suggestDefault: \"নতুন উৎসে ডিফল্ট হিসেবে পরামর্শ দিন\",\n    promptHint: \"প্রম্পট উৎসের কন্টেন্ট মাথায় রেখে লিখতে হবে। আপনি মডেলকে সারাংশ, অন্তর্দৃষ্টি বের করতে বা টেবিলের মতো স্ট্রাকচার্ড আউটপুট তৈরি করতে বলতে পারেন।\",\n    createSuccess: \"ট্রান্সফরমেশন সফলভাবে তৈরি হয়েছে\",\n    updateSuccess: \"ট্রান্সফরমেশন সফলভাবে আপডেট হয়েছে\",\n    deleteSuccess: \"ট্রান্সফরমেশন সফলভাবে মুছে ফেলা হয়েছে\",\n    noTransformations: \"এখনও কোন ট্রান্সফরমেশন নেই\",\n    createOne: \"শুরু করতে একটি ট্রান্সফরমেশন তৈরি করুন\",\n    selectModel: \"একটি মডেল নির্বাচন করুন\",\n    deleteConfirm: \"আপনি কি নিশ্চিত এই ট্রান্সফরমেশন মুছে ফেলতে চান?\",\n    model: \"মডেল\",\n    systemPrompt: \"সিস্টেম প্রম্পট\",\n    overrideModelDesc: \"এই চ্যাট সেশনের জন্য ডিফল্ট মডেল ওভাররাইড করুন। সিস্টেম ডিফল্ট ব্যবহার করতে খালি রাখুন।\",\n    sessionUseReplacement: \"এই সেশন ডিফল্ট মডেলের পরিবর্তে {name} ব্যবহার করবে।\",\n    systemDefault: \"সিস্টেম ডিফল্ট\",\n  },\n  models: {\n    embedding: \"এমবেডিং মডেল\",\n    tts: \"টেক্সট টু স্পিচ (TTS)\",\n    stt: \"স্পিচ টু টেক্সট (STT)\",\n    apiKey: \"API কী\",\n    deleteSuccess: \"মডেল সফলভাবে মুছে ফেলা হয়েছে\",\n    saveSuccess: \"মডেল সফলভাবে সংরক্ষিত হয়েছে\",\n    noModels: \"কোন মডেল নেই\",\n    discoverModels: \"মডেল আবিষ্কার করুন\",\n    noModelsFound: \"এই প্রোভাইডার থেকে কোন মডেল পাওয়া যায়নি\",\n    modelType: \"মডেল ধরন\",\n    modelTypeHint: \"আপনি যেই ধরনের মডেল যোগ করতে চান তা নির্বাচন করুন। যদি আপনার বিভিন্ন ধরনের প্রয়োজন হয়, তাহলে আলাদা ব্যাচে যোগ করুন।\",\n    deleteModel: \"মডেল মুছুন\",\n    defaultAssignments: \"ডিফল্ট মডেল অ্যাসাইনমেন্ট\",\n    defaultAssignmentsDesc: \"Open Notebook জুড়ে বিভিন্ন কাজের জন্য কোন মডেল ব্যবহার করব তা কনফিগার করুন\",\n    missingRequiredModels: \"প্রয়োজনীয় মডেল অনুপস্থিত: {models}। এগুলি ছাড়া Open Notebook সঠিকভাবে কাজ নাও করতে পারে।\",\n    selectModelPlaceholder: \"একটি মডেল নির্বাচন করুন\",\n    requiredModelPlaceholder: \"⚠️ প্রয়োজন - একটি মডেল নির্বাচন করুন\",\n    chatModelLabel: \"চ্যাট মডেল\",\n    chatModelDesc: \"চ্যাট কথোপকথনের জন্য ব্যবহৃত\",\n    transformationModelLabel: \"ট্রান্সফরমেশন মডেল\",\n    transformationModelDesc: \"সারাংশ, অন্তর্দৃষ্টি এবং ট্রান্সফরমেশনের জন্য ব্যবহৃত\",\n    toolsModelLabel: \"টুলস মডেল\",\n    toolsModelDesc: \"ফাংশন কলিং এর জন্য ব্যবহৃত - OpenAI বা Anthropic প্রস্তাবিত\",\n    largeContextModelLabel: \"বড় কন্টেক্সট মডেল\",\n    largeContextModelDesc: \"বড় ডকুমেন্ট প্রক্রিয়াকরণের জন্য ব্যবহৃত - Gemini প্রস্তাবিত\",\n    embeddingModelLabel: \"এমবেডিং মডেল\",\n    embeddingModelDesc: \"সেমান্টিক সার্চ এবং ভেক্টর এমবেডিংয়ের জন্য ব্যবহৃত\",\n    ttsModelLabel: \"টেক্সট-টু-স্পিচ মডেল\",\n    ttsModelDesc: \"পডকাস্ট তৈরির জন্য ব্যবহৃত\",\n    sttModelLabel: \"স্পিচ-টু-টেক্সট মডেল\",\n    sttModelDesc: \"অডিও ট্রান্সক্রিপশনের জন্য ব্যবহৃত\",\n    embeddingChangeTitle: \"এমবেডিং মডেল পরিবর্তন\",\n    embeddingChangeConfirm: \"আপনি আপনার এমবেডিং মডেল {from} থেকে {to} তে পরিবর্তন করতে যাচ্ছেন।\",\n    rebuildRequired: \"গুরুত্বপূর্ণ: পুনর্নির্মাণ প্রয়োজন\",\n    rebuildReason: \"আপনার এমবেডিং মডেল পরিবর্তন করার জন্য সামঞ্জস্য বজায় রাখতে সব বিদ্যমান এমবেডিং পুনর্নির্মাণ প্রয়োজন। পুনর্নির্মাণ ছাড়া, আপনার অনুসন্ধান ভুল বা অসম্পূর্ণ ফলাফল ফিরিয়ে দিতে পারে।\",\n    whatHappensNext: \"পরবর্তীতে কি ঘটে:\",\n    step1: \"আপনার ডিফল্ট এমবেডিং মডেল আপডেট হবে\",\n    step2: \"পুনর্নির্মাণ পর্যন্ত বিদ্যমান এমবেডিংগুলি অপরিবর্তিত থাকবে\",\n    step3: \"নতুন কন্টেন্ট নতুন এমবেডিং মডেল ব্যবহার করবে\",\n    step4: \"আপনার যত তাড়াতাড়ি সম্ভব এমবেডিং পুনর্নির্মাণ করা উচিত\",\n    proceedToRebuildPrompt: \"এখনই পুনর্নির্মাণ শুরু করতে Advanced পেজে যেতে চান?\",\n    changeModelOnly: \"শুধু মডেল পরিবর্তন\",\n    changeAndRebuild: \"পরিবর্তন এবং পুনর্নির্মাণে যান\",\n    autoAssign: \"স্বয়ংক্রিয় ডিফল্ট অ্যাসাইন\",\n    autoAssigning: \"অ্যাসাইন করা হচ্ছে...\",\n    autoAssignSuccess: \"{count}টি ডিফল্ট মডেল স্বয়ংক্রিয়ভাবে অ্যাসাইন করা হয়েছে\",\n    autoAssignNoModels: \"অ্যাসাইনের জন্য কোন মডেল উপলব্ধ নেই। অনুগ্রহ করে প্রথমে মডেল সিঙ্ক করুন।\",\n    autoAssignAlreadySet: \"সব ডিফল্ট মডেল ইতিমধ্যে কনফিগার করা আছে\",\n    testModel: \"মডেল পরীক্ষা\",\n    testModelSuccess: \"মডেল পরীক্ষা পাস\",\n    testModelFailed: \"মডেল পরীক্ষা ব্যর্থ\",\n    searchOrAddModel: \"মডেলের নাম খুঁজুন বা টাইপ করুন...\",\n    addCustomModel: \"\\\"{name}\\\" যোগ করুন\",\n  },\n  apiKeys: {\n    title: \"আপনার নিজের API কী দিয়ে আপনার AI কনফিগার করুন\",\n    description: \"Open Notebook এ AI প্রোভাইডার সক্ষম করতে ডেটাবেসে নিরাপদভাবে API কী সংরক্ষণ করুন।\",\n    encryptionRequired: \"এনক্রিপশন কী কনফিগার করা হয়নি\",\n    encryptionRequiredDescription: \"ডেটাবেসে API কী সংরক্ষণ করতে OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable যেকোন গোপন স্ট্রিংয়ে সেট করুন।\",\n    configured: \"কনফিগার করা\",\n    notConfigured: \"কনফিগার করা হয়নি\",\n    migrationAvailable: \"এনভায়রনমেন্ট ভ্যারিয়েবল সনাক্ত করা হয়েছে\",\n    migrationDescription: \"{count}টি API কী environment variable এর মাধ্যমে কনফিগার করা আছে এবং সহজ ব্যবস্থাপনার জন্য ডেটাবেসে মাইগ্রেট করা যেতে পারে।\",\n    migrateToDatabase: \"ডেটাবেসে মাইগ্রেট করুন\",\n    migrating: \"মাইগ্রেট করা হচ্ছে...\",\n    migrationSuccess: \"{count}টি API কী সফলভাবে মাইগ্রেট হয়েছে\",\n    migrationErrors: \"{count}টি কী মাইগ্রেট করতে ব্যর্থ\",\n    migrationNothingToMigrate: \"সব কী ইতিমধ্যে ডেটাবেসে আছে\",\n    learnMore: \"API কী কনফিগার করা শিখুন →\",\n    testConnection: \"সংযোগ পরীক্ষা\",\n    testSuccess: \"সংযোগ সফল\",\n    testFailed: \"সংযোগ পরীক্ষা ব্যর্থ\",\n    syncModels: \"মডেল সিঙ্ক করুন\",\n    syncSuccess: \"{discovered}টি মডেল আবিষ্কৃত, {new}টি নতুন যোগ\",\n    syncNoNew: \"{count}টি মডেল আবিষ্কৃত, সব ইতিমধ্যে নিবন্ধিত\",\n    syncFailed: \"মডেল সিঙ্ক করতে ব্যর্থ\",\n    getApiKey: \"API কী পান\",\n    vertexProject: \"GCP প্রজেক্ট ID\",\n    vertexLocation: \"অঞ্চল\",\n    vertexCredentials: \"সার্ভিস অ্যাকাউন্ট JSON পাথ\",\n    addConfig: \"কনফিগারেশন যোগ করুন\",\n    editConfig: \"কনফিগারেশন সম্পাদনা\",\n    deleteConfig: \"কনফিগারেশন মুছুন\",\n    configName: \"কনফিগারেশনের নাম\",\n    configNameHint: \"এই কনফিগারেশনের জন্য একটি বর্ণনামূলক নাম (যেমন, 'Production', 'Development')\",\n    baseUrl: \"বেস URL\",\n    baseUrlOverrideHint: \"শুধুমাত্র তখনই এটি পরিবর্তন করুন যদি আপনার প্রোভাইডারের ডিফল্ট API এন্ডপয়েন্ট ওভাররাইড করতে হয়।\",\n    deleteConfigConfirm: \"আপনি কি নিশ্চিত '{name}' মুছে ফেলতে চান? এটি পুনরায় করা যাবে না।\",\n    configSaveSuccess: \"কনফিগারেশন সফলভাবে সংরক্ষিত\",\n    configUpdateSuccess: \"কনফিগারেশন সফলভাবে আপডেট\",\n    configDeleteSuccess: \"কনফিগারেশন সফলভাবে মুছে ফেলা\",\n    apiKeyEditHint: \"বিদ্যমান API কী রাখতে খালি রাখুন\",\n  },\n  setupBanner: {\n    encryptionRequired: \"এনক্রিপশন কী কনফিগার করা হয়নি\",\n    encryptionRequiredDescription: \"নিরাপদ credential স্টোরেজ সক্ষম করতে OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable সেট করুন।\",\n    migrationAvailable: \"API key মাইগ্রেশন উপলব্ধ\",\n    migrationDescription: \"{count}টি প্রভাইডারের API key environment variable দিয়ে সেট করা আছে। সহজ ব্যবস্থাপনার জন্য সেগুলি ডেটাবেসে মাইগ্রেট করুন।\",\n    goToSettings: \"সেটিংসে যান\",\n    viewDocs: \"ডকুমেন্টেশন দেখুন\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/en-US/index.ts",
    "content": "export const enUS = {\n  common: {\n    search: \"Search...\",\n    create: \"New\",\n    new: \"New\",\n    cancel: \"Cancel\",\n    delete: \"Delete\",\n    edit: \"Edit\",\n    theme: \"Theme\",\n    signOut: \"Sign Out\",\n    noMatches: \"No matches found\",\n    tryDifferentSearch: \"Try using a different search term.\",\n    light: \"Light\",\n    dark: \"Dark\",\n    system: \"System\",\n    loading: \"Loading...\",\n    note: \"Note\",\n    insight: \"Insight\",\n    newSource: \"New Source\",\n    newNotebook: \"New Notebook\",\n    newPodcast: \"New Podcast\",\n    language: \"Language\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"Source\",\n    notebook: \"Notebook\",\n    podcast: \"Podcast\",\n    quickActions: \"Quick actions\",\n    quickActionsDesc: \"Navigation, search, ask, theme\",\n    appName: \"Open Notebook\",\n    add: \"Add\",\n    remove: \"Remove\",\n    confirm: \"Confirm\",\n    warning: \"Warning\",\n    error: \"Error\",\n    success: \"Success\",\n    model: \"Model\",\n    back: \"Back\",\n    next: \"Next\",\n    done: \"Done\",\n    processing: \"Processing...\",\n    creating: \"Creating...\",\n    linked: \"Linked\",\n    adding: \"Adding...\",\n    addSelected: \"Add Selected\",\n    customModel: \"Custom Model\",\n    failed: \"failed\",\n    current: \"Current\",\n    save: \"Save\",\n    writeNote: \"Write Note\",\n    batchMode: \"Batch Mode\",\n    optional: \"Optional\",\n    type: \"Type\",\n    title: \"Title\",\n    created: \"Created {time}\",\n    updated: \"Updated {time}\",\n    actions: \"Actions\",\n    noResults: \"No results\",\n    references: \"References\",\n    refreshPage: \"Please try refreshing the page\",\n    refresh: \"Refresh\",\n    aiGenerated: \"AI Generated\",\n    human: \"Human\",\n    unknown: \"Unknown\",\n    notes: \"Notes\",\n    chat: \"Chat\",\n    deleteForever: \"Delete Forever\",\n    connectionError: \"Connection Error\",\n    unableToConnect: \"Unable to connect to the API server\",\n    retryConnection: \"Retry Connection\",\n    diagnosticInfo: \"Diagnostic Information\",\n    version: \"Version\",\n    built: \"Built\",\n    apiUrl: \"API URL\",\n    frontendUrl: \"Frontend URL\",\n    checkConsoleLogs: \"Check browser console for detailed logs (look for 🔧 [Config] messages)\",\n    yes: \"Yes\",\n    no: \"No\",\n    saving: \"Saving...\",\n    description: \"Description\",\n    saveToNote: \"Save to note\",\n    copyToClipboard: \"Copy to clipboard\",\n    close: \"Close\",\n    insights: \"Insights\",\n    progress: \"Progress\",\n    deleting: \"Deleting...\",\n    created_label: \"Created\",\n    updated_label: \"Updated\",\n    download: \"Download\",\n    saveChanges: \"Save Changes\",\n    name: \"Name\",\n    default: \"Default\",\n    nameRequired: \"Name is required\",\n    modelConfiguration: \"Model Configuration\",\n    resetToDefault: \"Reset to Default\",\n    reasoning: \"Reasoning\",\n    searchTerms: \"Search Terms\",\n    strategy: \"Strategy\",\n    individualAnswers: \"Individual Answers ({count})\",\n    finalAnswer: \"Final Answer\",\n    notebookLabel: \"Notebook: {name}\",\n    itemNotFound: \"This {type} could not be found\",\n    accessibility: {\n      transformationViews: \"Transformation views\",\n      searchKB: \"Ask or search your knowledge base\",\n      enterQuestion: \"Enter your question to ask the knowledge base\",\n      enterSearch: \"Enter search query\",\n      searchKBBtn: \"Search knowledge base\",\n      podcastViews: \"Podcast views\",\n      ytVideo: \"YouTube video\",\n      askResponse: \"Ask Response\",\n      searchNotebooks: \"Search notebooks\",\n    },\n    url: \"URL\",\n    errorDetails: \"Error Details\",\n    editTransformation: \"Edit Transformation\",\n    retry: \"Try Again\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"completed\",\n    saveSuccess: \"Saved successfully\",\n    contextModes: {\n      off: \"Not included in chat\",\n      insights: \"Insights only\",\n      full: \"Full content\",\n      clickToCycle: \"Click to cycle\",\n    },\n    clickToEdit: \"Click to edit\",\n  },\n  apiErrors: {\n    notebookNotFound: \"Notebook not found\",\n    sourceNotFound: \"Source not found\",\n    transformationNotFound: \"Transformation not found\",\n    fileUploadFailed: \"File upload failed\",\n    urlRequired: \"URL is required for link type\",\n    contentRequired: \"Content is required for text type\",\n    invalidSourceType: \"Invalid source type\",\n    processingFailed: \"Processing failed\",\n    failedToQueue: \"Failed to queue processing\",\n    invalidSortBy: \"Sort field must be 'created' or 'updated'\",\n    invalidSortOrder: \"Sort order must be 'asc' or 'desc'\",\n    accessDenied: \"Access to file denied\",\n    fileNotFoundOnServer: \"File not found on server\",\n    searchFailed: \"Search failed\",\n    askFailed: \"Ask failed\",\n    pleaseEnterQuestion: \"Please enter a question\",\n    pleaseConfigureModels: \"Please configure all required models\",\n    failedToCreateSession: \"Failed to create session\",\n    failedToUpdateSession: \"Failed to update session\",\n    failedToDeleteSession: \"Failed to delete session\",\n    failedToSendMessage: \"Failed to send message\",\n    unauthorized: \"Unauthorized access, please check your password\",\n    invalidPassword: \"Invalid password\",\n    embeddingModelRequired: \"This feature requires an embedding model. Please configure one in the Models section.\",\n    strategyModelNotFound: \"Strategy model not found\",\n    answerModelNotFound: \"Answer model not found\",\n    finalAnswerModelNotFound: \"Final answer model not found\",\n    noAnswerGenerated: \"No answer could be generated\",\n    genericError: \"An unexpected error occurred\",\n  },\n  connectionErrors: {\n    apiTitle: \"Unable to Connect to API Server\",\n    apiDesc: \"The Open Notebook API server could not be reached\",\n    dbTitle: \"Database Connection Failed\",\n    dbDesc: \"The API server is running, but the database is not accessible\",\n    troubleshooting: \"This usually means:\",\n    apiUnreachable1: \"The API server is not running\",\n    apiUnreachable2: \"The API server is running on a different address\",\n    apiUnreachable3: \"Network connectivity issues\",\n    dbFailed1: \"SurrealDB is not running\",\n    dbFailed2: \"Database connection settings are incorrect\",\n    dbFailed3: \"Network issues between API and database\",\n    quickFixes: \"Quick fixes:\",\n    setApiUrl: \"Set the API_URL environment variable:\",\n    checkSurreal: \"Check if SurrealDB is running:\",\n    seeDocumentation: \"For detailed setup instructions, see:\",\n    docLink: \"Open Notebook Documentation\",\n    showTechnical: \"Show Technical Details\",\n    attemptedUrl: \"Attempted URL\",\n    message: \"Message\",\n    technicalDetails: \"Technical Details\",\n    stackTrace: \"Stack Trace\",\n    retryLabel: \"Retry Connection\",\n    retryHint: \"Press R or click the button to retry\",\n    dockerLabel: \"For Docker\",\n    localDevLabel: \"For local development\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"Enter your password to access the application\",\n    passwordPlaceholder: \"Password\",\n    signingIn: \"Signing in...\",\n    signIn: \"Sign In\",\n    connectErrorHint: \"Unable to connect to server. Please check if the API is running.\",\n  },\n  navigation: {\n    collect: \"Collect\",\n    process: \"Process\",\n    create: \"Create\",\n    manage: \"Manage\",\n    sources: \"Sources\",\n    notebooks: \"Notebooks\",\n    askAndSearch: \"Ask and Search\",\n    podcasts: \"Podcasts\",\n    models: \"Models\",\n    transformations: \"Transformations\",\n    transformation: \"Transformation\",\n    settings: \"Settings\",\n    advanced: \"Advanced\",\n    nav: \"Navigation\",\n    language: \"Toggle language\",\n    theme: \"Theme\",\n    ask: \"Ask\",\n  },\n  notebooks: {\n    title: \"Notebooks\",\n    newNotebook: \"New Notebook\",\n    searchPlaceholder: \"Search notebooks...\",\n    archived: \"Archived\",\n    archive: \"Archive\",\n    unarchive: \"Unarchive\",\n    deleteNotebook: \"Delete Notebook\",\n    deleteNotebookDesc: \"Are you sure you want to delete \\\"{name}\\\"? This action cannot be undone.\",\n    deleteNotebookLoading: \"Loading deletion preview...\",\n    deleteNotebookNotes: \"{count} note(s) will be permanently deleted.\",\n    deleteNotebookNoNotes: \"No notes to delete.\",\n    deleteNotebookExclusiveSources: \"{count} source(s) exist only in this notebook.\",\n    deleteNotebookSharedSources: \"{count} source(s) are shared with other notebooks and will be unlinked.\",\n    deleteNotebookNoSources: \"No sources in this notebook.\",\n    deleteExclusiveSourcesLabel: \"Delete exclusive sources\",\n    keepExclusiveSourcesLabel: \"Unlink and keep them\",\n    activeNotebooks: \"Active Notebooks\",\n    archivedNotebooks: \"Archived Notebooks\",\n    notFound: \"Notebook not found\",\n    notFoundDesc: \"The requested notebook does not exist.\",\n    updated: \"Updated\",\n    namePlaceholder: \"Notebook name\",\n    addDescription: \"Add description...\",\n    noNotesYet: \"No notes yet\",\n    deleteNote: \"Delete Note\",\n    deleteNoteConfirm: \"Are you sure you want to delete this note? This action cannot be undone.\",\n    noteCreatedSuccess: \"Note created successfully\",\n    failedToCreateNote: \"Failed to create note\",\n    noteUpdatedSuccess: \"Note updated successfully\",\n    failedToUpdateNote: \"Failed to update note\",\n    noteDeletedSuccess: \"Note deleted successfully\",\n    failedToDeleteNote: \"Failed to delete note\",\n    createNew: \"Create New Notebook\",\n    createNewDesc: \"Enter a name and optional description to get started.\",\n    descPlaceholder: \"Add more info about this notebook here...\",\n    createSuccess: \"Notebook created successfully\",\n    updateSuccess: \"Notebook updated successfully\",\n    deleteSuccess: \"Notebook deleted successfully\",\n  },\n  sources: {\n    title: \"Sources\",\n    add: \"Add Source\",\n    addNew: \"Add New Source\",\n    addExisting: \"Add Existing Source\",\n    delete: \"Delete Source\",\n    statusPreparing: \"Preparing\",\n    statusQueued: \"Queued\",\n    statusProcessing: \"Processing\",\n    statusCompleted: \"Completed\",\n    statusFailed: \"Failed\",\n    statusPreparingDesc: \"Preparing to process\",\n    statusQueuedDesc: \"Waiting to be processed\",\n    statusProcessingDesc: \"Being processed\",\n    statusCompletedDesc: \"Successfully processed\",\n    statusFailedDesc: \"Processing failed\",\n    failedToLoad: \"Failed to load sources\",\n    allSourcesDesc: \"View all your sources here. You can add new sources or manage existing ones.\",\n    allSources: \"All Sources\",\n    insights: \"Insights\",\n    yes: \"Yes\",\n    no: \"No\",\n    loadingMore: \"Loading more...\",\n    noSourcesYet: \"No sources yet\",\n    allSourcesDescShort: \"View all your sources here.\",\n    cannotSaveNoteNoNotebook: \"Cannot save note: notebook ID not available\",\n    createFirstSource: \"Add your first source to start building your knowledge base.\",\n    deleteSourceConfirm: \"Are you sure you want to delete this source?\",\n    deleteConfirm: \"Are you sure you want to delete this?\",\n    deleteConfirmWithTitle: \"Are you sure you want to delete \\\"{title}\\\"?\",\n    deleteSuccess: \"Source deleted successfully. Note: To delete the file from storage, you must enable checking the \\\"delete file\\\" option in the settings page.\",\n    failedToDelete: \"Failed to delete source\",\n    sourceQueued: \"Source Queued\",\n    sourceQueuedDesc: \"Source submitted for background processing. You can monitor progress in the sources list.\",\n    sourceAddedSuccess: \"Source added successfully\",\n    failedToAddSource: \"Failed to add source\",\n    sourceUpdatedSuccess: \"Source updated successfully\",\n    failedToUpdateSource: \"Failed to update source\",\n    sourceDeletedSuccess: \"Source deleted successfully\",\n    failedToDeleteSource: \"Failed to delete source\",\n    fileUploadedSuccess: \"File uploaded successfully\",\n    failedToUploadFile: \"Failed to upload file\",\n    sourceRequeued: \"Source Retry Queued\",\n    sourceRequeuedDesc: \"The source has been requeued for processing.\",\n    failedToRetry: \"Retry Failed\",\n    sourcesAddedToNotebook: \"{count} source(s) added to notebook\",\n    failedToAddSourcesToNotebook: \"Failed to add sources to notebook\",\n    partialAddSuccess: \"{success} source(s) added, {failed} failed\",\n    sourceRemovedFromNotebook: \"Source removed from notebook successfully\",\n    failedToRemoveSourceFromNotebook: \"Failed to remove source from notebook\",\n    removeConfirm: \"Are you sure you want to remove this from the notebook?\",\n    checking: \"Checking...\",\n    untitledSource: \"Untitled Source\",\n    maxItems: \"max {count}\",\n    insightsCount: \"{count} insights\",\n    details: \"Details\",\n    detailsTitle: \"Source Details\",\n    content: \"Content\",\n    metadata: \"Metadata\",\n    type: {\n      link: \"Link\",\n      file: \"File\",\n      text: \"Text\",\n    },\n    id: \"Source ID\",\n    topics: \"Topics\",\n    embedded: \"Embedded\",\n    notEmbedded: \"Not Embedded\",\n    embedContent: \"Embed Content\",\n    embedding: \"Embedding...\",\n    alreadyEmbedded: \"Already Embedded\",\n    downloadFile: \"Download File\",\n    fileUnavailable: \"File unavailable\",\n    preparing: \"Preparing...\",\n    generateNewInsight: \"Generate New Insight\",\n    selectTransformation: \"Select a transformation...\",\n    noInsightsYet: \"No insights yet\",\n    createFirstInsight: \"Create your first insight using a transformation above\",\n    viewInsight: \"View Insight\",\n    deleteInsight: \"Delete Insight\",\n    deleteInsightConfirm: \"Are you sure you want to delete this insight? This action cannot be undone.\",\n    insightGenerationStarted: \"Insight generation started. It will appear shortly.\",\n    editNote: \"Edit note\",\n    createNote: \"Create note\",\n    addTitle: \"Add a title...\",\n    untitledNote: \"Untitled Note\",\n    writeNotePlaceholder: \"Write your note content here...\",\n    saveNote: \"Save Note\",\n    createNoteBtn: \"Create Note\",\n    createFirstNote: \"Create your first note to capture insights and observations.\",\n    urlLabel: \"URL(s) *\",\n    fileLabel: \"File(s) *\",\n    textContentLabel: \"Text Content *\",\n    enterUrlsPlaceholder: \"Enter URLs, one per line\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"Paste multiple URLs (one per line) to batch import\",\n    invalidUrlsDetected: \"Invalid URLs detected:\",\n    lineLabel: \"Line {line}\",\n    fixInvalidUrls: \"Please fix or remove invalid URLs to continue\",\n    selectMultipleFilesHint: \"Select multiple files to batch import. Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)\",\n    selectedFiles: \"Selected files:\",\n    textPlaceholder: \"Paste or type your content here...\",\n    htmlDetected: \"HTML content detected. It will be converted to Markdown after processing.\",\n    titlePlaceholder: \"Give your source a descriptive title\",\n    batchTitlesAuto: \"Titles will be automatically generated for each source.\",\n    batchCommonSettings: \"The same notebooks and transformations will be applied to all items.\",\n    urlsCount: \"{count} URL(s)\",\n    filesCount: \"{count} file(s)\",\n    addSource: \"Add Source\",\n    notEmbeddedAlert: \"Content Not Embedded\",\n    notEmbeddedDesc: \"This content hasn't been embedded for vector search. Embedding enables advanced search capabilities and better content discovery.\",\n    openOnYoutube: \"Open on YouTube\",\n    urlCopied: \"URL copied to clipboard\",\n    viewSource: \"View Source\",\n    noInsightSelected: \"No insight selected\",\n    sourceInsight: \"Source Insight\",\n    manageNotebooks: \"Manage Notebooks\",\n    manageNotebooksDesc: \"Manage which notebooks contain this source\",\n    noNotebooksAvailable: \"No notebooks available\",\n    loadFailed: \"Failed to load source details\",\n    removeFromNotebook: \"Remove from Notebook\",\n    retryProcessing: \"Retry Processing\",\n    deleteSource: \"Delete Source\",\n    retry: \"Retry\",\n    addExistingTitle: \"Add Existing Sources\",\n    addExistingDesc: \"Select existing sources from across all your notebooks to add to the current one.\",\n    searchPlaceholder: \"Search sources by name or URL...\",\n    noNotebooksFound: \"No notebooks found.\",\n    showingFirst100: \"Showing first 100 sources. Use search to find specific ones.\",\n    selectedCount: \"{count} sources selected\",\n    added: \"Added on {date}\",\n    addUrl: \"Add URL\",\n    uploadFile: \"Upload File\",\n    enterText: \"Enter Text\",\n    processDescription: \"Content will be processed and analyzed by AI.\",\n    processingFiles: \"Processing your files...\",\n    titleRequired: \"A title is required for text content\",\n    titleGenerated: \"If left empty, a title will be generated from the content\",\n    batchCount: \"{count} {type} will be processed\",\n    enableEmbedding: \"Enable embedding for search\",\n    embeddingDesc: \"Allows this source to be found in vector searches and AI queries\",\n    embeddingAlways: \"Embedding enabled automatically\",\n    embeddingAlwaysDesc: \"Your settings are configured to always embed content for vector search.\",\n    embeddingNever: \"Embedding disabled\",\n    embeddingNeverDesc: \"Your settings are configured to skip embedding. Vector search won't be available for this source.\",\n    changeInSettings: \"You can change this in Settings\",\n    notFound: \"Source not found\",\n    noContent: \"No content available\",\n    insightsDesc: \"Insights generated from model analysis\",\n    uploadedFile: \"Uploaded file\",\n    fileUnavailableDesc: \"This file is currently unavailable due to storage system reasons.\",\n    batchSuccess: \"{count} source(s) created successfully\",\n    batchFailed: \"Failed to create all {count} sources\",\n    batchPartial: \"{success} succeeded, {failed} failed\",\n    submittingSource: \"Submitting source for processing...\",\n    processingBatchSources: \"Processing {count} sources. This may take a few moments.\",\n    processingSource: \"Your source is being processed. This may take a few moments.\",\n    maxFilesAllowed: \"Maximum {count} files allowed per batch\",\n  },\n  chat: {\n    sessions: \"Sessions\",\n    sessionTitlePlaceholder: \"Type a title here...\",\n    noSessions: \"No chat sessions yet\",\n    deleteSession: \"Delete Session\",\n    deleteSessionDesc: \"Are you sure you want to delete this chat session? This action cannot be undone.\",\n    sendPlaceholder: \"Ask anything about your sources...\",\n    sessionsTitle: \"Chat Sessions\",\n    chatWith: \"Chat with {name}\",\n    startConversation: \"Start a conversation about this {type}\",\n    askQuestions: \"Ask questions to understand the content better\",\n    pressToSend: \"Press {key} to send\",\n    model: \"Model\",\n    createToStart: \"Create a session to start.\",\n    chatWithNotebook: \"Chat with Notebook\",\n    unableToLoadChat: \"Unable to load chat\",\n    noDescription: \"No description\",\n    startByCreating: \"Start by creating your first notebook to organize your research.\",\n    messagesCount: \"{count} messages\",\n    sessionCreated: \"Chat session created\",\n    sessionUpdated: \"Session updated\",\n    sessionDeleted: \"Session deleted\",\n  },\n  searchPage: {\n    askAndSearch: \"Ask and Search\",\n    chooseAMode: \"Choose a mode\",\n    askBeta: \"Ask (beta)\",\n    search: \"Search\",\n    askYourKb: \"Ask Your Knowledge Base (beta)\",\n    askYourKbDesc: \"The LLM will answer your query based on the documents in your knowledge base.\",\n    question: \"Question\",\n    enterQuestionPlaceholder: \"Enter your question...\",\n    pressToSubmit: \"Press Cmd/Ctrl+Enter to submit\",\n    noEmbeddingModel: \"You can't use this feature because you have no embedding model selected. Please set one up in the Models page.\",\n    usingCustomModels: \"Using Custom Models\",\n    usingDefaultModels: \"Using Default Models\",\n    advanced: \"Advanced\",\n    strategy: \"Strategy\",\n    answer: \"Answer\",\n    final: \"Final\",\n    ask: \"Ask\",\n    processing: \"Processing...\",\n    saveToNotebooks: \"Save to Notebooks\",\n    searchDesc: \"Search your knowledge base for specific keywords or concepts\",\n    enterSearchPlaceholder: \"Enter search query...\",\n    pressToSearch: \"Press Enter to search\",\n    searchType: \"Search Type\",\n    vectorSearchWarning: \"Vector search requires an embedding model. Only text search is available.\",\n    textSearch: \"Text Search\",\n    vectorSearch: \"Vector Search\",\n    searchIn: \"Search In\",\n    searchSources: \"Search Sources\",\n    searchNotes: \"Search Notes\",\n    resultsFound: \"{count} results found\",\n    matches: \"Matches ({count})\",\n    noResultsFor: \"No results found for “{query}”\",\n    notSet: \"Not set\",\n    saveToNotebook: \"Save to Notebook\",\n    saveSuccess: \"Successfully saved to notebook\",\n    saveError: \"Failed to save to notebook\",\n    selectNotebook: \"Select Notebook\",\n    searchAndAsk: \"Search & Ask\",\n    searchResultsFor: \"Search results for “{query}”\",\n    askAbout: \"Ask about “{query}”\",\n    orSearchKb: \"Or search your knowledge base\",\n    saving: \"Saving...\",\n    advancedModelTitle: \"Advanced Model Selection\",\n    advancedModelDesc: \"Choose specific models for each stage of the Ask process\",\n    strategyModel: \"Strategy Model\",\n    answerModel: \"Answer Model\",\n    finalAnswerModel: \"Final Answer Model\",\n    selectStrategyPlaceholder: \"Select strategy model\",\n    selectAnswerPlaceholder: \"Select answer model\",\n    selectFinalPlaceholder: \"Select final answer model\",\n    saveChanges: \"Save Changes\",\n    processingQuestion: \"Processing your question...\",\n  },\n  podcasts: {\n    generateEpisode: \"Generate Podcast Episode\",\n    generateEpisodeDesc: \"Select the content to include and configure the episode details before generating a new podcast episode.\",\n    content: \"Content\",\n    contentDesc: \"Pick notebooks, sources, and notes to include in this episode.\",\n    itemsSelected: \"{count} items selected\",\n    tokens: \"{count} tokens\",\n    chars: \"{count} chars\",\n    loadingNotebooks: \"Loading notebooks...\",\n    noNotebooksFoundInPodcasts: \"No notebooks found. Create a notebook and add content before generating a podcast.\",\n    noContentSelected: \"No content selected\",\n    summary: \"Summary\",\n    fullContent: \"Full content\",\n    untitledSource: \"Untitled source\",\n    untitledNote: \"Untitled note\",\n    episodeSettings: \"Episode Settings\",\n    episodeProfile: \"Episode profile\",\n    episodeProfilePlaceholder: \"Select an episode profile\",\n    episodeName: \"Episode name\",\n    episodeNamePlaceholder: \"e.g., AI and the Future of Work\",\n    additionalInstructions: \"Additional instructions\",\n    instructionsPlaceholder: \"Any supplementary advice to append to the episode briefing...\",\n    generating: \"Generating...\",\n    generate: \"Generate\",\n    hostPlaceholder: \"Host {number}\",\n    profileRequired: \"Episode Profile Required\",\n    profileRequiredDesc: \"Select an episode profile before generating a podcast.\",\n    nameRequired: \"Episode name required\",\n    nameRequiredDesc: \"Provide a name for the episode.\",\n    addContext: \"Add context\",\n    addContextDesc: \"Select at least one source or note to include in the episode.\",\n    generationFailed: \"Podcast generation failed\",\n    speakerProfile: \"Speaker Profile\",\n    usesSpeakerProfile: \"Uses speaker profile\",\n    sources: \"Sources\",\n    notes: \"Notes\",\n    noSources: \"No sources available in this notebook.\",\n    noNotes: \"No notes available in this notebook.\",\n    selectMode: \"Select mode\",\n    buildContextFailed: \"Failed to build context. Please review your selections.\",\n    podcastTaskStarted: \"Podcast task started\",\n    loadingProfiles: \"Loading episode profiles...\",\n    noProfilesFound: \"No episode profiles found. Create an episode profile before generating a podcast.\",\n    listTitle: \"Podcasts\",\n    listDesc: \"Keep track of generated episodes and manage reusable profiles.\",\n    chooseAView: \"Choose a view\",\n    episodesTab: \"Episodes\",\n    templatesTab: \"Profiles\",\n    overviewTitle: \"Episodes overview\",\n    overviewDesc: \"Monitor podcast generation jobs and review the final artefacts.\",\n    generateBtn: \"Generate Podcast\",\n    total: \"Total\",\n    processingLabel: \"Processing\",\n    completedLabel: \"Completed\",\n    failedLabel: \"Failed\",\n    pendingLabel: \"Pending\",\n    loadErrorTitle: \"Failed to load episodes\",\n    loadErrorDesc: \"We could not fetch the latest podcast episodes. Try again shortly.\",\n    loadingEpisodes: \"Loading episodes…\",\n    noEpisodesYet: \"No podcast episodes yet. Generate your first one from the notebook or source chat interfaces.\",\n    statusRunningTitle: \"Currently Processing\",\n    statusRunningDesc: \"Episodes that are actively generating assets.\",\n    statusPendingTitle: \"Queued / Pending\",\n    statusPendingDesc: \"Submitted episodes waiting to start processing.\",\n    statusCompletedTitle: \"Completed Episodes\",\n    statusCompletedDesc: \"Ready to review, download, or publish.\",\n    statusFailedTitle: \"Failed Episodes\",\n    statusFailedDesc: \"Episodes that encountered issues during generation.\",\n    templatesWorkspaceTitle: \"Profiles workspace\",\n    templatesWorkspaceDesc: \"Build reusable episode and speaker configurations for fast podcast production.\",\n    howTemplatesPowerTitle: \"How profiles power podcast generation\",\n    howTemplatesPowerDesc: \"Profiles split the podcast workflow into two reusable building blocks. Mix and match them whenever you generate a new episode.\",\n    episodeProfilesSetFormat: \"Episode profiles set the format\",\n    episodeProfilesList1: \"Outline the number of segments and how the story flows\",\n    episodeProfilesList2: \"Pick the language models used for briefing, outlining, and script writing\",\n    episodeProfilesList3: \"Store default briefings so every episode starts with a consistent tone\",\n    speakerProfilesBringVoices: \"Speaker profiles bring voices to life\",\n    speakerProfilesList1: \"Choose the text-to-speech provider and model\",\n    speakerProfilesList2: \"Capture personality, backstory, and pronunciation notes per speaker\",\n    speakerProfilesList3: \"Reuse the same host or guest voices across different episode formats\",\n    recommendedWorkflow: \"Recommended workflow\",\n    workflowStep1: \"Create speaker profiles for each voice you need\",\n    workflowStep2: \"Build episode profiles that reference those speakers by name\",\n    workflowStep3: \"Generate podcasts by selecting the episode profile that fits the story\",\n    workflowHint: \"Episode profiles reference speaker profiles by name, so starting with speakers avoids missing voice assignments later.\",\n    failedToLoadTemplates: \"Failed to load profiles data\",\n    failedToLoadTemplatesDesc: \"Ensure the API is running and try again. Some sections may be incomplete.\",\n    loadingTemplates: \"Loading profiles…\",\n    speakerProfilesTitle: \"Speaker profiles\",\n    speakerProfilesDesc: \"Configure voices and personalities for generated episodes.\",\n    createSpeaker: \"Create speaker\",\n    noSpeakerProfiles: \"No speaker profiles yet. Create one to make episode profiles available.\",\n    noDescription: \"No description provided.\",\n    usedByCount_one: \"Used by 1 episode\",\n    usedByCount_other: \"Used by {count} episodes\",\n    usedByCount: \"Used by {count} episodes\",\n    unused: \"Unused\",\n    voiceId: \"Voice ID\",\n    backstory: \"Backstory\",\n    personality: \"Personality\",\n    edit: \"Edit\",\n    duplicate: \"Duplicate\",\n    deleteSpeakerProfileTitle: \"Delete speaker profile?\",\n    deleteSpeakerProfileDesc: \"Deleting “{name}” cannot be undone.\",\n    deleteSpeakerDisabledHint: \"Remove this speaker from episode profiles before deleting it.\",\n    deleting: \"Deleting…\",\n    episodeProfilesTitle: \"Episode profiles\",\n    episodeProfilesDesc: \"Define reusable generation settings for your shows.\",\n    createProfile: \"Create profile\",\n    createSpeakerFirst: \"Create a speaker profile before adding an episode profile.\",\n    noEpisodeProfiles: \"No episode profiles yet. Create one to kickstart podcast generation.\",\n    speakerCreated: \"Speaker Created\",\n    speakerCreatedDesc: \"The speaker \\\"{name}\\\" has been successfully added.\",\n    failedToCreateSpeaker: \"Failed to create speaker profile\",\n    speakerUpdated: \"Speaker Updated\",\n    speakerUpdatedDesc: \"The speaker \\\"{name}\\\" has been successfully updated.\",\n    failedToUpdateSpeaker: \"Failed to update speaker profile\",\n    speakerDeleted: \"Speaker Deleted\",\n    speakerDeletedDesc: \"The speaker \\\"{name}\\\" has been successfully removed.\",\n    failedToDeleteSpeaker: \"Failed to delete speaker profile\",\n    speakerDuplicated: \"Speaker Duplicated\",\n    speakerDuplicatedDesc: \"The speaker \\\"{name}\\\" has been successfully duplicated.\",\n    failedToDuplicateSpeaker: \"Failed to duplicate speaker profile\",\n    generationStarted: \"Generation Started\",\n    generationStartedDesc: \"Podcast generation has been queued.\",\n    failedToStartGeneration: \"Failed to start generation\",\n    tryAgainMoment: \"Please try again in a moment.\",\n    deleteProfileTitle: \"Delete profile?\",\n    deleteProfileDesc: \"This will remove “{name}”. Existing episodes keep their data, but new ones will no longer use this configuration.\",\n    profileCreated: \"Profile Created\",\n    profileCreatedDesc: \"The episode profile \\\"{name}\\\" has been successfully created.\",\n    failedToCreateProfile: \"Failed to create profile\",\n    profileUpdated: \"Profile Updated\",\n    profileUpdatedDesc: \"The episode profile \\\"{name}\\\" has been successfully updated.\",\n    failedToUpdateProfile: \"Failed to update profile\",\n    profileDeleted: \"Profile Deleted\",\n    profileDeletedDesc: \"The episode profile \\\"{name}\\\" has been successfully removed.\",\n    failedToDeleteProfile: \"Failed to delete profile\",\n    failedToDeleteProfileDesc: \"Failed to remove the episode profile.\",\n    profileDuplicated: \"Profile Duplicated\",\n    profileDuplicatedDesc: \"The episode profile \\\"{name}\\\" has been successfully duplicated.\",\n    failedToDuplicateProfile: \"Failed to duplicate profile\",\n    episodeDeleted: \"Episode Deleted\",\n    episodeDeletedDesc: \"The episode has been successfully deleted.\",\n    failedToDeleteEpisode: \"Failed to delete episode\",\n    failedToDeleteSpeakerDesc: \"Failed to remove the speaker profile.\",\n    outlineModel: \"Outline model\",\n    transcriptModel: \"Transcript model\",\n    segments: \"Segments\",\n    defaultBriefingTitle: \"Default briefing\",\n    created: \"Created at {time}\",\n    details: \"Details\",\n    summaryTab: \"Summary\",\n    outlineTab: \"Outline\",\n    transcriptTab: \"Transcript\",\n    briefing: \"Briefing\",\n    noOutline: \"No outline available.\",\n    noTranscript: \"No transcript available.\",\n    deleteEpisodeTitle: \"Delete episode?\",\n    deleteEpisodeDesc: \"This will remove “{name}” and its audio file permanently.\",\n    audioUnavailable: \"Audio unavailable\",\n    segment: \"Segment\",\n    speaker: \"Speaker\",\n    profile: \"Profile\",\n    link: \"Link\",\n    file: \"File\",\n    embedded: \"Embedded\",\n    notEmbedded: \"Not embedded\",\n    noSpeakerProfilesAvailable: \"No speaker profiles available\",\n    editEpisodeProfile: \"Edit Episode Profile\",\n    createEpisodeProfile: \"Create Episode Profile\",\n    episodeProfileFormDesc: \"Define how episodes should be generated and which speaker configuration they use by default.\",\n    noSpeakerProfilesDesc: \"Create a speaker profile before configuring an episode profile.\",\n    profileName: \"Profile name\",\n    profileNamePlaceholder: \"e.g., Tech discussion\",\n    descriptionPlaceholder: \"Short summary of when to use this profile\",\n    speakerConfig: \"Speaker configuration\",\n    selectSpeakerProfile: \"Select a speaker profile\",\n    outlineGeneration: \"Outline generation\",\n    transcriptGeneration: \"Transcript generation\",\n    defaultBriefingPlaceholder: \"Outline the structure, tone, and goals for this episode format\",\n    editSpeakerProfile: \"Edit Speaker Profile\",\n    createSpeakerProfile: \"Create Speaker Profile\",\n    speakerProfileFormDesc: \"Configure text-to-speech settings and define up to four speakers.\",\n    speakers: \"Speakers\",\n    speakersDesc: \"Configure between one and four voices for this profile.\",\n    addSpeaker: \"Add speaker\",\n    speakerNumber: \"Speaker {number}\",\n    backstoryPlaceholder: \"Short biography or context for the speaker\",\n    personalityPlaceholder: \"Describe style and tone\",\n    outlineModelRequired: \"Outline model is required\",\n    transcriptModelRequired: \"Transcript model is required\",\n    defaultBriefingRequired: \"Default briefing is required\",\n    segmentsInteger: \"Must be an integer\",\n    segmentsMin: \"At least 3 segments\",\n    segmentsMax: \"Maximum 20 segments\",\n    voiceIdRequired: \"Voice ID is required\",\n    backstoryRequired: \"Backstory is required\",\n    personalityRequired: \"Personality is required\",\n    speakerCountMin: \"At least one speaker is required\",\n    speakerCountMax: \"You can configure up to 4 speakers\",\n    delete: \"Delete\",\n    failedToDelete: \"Failed to delete podcast\",\n    retry: \"Retry\",\n    retrying: \"Retrying…\",\n    retryStarted: \"Retry Started\",\n    retryStartedDesc: \"A new podcast generation job has been submitted.\",\n    failedToRetry: \"Failed to retry episode\",\n    errorDetails: \"Error details\",\n    language: \"Language\",\n    languagePlaceholder: \"Select a language (optional)\",\n    podcastLanguage: \"Podcast language\",\n    selectOutlineModel: \"Select outline model\",\n    selectTranscriptModel: \"Select transcript model\",\n    voiceModel: \"Voice model\",\n    voiceModelRequired: \"Voice model is required\",\n    selectVoiceModel: \"Select voice model\",\n    perSpeakerTtsOverride: \"Per-speaker TTS override (optional)\",\n    useProfileDefault: \"Use profile default\",\n    setupRequired: \"Setup required\",\n    setupRequiredDesc:\n      \"Some profiles don't have models configured yet. Edit them to select models before generating podcasts.\",\n    notConfigured: \"Not configured\",\n  },\n  settings: {\n    contentProcessing: \"Content Processing\",\n    contentProcessingDesc: \"Configure how documents and URLs are processed\",\n    docEngine: \"Document Processing Engine\",\n    docEnginePlaceholder: \"Select document processing engine\",\n    urlEngine: \"URL Processing Engine\",\n    urlEnginePlaceholder: \"Select URL processing engine\",\n    autoRecommended: \"Auto (Recommended)\",\n    simple: \"Simple\",\n    docling: \"Docling\",\n    helpMeChoose: \"Help me choose\",\n    docHelp: \"· Docling is a little slower but more accurate, specially if the documents contain tables and images. · Simple will extract any content from the document without formatting it. · Auto (recommended) will try to process through docling and default to simple.\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl is a paid service (with a free tier), and very powerful. · Jina is a good option as well and also has a free tier. · Simple will use basic HTTP extraction and will miss content on javascript-based websites. · Auto (recommended) will try to use firecrawl then Jina, finally fallback to simple.\",\n    embeddingAndSearch: \"Embedding and Search\",\n    embeddingAndSearchDesc: \"Configure search and embedding options\",\n    defaultEmbeddingOption: \"Default Embedding Option\",\n    embeddingOptionPlaceholder: \"Select embedding option\",\n    ask: \"Ask\",\n    always: \"Always\",\n    never: \"Never\",\n    embeddingHelp: \"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.\",\n    fileManagement: \"File Management\",\n    fileManagementDesc: \"Configure file handling and storage options\",\n    autoDeleteFiles: \"Auto Delete Files\",\n    autoDeletePlaceholder: \"Select auto delete option\",\n    filesHelp: \"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.\",\n    loadFailed: \"Failed to load settings\",\n  },\n  advanced: {\n    title: \"AdvancedTools\",\n    desc: \"Advanced tools and utilities for power users\",\n    systemInfo: \"System Info\",\n    rebuildEmbeddings: \"Rebuild Embeddings\",\n    rebuildEmbeddingsDesc: \"Rebuild vector search index for all sources\",\n    currentVersion: \"Current Version\",\n    latestVersion: \"Latest Version\",\n    status: \"Status\",\n    updateAvailable: \"Version {version} Available\",\n    updateAvailableDesc: \"A new version of Open Notebook is available.\",\n    upToDate: \"Up to Date\",\n    unknown: \"Unknown\",\n    viewOnGithub: \"View on GitHub\",\n    updateCheckFailed: \"Unable to check for updates. GitHub may be unreachable.\",\n    rebuild: {\n      mode: \"Rebuild Mode\",\n      existing: \"Existing\",\n      all: \"All\",\n      existingDesc: \"Re-embed only items that already have embeddings (faster, for model switching)\",\n      allDesc: \"Re-embed existing items + create embeddings for items without any (slower, comprehensive)\",\n      include: \"Include in Rebuild\",\n      selectOneError: \"Please select at least one item type to rebuild\",\n      starting: \"Starting Rebuild...\",\n      startBtn: \"🚀 Start Rebuild\",\n      queued: \"Queued\",\n      running: \"Submitting jobs...\",\n      completed: \"Jobs Submitted!\",\n      failed: \"Failed\",\n      leavePageHint: \"You can leave this page as this will run in the background\",\n      startNew: \"Start New Rebuild\",\n      itemsProcessed: \"{processed}/{total} jobs submitted ({percent}%)\",\n      failedItems: \"{count} jobs failed to submit\",\n      time: \"Time\",\n      whenToRebuild: \"When should I rebuild embeddings?\",\n      whenToRebuildAns: \"You should rebuild when switching models, upgrading versions, fixing corruption, or after bulk imports.\",\n      howLong: \"How long does rebuilding take?\",\n      howLongAns: \"Processing time depends on item count, model speed, and API rate limits. Local models are usually very fast.\",\n      isSafe: \"Is it safe to rebuild while using the app?\",\n      isSafeAns: \"Yes, rebuilding is safe! It doesn't delete content, only replaces embeddings, and handles errors gracefully.\",\n    },\n  },\n  transformations: {\n    title: \"Transformations\",\n    desc: \"Transformations are prompts that will be used by the LLM to process a source and extract insights, summaries, etc.\",\n    workspace: \"Choose a workspace\",\n    playground: \"Playground\",\n    defaultPrompt: \"Default Transformation Prompt\",\n    defaultPromptDesc: \"This will be added to all your transformation prompts\",\n    defaultPromptPlaceholder: \"Enter your default transformation instructions...\",\n    listTitle: \"Custom Transformations\",\n    createNew: \"Create New\",\n    inputLabel: \"Input Text\",\n    inputPlaceholder: \"Enter some text to transform...\",\n    outputLabel: \"Output\",\n    runTest: \"Run Transformation\",\n    running: \"Running...\",\n    selectToStart: \"Select a transformation to start\",\n    name: \"Name\",\n    namePlaceholder: \"Unique identifier, e.g. key_topics\",\n    titlePlaceholder: \"Displayed title, defaults to name\",\n    promptPlaceholder: \"Write the prompt that will power this transformation...\",\n    descriptionPlaceholder: \"Describe what this transformation does.\",\n    suggestDefault: \"Suggest by default on new sources\",\n    promptHint: \"Prompts should be written with the source content in mind. You can ask the model to summarise, extract insights, or produce structured outputs such as tables.\",\n    createSuccess: \"Transformation created successfully\",\n    updateSuccess: \"Transformation updated successfully\",\n    deleteSuccess: \"Transformation deleted successfully\",\n    noTransformations: \"No transformations yet\",\n    createOne: \"Create a transformation to get started\",\n    selectModel: \"Select a model\",\n    deleteConfirm: \"Are you sure you want to delete this transformation?\",\n    model: \"Model\",\n    systemPrompt: \"System Prompt\",\n    overrideModelDesc: \"Override the default model for this chat session. Leave empty to use the system default.\",\n    sessionUseReplacement: \"This session will use {name} instead of the default model.\",\n    systemDefault: \"System Default\",\n  },\n  models: {\n    embedding: \"Embedding Models\",\n    tts: \"Text to Speech (TTS)\",\n    stt: \"Speech to Text (STT)\",\n    apiKey: \"API Key\",\n    deleteSuccess: \"Model deleted successfully\",\n    saveSuccess: \"Model saved successfully\",\n    noModels: \"No models\",\n    discoverModels: \"Discover Models\",\n    noModelsFound: \"No models found from this provider\",\n    modelType: \"Model Type\",\n    modelTypeHint: \"Select the type for the models you want to add. If you need different types, add them in separate batches.\",\n    deleteModel: \"Delete Model\",\n    defaultAssignments: \"Default Model Assignments\",\n    defaultAssignmentsDesc: \"Configure which models to use for different purposes across Open Notebook\",\n    missingRequiredModels: \"Missing required models: {models}. Open Notebook may not function properly without these.\",\n    selectModelPlaceholder: \"Select a model\",\n    requiredModelPlaceholder: \"⚠️ Required - Select a model\",\n    chatModelLabel: \"Chat Model\",\n    chatModelDesc: \"Used for chat conversations\",\n    transformationModelLabel: \"Transformation Model\",\n    transformationModelDesc: \"Used for summaries, insights, and transformations\",\n    toolsModelLabel: \"Tools Model\",\n    toolsModelDesc: \"Used for function calling - OpenAI or Anthropic recommended\",\n    largeContextModelLabel: \"Large Context Model\",\n    largeContextModelDesc: \"Used for processing large documents - Gemini recommended\",\n    embeddingModelLabel: \"Embedding Model\",\n    embeddingModelDesc: \"Used for semantic search and vector embeddings\",\n    ttsModelLabel: \"Text-to-Speech Model\",\n    ttsModelDesc: \"Used for podcast generation\",\n    sttModelLabel: \"Speech-to-Text Model\",\n    sttModelDesc: \"Used for audio transcription\",\n    embeddingChangeTitle: \"Embedding Model Change\",\n    embeddingChangeConfirm: \"You are about to change your embedding model from {from} to {to}.\",\n    rebuildRequired: \"Important: Rebuild Required\",\n    rebuildReason: \"Changing your embedding model requires rebuilding all existing embeddings to maintain consistency. Without rebuilding, your searches may return incorrect or incomplete results.\",\n    whatHappensNext: \"What happens next:\",\n    step1: \"Your default embedding model will be updated\",\n    step2: \"Existing embeddings will remain unchanged until rebuild\",\n    step3: \"New content will use the new embedding model\",\n    step4: \"You should rebuild embeddings as soon as possible\",\n    proceedToRebuildPrompt: \"Would you like to proceed to the Advanced page to start the rebuild now?\",\n    changeModelOnly: \"Change Model Only\",\n    changeAndRebuild: \"Change & Go to Rebuild\",\n    autoAssign: \"Auto-assign Defaults\",\n    autoAssigning: \"Assigning...\",\n    autoAssignSuccess: \"{count} default models automatically assigned\",\n    autoAssignNoModels: \"No models available to assign. Please sync models first.\",\n    autoAssignAlreadySet: \"All default models are already configured\",\n    testModel: \"Test Model\",\n    testModelSuccess: \"Model Test Passed\",\n    testModelFailed: \"Model Test Failed\",\n    searchOrAddModel: \"Search or type a model name...\",\n    addCustomModel: \"Add \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"Configure your AI with your own API keys\",\n    description: \"Store API keys securely in the database to enable AI providers in Open Notebook.\",\n    encryptionRequired: \"Encryption key not configured\",\n    encryptionRequiredDescription: \"Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to any secret string to enable storing API keys in the database.\",\n    configured: \"Configured\",\n    notConfigured: \"Not configured\",\n    migrationAvailable: \"Environment Variables Detected\",\n    migrationDescription: \"{count} API key(s) are configured via environment variables and can be migrated to the database for easier management.\",\n    migrateToDatabase: \"Migrate to Database\",\n    migrating: \"Migrating...\",\n    migrationSuccess: \"{count} API key(s) migrated successfully\",\n    migrationErrors: \"{count} key(s) failed to migrate\",\n    migrationNothingToMigrate: \"All keys are already in the database\",\n    learnMore: \"Learn how to configure API keys →\",\n    testConnection: \"Test Connection\",\n    testSuccess: \"Connection successful\",\n    testFailed: \"Connection test failed\",\n    syncModels: \"Sync Models\",\n    syncSuccess: \"Discovered {discovered} models, added {new} new\",\n    syncNoNew: \"Discovered {count} models, all already registered\",\n    syncFailed: \"Failed to sync models\",\n    getApiKey: \"Get API Key\",\n    vertexProject: \"GCP Project ID\",\n    vertexLocation: \"Region\",\n    vertexCredentials: \"Service Account JSON Path\",\n    addConfig: \"Add Configuration\",\n    editConfig: \"Edit Configuration\",\n    deleteConfig: \"Delete Configuration\",\n    configName: \"Configuration Name\",\n    configNameHint: \"A descriptive name for this configuration (e.g., 'Production', 'Development')\",\n    baseUrl: \"Base URL\",\n    baseUrlOverrideHint: \"Only change this if you need to override the provider's default API endpoint.\",\n    deleteConfigConfirm: \"Are you sure you want to delete '{name}'? This cannot be undone.\",\n    configSaveSuccess: \"Configuration saved successfully\",\n    configUpdateSuccess: \"Configuration updated successfully\",\n    configDeleteSuccess: \"Configuration deleted successfully\",\n    apiKeyEditHint: \"Leave blank to keep the existing API key\",\n  },\n  setupBanner: {\n    encryptionRequired: \"Encryption key not configured\",\n    encryptionRequiredDescription: \"Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to enable secure credential storage.\",\n    migrationAvailable: \"API key migration available\",\n    migrationDescription: \"{count} provider(s) have API keys set via environment variables. Migrate them to the database for easier management.\",\n    goToSettings: \"Go to Settings\",\n    viewDocs: \"View docs\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/fr-FR/index.ts",
    "content": "export const frFR = {\n  common: {\n    search: \"Recherche...\",\n    create: \"Créer\",\n    new: \"Nouveau\",\n    cancel: \"Annuler\",\n    delete: \"Supprimer\",\n    edit: \"Modifier\",\n    theme: \"Thème\",\n    signOut: \"Se déconnecter\",\n    noMatches: \"Aucun résultat trouvé\",\n    tryDifferentSearch: \"Essayez d'utiliser un terme de recherche différent.\",\n    light: \"Clair\",\n    dark: \"Sombre\",\n    system: \"Système\",\n    loading: \"Chargement...\",\n    note: \"Note\",\n    insight: \"Aperçu\",\n    newSource: \"Nouvelle Source\",\n    newNotebook: \"Nouveau Carnet\",\n    newPodcast: \"Nouveau Podcast\",\n    language: \"Langue\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"Source\",\n    notebook: \"Carnet\",\n    podcast: \"Podcast\",\n    quickActions: \"Actions rapides\",\n    quickActionsDesc: \"Navigation, recherche, poser une question, thème\",\n    appName: \"Open Notebook\",\n    add: \"Ajouter\",\n    remove: \"Retirer\",\n    confirm: \"Confirmer\",\n    warning: \"Avertissement\",\n    error: \"Erreur\",\n    success: \"Succès\",\n    model: \"Modèle\",\n    back: \"Retour\",\n    next: \"Suivant\",\n    done: \"Terminé\",\n    processing: \"Traitement...\",\n    creating: \"Création...\",\n    linked: \"Lié\",\n    adding: \"Ajout en cours...\",\n    addSelected: \"Ajouter la sélection\",\n    customModel: \"Modèle personnalisé\",\n    failed: \"échec\",\n    current: \"Actuel\",\n    save: \"Enregistrer\",\n    writeNote: \"Écrire une note\",\n    batchMode: \"Mode par lot\",\n    optional: \"Optionnel\",\n    type: \"Type\",\n    title: \"Titre\",\n    created: \"Créé à {time}\",\n    updated: \"Mis à jour à {time}\",\n    actions: \"Actions\",\n    noResults: \"Aucun résultat\",\n    references: \"Références\",\n    refreshPage: \"Veuillez essayer de rafraîchir la page\",\n    refresh: \"Rafraîchir\",\n    aiGenerated: \"Généré par IA\",\n    human: \"Humain\",\n    unknown: \"Inconnu\",\n    notes: \"Notes\",\n    chat: \"Chat\",\n    deleteForever: \"Supprimer définitivement\",\n    connectionError: \"Erreur de connexion\",\n    unableToConnect: \"Impossible de se connecter au serveur API\",\n    retryConnection: \"Réessayer la connexion\",\n    diagnosticInfo: \"Informations de diagnostic\",\n    version: \"Version\",\n    built: \"Compilé le\",\n    apiUrl: \"URL de l'API\",\n    frontendUrl: \"URL du Frontend\",\n    checkConsoleLogs: \"Vérifiez la console du navigateur pour les logs détaillés (cherchez les messages 🔧 [Config])\",\n    yes: \"Oui\",\n    no: \"Non\",\n    saving: \"Enregistrement...\",\n    description: \"Description\",\n    saveToNote: \"Enregistrer dans la note\",\n    copyToClipboard: \"Copier dans le presse-papiers\",\n    close: \"Fermer\",\n    insights: \"Analyses\",\n    progress: \"Progression\",\n    deleting: \"Suppression...\",\n    created_label: \"Créé\",\n    updated_label: \"Mis à jour\",\n    download: \"Télécharger\",\n    saveChanges: \"Enregistrer les modifications\",\n    name: \"Nom\",\n    default: \"Par défaut\",\n    nameRequired: \"Le nom est requis\",\n    modelConfiguration: \"Configuration du modèle\",\n    resetToDefault: \"Réinitialiser\",\n    reasoning: \"Raisonnement\",\n    searchTerms: \"Termes de recherche\",\n    strategy: \"Stratégie\",\n    individualAnswers: \"Réponses individuelles ({count})\",\n    finalAnswer: \"Réponse finale\",\n    notebookLabel: \"Carnet : {name}\",\n    itemNotFound: \"Ce {type} est introuvable\",\n    accessibility: {\n      transformationViews: \"Vues de transformation\",\n      searchKB: \"Interroger ou fouiller votre base de connaissances\",\n      enterQuestion: \"Entrez votre question pour interroger la base de connaissances\",\n      enterSearch: \"Entrez votre recherche\",\n      searchKBBtn: \"Rechercher dans la base de connaissances\",\n      podcastViews: \"Vues podcast\",\n      ytVideo: \"Vidéo YouTube\",\n      askResponse: \"Réponse à la question\",\n      searchNotebooks: \"Rechercher dans les carnets\",\n    },\n    url: \"URL\",\n    errorDetails: \"Détails de l'erreur\",\n    editTransformation: \"Modifier la transformation\",\n    retry: \"Réessayer\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"terminé\",\n    saveSuccess: \"Enregistré avec succès\",\n    contextModes: {\n      off: \"Non inclus dans le chat\",\n      insights: \"Analyses uniquement\",\n      full: \"Contenu complet\",\n      clickToCycle: \"Cliquez pour faire défiler\",\n    },\n    clickToEdit: \"Cliquez pour modifier\",\n  },\n  apiErrors: {\n    notebookNotFound: \"Carnet introuvable\",\n    sourceNotFound: \"Source introuvable\",\n    transformationNotFound: \"Transformation introuvable\",\n    fileUploadFailed: \"Échec du téléchargement du fichier\",\n    urlRequired: \"L'URL est requise pour le type lien\",\n    contentRequired: \"Le contenu est requis pour le type texte\",\n    invalidSourceType: \"Type de source invalide\",\n    processingFailed: \"Échec du traitement\",\n    failedToQueue: \"Échec de la mise en file d'attente du traitement\",\n    invalidSortBy: \"Le champ de tri doit être 'created' ou 'updated'\",\n    invalidSortOrder: \"L'ordre de tri doit être 'asc' ou 'desc'\",\n    accessDenied: \"Accès au fichier refusé\",\n    fileNotFoundOnServer: \"Fichier introuvable sur le serveur\",\n    searchFailed: \"La recherche a échoué\",\n    askFailed: \"La demande a échoué\",\n    pleaseEnterQuestion: \"Veuillez entrer une question\",\n    pleaseConfigureModels: \"Veuillez configurer tous les modèles requis\",\n    failedToCreateSession: \"Échec de la création de la session\",\n    failedToUpdateSession: \"Échec de la mise à jour de la session\",\n    failedToDeleteSession: \"Échec de la suppression de la session\",\n    failedToSendMessage: \"Échec de l'envoi du message\",\n    unauthorized: \"Accès non autorisé, veuillez vérifier votre mot de passe\",\n    invalidPassword: \"Mot de passe invalide\",\n    embeddingModelRequired: \"Cette fonctionnalité nécessite un modèle d'embedding. Veuillez en configurer un dans la section Modèles.\",\n    strategyModelNotFound: \"Modèle de stratégie introuvable\",\n    answerModelNotFound: \"Modèle de réponse introuvable\",\n    finalAnswerModelNotFound: \"Modèle de réponse finale introuvable\",\n    noAnswerGenerated: \"Aucune réponse n'a pu être générée\",\n    genericError: \"Une erreur inattendue est survenue\",\n  },\n  connectionErrors: {\n    apiTitle: \"Impossible de se connecter au serveur API\",\n    apiDesc: \"Le serveur API de Open Notebook est injoignable\",\n    dbTitle: \"Échec de la connexion à la base de données\",\n    dbDesc: \"Le serveur API fonctionne, mais la base de données n'est pas accessible\",\n    troubleshooting: \"Cela signifie généralement :\",\n    apiUnreachable1: \"Le serveur API n'est pas lancé\",\n    apiUnreachable2: \"Le serveur API fonctionne sur une adresse différente\",\n    apiUnreachable3: \"Problèmes de connectivité réseau\",\n    dbFailed1: \"SurrealDB n'est pas lancé\",\n    dbFailed2: \"Les paramètres de connexion à la base de données sont incorrects\",\n    dbFailed3: \"Problèmes réseau entre l'API et la base de données\",\n    quickFixes: \"Solutions rapides :\",\n    setApiUrl: \"Définissez la variable d'environnement API_URL :\",\n    checkSurreal: \"Vérifiez si SurrealDB est lancé :\",\n    seeDocumentation: \"Pour des instructions de configuration détaillées, consultez :\",\n    docLink: \"Documentation de Open Notebook\",\n    showTechnical: \"Afficher les détails techniques\",\n    attemptedUrl: \"URL tentée\",\n    message: \"Message\",\n    technicalDetails: \"Détails techniques\",\n    stackTrace: \"Trace de la pile (Stack Trace)\",\n    retryLabel: \"Réessayer la connexion\",\n    retryHint: \"Appuyez sur R ou cliquez sur le bouton pour réessayer\",\n    dockerLabel: \"Pour Docker\",\n    localDevLabel: \"Pour le développement local\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"Entrez votre mot de passe pour accéder à l'application\",\n    passwordPlaceholder: \"Mot de passe\",\n    signingIn: \"Connexion...\",\n    signIn: \"Se connecter\",\n    connectErrorHint: \"Impossible de se connecter au serveur. Veuillez vérifier si l'API est lancée.\",\n  },\n  navigation: {\n    collect: \"Collecter\",\n    process: \"Traiter\",\n    create: \"Créer\",\n    manage: \"Gérer\",\n    sources: \"Sources\",\n    notebooks: \"Carnets\",\n    askAndSearch: \"Demander et rechercher\",\n    podcasts: \"Podcasts\",\n    models: \"Modèles\",\n    transformations: \"Transformations\",\n    transformation: \"Transformation\",\n    settings: \"Paramètres\",\n    advanced: \"Avancé\",\n    nav: \"Navigation\",\n    language: \"Changer de langue\",\n    theme: \"Thème\",\n    ask: \"Demander\",\n  },\n  notebooks: {\n    title: \"Carnets\",\n    newNotebook: \"Nouveau Carnet\",\n    searchPlaceholder: \"Rechercher des carnets...\",\n    archived: \"Archivé\",\n    archive: \"Archiver\",\n    unarchive: \"Désarchiver\",\n    deleteNotebook: \"Supprimer le carnet\",\n    deleteNotebookDesc: \"Êtes-vous sûr de vouloir supprimer \\\"{name}\\\" ? Cette action est irréversible.\",\n    deleteNotebookLoading: \"Chargement de l'aperçu de suppression...\",\n    deleteNotebookNotes: \"{count} note(s) seront supprimées définitivement.\",\n    deleteNotebookNoNotes: \"Aucune note à supprimer.\",\n    deleteNotebookExclusiveSources: \"{count} source(s) existent uniquement dans ce carnet.\",\n    deleteNotebookSharedSources: \"{count} source(s) sont partagées avec d'autres carnets et seront déliées.\",\n    deleteNotebookNoSources: \"Aucune source dans ce carnet.\",\n    deleteExclusiveSourcesLabel: \"Supprimer les sources exclusives\",\n    keepExclusiveSourcesLabel: \"Délier et les conserver\",\n    activeNotebooks: \"Carnets actifs\",\n    archivedNotebooks: \"Carnets archivés\",\n    notFound: \"Carnet introuvable\",\n    notFoundDesc: \"Le carnet demandé n'existe pas.\",\n    updated: \"Mis à jour\",\n    namePlaceholder: \"Nom du carnet\",\n    addDescription: \"Ajouter une description...\",\n    noNotesYet: \"Aucune note pour le moment\",\n    deleteNote: \"Supprimer la note\",\n    deleteNoteConfirm: \"Êtes-vous sûr de vouloir supprimer cette note ? Cette action est irréversible.\",\n    noteCreatedSuccess: \"Note créée avec succès\",\n    failedToCreateNote: \"Échec de la création de la note\",\n    noteUpdatedSuccess: \"Note mise à jour avec succès\",\n    failedToUpdateNote: \"Échec de la mise à jour de la note\",\n    noteDeletedSuccess: \"Note supprimée avec succès\",\n    failedToDeleteNote: \"Échec de la suppression de la note\",\n    createNew: \"Créer un nouveau carnet\",\n    createNewDesc: \"Entrez un nom et une description facultative pour commencer.\",\n    descPlaceholder: \"Ajoutez plus d'informations sur ce carnet ici...\",\n    createSuccess: \"Carnet créé avec succès\",\n    updateSuccess: \"Carnet mis à jour avec succès\",\n    deleteSuccess: \"Carnet supprimé avec succès\",\n  },\n  sources: {\n    title: \"Sources\",\n    add: \"Ajouter une source\",\n    addNew: \"Ajouter une nouvelle source\",\n    addExisting: \"Ajouter une source existante\",\n    delete: \"Supprimer la source\",\n    statusPreparing: \"Préparation\",\n    statusQueued: \"En attente\",\n    statusProcessing: \"Traitement\",\n    statusCompleted: \"Terminé\",\n    statusFailed: \"Échec\",\n    statusPreparingDesc: \"Préparation au traitement\",\n    statusQueuedDesc: \"En attente de traitement\",\n    statusProcessingDesc: \"En cours de traitement\",\n    statusCompletedDesc: \"Traitée avec succès\",\n    statusFailedDesc: \"Échec du traitement\",\n    failedToLoad: \"Échec du chargement des sources\",\n    allSourcesDesc: \"Affichez toutes vos sources ici. Vous pouvez en ajouter de nouvelles ou gérer les existantes.\",\n    allSources: \"Toutes les sources\",\n    insights: \"Aperçus\",\n    yes: \"Oui\",\n    no: \"Non\",\n    loadingMore: \"Chargement...\",\n    noSourcesYet: \"Aucune source pour le moment\",\n    allSourcesDescShort: \"Affichez toutes vos sources ici.\",\n    cannotSaveNoteNoNotebook: \"Impossible d'enregistrer la note : ID du carnet non disponible\",\n    createFirstSource: \"Ajoutez votre première source pour commencer à bâtir votre base de connaissances.\",\n    deleteSourceConfirm: \"Êtes-vous sûr de vouloir supprimer cette source ?\",\n    deleteConfirm: \"Êtes-vous sûr de vouloir supprimer cet élément ?\",\n    deleteConfirmWithTitle: \"Êtes-vous sûr de vouloir supprimer \\\"{title}\\\" ?\",\n    deleteSuccess: \"Source supprimée avec succès. Note : Pour supprimer le fichier du stockage, vous devez activer l'option \\\"supprimer le fichier\\\" dans la page des paramètres.\",\n    failedToDelete: \"Échec de la suppression de la source\",\n    sourceQueued: \"Source mise en attente\",\n    sourceQueuedDesc: \"Source soumise pour traitement en arrière-plan. Vous pouvez suivre la progression dans la liste des sources.\",\n    sourceAddedSuccess: \"Source ajoutée avec succès\",\n    failedToAddSource: \"Échec de l'ajout de la source\",\n    sourceUpdatedSuccess: \"Source mise à jour avec succès\",\n    failedToUpdateSource: \"Échec de la mise à jour de la source\",\n    sourceDeletedSuccess: \"Source supprimée avec succès\",\n    failedToDeleteSource: \"Échec de la suppression de la source\",\n    fileUploadedSuccess: \"Fichier téléchargé avec succès\",\n    failedToUploadFile: \"Échec du téléchargement du fichier\",\n    sourceRequeued: \"Nouvelle tentative de traitement mise en attente\",\n    sourceRequeuedDesc: \"La source a été remise en file d'attente pour traitement.\",\n    failedToRetry: \"Échec de la tentative\",\n    sourcesAddedToNotebook: \"{count} source(s) ajoutée(s) au carnet\",\n    failedToAddSourcesToNotebook: \"Échec de l'ajout des sources au carnet\",\n    partialAddSuccess: \"{success} source(s) ajoutée(s), {failed} échouée(s)\",\n    sourceRemovedFromNotebook: \"Source retirée du carnet avec succès\",\n    failedToRemoveSourceFromNotebook: \"Échec du retrait de la source du carnet\",\n    removeConfirm: \"Êtes-vous sûr de vouloir retirer cet élément du carnet ?\",\n    checking: \"Vérification...\",\n    untitledSource: \"Source sans titre\",\n    maxItems: \"max {count}\",\n    insightsCount: \"{count} aperçus\",\n    details: \"Détails\",\n    detailsTitle: \"Détails de la source\",\n    content: \"Contenu\",\n    metadata: \"Métadonnées\",\n    type: {\n      link: \"Lien\",\n      file: \"Fichier\",\n      text: \"Texte\",\n    },\n    id: \"ID de la source\",\n    topics: \"Sujets\",\n    embedded: \"Indexé (Embedded)\",\n    notEmbedded: \"Non indexé\",\n    embedContent: \"Indexer le contenu\",\n    embedding: \"Indexation en cours...\",\n    alreadyEmbedded: \"Déjà indexé\",\n    downloadFile: \"Télécharger le fichier\",\n    fileUnavailable: \"Fichier indisponible\",\n    preparing: \"Préparation...\",\n    generateNewInsight: \"Générer un nouvel aperçu\",\n    selectTransformation: \"Sélectionner une transformation...\",\n    noInsightsYet: \"Aucun aperçu pour le moment\",\n    createFirstInsight: \"Créez votre premier aperçu en utilisant une transformation ci-dessus\",\n    viewInsight: \"Voir l'aperçu\",\n    deleteInsight: \"Supprimer l'aperçu\",\n    deleteInsightConfirm: \"Êtes-vous sûr de vouloir supprimer cet aperçu ? Cette action est irréversible.\",\n    insightGenerationStarted: \"Génération de l'aperçu lancée. Il apparaîtra sous peu.\",\n    editNote: \"Modifier la note\",\n    createNote: \"Créer une note\",\n    addTitle: \"Ajouter un titre...\",\n    untitledNote: \"Note sans titre\",\n    writeNotePlaceholder: \"Écrivez le contenu de votre note ici...\",\n    saveNote: \"Enregistrer la note\",\n    createNoteBtn: \"Créer la note\",\n    createFirstNote: \"Créez votre première note pour capturer des idées et des observations.\",\n    urlLabel: \"URL(s) *\",\n    fileLabel: \"Fichier(s) *\",\n    textContentLabel: \"Contenu textuel *\",\n    enterUrlsPlaceholder: \"Entrez les URL, une par ligne\\nhttps://exemple.com/article1\\nhttps://exemple.com/article2\",\n    batchUrlHint: \"Collez plusieurs URL (une par ligne) pour une importation groupée\",\n    invalidUrlsDetected: \"URL invalides détectées :\",\n    lineLabel: \"Ligne {line}\",\n    fixInvalidUrls: \"Veuillez corriger ou supprimer les URL invalides pour continuer\",\n    selectMultipleFilesHint: \"Sélectionnez plusieurs fichiers pour une importation groupée. Supportés : Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Média (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)\",\n    selectedFiles: \"Fichiers sélectionnés :\",\n    textPlaceholder: \"Collez ou tapez votre contenu ici...\",\n    htmlDetected: \"Contenu HTML détecté. Il sera converti en Markdown après traitement.\",\n    titlePlaceholder: \"Donnez un titre descriptif à votre source\",\n    batchTitlesAuto: \"Les titres seront générés automatiquement pour chaque source.\",\n    batchCommonSettings: \"Les mêmes carnets et transformations seront appliqués à tous les éléments.\",\n    urlsCount: \"{count} URL(s)\",\n    filesCount: \"{count} fichier(s)\",\n    addSource: \"Ajouter la source\",\n    notEmbeddedAlert: \"Contenu non indexé\",\n    notEmbeddedDesc: \"Ce contenu n'a pas été indexé pour la recherche vectorielle. L'indexation permet des capacités de recherche avancées et une meilleure découverte de contenu.\",\n    openOnYoutube: \"Ouvrir sur YouTube\",\n    urlCopied: \"URL copiée dans le presse-papiers\",\n    viewSource: \"Voir la source\",\n    noInsightSelected: \"Aucun aperçu sélectionné\",\n    sourceInsight: \"Aperçu de la source\",\n    manageNotebooks: \"Gérer les carnets\",\n    manageNotebooksDesc: \"Gérer quels carnets contiennent cette source\",\n    noNotebooksAvailable: \"Aucun carnet disponible\",\n    loadFailed: \"Échec du chargement des détails de la source\",\n    removeFromNotebook: \"Retirer du carnet\",\n    retryProcessing: \"Réessayer le traitement\",\n    deleteSource: \"Supprimer la source\",\n    retry: \"Réessayer\",\n    addExistingTitle: \"Ajouter des sources existantes\",\n    addExistingDesc: \"Sélectionnez des sources existantes parmi tous vos carnets pour les ajouter au carnet actuel.\",\n    searchPlaceholder: \"Rechercher des sources par nom ou URL...\",\n    noNotebooksFound: \"Aucun carnet trouvé.\",\n    showingFirst100: \"Affichage des 100 premières sources. Utilisez la recherche pour en trouver des spécifiques.\",\n    selectedCount: \"{count} sources sélectionnées\",\n    added: \"Ajouté le {date}\",\n    addUrl: \"Ajouter une URL\",\n    uploadFile: \"Télécharger un fichier\",\n    enterText: \"Saisir du texte\",\n    processDescription: \"Le contenu sera traité et analysé par l'IA.\",\n    processingFiles: \"Traitement de vos fichiers...\",\n    titleRequired: \"Un titre est requis pour le contenu textuel\",\n    titleGenerated: \"Si laissé vide, un titre sera généré à partir du contenu\",\n    batchCount: \"{count} {type} seront traités\",\n    enableEmbedding: \"Activer l'indexation pour la recherche\",\n    embeddingDesc: \"Permet à cette source d'être trouvée dans les recherches vectorielles et les requêtes IA\",\n    embeddingAlways: \"Indexation activée automatiquement\",\n    embeddingAlwaysDesc: \"Vos paramètres sont configurés pour toujours indexer le contenu pour la recherche vectorielle.\",\n    embeddingNever: \"Indexation désactivée\",\n    embeddingNeverDesc: \"Vos paramètres sont configurés pour ignorer l'indexation. La recherche vectorielle ne sera pas disponible pour cette source.\",\n    changeInSettings: \"Vous pouvez modifier cela dans les Paramètres\",\n    notFound: \"Source introuvable\",\n    noContent: \"Aucun contenu disponible\",\n    insightsDesc: \"Aperçus générés par l'analyse du modèle\",\n    uploadedFile: \"Fichier téléchargé\",\n    fileUnavailableDesc: \"Ce fichier est actuellement indisponible pour des raisons liées au système de stockage.\",\n    batchSuccess: \"{count} source(s) créée(s) avec succès\",\n    batchFailed: \"Échec de la création des {count} sources\",\n    batchPartial: \"{success} réussies, {failed} échouées\",\n    submittingSource: \"Soumission de la source pour traitement...\",\n    processingBatchSources: \"Traitement de {count} sources. Cela peut prendre quelques instants.\",\n    processingSource: \"Votre source est en cours de traitement. Cela peut prendre quelques instants.\",\n    maxFilesAllowed: \"Maximum {count} fichiers autorisés par lot\",\n  },\n  chat: {\n    sessions: \"Sessions\",\n    sessionTitlePlaceholder: \"Saisissez un titre ici...\",\n    noSessions: \"Aucune session de chat pour le moment\",\n    deleteSession: \"Supprimer la session\",\n    deleteSessionDesc: \"Êtes-vous sûr de vouloir supprimer cette session de chat ? Cette action est irréversible.\",\n    sendPlaceholder: \"Posez n'importe quelle question sur vos sources...\",\n    sessionsTitle: \"Sessions de Chat\",\n    chatWith: \"Discuter avec {name}\",\n    startConversation: \"Commencer une conversation sur ce {type}\",\n    askQuestions: \"Posez des questions pour mieux comprendre le contenu\",\n    pressToSend: \"Appuyez sur {key} pour envoyer\",\n    model: \"Modèle\",\n    createToStart: \"Créez une session pour commencer.\",\n    chatWithNotebook: \"Discuter avec le Carnet\",\n    unableToLoadChat: \"Impossible de charger le chat\",\n    noDescription: \"Aucune description\",\n    startByCreating: \"Commencez par créer votre premier carnet pour organiser vos recherches.\",\n    messagesCount: \"{count} messages\",\n    sessionCreated: \"Session de chat créée\",\n    sessionUpdated: \"Session mise à jour\",\n    sessionDeleted: \"Session supprimée\",\n  },\n  searchPage: {\n    askAndSearch: \"Poser une question et Rechercher\",\n    chooseAMode: \"Choisir un mode\",\n    askBeta: \"Demander (bêta)\",\n    search: \"Recherche\",\n    askYourKb: \"Interroger votre base de connaissances (bêta)\",\n    askYourKbDesc: \"Le LLM répondra à votre requête en se basant sur les documents de votre base de connaissances.\",\n    question: \"Question\",\n    enterQuestionPlaceholder: \"Entrez votre question...\",\n    pressToSubmit: \"Appuyez sur Cmd/Ctrl+Entrée pour envoyer\",\n    noEmbeddingModel: \"Vous ne pouvez pas utiliser cette fonctionnalité car aucun modèle d'embedding n'est sélectionné. Veuillez en configurer un dans la page Modèles.\",\n    usingCustomModels: \"Utilisation de modèles personnalisés\",\n    usingDefaultModels: \"Utilisation des modèles par défaut\",\n    advanced: \"Avancé\",\n    strategy: \"Stratégie\",\n    answer: \"Réponse\",\n    final: \"Final\",\n    ask: \"Demander\",\n    processing: \"Traitement...\",\n    saveToNotebooks: \"Enregistrer dans les Carnets\",\n    searchDesc: \"Recherchez des mots-clés ou des concepts spécifiques dans votre base de connaissances\",\n    enterSearchPlaceholder: \"Entrez votre recherche...\",\n    pressToSearch: \"Appuyez sur Entrée pour rechercher\",\n    searchType: \"Type de recherche\",\n    vectorSearchWarning: \"La recherche vectorielle nécessite un modèle d'embedding. Seule la recherche textuelle est disponible.\",\n    textSearch: \"Recherche textuelle\",\n    vectorSearch: \"Recherche vectorielle\",\n    searchIn: \"Rechercher dans\",\n    searchSources: \"Rechercher dans les Sources\",\n    searchNotes: \"Rechercher dans les Notes\",\n    resultsFound: \"{count} résultats trouvés\",\n    matches: \"Correspondances ({count})\",\n    noResultsFor: \"Aucun résultat trouvé pour “{query}”\",\n    notSet: \"Non défini\",\n    saveToNotebook: \"Enregistrer dans le Carnet\",\n    saveSuccess: \"Enregistré avec succès dans le carnet\",\n    saveError: \"Échec de l'enregistrement dans le carnet\",\n    selectNotebook: \"Sélectionner un carnet\",\n    searchAndAsk: \"Rechercher & Demander\",\n    searchResultsFor: \"Résultats de recherche pour “{query}”\",\n    askAbout: \"Poser une question sur “{query}”\",\n    orSearchKb: \"Ou rechercher dans votre base de connaissances\",\n    saving: \"Enregistrement...\",\n    advancedModelTitle: \"Sélection de modèle avancée\",\n    advancedModelDesc: \"Choisissez des modèles spécifiques pour chaque étape du processus de demande\",\n    strategyModel: \"Modèle de stratégie\",\n    answerModel: \"Modèle de réponse\",\n    finalAnswerModel: \"Modèle de réponse finale\",\n    selectStrategyPlaceholder: \"Sélectionner le modèle de stratégie\",\n    selectAnswerPlaceholder: \"Sélectionner le modèle de réponse\",\n    selectFinalPlaceholder: \"Sélectionner le modèle final\",\n    saveChanges: \"Enregistrer les modifications\",\n    processingQuestion: \"Traitement de votre question...\",\n  },\n  podcasts: {\n    generateEpisode: \"Générer un épisode de podcast\",\n    generateEpisodeDesc: \"Sélectionnez le contenu à inclure et configurez les détails de l'épisode avant de générer un nouvel épisode de podcast.\",\n    content: \"Contenu\",\n    contentDesc: \"Choisissez les carnets, sources et notes à inclure dans cet épisode.\",\n    itemsSelected: \"{count} éléments sélectionnés\",\n    tokens: \"{count} tokens\",\n    chars: \"{count} caractères\",\n    loadingNotebooks: \"Chargement des carnets...\",\n    noNotebooksFoundInPodcasts: \"Aucun carnet trouvé. Créez un carnet et ajoutez du contenu avant de générer un podcast.\",\n    noContentSelected: \"Aucun contenu sélectionné\",\n    summary: \"Résumé\",\n    fullContent: \"Contenu complet\",\n    untitledSource: \"Source sans titre\",\n    untitledNote: \"Note sans titre\",\n    episodeSettings: \"Paramètres de l'épisode\",\n    episodeProfile: \"Profil de l'épisode\",\n    episodeProfilePlaceholder: \"Sélectionnez un profil d'épisode\",\n    episodeName: \"Nom de l'épisode\",\n    episodeNamePlaceholder: \"ex: L'IA et le futur du travail\",\n    additionalInstructions: \"Instructions supplémentaires\",\n    instructionsPlaceholder: \"Tout conseil supplémentaire à ajouter au briefing de l'épisode...\",\n    generating: \"Génération...\",\n    generate: \"Générer\",\n    hostPlaceholder: \"Hôte {number}\",\n    profileRequired: \"Profil d'épisode requis\",\n    profileRequiredDesc: \"Sélectionnez un profil d'épisode avant de générer un podcast.\",\n    nameRequired: \"Nom de l'épisode requis\",\n    nameRequiredDesc: \"Fournissez un nom pour l'épisode.\",\n    addContext: \"Ajouter du contexte\",\n    addContextDesc: \"Sélectionnez au moins une source ou une note à inclure dans l'épisode.\",\n    generationFailed: \"Échec de la génération du podcast\",\n    speakerProfile: \"Profil de l'intervenant\",\n    usesSpeakerProfile: \"Utilise le profil de l'intervenant\",\n    sources: \"Sources\",\n    notes: \"Notes\",\n    noSources: \"Aucune source disponible dans ce carnet.\",\n    noNotes: \"Aucune note disponible dans ce carnet.\",\n    selectMode: \"Sélectionner le mode\",\n    buildContextFailed: \"Échec de la construction du contexte. Veuillez vérifier vos sélections.\",\n    podcastTaskStarted: \"Tâche de podcast démarrée\",\n    loadingProfiles: \"Chargement des profils d'épisode...\",\n    noProfilesFound: \"Aucun profil d'épisode trouvé. Créez un profil d'épisode avant de générer un podcast.\",\n    listTitle: \"Podcasts\",\n    listDesc: \"Suivez les épisodes générés et gérez les profils réutilisables.\",\n    chooseAView: \"Choisir une vue\",\n    episodesTab: \"Épisodes\",\n    templatesTab: \"Profils\",\n    overviewTitle: \"Aperçu des épisodes\",\n    overviewDesc: \"Surveillez les tâches de génération de podcast et consultez les artefacts finaux.\",\n    generateBtn: \"Générer un podcast\",\n    total: \"Total\",\n    processingLabel: \"En cours\",\n    completedLabel: \"Terminé\",\n    failedLabel: \"Échoué\",\n    pendingLabel: \"En attente\",\n    loadErrorTitle: \"Échec du chargement des épisodes\",\n    loadErrorDesc: \"Nous n'avons pas pu récupérer les derniers épisodes. Réessayez dans un instant.\",\n    loadingEpisodes: \"Chargement des épisodes…\",\n    noEpisodesYet: \"Aucun épisode de podcast pour le moment. Générez votre premier depuis le carnet ou les interfaces de chat.\",\n    statusRunningTitle: \"En cours de traitement\",\n    statusRunningDesc: \"Épisodes dont les ressources sont activement en cours de génération.\",\n    statusPendingTitle: \"En file d'attente / En attente\",\n    statusPendingDesc: \"Épisodes soumis en attente de traitement.\",\n    statusCompletedTitle: \"Épisodes terminés\",\n    statusCompletedDesc: \"Prêts à être consultés, téléchargés ou publiés.\",\n    statusFailedTitle: \"Épisodes échoués\",\n    statusFailedDesc: \"Épisodes ayant rencontré des problèmes lors de la génération.\",\n    templatesWorkspaceTitle: \"Espace de travail des profils\",\n    templatesWorkspaceDesc: \"Créez des configurations d'épisodes et d'intervenants réutilisables pour une production rapide.\",\n    howTemplatesPowerTitle: \"Comment les profils propulsent la génération\",\n    howTemplatesPowerDesc: \"Les profils divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.\",\n    episodeProfilesSetFormat: \"Les profils d'épisode définissent le format\",\n    episodeProfilesList1: \"Définissez le nombre de segments et le déroulement de l'histoire\",\n    episodeProfilesList2: \"Choisissez les modèles de langue pour le briefing, le plan et l'écriture du script\",\n    episodeProfilesList3: \"Enregistrez des briefings par défaut pour un ton cohérent\",\n    speakerProfilesBringVoices: \"Les profils d'intervenants donnent vie aux voix\",\n    speakerProfilesList1: \"Choisissez le fournisseur de synthèse vocale (TTS) et le modèle\",\n    speakerProfilesList2: \"Capturez la personnalité, l'histoire et les notes de prononciation par intervenant\",\n    speakerProfilesList3: \"Réutilisez les mêmes voix d'hôtes ou d'invités sur différents formats\",\n    recommendedWorkflow: \"Flux de travail recommandé\",\n    workflowStep1: \"Créez des profils d'intervenants pour chaque voix nécessaire\",\n    workflowStep2: \"Créez des profils d'épisodes qui référencent ces intervenants par leur nom\",\n    workflowStep3: \"Générez des podcasts en sélectionnant le profil d'épisode adapté\",\n    workflowHint: \"Les profils d'épisode référencent les intervenants par nom ; commencer par les voix évite les oublis d'attribution plus tard.\",\n    failedToLoadTemplates: \"Échec du chargement des profils\",\n    failedToLoadTemplatesDesc: \"Vérifiez que l'API fonctionne et réessayez. Certaines sections peuvent être incomplètes.\",\n    loadingTemplates: \"Chargement des profils…\",\n    speakerProfilesTitle: \"Profils d'intervenants\",\n    speakerProfilesDesc: \"Configurez les voix et personnalités pour les épisodes générés.\",\n    createSpeaker: \"Créer un intervenant\",\n    noSpeakerProfiles: \"Aucun profil d'intervenant. Créez-en un pour activer les profils d'épisodes.\",\n    noDescription: \"Aucune description fournie.\",\n    usedByCount_one: \"Utilisé par 1 épisode\",\n    usedByCount_other: \"Utilisé par {count} épisodes\",\n    usedByCount: \"Utilisé par {count} épisodes\",\n    unused: \"Inutilisé\",\n    voiceId: \"ID de la voix\",\n    backstory: \"Histoire (Backstory)\",\n    personality: \"Personnalité\",\n    edit: \"Modifier\",\n    duplicate: \"Dupliquer\",\n    deleteSpeakerProfileTitle: \"Supprimer le profil de l'intervenant ?\",\n    deleteSpeakerProfileDesc: \"La suppression de “{name}” est irréversible.\",\n    deleteSpeakerDisabledHint: \"Retirez cet intervenant des profils d'épisode avant de le supprimer.\",\n    deleting: \"Suppression…\",\n    episodeProfilesTitle: \"Profils d'épisode\",\n    episodeProfilesDesc: \"Définissez des paramètres de génération réutilisables pour vos émissions.\",\n    createProfile: \"Créer un profil\",\n    createSpeakerFirst: \"Créez un profil d'intervenant avant d'ajouter un profil d'épisode.\",\n    noEpisodeProfiles: \"Aucun profil d'épisode. Créez-en un pour lancer la génération de podcasts.\",\n    speakerCreated: \"Intervenant créé\",\n    speakerCreatedDesc: \"L'intervenant \\\"{name}\\\" a été ajouté avec succès.\",\n    failedToCreateSpeaker: \"Échec de la création du profil d'intervenant\",\n    speakerUpdated: \"Intervenant mis à jour\",\n    speakerUpdatedDesc: \"L'intervenant \\\"{name}\\\" a été mis à jour avec succès.\",\n    failedToUpdateSpeaker: \"Échec de la mise à jour du profil d'intervenant\",\n    speakerDeleted: \"Intervenant supprimé\",\n    speakerDeletedDesc: \"L'intervenant \\\"{name}\\\" a été retiré avec succès.\",\n    failedToDeleteSpeaker: \"Échec de la suppression du profil d'intervenant\",\n    speakerDuplicated: \"Intervenant dupliqué\",\n    speakerDuplicatedDesc: \"L'intervenant \\\"{name}\\\" a été dupliqué avec succès.\",\n    failedToDuplicateSpeaker: \"Échec de la duplication du profil d'intervenant\",\n    generationStarted: \"Génération démarrée\",\n    generationStartedDesc: \"La génération du podcast a été mise en file d'attente.\",\n    failedToStartGeneration: \"Échec du démarrage de la génération\",\n    tryAgainMoment: \"Veuillez réessayer dans un instant.\",\n    deleteProfileTitle: \"Supprimer le profil ?\",\n    deleteProfileDesc: \"Ceci supprimera “{name}”. Les épisodes existants conservent leurs données, mais les nouveaux ne pourront plus utiliser cette configuration.\",\n    profileCreated: \"Profil créé\",\n    profileCreatedDesc: \"Le profil d'épisode \\\"{name}\\\" a été créé avec succès.\",\n    failedToCreateProfile: \"Échec de la création du profil\",\n    profileUpdated: \"Profil mis à jour\",\n    profileUpdatedDesc: \"Le profil d'épisode \\\"{name}\\\" a été mis à jour avec succès.\",\n    failedToUpdateProfile: \"Échec de la mise à jour du profil\",\n    profileDeleted: \"Profil supprimé\",\n    profileDeletedDesc: \"Le profil d'épisode \\\"{name}\\\" a été retiré avec succès.\",\n    failedToDeleteProfile: \"Échec de la suppression du profil\",\n    failedToDeleteProfileDesc: \"Impossible de retirer le profil d'épisode.\",\n    profileDuplicated: \"Profil dupliqué\",\n    profileDuplicatedDesc: \"Le profil d'épisode \\\"{name}\\\" a été dupliqué avec succès.\",\n    failedToDuplicateProfile: \"Échec de la duplication du profil\",\n    episodeDeleted: \"Épisode supprimé\",\n    episodeDeletedDesc: \"L'épisode a été supprimé avec succès.\",\n    failedToDeleteEpisode: \"Échec de la suppression de l'épisode\",\n    failedToDeleteSpeakerDesc: \"Impossible de retirer le profil de l'intervenant.\",\n    outlineModel: \"Modèle de plan\",\n    transcriptModel: \"Modèle de transcription\",\n    segments: \"Segments\",\n    defaultBriefingTitle: \"Briefing par défaut\",\n    created: \"Créé à {time}\",\n    details: \"Détails\",\n    summaryTab: \"Résumé\",\n    outlineTab: \"Plan\",\n    transcriptTab: \"Transcription\",\n    briefing: \"Briefing\",\n    noOutline: \"Aucun plan disponible.\",\n    noTranscript: \"Aucune transcription disponible.\",\n    deleteEpisodeTitle: \"Supprimer l'épisode ?\",\n    deleteEpisodeDesc: \"Ceci supprimera définitivement “{name}” et son fichier audio.\",\n    audioUnavailable: \"Audio indisponible\",\n    segment: \"Segment\",\n    speaker: \"Intervenant\",\n    profile: \"Profil\",\n    link: \"Lien\",\n    file: \"Fichier\",\n    embedded: \"Indexé\",\n    notEmbedded: \"Non indexé\",\n    noSpeakerProfilesAvailable: \"Aucun profil d'intervenant disponible\",\n    editEpisodeProfile: \"Modifier le profil d'épisode\",\n    createEpisodeProfile: \"Créer un profil d'épisode\",\n    episodeProfileFormDesc: \"Définissez comment les épisodes doivent être générés et quelle configuration d'intervenants ils utilisent par défaut.\",\n    noSpeakerProfilesDesc: \"Créez un profil d'intervenant avant de configurer un profil d'épisode.\",\n    profileName: \"Nom du profil\",\n    profileNamePlaceholder: \"ex: Discussion tech\",\n    descriptionPlaceholder: \"Bref résumé de l'usage de ce profil\",\n    speakerConfig: \"Configuration des intervenants\",\n    selectSpeakerProfile: \"Sélectionnez un profil d'intervenant\",\n    outlineGeneration: \"Génération du plan\",\n    transcriptGeneration: \"Génération de la transcription\",\n    defaultBriefingPlaceholder: \"Décrivez la structure, le ton et les objectifs pour ce format d'épisode\",\n    editSpeakerProfile: \"Modifier le profil de l'intervenant\",\n    createSpeakerProfile: \"Créer un profil d'intervenant\",\n    speakerProfileFormDesc: \"Configurez les paramètres de synthèse vocale et définissez jusqu'à quatre intervenants.\",\n    speakers: \"Intervenants\",\n    speakersDesc: \"Configurez entre un et quatre intervenants pour ce profil.\",\n    addSpeaker: \"Ajouter un intervenant\",\n    speakerNumber: \"Intervenant {number}\",\n    backstoryPlaceholder: \"Courte biographie ou contexte de l'intervenant\",\n    personalityPlaceholder: \"Décrivez le style et le ton\",\n    outlineModelRequired: \"Le modèle du plan est requis\",\n    transcriptModelRequired: \"Le modèle de transcription est requis\",\n    defaultBriefingRequired: \"Le briefing par défaut est requis\",\n    segmentsInteger: \"Doit être un nombre entier\",\n    segmentsMin: \"Au moins 3 segments\",\n    segmentsMax: \"20 segments maximum\",\n    voiceIdRequired: \"L'ID de la voix est requis\",\n    backstoryRequired: \"L'histoire (backstory) est requise\",\n    personalityRequired: \"La personnalité est requise\",\n    speakerCountMin: \"Au moins un intervenant est requis\",\n    speakerCountMax: \"Vous pouvez configurer jusqu'à 4 intervenants\",\n    delete: \"Supprimer\",\n    failedToDelete: \"Échec de la suppression du podcast\",\n    retry: \"Réessayer\",\n    retrying: \"Nouvelle tentative…\",\n    retryStarted: \"Nouvelle tentative lancée\",\n    retryStartedDesc: \"Un nouveau travail de génération de podcast a été soumis.\",\n    failedToRetry: \"Échec de la nouvelle tentative\",\n    errorDetails: \"Détails de l'erreur\",\n    language: \"Langue\",\n    languagePlaceholder: \"Sélectionnez une langue (optionnel)\",\n    podcastLanguage: \"Langue du podcast\",\n    selectOutlineModel: \"Sélectionnez le modèle de plan\",\n    selectTranscriptModel: \"Sélectionnez le modèle de transcription\",\n    voiceModel: \"Modèle vocal\",\n    voiceModelRequired: \"Le modèle vocal est requis\",\n    selectVoiceModel: \"Sélectionnez le modèle vocal\",\n    perSpeakerTtsOverride: \"Remplacement TTS par intervenant (optionnel)\",\n    useProfileDefault: \"Utiliser le profil par défaut\",\n    setupRequired: \"Configuration requise\",\n    setupRequiredDesc: \"Certains profils n'ont pas encore de modèles configurés. Modifiez-les pour sélectionner des modèles avant de générer des podcasts.\",\n    notConfigured: \"Non configuré\",\n  },\n  settings: {\n    contentProcessing: \"Traitement du contenu\",\n    contentProcessingDesc: \"Configurez la manière dont les documents et les URL sont traités\",\n    docEngine: \"Moteur de traitement de documents\",\n    docEnginePlaceholder: \"Sélectionnez un moteur de traitement de documents\",\n    urlEngine: \"Moteur de traitement d'URL\",\n    urlEnginePlaceholder: \"Sélectionnez un moteur de traitement d'URL\",\n    autoRecommended: \"Auto (Recommandé)\",\n    simple: \"Simple\",\n    docling: \"Docling\",\n    helpMeChoose: \"Aidez-moi à choisir\",\n    docHelp: \"· Docling est un peu plus lent mais plus précis, surtout si les documents contiennent des tableaux et des images. · Simple extraira tout le contenu du document sans le formater. · Auto (recommandé) essaiera de traiter via Docling et se rabattra sur Simple par défaut.\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl est un service payant (avec un niveau gratuit), et très puissant. · Jina est également une bonne option et dispose aussi d'un niveau gratuit. · Simple utilisera une extraction HTTP basique et manquera du contenu sur les sites basés sur Javascript. · Auto (recommandé) essaiera d'utiliser Firecrawl puis Jina, et enfin se rabattra sur Simple.\",\n    embeddingAndSearch: \"Indexation (Embedding) et Recherche\",\n    embeddingAndSearchDesc: \"Configurez les options de recherche et d'indexation\",\n    defaultEmbeddingOption: \"Option d'indexation par défaut\",\n    embeddingOptionPlaceholder: \"Sélectionnez une option d'indexation\",\n    ask: \"Demander\",\n    always: \"Toujours\",\n    never: \"Jamais\",\n    embeddingHelp: \"L'indexation du contenu facilite sa recherche par vous et vos agents IA. Si vous utilisez un modèle d'embedding local (Ollama, par exemple), vous n'avez pas à vous soucier du coût et pouvez tout indexer.\",\n    fileManagement: \"Gestion des fichiers\",\n    fileManagementDesc: \"Configurez les options de manipulation et de stockage des fichiers\",\n    autoDeleteFiles: \"Suppression automatique des fichiers\",\n    autoDeletePlaceholder: \"Sélectionnez une option de suppression automatique\",\n    filesHelp: \"Une fois vos fichiers téléchargés et traités, ils ne sont plus nécessaires. La plupart des utilisateurs devraient autoriser Open Notebook à supprimer automatiquement les fichiers du dossier de téléchargement.\",\n    loadFailed: \"Échec du chargement des paramètres\",\n  },\n  advanced: {\n    title: \"Outils Avancés\",\n    desc: \"Outils et utilitaires avancés pour les utilisateurs expérimentés\",\n    systemInfo: \"Infos Système\",\n    rebuildEmbeddings: \"Reconstruire les index (Embeddings)\",\n    rebuildEmbeddingsDesc: \"Reconstruire l'index de recherche vectorielle pour toutes les sources\",\n    currentVersion: \"Version actuelle\",\n    latestVersion: \"Dernière version\",\n    status: \"État\",\n    updateAvailable: \"Version {version} disponible\",\n    updateAvailableDesc: \"Une nouvelle version de Open Notebook est disponible.\",\n    upToDate: \"À jour\",\n    unknown: \"Inconnu\",\n    viewOnGithub: \"Voir sur GitHub\",\n    updateCheckFailed: \"Impossible de vérifier les mises à jour. GitHub est peut-être injoignable.\",\n    rebuild: {\n      mode: \"Mode de reconstruction\",\n      existing: \"Existant\",\n      all: \"Tout\",\n      existingDesc: \"Ré-indexer uniquement les éléments qui ont déjà des embeddings (plus rapide, utile lors d'un changement de modèle)\",\n      allDesc: \"Ré-indexer les éléments existants + créer des embeddings pour les éléments qui n'en ont pas (plus lent, complet)\",\n      include: \"Inclure dans la reconstruction\",\n      selectOneError: \"Veuillez sélectionner au moins un type d'élément à reconstruire\",\n      starting: \"Démarrage de la reconstruction...\",\n      startBtn: \"🚀 Lancer la reconstruction\",\n      queued: \"En attente\",\n      running: \"En cours...\",\n      completed: \"Terminé !\",\n      failed: \"Échoué\",\n      leavePageHint: \"Vous pouvez quitter cette page, car l'opération s'exécute en arrière-plan\",\n      startNew: \"Lancer une nouvelle reconstruction\",\n      itemsProcessed: \"{processed}/{total} éléments ({percent}%)\",\n      failedItems: \"{count} éléments n'ont pas pu être traités\",\n      time: \"Temps\",\n      whenToRebuild: \"Quand dois-je reconstruire les embeddings ?\",\n      whenToRebuildAns: \"Vous devriez reconstruire lors d'un changement de modèle, d'une mise à jour de version, pour corriger une corruption de données ou après des imports massifs.\",\n      howLong: \"Combien de temps dure la reconstruction ?\",\n      howLongAns: \"Le temps de traitement dépend du nombre d'éléments, de la vitesse du modèle et des limites de débit de l'API. Les modèles locaux sont généralement très rapides.\",\n      isSafe: \"Est-il sûr de reconstruire pendant l'utilisation de l'application ?\",\n      isSafeAns: \"Oui, la reconstruction est sûre ! Elle ne supprime pas le contenu, remplace seulement les embeddings et gère les erreurs proprement.\",\n    },\n  },\n  transformations: {\n    title: \"Transformations\",\n    desc: \"Les transformations sont des prompts utilisés par le LLM pour traiter une source et extraire des aperçus, des résumés, etc.\",\n    workspace: \"Choisissez un espace de travail\",\n    playground: \"Bac à sable (Playground)\",\n    defaultPrompt: \"Prompt de transformation par défaut\",\n    defaultPromptDesc: \"Ceci sera ajouté à tous vos prompts de transformation\",\n    defaultPromptPlaceholder: \"Entrez vos instructions de transformation par défaut...\",\n    listTitle: \"Transformations personnalisées\",\n    createNew: \"Créer une nouvelle\",\n    inputLabel: \"Texte d'entrée\",\n    inputPlaceholder: \"Entrez du texte à transformer...\",\n    outputLabel: \"Sortie\",\n    runTest: \"Exécuter la transformation\",\n    running: \"Exécution...\",\n    selectToStart: \"Sélectionnez une transformation pour commencer\",\n    name: \"Nom\",\n    namePlaceholder: \"Identifiant unique, ex: points_cles\",\n    titlePlaceholder: \"Titre affiché, par défaut le nom\",\n    promptPlaceholder: \"Écrivez le prompt qui alimentera cette transformation...\",\n    descriptionPlaceholder: \"Décrivez ce que fait cette transformation.\",\n    suggestDefault: \"Suggérer par défaut sur les nouvelles sources\",\n    promptHint: \"Les prompts doivent être rédigés en pensant au contenu de la source. Vous pouvez demander au modèle de résumer, d'extraire des analyses ou de produire des sorties structurées comme des tableaux.\",\n    createSuccess: \"Transformation créée avec succès\",\n    updateSuccess: \"Transformation mise à jour avec succès\",\n    deleteSuccess: \"Transformation supprimée avec succès\",\n    noTransformations: \"Aucune transformation pour le moment\",\n    createOne: \"Créez une transformation pour commencer\",\n    selectModel: \"Sélectionnez un modèle\",\n    deleteConfirm: \"Êtes-vous sûr de vouloir supprimer cette transformation ?\",\n    model: \"Modèle\",\n    systemPrompt: \"Prompt Système\",\n    overrideModelDesc: \"Remplacer le modèle par défaut pour cette session de chat. Laissez vide pour utiliser le modèle par défaut du système.\",\n    sessionUseReplacement: \"Cette session utilisera {name} au lieu du modèle par défaut.\",\n    systemDefault: \"Défaut Système\",\n  },\n  models: {\n    embedding: \"Modèles d'Embedding\",\n    tts: \"Synthèse vocale (TTS)\",\n    stt: \"Transcription vocale (STT)\",\n    apiKey: \"Clé API\",\n    deleteSuccess: \"Modèle supprimé avec succès\",\n    saveSuccess: \"Modèle enregistré avec succès\",\n    noModels: \"Aucun modèle\",\n    discoverModels: \"Découvrir les modèles\",\n    noModelsFound: \"Aucun modèle trouvé pour ce fournisseur\",\n    modelType: \"Type de modèle\",\n    modelTypeHint: \"Sélectionnez le type de modèles que vous souhaitez ajouter. Si vous avez besoin de types différents, ajoutez-les par lots séparés.\",\n    deleteModel: \"Supprimer le modèle\",\n    defaultAssignments: \"Attributions des modèles par défaut\",\n    defaultAssignmentsDesc: \"Configurez quels modèles utiliser pour les différents usages d'Open Notebook\",\n    missingRequiredModels: \"Modèles requis manquants : {models}. Open Notebook pourrait ne pas fonctionner correctement sans eux.\",\n    selectModelPlaceholder: \"Sélectionnez un modèle\",\n    requiredModelPlaceholder: \"⚠️ Requis - Sélectionnez un modèle\",\n    chatModelLabel: \"Modèle de Chat\",\n    chatModelDesc: \"Utilisé pour les conversations\",\n    transformationModelLabel: \"Modèle de Transformation\",\n    transformationModelDesc: \"Utilisé pour les résumés, les aperçus et les transformations\",\n    toolsModelLabel: \"Modèle d'Outils\",\n    toolsModelDesc: \"Utilisé pour l'appel de fonctions (OpenAI ou Anthropic recommandé)\",\n    largeContextModelLabel: \"Modèle à large contexte\",\n    largeContextModelDesc: \"Utilisé pour le traitement de documents volumineux (Gemini recommandé)\",\n    embeddingModelLabel: \"Modèle d'Embedding\",\n    embeddingModelDesc: \"Utilisé pour la recherche sémantique et les index vectoriels\",\n    ttsModelLabel: \"Modèle de Synthèse Vocale (TTS)\",\n    ttsModelDesc: \"Utilisé pour la génération de podcasts\",\n    sttModelLabel: \"Modèle de Transcription Vocale (STT)\",\n    sttModelDesc: \"Utilisé pour la transcription audio\",\n    embeddingChangeTitle: \"Changement de modèle d'embedding\",\n    embeddingChangeConfirm: \"Vous êtes sur le point de changer votre modèle d'embedding de {from} à {to}.\",\n    rebuildRequired: \"Important : Reconstruction requise\",\n    rebuildReason: \"Changer votre modèle d'embedding nécessite de reconstruire tous les index existants pour maintenir la cohérence. Sans cela, vos recherches pourraient retourner des résultats incorrects ou incomplets.\",\n    whatHappensNext: \"Que se passe-t-il ensuite :\",\n    step1: \"Votre modèle d'embedding par défaut sera mis à jour\",\n    step2: \"Les embeddings existants resteront inchangés jusqu'à la reconstruction\",\n    step3: \"Le nouveau contenu utilisera le nouveau modèle d'embedding\",\n    step4: \"Vous devriez reconstruire les index dès que possible\",\n    proceedToRebuildPrompt: \"Souhaitez-vous aller sur la page Avancé pour lancer la reconstruction maintenant ?\",\n    changeModelOnly: \"Changer le modèle uniquement\",\n    changeAndRebuild: \"Changer & Aller à la reconstruction\",\n    autoAssign: \"Attribution automatique des défauts\",\n    autoAssigning: \"Attribution en cours...\",\n    autoAssignSuccess: \"{count} modèles par défaut attribués automatiquement\",\n    autoAssignNoModels: \"Aucun modèle disponible à attribuer. Veuillez d'abord synchroniser les modèles.\",\n    autoAssignAlreadySet: \"Tous les modèles par défaut sont déjà configurés\",\n    testModel: \"Tester le modèle\",\n    testModelSuccess: \"Test du modèle réussi\",\n    testModelFailed: \"Test du modèle échoué\",\n    searchOrAddModel: \"Rechercher ou saisir un nom de modèle...\",\n    addCustomModel: \"Ajouter \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"Configurez votre IA avec vos propres clés API\",\n    description: \"Stockez les clés API de manière sécurisée dans la base de données pour activer les fournisseurs d'IA dans Open Notebook.\",\n    encryptionRequired: \"Clé de chiffrement non configurée\",\n    encryptionRequiredDescription: \"Définissez la variable d'environnement OPEN_NOTEBOOK_ENCRYPTION_KEY avec une chaîne secrète pour activer le stockage des clés API dans la base de données.\",\n    configured: \"Configuré\",\n    notConfigured: \"Non configuré\",\n    migrationAvailable: \"Variables d'environnement détectées\",\n    migrationDescription: \"{count} clé(s) API sont configurées via des variables d'environnement et peuvent être migrées vers la base de données pour une gestion plus facile.\",\n    migrateToDatabase: \"Migrer vers la base de données\",\n    migrating: \"Migration en cours...\",\n    migrationSuccess: \"{count} clé(s) API migrée(s) avec succès\",\n    migrationErrors: \"{count} clé(s) n'ont pas pu être migrée(s)\",\n    migrationNothingToMigrate: \"Toutes les clés sont déjà dans la base de données\",\n    learnMore: \"Apprenez à configurer les clés API →\",\n    testConnection: \"Tester la connexion\",\n    testSuccess: \"Connexion réussie\",\n    testFailed: \"Échec du test de connexion\",\n    syncModels: \"Synchroniser les modèles\",\n    syncSuccess: \"{discovered} modèles découverts, {new} nouveaux ajoutés\",\n    syncNoNew: \"{count} modèles découverts, tous déjà enregistrés\",\n    syncFailed: \"Échec de la synchronisation des modèles\",\n    getApiKey: \"Obtenir une clé API\",\n    vertexProject: \"ID du projet GCP\",\n    vertexLocation: \"Région\",\n    vertexCredentials: \"Chemin du JSON du compte de service\",\n    addConfig: \"Ajouter une configuration\",\n    editConfig: \"Modifier la configuration\",\n    deleteConfig: \"Supprimer la configuration\",\n    configName: \"Nom de la configuration\",\n    configNameHint: \"Un nom descriptif pour cette configuration (ex : « Production », « Développement »)\",\n    baseUrl: \"URL de base\",\n    baseUrlOverrideHint: \"Ne modifiez ceci que si vous devez remplacer le point d'accès API par défaut du fournisseur.\",\n    deleteConfigConfirm: \"Êtes-vous sûr de vouloir supprimer « {name} » ? Cette action est irréversible.\",\n    configSaveSuccess: \"Configuration enregistrée avec succès\",\n    configUpdateSuccess: \"Configuration mise à jour avec succès\",\n    configDeleteSuccess: \"Configuration supprimée avec succès\",\n    apiKeyEditHint: \"Laissez vide pour conserver la clé API existante\",\n  },\n  setupBanner: {\n    encryptionRequired: \"Clé de chiffrement non configurée\",\n    encryptionRequiredDescription: \"Définissez la variable d'environnement OPEN_NOTEBOOK_ENCRYPTION_KEY pour activer le stockage sécurisé des identifiants.\",\n    migrationAvailable: \"Migration des clés API disponible\",\n    migrationDescription: \"{count} fournisseur(s) ont des clés API définies via des variables d'environnement. Migrez-les vers la base de données pour une gestion plus facile.\",\n    goToSettings: \"Aller aux paramètres\",\n    viewDocs: \"Voir la documentation\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/index.test.ts",
    "content": "import { describe, it, expect } from 'vitest'\nimport fs from 'fs'\nimport path from 'path'\nimport { resources } from './index'\nimport { enUS } from './en-US'\n\nconst getKeys = (obj: Record<string, unknown>, prefix = ''): string[] => {\n  return Object.keys(obj).reduce((res: string[], el) => {\n    const val = obj[el]\n    if (typeof val === 'object' && val !== null && !Array.isArray(val)) {\n      return [...res, ...getKeys(val as Record<string, unknown>, prefix + el + '.')]\n    }\n    return [...res, prefix + el]\n  }, [])\n}\n\ndescribe('Locale Parity', () => {\n  const enKeys = getKeys(enUS)\n\n  const locales = Object.entries(resources).filter(([code]) => code !== 'en-US')\n\n  it.each(locales.map(([code, resource]) => [code, resource] as const))(\n    '%s should have the same keys as en-US',\n    (code, resource) => {\n      const localeKeys = getKeys(resource.translation as Record<string, unknown>)\n\n      const missing = enKeys.filter(key => !localeKeys.includes(key))\n      const extra = localeKeys.filter(key => !enKeys.includes(key))\n\n      expect(missing, `Missing keys in ${code}: ${missing.join(', ')}`).toEqual([])\n      expect(extra, `Extra keys in ${code}: ${extra.join(', ')}`).toEqual([])\n    },\n  )\n})\n\ndescribe('Unused Key Detection', () => {\n  it(\n    'all en-US leaf keys should be referenced in source files',\n    () => {\n      const srcDir = path.resolve(__dirname, '../../..')\n      const localesDir = path.resolve(__dirname)\n\n      const files = fs.readdirSync(srcDir, { recursive: true }) as string[]\n      const sourceFiles = files.filter(f => {\n        const full = path.join(srcDir, f)\n        if (full.startsWith(localesDir)) return false\n        if (f.endsWith('.test.ts') || f.endsWith('.test.tsx')) return false\n        return f.endsWith('.ts') || f.endsWith('.tsx')\n      })\n\n      // Normalize optional chaining (t?.common?.key → t.common.key)\n      // so that keys like \"common.errorDetails\" match \"common?.errorDetails\"\n      const corpus = sourceFiles\n        .map(f => fs.readFileSync(path.join(srcDir, f), 'utf-8'))\n        .join('\\n')\n        .replace(/\\?\\./g, '.')\n\n      const leafKeys = getKeys(enUS)\n      const unused = leafKeys.filter(key => !corpus.includes(key))\n\n      expect(\n        unused,\n        `Found ${unused.length} unused i18n key(s):\\n${unused.join('\\n')}`,\n      ).toEqual([])\n    },\n    30_000,\n  )\n})\n"
  },
  {
    "path": "frontend/src/lib/locales/index.ts",
    "content": "import { zhCN } from './zh-CN';\nimport { enUS } from './en-US';\nimport { zhTW } from './zh-TW';\nimport { ptBR } from './pt-BR';\nimport { jaJP } from './ja-JP';\nimport { itIT } from './it-IT';\nimport { frFR } from './fr-FR';\nimport { ruRU } from './ru-RU';\nimport { bnIN } from './bn-IN';\n\nexport const resources = {\n  'zh-CN': { translation: zhCN },\n  'en-US': { translation: enUS },\n  'zh-TW': { translation: zhTW },\n  'pt-BR': { translation: ptBR },\n  'ja-JP': { translation: jaJP },\n  'it-IT': { translation: itIT },\n  'fr-FR': { translation: frFR },\n  'ru-RU': { translation: ruRU },\n  'bn-IN': { translation: bnIN },\n} as const;\n\nexport type TranslationKeys = typeof enUS;\n\nexport type LanguageCode = 'zh-CN' | 'en-US' | 'zh-TW' | 'pt-BR' | 'ja-JP' | 'it-IT' | 'fr-FR' | 'ru-RU' | 'bn-IN';\n\nexport type Language = {\n  code: LanguageCode;\n  label: string;\n};\n\nexport const languages: Language[] = [\n  { code: 'en-US', label: 'English' },\n  { code: 'zh-CN', label: '简体中文' },\n  { code: 'zh-TW', label: '繁體中文' },\n  { code: 'pt-BR', label: 'Português' },\n  { code: 'ja-JP', label: '日本語' },\n  { code: 'it-IT', label: 'Italiano' },\n  { code: 'fr-FR', label: 'Français' },\n  { code: 'ru-RU', label: 'Русский' },\n  { code: 'bn-IN', label: 'বাংলা' },\n];\n\nexport { zhCN, enUS, zhTW, ptBR, jaJP, itIT, frFR, ruRU, bnIN };\n"
  },
  {
    "path": "frontend/src/lib/locales/it-IT/index.ts",
    "content": "export const itIT = {\n  common: {\n    search: \"Cerca...\",\n    create: \"Nuovo\",\n    new: \"Nuovo\",\n    cancel: \"Annulla\",\n    delete: \"Elimina\",\n    edit: \"Modifica\",\n    theme: \"Tema\",\n    signOut: \"Esci\",\n    noMatches: \"Nessun risultato trovato\",\n    tryDifferentSearch: \"Prova con un termine di ricerca diverso.\",\n    light: \"Chiaro\",\n    dark: \"Scuro\",\n    system: \"Sistema\",\n    loading: \"Caricamento...\",\n    note: \"Nota\",\n    insight: \"Approfondimento\",\n    newSource: \"Nuova fonte\",\n    newNotebook: \"Nuovo quaderno\",\n    newPodcast: \"Nuovo podcast\",\n    language: \"Lingua\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"Fonte\",\n    notebook: \"Quaderno\",\n    podcast: \"Podcast\",\n    quickActions: \"Azioni rapide\",\n    quickActionsDesc: \"Navigazione, ricerca, domande, tema\",\n    appName: \"Open Notebook\",\n    add: \"Aggiungi\",\n    remove: \"Rimuovi\",\n    confirm: \"Conferma\",\n    warning: \"Attenzione\",\n    error: \"Errore\",\n    success: \"Successo\",\n    model: \"Modello\",\n    back: \"Indietro\",\n    next: \"Avanti\",\n    done: \"Fatto\",\n    processing: \"Elaborazione...\",\n    creating: \"Creazione...\",\n    linked: \"Collegato\",\n    adding: \"Aggiunta in corso...\",\n    addSelected: \"Aggiungi selezionati\",\n    customModel: \"Modello personalizzato\",\n    failed: \"fallito\",\n    current: \"Corrente\",\n    save: \"Salva\",\n    writeNote: \"Scrivi nota\",\n    batchMode: \"Modalità batch\",\n    optional: \"Opzionale\",\n    type: \"Tipo\",\n    title: \"Titolo\",\n    created: \"Creato {time}\",\n    updated: \"Aggiornato {time}\",\n    actions: \"Azioni\",\n    noResults: \"Nessun risultato\",\n    references: \"Riferimenti\",\n    refreshPage: \"Prova ad aggiornare la pagina\",\n    refresh: \"Aggiorna\",\n    aiGenerated: \"Generato da IA\",\n    human: \"Umano\",\n    unknown: \"Sconosciuto\",\n    notes: \"Note\",\n    chat: \"Chat\",\n    deleteForever: \"Elimina definitivamente\",\n    connectionError: \"Errore di connessione\",\n    unableToConnect: \"Impossibile connettersi al server API\",\n    retryConnection: \"Riprova connessione\",\n    diagnosticInfo: \"Informazioni diagnostiche\",\n    version: \"Versione\",\n    built: \"Compilato\",\n    apiUrl: \"URL API\",\n    frontendUrl: \"URL Frontend\",\n    checkConsoleLogs: \"Controlla la console del browser per log dettagliati (cerca i messaggi 🔧 [Config])\",\n    yes: \"Sì\",\n    no: \"No\",\n    saving: \"Salvataggio...\",\n    description: \"Descrizione\",\n    saveToNote: \"Salva come nota\",\n    copyToClipboard: \"Copia negli appunti\",\n    close: \"Chiudi\",\n    insights: \"Approfondimenti\",\n    progress: \"Progresso\",\n    deleting: \"Eliminazione...\",\n    created_label: \"Creato\",\n    updated_label: \"Aggiornato\",\n    download: \"Scarica\",\n    saveChanges: \"Salva modifiche\",\n    name: \"Nome\",\n    default: \"Predefinito\",\n    nameRequired: \"Il nome è obbligatorio\",\n    modelConfiguration: \"Configurazione modello\",\n    resetToDefault: \"Ripristina predefinito\",\n    reasoning: \"Ragionamento\",\n    searchTerms: \"Termini di ricerca\",\n    strategy: \"Strategia\",\n    individualAnswers: \"Risposte individuali ({count})\",\n    finalAnswer: \"Risposta finale\",\n    notebookLabel: \"Quaderno: {name}\",\n    itemNotFound: \"Questo {type} non è stato trovato\",\n    accessibility: {\n      transformationViews: \"Viste trasformazioni\",\n      searchKB: \"Chiedi o cerca nella tua base di conoscenza\",\n      enterQuestion: \"Inserisci la tua domanda per interrogare la base di conoscenza\",\n      enterSearch: \"Inserisci la query di ricerca\",\n      searchKBBtn: \"Cerca nella base di conoscenza\",\n      podcastViews: \"Viste podcast\",\n      ytVideo: \"Video YouTube\",\n      askResponse: \"Risposta alla domanda\",\n      searchNotebooks: \"Cerca quaderni\",\n    },\n    url: \"URL\",\n    errorDetails: \"Dettagli errore\",\n    editTransformation: \"Modifica trasformazione\",\n    retry: \"Riprova\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"completato\",\n    saveSuccess: \"Salvato con successo\",\n    contextModes: {\n      off: \"Non incluso nella chat\",\n      insights: \"Solo approfondimenti\",\n      full: \"Contenuto completo\",\n      clickToCycle: \"Clicca per cambiare\",\n    },\n    clickToEdit: \"Clicca per modificare\",\n  },\n  apiErrors: {\n    notebookNotFound: \"Quaderno non trovato\",\n    sourceNotFound: \"Fonte non trovata\",\n    transformationNotFound: \"Trasformazione non trovata\",\n    fileUploadFailed: \"Caricamento file fallito\",\n    urlRequired: \"L'URL è obbligatorio per il tipo link\",\n    contentRequired: \"Il contenuto è obbligatorio per il tipo testo\",\n    invalidSourceType: \"Tipo di fonte non valido\",\n    processingFailed: \"Elaborazione fallita\",\n    failedToQueue: \"Impossibile accodare l'elaborazione\",\n    invalidSortBy: \"Il campo di ordinamento deve essere 'created' o 'updated'\",\n    invalidSortOrder: \"L'ordine deve essere 'asc' o 'desc'\",\n    accessDenied: \"Accesso al file negato\",\n    fileNotFoundOnServer: \"File non trovato sul server\",\n    searchFailed: \"Ricerca fallita\",\n    askFailed: \"Richiesta fallita\",\n    pleaseEnterQuestion: \"Inserisci una domanda\",\n    pleaseConfigureModels: \"Configura tutti i modelli richiesti\",\n    failedToCreateSession: \"Impossibile creare la sessione\",\n    failedToUpdateSession: \"Impossibile aggiornare la sessione\",\n    failedToDeleteSession: \"Impossibile eliminare la sessione\",\n    failedToSendMessage: \"Impossibile inviare il messaggio\",\n    unauthorized: \"Accesso non autorizzato, controlla la password\",\n    invalidPassword: \"Password non valida\",\n    embeddingModelRequired: \"Questa funzionalità richiede un modello di embedding. Configurane uno nella sezione Modelli.\",\n    strategyModelNotFound: \"Modello strategia non trovato\",\n    answerModelNotFound: \"Modello risposta non trovato\",\n    finalAnswerModelNotFound: \"Modello risposta finale non trovato\",\n    noAnswerGenerated: \"Nessuna risposta generata\",\n    genericError: \"Si è verificato un errore imprevisto\",\n  },\n  connectionErrors: {\n    apiTitle: \"Impossibile connettersi al server API\",\n    apiDesc: \"Il server API di Open Notebook non è raggiungibile\",\n    dbTitle: \"Connessione al database fallita\",\n    dbDesc: \"Il server API è in esecuzione, ma il database non è accessibile\",\n    troubleshooting: \"Questo di solito significa:\",\n    apiUnreachable1: \"Il server API non è in esecuzione\",\n    apiUnreachable2: \"Il server API è su un indirizzo diverso\",\n    apiUnreachable3: \"Problemi di connettività di rete\",\n    dbFailed1: \"SurrealDB non è in esecuzione\",\n    dbFailed2: \"Le impostazioni di connessione al database sono errate\",\n    dbFailed3: \"Problemi di rete tra API e database\",\n    quickFixes: \"Soluzioni rapide:\",\n    setApiUrl: \"Imposta la variabile d'ambiente API_URL:\",\n    checkSurreal: \"Verifica se SurrealDB è in esecuzione:\",\n    seeDocumentation: \"Per istruzioni dettagliate, consulta:\",\n    docLink: \"Documentazione Open Notebook\",\n    showTechnical: \"Mostra dettagli tecnici\",\n    attemptedUrl: \"URL tentato\",\n    message: \"Messaggio\",\n    technicalDetails: \"Dettagli tecnici\",\n    stackTrace: \"Stack trace\",\n    retryLabel: \"Riprova connessione\",\n    retryHint: \"Premi R o clicca il pulsante per riprovare\",\n    dockerLabel: \"Per docker\",\n    localDevLabel: \"Per sviluppo locale\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"Inserisci la password per accedere all'applicazione\",\n    passwordPlaceholder: \"Password\",\n    signingIn: \"Accesso in corso...\",\n    signIn: \"Accedi\",\n    connectErrorHint: \"Impossibile connettersi al server. Verifica che l'API sia in esecuzione.\",\n  },\n  navigation: {\n    collect: \"Raccogli\",\n    process: \"Elabora\",\n    create: \"Crea\",\n    manage: \"Gestisci\",\n    sources: \"Fonti\",\n    notebooks: \"Quaderni\",\n    askAndSearch: \"Chiedi e cerca\",\n    podcasts: \"Podcast\",\n    models: \"Modelli\",\n    transformations: \"Trasformazioni\",\n    transformation: \"Trasformazione\",\n    settings: \"Impostazioni\",\n    advanced: \"Avanzate\",\n    nav: \"Navigazione\",\n    language: \"Cambia lingua\",\n    theme: \"Tema\",\n    ask: \"Chiedi\",\n  },\n  notebooks: {\n    title: \"Quaderni\",\n    newNotebook: \"Nuovo quaderno\",\n    searchPlaceholder: \"Cerca quaderni...\",\n    archived: \"Archiviati\",\n    archive: \"Archivia\",\n    unarchive: \"Ripristina\",\n    deleteNotebook: \"Elimina quaderno\",\n    deleteNotebookDesc: \"Sei sicuro di voler eliminare \\\"{name}\\\"? Questa azione non può essere annullata.\",\n    deleteNotebookLoading: \"Caricamento anteprima eliminazione...\",\n    deleteNotebookNotes: \"{count} nota/e verranno eliminate definitivamente.\",\n    deleteNotebookNoNotes: \"Nessuna nota da eliminare.\",\n    deleteNotebookExclusiveSources: \"{count} fonte/i esistono solo in questo quaderno.\",\n    deleteNotebookSharedSources: \"{count} fonte/i sono condivise con altri quaderni e verranno scollegate.\",\n    deleteNotebookNoSources: \"Nessuna fonte in questo quaderno.\",\n    deleteExclusiveSourcesLabel: \"Elimina fonti esclusive\",\n    keepExclusiveSourcesLabel: \"Scollega e mantieni\",\n    activeNotebooks: \"Quaderni attivi\",\n    archivedNotebooks: \"Quaderni archiviati\",\n    notFound: \"Quaderno non trovato\",\n    notFoundDesc: \"Il quaderno richiesto non esiste.\",\n    updated: \"Aggiornato\",\n    namePlaceholder: \"Nome quaderno\",\n    addDescription: \"Aggiungi descrizione...\",\n    noNotesYet: \"Ancora nessuna nota\",\n    deleteNote: \"Elimina Nota\",\n    deleteNoteConfirm: \"Sei sicuro di voler eliminare questa nota? Questa azione non può essere annullata.\",\n    noteCreatedSuccess: \"Nota creata con successo\",\n    failedToCreateNote: \"Impossibile creare la nota\",\n    noteUpdatedSuccess: \"Nota aggiornata con successo\",\n    failedToUpdateNote: \"Impossibile aggiornare la nota\",\n    noteDeletedSuccess: \"Nota eliminata con successo\",\n    failedToDeleteNote: \"Impossibile eliminare la nota\",\n    createNew: \"Crea nuovo quaderno\",\n    createNewDesc: \"Inserisci un nome e una descrizione opzionale per iniziare.\",\n    descPlaceholder: \"Aggiungi più informazioni su questo quaderno qui...\",\n    createSuccess: \"Quaderno creato con successo\",\n    updateSuccess: \"Quaderno aggiornato con successo\",\n    deleteSuccess: \"Quaderno eliminato con successo\",\n  },\n  sources: {\n    title: \"Fonti\",\n    add: \"Aggiungi fonte\",\n    addNew: \"Aggiungi nuova fonte\",\n    addExisting: \"Aggiungi fonte esistente\",\n    delete: \"Elimina Fonte\",\n    statusPreparing: \"In preparazione\",\n    statusQueued: \"In coda\",\n    statusProcessing: \"In elaborazione\",\n    statusCompleted: \"Completato\",\n    statusFailed: \"Fallito\",\n    statusPreparingDesc: \"Preparazione all'elaborazione\",\n    statusQueuedDesc: \"In attesa di elaborazione\",\n    statusProcessingDesc: \"In fase di elaborazione\",\n    statusCompletedDesc: \"Elaborato con successo\",\n    statusFailedDesc: \"Elaborazione fallita\",\n    failedToLoad: \"Impossibile caricare le fonti\",\n    allSourcesDesc: \"Visualizza tutte le tue fonti qui. Puoi aggiungere nuove fonti o gestire quelle esistenti.\",\n    allSources: \"Tutte le fonti\",\n    insights: \"Approfondimenti\",\n    yes: \"Sì\",\n    no: \"No\",\n    loadingMore: \"Caricamento...\",\n    noSourcesYet: \"Ancora nessuna fonte\",\n    allSourcesDescShort: \"Visualizza tutte le tue fonti qui.\",\n    cannotSaveNoteNoNotebook: \"Impossibile salvare la nota: ID quaderno non disponibile\",\n    createFirstSource: \"Aggiungi la tua prima fonte per iniziare a costruire la tua base di conoscenza.\",\n    deleteSourceConfirm: \"Sei sicuro di voler eliminare questa fonte?\",\n    deleteConfirm: \"Sei sicuro di voler eliminare questo elemento?\",\n    deleteConfirmWithTitle: \"Sei sicuro di voler eliminare \\\"{title}\\\"?\",\n    deleteSuccess: \"Fonte eliminata con successo. Nota: Per eliminare il file dallo storage, devi abilitare l'opzione \\\"elimina file\\\" nella pagina impostazioni.\",\n    failedToDelete: \"Impossibile eliminare la fonte\",\n    sourceQueued: \"Fonte in coda\",\n    sourceQueuedDesc: \"Fonte inviata per l'elaborazione in background. Puoi monitorare il progresso nella lista fonti.\",\n    sourceAddedSuccess: \"Fonte aggiunta con successo\",\n    failedToAddSource: \"Impossibile aggiungere la fonte\",\n    sourceUpdatedSuccess: \"Fonte aggiornata con successo\",\n    failedToUpdateSource: \"Impossibile aggiornare la fonte\",\n    sourceDeletedSuccess: \"Fonte eliminata con successo\",\n    failedToDeleteSource: \"Impossibile eliminare la fonte\",\n    fileUploadedSuccess: \"File caricato con successo\",\n    failedToUploadFile: \"Impossibile caricare il file\",\n    sourceRequeued: \"Fonte rimessa in coda\",\n    sourceRequeuedDesc: \"La fonte è stata rimessa in coda per l'elaborazione.\",\n    failedToRetry: \"Nuovo tentativo fallito\",\n    sourcesAddedToNotebook: \"{count} fonte/i aggiunte al quaderno\",\n    failedToAddSourcesToNotebook: \"Impossibile aggiungere le fonti al quaderno\",\n    partialAddSuccess: \"{success} fonte/i aggiunte, {failed} fallite\",\n    sourceRemovedFromNotebook: \"Fonte rimossa dal quaderno con successo\",\n    failedToRemoveSourceFromNotebook: \"Impossibile rimuovere la fonte dal quaderno\",\n    removeConfirm: \"Sei sicuro di voler rimuovere questa fonte dal quaderno?\",\n    checking: \"Verifica...\",\n    untitledSource: \"Fonte senza titolo\",\n    maxItems: \"max {count}\",\n    insightsCount: \"{count} approfondimenti\",\n    details: \"Dettagli\",\n    detailsTitle: \"Dettagli fonte\",\n    content: \"Contenuto\",\n    metadata: \"Metadati\",\n    type: {\n      link: \"Link\",\n      file: \"File\",\n      text: \"Testo\",\n    },\n    id: \"ID Fonte\",\n    topics: \"Argomenti\",\n    embedded: \"Indicizzato\",\n    notEmbedded: \"Non indicizzato\",\n    embedContent: \"Indicizza contenuto\",\n    embedding: \"Indicizzazione...\",\n    alreadyEmbedded: \"Già indicizzato\",\n    downloadFile: \"Scarica file\",\n    fileUnavailable: \"File non disponibile\",\n    preparing: \"Preparazione...\",\n    generateNewInsight: \"Genera nuovo approfondimento\",\n    selectTransformation: \"Seleziona una trasformazione...\",\n    noInsightsYet: \"Ancora nessun approfondimento\",\n    createFirstInsight: \"Crea il tuo primo approfondimento usando una trasformazione sopra\",\n    viewInsight: \"Visualizza approfondimento\",\n    deleteInsight: \"Elimina approfondimento\",\n    deleteInsightConfirm: \"Sei sicuro di voler eliminare questo approfondimento? Questa azione non può essere annullata.\",\n    insightGenerationStarted: \"Generazione dell'approfondimento avviata. Apparirà a breve.\",\n    editNote: \"Modifica nota\",\n    createNote: \"Crea nota\",\n    addTitle: \"Aggiungi un titolo...\",\n    untitledNote: \"Nota Senza Titolo\",\n    writeNotePlaceholder: \"Scrivi il contenuto della tua nota qui...\",\n    saveNote: \"Salva nota\",\n    createNoteBtn: \"Crea nota\",\n    createFirstNote: \"Crea la tua prima nota per catturare intuizioni e osservazioni.\",\n    urlLabel: \"URL *\",\n    fileLabel: \"File *\",\n    textContentLabel: \"Contenuto testo *\",\n    enterUrlsPlaceholder: \"Inserisci gli URL, uno per riga\\nhttps://esempio.com/articolo1\\nhttps://esempio.com/articolo2\",\n    batchUrlHint: \"Incolla più URL (uno per riga) per importazione batch\",\n    invalidUrlsDetected: \"URL non validi rilevati:\",\n    lineLabel: \"Riga {line}\",\n    fixInvalidUrls: \"Correggi o rimuovi gli URL non validi per continuare\",\n    selectMultipleFilesHint: \"Seleziona più file per importazione batch. Supportati: Documenti (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Immagini (JPG, PNG), Archivi (ZIP)\",\n    selectedFiles: \"File selezionati:\",\n    textPlaceholder: \"Incolla o digita il contenuto qui...\",\n    htmlDetected: \"Rilevato contenuto HTML. Verrà convertito in Markdown dopo l'elaborazione.\",\n    titlePlaceholder: \"Dai alla tua fonte un titolo descrittivo\",\n    batchTitlesAuto: \"I titoli verranno generati automaticamente per ogni fonte.\",\n    batchCommonSettings: \"Gli stessi quaderni e trasformazioni verranno applicati a tutti gli elementi.\",\n    urlsCount: \"{count} URL\",\n    filesCount: \"{count} file\",\n    addSource: \"Aggiungi Fonte\",\n    notEmbeddedAlert: \"Contenuto non indicizzato\",\n    notEmbeddedDesc: \"Questo contenuto non è stato indicizzato per la ricerca vettoriale. L'indicizzazione abilita funzionalità di ricerca avanzate e una migliore scoperta dei contenuti.\",\n    openOnYoutube: \"Apri su YouTube\",\n    urlCopied: \"URL copiato negli appunti\",\n    viewSource: \"Visualizza fonte\",\n    noInsightSelected: \"Nessun approfondimento selezionato\",\n    sourceInsight: \"Approfondimento Fonte\",\n    manageNotebooks: \"Gestisci quaderni\",\n    manageNotebooksDesc: \"Gestisci quali quaderni contengono questa fonte\",\n    noNotebooksAvailable: \"Nessun quaderno disponibile\",\n    loadFailed: \"Impossibile caricare i dettagli della fonte\",\n    removeFromNotebook: \"Rimuovi dal quaderno\",\n    retryProcessing: \"Riprova elaborazione\",\n    deleteSource: \"Elimina fonte\",\n    retry: \"Riprova\",\n    addExistingTitle: \"Aggiungi fonti esistenti\",\n    addExistingDesc: \"Seleziona fonti esistenti da tutti i tuoi quaderni per aggiungerle a quello corrente.\",\n    searchPlaceholder: \"Cerca fonti per nome o URL...\",\n    noNotebooksFound: \"Nessun quaderno trovato.\",\n    showingFirst100: \"Mostrate le prime 100 fonti. Usa la ricerca per trovarne di specifiche.\",\n    selectedCount: \"{count} fonti selezionate\",\n    added: \"Aggiunto il {date}\",\n    addUrl: \"Aggiungi URL\",\n    uploadFile: \"Carica file\",\n    enterText: \"Inserisci testo\",\n    processDescription: \"Il contenuto verrà elaborato e analizzato dall'IA.\",\n    processingFiles: \"Elaborazione dei tuoi file...\",\n    titleRequired: \"Un titolo è obbligatorio per il contenuto testuale\",\n    titleGenerated: \"Se lasciato vuoto, verrà generato un titolo dal contenuto\",\n    batchCount: \"{count} {type} verranno elaborati\",\n    enableEmbedding: \"Abilita indicizzazione per la ricerca\",\n    embeddingDesc: \"Permette di trovare questa fonte nelle ricerche vettoriali e query IA\",\n    embeddingAlways: \"Indicizzazione abilitata automaticamente\",\n    embeddingAlwaysDesc: \"Le tue impostazioni sono configurate per indicizzare sempre il contenuto per la ricerca vettoriale.\",\n    embeddingNever: \"Indicizzazione disabilitata\",\n    embeddingNeverDesc: \"Le tue impostazioni sono configurate per saltare l'indicizzazione. La ricerca vettoriale non sarà disponibile per questa fonte.\",\n    changeInSettings: \"Puoi modificare questo nelle Impostazioni\",\n    notFound: \"Fonte non trovata\",\n    noContent: \"Nessun contenuto disponibile\",\n    insightsDesc: \"Approfondimenti generati dall'analisi del modello\",\n    uploadedFile: \"File caricato\",\n    fileUnavailableDesc: \"Questo file non è attualmente disponibile per motivi di storage.\",\n    batchSuccess: \"{count} fonte/i create con successo\",\n    batchFailed: \"Impossibile creare tutte le {count} fonti\",\n    batchPartial: \"{success} riuscite, {failed} fallite\",\n    submittingSource: \"Invio fonte per l'elaborazione...\",\n    processingBatchSources: \"Elaborazione di {count} fonti. Potrebbe richiedere qualche istante.\",\n    processingSource: \"La tua fonte è in elaborazione. Potrebbe richiedere qualche istante.\",\n    maxFilesAllowed: \"Massimo {count} file consentiti per batch\",\n  },\n  chat: {\n    sessions: \"Sessioni\",\n    sessionTitlePlaceholder: \"Digita un titolo qui...\",\n    noSessions: \"Ancora nessuna sessione chat\",\n    deleteSession: \"Elimina sessione\",\n    deleteSessionDesc: \"Sei sicuro di voler eliminare questa sessione chat? Questa azione non può essere annullata.\",\n    sendPlaceholder: \"Chiedi qualsiasi cosa sulle tue fonti...\",\n    sessionsTitle: \"Sessioni chat\",\n    chatWith: \"Chatta con {name}\",\n    startConversation: \"Inizia una conversazione su questo {type}\",\n    askQuestions: \"Fai domande per capire meglio il contenuto\",\n    pressToSend: \"Premi {key} per inviare\",\n    model: \"Modello\",\n    createToStart: \"Crea una sessione per iniziare.\",\n    chatWithNotebook: \"Chatta con il quaderno\",\n    unableToLoadChat: \"Impossibile caricare la chat\",\n    noDescription: \"Nessuna descrizione\",\n    startByCreating: \"Inizia creando il tuo primo quaderno per organizzare la tua ricerca.\",\n    messagesCount: \"{count} messaggi\",\n    sessionCreated: \"Sessione chat creata\",\n    sessionUpdated: \"Sessione aggiornata\",\n    sessionDeleted: \"Sessione eliminata\",\n  },\n  searchPage: {\n    askAndSearch: \"Chiedi e cerca\",\n    chooseAMode: \"Scegli una modalità\",\n    askBeta: \"Chiedi (beta)\",\n    search: \"Cerca\",\n    askYourKb: \"Chiedi alla tua base di conoscenza (beta)\",\n    askYourKbDesc: \"L'LLM risponderà alla tua domanda basandosi sui documenti nella tua base di conoscenza.\",\n    question: \"Domanda\",\n    enterQuestionPlaceholder: \"Inserisci la tua domanda...\",\n    pressToSubmit: \"Premi Cmd/Ctrl+Invio per inviare\",\n    noEmbeddingModel: \"Non puoi usare questa funzionalità perché non hai un modello di embedding selezionato. Configurane uno nella pagina modelli.\",\n    usingCustomModels: \"Uso modelli personalizzati\",\n    usingDefaultModels: \"Uso modelli predefiniti\",\n    advanced: \"Avanzate\",\n    strategy: \"Strategia\",\n    answer: \"Risposta\",\n    final: \"Finale\",\n    ask: \"Chiedi\",\n    processing: \"Elaborazione...\",\n    saveToNotebooks: \"Salva nei quaderni\",\n    searchDesc: \"Cerca nella tua base di conoscenza parole chiave o concetti specifici\",\n    enterSearchPlaceholder: \"Inserisci la query di ricerca...\",\n    pressToSearch: \"Premi invio per cercare\",\n    searchType: \"Tipo di ricerca\",\n    vectorSearchWarning: \"La ricerca vettoriale richiede un modello di embedding. Solo la ricerca testuale è disponibile.\",\n    textSearch: \"Ricerca testuale\",\n    vectorSearch: \"Ricerca vettoriale\",\n    searchIn: \"Cerca In\",\n    searchSources: \"Cerca nelle fonti\",\n    searchNotes: \"Cerca nelle note\",\n    resultsFound: \"{count} risultati trovati\",\n    matches: \"Corrispondenze ({count})\",\n    noResultsFor: \"Nessun risultato trovato per \\\"{query}\\\"\",\n    notSet: \"Non impostato\",\n    saveToNotebook: \"Salva nel quaderno\",\n    saveSuccess: \"Salvato con successo nel quaderno\",\n    saveError: \"Impossibile salvare nel quaderno\",\n    selectNotebook: \"Seleziona quaderno\",\n    searchAndAsk: \"Cerca e chiedi\",\n    searchResultsFor: \"Risultati di ricerca per \\\"{query}\\\"\",\n    askAbout: \"Chiedi riguardo \\\"{query}\\\"\",\n    orSearchKb: \"Oppure cerca nella tua base di conoscenza\",\n    saving: \"Salvataggio...\",\n    advancedModelTitle: \"Selezione modello avanzata\",\n    advancedModelDesc: \"Scegli modelli specifici per ogni fase del processo Chiedi\",\n    strategyModel: \"Modello strategia\",\n    answerModel: \"Modello risposta\",\n    finalAnswerModel: \"Modello risposta finale\",\n    selectStrategyPlaceholder: \"Seleziona modello strategia\",\n    selectAnswerPlaceholder: \"Seleziona modello risposta\",\n    selectFinalPlaceholder: \"Seleziona modello risposta finale\",\n    saveChanges: \"Salva modifiche\",\n    processingQuestion: \"Elaborazione della tua domanda...\",\n  },\n  podcasts: {\n    generateEpisode: \"Genera episodio podcast\",\n    generateEpisodeDesc: \"Seleziona il contenuto da includere e configura i dettagli dell'episodio prima di generare un nuovo episodio podcast.\",\n    content: \"Contenuto\",\n    contentDesc: \"Scegli quaderni, fonti e note da includere in questo episodio.\",\n    itemsSelected: \"{count} elementi selezionati\",\n    tokens: \"{count} token\",\n    chars: \"{count} caratteri\",\n    loadingNotebooks: \"Caricamento quaderni...\",\n    noNotebooksFoundInPodcasts: \"Nessun quaderno trovato. Crea un quaderno e aggiungi contenuto prima di generare un podcast.\",\n    noContentSelected: \"Nessun contenuto selezionato\",\n    summary: \"Riepilogo\",\n    fullContent: \"Contenuto completo\",\n    untitledSource: \"Fonte senza titolo\",\n    untitledNote: \"Nota senza titolo\",\n    episodeSettings: \"Impostazioni episodio\",\n    episodeProfile: \"Profilo episodio\",\n    episodeProfilePlaceholder: \"Seleziona un profilo episodio\",\n    episodeName: \"Nome episodio\",\n    episodeNamePlaceholder: \"es., IA e il futuro del Lavoro\",\n    additionalInstructions: \"Istruzioni aggiuntive\",\n    instructionsPlaceholder: \"Eventuali consigli supplementari da aggiungere al briefing dell'episodio...\",\n    generating: \"Generazione...\",\n    generate: \"Genera\",\n    hostPlaceholder: \"Conduttore {number}\",\n    profileRequired: \"Profilo episodio richiesto\",\n    profileRequiredDesc: \"Seleziona un profilo episodio prima di generare un podcast.\",\n    nameRequired: \"Nome episodio richiesto\",\n    nameRequiredDesc: \"Fornisci un nome per l'episodio.\",\n    addContext: \"Aggiungi contesto\",\n    addContextDesc: \"Seleziona almeno una fonte o nota da includere nell'episodio.\",\n    generationFailed: \"Generazione podcast fallita\",\n    speakerProfile: \"Profilo Speaker\",\n    usesSpeakerProfile: \"Usa profilo speaker\",\n    sources: \"Fonti\",\n    notes: \"Note\",\n    noSources: \"Nessuna fonte disponibile in questo quaderno.\",\n    noNotes: \"Nessuna nota disponibile in questo quaderno.\",\n    selectMode: \"Seleziona modalità\",\n    buildContextFailed: \"Impossibile costruire il contesto. Rivedi le tue selezioni.\",\n    podcastTaskStarted: \"Attività podcast avviata\",\n    loadingProfiles: \"Caricamento profili episodio...\",\n    noProfilesFound: \"Nessun profilo episodio trovato. Crea un profilo episodio prima di generare un podcast.\",\n    listTitle: \"Podcast\",\n    listDesc: \"Tieni traccia degli episodi generati e gestisci i profili riutilizzabili.\",\n    chooseAView: \"Scegli una vista\",\n    episodesTab: \"Episodi\",\n    templatesTab: \"Profili\",\n    overviewTitle: \"Panoramica episodi\",\n    overviewDesc: \"Monitora i lavori di generazione podcast e rivedi gli artefatti finali.\",\n    generateBtn: \"Genera Podcast\",\n    total: \"Totale\",\n    processingLabel: \"In elaborazione\",\n    completedLabel: \"Completati\",\n    failedLabel: \"Falliti\",\n    pendingLabel: \"In attesa\",\n    loadErrorTitle: \"Impossibile caricare gli episodi\",\n    loadErrorDesc: \"Non siamo riusciti a recuperare gli ultimi episodi podcast. Riprova tra poco.\",\n    loadingEpisodes: \"Caricamento episodi…\",\n    noEpisodesYet: \"Ancora nessun episodio podcast. Genera il tuo primo dalle interfacce chat di quaderni o fonti.\",\n    statusRunningTitle: \"In elaborazione\",\n    statusRunningDesc: \"Episodi che stanno attivamente generando risorse.\",\n    statusPendingTitle: \"In coda / In attesa\",\n    statusPendingDesc: \"Episodi inviati in attesa di iniziare l'elaborazione.\",\n    statusCompletedTitle: \"Episodi completati\",\n    statusCompletedDesc: \"Pronti per revisione, download o pubblicazione.\",\n    statusFailedTitle: \"Episodi falliti\",\n    statusFailedDesc: \"Episodi che hanno riscontrato problemi durante la generazione.\",\n    templatesWorkspaceTitle: \"Area di lavoro profili\",\n    templatesWorkspaceDesc: \"Costruisci configurazioni riutilizzabili per episodi e speaker per una produzione podcast rapida.\",\n    howTemplatesPowerTitle: \"Come i profili potenziano la generazione podcast\",\n    howTemplatesPowerDesc: \"I profili dividono il flusso di lavoro podcast in due blocchi riutilizzabili. Combinali quando generi un nuovo episodio.\",\n    episodeProfilesSetFormat: \"I profili episodio impostano il formato\",\n    episodeProfilesList1: \"Delinea il numero di segmenti e come scorre la storia\",\n    episodeProfilesList2: \"Scegli i modelli linguistici usati per briefing, outline e scrittura script\",\n    episodeProfilesList3: \"Memorizza briefing predefiniti così ogni episodio inizia con un tono coerente\",\n    speakerProfilesBringVoices: \"I profili speaker danno vita alle voci\",\n    speakerProfilesList1: \"Scegli il provider e modello text-to-speech\",\n    speakerProfilesList2: \"Cattura personalità, background e note di pronuncia per speaker\",\n    speakerProfilesList3: \"Riutilizza le stesse voci di conduttori o ospiti in diversi formati episodio\",\n    recommendedWorkflow: \"Flusso di lavoro consigliato\",\n    workflowStep1: \"Crea profili speaker per ogni voce di cui hai bisogno\",\n    workflowStep2: \"Costruisci profili episodio che riferiscono quegli speaker per nome\",\n    workflowStep3: \"Genera podcast selezionando il profilo episodio adatto alla storia\",\n    workflowHint: \"I profili episodio riferiscono i profili speaker per nome, quindi iniziare dagli speaker evita assegnazioni vocali mancanti.\",\n    failedToLoadTemplates: \"Impossibile caricare i dati dei profili\",\n    failedToLoadTemplatesDesc: \"Assicurati che l'API sia in esecuzione e riprova. Alcune sezioni potrebbero essere incomplete.\",\n    loadingTemplates: \"Caricamento profili…\",\n    speakerProfilesTitle: \"Profili speaker\",\n    speakerProfilesDesc: \"Configura voci e personalità per gli episodi generati.\",\n    createSpeaker: \"Crea speaker\",\n    noSpeakerProfiles: \"Ancora nessun profilo speaker. Creane uno per rendere disponibili i profili episodio.\",\n    noDescription: \"Nessuna descrizione fornita.\",\n    usedByCount_one: \"Usato da 1 episodio\",\n    usedByCount_other: \"Usato da {count} episodi\",\n    usedByCount: \"Usato da {count} episodi\",\n    unused: \"Non utilizzato\",\n    voiceId: \"ID Voce\",\n    backstory: \"Background\",\n    personality: \"Personalità\",\n    edit: \"Modifica\",\n    duplicate: \"Duplica\",\n    deleteSpeakerProfileTitle: \"Eliminare il profilo speaker?\",\n    deleteSpeakerProfileDesc: \"L'eliminazione di \\\"{name}\\\" non può essere annullata.\",\n    deleteSpeakerDisabledHint: \"Rimuovi questo speaker dai profili episodio prima di eliminarlo.\",\n    deleting: \"Eliminazione…\",\n    episodeProfilesTitle: \"Profili episodio\",\n    episodeProfilesDesc: \"Definisci impostazioni di generazione riutilizzabili per i tuoi programmi.\",\n    createProfile: \"Crea profilo\",\n    createSpeakerFirst: \"Crea un profilo speaker prima di aggiungere un profilo episodio.\",\n    noEpisodeProfiles: \"Ancora nessun profilo episodio. Creane uno per avviare la generazione podcast.\",\n    speakerCreated: \"Speaker creato\",\n    speakerCreatedDesc: \"Lo speaker \\\"{name}\\\" è stato aggiunto con successo.\",\n    failedToCreateSpeaker: \"Impossibile creare il profilo speaker\",\n    speakerUpdated: \"Speaker aggiornato\",\n    speakerUpdatedDesc: \"Lo speaker \\\"{name}\\\" è stato aggiornato con successo.\",\n    failedToUpdateSpeaker: \"Impossibile aggiornare il profilo speaker\",\n    speakerDeleted: \"Speaker eliminato\",\n    speakerDeletedDesc: \"Lo speaker \\\"{name}\\\" è stato rimosso con successo.\",\n    failedToDeleteSpeaker: \"Impossibile eliminare il profilo speaker\",\n    speakerDuplicated: \"Speaker duplicato\",\n    speakerDuplicatedDesc: \"Lo speaker \\\"{name}\\\" è stato duplicato con successo.\",\n    failedToDuplicateSpeaker: \"Impossibile duplicare il profilo speaker\",\n    generationStarted: \"Generazione Avviata\",\n    generationStartedDesc: \"La generazione del podcast è stata accodata.\",\n    failedToStartGeneration: \"Impossibile avviare la generazione\",\n    tryAgainMoment: \"Riprova tra un momento.\",\n    deleteProfileTitle: \"Eliminare il profilo?\",\n    deleteProfileDesc: \"Questo rimuoverà \\\"{name}\\\". Gli episodi esistenti mantengono i loro dati, ma i nuovi non useranno più questa configurazione.\",\n    profileCreated: \"Profilo creato\",\n    profileCreatedDesc: \"Il profilo episodio \\\"{name}\\\" è stato creato con successo.\",\n    failedToCreateProfile: \"Impossibile creare il profilo\",\n    profileUpdated: \"Profilo aggiornato\",\n    profileUpdatedDesc: \"Il profilo episodio \\\"{name}\\\" è stato aggiornato con successo.\",\n    failedToUpdateProfile: \"Impossibile aggiornare il profilo\",\n    profileDeleted: \"Profilo eliminato\",\n    profileDeletedDesc: \"Il profilo episodio \\\"{name}\\\" è stato rimosso con successo.\",\n    failedToDeleteProfile: \"Impossibile eliminare il profilo\",\n    failedToDeleteProfileDesc: \"Impossibile rimuovere il profilo episodio.\",\n    profileDuplicated: \"Profilo duplicato\",\n    profileDuplicatedDesc: \"Il profilo episodio \\\"{name}\\\" è stato duplicato con successo.\",\n    failedToDuplicateProfile: \"Impossibile duplicare il profilo\",\n    episodeDeleted: \"Episodio eliminato\",\n    episodeDeletedDesc: \"L'episodio è stato eliminato con successo.\",\n    failedToDeleteEpisode: \"Impossibile eliminare l'episodio\",\n    failedToDeleteSpeakerDesc: \"Impossibile rimuovere il profilo speaker.\",\n    outlineModel: \"Modello outline\",\n    transcriptModel: \"Modello trascrizione\",\n    segments: \"Segmenti\",\n    defaultBriefingTitle: \"Briefing predefinito\",\n    created: \"Creato il {time}\",\n    details: \"Dettagli\",\n    summaryTab: \"Riepilogo\",\n    outlineTab: \"Outline\",\n    transcriptTab: \"Trascrizione\",\n    briefing: \"Briefing\",\n    noOutline: \"Nessun outline disponibile.\",\n    noTranscript: \"Nessuna trascrizione disponibile.\",\n    deleteEpisodeTitle: \"Eliminare l'episodio?\",\n    deleteEpisodeDesc: \"Questo rimuoverà \\\"{name}\\\" e il suo file audio permanentemente.\",\n    audioUnavailable: \"Audio non disponibile\",\n    segment: \"Segmento\",\n    speaker: \"Speaker\",\n    profile: \"Profilo\",\n    link: \"Link\",\n    file: \"File\",\n    embedded: \"Indicizzato\",\n    notEmbedded: \"Non indicizzato\",\n    noSpeakerProfilesAvailable: \"Nessun profilo speaker disponibile\",\n    editEpisodeProfile: \"Modifica profilo episodio\",\n    createEpisodeProfile: \"Crea profilo episodio\",\n    episodeProfileFormDesc: \"Definisci come devono essere generati gli episodi e quale configurazione speaker usare di default.\",\n    noSpeakerProfilesDesc: \"Crea un profilo speaker prima di configurare un profilo episodio.\",\n    profileName: \"Nome profilo\",\n    profileNamePlaceholder: \"es., Discussione tech\",\n    descriptionPlaceholder: \"Breve riepilogo di quando usare questo profilo\",\n    speakerConfig: \"Configurazione speaker\",\n    selectSpeakerProfile: \"Seleziona un profilo speaker\",\n    outlineGeneration: \"Generazione outline\",\n    transcriptGeneration: \"Generazione trascrizione\",\n    defaultBriefingPlaceholder: \"Delinea struttura, tono e obiettivi per questo formato episodio\",\n    editSpeakerProfile: \"Modifica profilo speaker\",\n    createSpeakerProfile: \"Crea profilo speaker\",\n    speakerProfileFormDesc: \"Configura le impostazioni text-to-speech e definisci fino a quattro speaker.\",\n    speakers: \"Speaker\",\n    speakersDesc: \"Configura da una a quattro voci per questo profilo.\",\n    addSpeaker: \"Aggiungi speaker\",\n    speakerNumber: \"Speaker {number}\",\n    backstoryPlaceholder: \"Breve biografia o contesto per lo speaker\",\n    personalityPlaceholder: \"Descrivi stile e tono\",\n    outlineModelRequired: \"Il modello outline è obbligatorio\",\n    transcriptModelRequired: \"Il modello trascrizione è obbligatorio\",\n    defaultBriefingRequired: \"Il briefing predefinito è obbligatorio\",\n    segmentsInteger: \"Deve essere un numero intero\",\n    segmentsMin: \"Almeno 3 segmenti\",\n    segmentsMax: \"Massimo 20 segmenti\",\n    voiceIdRequired: \"L'ID voce è obbligatorio\",\n    backstoryRequired: \"Il background è obbligatorio\",\n    personalityRequired: \"La personalità è obbligatoria\",\n    speakerCountMin: \"È richiesto almeno uno speaker\",\n    speakerCountMax: \"Puoi configurare fino a 4 speaker\",\n    delete: \"Elimina\",\n    failedToDelete: \"Impossibile eliminare il podcast\",\n    retry: \"Riprova\",\n    retrying: \"Nuovo tentativo…\",\n    retryStarted: \"Nuovo tentativo avviato\",\n    retryStartedDesc: \"Un nuovo lavoro di generazione podcast è stato inviato.\",\n    failedToRetry: \"Impossibile riprovare\",\n    errorDetails: \"Dettagli errore\",\n    language: \"Lingua\",\n    languagePlaceholder: \"Seleziona una lingua (opzionale)\",\n    podcastLanguage: \"Lingua del podcast\",\n    selectOutlineModel: \"Seleziona modello outline\",\n    selectTranscriptModel: \"Seleziona modello trascrizione\",\n    voiceModel: \"Modello vocale\",\n    voiceModelRequired: \"Il modello vocale è obbligatorio\",\n    selectVoiceModel: \"Seleziona modello vocale\",\n    perSpeakerTtsOverride: \"Override TTS per speaker (opzionale)\",\n    useProfileDefault: \"Usa predefinito del profilo\",\n    setupRequired: \"Configurazione necessaria\",\n    setupRequiredDesc: \"Alcuni profili non hanno ancora modelli configurati. Modificali per selezionare i modelli prima di generare podcast.\",\n    notConfigured: \"Non configurato\",\n  },\n  settings: {\n    contentProcessing: \"Elaborazione contenuti\",\n    contentProcessingDesc: \"Configura come vengono elaborati documenti e URL\",\n    docEngine: \"Motore elaborazione documenti\",\n    docEnginePlaceholder: \"Seleziona motore elaborazione documenti\",\n    urlEngine: \"Motore elaborazione URL\",\n    urlEnginePlaceholder: \"Seleziona motore elaborazione URL\",\n    autoRecommended: \"Auto (consigliato)\",\n    simple: \"Semplice\",\n    docling: \"Docling\",\n    helpMeChoose: \"Aiutami a scegliere\",\n    docHelp: \"· Docling è un po' più lento ma più accurato, specialmente se i documenti contengono tabelle e immagini. · Semplice estrarrà qualsiasi contenuto dal documento senza formattarlo. · Auto (consigliato) proverà a elaborare tramite docling e userà semplice come fallback.\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl è un servizio a pagamento (con piano gratuito), molto potente. · Jina è una buona alternativa con piano gratuito. · Semplice usa estrazione HTTP base e perderà contenuto su siti basati su javascript. · Auto (consigliato) proverà firecrawl poi Jina, infine semplice come fallback.\",\n    embeddingAndSearch: \"Indicizzazione e ricerca\",\n    embeddingAndSearchDesc: \"Configura opzioni di ricerca e indicizzazione\",\n    defaultEmbeddingOption: \"Opzione indicizzazione predefinita\",\n    embeddingOptionPlaceholder: \"Seleziona opzione indicizzazione\",\n    ask: \"Chiedi\",\n    always: \"Sempre\",\n    never: \"Mai\",\n    embeddingHelp: \"Indicizzare il contenuto renderà più facile trovarlo per te e per i tuoi agenti IA. Se usi un modello di embedding locale (Ollama, per esempio), non dovresti preoccuparti del costo e indicizzare tutto.\",\n    fileManagement: \"Gestione file\",\n    fileManagementDesc: \"Configura opzioni di gestione e archiviazione file\",\n    autoDeleteFiles: \"Elimina file automaticamente\",\n    autoDeletePlaceholder: \"Seleziona opzione eliminazione automatica\",\n    filesHelp: \"Una volta caricati ed elaborati, i tuoi file non sono più necessari. La maggior parte degli utenti dovrebbe permettere a Open Notebook di eliminare automaticamente i file caricati dalla cartella upload.\",\n    loadFailed: \"Impossibile caricare le impostazioni\",\n  },\n  advanced: {\n    title: \"Strumenti avanzati\",\n    desc: \"Strumenti e utilità avanzate per utenti esperti\",\n    systemInfo: \"Informazioni sistema\",\n    rebuildEmbeddings: \"Ricostruisci indicizzazioni\",\n    rebuildEmbeddingsDesc: \"Ricostruisci l'indice di ricerca vettoriale per tutte le fonti\",\n    currentVersion: \"Versione corrente\",\n    latestVersion: \"Ultima versione\",\n    status: \"Stato\",\n    updateAvailable: \"Versione {version} disponibile\",\n    updateAvailableDesc: \"È disponibile una nuova versione di Open Notebook.\",\n    upToDate: \"Aggiornato\",\n    unknown: \"Sconosciuto\",\n    viewOnGithub: \"Visualizza su GitHub\",\n    updateCheckFailed: \"Impossibile verificare aggiornamenti. GitHub potrebbe non essere raggiungibile.\",\n    rebuild: {\n      mode: \"Modalità ricostruzione\",\n      existing: \"Esistenti\",\n      all: \"Tutti\",\n      existingDesc: \"Re-indicizza solo elementi che hanno già indicizzazioni (più veloce, per cambio modello)\",\n      allDesc: \"Re-indicizza elementi esistenti + crea indicizzazioni per elementi senza (più lento, completo)\",\n      include: \"Includi nella ricostruzione\",\n      selectOneError: \"Seleziona almeno un tipo di elemento da ricostruire\",\n      starting: \"Avvio ricostruzione...\",\n      startBtn: \"🚀 Avvia ricostruzione\",\n      queued: \"In coda\",\n      running: \"In esecuzione...\",\n      completed: \"Completato!\",\n      failed: \"Fallito\",\n      leavePageHint: \"Puoi lasciare questa pagina poiché verrà eseguito in background\",\n      startNew: \"Avvia nuova ricostruzione\",\n      itemsProcessed: \"{processed}/{total} elementi ({percent}%)\",\n      failedItems: \"{count} elementi non elaborati\",\n      time: \"Tempo\",\n      whenToRebuild: \"Quando dovrei ricostruire le indicizzazioni?\",\n      whenToRebuildAns: \"Dovresti ricostruire quando cambi modelli, aggiorni versioni, correggi corruzione o dopo importazioni massive.\",\n      howLong: \"Quanto tempo richiede la ricostruzione?\",\n      howLongAns: \"Il tempo di elaborazione dipende dal numero di elementi, velocità del modello e limiti API. I modelli locali sono solitamente molto veloci.\",\n      isSafe: \"È sicuro ricostruire mentre uso l'app?\",\n      isSafeAns: \"Sì, la ricostruzione è sicura! Non elimina contenuti, sostituisce solo le indicizzazioni e gestisce gli errori con grazia.\",\n    },\n  },\n  transformations: {\n    title: \"Trasformazioni\",\n    desc: \"Le trasformazioni sono prompt che verranno usati dall'LLM per elaborare una fonte ed estrarre approfondimenti, riepiloghi, ecc.\",\n    workspace: \"Scegli un'area di lavoro\",\n    playground: \"Playground\",\n    defaultPrompt: \"Prompt trasformazione predefinito\",\n    defaultPromptDesc: \"Questo verrà aggiunto a tutti i tuoi prompt di trasformazione\",\n    defaultPromptPlaceholder: \"Inserisci le tue istruzioni di trasformazione predefinite...\",\n    listTitle: \"Trasformazioni personalizzate\",\n    createNew: \"Crea Nuova\",\n    inputLabel: \"Testo di input\",\n    inputPlaceholder: \"Inserisci del testo da trasformare...\",\n    outputLabel: \"Output\",\n    runTest: \"Esegui trasformazione\",\n    running: \"Esecuzione...\",\n    selectToStart: \"Seleziona una trasformazione per iniziare\",\n    name: \"Nome\",\n    namePlaceholder: \"Identificativo unico, es. argomenti_chiave\",\n    titlePlaceholder: \"Titolo visualizzato, default al nome\",\n    promptPlaceholder: \"Scrivi il prompt che alimenterà questa trasformazione...\",\n    descriptionPlaceholder: \"Descrivi cosa fa questa trasformazione.\",\n    suggestDefault: \"Suggerisci di default per nuove fonti\",\n    promptHint: \"I prompt dovrebbero essere scritti considerando il contenuto della fonte. Puoi chiedere al modello di riassumere, estrarre approfondimenti o produrre output strutturati come tabelle.\",\n    createSuccess: \"Trasformazione creata con successo\",\n    updateSuccess: \"Trasformazione aggiornata con successo\",\n    deleteSuccess: \"Trasformazione eliminata con successo\",\n    noTransformations: \"Ancora nessuna trasformazione\",\n    createOne: \"Crea una trasformazione per iniziare\",\n    selectModel: \"Seleziona un modello\",\n    deleteConfirm: \"Sei sicuro di voler eliminare questa trasformazione?\",\n    model: \"Modello\",\n    systemPrompt: \"Prompt di sistema\",\n    overrideModelDesc: \"Sovrascrivi il modello predefinito per questa sessione chat. Lascia vuoto per usare il default di sistema.\",\n    sessionUseReplacement: \"Questa sessione userà {name} invece del modello predefinito.\",\n    systemDefault: \"Predefinito di sistema\",\n  },\n  models: {\n    embedding: \"Modelli di embedding\",\n    tts: \"Text to Speech (TTS)\",\n    stt: \"Speech to Text (STT)\",\n    apiKey: \"Chiave API\",\n    deleteSuccess: \"Modello eliminato con successo\",\n    saveSuccess: \"Modello salvato con successo\",\n    noModels: \"Nessun modello\",\n    discoverModels: \"Scopri Modelli\",\n    noModelsFound: \"Nessun modello trovato per questo provider\",\n    modelType: \"Tipo di Modello\",\n    modelTypeHint: \"Seleziona il tipo per i modelli che vuoi aggiungere. Se hai bisogno di tipi diversi, aggiungili in lotti separati.\",\n    deleteModel: \"Elimina modello\",\n    defaultAssignments: \"Assegnazioni modelli predefiniti\",\n    defaultAssignmentsDesc: \"Configura quali modelli usare per diversi scopi in Open Notebook\",\n    missingRequiredModels: \"Modelli richiesti mancanti: {models}. Open Notebook potrebbe non funzionare correttamente senza questi.\",\n    selectModelPlaceholder: \"Seleziona un modello\",\n    requiredModelPlaceholder: \"⚠️ Richiesto - Seleziona un modello\",\n    chatModelLabel: \"Modello chat\",\n    chatModelDesc: \"Usato per le conversazioni chat\",\n    transformationModelLabel: \"Modello trasformazione\",\n    transformationModelDesc: \"Usato per riepiloghi, approfondimenti e trasformazioni\",\n    toolsModelLabel: \"Modello strumenti\",\n    toolsModelDesc: \"Usato per chiamate funzione - OpenAI o Anthropic consigliati\",\n    largeContextModelLabel: \"Modello contesto ampio\",\n    largeContextModelDesc: \"Usato per elaborare documenti grandi - Gemini consigliato\",\n    embeddingModelLabel: \"Modello di embedding\",\n    embeddingModelDesc: \"Usato per ricerca semantica e embedding vettoriali\",\n    ttsModelLabel: \"Modello Text-to-Speech\",\n    ttsModelDesc: \"Usato per la generazione podcast\",\n    sttModelLabel: \"Modello Speech-to-Text\",\n    sttModelDesc: \"Usato per la trascrizione audio\",\n    embeddingChangeTitle: \"Cambio modello di embedding\",\n    embeddingChangeConfirm: \"Stai per cambiare il modello di embedding da {from} a {to}.\",\n    rebuildRequired: \"Importante: ricostruzione richiesta\",\n    rebuildReason: \"Cambiare il modello di embedding richiede la ricostruzione di tutti gli embedding esistenti per mantenere la coerenza. Senza ricostruzione, le tue ricerche potrebbero restituire risultati errati o incompleti.\",\n    whatHappensNext: \"Cosa succede dopo:\",\n    step1: \"Il tuo modello di embedding predefinito verrà aggiornato\",\n    step2: \"Gli embedding esistenti rimarranno invariati fino alla ricostruzione\",\n    step3: \"I nuovi contenuti useranno il nuovo modello di embedding\",\n    step4: \"Dovresti ricostruire gli embedding il prima possibile\",\n    proceedToRebuildPrompt: \"Vuoi procedere alla pagina avanzate per avviare la ricostruzione ora?\",\n    changeModelOnly: \"Cambia solo modello\",\n    changeAndRebuild: \"Cambia e vai a ricostruzione\",\n    autoAssign: \"Assegnazione automatica predefiniti\",\n    autoAssigning: \"Assegnazione in corso...\",\n    autoAssignSuccess: \"{count} modelli predefiniti assegnati automaticamente\",\n    autoAssignNoModels: \"Nessun modello disponibile da assegnare. Sincronizza prima i modelli.\",\n    autoAssignAlreadySet: \"Tutti i modelli predefiniti sono già configurati\",\n    testModel: \"Testa Modello\",\n    testModelSuccess: \"Test del Modello Superato\",\n    testModelFailed: \"Test del Modello Fallito\",\n    searchOrAddModel: \"Cerca o digita un nome modello...\",\n    addCustomModel: \"Aggiungi \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"Configura la tua IA con le tue chiavi API\",\n    description: \"Salva le chiavi API in modo sicuro nel database per abilitare i provider IA in Open Notebook.\",\n    encryptionRequired: \"Chiave di crittografia non configurata\",\n    encryptionRequiredDescription: \"Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY su una stringa segreta qualsiasi per abilitare il salvataggio delle chiavi API nel database.\",\n    configured: \"Configurato\",\n    notConfigured: \"Non configurato\",\n    migrationAvailable: \"Variabili d'ambiente rilevate\",\n    migrationDescription: \"{count} chiave/i API configurata/e tramite variabili d'ambiente. Puoi migrarle nel database per una gestione più semplice.\",\n    migrateToDatabase: \"Migra nel database\",\n    migrating: \"Migrazione in corso...\",\n    migrationSuccess: \"{count} chiave/i API migrata/e con successo\",\n    migrationErrors: \"{count} chiave/i non migrata/e\",\n    migrationNothingToMigrate: \"Tutte le chiavi sono già nel database\",\n    learnMore: \"Scopri come configurare le chiavi API →\",\n    testConnection: \"Testa connessione\",\n    testSuccess: \"Connessione riuscita\",\n    testFailed: \"Test di connessione fallito\",\n    syncModels: \"Sincronizza modelli\",\n    syncSuccess: \"Trovati {discovered} modelli, aggiunti {new} nuovi\",\n    syncNoNew: \"Trovati {count} modelli, tutti già registrati\",\n    syncFailed: \"Sincronizzazione modelli fallita\",\n    getApiKey: \"Ottieni chiave API\",\n    vertexProject: \"ID progetto GCP\",\n    vertexLocation: \"Regione\",\n    vertexCredentials: \"Percorso JSON account di servizio\",\n    addConfig: \"Aggiungi configurazione\",\n    editConfig: \"Modifica configurazione\",\n    deleteConfig: \"Elimina configurazione\",\n    configName: \"Nome configurazione\",\n    configNameHint: \"Un nome descrittivo per questa configurazione (es. 'Produzione', 'Sviluppo')\",\n    baseUrl: \"URL base\",\n    baseUrlOverrideHint: \"Modifica solo se devi sovrascrivere l'endpoint API predefinito del provider.\",\n    deleteConfigConfirm: \"Sei sicuro di voler eliminare '{name}'? Questa azione non può essere annullata.\",\n    configSaveSuccess: \"Configurazione salvata con successo\",\n    configUpdateSuccess: \"Configurazione aggiornata con successo\",\n    configDeleteSuccess: \"Configurazione eliminata con successo\",\n    apiKeyEditHint: \"Lascia vuoto per mantenere la chiave API esistente\",\n  },\n  setupBanner: {\n    encryptionRequired: \"Chiave di crittografia non configurata\",\n    encryptionRequiredDescription: \"Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY per abilitare l'archiviazione sicura delle credenziali.\",\n    migrationAvailable: \"Migrazione chiavi API disponibile\",\n    migrationDescription: \"{count} provider hanno chiavi API impostate tramite variabili d'ambiente. Migrale nel database per una gestione più semplice.\",\n    goToSettings: \"Vai alle Impostazioni\",\n    viewDocs: \"Vedi documentazione\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/ja-JP/index.ts",
    "content": "export const jaJP = {\n  common: {\n    search: \"検索...\",\n    create: \"新規\",\n    new: \"新規\",\n    cancel: \"キャンセル\",\n    delete: \"削除\",\n    edit: \"編集\",\n    theme: \"テーマ\",\n    signOut: \"サインアウト\",\n    noMatches: \"一致する結果がありません\",\n    tryDifferentSearch: \"別の検索ワードをお試しください。\",\n    light: \"ライト\",\n    dark: \"ダーク\",\n    system: \"システム\",\n    loading: \"読み込み中...\",\n    note: \"ノート\",\n    insight: \"インサイト\",\n    newSource: \"新規ソース\",\n    newNotebook: \"新規ノートブック\",\n    newPodcast: \"新規ポッドキャスト\",\n    language: \"言語\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"ソース\",\n    notebook: \"ノートブック\",\n    podcast: \"ポッドキャスト\",\n    quickActions: \"クイックアクション\",\n    quickActionsDesc: \"ナビゲーション、検索、質問、テーマ\",\n    appName: \"Open Notebook\",\n    add: \"追加\",\n    remove: \"削除\",\n    confirm: \"確認\",\n    warning: \"警告\",\n    error: \"エラー\",\n    success: \"成功\",\n    model: \"モデル\",\n    back: \"戻る\",\n    next: \"次へ\",\n    done: \"完了\",\n    processing: \"処理中...\",\n    creating: \"作成中...\",\n    linked: \"リンク済み\",\n    adding: \"追加中...\",\n    addSelected: \"選択項目を追加\",\n    customModel: \"カスタムモデル\",\n    failed: \"失敗\",\n    current: \"現在\",\n    save: \"保存\",\n    writeNote: \"ノートを書く\",\n    batchMode: \"一括モード\",\n    optional: \"任意\",\n    type: \"種類\",\n    title: \"タイトル\",\n    created: \"{time}に作成\",\n    updated: \"{time}に更新\",\n    actions: \"アクション\",\n    noResults: \"結果なし\",\n    references: \"参照\",\n    refreshPage: \"ページを更新してください\",\n    refresh: \"更新\",\n    aiGenerated: \"AI生成\",\n    human: \"手動\",\n    unknown: \"不明\",\n    notes: \"ノート\",\n    chat: \"チャット\",\n    deleteForever: \"完全に削除\",\n    connectionError: \"接続エラー\",\n    unableToConnect: \"APIサーバーに接続できません\",\n    retryConnection: \"再接続\",\n    diagnosticInfo: \"診断情報\",\n    version: \"バージョン\",\n    built: \"ビルド日時\",\n    apiUrl: \"API URL\",\n    frontendUrl: \"フロントエンドURL\",\n    checkConsoleLogs: \"ブラウザコンソールで詳細ログを確認してください（🔧 [Config] メッセージを探してください）\",\n    yes: \"はい\",\n    no: \"いいえ\",\n    saving: \"保存中...\",\n    description: \"説明\",\n    saveToNote: \"ノートに保存\",\n    copyToClipboard: \"クリップボードにコピー\",\n    close: \"閉じる\",\n    insights: \"インサイト\",\n    progress: \"進捗\",\n    deleting: \"削除中...\",\n    created_label: \"作成日時\",\n    updated_label: \"更新日時\",\n    download: \"ダウンロード\",\n    saveChanges: \"変更を保存\",\n    name: \"名前\",\n    default: \"デフォルト\",\n    nameRequired: \"名前は必須です\",\n    modelConfiguration: \"モデル設定\",\n    resetToDefault: \"デフォルトに戻す\",\n    reasoning: \"推論\",\n    searchTerms: \"検索ワード\",\n    strategy: \"戦略\",\n    individualAnswers: \"個別回答（{count}件）\",\n    finalAnswer: \"最終回答\",\n    notebookLabel: \"ノートブック: {name}\",\n    itemNotFound: \"この{type}は見つかりませんでした\",\n    accessibility: {\n      transformationViews: \"トランスフォーメーション表示\",\n      searchKB: \"ナレッジベースに質問・検索\",\n      enterQuestion: \"ナレッジベースへの質問を入力\",\n      enterSearch: \"検索クエリを入力\",\n      searchKBBtn: \"ナレッジベースを検索\",\n      podcastViews: \"ポッドキャスト表示\",\n      ytVideo: \"YouTube動画\",\n      askResponse: \"質問への回答\",\n      searchNotebooks: \"ノートブックを検索\",\n    },\n    url: \"URL\",\n    errorDetails: \"エラー詳細\",\n    editTransformation: \"トランスフォーメーションを編集\",\n    retry: \"再試行\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"完了\",\n    saveSuccess: \"保存しました\",\n    contextModes: {\n      off: \"チャットに含めない\",\n      insights: \"インサイトのみ\",\n      full: \"全文\",\n      clickToCycle: \"クリックで切り替え\",\n    },\n    clickToEdit: \"クリックして編集\",\n  },\n  apiErrors: {\n    notebookNotFound: \"ノートブックが見つかりません\",\n    sourceNotFound: \"ソースが見つかりません\",\n    transformationNotFound: \"トランスフォーメーションが見つかりません\",\n    fileUploadFailed: \"ファイルのアップロードに失敗しました\",\n    urlRequired: \"リンクタイプにはURLが必要です\",\n    contentRequired: \"テキストタイプにはコンテンツが必要です\",\n    invalidSourceType: \"無効なソースタイプです\",\n    processingFailed: \"処理に失敗しました\",\n    failedToQueue: \"処理キューへの追加に失敗しました\",\n    invalidSortBy: \"ソートフィールドは'created'または'updated'である必要があります\",\n    invalidSortOrder: \"ソート順は'asc'または'desc'である必要があります\",\n    accessDenied: \"ファイルへのアクセスが拒否されました\",\n    fileNotFoundOnServer: \"サーバー上にファイルが見つかりません\",\n    searchFailed: \"検索に失敗しました\",\n    askFailed: \"質問の処理に失敗しました\",\n    pleaseEnterQuestion: \"質問を入力してください\",\n    pleaseConfigureModels: \"必要なモデルをすべて設定してください\",\n    failedToCreateSession: \"セッションの作成に失敗しました\",\n    failedToUpdateSession: \"セッションの更新に失敗しました\",\n    failedToDeleteSession: \"セッションの削除に失敗しました\",\n    failedToSendMessage: \"メッセージの送信に失敗しました\",\n    unauthorized: \"認証エラー。パスワードを確認してください\",\n    invalidPassword: \"パスワードが無効です\",\n    embeddingModelRequired: \"この機能にはEmbeddingモデルが必要です。モデルセクションで設定してください。\",\n    strategyModelNotFound: \"戦略モデルが見つかりません\",\n    answerModelNotFound: \"回答モデルが見つかりません\",\n    finalAnswerModelNotFound: \"最終回答モデルが見つかりません\",\n    noAnswerGenerated: \"回答を生成できませんでした\",\n    genericError: \"予期しないエラーが発生しました\",\n  },\n  connectionErrors: {\n    apiTitle: \"APIサーバーに接続できません\",\n    apiDesc: \"Open Notebook APIサーバーに到達できませんでした\",\n    dbTitle: \"データベース接続に失敗しました\",\n    dbDesc: \"APIサーバーは稼働していますが、データベースにアクセスできません\",\n    troubleshooting: \"考えられる原因：\",\n    apiUnreachable1: \"APIサーバーが起動していない\",\n    apiUnreachable2: \"APIサーバーが別のアドレスで動作している\",\n    apiUnreachable3: \"ネットワーク接続の問題\",\n    dbFailed1: \"SurrealDBが起動していない\",\n    dbFailed2: \"データベース接続設定が正しくない\",\n    dbFailed3: \"APIとデータベース間のネットワーク問題\",\n    quickFixes: \"解決方法：\",\n    setApiUrl: \"API_URL環境変数を設定:\",\n    checkSurreal: \"SurrealDBが起動しているか確認:\",\n    seeDocumentation: \"詳細なセットアップ手順はこちら:\",\n    docLink: \"Open Notebookドキュメント\",\n    showTechnical: \"技術的な詳細を表示\",\n    attemptedUrl: \"接続試行URL\",\n    message: \"メッセージ\",\n    technicalDetails: \"技術的な詳細\",\n    stackTrace: \"スタックトレース\",\n    retryLabel: \"再接続\",\n    retryHint: \"Rキーまたはボタンをクリックして再試行\",\n    dockerLabel: \"Docker環境の場合\",\n    localDevLabel: \"ローカル開発の場合\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"パスワードを入力してアプリケーションにアクセス\",\n    passwordPlaceholder: \"パスワード\",\n    signingIn: \"サインイン中...\",\n    signIn: \"サインイン\",\n    connectErrorHint: \"サーバーに接続できません。APIが起動しているか確認してください。\",\n  },\n  navigation: {\n    collect: \"収集\",\n    process: \"処理\",\n    create: \"作成\",\n    manage: \"管理\",\n    sources: \"ソース\",\n    notebooks: \"ノートブック\",\n    askAndSearch: \"質問と検索\",\n    podcasts: \"ポッドキャスト\",\n    models: \"モデル\",\n    transformations: \"トランスフォーメーション\",\n    transformation: \"トランスフォーメーション\",\n    settings: \"設定\",\n    advanced: \"詳細設定\",\n    nav: \"ナビゲーション\",\n    language: \"言語を切り替え\",\n    theme: \"テーマ\",\n    ask: \"質問\",\n  },\n  notebooks: {\n    title: \"ノートブック\",\n    newNotebook: \"新規ノートブック\",\n    searchPlaceholder: \"ノートブックを検索...\",\n    archived: \"アーカイブ済み\",\n    archive: \"アーカイブ\",\n    unarchive: \"アーカイブ解除\",\n    deleteNotebook: \"ノートブックを削除\",\n    deleteNotebookDesc: \"\\\"{name}\\\" を削除しますか？この操作は元に戻せません。\",\n    deleteNotebookLoading: \"削除プレビューを読み込み中...\",\n    deleteNotebookNotes: \"{count}件のノートが完全に削除されます。\",\n    deleteNotebookNoNotes: \"削除するノートはありません。\",\n    deleteNotebookExclusiveSources: \"{count}件のソースはこのノートブックにのみ存在します。\",\n    deleteNotebookSharedSources: \"{count}件のソースは他のノートブックと共有されており、リンクが解除されます。\",\n    deleteNotebookNoSources: \"このノートブックにソースはありません。\",\n    deleteExclusiveSourcesLabel: \"専用ソースを削除\",\n    keepExclusiveSourcesLabel: \"リンク解除して保持\",\n    activeNotebooks: \"アクティブなノートブック\",\n    archivedNotebooks: \"アーカイブ済みノートブック\",\n    notFound: \"ノートブックが見つかりません\",\n    notFoundDesc: \"指定されたノートブックは存在しません。\",\n    updated: \"更新日時\",\n    namePlaceholder: \"ノートブック名\",\n    addDescription: \"説明を追加...\",\n    noNotesYet: \"ノートがまだありません\",\n    deleteNote: \"ノートを削除\",\n    deleteNoteConfirm: \"このノートを削除しますか？この操作は元に戻せません。\",\n    noteCreatedSuccess: \"ノートを作成しました\",\n    failedToCreateNote: \"ノートの作成に失敗しました\",\n    noteUpdatedSuccess: \"ノートを更新しました\",\n    failedToUpdateNote: \"ノートの更新に失敗しました\",\n    noteDeletedSuccess: \"ノートを削除しました\",\n    failedToDeleteNote: \"ノートの削除に失敗しました\",\n    createNew: \"新規ノートブックを作成\",\n    createNewDesc: \"名前と説明（任意）を入力してください。\",\n    descPlaceholder: \"このノートブックについての情報を追加...\",\n    createSuccess: \"ノートブックを作成しました\",\n    updateSuccess: \"ノートブックを更新しました\",\n    deleteSuccess: \"ノートブックを削除しました\",\n  },\n  sources: {\n    title: \"ソース\",\n    add: \"ソースを追加\",\n    addNew: \"新規ソースを追加\",\n    addExisting: \"既存ソースを追加\",\n    delete: \"ソースを削除\",\n    statusPreparing: \"準備中\",\n    statusQueued: \"キュー待ち\",\n    statusProcessing: \"処理中\",\n    statusCompleted: \"完了\",\n    statusFailed: \"失敗\",\n    statusPreparingDesc: \"処理の準備中\",\n    statusQueuedDesc: \"処理待ち\",\n    statusProcessingDesc: \"処理中\",\n    statusCompletedDesc: \"処理完了\",\n    statusFailedDesc: \"処理失敗\",\n    failedToLoad: \"ソースの読み込みに失敗しました\",\n    allSourcesDesc: \"すべてのソースを表示します。新しいソースの追加や既存ソースの管理ができます。\",\n    allSources: \"すべてのソース\",\n    insights: \"インサイト\",\n    yes: \"はい\",\n    no: \"いいえ\",\n    loadingMore: \"さらに読み込み中...\",\n    noSourcesYet: \"ソースがまだありません\",\n    allSourcesDescShort: \"すべてのソースを表示します。\",\n    cannotSaveNoteNoNotebook: \"ノートを保存できません：ノートブックIDが利用できません\",\n    createFirstSource: \"最初のソースを追加してナレッジベースの構築を始めましょう。\",\n    deleteSourceConfirm: \"このソースを削除しますか？\",\n    deleteConfirm: \"削除しますか？\",\n    deleteConfirmWithTitle: \"「{title}」を削除しますか？\",\n    deleteSuccess: \"ソースを削除しました。注意：ストレージからファイルを削除するには、設定ページで「ファイルを削除」オプションを有効にする必要があります。\",\n    failedToDelete: \"ソースの削除に失敗しました\",\n    sourceQueued: \"ソースをキューに追加\",\n    sourceQueuedDesc: \"ソースをバックグラウンド処理に送信しました。ソース一覧で進捗を確認できます。\",\n    sourceAddedSuccess: \"ソースを追加しました\",\n    failedToAddSource: \"ソースの追加に失敗しました\",\n    sourceUpdatedSuccess: \"ソースを更新しました\",\n    failedToUpdateSource: \"ソースの更新に失敗しました\",\n    sourceDeletedSuccess: \"ソースを削除しました\",\n    failedToDeleteSource: \"ソースの削除に失敗しました\",\n    fileUploadedSuccess: \"ファイルをアップロードしました\",\n    failedToUploadFile: \"ファイルのアップロードに失敗しました\",\n    sourceRequeued: \"ソースの再処理をキューに追加\",\n    sourceRequeuedDesc: \"ソースを再処理キューに追加しました。\",\n    failedToRetry: \"再試行に失敗\",\n    sourcesAddedToNotebook: \"{count}件のソースをノートブックに追加しました\",\n    failedToAddSourcesToNotebook: \"ノートブックへのソース追加に失敗しました\",\n    partialAddSuccess: \"{success}件追加成功、{failed}件失敗\",\n    sourceRemovedFromNotebook: \"ノートブックからソースを削除しました\",\n    failedToRemoveSourceFromNotebook: \"ノートブックからのソース削除に失敗しました\",\n    removeConfirm: \"ノートブックからこのソースを削除しますか？\",\n    checking: \"確認中...\",\n    untitledSource: \"無題のソース\",\n    maxItems: \"最大{count}件\",\n    insightsCount: \"{count}件のインサイト\",\n    details: \"詳細\",\n    detailsTitle: \"ソース詳細\",\n    content: \"コンテンツ\",\n    metadata: \"メタデータ\",\n    type: {\n      link: \"リンク\",\n      file: \"ファイル\",\n      text: \"テキスト\",\n    },\n    id: \"ソースID\",\n    topics: \"トピック\",\n    embedded: \"Embedding済み\",\n    notEmbedded: \"未Embedding\",\n    embedContent: \"Embeddingを実行\",\n    embedding: \"Embedding中...\",\n    alreadyEmbedded: \"Embedding済み\",\n    downloadFile: \"ファイルをダウンロード\",\n    fileUnavailable: \"ファイルが利用できません\",\n    preparing: \"準備中...\",\n    generateNewInsight: \"新しいインサイトを生成\",\n    selectTransformation: \"トランスフォーメーションを選択...\",\n    noInsightsYet: \"インサイトがまだありません\",\n    createFirstInsight: \"上のトランスフォーメーションを使って最初のインサイトを作成しましょう\",\n    viewInsight: \"インサイトを表示\",\n    deleteInsight: \"インサイトを削除\",\n    deleteInsightConfirm: \"このインサイトを削除しますか？この操作は元に戻せません。\",\n    insightGenerationStarted: \"インサイトの生成が開始されました。まもなく表示されます。\",\n    editNote: \"ノートを編集\",\n    createNote: \"ノートを作成\",\n    addTitle: \"タイトルを追加...\",\n    untitledNote: \"無題のノート\",\n    writeNotePlaceholder: \"ノートの内容をここに入力...\",\n    saveNote: \"ノートを保存\",\n    createNoteBtn: \"ノートを作成\",\n    createFirstNote: \"最初のノートを作成してインサイトや気づきを記録しましょう。\",\n    urlLabel: \"URL *\",\n    fileLabel: \"ファイル *\",\n    textContentLabel: \"テキストコンテンツ *\",\n    enterUrlsPlaceholder: \"URLを1行ずつ入力\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"複数のURLを貼り付けて一括インポート（1行に1つ）\",\n    invalidUrlsDetected: \"無効なURLが検出されました:\",\n    lineLabel: \"{line}行目\",\n    fixInvalidUrls: \"無効なURLを修正または削除してください\",\n    selectMultipleFilesHint: \"複数ファイルを選択して一括インポート。対応形式：ドキュメント（PDF、DOC、DOCX、PPT、XLS、EPUB、TXT、MD）、メディア（MP4、MP3、WAV、M4A）、画像（JPG、PNG）、アーカイブ（ZIP）\",\n    selectedFiles: \"選択されたファイル:\",\n    textPlaceholder: \"コンテンツを貼り付けまたは入力...\",\n    htmlDetected: \"HTMLコンテンツが検出されました。処理後にMarkdownに変換されます。\",\n    titlePlaceholder: \"ソースにわかりやすいタイトルを付けてください\",\n    batchTitlesAuto: \"タイトルは各ソースごとに自動生成されます。\",\n    batchCommonSettings: \"同じノートブックとトランスフォーメーションがすべてのアイテムに適用されます。\",\n    urlsCount: \"{count}件のURL\",\n    filesCount: \"{count}件のファイル\",\n    addSource: \"ソースを追加\",\n    notEmbeddedAlert: \"コンテンツが未Embedding\",\n    notEmbeddedDesc: \"このコンテンツはベクトル検索用にEmbeddingされていません。Embeddingを行うと高度な検索機能やコンテンツの発見性が向上します。\",\n    openOnYoutube: \"YouTubeで開く\",\n    urlCopied: \"URLをクリップボードにコピーしました\",\n    viewSource: \"ソースを表示\",\n    noInsightSelected: \"インサイトが選択されていません\",\n    sourceInsight: \"ソースインサイト\",\n    manageNotebooks: \"ノートブックを管理\",\n    manageNotebooksDesc: \"このソースを含むノートブックを管理\",\n    noNotebooksAvailable: \"利用可能なノートブックがありません\",\n    loadFailed: \"ソース詳細の読み込みに失敗しました\",\n    removeFromNotebook: \"ノートブックから削除\",\n    retryProcessing: \"処理を再試行\",\n    deleteSource: \"ソースを削除\",\n    retry: \"再試行\",\n    addExistingTitle: \"既存ソースを追加\",\n    addExistingDesc: \"すべてのノートブックから既存のソースを選択して現在のノートブックに追加します。\",\n    searchPlaceholder: \"名前またはURLでソースを検索...\",\n    noNotebooksFound: \"ノートブックが見つかりません。\",\n    showingFirst100: \"最初の100件を表示中。検索で特定のソースを探してください。\",\n    selectedCount: \"{count}件選択中\",\n    added: \"{date}に追加\",\n    addUrl: \"URLを追加\",\n    uploadFile: \"ファイルをアップロード\",\n    enterText: \"テキストを入力\",\n    processDescription: \"コンテンツはAIによって処理・分析されます。\",\n    processingFiles: \"ファイルを処理中...\",\n    titleRequired: \"テキストコンテンツにはタイトルが必要です\",\n    titleGenerated: \"空欄の場合、コンテンツからタイトルが自動生成されます\",\n    batchCount: \"{count}件の{type}が処理されます\",\n    enableEmbedding: \"検索用にEmbeddingを有効化\",\n    embeddingDesc: \"このソースをベクトル検索やAIクエリで見つけられるようにします\",\n    embeddingAlways: \"Embeddingは自動的に有効\",\n    embeddingAlwaysDesc: \"設定でベクトル検索用に常にEmbeddingするよう構成されています。\",\n    embeddingNever: \"Embeddingは無効\",\n    embeddingNeverDesc: \"設定でEmbeddingをスキップするよう構成されています。このソースではベクトル検索は利用できません。\",\n    changeInSettings: \"設定で変更できます\",\n    notFound: \"ソースが見つかりません\",\n    noContent: \"コンテンツがありません\",\n    insightsDesc: \"モデル分析から生成されたインサイト\",\n    uploadedFile: \"アップロードされたファイル\",\n    fileUnavailableDesc: \"ストレージシステムの理由により、このファイルは現在利用できません。\",\n    batchSuccess: \"{count}件のソースを作成しました\",\n    batchFailed: \"{count}件すべてのソース作成に失敗しました\",\n    batchPartial: \"{success}件成功、{failed}件失敗\",\n    submittingSource: \"ソースを処理に送信中...\",\n    processingBatchSources: \"{count}件のソースを処理中。しばらくお待ちください。\",\n    processingSource: \"ソースを処理中です。しばらくお待ちください。\",\n    maxFilesAllowed: \"一括処理は最大{count}件までです\",\n  },\n  chat: {\n    sessions: \"セッション\",\n    sessionTitlePlaceholder: \"タイトルを入力...\",\n    noSessions: \"チャットセッションがまだありません\",\n    deleteSession: \"セッションを削除\",\n    deleteSessionDesc: \"このチャットセッションを削除しますか？この操作は元に戻せません。\",\n    sendPlaceholder: \"ソースについて何でも質問してください...\",\n    sessionsTitle: \"チャットセッション\",\n    chatWith: \"{name}とチャット\",\n    startConversation: \"この{type}について会話を始めましょう\",\n    askQuestions: \"コンテンツをより深く理解するために質問してください\",\n    pressToSend: \"{key}を押して送信\",\n    model: \"モデル\",\n    createToStart: \"セッションを作成して開始\",\n    chatWithNotebook: \"ノートブックとチャット\",\n    unableToLoadChat: \"チャットを読み込めません\",\n    noDescription: \"説明なし\",\n    startByCreating: \"最初のノートブックを作成してリサーチを整理しましょう。\",\n    messagesCount: \"{count}件のメッセージ\",\n    sessionCreated: \"チャットセッションを作成しました\",\n    sessionUpdated: \"セッションを更新しました\",\n    sessionDeleted: \"セッションを削除しました\",\n  },\n  searchPage: {\n    askAndSearch: \"質問と検索\",\n    chooseAMode: \"モードを選択\",\n    askBeta: \"質問（ベータ）\",\n    search: \"検索\",\n    askYourKb: \"ナレッジベースに質問（ベータ）\",\n    askYourKbDesc: \"LLMがナレッジベース内のドキュメントに基づいてクエリに回答します。\",\n    question: \"質問\",\n    enterQuestionPlaceholder: \"質問を入力...\",\n    pressToSubmit: \"Cmd/Ctrl+Enterで送信\",\n    noEmbeddingModel: \"Embeddingモデルが選択されていないため、この機能は使用できません。モデルページで設定してください。\",\n    usingCustomModels: \"カスタムモデルを使用中\",\n    usingDefaultModels: \"デフォルトモデルを使用中\",\n    advanced: \"詳細設定\",\n    strategy: \"戦略\",\n    answer: \"回答\",\n    final: \"最終\",\n    ask: \"質問する\",\n    processing: \"処理中...\",\n    saveToNotebooks: \"ノートブックに保存\",\n    searchDesc: \"特定のキーワードやコンセプトでナレッジベースを検索\",\n    enterSearchPlaceholder: \"検索クエリを入力...\",\n    pressToSearch: \"Enterで検索\",\n    searchType: \"検索タイプ\",\n    vectorSearchWarning: \"ベクトル検索にはEmbeddingモデルが必要です。テキスト検索のみ利用可能です。\",\n    textSearch: \"テキスト検索\",\n    vectorSearch: \"ベクトル検索\",\n    searchIn: \"検索対象\",\n    searchSources: \"ソースを検索\",\n    searchNotes: \"ノートを検索\",\n    resultsFound: \"{count}件の結果\",\n    matches: \"一致（{count}件）\",\n    noResultsFor: \"「{query}」に一致する結果がありません\",\n    notSet: \"未設定\",\n    saveToNotebook: \"ノートブックに保存\",\n    saveSuccess: \"ノートブックに保存しました\",\n    saveError: \"ノートブックへの保存に失敗しました\",\n    selectNotebook: \"ノートブックを選択\",\n    searchAndAsk: \"検索と質問\",\n    searchResultsFor: \"「{query}」の検索結果\",\n    askAbout: \"「{query}」について質問\",\n    orSearchKb: \"またはナレッジベースを検索\",\n    saving: \"保存中...\",\n    advancedModelTitle: \"詳細モデル選択\",\n    advancedModelDesc: \"質問処理の各段階で使用するモデルを選択\",\n    strategyModel: \"戦略モデル\",\n    answerModel: \"回答モデル\",\n    finalAnswerModel: \"最終回答モデル\",\n    selectStrategyPlaceholder: \"戦略モデルを選択\",\n    selectAnswerPlaceholder: \"回答モデルを選択\",\n    selectFinalPlaceholder: \"最終回答モデルを選択\",\n    saveChanges: \"変更を保存\",\n    processingQuestion: \"質問を処理中...\",\n  },\n  podcasts: {\n    generateEpisode: \"ポッドキャストエピソードを生成\",\n    generateEpisodeDesc: \"含めるコンテンツを選択し、新しいポッドキャストエピソードを生成する前にエピソードの詳細を設定してください。\",\n    content: \"コンテンツ\",\n    contentDesc: \"このエピソードに含めるノートブック、ソース、ノートを選択してください。\",\n    itemsSelected: \"{count}件選択中\",\n    tokens: \"{count}トークン\",\n    chars: \"{count}文字\",\n    loadingNotebooks: \"ノートブックを読み込み中...\",\n    noNotebooksFoundInPodcasts: \"ノートブックが見つかりません。ポッドキャストを生成する前にノートブックを作成してコンテンツを追加してください。\",\n    noContentSelected: \"コンテンツが選択されていません\",\n    summary: \"要約\",\n    fullContent: \"全文\",\n    untitledSource: \"無題のソース\",\n    untitledNote: \"無題のノート\",\n    episodeSettings: \"エピソード設定\",\n    episodeProfile: \"エピソードプロファイル\",\n    episodeProfilePlaceholder: \"エピソードプロファイルを選択\",\n    episodeName: \"エピソード名\",\n    episodeNamePlaceholder: \"例：AIと仕事の未来\",\n    additionalInstructions: \"追加の指示\",\n    instructionsPlaceholder: \"エピソードブリーフィングに追加するアドバイス...\",\n    generating: \"生成中...\",\n    generate: \"生成\",\n    hostPlaceholder: \"ホスト{number}\",\n    profileRequired: \"エピソードプロファイルが必要です\",\n    profileRequiredDesc: \"ポッドキャストを生成する前にエピソードプロファイルを選択してください。\",\n    nameRequired: \"エピソード名が必要です\",\n    nameRequiredDesc: \"エピソード名を入力してください。\",\n    addContext: \"コンテキストを追加\",\n    addContextDesc: \"エピソードに含めるソースまたはノートを少なくとも1つ選択してください。\",\n    generationFailed: \"ポッドキャスト生成に失敗しました\",\n    speakerProfile: \"スピーカープロファイル\",\n    usesSpeakerProfile: \"スピーカープロファイルを使用\",\n    sources: \"ソース\",\n    notes: \"ノート\",\n    noSources: \"このノートブックにはソースがありません。\",\n    noNotes: \"このノートブックにはノートがありません。\",\n    selectMode: \"モードを選択\",\n    buildContextFailed: \"コンテキストの構築に失敗しました。選択内容を確認してください。\",\n    podcastTaskStarted: \"ポッドキャストタスクを開始しました\",\n    loadingProfiles: \"エピソードプロファイルを読み込み中...\",\n    noProfilesFound: \"エピソードプロファイルが見つかりません。ポッドキャストを生成する前にエピソードプロファイルを作成してください。\",\n    listTitle: \"ポッドキャスト\",\n    listDesc: \"生成されたエピソードを追跡し、再利用可能なプロファイルを管理します。\",\n    chooseAView: \"表示を選択\",\n    episodesTab: \"エピソード\",\n    templatesTab: \"プロファイル\",\n    overviewTitle: \"エピソード概要\",\n    overviewDesc: \"ポッドキャスト生成ジョブを監視し、最終成果物を確認します。\",\n    generateBtn: \"ポッドキャストを生成\",\n    total: \"合計\",\n    processingLabel: \"処理中\",\n    completedLabel: \"完了\",\n    failedLabel: \"失敗\",\n    pendingLabel: \"保留中\",\n    loadErrorTitle: \"エピソードの読み込みに失敗しました\",\n    loadErrorDesc: \"最新のポッドキャストエピソードを取得できませんでした。しばらくしてから再試行してください。\",\n    loadingEpisodes: \"エピソードを読み込み中...\",\n    noEpisodesYet: \"ポッドキャストエピソードがまだありません。ノートブックまたはソースのチャットインターフェースから最初のエピソードを生成してください。\",\n    statusRunningTitle: \"処理中\",\n    statusRunningDesc: \"現在アセットを生成中のエピソード。\",\n    statusPendingTitle: \"キュー待ち / 保留中\",\n    statusPendingDesc: \"処理開始を待っている送信済みエピソード。\",\n    statusCompletedTitle: \"完了したエピソード\",\n    statusCompletedDesc: \"確認、ダウンロード、公開の準備が整っています。\",\n    statusFailedTitle: \"失敗したエピソード\",\n    statusFailedDesc: \"生成中に問題が発生したエピソード。\",\n    templatesWorkspaceTitle: \"プロファイルワークスペース\",\n    templatesWorkspaceDesc: \"高速なポッドキャスト制作のための再利用可能なエピソードおよびスピーカー設定を構築します。\",\n    howTemplatesPowerTitle: \"プロファイルがポッドキャスト生成を強化する仕組み\",\n    howTemplatesPowerDesc: \"プロファイルはポッドキャストワークフローを2つの再利用可能なビルディングブロックに分割します。新しいエピソードを生成する際に自由に組み合わせてください。\",\n    episodeProfilesSetFormat: \"エピソードプロファイルがフォーマットを設定\",\n    episodeProfilesList1: \"セグメント数とストーリーの流れを概説\",\n    episodeProfilesList2: \"ブリーフィング、アウトライン、スクリプト作成に使用する言語モデルを選択\",\n    episodeProfilesList3: \"デフォルトのブリーフィングを保存し、すべてのエピソードで一貫したトーンで開始\",\n    speakerProfilesBringVoices: \"スピーカープロファイルが声に命を吹き込む\",\n    speakerProfilesList1: \"音声合成プロバイダーとモデルを選択\",\n    speakerProfilesList2: \"スピーカーごとの個性、経歴、発音メモを記録\",\n    speakerProfilesList3: \"異なるエピソードフォーマットで同じホストやゲストの声を再利用\",\n    recommendedWorkflow: \"推奨ワークフロー\",\n    workflowStep1: \"必要な各声のスピーカープロファイルを作成\",\n    workflowStep2: \"それらのスピーカーを名前で参照するエピソードプロファイルを構築\",\n    workflowStep3: \"ストーリーに合ったエピソードプロファイルを選択してポッドキャストを生成\",\n    workflowHint: \"エピソードプロファイルはスピーカープロファイルを名前で参照するため、スピーカーから始めることで後の声の割り当て漏れを防ぎます。\",\n    failedToLoadTemplates: \"プロファイルデータの読み込みに失敗しました\",\n    failedToLoadTemplatesDesc: \"APIが実行中か確認して再試行してください。一部のセクションが不完全な場合があります。\",\n    loadingTemplates: \"プロファイルを読み込み中...\",\n    speakerProfilesTitle: \"スピーカープロファイル\",\n    speakerProfilesDesc: \"生成されるエピソードの声と個性を設定します。\",\n    createSpeaker: \"スピーカーを作成\",\n    noSpeakerProfiles: \"スピーカープロファイルがまだありません。エピソードプロファイルを利用するには作成してください。\",\n    noDescription: \"説明なし。\",\n    usedByCount_one: \"1つのエピソードで使用\",\n    usedByCount_other: \"{count}個のエピソードで使用\",\n    usedByCount: \"{count}個のエピソードで使用\",\n    unused: \"未使用\",\n    voiceId: \"Voice ID\",\n    backstory: \"バックストーリー\",\n    personality: \"パーソナリティ\",\n    edit: \"編集\",\n    duplicate: \"複製\",\n    deleteSpeakerProfileTitle: \"スピーカープロファイルを削除しますか？\",\n    deleteSpeakerProfileDesc: \"「{name}」を削除すると元に戻せません。\",\n    deleteSpeakerDisabledHint: \"削除する前にエピソードプロファイルからこのスピーカーを削除してください。\",\n    deleting: \"削除中...\",\n    episodeProfilesTitle: \"エピソードプロファイル\",\n    episodeProfilesDesc: \"番組用の再利用可能な生成設定を定義します。\",\n    createProfile: \"プロファイルを作成\",\n    createSpeakerFirst: \"エピソードプロファイルを追加する前にスピーカープロファイルを作成してください。\",\n    noEpisodeProfiles: \"エピソードプロファイルがまだありません。ポッドキャスト生成を開始するには作成してください。\",\n    speakerCreated: \"スピーカーを作成しました\",\n    speakerCreatedDesc: \"スピーカー「{name}」を追加しました。\",\n    failedToCreateSpeaker: \"スピーカープロファイルの作成に失敗しました\",\n    speakerUpdated: \"スピーカーを更新しました\",\n    speakerUpdatedDesc: \"スピーカー「{name}」を更新しました。\",\n    failedToUpdateSpeaker: \"スピーカープロファイルの更新に失敗しました\",\n    speakerDeleted: \"スピーカーを削除しました\",\n    speakerDeletedDesc: \"スピーカー「{name}」を削除しました。\",\n    failedToDeleteSpeaker: \"スピーカープロファイルの削除に失敗しました\",\n    speakerDuplicated: \"スピーカーを複製しました\",\n    speakerDuplicatedDesc: \"スピーカー「{name}」を複製しました。\",\n    failedToDuplicateSpeaker: \"スピーカープロファイルの複製に失敗しました\",\n    generationStarted: \"生成を開始しました\",\n    generationStartedDesc: \"ポッドキャスト生成がキューに追加されました。\",\n    failedToStartGeneration: \"生成の開始に失敗しました\",\n    tryAgainMoment: \"しばらくしてから再試行してください。\",\n    deleteProfileTitle: \"プロファイルを削除しますか？\",\n    deleteProfileDesc: \"「{name}」を削除します。既存のエピソードはデータを保持しますが、新しいエピソードはこの設定を使用できなくなります。\",\n    profileCreated: \"プロファイルを作成しました\",\n    profileCreatedDesc: \"エピソードプロファイル「{name}」を作成しました。\",\n    failedToCreateProfile: \"プロファイルの作成に失敗しました\",\n    profileUpdated: \"プロファイルを更新しました\",\n    profileUpdatedDesc: \"エピソードプロファイル「{name}」を更新しました。\",\n    failedToUpdateProfile: \"プロファイルの更新に失敗しました\",\n    profileDeleted: \"プロファイルを削除しました\",\n    profileDeletedDesc: \"エピソードプロファイル「{name}」を削除しました。\",\n    failedToDeleteProfile: \"プロファイルの削除に失敗しました\",\n    failedToDeleteProfileDesc: \"エピソードプロファイルの削除に失敗しました。\",\n    profileDuplicated: \"プロファイルを複製しました\",\n    profileDuplicatedDesc: \"エピソードプロファイル「{name}」を複製しました。\",\n    failedToDuplicateProfile: \"プロファイルの複製に失敗しました\",\n    episodeDeleted: \"エピソードを削除しました\",\n    episodeDeletedDesc: \"エピソードを削除しました。\",\n    failedToDeleteEpisode: \"エピソードの削除に失敗しました\",\n    failedToDeleteSpeakerDesc: \"スピーカープロファイルの削除に失敗しました。\",\n    outlineModel: \"アウトラインモデル\",\n    transcriptModel: \"トランスクリプトモデル\",\n    segments: \"セグメント\",\n    defaultBriefingTitle: \"デフォルトブリーフィング\",\n    created: \"{time}に作成\",\n    details: \"詳細\",\n    summaryTab: \"要約\",\n    outlineTab: \"アウトライン\",\n    transcriptTab: \"トランスクリプト\",\n    briefing: \"ブリーフィング\",\n    noOutline: \"アウトラインがありません。\",\n    noTranscript: \"トランスクリプトがありません。\",\n    deleteEpisodeTitle: \"エピソードを削除しますか？\",\n    deleteEpisodeDesc: \"「{name}」とその音声ファイルを完全に削除します。\",\n    audioUnavailable: \"音声が利用できません\",\n    segment: \"セグメント\",\n    speaker: \"スピーカー\",\n    profile: \"プロファイル\",\n    link: \"リンク\",\n    file: \"ファイル\",\n    embedded: \"Embedding済み\",\n    notEmbedded: \"未Embedding\",\n    noSpeakerProfilesAvailable: \"スピーカープロファイルがありません\",\n    editEpisodeProfile: \"エピソードプロファイルを編集\",\n    createEpisodeProfile: \"エピソードプロファイルを作成\",\n    episodeProfileFormDesc: \"エピソードの生成方法とデフォルトで使用するスピーカー設定を定義します。\",\n    noSpeakerProfilesDesc: \"エピソードプロファイルを設定する前にスピーカープロファイルを作成してください。\",\n    profileName: \"プロファイル名\",\n    profileNamePlaceholder: \"例：テックディスカッション\",\n    descriptionPlaceholder: \"このプロファイルを使用する場面の簡単な説明\",\n    speakerConfig: \"スピーカー設定\",\n    selectSpeakerProfile: \"スピーカープロファイルを選択\",\n    outlineGeneration: \"アウトライン生成\",\n    transcriptGeneration: \"トランスクリプト生成\",\n    defaultBriefingPlaceholder: \"このエピソードフォーマットの構成、トーン、目標を概説\",\n    editSpeakerProfile: \"スピーカープロファイルを編集\",\n    createSpeakerProfile: \"スピーカープロファイルを作成\",\n    speakerProfileFormDesc: \"音声合成設定を構成し、最大4人のスピーカーを定義します。\",\n    speakers: \"スピーカー\",\n    speakersDesc: \"このプロファイルに1〜4人の声を設定します。\",\n    addSpeaker: \"スピーカーを追加\",\n    speakerNumber: \"スピーカー{number}\",\n    backstoryPlaceholder: \"スピーカーの短い経歴やコンテキスト\",\n    personalityPlaceholder: \"スタイルとトーンを説明\",\n    outlineModelRequired: \"アウトラインモデルは必須です\",\n    transcriptModelRequired: \"トランスクリプトモデルは必須です\",\n    defaultBriefingRequired: \"デフォルトブリーフィングは必須です\",\n    segmentsInteger: \"整数である必要があります\",\n    segmentsMin: \"最低3セグメント\",\n    segmentsMax: \"最大20セグメント\",\n    voiceIdRequired: \"Voice IDは必須です\",\n    backstoryRequired: \"バックストーリーは必須です\",\n    personalityRequired: \"パーソナリティは必須です\",\n    speakerCountMin: \"最低1人のスピーカーが必要です\",\n    speakerCountMax: \"最大4人まで設定できます\",\n    delete: \"削除\",\n    failedToDelete: \"ポッドキャストの削除に失敗しました\",\n    retry: \"再試行\",\n    retrying: \"再試行中…\",\n    retryStarted: \"再試行を開始しました\",\n    retryStartedDesc: \"新しいポッドキャスト生成ジョブが送信されました。\",\n    failedToRetry: \"再試行に失敗しました\",\n    errorDetails: \"エラー詳細\",\n    language: \"言語\",\n    languagePlaceholder: \"言語を選択（任意）\",\n    podcastLanguage: \"ポッドキャストの言語\",\n    selectOutlineModel: \"アウトラインモデルを選択\",\n    selectTranscriptModel: \"トランスクリプトモデルを選択\",\n    voiceModel: \"音声モデル\",\n    voiceModelRequired: \"音声モデルは必須です\",\n    selectVoiceModel: \"音声モデルを選択\",\n    perSpeakerTtsOverride: \"スピーカーごとのTTSオーバーライド（任意）\",\n    useProfileDefault: \"プロファイルのデフォルトを使用\",\n    setupRequired: \"設定が必要\",\n    setupRequiredDesc: \"一部のプロファイルにモデルが設定されていません。ポッドキャストを生成する前に、編集してモデルを選択してください。\",\n    notConfigured: \"未設定\",\n  },\n  settings: {\n    contentProcessing: \"コンテンツ処理\",\n    contentProcessingDesc: \"ドキュメントとURLの処理方法を設定\",\n    docEngine: \"ドキュメント処理エンジン\",\n    docEnginePlaceholder: \"ドキュメント処理エンジンを選択\",\n    urlEngine: \"URL処理エンジン\",\n    urlEnginePlaceholder: \"URL処理エンジンを選択\",\n    autoRecommended: \"自動（推奨）\",\n    simple: \"シンプル\",\n    docling: \"Docling\",\n    helpMeChoose: \"選び方\",\n    docHelp: \"・Doclingは少し遅いですが、特にテーブルや画像を含むドキュメントでより正確です。・シンプルはフォーマットせずにドキュメントからコンテンツを抽出します。・自動（推奨）はDoclingで処理を試み、失敗した場合はシンプルにフォールバックします。\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"・Firecrawlは有料サービス（無料枠あり）で非常に強力です。・Jinaも良いオプションで無料枠があります。・シンプルは基本的なHTTP抽出を使用し、JavaScriptベースのウェブサイトのコンテンツを取得できない場合があります。・自動（推奨）はFirecrawl、Jina、最後にシンプルの順で試みます。\",\n    embeddingAndSearch: \"Embeddingと検索\",\n    embeddingAndSearchDesc: \"検索とEmbeddingオプションを設定\",\n    defaultEmbeddingOption: \"デフォルトEmbeddingオプション\",\n    embeddingOptionPlaceholder: \"Embeddingオプションを選択\",\n    ask: \"確認する\",\n    always: \"常に実行\",\n    never: \"実行しない\",\n    embeddingHelp: \"コンテンツをEmbeddingすると、あなたやAIエージェントが見つけやすくなります。ローカルのEmbeddingモデル（Ollamaなど）を使用している場合はコストを気にせずすべてをEmbeddingできます。\",\n    fileManagement: \"ファイル管理\",\n    fileManagementDesc: \"ファイルの処理とストレージオプションを設定\",\n    autoDeleteFiles: \"ファイル自動削除\",\n    autoDeletePlaceholder: \"自動削除オプションを選択\",\n    filesHelp: \"ファイルがアップロードされて処理されると、ファイル自体は不要になります。ほとんどのユーザーはOpen Notebookがアップロードフォルダから自動的にファイルを削除することを許可すべきです。\",\n    loadFailed: \"設定の読み込みに失敗しました\",\n  },\n  advanced: {\n    title: \"詳細ツール\",\n    desc: \"パワーユーザー向けの詳細ツールとユーティリティ\",\n    systemInfo: \"システム情報\",\n    rebuildEmbeddings: \"Embeddingを再構築\",\n    rebuildEmbeddingsDesc: \"すべてのソースのベクトル検索インデックスを再構築\",\n    currentVersion: \"現在のバージョン\",\n    latestVersion: \"最新バージョン\",\n    status: \"ステータス\",\n    updateAvailable: \"バージョン{version}が利用可能\",\n    updateAvailableDesc: \"Open Notebookの新しいバージョンが利用可能です。\",\n    upToDate: \"最新版です\",\n    unknown: \"不明\",\n    viewOnGithub: \"GitHubで表示\",\n    updateCheckFailed: \"更新を確認できません。GitHubに接続できない可能性があります。\",\n    rebuild: {\n      mode: \"再構築モード\",\n      existing: \"既存のみ\",\n      all: \"すべて\",\n      existingDesc: \"すでにEmbeddingがあるアイテムのみ再Embedding（高速、モデル切り替え用）\",\n      allDesc: \"既存アイテムの再Embedding＋Embeddingがないアイテムも作成（低速、包括的）\",\n      include: \"再構築に含める\",\n      selectOneError: \"再構築するアイテムタイプを少なくとも1つ選択してください\",\n      starting: \"再構築を開始中...\",\n      startBtn: \"🚀 再構築を開始\",\n      queued: \"キュー待ち\",\n      running: \"ジョブ送信中...\",\n      completed: \"ジョブ送信完了！\",\n      failed: \"失敗\",\n      leavePageHint: \"バックグラウンドで実行されるため、このページを離れても構いません\",\n      startNew: \"新しい再構築を開始\",\n      itemsProcessed: \"{processed}/{total}件送信済み（{percent}%）\",\n      failedItems: \"{count}件のジョブの送信に失敗しました\",\n      time: \"経過時間\",\n      whenToRebuild: \"いつEmbeddingを再構築すべき？\",\n      whenToRebuildAns: \"モデルの切り替え時、バージョンアップ時、破損の修復時、または一括インポート後に再構築してください。\",\n      howLong: \"再構築にはどのくらい時間がかかる？\",\n      howLongAns: \"処理時間はアイテム数、モデル速度、APIレート制限によって異なります。ローカルモデルは通常非常に高速です。\",\n      isSafe: \"アプリ使用中に再構築しても安全？\",\n      isSafeAns: \"はい、再構築は安全です！コンテンツは削除されず、Embeddingのみが置き換えられ、エラーは適切に処理されます。\",\n    },\n  },\n  transformations: {\n    title: \"トランスフォーメーション\",\n    desc: \"トランスフォーメーションはLLMがソースを処理してインサイト、要約などを抽出するためのプロンプトです。\",\n    workspace: \"ワークスペースを選択\",\n    playground: \"プレイグラウンド\",\n    defaultPrompt: \"デフォルトトランスフォーメーションプロンプト\",\n    defaultPromptDesc: \"これはすべてのトランスフォーメーションプロンプトに追加されます\",\n    defaultPromptPlaceholder: \"デフォルトのトランスフォーメーション指示を入力...\",\n    listTitle: \"カスタムトランスフォーメーション\",\n    createNew: \"新規作成\",\n    inputLabel: \"入力テキスト\",\n    inputPlaceholder: \"変換するテキストを入力...\",\n    outputLabel: \"出力\",\n    runTest: \"トランスフォーメーションを実行\",\n    running: \"実行中...\",\n    selectToStart: \"トランスフォーメーションを選択して開始\",\n    name: \"名前\",\n    namePlaceholder: \"一意の識別子、例: key_topics\",\n    titlePlaceholder: \"表示タイトル、空欄の場合は名前を使用\",\n    promptPlaceholder: \"このトランスフォーメーションを実行するプロンプトを書いてください...\",\n    descriptionPlaceholder: \"このトランスフォーメーションの機能を説明してください。\",\n    suggestDefault: \"新しいソースでデフォルトで提案\",\n    promptHint: \"プロンプトはソースコンテンツを念頭に置いて書いてください。モデルに要約、インサイトの抽出、テーブルなどの構造化出力の生成を依頼できます。\",\n    createSuccess: \"トランスフォーメーションを作成しました\",\n    updateSuccess: \"トランスフォーメーションを更新しました\",\n    deleteSuccess: \"トランスフォーメーションを削除しました\",\n    noTransformations: \"トランスフォーメーションがまだありません\",\n    createOne: \"開始するにはトランスフォーメーションを作成してください\",\n    selectModel: \"モデルを選択\",\n    deleteConfirm: \"このトランスフォーメーションを削除しますか？\",\n    model: \"モデル\",\n    systemPrompt: \"システムプロンプト\",\n    overrideModelDesc: \"このチャットセッションのデフォルトモデルを上書きします。空欄の場合はシステムデフォルトを使用します。\",\n    sessionUseReplacement: \"このセッションはデフォルトモデルの代わりに{name}を使用します。\",\n    systemDefault: \"システムデフォルト\",\n  },\n  models: {\n    embedding: \"Embeddingモデル\",\n    tts: \"音声合成（TTS）\",\n    stt: \"音声認識（STT）\",\n    apiKey: \"APIキー\",\n    deleteSuccess: \"モデルを削除しました\",\n    saveSuccess: \"モデルを保存しました\",\n    noModels: \"モデルなし\",\n    discoverModels: \"モデルを検出\",\n    noModelsFound: \"このプロバイダーからモデルが見つかりません\",\n    modelType: \"モデルタイプ\",\n    modelTypeHint: \"追加するモデルのタイプを選択してください。異なるタイプが必要な場合は、別々のバッチで追加してください。\",\n    deleteModel: \"モデルを削除\",\n    defaultAssignments: \"デフォルトモデル割り当て\",\n    defaultAssignmentsDesc: \"Open Notebook全体で異なる目的に使用するモデルを設定\",\n    missingRequiredModels: \"必須モデルがありません: {models}。これらがないとOpen Notebookが正しく機能しない可能性があります。\",\n    selectModelPlaceholder: \"モデルを選択\",\n    requiredModelPlaceholder: \"⚠️ 必須 - モデルを選択\",\n    chatModelLabel: \"チャットモデル\",\n    chatModelDesc: \"チャット会話に使用\",\n    transformationModelLabel: \"トランスフォーメーションモデル\",\n    transformationModelDesc: \"要約、インサイト、トランスフォーメーションに使用\",\n    toolsModelLabel: \"ツールモデル\",\n    toolsModelDesc: \"関数呼び出しに使用 - OpenAIまたはAnthropicを推奨\",\n    largeContextModelLabel: \"大規模コンテキストモデル\",\n    largeContextModelDesc: \"大きなドキュメントの処理に使用 - Geminiを推奨\",\n    embeddingModelLabel: \"Embeddingモデル\",\n    embeddingModelDesc: \"セマンティック検索とベクトルEmbeddingに使用\",\n    ttsModelLabel: \"音声合成モデル\",\n    ttsModelDesc: \"ポッドキャスト生成に使用\",\n    sttModelLabel: \"音声認識モデル\",\n    sttModelDesc: \"音声の書き起こしに使用\",\n    embeddingChangeTitle: \"Embeddingモデルの変更\",\n    embeddingChangeConfirm: \"Embeddingモデルを{from}から{to}に変更しようとしています。\",\n    rebuildRequired: \"重要：再構築が必要\",\n    rebuildReason: \"Embeddingモデルを変更する場合、一貫性を維持するためにすべての既存Embeddingを再構築する必要があります。再構築しないと、検索結果が不正確または不完全になる可能性があります。\",\n    whatHappensNext: \"次に起こること：\",\n    step1: \"デフォルトのEmbeddingモデルが更新されます\",\n    step2: \"既存のEmbeddingは再構築するまで変更されません\",\n    step3: \"新しいコンテンツは新しいEmbeddingモデルを使用します\",\n    step4: \"できるだけ早くEmbeddingを再構築してください\",\n    proceedToRebuildPrompt: \"今すぐ詳細設定ページで再構築を開始しますか？\",\n    changeModelOnly: \"モデルのみ変更\",\n    changeAndRebuild: \"変更して再構築へ\",\n    autoAssign: \"デフォルトを自動割り当て\",\n    autoAssigning: \"割り当て中...\",\n    autoAssignSuccess: \"{count}件のデフォルトモデルを自動的に割り当てました\",\n    autoAssignNoModels: \"割り当て可能なモデルがありません。先にモデルを同期してください。\",\n    autoAssignAlreadySet: \"すべてのデフォルトモデルは既に設定されています\",\n    testModel: \"モデルをテスト\",\n    testModelSuccess: \"モデルテスト成功\",\n    testModelFailed: \"モデルテスト失敗\",\n    searchOrAddModel: \"検索またはモデル名を入力...\",\n    addCustomModel: \"\\\"{name}\\\" を追加\",\n  },\n  apiKeys: {\n    title: \"独自のAPIキーでAIを設定\",\n    description: \"APIキーをデータベースに安全に保存し、Open NotebookでAIプロバイダーを有効にします。\",\n    encryptionRequired: \"暗号化キーが設定されていません\",\n    encryptionRequiredDescription: \"OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数に任意の秘密文字列を設定して、データベースへのAPIキーの保存を有効にしてください。\",\n    configured: \"設定済み\",\n    notConfigured: \"未設定\",\n    migrationAvailable: \"環境変数を検出\",\n    migrationDescription: \"{count}個のAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行できます。\",\n    migrateToDatabase: \"データベースに移行\",\n    migrating: \"移行中...\",\n    migrationSuccess: \"{count}個のAPIキーを移行しました\",\n    migrationErrors: \"{count}個のキーの移行に失敗しました\",\n    migrationNothingToMigrate: \"すべてのキーはすでにデータベースにあります\",\n    learnMore: \"APIキーの設定方法を確認 →\",\n    testConnection: \"接続テスト\",\n    testSuccess: \"接続成功\",\n    testFailed: \"接続テストに失敗\",\n    syncModels: \"モデル同期\",\n    syncSuccess: \"{discovered} モデルを発見、{new} 個を新規追加\",\n    syncNoNew: \"{count} モデルを発見、すべて登録済み\",\n    syncFailed: \"モデルの同期に失敗\",\n    getApiKey: \"APIキーを取得\",\n    vertexProject: \"GCPプロジェクトID\",\n    vertexLocation: \"リージョン\",\n    vertexCredentials: \"サービスアカウントJSONパス\",\n    addConfig: \"設定を追加\",\n    editConfig: \"設定を編集\",\n    deleteConfig: \"設定を削除\",\n    configName: \"設定名\",\n    configNameHint: \"この設定の説明的な名前（例：本番環境、開発環境）\",\n    baseUrl: \"ベースURL\",\n    baseUrlOverrideHint: \"プロバイダーのデフォルト API エンドポイントを上書きする場合のみ変更してください。\",\n    deleteConfigConfirm: \"「{name}」を削除してもよろしいですか？この操作は元に戻せません。\",\n    configSaveSuccess: \"設定が正常に保存されました\",\n    configUpdateSuccess: \"設定が正常に変更されました\",\n    configDeleteSuccess: \"設定が正常に削除されました\",\n    apiKeyEditHint: \"既存のAPIキーを維持するには空白のままにしてください\",\n  },\n  setupBanner: {\n    encryptionRequired: \"暗号化キーが設定されていません\",\n    encryptionRequiredDescription: \"OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数を設定して、安全な認証情報の保存を有効にしてください。\",\n    migrationAvailable: \"APIキーの移行が可能です\",\n    migrationDescription: \"{count} 個のプロバイダーのAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行してください。\",\n    goToSettings: \"設定へ移動\",\n    viewDocs: \"ドキュメントを見る\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/pt-BR/index.ts",
    "content": "export const ptBR = {\n  common: {\n    search: \"Buscar...\",\n    create: \"Novo\",\n    new: \"Novo\",\n    cancel: \"Cancelar\",\n    delete: \"Excluir\",\n    edit: \"Editar\",\n    theme: \"Tema\",\n    signOut: \"Sair\",\n    noMatches: \"Nenhum resultado encontrado\",\n    tryDifferentSearch: \"Tente usar um termo de busca diferente.\",\n    light: \"Claro\",\n    dark: \"Escuro\",\n    system: \"Sistema\",\n    loading: \"Carregando...\",\n    note: \"Nota\",\n    insight: \"Insight\",\n    newSource: \"Nova Fonte\",\n    newNotebook: \"Novo Caderno\",\n    newPodcast: \"Novo Podcast\",\n    language: \"Idioma\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"Fonte\",\n    notebook: \"Caderno\",\n    podcast: \"Podcast\",\n    quickActions: \"Ações rápidas\",\n    quickActionsDesc: \"Navegação, busca, perguntar, tema\",\n    appName: \"Open Notebook\",\n    add: \"Adicionar\",\n    remove: \"Remover\",\n    confirm: \"Confirmar\",\n    warning: \"Aviso\",\n    error: \"Erro\",\n    success: \"Sucesso\",\n    model: \"Modelo\",\n    back: \"Voltar\",\n    next: \"Próximo\",\n    done: \"Concluído\",\n    processing: \"Processando...\",\n    creating: \"Criando...\",\n    linked: \"Vinculado\",\n    adding: \"Adicionando...\",\n    addSelected: \"Adicionar Selecionados\",\n    customModel: \"Modelo Personalizado\",\n    failed: \"falhou\",\n    current: \"Atual\",\n    save: \"Salvar\",\n    writeNote: \"Escrever Nota\",\n    batchMode: \"Modo em Lote\",\n    optional: \"Opcional\",\n    type: \"Tipo\",\n    title: \"Título\",\n    created: \"Criado {time}\",\n    updated: \"Atualizado {time}\",\n    actions: \"Ações\",\n    noResults: \"Sem resultados\",\n    references: \"Referências\",\n    refreshPage: \"Por favor, tente atualizar a página\",\n    refresh: \"Atualizar\",\n    aiGenerated: \"Gerado por IA\",\n    human: \"Humano\",\n    unknown: \"Desconhecido\",\n    notes: \"Notas\",\n    chat: \"Chat\",\n    deleteForever: \"Excluir Permanentemente\",\n    connectionError: \"Erro de Conexão\",\n    unableToConnect: \"Não foi possível conectar ao servidor da API\",\n    retryConnection: \"Tentar Novamente\",\n    diagnosticInfo: \"Informações de Diagnóstico\",\n    version: \"Versão\",\n    built: \"Compilado\",\n    apiUrl: \"URL da API\",\n    frontendUrl: \"URL do Frontend\",\n    checkConsoleLogs: \"Verifique o console do navegador para logs detalhados (procure por mensagens 🔧 [Config])\",\n    yes: \"Sim\",\n    no: \"Não\",\n    saving: \"Salvando...\",\n    description: \"Descrição\",\n    saveToNote: \"Salvar em nota\",\n    copyToClipboard: \"Copiar para área de transferência\",\n    close: \"Fechar\",\n    insights: \"Insights\",\n    progress: \"Progresso\",\n    deleting: \"Excluindo...\",\n    created_label: \"Criado\",\n    updated_label: \"Atualizado\",\n    download: \"Baixar\",\n    saveChanges: \"Salvar Alterações\",\n    name: \"Nome\",\n    default: \"Padrão\",\n    nameRequired: \"Nome é obrigatório\",\n    modelConfiguration: \"Configuração do Modelo\",\n    resetToDefault: \"Restaurar Padrão\",\n    reasoning: \"Raciocínio\",\n    searchTerms: \"Termos de Busca\",\n    strategy: \"Estratégia\",\n    individualAnswers: \"Respostas Individuais ({count})\",\n    finalAnswer: \"Resposta Final\",\n    notebookLabel: \"Caderno: {name}\",\n    itemNotFound: \"Este {type} não foi encontrado\",\n    accessibility: {\n      transformationViews: \"Visualizações de transformação\",\n      searchKB: \"Perguntar ou buscar na base de conhecimento\",\n      enterQuestion: \"Digite sua pergunta para a base de conhecimento\",\n      enterSearch: \"Digite sua busca\",\n      searchKBBtn: \"Buscar na base de conhecimento\",\n      podcastViews: \"Visualizações de podcast\",\n      ytVideo: \"Vídeo do YouTube\",\n      askResponse: \"Resposta da Consulta\",\n      searchNotebooks: \"Buscar cadernos\",\n    },\n    url: \"URL\",\n    errorDetails: \"Detalhes do Erro\",\n    editTransformation: \"Editar Transformação\",\n    retry: \"Tentar Novamente\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"concluído\",\n    saveSuccess: \"Salvo com sucesso\",\n    contextModes: {\n      off: \"Não incluído no chat\",\n      insights: \"Apenas insights\",\n      full: \"Conteúdo completo\",\n      clickToCycle: \"Clique para alternar\",\n    },\n    clickToEdit: \"Clique para editar\",\n  },\n  apiErrors: {\n    notebookNotFound: \"Caderno não encontrado\",\n    sourceNotFound: \"Fonte não encontrada\",\n    transformationNotFound: \"Transformação não encontrada\",\n    fileUploadFailed: \"Falha no upload do arquivo\",\n    urlRequired: \"URL é obrigatória para tipo link\",\n    contentRequired: \"Conteúdo é obrigatório para tipo texto\",\n    invalidSourceType: \"Tipo de fonte inválido\",\n    processingFailed: \"Processamento falhou\",\n    failedToQueue: \"Falha ao enfileirar processamento\",\n    invalidSortBy: \"Campo de ordenação deve ser 'created' ou 'updated'\",\n    invalidSortOrder: \"Ordem deve ser 'asc' ou 'desc'\",\n    accessDenied: \"Acesso ao arquivo negado\",\n    fileNotFoundOnServer: \"Arquivo não encontrado no servidor\",\n    searchFailed: \"Busca falhou\",\n    askFailed: \"Consulta falhou\",\n    pleaseEnterQuestion: \"Por favor, digite uma pergunta\",\n    pleaseConfigureModels: \"Por favor, configure todos os modelos necessários\",\n    failedToCreateSession: \"Falha ao criar sessão\",\n    failedToUpdateSession: \"Falha ao atualizar sessão\",\n    failedToDeleteSession: \"Falha ao excluir sessão\",\n    failedToSendMessage: \"Falha ao enviar mensagem\",\n    unauthorized: \"Acesso não autorizado, verifique sua senha\",\n    invalidPassword: \"Senha inválida\",\n    embeddingModelRequired: \"Este recurso requer um modelo de embedding. Configure um na seção Modelos.\",\n    strategyModelNotFound: \"Modelo de estratégia não encontrado\",\n    answerModelNotFound: \"Modelo de resposta não encontrado\",\n    finalAnswerModelNotFound: \"Modelo de resposta final não encontrado\",\n    noAnswerGenerated: \"Nenhuma resposta pôde ser gerada\",\n    genericError: \"Ocorreu um erro inesperado\",\n  },\n  connectionErrors: {\n    apiTitle: \"Não foi possível conectar ao servidor da API\",\n    apiDesc: \"O servidor da API do Open Notebook não pôde ser alcançado\",\n    dbTitle: \"Falha na conexão com o banco de dados\",\n    dbDesc: \"O servidor da API está rodando, mas o banco de dados não está acessível\",\n    troubleshooting: \"Isso geralmente significa:\",\n    apiUnreachable1: \"O servidor da API não está rodando\",\n    apiUnreachable2: \"O servidor da API está rodando em um endereço diferente\",\n    apiUnreachable3: \"Problemas de conectividade de rede\",\n    dbFailed1: \"SurrealDB não está rodando\",\n    dbFailed2: \"Configurações de conexão do banco de dados estão incorretas\",\n    dbFailed3: \"Problemas de rede entre API e banco de dados\",\n    quickFixes: \"Soluções rápidas:\",\n    setApiUrl: \"Defina a variável de ambiente API_URL:\",\n    checkSurreal: \"Verifique se o SurrealDB está rodando:\",\n    seeDocumentation: \"Para instruções detalhadas de configuração, veja:\",\n    docLink: \"Documentação do Open Notebook\",\n    showTechnical: \"Mostrar Detalhes Técnicos\",\n    attemptedUrl: \"URL Tentada\",\n    message: \"Mensagem\",\n    technicalDetails: \"Detalhes Técnicos\",\n    stackTrace: \"Stack Trace\",\n    retryLabel: \"Tentar Conexão Novamente\",\n    retryHint: \"Pressione R ou clique no botão para tentar novamente\",\n    dockerLabel: \"Para Docker\",\n    localDevLabel: \"Para desenvolvimento local\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"Digite sua senha para acessar o aplicativo\",\n    passwordPlaceholder: \"Senha\",\n    signingIn: \"Entrando...\",\n    signIn: \"Entrar\",\n    connectErrorHint: \"Não foi possível conectar ao servidor. Verifique se a API está rodando.\",\n  },\n  navigation: {\n    collect: \"Coletar\",\n    process: \"Processar\",\n    create: \"Criar\",\n    manage: \"Gerenciar\",\n    sources: \"Fontes\",\n    notebooks: \"Cadernos\",\n    askAndSearch: \"Perguntar e Buscar\",\n    podcasts: \"Podcasts\",\n    models: \"Modelos\",\n    transformations: \"Transformações\",\n    transformation: \"Transformação\",\n    settings: \"Configurações\",\n    advanced: \"Avançado\",\n    nav: \"Navegação\",\n    language: \"Alternar idioma\",\n    theme: \"Tema\",\n    ask: \"Perguntar\",\n  },\n  notebooks: {\n    title: \"Cadernos\",\n    newNotebook: \"Novo Caderno\",\n    searchPlaceholder: \"Buscar cadernos...\",\n    archived: \"Arquivado\",\n    archive: \"Arquivar\",\n    unarchive: \"Desarquivar\",\n    deleteNotebook: \"Excluir Caderno\",\n    deleteNotebookDesc: \"Tem certeza que deseja excluir \\\"{name}\\\"? Esta ação não pode ser desfeita.\",\n    deleteNotebookLoading: \"Carregando prévia da exclusão...\",\n    deleteNotebookNotes: \"{count} nota(s) serão permanentemente excluídas.\",\n    deleteNotebookNoNotes: \"Nenhuma nota para excluir.\",\n    deleteNotebookExclusiveSources: \"{count} fonte(s) existem apenas neste caderno.\",\n    deleteNotebookSharedSources: \"{count} fonte(s) são compartilhadas com outros cadernos e serão desvinculadas.\",\n    deleteNotebookNoSources: \"Nenhuma fonte neste caderno.\",\n    deleteExclusiveSourcesLabel: \"Excluir fontes exclusivas\",\n    keepExclusiveSourcesLabel: \"Desvincular e manter\",\n    activeNotebooks: \"Cadernos Ativos\",\n    archivedNotebooks: \"Cadernos Arquivados\",\n    notFound: \"Caderno não encontrado\",\n    notFoundDesc: \"O caderno solicitado não existe.\",\n    updated: \"Atualizado\",\n    namePlaceholder: \"Nome do caderno\",\n    addDescription: \"Adicionar descrição...\",\n    noNotesYet: \"Nenhuma nota ainda\",\n    deleteNote: \"Excluir Nota\",\n    deleteNoteConfirm: \"Tem certeza que deseja excluir esta nota? Esta ação não pode ser desfeita.\",\n    noteCreatedSuccess: \"Nota criada com sucesso\",\n    failedToCreateNote: \"Falha ao criar nota\",\n    noteUpdatedSuccess: \"Nota atualizada com sucesso\",\n    failedToUpdateNote: \"Falha ao atualizar nota\",\n    noteDeletedSuccess: \"Nota excluída com sucesso\",\n    failedToDeleteNote: \"Falha ao excluir nota\",\n    createNew: \"Criar Novo Caderno\",\n    createNewDesc: \"Digite um nome e uma descrição opcional para começar.\",\n    descPlaceholder: \"Adicione mais informações sobre este caderno aqui...\",\n    createSuccess: \"Caderno criado com sucesso\",\n    updateSuccess: \"Caderno atualizado com sucesso\",\n    deleteSuccess: \"Caderno excluído com sucesso\",\n  },\n  sources: {\n    title: \"Fontes\",\n    add: \"Adicionar Fonte\",\n    addNew: \"Adicionar Nova Fonte\",\n    addExisting: \"Adicionar Fonte Existente\",\n    delete: \"Excluir Fonte\",\n    statusPreparing: \"Preparando\",\n    statusQueued: \"Na Fila\",\n    statusProcessing: \"Processando\",\n    statusCompleted: \"Concluído\",\n    statusFailed: \"Falhou\",\n    statusPreparingDesc: \"Preparando para processar\",\n    statusQueuedDesc: \"Aguardando processamento\",\n    statusProcessingDesc: \"Sendo processado\",\n    statusCompletedDesc: \"Processado com sucesso\",\n    statusFailedDesc: \"Processamento falhou\",\n    failedToLoad: \"Falha ao carregar fontes\",\n    allSourcesDesc: \"Veja todas as suas fontes aqui. Você pode adicionar novas fontes ou gerenciar as existentes.\",\n    allSources: \"Todas as Fontes\",\n    insights: \"Insights\",\n    yes: \"Sim\",\n    no: \"Não\",\n    loadingMore: \"Carregando mais...\",\n    noSourcesYet: \"Nenhuma fonte ainda\",\n    allSourcesDescShort: \"Veja todas as suas fontes aqui.\",\n    cannotSaveNoteNoNotebook: \"Não é possível salvar nota: ID do caderno não disponível\",\n    createFirstSource: \"Adicione sua primeira fonte para começar a construir sua base de conhecimento.\",\n    deleteSourceConfirm: \"Tem certeza que deseja excluir esta fonte?\",\n    deleteConfirm: \"Tem certeza que deseja excluir isto?\",\n    deleteConfirmWithTitle: \"Tem certeza que deseja excluir \\\"{title}\\\"?\",\n    deleteSuccess: \"Fonte excluída com sucesso. Nota: Para excluir o arquivo do armazenamento, você deve habilitar a opção \\\"excluir arquivo\\\" na página de configurações.\",\n    failedToDelete: \"Falha ao excluir fonte\",\n    sourceQueued: \"Fonte Enfileirada\",\n    sourceQueuedDesc: \"Fonte enviada para processamento em segundo plano. Você pode monitorar o progresso na lista de fontes.\",\n    sourceAddedSuccess: \"Fonte adicionada com sucesso\",\n    failedToAddSource: \"Falha ao adicionar fonte\",\n    sourceUpdatedSuccess: \"Fonte atualizada com sucesso\",\n    failedToUpdateSource: \"Falha ao atualizar fonte\",\n    sourceDeletedSuccess: \"Fonte excluída com sucesso\",\n    failedToDeleteSource: \"Falha ao excluir fonte\",\n    fileUploadedSuccess: \"Arquivo enviado com sucesso\",\n    failedToUploadFile: \"Falha ao enviar arquivo\",\n    sourceRequeued: \"Fonte Reenfileirada\",\n    sourceRequeuedDesc: \"A fonte foi reenfileirada para processamento.\",\n    failedToRetry: \"Falha ao Tentar Novamente\",\n    sourcesAddedToNotebook: \"{count} fonte(s) adicionada(s) ao caderno\",\n    failedToAddSourcesToNotebook: \"Falha ao adicionar fontes ao caderno\",\n    partialAddSuccess: \"{success} fonte(s) adicionada(s), {failed} falhou(aram)\",\n    sourceRemovedFromNotebook: \"Fonte removida do caderno com sucesso\",\n    failedToRemoveSourceFromNotebook: \"Falha ao remover fonte do caderno\",\n    removeConfirm: \"Tem certeza que deseja remover isto do caderno?\",\n    checking: \"Verificando...\",\n    untitledSource: \"Fonte Sem Título\",\n    maxItems: \"máx {count}\",\n    insightsCount: \"{count} insights\",\n    details: \"Detalhes\",\n    detailsTitle: \"Detalhes da Fonte\",\n    content: \"Conteúdo\",\n    metadata: \"Metadados\",\n    type: {\n      link: \"Link\",\n      file: \"Arquivo\",\n      text: \"Texto\",\n    },\n    id: \"ID da Fonte\",\n    topics: \"Tópicos\",\n    embedded: \"Incorporado\",\n    notEmbedded: \"Não Incorporado\",\n    embedContent: \"Incorporar Conteúdo\",\n    embedding: \"Incorporando...\",\n    alreadyEmbedded: \"Já Incorporado\",\n    downloadFile: \"Baixar Arquivo\",\n    fileUnavailable: \"Arquivo indisponível\",\n    preparing: \"Preparando...\",\n    generateNewInsight: \"Gerar Novo Insight\",\n    selectTransformation: \"Selecione uma transformação...\",\n    noInsightsYet: \"Nenhum insight ainda\",\n    createFirstInsight: \"Crie seu primeiro insight usando uma transformação acima\",\n    viewInsight: \"Ver Insight\",\n    deleteInsight: \"Excluir Insight\",\n    deleteInsightConfirm: \"Tem certeza que deseja excluir este insight? Esta ação não pode ser desfeita.\",\n    insightGenerationStarted: \"Geração de insight iniciada. Aparecerá em breve.\",\n    editNote: \"Editar nota\",\n    createNote: \"Criar nota\",\n    addTitle: \"Adicionar título...\",\n    untitledNote: \"Nota Sem Título\",\n    writeNotePlaceholder: \"Escreva o conteúdo da sua nota aqui...\",\n    saveNote: \"Salvar Nota\",\n    createNoteBtn: \"Criar Nota\",\n    createFirstNote: \"Crie sua primeira nota para capturar insights e observações.\",\n    urlLabel: \"URL(s) *\",\n    fileLabel: \"Arquivo(s) *\",\n    textContentLabel: \"Conteúdo de Texto *\",\n    enterUrlsPlaceholder: \"Digite as URLs, uma por linha\\nhttps://exemplo.com/artigo1\\nhttps://exemplo.com/artigo2\",\n    batchUrlHint: \"Cole múltiplas URLs (uma por linha) para importação em lote\",\n    invalidUrlsDetected: \"URLs inválidas detectadas:\",\n    lineLabel: \"Linha {line}\",\n    fixInvalidUrls: \"Por favor, corrija ou remova as URLs inválidas para continuar\",\n    selectMultipleFilesHint: \"Selecione múltiplos arquivos para importação em lote. Suportados: Documentos (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Mídia (MP4, MP3, WAV, M4A), Imagens (JPG, PNG), Arquivos (ZIP)\",\n    selectedFiles: \"Arquivos selecionados:\",\n    textPlaceholder: \"Cole ou digite seu conteúdo aqui...\",\n    htmlDetected: \"Conteúdo HTML detectado. Será convertido para Markdown após o processamento.\",\n    titlePlaceholder: \"Dê um título descritivo para sua fonte\",\n    batchTitlesAuto: \"Os títulos serão gerados automaticamente para cada fonte.\",\n    batchCommonSettings: \"Os mesmos cadernos e transformações serão aplicados a todos os itens.\",\n    urlsCount: \"{count} URL(s)\",\n    filesCount: \"{count} arquivo(s)\",\n    addSource: \"Adicionar Fonte\",\n    notEmbeddedAlert: \"Conteúdo Não Incorporado\",\n    notEmbeddedDesc: \"Este conteúdo não foi incorporado para busca vetorial. A incorporação habilita recursos avançados de busca e melhor descoberta de conteúdo.\",\n    openOnYoutube: \"Abrir no YouTube\",\n    urlCopied: \"URL copiada para área de transferência\",\n    viewSource: \"Ver Fonte\",\n    noInsightSelected: \"Nenhum insight selecionado\",\n    sourceInsight: \"Insight da Fonte\",\n    manageNotebooks: \"Gerenciar Cadernos\",\n    manageNotebooksDesc: \"Gerencie quais cadernos contêm esta fonte\",\n    noNotebooksAvailable: \"Nenhum caderno disponível\",\n    loadFailed: \"Falha ao carregar detalhes da fonte\",\n    removeFromNotebook: \"Remover do Caderno\",\n    retryProcessing: \"Tentar Processamento Novamente\",\n    deleteSource: \"Excluir Fonte\",\n    retry: \"Tentar Novamente\",\n    addExistingTitle: \"Adicionar Fontes Existentes\",\n    addExistingDesc: \"Selecione fontes existentes de todos os seus cadernos para adicionar ao atual.\",\n    searchPlaceholder: \"Buscar fontes por nome ou URL...\",\n    noNotebooksFound: \"Nenhum caderno encontrado.\",\n    showingFirst100: \"Mostrando as primeiras 100 fontes. Use a busca para encontrar específicas.\",\n    selectedCount: \"{count} fontes selecionadas\",\n    added: \"Adicionado em {date}\",\n    addUrl: \"Adicionar URL\",\n    uploadFile: \"Enviar Arquivo\",\n    enterText: \"Inserir Texto\",\n    processDescription: \"O conteúdo será processado e analisado por IA.\",\n    processingFiles: \"Processando seus arquivos...\",\n    titleRequired: \"Um título é obrigatório para conteúdo de texto\",\n    titleGenerated: \"Se deixado vazio, um título será gerado a partir do conteúdo\",\n    batchCount: \"{count} {type} serão processados\",\n    enableEmbedding: \"Habilitar incorporação para busca\",\n    embeddingDesc: \"Permite que esta fonte seja encontrada em buscas vetoriais e consultas de IA\",\n    embeddingAlways: \"Incorporação habilitada automaticamente\",\n    embeddingAlwaysDesc: \"Suas configurações estão definidas para sempre incorporar conteúdo para busca vetorial.\",\n    embeddingNever: \"Incorporação desabilitada\",\n    embeddingNeverDesc: \"Suas configurações estão definidas para pular incorporação. Busca vetorial não estará disponível para esta fonte.\",\n    changeInSettings: \"Você pode alterar isso em Configurações\",\n    notFound: \"Fonte não encontrada\",\n    noContent: \"Nenhum conteúdo disponível\",\n    insightsDesc: \"Insights gerados a partir da análise do modelo\",\n    uploadedFile: \"Arquivo enviado\",\n    fileUnavailableDesc: \"Este arquivo está indisponível no momento por razões do sistema de armazenamento.\",\n    batchSuccess: \"{count} fonte(s) criada(s) com sucesso\",\n    batchFailed: \"Falha ao criar todas as {count} fontes\",\n    batchPartial: \"{success} sucesso, {failed} falhou(aram)\",\n    submittingSource: \"Enviando fonte para processamento...\",\n    processingBatchSources: \"Processando {count} fontes. Isso pode levar alguns momentos.\",\n    processingSource: \"Sua fonte está sendo processada. Isso pode levar alguns momentos.\",\n    maxFilesAllowed: \"Máximo de {count} arquivos permitidos por lote\",\n  },\n  chat: {\n    sessions: \"Sessões\",\n    sessionTitlePlaceholder: \"Digite um título aqui...\",\n    noSessions: \"Nenhuma sessão de chat ainda\",\n    deleteSession: \"Excluir Sessão\",\n    deleteSessionDesc: \"Tem certeza que deseja excluir esta sessão de chat? Esta ação não pode ser desfeita.\",\n    sendPlaceholder: \"Pergunte qualquer coisa sobre suas fontes...\",\n    sessionsTitle: \"Sessões de Chat\",\n    chatWith: \"Conversar com {name}\",\n    startConversation: \"Inicie uma conversa sobre este {type}\",\n    askQuestions: \"Faça perguntas para entender melhor o conteúdo\",\n    pressToSend: \"Pressione {key} para enviar\",\n    model: \"Modelo\",\n    createToStart: \"Crie uma sessão para começar.\",\n    chatWithNotebook: \"Conversar com Caderno\",\n    unableToLoadChat: \"Não foi possível carregar o chat\",\n    noDescription: \"Sem descrição\",\n    startByCreating: \"Comece criando seu primeiro caderno para organizar sua pesquisa.\",\n    messagesCount: \"{count} mensagens\",\n    sessionCreated: \"Sessão de chat criada\",\n    sessionUpdated: \"Sessão atualizada\",\n    sessionDeleted: \"Sessão excluída\",\n  },\n  searchPage: {\n    askAndSearch: \"Perguntar e Buscar\",\n    chooseAMode: \"Escolha um modo\",\n    askBeta: \"Perguntar (beta)\",\n    search: \"Buscar\",\n    askYourKb: \"Pergunte à Sua Base de Conhecimento (beta)\",\n    askYourKbDesc: \"O LLM responderá sua consulta com base nos documentos da sua base de conhecimento.\",\n    question: \"Pergunta\",\n    enterQuestionPlaceholder: \"Digite sua pergunta...\",\n    pressToSubmit: \"Pressione Cmd/Ctrl+Enter para enviar\",\n    noEmbeddingModel: \"Você não pode usar este recurso porque não tem um modelo de embedding selecionado. Configure um na página de Modelos.\",\n    usingCustomModels: \"Usando Modelos Personalizados\",\n    usingDefaultModels: \"Usando Modelos Padrão\",\n    advanced: \"Avançado\",\n    strategy: \"Estratégia\",\n    answer: \"Resposta\",\n    final: \"Final\",\n    ask: \"Perguntar\",\n    processing: \"Processando...\",\n    saveToNotebooks: \"Salvar em Cadernos\",\n    searchDesc: \"Busque em sua base de conhecimento por palavras-chave ou conceitos específicos\",\n    enterSearchPlaceholder: \"Digite sua busca...\",\n    pressToSearch: \"Pressione Enter para buscar\",\n    searchType: \"Tipo de Busca\",\n    vectorSearchWarning: \"Busca vetorial requer um modelo de embedding. Apenas busca por texto está disponível.\",\n    textSearch: \"Busca por Texto\",\n    vectorSearch: \"Busca Vetorial\",\n    searchIn: \"Buscar Em\",\n    searchSources: \"Buscar Fontes\",\n    searchNotes: \"Buscar Notas\",\n    resultsFound: \"{count} resultados encontrados\",\n    matches: \"Correspondências ({count})\",\n    noResultsFor: \"Nenhum resultado encontrado para \\\"{query}\\\"\",\n    notSet: \"Não definido\",\n    saveToNotebook: \"Salvar no Caderno\",\n    saveSuccess: \"Salvo no caderno com sucesso\",\n    saveError: \"Falha ao salvar no caderno\",\n    selectNotebook: \"Selecionar Caderno\",\n    searchAndAsk: \"Buscar e Perguntar\",\n    searchResultsFor: \"Resultados da busca para \\\"{query}\\\"\",\n    askAbout: \"Perguntar sobre \\\"{query}\\\"\",\n    orSearchKb: \"Ou busque em sua base de conhecimento\",\n    saving: \"Salvando...\",\n    advancedModelTitle: \"Seleção Avançada de Modelo\",\n    advancedModelDesc: \"Escolha modelos específicos para cada etapa do processo de Perguntar\",\n    strategyModel: \"Modelo de Estratégia\",\n    answerModel: \"Modelo de Resposta\",\n    finalAnswerModel: \"Modelo de Resposta Final\",\n    selectStrategyPlaceholder: \"Selecione o modelo de estratégia\",\n    selectAnswerPlaceholder: \"Selecione o modelo de resposta\",\n    selectFinalPlaceholder: \"Selecione o modelo de resposta final\",\n    saveChanges: \"Salvar Alterações\",\n    processingQuestion: \"Processando sua pergunta...\",\n  },\n  podcasts: {\n    generateEpisode: \"Gerar Episódio de Podcast\",\n    generateEpisodeDesc: \"Selecione o conteúdo a incluir e configure os detalhes do episódio antes de gerar um novo episódio de podcast.\",\n    content: \"Conteúdo\",\n    contentDesc: \"Escolha cadernos, fontes e notas para incluir neste episódio.\",\n    itemsSelected: \"{count} itens selecionados\",\n    tokens: \"{count} tokens\",\n    chars: \"{count} caracteres\",\n    loadingNotebooks: \"Carregando cadernos...\",\n    noNotebooksFoundInPodcasts: \"Nenhum caderno encontrado. Crie um caderno e adicione conteúdo antes de gerar um podcast.\",\n    noContentSelected: \"Nenhum conteúdo selecionado\",\n    summary: \"Resumo\",\n    fullContent: \"Conteúdo completo\",\n    untitledSource: \"Fonte sem título\",\n    untitledNote: \"Nota sem título\",\n    episodeSettings: \"Configurações do Episódio\",\n    episodeProfile: \"Perfil do episódio\",\n    episodeProfilePlaceholder: \"Selecione um perfil de episódio\",\n    episodeName: \"Nome do episódio\",\n    episodeNamePlaceholder: \"ex., IA e o Futuro do Trabalho\",\n    additionalInstructions: \"Instruções adicionais\",\n    instructionsPlaceholder: \"Qualquer conselho suplementar para adicionar ao briefing do episódio...\",\n    generating: \"Gerando...\",\n    generate: \"Gerar\",\n    hostPlaceholder: \"Apresentador {number}\",\n    profileRequired: \"Perfil de Episódio Necessário\",\n    profileRequiredDesc: \"Selecione um perfil de episódio antes de gerar um podcast.\",\n    nameRequired: \"Nome do episódio necessário\",\n    nameRequiredDesc: \"Forneça um nome para o episódio.\",\n    addContext: \"Adicionar contexto\",\n    addContextDesc: \"Selecione pelo menos uma fonte ou nota para incluir no episódio.\",\n    generationFailed: \"Geração do podcast falhou\",\n    speakerProfile: \"Perfil do Locutor\",\n    usesSpeakerProfile: \"Usa perfil de locutor\",\n    sources: \"Fontes\",\n    notes: \"Notas\",\n    noSources: \"Nenhuma fonte disponível neste caderno.\",\n    noNotes: \"Nenhuma nota disponível neste caderno.\",\n    selectMode: \"Selecionar modo\",\n    buildContextFailed: \"Falha ao construir contexto. Por favor, revise suas seleções.\",\n    podcastTaskStarted: \"Tarefa de podcast iniciada\",\n    loadingProfiles: \"Carregando perfis de episódio...\",\n    noProfilesFound: \"Nenhum perfil de episódio encontrado. Crie um perfil de episódio antes de gerar um podcast.\",\n    listTitle: \"Podcasts\",\n    listDesc: \"Acompanhe episódios gerados e gerencie perfis reutilizáveis.\",\n    chooseAView: \"Escolha uma visualização\",\n    episodesTab: \"Episódios\",\n    templatesTab: \"Perfis\",\n    overviewTitle: \"Visão geral dos episódios\",\n    overviewDesc: \"Monitore trabalhos de geração de podcast e revise os artefatos finais.\",\n    generateBtn: \"Gerar Podcast\",\n    total: \"Total\",\n    processingLabel: \"Processando\",\n    completedLabel: \"Concluídos\",\n    failedLabel: \"Falharam\",\n    pendingLabel: \"Pendentes\",\n    loadErrorTitle: \"Falha ao carregar episódios\",\n    loadErrorDesc: \"Não foi possível buscar os episódios de podcast mais recentes. Tente novamente em breve.\",\n    loadingEpisodes: \"Carregando episódios…\",\n    noEpisodesYet: \"Nenhum episódio de podcast ainda. Gere seu primeiro a partir das interfaces de chat de caderno ou fonte.\",\n    statusRunningTitle: \"Processando Atualmente\",\n    statusRunningDesc: \"Episódios que estão gerando ativos ativamente.\",\n    statusPendingTitle: \"Na Fila / Pendentes\",\n    statusPendingDesc: \"Episódios enviados aguardando início do processamento.\",\n    statusCompletedTitle: \"Episódios Concluídos\",\n    statusCompletedDesc: \"Prontos para revisar, baixar ou publicar.\",\n    statusFailedTitle: \"Episódios com Falha\",\n    statusFailedDesc: \"Episódios que encontraram problemas durante a geração.\",\n    templatesWorkspaceTitle: \"Área de trabalho de perfis\",\n    templatesWorkspaceDesc: \"Construa configurações de episódio e locutor reutilizáveis para produção rápida de podcasts.\",\n    howTemplatesPowerTitle: \"Como os perfis potencializam a geração de podcasts\",\n    howTemplatesPowerDesc: \"Os perfis dividem o fluxo de trabalho do podcast em dois blocos de construção reutilizáveis. Misture e combine-os sempre que gerar um novo episódio.\",\n    episodeProfilesSetFormat: \"Perfis de episódio definem o formato\",\n    episodeProfilesList1: \"Delineiam o número de segmentos e como a história flui\",\n    episodeProfilesList2: \"Escolhem os modelos de linguagem usados para briefing, outline e escrita do roteiro\",\n    episodeProfilesList3: \"Armazenam briefings padrão para que cada episódio comece com um tom consistente\",\n    speakerProfilesBringVoices: \"Perfis de locutor dão vida às vozes\",\n    speakerProfilesList1: \"Escolhem o provedor e modelo de text-to-speech\",\n    speakerProfilesList2: \"Capturam personalidade, história e notas de pronúncia por locutor\",\n    speakerProfilesList3: \"Reutilizam as mesmas vozes de apresentador ou convidado em diferentes formatos de episódio\",\n    recommendedWorkflow: \"Fluxo de trabalho recomendado\",\n    workflowStep1: \"Crie perfis de locutor para cada voz que você precisa\",\n    workflowStep2: \"Construa perfis de episódio que referenciam esses locutores pelo nome\",\n    workflowStep3: \"Gere podcasts selecionando o perfil de episódio que se encaixa na história\",\n    workflowHint: \"Perfis de episódio referenciam perfis de locutor pelo nome, então começar com locutores evita atribuições de voz faltantes depois.\",\n    failedToLoadTemplates: \"Falha ao carregar dados de perfis\",\n    failedToLoadTemplatesDesc: \"Certifique-se de que a API está rodando e tente novamente. Algumas seções podem estar incompletas.\",\n    loadingTemplates: \"Carregando perfis…\",\n    speakerProfilesTitle: \"Perfis de locutor\",\n    speakerProfilesDesc: \"Configure vozes e personalidades para episódios gerados.\",\n    createSpeaker: \"Criar locutor\",\n    noSpeakerProfiles: \"Nenhum perfil de locutor ainda. Crie um para disponibilizar perfis de episódio.\",\n    noDescription: \"Nenhuma descrição fornecida.\",\n    usedByCount_one: \"Usado por 1 episódio\",\n    usedByCount_other: \"Usado por {count} episódios\",\n    usedByCount: \"Usado por {count} episódios\",\n    unused: \"Não utilizado\",\n    voiceId: \"ID da Voz\",\n    backstory: \"História\",\n    personality: \"Personalidade\",\n    edit: \"Editar\",\n    duplicate: \"Duplicar\",\n    deleteSpeakerProfileTitle: \"Excluir perfil de locutor?\",\n    deleteSpeakerProfileDesc: \"Excluir \\\"{name}\\\" não pode ser desfeito.\",\n    deleteSpeakerDisabledHint: \"Remova este locutor dos perfis de episódio antes de excluí-lo.\",\n    deleting: \"Excluindo…\",\n    episodeProfilesTitle: \"Perfis de episódio\",\n    episodeProfilesDesc: \"Defina configurações de geração reutilizáveis para seus programas.\",\n    createProfile: \"Criar perfil\",\n    createSpeakerFirst: \"Crie um perfil de locutor antes de adicionar um perfil de episódio.\",\n    noEpisodeProfiles: \"Nenhum perfil de episódio ainda. Crie um para iniciar a geração de podcasts.\",\n    speakerCreated: \"Locutor Criado\",\n    speakerCreatedDesc: \"O locutor \\\"{name}\\\" foi adicionado com sucesso.\",\n    failedToCreateSpeaker: \"Falha ao criar perfil de locutor\",\n    speakerUpdated: \"Locutor Atualizado\",\n    speakerUpdatedDesc: \"O locutor \\\"{name}\\\" foi atualizado com sucesso.\",\n    failedToUpdateSpeaker: \"Falha ao atualizar perfil de locutor\",\n    speakerDeleted: \"Locutor Excluído\",\n    speakerDeletedDesc: \"O locutor \\\"{name}\\\" foi removido com sucesso.\",\n    failedToDeleteSpeaker: \"Falha ao excluir perfil de locutor\",\n    speakerDuplicated: \"Locutor Duplicado\",\n    speakerDuplicatedDesc: \"O locutor \\\"{name}\\\" foi duplicado com sucesso.\",\n    failedToDuplicateSpeaker: \"Falha ao duplicar perfil de locutor\",\n    generationStarted: \"Geração Iniciada\",\n    generationStartedDesc: \"A geração do podcast foi enfileirada.\",\n    failedToStartGeneration: \"Falha ao iniciar geração\",\n    tryAgainMoment: \"Por favor, tente novamente em um momento.\",\n    deleteProfileTitle: \"Excluir perfil?\",\n    deleteProfileDesc: \"Isso removerá \\\"{name}\\\". Episódios existentes mantêm seus dados, mas novos não usarão mais esta configuração.\",\n    profileCreated: \"Perfil Criado\",\n    profileCreatedDesc: \"O perfil de episódio \\\"{name}\\\" foi criado com sucesso.\",\n    failedToCreateProfile: \"Falha ao criar perfil\",\n    profileUpdated: \"Perfil Atualizado\",\n    profileUpdatedDesc: \"O perfil de episódio \\\"{name}\\\" foi atualizado com sucesso.\",\n    failedToUpdateProfile: \"Falha ao atualizar perfil\",\n    profileDeleted: \"Perfil Excluído\",\n    profileDeletedDesc: \"O perfil de episódio \\\"{name}\\\" foi removido com sucesso.\",\n    failedToDeleteProfile: \"Falha ao excluir perfil\",\n    failedToDeleteProfileDesc: \"Falha ao remover o perfil de episódio.\",\n    profileDuplicated: \"Perfil Duplicado\",\n    profileDuplicatedDesc: \"O perfil de episódio \\\"{name}\\\" foi duplicado com sucesso.\",\n    failedToDuplicateProfile: \"Falha ao duplicar perfil\",\n    episodeDeleted: \"Episódio Excluído\",\n    episodeDeletedDesc: \"O episódio foi excluído com sucesso.\",\n    failedToDeleteEpisode: \"Falha ao excluir episódio\",\n    failedToDeleteSpeakerDesc: \"Falha ao remover o perfil de locutor.\",\n    outlineModel: \"Modelo de outline\",\n    transcriptModel: \"Modelo de transcrição\",\n    segments: \"Segmentos\",\n    defaultBriefingTitle: \"Briefing padrão\",\n    created: \"Criado em {time}\",\n    details: \"Detalhes\",\n    summaryTab: \"Resumo\",\n    outlineTab: \"Outline\",\n    transcriptTab: \"Transcrição\",\n    briefing: \"Briefing\",\n    noOutline: \"Nenhum outline disponível.\",\n    noTranscript: \"Nenhuma transcrição disponível.\",\n    deleteEpisodeTitle: \"Excluir episódio?\",\n    deleteEpisodeDesc: \"Isso removerá \\\"{name}\\\" e seu arquivo de áudio permanentemente.\",\n    audioUnavailable: \"Áudio indisponível\",\n    segment: \"Segmento\",\n    speaker: \"Locutor\",\n    profile: \"Perfil\",\n    link: \"Link\",\n    file: \"Arquivo\",\n    embedded: \"Incorporado\",\n    notEmbedded: \"Não incorporado\",\n    noSpeakerProfilesAvailable: \"Nenhum perfil de locutor disponível\",\n    editEpisodeProfile: \"Editar Perfil de Episódio\",\n    createEpisodeProfile: \"Criar Perfil de Episódio\",\n    episodeProfileFormDesc: \"Defina como os episódios devem ser gerados e qual configuração de locutor usar por padrão.\",\n    noSpeakerProfilesDesc: \"Crie um perfil de locutor antes de configurar um perfil de episódio.\",\n    profileName: \"Nome do perfil\",\n    profileNamePlaceholder: \"ex., Discussão tech\",\n    descriptionPlaceholder: \"Breve resumo de quando usar este perfil\",\n    speakerConfig: \"Configuração de locutor\",\n    selectSpeakerProfile: \"Selecione um perfil de locutor\",\n    outlineGeneration: \"Geração de outline\",\n    transcriptGeneration: \"Geração de transcrição\",\n    defaultBriefingPlaceholder: \"Delineie a estrutura, tom e objetivos para este formato de episódio\",\n    editSpeakerProfile: \"Editar Perfil de Locutor\",\n    createSpeakerProfile: \"Criar Perfil de Locutor\",\n    speakerProfileFormDesc: \"Configure as configurações de text-to-speech e defina até quatro locutores.\",\n    speakers: \"Locutores\",\n    speakersDesc: \"Configure entre uma e quatro vozes para este perfil.\",\n    addSpeaker: \"Adicionar locutor\",\n    speakerNumber: \"Locutor {number}\",\n    backstoryPlaceholder: \"Breve biografia ou contexto para o locutor\",\n    personalityPlaceholder: \"Descreva estilo e tom\",\n    outlineModelRequired: \"Modelo de outline é obrigatório\",\n    transcriptModelRequired: \"Modelo de transcrição é obrigatório\",\n    defaultBriefingRequired: \"Briefing padrão é obrigatório\",\n    segmentsInteger: \"Deve ser um número inteiro\",\n    segmentsMin: \"Mínimo de 3 segmentos\",\n    segmentsMax: \"Máximo de 20 segmentos\",\n    voiceIdRequired: \"ID da voz é obrigatório\",\n    backstoryRequired: \"História é obrigatória\",\n    personalityRequired: \"Personalidade é obrigatória\",\n    speakerCountMin: \"Pelo menos um locutor é necessário\",\n    speakerCountMax: \"Você pode configurar até 4 locutores\",\n    delete: \"Excluir\",\n    failedToDelete: \"Falha ao excluir podcast\",\n    retry: \"Tentar novamente\",\n    retrying: \"Tentando novamente…\",\n    retryStarted: \"Nova tentativa iniciada\",\n    retryStartedDesc: \"Um novo trabalho de geração de podcast foi enviado.\",\n    failedToRetry: \"Falha ao tentar novamente\",\n    errorDetails: \"Detalhes do erro\",\n    language: \"Idioma\",\n    languagePlaceholder: \"Selecione um idioma (opcional)\",\n    podcastLanguage: \"Idioma do podcast\",\n    selectOutlineModel: \"Selecione o modelo de roteiro\",\n    selectTranscriptModel: \"Selecione o modelo de transcrição\",\n    voiceModel: \"Modelo de voz\",\n    voiceModelRequired: \"Modelo de voz é obrigatório\",\n    selectVoiceModel: \"Selecione o modelo de voz\",\n    perSpeakerTtsOverride: \"Override de TTS por speaker (opcional)\",\n    useProfileDefault: \"Usar padrão do perfil\",\n    setupRequired: \"Configuração necessária\",\n    setupRequiredDesc: \"Alguns perfis ainda não têm modelos configurados. Edite-os para selecionar modelos antes de gerar podcasts.\",\n    notConfigured: \"Não configurado\",\n  },\n  settings: {\n    contentProcessing: \"Processamento de Conteúdo\",\n    contentProcessingDesc: \"Configure como documentos e URLs são processados\",\n    docEngine: \"Motor de Processamento de Documentos\",\n    docEnginePlaceholder: \"Selecione o motor de processamento de documentos\",\n    urlEngine: \"Motor de Processamento de URL\",\n    urlEnginePlaceholder: \"Selecione o motor de processamento de URL\",\n    autoRecommended: \"Auto (Recomendado)\",\n    simple: \"Simples\",\n    docling: \"Docling\",\n    helpMeChoose: \"Ajude-me a escolher\",\n    docHelp: \"· Docling é um pouco mais lento, mas mais preciso, especialmente se os documentos contêm tabelas e imagens. · Simples extrairá qualquer conteúdo do documento sem formatá-lo. · Auto (recomendado) tentará processar através do docling e usará simples como fallback.\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl é um serviço pago (com tier gratuito), e muito poderoso. · Jina também é uma boa opção e também tem um tier gratuito. · Simples usará extração HTTP básica e perderá conteúdo em sites baseados em javascript. · Auto (recomendado) tentará usar firecrawl, depois Jina, e finalmente fallback para simples.\",\n    embeddingAndSearch: \"Embedding e Busca\",\n    embeddingAndSearchDesc: \"Configure opções de busca e embedding\",\n    defaultEmbeddingOption: \"Opção Padrão de Embedding\",\n    embeddingOptionPlaceholder: \"Selecione a opção de embedding\",\n    ask: \"Perguntar\",\n    always: \"Sempre\",\n    never: \"Nunca\",\n    embeddingHelp: \"Incorporar o conteúdo facilitará encontrá-lo por você e seus agentes de IA. Se você está rodando um modelo de embedding local (Ollama, por exemplo), não precisa se preocupar com custo e pode incorporar tudo.\",\n    fileManagement: \"Gerenciamento de Arquivos\",\n    fileManagementDesc: \"Configure opções de manipulação e armazenamento de arquivos\",\n    autoDeleteFiles: \"Excluir Arquivos Automaticamente\",\n    autoDeletePlaceholder: \"Selecione a opção de exclusão automática\",\n    filesHelp: \"Uma vez que seus arquivos são enviados e processados, eles não são mais necessários. A maioria dos usuários deve permitir que o Open Notebook exclua arquivos enviados da pasta de upload automaticamente.\",\n    loadFailed: \"Falha ao carregar configurações\",\n  },\n  advanced: {\n    title: \"Ferramentas Avançadas\",\n    desc: \"Ferramentas e utilitários avançados para usuários avançados\",\n    systemInfo: \"Informações do Sistema\",\n    rebuildEmbeddings: \"Reconstruir Embeddings\",\n    rebuildEmbeddingsDesc: \"Reconstruir índice de busca vetorial para todas as fontes\",\n    currentVersion: \"Versão Atual\",\n    latestVersion: \"Última Versão\",\n    status: \"Status\",\n    updateAvailable: \"Versão {version} Disponível\",\n    updateAvailableDesc: \"Uma nova versão do Open Notebook está disponível.\",\n    upToDate: \"Atualizado\",\n    unknown: \"Desconhecido\",\n    viewOnGithub: \"Ver no GitHub\",\n    updateCheckFailed: \"Não foi possível verificar atualizações. O GitHub pode estar inacessível.\",\n    rebuild: {\n      mode: \"Modo de Reconstrução\",\n      existing: \"Existentes\",\n      all: \"Todos\",\n      existingDesc: \"Re-incorporar apenas itens que já têm embeddings (mais rápido, para troca de modelo)\",\n      allDesc: \"Re-incorporar itens existentes + criar embeddings para itens sem nenhum (mais lento, abrangente)\",\n      include: \"Incluir na Reconstrução\",\n      selectOneError: \"Por favor, selecione pelo menos um tipo de item para reconstruir\",\n      starting: \"Iniciando Reconstrução...\",\n      startBtn: \"🚀 Iniciar Reconstrução\",\n      queued: \"Na Fila\",\n      running: \"Enviando jobs...\",\n      completed: \"Jobs Enviados!\",\n      failed: \"Falhou\",\n      leavePageHint: \"Você pode sair desta página pois isso será executado em segundo plano\",\n      startNew: \"Iniciar Nova Reconstrução\",\n      itemsProcessed: \"{processed}/{total} jobs enviados ({percent}%)\",\n      failedItems: \"{count} jobs falharam ao enviar\",\n      time: \"Tempo\",\n      whenToRebuild: \"Quando devo reconstruir embeddings?\",\n      whenToRebuildAns: \"Você deve reconstruir ao trocar modelos, atualizar versões, corrigir corrupção ou após importações em massa.\",\n      howLong: \"Quanto tempo leva a reconstrução?\",\n      howLongAns: \"O tempo de processamento depende da quantidade de itens, velocidade do modelo e limites de taxa da API. Modelos locais geralmente são muito rápidos.\",\n      isSafe: \"É seguro reconstruir enquanto usa o aplicativo?\",\n      isSafeAns: \"Sim, reconstruir é seguro! Não exclui conteúdo, apenas substitui embeddings, e lida com erros graciosamente.\",\n    },\n  },\n  transformations: {\n    title: \"Transformações\",\n    desc: \"Transformações são prompts que serão usados pelo LLM para processar uma fonte e extrair insights, resumos, etc.\",\n    workspace: \"Escolha um espaço de trabalho\",\n    playground: \"Playground\",\n    defaultPrompt: \"Prompt de Transformação Padrão\",\n    defaultPromptDesc: \"Isso será adicionado a todos os seus prompts de transformação\",\n    defaultPromptPlaceholder: \"Digite suas instruções padrão de transformação...\",\n    listTitle: \"Transformações Personalizadas\",\n    createNew: \"Criar Nova\",\n    inputLabel: \"Texto de Entrada\",\n    inputPlaceholder: \"Digite algum texto para transformar...\",\n    outputLabel: \"Saída\",\n    runTest: \"Executar Transformação\",\n    running: \"Executando...\",\n    selectToStart: \"Selecione uma transformação para começar\",\n    name: \"Nome\",\n    namePlaceholder: \"Identificador único, ex. topicos_principais\",\n    titlePlaceholder: \"Título exibido, usa o nome por padrão\",\n    promptPlaceholder: \"Escreva o prompt que vai alimentar esta transformação...\",\n    descriptionPlaceholder: \"Descreva o que esta transformação faz.\",\n    suggestDefault: \"Sugerir por padrão em novas fontes\",\n    promptHint: \"Prompts devem ser escritos com o conteúdo da fonte em mente. Você pode pedir ao modelo para resumir, extrair insights ou produzir saídas estruturadas como tabelas.\",\n    createSuccess: \"Transformação criada com sucesso\",\n    updateSuccess: \"Transformação atualizada com sucesso\",\n    deleteSuccess: \"Transformação excluída com sucesso\",\n    noTransformations: \"Nenhuma transformação ainda\",\n    createOne: \"Crie uma transformação para começar\",\n    selectModel: \"Selecione um modelo\",\n    deleteConfirm: \"Tem certeza que deseja excluir esta transformação?\",\n    model: \"Modelo\",\n    systemPrompt: \"Prompt do Sistema\",\n    overrideModelDesc: \"Substitua o modelo padrão para esta sessão de chat. Deixe vazio para usar o padrão do sistema.\",\n    sessionUseReplacement: \"Esta sessão usará {name} em vez do modelo padrão.\",\n    systemDefault: \"Padrão do Sistema\",\n  },\n  models: {\n    embedding: \"Modelos de Embedding\",\n    tts: \"Text to Speech (TTS)\",\n    stt: \"Speech to Text (STT)\",\n    apiKey: \"Chave da API\",\n    deleteSuccess: \"Modelo excluído com sucesso\",\n    saveSuccess: \"Modelo salvo com sucesso\",\n    noModels: \"Sem modelos\",\n    discoverModels: \"Descobrir Modelos\",\n    noModelsFound: \"Nenhum modelo encontrado para este provedor\",\n    modelType: \"Tipo do Modelo\",\n    modelTypeHint: \"Selecione o tipo para os modelos que deseja adicionar. Se precisar de tipos diferentes, adicione em lotes separados.\",\n    deleteModel: \"Excluir Modelo\",\n    defaultAssignments: \"Atribuições de Modelo Padrão\",\n    defaultAssignmentsDesc: \"Configure quais modelos usar para diferentes propósitos no Open Notebook\",\n    missingRequiredModels: \"Modelos obrigatórios ausentes: {models}. O Open Notebook pode não funcionar corretamente sem eles.\",\n    selectModelPlaceholder: \"Selecione um modelo\",\n    requiredModelPlaceholder: \"⚠️ Obrigatório - Selecione um modelo\",\n    chatModelLabel: \"Modelo de Chat\",\n    chatModelDesc: \"Usado para conversas de chat\",\n    transformationModelLabel: \"Modelo de Transformação\",\n    transformationModelDesc: \"Usado para resumos, insights e transformações\",\n    toolsModelLabel: \"Modelo de Ferramentas\",\n    toolsModelDesc: \"Usado para chamadas de função - OpenAI ou Anthropic recomendado\",\n    largeContextModelLabel: \"Modelo de Contexto Grande\",\n    largeContextModelDesc: \"Usado para processar documentos grandes - Gemini recomendado\",\n    embeddingModelLabel: \"Modelo de Embedding\",\n    embeddingModelDesc: \"Usado para busca semântica e embeddings vetoriais\",\n    ttsModelLabel: \"Modelo Text-to-Speech\",\n    ttsModelDesc: \"Usado para geração de podcast\",\n    sttModelLabel: \"Modelo Speech-to-Text\",\n    sttModelDesc: \"Usado para transcrição de áudio\",\n    embeddingChangeTitle: \"Alteração de Modelo de Embedding\",\n    embeddingChangeConfirm: \"Você está prestes a alterar seu modelo de embedding de {from} para {to}.\",\n    rebuildRequired: \"Importante: Reconstrução Necessária\",\n    rebuildReason: \"Alterar seu modelo de embedding requer reconstruir todos os embeddings existentes para manter a consistência. Sem reconstruir, suas buscas podem retornar resultados incorretos ou incompletos.\",\n    whatHappensNext: \"O que acontece em seguida:\",\n    step1: \"Seu modelo de embedding padrão será atualizado\",\n    step2: \"Embeddings existentes permanecerão inalterados até a reconstrução\",\n    step3: \"Novo conteúdo usará o novo modelo de embedding\",\n    step4: \"Você deve reconstruir os embeddings o mais rápido possível\",\n    proceedToRebuildPrompt: \"Gostaria de ir para a página Avançado para iniciar a reconstrução agora?\",\n    changeModelOnly: \"Apenas Alterar Modelo\",\n    changeAndRebuild: \"Alterar e Ir para Reconstrução\",\n    autoAssign: \"Atribuir Automaticamente\",\n    autoAssigning: \"Atribuindo...\",\n    autoAssignSuccess: \"{count} modelos padrão atribuídos automaticamente\",\n    autoAssignNoModels: \"Nenhum modelo disponível para atribuir. Por favor, sincronize os modelos primeiro.\",\n    autoAssignAlreadySet: \"Todos os modelos padrão já estão configurados\",\n    testModel: \"Testar Modelo\",\n    testModelSuccess: \"Teste do Modelo Passou\",\n    testModelFailed: \"Teste do Modelo Falhou\",\n    searchOrAddModel: \"Pesquisar ou digitar nome do modelo...\",\n    addCustomModel: \"Adicionar \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"Configure sua IA com suas próprias chaves de API\",\n    description: \"Armazene chaves de API com segurança no banco de dados para habilitar provedores de IA no Open Notebook.\",\n    encryptionRequired: \"Chave de criptografia não configurada\",\n    encryptionRequiredDescription: \"Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY com qualquer string secreta para armazenar chaves de API no banco de dados.\",\n    configured: \"Configurado\",\n    notConfigured: \"Não configurado\",\n    migrationAvailable: \"Variáveis de Ambiente Detectadas\",\n    migrationDescription: \"{count} chave(s) de API estão configuradas via variáveis de ambiente e podem ser migradas para o banco de dados para facilitar o gerenciamento.\",\n    migrateToDatabase: \"Migrar para Banco de Dados\",\n    migrating: \"Migrando...\",\n    migrationSuccess: \"{count} chave(s) de API migrada(s) com sucesso\",\n    migrationErrors: \"{count} chave(s) falhou ao migrar\",\n    migrationNothingToMigrate: \"Todas as chaves já estão no banco de dados\",\n    learnMore: \"Saiba como configurar chaves de API →\",\n    testConnection: \"Testar Conexão\",\n    testSuccess: \"Conexão bem-sucedida\",\n    testFailed: \"Falha no teste de conexão\",\n    syncModels: \"Sincronizar Modelos\",\n    syncSuccess: \"Descobertos {discovered} modelos, {new} novos adicionados\",\n    syncNoNew: \"Descobertos {count} modelos, todos já registrados\",\n    syncFailed: \"Falha ao sincronizar modelos\",\n    getApiKey: \"Obter Chave de API\",\n    vertexProject: \"ID do Projeto GCP\",\n    vertexLocation: \"Região\",\n    vertexCredentials: \"Caminho do JSON da Conta de Serviço\",\n    addConfig: \"Adicionar Configuração\",\n    editConfig: \"Editar Configuração\",\n    deleteConfig: \"Excluir Configuração\",\n    configName: \"Nome da Configuração\",\n    configNameHint: \"Um nome descritivo para esta configuração (ex.: 'Produção', 'Desenvolvimento')\",\n    baseUrl: \"URL Base\",\n    baseUrlOverrideHint: \"Altere apenas se precisar sobrescrever o endpoint padrão do provedor.\",\n    deleteConfigConfirm: \"Tem certeza de que deseja excluir '{name}'? Esta ação não pode ser desfeita.\",\n    configSaveSuccess: \"Configuração salva com sucesso\",\n    configUpdateSuccess: \"Configuração atualizada com sucesso\",\n    configDeleteSuccess: \"Configuração excluída com sucesso\",\n    apiKeyEditHint: \"Deixe em branco para manter a chave de API existente\",\n  },\n  setupBanner: {\n    encryptionRequired: \"Chave de criptografia não configurada\",\n    encryptionRequiredDescription: \"Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY para habilitar o armazenamento seguro de credenciais.\",\n    migrationAvailable: \"Migração de chaves de API disponível\",\n    migrationDescription: \"{count} provedor(es) possuem chaves de API definidas por variáveis de ambiente. Migre-as para o banco de dados para facilitar o gerenciamento.\",\n    goToSettings: \"Ir para Configurações\",\n    viewDocs: \"Ver documentação\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/ru-RU/index.ts",
    "content": "export const ruRU = {\n  common: {\n    search: \"Поиск...\",\n    create: \"Создать\",\n    new: \"Новый\",\n    cancel: \"Отмена\",\n    delete: \"Удалить\",\n    edit: \"Редактировать\",\n    theme: \"Тема\",\n    signOut: \"Выйти\",\n    noMatches: \"Совпадений не найдено\",\n    tryDifferentSearch: \"Попробуйте другой поисковый запрос.\",\n    light: \"Светлая\",\n    dark: \"Тёмная\",\n    system: \"Системная\",\n    loading: \"Загрузка...\",\n    note: \"Заметка\",\n    insight: \"Инсайт\",\n    newSource: \"Новый источник\",\n    newNotebook: \"Новый блокнот\",\n    newPodcast: \"Новый подкаст\",\n    language: \"Язык\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"Источник\",\n    notebook: \"Блокнот\",\n    podcast: \"Подкаст\",\n    quickActions: \"Быстрые действия\",\n    quickActionsDesc: \"Навигация, поиск, запрос, тема\",\n    appName: \"Open Notebook\",\n    add: \"Добавить\",\n    remove: \"Удалить\",\n    confirm: \"Подтвердить\",\n    warning: \"Предупреждение\",\n    error: \"Ошибка\",\n    success: \"Успешно\",\n    model: \"Модель\",\n    back: \"Назад\",\n    next: \"Далее\",\n    done: \"Готово\",\n    processing: \"Обработка...\",\n    creating: \"Создание...\",\n    linked: \"Связано\",\n    adding: \"Добавление...\",\n    addSelected: \"Добавить выбранное\",\n    customModel: \"Своя модель\",\n    failed: \"не удалось\",\n    current: \"Текущий\",\n    save: \"Сохранить\",\n    writeNote: \"Написать заметку\",\n    batchMode: \"Пакетный режим\",\n    optional: \"Необязательно\",\n    type: \"Тип\",\n    title: \"Название\",\n    created: \"Создано {time}\",\n    updated: \"Обновлено {time}\",\n    actions: \"Действия\",\n    noResults: \"Нет результатов\",\n    references: \"Ссылки\",\n    refreshPage: \"Попробуйте обновить страницу\",\n    refresh: \"Обновить\",\n    aiGenerated: \"Сгенерировано ИИ\",\n    human: \"Человек\",\n    unknown: \"Неизвестно\",\n    notes: \"Заметки\",\n    chat: \"Чат\",\n    deleteForever: \"Удалить навсегда\",\n    connectionError: \"Ошибка подключения\",\n    unableToConnect: \"Не удаётся подключиться к API-серверу\",\n    retryConnection: \"Повторить подключение\",\n    diagnosticInfo: \"Диагностическая информация\",\n    version: \"Версия\",\n    built: \"Сборка\",\n    apiUrl: \"URL API\",\n    frontendUrl: \"URL фронтенда\",\n    checkConsoleLogs: \"Проверьте консоль браузера для подробных логов (ищите сообщения 🔧 [Config])\",\n    yes: \"Да\",\n    no: \"Нет\",\n    saving: \"Сохранение...\",\n    description: \"Описание\",\n    saveToNote: \"Сохранить в заметку\",\n    copyToClipboard: \"Копировать в буфер обмена\",\n    close: \"Закрыть\",\n    insights: \"Инсайты\",\n    progress: \"Прогресс\",\n    deleting: \"Удаление...\",\n    created_label: \"Создано\",\n    updated_label: \"Обновлено\",\n    download: \"Скачать\",\n    saveChanges: \"Сохранить изменения\",\n    name: \"Название\",\n    default: \"По умолчанию\",\n    nameRequired: \"Название обязательно\",\n    modelConfiguration: \"Настройка модели\",\n    resetToDefault: \"Сбросить по умолчанию\",\n    reasoning: \"Рассуждение\",\n    searchTerms: \"Поисковые запросы\",\n    strategy: \"Стратегия\",\n    individualAnswers: \"Отдельные ответы ({count})\",\n    finalAnswer: \"Итоговый ответ\",\n    notebookLabel: \"Блокнот: {name}\",\n    itemNotFound: \"Этот {type} не найден\",\n    accessibility: {\n      transformationViews: \"Представления трансформаций\",\n      searchKB: \"Спросить или найти в базе знаний\",\n      enterQuestion: \"Введите вопрос для базы знаний\",\n      enterSearch: \"Введите поисковый запрос\",\n      searchKBBtn: \"Поиск по базе знаний\",\n      podcastViews: \"Представления подкастов\",\n      ytVideo: \"Видео YouTube\",\n      askResponse: \"Ответ на запрос\",\n      searchNotebooks: \"Поиск блокнотов\",\n    },\n    url: \"URL\",\n    errorDetails: \"Детали ошибки\",\n    editTransformation: \"Редактировать трансформацию\",\n    retry: \"Повторить\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"Português\",\n    completed: \"завершено\",\n    saveSuccess: \"Успешно сохранено\",\n    contextModes: {\n      off: \"Не включено в чат\",\n      insights: \"Только инсайты\",\n      full: \"Полное содержимое\",\n      clickToCycle: \"Нажмите для переключения\",\n    },\n    clickToEdit: \"Нажмите для редактирования\",\n  },\n  apiErrors: {\n    notebookNotFound: \"Блокнот не найден\",\n    sourceNotFound: \"Источник не найден\",\n    transformationNotFound: \"Трансформация не найдена\",\n    fileUploadFailed: \"Не удалось загрузить файл\",\n    urlRequired: \"URL обязателен для типа «ссылка»\",\n    contentRequired: \"Содержимое обязательно для типа «текст»\",\n    invalidSourceType: \"Недопустимый тип источника\",\n    processingFailed: \"Обработка не удалась\",\n    failedToQueue: \"Не удалось поставить в очередь обработки\",\n    invalidSortBy: \"Поле сортировки должно быть 'created' или 'updated'\",\n    invalidSortOrder: \"Порядок сортировки должен быть 'asc' или 'desc'\",\n    accessDenied: \"Доступ к файлу запрещён\",\n    fileNotFoundOnServer: \"Файл не найден на сервере\",\n    searchFailed: \"Поиск не удался\",\n    askFailed: \"Запрос не удался\",\n    pleaseEnterQuestion: \"Пожалуйста, введите вопрос\",\n    pleaseConfigureModels: \"Пожалуйста, настройте все необходимые модели\",\n    failedToCreateSession: \"Не удалось создать сессию\",\n    failedToUpdateSession: \"Не удалось обновить сессию\",\n    failedToDeleteSession: \"Не удалось удалить сессию\",\n    failedToSendMessage: \"Не удалось отправить сообщение\",\n    unauthorized: \"Неавторизованный доступ, проверьте пароль\",\n    invalidPassword: \"Неверный пароль\",\n    embeddingModelRequired: \"Для этой функции требуется модель эмбеддингов. Настройте её в разделе «Модели».\",\n    strategyModelNotFound: \"Модель стратегии не найдена\",\n    answerModelNotFound: \"Модель ответов не найдена\",\n    finalAnswerModelNotFound: \"Модель итогового ответа не найдена\",\n    noAnswerGenerated: \"Не удалось сгенерировать ответ\",\n    genericError: \"Произошла непредвиденная ошибка\",\n  },\n  connectionErrors: {\n    apiTitle: \"Не удаётся подключиться к API-серверу\",\n    apiDesc: \"API-сервер Open Notebook недоступен\",\n    dbTitle: \"Ошибка подключения к базе данных\",\n    dbDesc: \"API-сервер работает, но база данных недоступна\",\n    troubleshooting: \"Обычно это означает:\",\n    apiUnreachable1: \"API-сервер не запущен\",\n    apiUnreachable2: \"API-сервер работает по другому адресу\",\n    apiUnreachable3: \"Проблемы с сетевым подключением\",\n    dbFailed1: \"SurrealDB не запущен\",\n    dbFailed2: \"Неверные настройки подключения к базе данных\",\n    dbFailed3: \"Сетевые проблемы между API и базой данных\",\n    quickFixes: \"Быстрые решения:\",\n    setApiUrl: \"Установите переменную окружения API_URL:\",\n    checkSurreal: \"Проверьте, запущен ли SurrealDB:\",\n    seeDocumentation: \"Подробные инструкции по настройке см. в:\",\n    docLink: \"Документация Open Notebook\",\n    showTechnical: \"Показать техническую информацию\",\n    attemptedUrl: \"Использованный URL\",\n    message: \"Сообщение\",\n    technicalDetails: \"Технические детали\",\n    stackTrace: \"Стек вызовов\",\n    retryLabel: \"Повторить подключение\",\n    retryHint: \"Нажмите R или кнопку для повторной попытки\",\n    dockerLabel: \"Для Docker\",\n    localDevLabel: \"Для локальной разработки\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"Введите пароль для доступа к приложению\",\n    passwordPlaceholder: \"Пароль\",\n    signingIn: \"Вход...\",\n    signIn: \"Войти\",\n    connectErrorHint: \"Не удаётся подключиться к серверу. Проверьте, запущен ли API.\",\n  },\n  navigation: {\n    collect: \"Собрать\",\n    process: \"Обработать\",\n    create: \"Создать\",\n    manage: \"Управление\",\n    sources: \"Источники\",\n    notebooks: \"Блокноты\",\n    askAndSearch: \"Запрос и поиск\",\n    podcasts: \"Подкасты\",\n    models: \"Модели\",\n    transformations: \"Трансформации\",\n    transformation: \"Трансформация\",\n    settings: \"Настройки\",\n    advanced: \"Дополнительно\",\n    nav: \"Навигация\",\n    language: \"Переключить язык\",\n    theme: \"Тема\",\n    ask: \"Запрос\",\n  },\n  notebooks: {\n    title: \"Блокноты\",\n    newNotebook: \"Новый блокнот\",\n    searchPlaceholder: \"Поиск блокнотов...\",\n    archived: \"Архивные\",\n    archive: \"Архивировать\",\n    unarchive: \"Разархивировать\",\n    deleteNotebook: \"Удалить блокнот\",\n    deleteNotebookDesc: \"Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.\",\n    deleteNotebookLoading: \"Загрузка предварительного просмотра удаления...\",\n    deleteNotebookNotes: \"Будет удалено заметок: {count}.\",\n    deleteNotebookNoNotes: \"Нет заметок для удаления.\",\n    deleteNotebookExclusiveSources: \"Источников только в этом блокноте: {count}.\",\n    deleteNotebookSharedSources: \"Источников, связанных с другими блокнотами (будут отвязаны): {count}.\",\n    deleteNotebookNoSources: \"В этом блокноте нет источников.\",\n    deleteExclusiveSourcesLabel: \"Удалить эксклюзивные источники\",\n    keepExclusiveSourcesLabel: \"Отвязать и сохранить\",\n    activeNotebooks: \"Активные блокноты\",\n    archivedNotebooks: \"Архивные блокноты\",\n    notFound: \"Блокнот не найден\",\n    notFoundDesc: \"Запрошенный блокнот не существует.\",\n    updated: \"Обновлено\",\n    namePlaceholder: \"Название блокнота\",\n    addDescription: \"Добавить описание...\",\n    noNotesYet: \"Пока нет заметок\",\n    deleteNote: \"Удалить заметку\",\n    deleteNoteConfirm: \"Вы уверены, что хотите удалить эту заметку? Это действие нельзя отменить.\",\n    noteCreatedSuccess: \"Заметка успешно создана\",\n    failedToCreateNote: \"Не удалось создать заметку\",\n    noteUpdatedSuccess: \"Заметка успешно обновлена\",\n    failedToUpdateNote: \"Не удалось обновить заметку\",\n    noteDeletedSuccess: \"Заметка успешно удалена\",\n    failedToDeleteNote: \"Не удалось удалить заметку\",\n    createNew: \"Создать новый блокнот\",\n    createNewDesc: \"Введите название и необязательное описание для начала.\",\n    descPlaceholder: \"Добавьте дополнительную информацию о блокноте...\",\n    createSuccess: \"Блокнот успешно создан\",\n    updateSuccess: \"Блокнот успешно обновлён\",\n    deleteSuccess: \"Блокнот успешно удалён\",\n  },\n  sources: {\n    title: \"Источники\",\n    add: \"Добавить источник\",\n    addNew: \"Добавить новый источник\",\n    addExisting: \"Добавить существующий источник\",\n    delete: \"Удалить источник\",\n    statusPreparing: \"Подготовка\",\n    statusQueued: \"В очереди\",\n    statusProcessing: \"Обработка\",\n    statusCompleted: \"Завершено\",\n    statusFailed: \"Ошибка\",\n    statusPreparingDesc: \"Подготовка к обработке\",\n    statusQueuedDesc: \"Ожидание обработки\",\n    statusProcessingDesc: \"Выполняется обработка\",\n    statusCompletedDesc: \"Успешно обработано\",\n    statusFailedDesc: \"Обработка не удалась\",\n    failedToLoad: \"Не удалось загрузить источники\",\n    allSourcesDesc: \"Просмотр всех источников. Вы можете добавлять новые или управлять существующими.\",\n    allSources: \"Все источники\",\n    insights: \"Инсайты\",\n    yes: \"Да\",\n    no: \"Нет\",\n    loadingMore: \"Загрузка...\",\n    noSourcesYet: \"Пока нет источников\",\n    allSourcesDescShort: \"Просмотр всех ваших источников.\",\n    cannotSaveNoteNoNotebook: \"Невозможно сохранить заметку: ID блокнота недоступен\",\n    createFirstSource: \"Добавьте первый источник, чтобы начать создание базы знаний.\",\n    deleteSourceConfirm: \"Вы уверены, что хотите удалить этот источник?\",\n    deleteConfirm: \"Вы уверены, что хотите удалить это?\",\n    deleteConfirmWithTitle: \"Вы уверены, что хотите удалить «{title}»?\",\n    deleteSuccess: \"Источник успешно удалён. Примечание: Чтобы удалить файл из хранилища, необходимо включить опцию «Удалить файл» в настройках.\",\n    failedToDelete: \"Не удалось удалить источник\",\n    sourceQueued: \"Источник в очереди\",\n    sourceQueuedDesc: \"Источник отправлен на фоновую обработку. Отслеживайте прогресс в списке источников.\",\n    sourceAddedSuccess: \"Источник успешно добавлен\",\n    failedToAddSource: \"Не удалось добавить источник\",\n    sourceUpdatedSuccess: \"Источник успешно обновлён\",\n    failedToUpdateSource: \"Не удалось обновить источник\",\n    sourceDeletedSuccess: \"Источник успешно удалён\",\n    failedToDeleteSource: \"Не удалось удалить источник\",\n    fileUploadedSuccess: \"Файл успешно загружен\",\n    failedToUploadFile: \"Не удалось загрузить файл\",\n    sourceRequeued: \"Повторная обработка в очереди\",\n    sourceRequeuedDesc: \"Источник поставлен в очередь на повторную обработку.\",\n    failedToRetry: \"Повтор не удался\",\n    sourcesAddedToNotebook: \"Добавлено источников в блокнот: {count}\",\n    failedToAddSourcesToNotebook: \"Не удалось добавить источники в блокнот\",\n    partialAddSuccess: \"Добавлено: {success}, не удалось: {failed}\",\n    sourceRemovedFromNotebook: \"Источник успешно удалён из блокнота\",\n    failedToRemoveSourceFromNotebook: \"Не удалось удалить источник из блокнота\",\n    removeConfirm: \"Вы уверены, что хотите удалить это из блокнота?\",\n    checking: \"Проверка...\",\n    untitledSource: \"Без названия\",\n    maxItems: \"макс. {count}\",\n    insightsCount: \"Инсайтов: {count}\",\n    details: \"Детали\",\n    detailsTitle: \"Детали источника\",\n    content: \"Содержимое\",\n    metadata: \"Метаданные\",\n    type: {\n      link: \"Ссылка\",\n      file: \"Файл\",\n      text: \"Текст\",\n    },\n    id: \"ID источника\",\n    topics: \"Темы\",\n    embedded: \"С эмбеддингом\",\n    notEmbedded: \"Без эмбеддинга\",\n    embedContent: \"Создать эмбеддинг\",\n    embedding: \"Создание эмбеддинга...\",\n    alreadyEmbedded: \"Эмбеддинг уже создан\",\n    downloadFile: \"Скачать файл\",\n    fileUnavailable: \"Файл недоступен\",\n    preparing: \"Подготовка...\",\n    generateNewInsight: \"Сгенерировать новый инсайт\",\n    selectTransformation: \"Выберите трансформацию...\",\n    noInsightsYet: \"Пока нет инсайтов\",\n    createFirstInsight: \"Создайте первый инсайт с помощью трансформации выше\",\n    viewInsight: \"Просмотреть инсайт\",\n    deleteInsight: \"Удалить инсайт\",\n    deleteInsightConfirm: \"Вы уверены, что хотите удалить этот инсайт? Это действие нельзя отменить.\",\n    insightGenerationStarted: \"Генерация инсайта запущена. Скоро он появится.\",\n    editNote: \"Редактировать заметку\",\n    createNote: \"Создать заметку\",\n    addTitle: \"Добавьте название...\",\n    untitledNote: \"Без названия\",\n    writeNotePlaceholder: \"Напишите содержимое заметки здесь...\",\n    saveNote: \"Сохранить заметку\",\n    createNoteBtn: \"Создать заметку\",\n    createFirstNote: \"Создайте первую заметку для записи идей и наблюдений.\",\n    urlLabel: \"URL(ы) *\",\n    fileLabel: \"Файл(ы) *\",\n    textContentLabel: \"Текстовое содержимое *\",\n    enterUrlsPlaceholder: \"Введите URL-адреса, по одному на строку\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"Вставьте несколько URL (по одному на строку) для пакетного импорта\",\n    invalidUrlsDetected: \"Обнаружены недопустимые URL:\",\n    lineLabel: \"Строка {line}\",\n    fixInvalidUrls: \"Исправьте или удалите недопустимые URL для продолжения\",\n    selectMultipleFilesHint: \"Выберите несколько файлов для пакетного импорта. Поддерживаются: Документы (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Медиа (MP4, MP3, WAV, M4A), Изображения (JPG, PNG), Архивы (ZIP)\",\n    selectedFiles: \"Выбранные файлы:\",\n    textPlaceholder: \"Вставьте или введите содержимое здесь...\",\n    htmlDetected: \"Обнаружено HTML-содержимое. После обработки оно будет преобразовано в Markdown.\",\n    titlePlaceholder: \"Дайте источнику понятное название\",\n    batchTitlesAuto: \"Названия будут автоматически сгенерированы для каждого источника.\",\n    batchCommonSettings: \"Одинаковые блокноты и трансформации будут применены ко всем элементам.\",\n    urlsCount: \"URL: {count}\",\n    filesCount: \"Файлов: {count}\",\n    addSource: \"Добавить источник\",\n    notEmbeddedAlert: \"Содержимое без эмбеддинга\",\n    notEmbeddedDesc: \"Для этого содержимого не создан эмбеддинг для векторного поиска. Эмбеддинг обеспечивает расширенные возможности поиска и лучшее обнаружение контента.\",\n    openOnYoutube: \"Открыть на YouTube\",\n    urlCopied: \"URL скопирован в буфер обмена\",\n    viewSource: \"Просмотреть источник\",\n    noInsightSelected: \"Инсайт не выбран\",\n    sourceInsight: \"Инсайт источника\",\n    manageNotebooks: \"Управление блокнотами\",\n    manageNotebooksDesc: \"Управление блокнотами, содержащими этот источник\",\n    noNotebooksAvailable: \"Нет доступных блокнотов\",\n    loadFailed: \"Не удалось загрузить детали источника\",\n    removeFromNotebook: \"Удалить из блокнота\",\n    retryProcessing: \"Повторить обработку\",\n    deleteSource: \"Удалить источник\",\n    retry: \"Повторить\",\n    addExistingTitle: \"Добавить существующие источники\",\n    addExistingDesc: \"Выберите существующие источники из всех блокнотов для добавления в текущий.\",\n    searchPlaceholder: \"Поиск источников по названию или URL...\",\n    noNotebooksFound: \"Блокноты не найдены.\",\n    showingFirst100: \"Показаны первые 100 источников. Используйте поиск для конкретных результатов.\",\n    selectedCount: \"Выбрано источников: {count}\",\n    added: \"Добавлено {date}\",\n    addUrl: \"Добавить URL\",\n    uploadFile: \"Загрузить файл\",\n    enterText: \"Ввести текст\",\n    processDescription: \"Содержимое будет обработано и проанализировано ИИ.\",\n    processingFiles: \"Обработка файлов...\",\n    titleRequired: \"Для текстового содержимого требуется название\",\n    titleGenerated: \"Если оставить пустым, название будет сгенерировано из содержимого\",\n    batchCount: \"{count} {type} будет обработано\",\n    enableEmbedding: \"Включить эмбеддинг для поиска\",\n    embeddingDesc: \"Позволяет находить этот источник в векторном поиске и запросах ИИ\",\n    embeddingAlways: \"Эмбеддинг включён автоматически\",\n    embeddingAlwaysDesc: \"В ваших настройках включено автоматическое создание эмбеддинга для векторного поиска.\",\n    embeddingNever: \"Эмбеддинг отключён\",\n    embeddingNeverDesc: \"В ваших настройках отключено создание эмбеддинга. Векторный поиск будет недоступен для этого источника.\",\n    changeInSettings: \"Вы можете изменить это в Настройках\",\n    notFound: \"Источник не найден\",\n    noContent: \"Содержимое недоступно\",\n    insightsDesc: \"Инсайты, сгенерированные анализом модели\",\n    uploadedFile: \"Загруженный файл\",\n    fileUnavailableDesc: \"Этот файл временно недоступен из-за проблем с хранилищем.\",\n    batchSuccess: \"Успешно создано источников: {count}\",\n    batchFailed: \"Не удалось создать все источники: {count}\",\n    batchPartial: \"Успешно: {success}, не удалось: {failed}\",\n    submittingSource: \"Отправка источника на обработку...\",\n    processingBatchSources: \"Обработка источников: {count}. Это может занять некоторое время.\",\n    processingSource: \"Источник обрабатывается. Это может занять некоторое время.\",\n    maxFilesAllowed: \"Максимальное количество файлов в пакете: {count}\",\n  },\n  chat: {\n    sessions: \"Сессии\",\n    sessionTitlePlaceholder: \"Введите название...\",\n    noSessions: \"Пока нет сессий чата\",\n    deleteSession: \"Удалить сессию\",\n    deleteSessionDesc: \"Вы уверены, что хотите удалить эту сессию чата? Это действие нельзя отменить.\",\n    sendPlaceholder: \"Задайте вопрос о ваших источниках...\",\n    sessionsTitle: \"Сессии чата\",\n    chatWith: \"Чат с {name}\",\n    startConversation: \"Начните разговор об этом {type}\",\n    askQuestions: \"Задавайте вопросы, чтобы лучше понять содержимое\",\n    pressToSend: \"Нажмите {key} для отправки\",\n    model: \"Модель\",\n    createToStart: \"Создайте сессию для начала.\",\n    chatWithNotebook: \"Чат с блокнотом\",\n    unableToLoadChat: \"Не удалось загрузить чат\",\n    noDescription: \"Без описания\",\n    startByCreating: \"Начните с создания первого блокнота для организации исследований.\",\n    messagesCount: \"Сообщений: {count}\",\n    sessionCreated: \"Сессия чата создана\",\n    sessionUpdated: \"Сессия обновлена\",\n    sessionDeleted: \"Сессия удалена\",\n  },\n  searchPage: {\n    askAndSearch: \"Запрос и поиск\",\n    chooseAMode: \"Выберите режим\",\n    askBeta: \"Запрос (бета)\",\n    search: \"Поиск\",\n    askYourKb: \"Спросите базу знаний (бета)\",\n    askYourKbDesc: \"LLM ответит на ваш запрос на основе документов в базе знаний.\",\n    question: \"Вопрос\",\n    enterQuestionPlaceholder: \"Введите ваш вопрос...\",\n    pressToSubmit: \"Нажмите Cmd/Ctrl+Enter для отправки\",\n    noEmbeddingModel: \"Вы не можете использовать эту функцию, потому что не выбрана модель эмбеддинга. Настройте её на странице «Модели».\",\n    usingCustomModels: \"Используются пользовательские модели\",\n    usingDefaultModels: \"Используются модели по умолчанию\",\n    advanced: \"Расширенные\",\n    strategy: \"Стратегия\",\n    answer: \"Ответ\",\n    final: \"Итоговый\",\n    ask: \"Спросить\",\n    processing: \"Обработка...\",\n    saveToNotebooks: \"Сохранить в блокноты\",\n    searchDesc: \"Поиск в базе знаний по ключевым словам или концепциям\",\n    enterSearchPlaceholder: \"Введите поисковый запрос...\",\n    pressToSearch: \"Нажмите Enter для поиска\",\n    searchType: \"Тип поиска\",\n    vectorSearchWarning: \"Векторный поиск требует модель эмбеддинга. Доступен только текстовый поиск.\",\n    textSearch: \"Текстовый поиск\",\n    vectorSearch: \"Векторный поиск\",\n    searchIn: \"Искать в\",\n    searchSources: \"Искать в источниках\",\n    searchNotes: \"Искать в заметках\",\n    resultsFound: \"Найдено результатов: {count}\",\n    matches: \"Совпадения ({count})\",\n    noResultsFor: \"Нет результатов для «{query}»\",\n    notSet: \"Не задано\",\n    saveToNotebook: \"Сохранить в блокнот\",\n    saveSuccess: \"Успешно сохранено в блокнот\",\n    saveError: \"Не удалось сохранить в блокнот\",\n    selectNotebook: \"Выберите блокнот\",\n    searchAndAsk: \"Поиск и запрос\",\n    searchResultsFor: \"Результаты поиска для «{query}»\",\n    askAbout: \"Спросить о «{query}»\",\n    orSearchKb: \"Или выполнить поиск по базе знаний\",\n    saving: \"Сохранение...\",\n    advancedModelTitle: \"Расширенный выбор моделей\",\n    advancedModelDesc: \"Выберите конкретные модели для каждого этапа процесса запроса\",\n    strategyModel: \"Модель стратегии\",\n    answerModel: \"Модель ответа\",\n    finalAnswerModel: \"Модель итогового ответа\",\n    selectStrategyPlaceholder: \"Выберите модель стратегии\",\n    selectAnswerPlaceholder: \"Выберите модель ответа\",\n    selectFinalPlaceholder: \"Выберите модель итогового ответа\",\n    saveChanges: \"Сохранить изменения\",\n    processingQuestion: \"Обработка вашего вопроса...\",\n  },\n  podcasts: {\n    generateEpisode: \"Сгенерировать эпизод подкаста\",\n    generateEpisodeDesc: \"Выберите контент для включения и настройте параметры эпизода перед генерацией.\",\n    content: \"Контент\",\n    contentDesc: \"Выберите блокноты, источники и заметки для этого эпизода.\",\n    itemsSelected: \"Выбрано элементов: {count}\",\n    tokens: \"Токенов: {count}\",\n    chars: \"Символов: {count}\",\n    loadingNotebooks: \"Загрузка блокнотов...\",\n    noNotebooksFoundInPodcasts: \"Блокноты не найдены. Создайте блокнот и добавьте контент перед генерацией подкаста.\",\n    noContentSelected: \"Контент не выбран\",\n    summary: \"Краткое содержание\",\n    fullContent: \"Полное содержимое\",\n    untitledSource: \"Без названия\",\n    untitledNote: \"Без названия\",\n    episodeSettings: \"Настройки эпизода\",\n    episodeProfile: \"Профиль эпизода\",\n    episodeProfilePlaceholder: \"Выберите профиль эпизода\",\n    episodeName: \"Название эпизода\",\n    episodeNamePlaceholder: \"напр., ИИ и будущее работы\",\n    additionalInstructions: \"Дополнительные инструкции\",\n    instructionsPlaceholder: \"Любые дополнительные указания для брифинга эпизода...\",\n    generating: \"Генерация...\",\n    generate: \"Сгенерировать\",\n    hostPlaceholder: \"Ведущий {number}\",\n    profileRequired: \"Требуется профиль эпизода\",\n    profileRequiredDesc: \"Выберите профиль эпизода перед генерацией подкаста.\",\n    nameRequired: \"Требуется название эпизода\",\n    nameRequiredDesc: \"Укажите название эпизода.\",\n    addContext: \"Добавить контекст\",\n    addContextDesc: \"Выберите хотя бы один источник или заметку для включения в эпизод.\",\n    generationFailed: \"Генерация подкаста не удалась\",\n    speakerProfile: \"Профиль говорящего\",\n    usesSpeakerProfile: \"Использует профиль говорящего\",\n    sources: \"Источники\",\n    notes: \"Заметки\",\n    noSources: \"В этом блокноте нет доступных источников.\",\n    noNotes: \"В этом блокноте нет доступных заметок.\",\n    selectMode: \"Выберите режим\",\n    buildContextFailed: \"Не удалось построить контекст. Проверьте выбранные элементы.\",\n    podcastTaskStarted: \"Задача подкаста запущена\",\n    loadingProfiles: \"Загрузка профилей эпизодов...\",\n    noProfilesFound: \"Профили эпизодов не найдены. Создайте профиль перед генерацией подкаста.\",\n    listTitle: \"Подкасты\",\n    listDesc: \"Отслеживайте сгенерированные эпизоды и управляйте профилями.\",\n    chooseAView: \"Выберите представление\",\n    episodesTab: \"Эпизоды\",\n    templatesTab: \"Профили\",\n    overviewTitle: \"Обзор эпизодов\",\n    overviewDesc: \"Отслеживайте задачи генерации подкастов и просматривайте готовые материалы.\",\n    generateBtn: \"Сгенерировать подкаст\",\n    total: \"Всего\",\n    processingLabel: \"В обработке\",\n    completedLabel: \"Завершено\",\n    failedLabel: \"Ошибка\",\n    pendingLabel: \"Ожидание\",\n    loadErrorTitle: \"Не удалось загрузить эпизоды\",\n    loadErrorDesc: \"Не удалось получить последние эпизоды подкастов. Попробуйте позже.\",\n    loadingEpisodes: \"Загрузка эпизодов…\",\n    noEpisodesYet: \"Пока нет эпизодов подкастов. Сгенерируйте первый из интерфейса чата блокнота или источника.\",\n    statusRunningTitle: \"В процессе\",\n    statusRunningDesc: \"Эпизоды, для которых активно генерируются материалы.\",\n    statusPendingTitle: \"В очереди / Ожидание\",\n    statusPendingDesc: \"Отправленные эпизоды, ожидающие начала обработки.\",\n    statusCompletedTitle: \"Завершённые эпизоды\",\n    statusCompletedDesc: \"Готовы к просмотру, загрузке или публикации.\",\n    statusFailedTitle: \"Неудачные эпизоды\",\n    statusFailedDesc: \"Эпизоды с ошибками во время генерации.\",\n    templatesWorkspaceTitle: \"Рабочее пространство профилей\",\n    templatesWorkspaceDesc: \"Создавайте переиспользуемые конфигурации эпизодов и говорящих для быстрого производства подкастов.\",\n    howTemplatesPowerTitle: \"Как профили ускоряют генерацию подкастов\",\n    howTemplatesPowerDesc: \"Профили разделяют процесс на два переиспользуемых компонента. Комбинируйте их при генерации нового эпизода.\",\n    episodeProfilesSetFormat: \"Профили эпизодов задают формат\",\n    episodeProfilesList1: \"Определяют количество сегментов и структуру повествования\",\n    episodeProfilesList2: \"Выбирают языковые модели для брифинга, планирования и написания сценария\",\n    episodeProfilesList3: \"Хранят стандартные брифинги для единообразного стиля эпизодов\",\n    speakerProfilesBringVoices: \"Профили говорящих оживляют голоса\",\n    speakerProfilesList1: \"Выбирают провайдера и модель озвучивания\",\n    speakerProfilesList2: \"Фиксируют личность, биографию и заметки о произношении для каждого говорящего\",\n    speakerProfilesList3: \"Переиспользуют одни и те же голоса ведущих или гостей в разных форматах эпизодов\",\n    recommendedWorkflow: \"Рекомендуемый рабочий процесс\",\n    workflowStep1: \"Создайте профили говорящих для каждого нужного голоса\",\n    workflowStep2: \"Создайте профили эпизодов со ссылками на говорящих по имени\",\n    workflowStep3: \"Генерируйте подкасты, выбирая подходящий профиль эпизода\",\n    workflowHint: \"Профили эпизодов ссылаются на профили говорящих по имени, поэтому начинайте с говорящих, чтобы избежать пропущенных назначений голосов.\",\n    failedToLoadTemplates: \"Не удалось загрузить данные профилей\",\n    failedToLoadTemplatesDesc: \"Убедитесь, что API работает, и попробуйте снова. Некоторые разделы могут быть неполными.\",\n    loadingTemplates: \"Загрузка профилей…\",\n    speakerProfilesTitle: \"Профили говорящих\",\n    speakerProfilesDesc: \"Настройте голоса и личности для генерируемых эпизодов.\",\n    createSpeaker: \"Создать говорящего\",\n    noSpeakerProfiles: \"Пока нет профилей говорящих. Создайте один, чтобы профили эпизодов стали доступны.\",\n    noDescription: \"Описание не указано.\",\n    usedByCount_one: \"Используется в 1 эпизоде\",\n    usedByCount_other: \"Используется в {count} эпизодах\",\n    usedByCount: \"Используется в {count} эпизодах\",\n    unused: \"Не используется\",\n    voiceId: \"ID голоса\",\n    backstory: \"Биография\",\n    personality: \"Личность\",\n    edit: \"Редактировать\",\n    duplicate: \"Дублировать\",\n    deleteSpeakerProfileTitle: \"Удалить профиль говорящего?\",\n    deleteSpeakerProfileDesc: \"Удаление «{name}» нельзя отменить.\",\n    deleteSpeakerDisabledHint: \"Сначала удалите этого говорящего из профилей эпизодов.\",\n    deleting: \"Удаление…\",\n    episodeProfilesTitle: \"Профили эпизодов\",\n    episodeProfilesDesc: \"Определите переиспользуемые настройки генерации для ваших шоу.\",\n    createProfile: \"Создать профиль\",\n    createSpeakerFirst: \"Сначала создайте профиль говорящего перед добавлением профиля эпизода.\",\n    noEpisodeProfiles: \"Пока нет профилей эпизодов. Создайте один для запуска генерации подкастов.\",\n    speakerCreated: \"Говорящий создан\",\n    speakerCreatedDesc: \"Говорящий «{name}» успешно добавлен.\",\n    failedToCreateSpeaker: \"Не удалось создать профиль говорящего\",\n    speakerUpdated: \"Говорящий обновлён\",\n    speakerUpdatedDesc: \"Говорящий «{name}» успешно обновлён.\",\n    failedToUpdateSpeaker: \"Не удалось обновить профиль говорящего\",\n    speakerDeleted: \"Говорящий удалён\",\n    speakerDeletedDesc: \"Говорящий «{name}» успешно удалён.\",\n    failedToDeleteSpeaker: \"Не удалось удалить профиль говорящего\",\n    speakerDuplicated: \"Говорящий дублирован\",\n    speakerDuplicatedDesc: \"Говорящий «{name}» успешно дублирован.\",\n    failedToDuplicateSpeaker: \"Не удалось дублировать профиль говорящего\",\n    generationStarted: \"Генерация запущена\",\n    generationStartedDesc: \"Генерация подкаста поставлена в очередь.\",\n    failedToStartGeneration: \"Не удалось запустить генерацию\",\n    tryAgainMoment: \"Попробуйте ещё раз через некоторое время.\",\n    deleteProfileTitle: \"Удалить профиль?\",\n    deleteProfileDesc: \"Это удалит «{name}». Существующие эпизоды сохранят свои данные, но новые больше не смогут использовать эту конфигурацию.\",\n    profileCreated: \"Профиль создан\",\n    profileCreatedDesc: \"Профиль эпизода «{name}» успешно создан.\",\n    failedToCreateProfile: \"Не удалось создать профиль\",\n    profileUpdated: \"Профиль обновлён\",\n    profileUpdatedDesc: \"Профиль эпизода «{name}» успешно обновлён.\",\n    failedToUpdateProfile: \"Не удалось обновить профиль\",\n    profileDeleted: \"Профиль удалён\",\n    profileDeletedDesc: \"Профиль эпизода «{name}» успешно удалён.\",\n    failedToDeleteProfile: \"Не удалось удалить профиль\",\n    failedToDeleteProfileDesc: \"Не удалось удалить профиль эпизода.\",\n    profileDuplicated: \"Профиль дублирован\",\n    profileDuplicatedDesc: \"Профиль эпизода «{name}» успешно дублирован.\",\n    failedToDuplicateProfile: \"Не удалось дублировать профиль\",\n    episodeDeleted: \"Эпизод удалён\",\n    episodeDeletedDesc: \"Эпизод успешно удалён.\",\n    failedToDeleteEpisode: \"Не удалось удалить эпизод\",\n    failedToDeleteSpeakerDesc: \"Не удалось удалить профиль говорящего.\",\n    outlineModel: \"Модель плана\",\n    transcriptModel: \"Модель транскрипта\",\n    segments: \"Сегменты\",\n    defaultBriefingTitle: \"Брифинг по умолчанию\",\n    created: \"Создано {time}\",\n    details: \"Детали\",\n    summaryTab: \"Краткое\",\n    outlineTab: \"План\",\n    transcriptTab: \"Транскрипт\",\n    briefing: \"Брифинг\",\n    noOutline: \"План недоступен.\",\n    noTranscript: \"Транскрипт недоступен.\",\n    deleteEpisodeTitle: \"Удалить эпизод?\",\n    deleteEpisodeDesc: \"Это навсегда удалит «{name}» и его аудиофайл.\",\n    audioUnavailable: \"Аудио недоступно\",\n    segment: \"Сегмент\",\n    speaker: \"Говорящий\",\n    profile: \"Профиль\",\n    link: \"Ссылка\",\n    file: \"Файл\",\n    embedded: \"С эмбеддингом\",\n    notEmbedded: \"Без эмбеддинга\",\n    noSpeakerProfilesAvailable: \"Нет доступных профилей говорящих\",\n    editEpisodeProfile: \"Редактировать профиль эпизода\",\n    createEpisodeProfile: \"Создать профиль эпизода\",\n    episodeProfileFormDesc: \"Определите, как должны генерироваться эпизоды и какую конфигурацию говорящих использовать по умолчанию.\",\n    noSpeakerProfilesDesc: \"Создайте профиль говорящего перед настройкой профиля эпизода.\",\n    profileName: \"Название профиля\",\n    profileNamePlaceholder: \"напр., Техническая дискуссия\",\n    descriptionPlaceholder: \"Краткое описание, когда использовать этот профиль\",\n    speakerConfig: \"Конфигурация говорящих\",\n    selectSpeakerProfile: \"Выберите профиль говорящего\",\n    outlineGeneration: \"Генерация плана\",\n    transcriptGeneration: \"Генерация транскрипта\",\n    defaultBriefingPlaceholder: \"Опишите структуру, тон и цели для этого формата эпизода\",\n    editSpeakerProfile: \"Редактировать профиль говорящего\",\n    createSpeakerProfile: \"Создать профиль говорящего\",\n    speakerProfileFormDesc: \"Настройте параметры озвучивания и определите до четырёх говорящих.\",\n    speakers: \"Говорящие\",\n    speakersDesc: \"Настройте от одного до четырёх голосов для этого профиля.\",\n    addSpeaker: \"Добавить говорящего\",\n    speakerNumber: \"Говорящий {number}\",\n    backstoryPlaceholder: \"Краткая биография или контекст для говорящего\",\n    personalityPlaceholder: \"Опишите стиль и тон\",\n    outlineModelRequired: \"Требуется модель плана\",\n    transcriptModelRequired: \"Требуется модель транскрипта\",\n    defaultBriefingRequired: \"Требуется брифинг по умолчанию\",\n    segmentsInteger: \"Должно быть целым числом\",\n    segmentsMin: \"Минимум 3 сегмента\",\n    segmentsMax: \"Максимум 20 сегментов\",\n    voiceIdRequired: \"Требуется ID голоса\",\n    backstoryRequired: \"Требуется биография\",\n    personalityRequired: \"Требуется описание личности\",\n    speakerCountMin: \"Требуется минимум один говорящий\",\n    speakerCountMax: \"Можно настроить до 4 говорящих\",\n    delete: \"Удалить\",\n    failedToDelete: \"Не удалось удалить подкаст\",\n    retry: \"Повторить\",\n    retrying: \"Повтор…\",\n    retryStarted: \"Повтор запущен\",\n    retryStartedDesc: \"Новое задание на генерацию подкаста отправлено.\",\n    failedToRetry: \"Не удалось повторить\",\n    errorDetails: \"Подробности ошибки\",\n    language: \"Язык\",\n    languagePlaceholder: \"Выберите язык (необязательно)\",\n    podcastLanguage: \"Язык подкаста\",\n    selectOutlineModel: \"Выберите модель плана\",\n    selectTranscriptModel: \"Выберите модель транскрипта\",\n    voiceModel: \"Голосовая модель\",\n    voiceModelRequired: \"Требуется голосовая модель\",\n    selectVoiceModel: \"Выберите голосовую модель\",\n    perSpeakerTtsOverride: \"Переопределение TTS для говорящего (необязательно)\",\n    useProfileDefault: \"Использовать настройки профиля\",\n    setupRequired: \"Требуется настройка\",\n    setupRequiredDesc: \"Некоторые профили ещё не имеют настроенных моделей. Отредактируйте их для выбора моделей перед генерацией подкастов.\",\n    notConfigured: \"Не настроено\",\n  },\n  settings: {\n    contentProcessing: \"Обработка контента\",\n    contentProcessingDesc: \"Настройте обработку документов и URL\",\n    docEngine: \"Движок обработки документов\",\n    docEnginePlaceholder: \"Выберите движок обработки документов\",\n    urlEngine: \"Движок обработки URL\",\n    urlEnginePlaceholder: \"Выберите движок обработки URL\",\n    autoRecommended: \"Авто (рекомендуется)\",\n    simple: \"Простой\",\n    docling: \"Docling\",\n    helpMeChoose: \"Помогите выбрать\",\n    docHelp: \"· Docling немного медленнее, но точнее, особенно для документов с таблицами и изображениями. · Simple извлекает содержимое без форматирования. · Авто (рекомендуется) попробует Docling и переключится на Simple при необходимости.\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl — платный сервис (есть бесплатный уровень), очень мощный. · Jina тоже хороший вариант с бесплатным уровнем. · Simple использует базовое HTTP-извлечение и пропустит контент на JavaScript-сайтах. · Авто (рекомендуется) попробует Firecrawl, затем Jina, затем Simple.\",\n    embeddingAndSearch: \"Эмбеддинг и поиск\",\n    embeddingAndSearchDesc: \"Настройте параметры поиска и эмбеддинга\",\n    defaultEmbeddingOption: \"Опция эмбеддинга по умолчанию\",\n    embeddingOptionPlaceholder: \"Выберите опцию эмбеддинга\",\n    ask: \"Спрашивать\",\n    always: \"Всегда\",\n    never: \"Никогда\",\n    embeddingHelp: \"Эмбеддинг контента упрощает поиск для вас и ИИ-агентов. Если вы используете локальную модель эмбеддинга (например, Ollama), можно не беспокоиться о стоимости и создавать эмбеддинг для всего.\",\n    fileManagement: \"Управление файлами\",\n    fileManagementDesc: \"Настройте обработку и хранение файлов\",\n    autoDeleteFiles: \"Автоудаление файлов\",\n    autoDeletePlaceholder: \"Выберите опцию автоудаления\",\n    filesHelp: \"После загрузки и обработки файлы больше не нужны. Большинству пользователей следует разрешить Open Notebook автоматически удалять загруженные файлы из папки загрузок.\",\n    loadFailed: \"Не удалось загрузить настройки\",\n  },\n  advanced: {\n    title: \"Дополнительные инструменты\",\n    desc: \"Расширенные инструменты и утилиты для опытных пользователей\",\n    systemInfo: \"Информация о системе\",\n    rebuildEmbeddings: \"Пересоздать эмбеддинги\",\n    rebuildEmbeddingsDesc: \"Пересоздать индекс векторного поиска для всех источников\",\n    currentVersion: \"Текущая версия\",\n    latestVersion: \"Последняя версия\",\n    status: \"Статус\",\n    updateAvailable: \"Доступна версия {version}\",\n    updateAvailableDesc: \"Доступна новая версия Open Notebook.\",\n    upToDate: \"Актуальная версия\",\n    unknown: \"Неизвестно\",\n    viewOnGithub: \"Посмотреть на GitHub\",\n    updateCheckFailed: \"Не удалось проверить обновления. GitHub может быть недоступен.\",\n    rebuild: {\n      mode: \"Режим пересоздания\",\n      existing: \"Существующие\",\n      all: \"Все\",\n      existingDesc: \"Пересоздать эмбеддинги только для элементов с существующими эмбеддингами (быстрее, для смены модели)\",\n      allDesc: \"Пересоздать существующие + создать эмбеддинги для элементов без них (медленнее, полный охват)\",\n      include: \"Включить в пересоздание\",\n      selectOneError: \"Выберите хотя бы один тип элементов для пересоздания\",\n      starting: \"Запуск пересоздания...\",\n      startBtn: \"🚀 Начать пересоздание\",\n      queued: \"В очереди\",\n      running: \"Отправка задач...\",\n      completed: \"Задачи отправлены!\",\n      failed: \"Ошибка\",\n      leavePageHint: \"Вы можете покинуть эту страницу — процесс выполняется в фоне\",\n      startNew: \"Начать новое пересоздание\",\n      itemsProcessed: \"Отправлено задач: {processed}/{total} ({percent}%)\",\n      failedItems: \"Не удалось отправить задач: {count}\",\n      time: \"Время\",\n      whenToRebuild: \"Когда нужно пересоздавать эмбеддинги?\",\n      whenToRebuildAns: \"При смене модели, обновлении версии, исправлении повреждений или после массового импорта.\",\n      howLong: \"Сколько времени занимает пересоздание?\",\n      howLongAns: \"Время зависит от количества элементов, скорости модели и лимитов API. Локальные модели обычно очень быстрые.\",\n      isSafe: \"Безопасно ли пересоздавать во время работы с приложением?\",\n      isSafeAns: \"Да, пересоздание безопасно! Оно не удаляет контент, только заменяет эмбеддинги, и корректно обрабатывает ошибки.\",\n    },\n  },\n  transformations: {\n    title: \"Трансформации\",\n    desc: \"Трансформации — это промпты, которые LLM использует для обработки источника и извлечения инсайтов, резюме и т.д.\",\n    workspace: \"Выберите рабочее пространство\",\n    playground: \"Песочница\",\n    defaultPrompt: \"Промпт трансформации по умолчанию\",\n    defaultPromptDesc: \"Этот текст будет добавлен ко всем промптам трансформаций\",\n    defaultPromptPlaceholder: \"Введите инструкции трансформации по умолчанию...\",\n    listTitle: \"Пользовательские трансформации\",\n    createNew: \"Создать новую\",\n    inputLabel: \"Входной текст\",\n    inputPlaceholder: \"Введите текст для трансформации...\",\n    outputLabel: \"Результат\",\n    runTest: \"Запустить трансформацию\",\n    running: \"Выполнение...\",\n    selectToStart: \"Выберите трансформацию для начала\",\n    name: \"Название\",\n    namePlaceholder: \"Уникальный идентификатор, напр. key_topics\",\n    titlePlaceholder: \"Отображаемое название, по умолчанию совпадает с именем\",\n    promptPlaceholder: \"Напишите промпт для этой трансформации...\",\n    descriptionPlaceholder: \"Опишите, что делает эта трансформация.\",\n    suggestDefault: \"Предлагать по умолчанию для новых источников\",\n    promptHint: \"Промпты должны учитывать содержимое источника. Вы можете попросить модель резюмировать, извлечь инсайты или создать структурированный вывод, например таблицы.\",\n    createSuccess: \"Трансформация успешно создана\",\n    updateSuccess: \"Трансформация успешно обновлена\",\n    deleteSuccess: \"Трансформация успешно удалена\",\n    noTransformations: \"Пока нет трансформаций\",\n    createOne: \"Создайте трансформацию для начала\",\n    selectModel: \"Выберите модель\",\n    deleteConfirm: \"Вы уверены, что хотите удалить эту трансформацию?\",\n    model: \"Модель\",\n    systemPrompt: \"Системный промпт\",\n    overrideModelDesc: \"Переопределить модель по умолчанию для этой сессии чата. Оставьте пустым для использования системной модели.\",\n    sessionUseReplacement: \"Эта сессия будет использовать {name} вместо модели по умолчанию.\",\n    systemDefault: \"Системная по умолчанию\",\n  },\n  models: {\n    embedding: \"Модели эмбеддинга\",\n    tts: \"Озвучивание (TTS)\",\n    stt: \"Распознавание речи (STT)\",\n    apiKey: \"API-ключ\",\n    deleteSuccess: \"Модель успешно удалена\",\n    saveSuccess: \"Модель успешно сохранена\",\n    noModels: \"Нет моделей\",\n    discoverModels: \"Обнаружение моделей\",\n    noModelsFound: \"Модели от этого провайдера не найдены\",\n    modelType: \"Тип модели\",\n    modelTypeHint: \"Выберите тип для добавляемых моделей. Если нужны разные типы, добавляйте их отдельными партиями.\",\n    deleteModel: \"Удалить модель\",\n    defaultAssignments: \"Назначение моделей по умолчанию\",\n    defaultAssignmentsDesc: \"Настройте, какие модели использовать для различных задач в Open Notebook\",\n    missingRequiredModels: \"Отсутствуют необходимые модели: {models}. Open Notebook может работать некорректно без них.\",\n    selectModelPlaceholder: \"Выберите модель\",\n    requiredModelPlaceholder: \"⚠️ Обязательно — выберите модель\",\n    chatModelLabel: \"Модель чата\",\n    chatModelDesc: \"Используется для чат-разговоров\",\n    transformationModelLabel: \"Модель трансформаций\",\n    transformationModelDesc: \"Используется для резюме, инсайтов и трансформаций\",\n    toolsModelLabel: \"Модель инструментов\",\n    toolsModelDesc: \"Используется для вызова функций — рекомендуется OpenAI или Anthropic\",\n    largeContextModelLabel: \"Модель для большого контекста\",\n    largeContextModelDesc: \"Используется для обработки больших документов — рекомендуется Gemini\",\n    embeddingModelLabel: \"Модель эмбеддинга\",\n    embeddingModelDesc: \"Используется для семантического поиска и векторных эмбеддингов\",\n    ttsModelLabel: \"Модель озвучивания\",\n    ttsModelDesc: \"Используется для генерации подкастов\",\n    sttModelLabel: \"Модель распознавания речи\",\n    sttModelDesc: \"Используется для транскрибации аудио\",\n    embeddingChangeTitle: \"Изменение модели эмбеддинга\",\n    embeddingChangeConfirm: \"Вы собираетесь изменить модель эмбеддинга с {from} на {to}.\",\n    rebuildRequired: \"Важно: Требуется пересоздание\",\n    rebuildReason: \"Изменение модели эмбеддинга требует пересоздания всех существующих эмбеддингов для сохранения согласованности. Без пересоздания поиск может возвращать некорректные или неполные результаты.\",\n    whatHappensNext: \"Что произойдёт далее:\",\n    step1: \"Модель эмбеддинга по умолчанию будет обновлена\",\n    step2: \"Существующие эмбеддинги останутся неизменными до пересоздания\",\n    step3: \"Новый контент будет использовать новую модель эмбеддинга\",\n    step4: \"Вам следует пересоздать эмбеддинги как можно скорее\",\n    proceedToRebuildPrompt: \"Хотите перейти на страницу «Дополнительно», чтобы начать пересоздание сейчас?\",\n    changeModelOnly: \"Только изменить модель\",\n    changeAndRebuild: \"Изменить и перейти к пересозданию\",\n    autoAssign: \"Автоназначение по умолчанию\",\n    autoAssigning: \"Назначение...\",\n    autoAssignSuccess: \"{count} моделей по умолчанию автоматически назначено\",\n    autoAssignNoModels: \"Нет доступных моделей для назначения. Сначала синхронизируйте модели.\",\n    autoAssignAlreadySet: \"Все модели по умолчанию уже настроены\",\n    testModel: \"Тестировать модель\",\n    testModelSuccess: \"Тест модели пройден\",\n    testModelFailed: \"Тест модели не пройден\",\n    searchOrAddModel: \"Поиск или введите имя модели...\",\n    addCustomModel: \"Добавить \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"Настройте ИИ с помощью собственных API-ключей\",\n    description: \"Храните API-ключи в базе данных для безопасного подключения провайдеров ИИ в Open Notebook.\",\n    encryptionRequired: \"Ключ шифрования не настроен\",\n    encryptionRequiredDescription: \"Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY в любую секретную строку для хранения API-ключей в базе данных.\",\n    configured: \"Настроено\",\n    notConfigured: \"Не настроено\",\n    migrationAvailable: \"Обнаружены переменные окружения\",\n    migrationDescription: \"{count} API-ключ(ей) настроено через переменные окружения и может быть перенесено в базу данных для удобного управления.\",\n    migrateToDatabase: \"Перенести в базу данных\",\n    migrating: \"Перенос...\",\n    migrationSuccess: \"{count} API-ключ(ей) успешно перенесено\",\n    migrationErrors: \"{count} ключ(ей) не удалось перенести\",\n    migrationNothingToMigrate: \"Все ключи уже находятся в базе данных\",\n    learnMore: \"Узнайте, как настроить API-ключи →\",\n    testConnection: \"Проверить подключение\",\n    testSuccess: \"Подключение успешно\",\n    testFailed: \"Проверка подключения не удалась\",\n    syncModels: \"Синхронизировать модели\",\n    syncSuccess: \"Обнаружено {discovered} моделей, добавлено {new} новых\",\n    syncNoNew: \"Обнаружено {count} моделей, все уже зарегистрированы\",\n    syncFailed: \"Не удалось синхронизировать модели\",\n    getApiKey: \"Получить API-ключ\",\n    vertexProject: \"ID проекта GCP\",\n    vertexLocation: \"Регион\",\n    vertexCredentials: \"Путь к JSON сервисного аккаунта\",\n    addConfig: \"Добавить конфигурацию\",\n    editConfig: \"Редактировать конфигурацию\",\n    deleteConfig: \"Удалить конфигурацию\",\n    configName: \"Название конфигурации\",\n    configNameHint: \"Описательное название для этой конфигурации (например, «Продакшн», «Разработка»)\",\n    baseUrl: \"Базовый URL\",\n    baseUrlOverrideHint: \"Изменяйте только если нужно переопределить стандартную конечную точку API провайдера.\",\n    deleteConfigConfirm: \"Вы уверены, что хотите удалить «{name}»? Это действие необратимо.\",\n    configSaveSuccess: \"Конфигурация успешно сохранена\",\n    configUpdateSuccess: \"Конфигурация успешно обновлена\",\n    configDeleteSuccess: \"Конфигурация успешно удалена\",\n    apiKeyEditHint: \"Оставьте пустым, чтобы сохранить текущий API-ключ\",\n  },\n  setupBanner: {\n    encryptionRequired: \"Ключ шифрования не настроен\",\n    encryptionRequiredDescription: \"Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY для безопасного хранения учётных данных.\",\n    migrationAvailable: \"Доступна миграция API-ключей\",\n    migrationDescription: \"{count} провайдер(ов) имеют API-ключи, заданные через переменные окружения. Перенесите их в базу данных для удобного управления.\",\n    goToSettings: \"Перейти к настройкам\",\n    viewDocs: \"Документация\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/zh-CN/index.ts",
    "content": "export const zhCN = {\n  common: {\n    search: \"搜索...\",\n    create: \"新建\",\n    new: \"新建\",\n    cancel: \"取消\",\n    delete: \"删除\",\n    edit: \"编辑\",\n    theme: \"主题\",\n    signOut: \"退出登录\",\n    noMatches: \"未找到匹配项\",\n    tryDifferentSearch: \"尝试使用不同的搜索词。\",\n    light: \"浅色\",\n    dark: \"深色\",\n    system: \"系统\",\n    loading: \"加载中...\",\n    note: \"笔记\",\n    insight: \"洞察\",\n    newSource: \"新建来源\",\n    newNotebook: \"新建笔记本\",\n    newPodcast: \"新建播客\",\n    language: \"语言\",\n    english: \"English\",\n    chinese: \"简体中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"来源\",\n    notebook: \"笔记本\",\n    podcast: \"播客\",\n    quickActions: \"快捷操作\",\n    quickActionsDesc: \"导航、搜索、提问、主题\",\n    appName: \"Open Notebook\",\n    add: \"添加\",\n    remove: \"移除\",\n    confirm: \"确认\",\n    warning: \"警告\",\n    error: \"操作失败\",\n    success: \"操作成功\",\n    model: \"模型\",\n    back: \"返回\",\n    next: \"下一步\",\n    done: \"完成\",\n    processing: \"处理中...\",\n    creating: \"创建中...\",\n    linked: \"已关联\",\n    adding: \"正在添加...\",\n    addSelected: \"添加所选\",\n    customModel: \"自定义模型\",\n    failed: \"失败\",\n    current: \"当前\",\n    save: \"保存\",\n    writeNote: \"撰写笔记\",\n    batchMode: \"批量模式\",\n    optional: \"可选\",\n    type: \"类型\",\n    title: \"标题\",\n    created: \"创建于 {time}\",\n    updated: \"更新于 {time}\",\n    actions: \"快捷操作\",\n    noResults: \"未找到结果\",\n    references: \"引用\",\n    refreshPage: \"请重试刷新页面\",\n    refresh: \"刷新\",\n    aiGenerated: \"AI 生成\",\n    human: \"人类\",\n    unknown: \"未知\",\n    notes: \"笔记\",\n    chat: \"聊天\",\n    deleteForever: \"永久删除\",\n    connectionError: \"连接错误\",\n    unableToConnect: \"无法连接到 API 服务器\",\n    retryConnection: \"重试连接\",\n    diagnosticInfo: \"诊断信息\",\n    version: \"版本\",\n    built: \"构建时间\",\n    apiUrl: \"API 地址\",\n    frontendUrl: \"前端地址\",\n    checkConsoleLogs: \"请检查浏览器控制台获取详细日志（搜索 🔧 [Config] 消息）\",\n    yes: \"是\",\n    no: \"否\",\n    saving: \"正在保存...\",\n    description: \"描述\",\n    saveToNote: \"保存到笔记\",\n    copyToClipboard: \"复制到剪贴板\",\n    close: \"关闭\",\n    insights: \"见解\",\n    progress: \"进度\",\n    deleting: \"正在删除...\",\n    created_label: \"创建时间\",\n    updated_label: \"更新时间\",\n    download: \"下载\",\n    saveChanges: \"保存更改\",\n    name: \"名称\",\n    default: \"默认\",\n    nameRequired: \"这是必填项\",\n    modelConfiguration: \"模型配置\",\n    resetToDefault: \"重置为默认\",\n    reasoning: \"推理过程\",\n    searchTerms: \"搜索词\",\n    strategy: \"策略\",\n    individualAnswers: \"独立回答 ({count})\",\n    finalAnswer: \"最终回答\",\n    notebookLabel: \"笔记本: {name}\",\n    itemNotFound: \"未找到该 {type}\",\n    accessibility: {\n      transformationViews: \"转换视图\",\n      searchKB: \"向知识库提问或搜索\",\n      enterQuestion: \"输入您的问题以询问知识库\",\n      enterSearch: \"输入搜索词\",\n      searchKBBtn: \"搜索知识库\",\n      podcastViews: \"播客视图\",\n      ytVideo: \"YouTube 视频\",\n      askResponse: \"提问回答\",\n      searchNotebooks: \"搜索笔记本\",\n    },\n    url: \"URL\",\n    errorDetails: \"错误详情\",\n    editTransformation: \"编辑转换规则\",\n    retry: \"重试\",\n    traditionalChinese: \"繁体中文\",\n    portuguese: \"葡萄牙语\",\n    completed: \"已完成\",\n    saveSuccess: \"保存成功\",\n    contextModes: {\n      off: \"未包含在聊天中\",\n      insights: \"仅限见解\",\n      full: \"全部内容\",\n      clickToCycle: \"点击循环切换\",\n    },\n    clickToEdit: \"点击编辑\",\n  },\n  apiErrors: {\n    notebookNotFound: \"找不到笔记本\",\n    sourceNotFound: \"找不到源文件\",\n    transformationNotFound: \"找不到转换规则\",\n    fileUploadFailed: \"文件上传失败\",\n    urlRequired: \"链接类型需要提供 URL\",\n    contentRequired: \"文本类型需要提供内容\",\n    invalidSourceType: \"无效的源类型\",\n    processingFailed: \"处理失败\",\n    failedToQueue: \"排队处理失败\",\n    invalidSortBy: \"排序字段必须是 'created' 或 'updated'\",\n    invalidSortOrder: \"排序方向必须是 'asc' 或 'desc'\",\n    accessDenied: \"文件访问被拒绝\",\n    fileNotFoundOnServer: \"服务器上找不到该文件\",\n    searchFailed: \"搜索失败\",\n    askFailed: \"提问失败\",\n    pleaseEnterQuestion: \"请输入问题\",\n    pleaseConfigureModels: \"请配置所有必选模型\",\n    failedToCreateSession: \"创建对话失败\",\n    failedToUpdateSession: \"更新会话失败\",\n    failedToDeleteSession: \"删除会话失败\",\n    failedToSendMessage: \"发送消息失败\",\n    unauthorized: \"无权访问，请检查您的密码\",\n    invalidPassword: \"密码错误\",\n    embeddingModelRequired: \"此功能需要嵌入模型。请在模型设置中配置一个。\",\n    strategyModelNotFound: \"未找到策略模型\",\n    answerModelNotFound: \"未找到回答模型\",\n    finalAnswerModelNotFound: \"未找到最终回答模型\",\n    noAnswerGenerated: \"未能生成回答\",\n    genericError: \"发生了意外错误\",\n  },\n  connectionErrors: {\n    apiTitle: \"无法连接到 API 服务器\",\n    apiDesc: \"无法访问 Open Notebook API 服务器\",\n    dbTitle: \"数据库连接失败\",\n    dbDesc: \"API 服务器正在运行，但无法访问数据库\",\n    troubleshooting: \"这通常意味着：\",\n    apiUnreachable1: \"API 服务器未运行\",\n    apiUnreachable2: \"API 服务器运行在不同的地址\",\n    apiUnreachable3: \"网络连接问题\",\n    dbFailed1: \"SurrealDB 未运行\",\n    dbFailed2: \"数据库连接设置不正确\",\n    dbFailed3: \"API 与数据库之间的网络问题\",\n    quickFixes: \"快速修复：\",\n    setApiUrl: \"设置 API_URL 环境变量：\",\n    checkSurreal: \"检查 SurrealDB 是否运行：\",\n    seeDocumentation: \"有关详细设置说明，请参阅：\",\n    docLink: \"Open Notebook 文档\",\n    showTechnical: \"显示技术细节\",\n    attemptedUrl: \"尝试的 URL\",\n    message: \"消息\",\n    technicalDetails: \"技术细节\",\n    stackTrace: \"堆栈跟踪\",\n    retryLabel: \"重试连接\",\n    retryHint: \"按 R 或点击按钮重试\",\n    dockerLabel: \"对于 Docker\",\n    localDevLabel: \"对于本地开发\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"输入密码以访问应用程序\",\n    passwordPlaceholder: \"密码\",\n    signingIn: \"正在登录...\",\n    signIn: \"登录\",\n    connectErrorHint: \"无法连接到服务器。请检查 API 是否正在运行。\",\n  },\n  navigation: {\n    collect: \"采集\",\n    process: \"处理\",\n    create: \"创作\",\n    manage: \"管理\",\n    sources: \"来源\",\n    notebooks: \"笔记本\",\n    askAndSearch: \"询问与搜索\",\n    podcasts: \"播客\",\n    models: \"模型\",\n    transformations: \"转换\",\n    transformation: \"转换\",\n    settings: \"设置\",\n    advanced: \"高级\",\n    nav: \"导航\",\n    language: \"切换语言\",\n    theme: \"主题\",\n    ask: \"提问\",\n  },\n  notebooks: {\n    title: \"笔记本\",\n    newNotebook: \"创建笔记本\",\n    searchPlaceholder: \"搜索笔记本...\",\n    archived: \"已归档\",\n    archive: \"归档\",\n    unarchive: \"取消归档\",\n    deleteNotebook: \"删除笔记本\",\n    deleteNotebookDesc: \"您确定要删除 \\\"{name}\\\" 吗？此操作无法撤销。\",\n    deleteNotebookLoading: \"正在加载删除预览...\",\n    deleteNotebookNotes: \"{count} 个笔记将被永久删除。\",\n    deleteNotebookNoNotes: \"没有要删除的笔记。\",\n    deleteNotebookExclusiveSources: \"{count} 个来源仅存在于此笔记本中。\",\n    deleteNotebookSharedSources: \"{count} 个来源与其他笔记本共享，将被取消关联。\",\n    deleteNotebookNoSources: \"此笔记本中没有来源。\",\n    deleteExclusiveSourcesLabel: \"删除专属来源\",\n    keepExclusiveSourcesLabel: \"取消关联并保留\",\n    activeNotebooks: \"活动的笔记本\",\n    archivedNotebooks: \"归档的笔记本\",\n    notFound: \"未找到笔记本\",\n    notFoundDesc: \"请求的笔记本不存在。\",\n    updated: \"已更新\",\n    namePlaceholder: \"笔记本名称\",\n    addDescription: \"添加描述...\",\n    noNotesYet: \"暂无笔记\",\n    deleteNote: \"删除笔记\",\n    deleteNoteConfirm: \"确定要删除此笔记吗？此操作无法撤销。\",\n    noteCreatedSuccess: \"笔记创建成功\",\n    failedToCreateNote: \"创建笔记失败\",\n    noteUpdatedSuccess: \"笔记更新成功\",\n    failedToUpdateNote: \"更新笔记失败\",\n    noteDeletedSuccess: \"笔记删除成功\",\n    failedToDeleteNote: \"删除笔记失败\",\n    createNew: \"创建新笔记本\",\n    createNewDesc: \"输入名称和可选描述以开始。\",\n    descPlaceholder: \"在此添加有关此笔记本的更多信息...\",\n    createSuccess: \"笔记本创建成功\",\n    updateSuccess: \"笔记本更新成功\",\n    deleteSuccess: \"笔记本删除成功\",\n  },\n  sources: {\n    title: \"来源\",\n    add: \"添加来源\",\n    addNew: \"添加新来源\",\n    addExisting: \"添加现有来源\",\n    delete: \"删除来源\",\n    statusPreparing: \"正在准备\",\n    statusQueued: \"已排队\",\n    statusProcessing: \"正在处理\",\n    statusCompleted: \"已完成\",\n    statusFailed: \"处理失败\",\n    statusPreparingDesc: \"准备处理中\",\n    statusQueuedDesc: \"等待处理\",\n    statusProcessingDesc: \"正在处理内容\",\n    statusCompletedDesc: \"处理成功\",\n    statusFailedDesc: \"处理失败\",\n    failedToLoad: \"加载来源失败\",\n    allSourcesDesc: \"在此查看所有来源。您可以添加新来源或管理现有来源。\",\n    allSources: \"所有来源\",\n    insights: \"见解\",\n    yes: \"是\",\n    no: \"否\",\n    loadingMore: \"正在加载更多...\",\n    noSourcesYet: \"暂无来源\",\n    allSourcesDescShort: \"在此查看所有来源。\",\n    cannotSaveNoteNoNotebook: \"无法保存笔记：缺少笔记本 ID\",\n    createFirstSource: \"添加您的第一个来源开始构建知识库。\",\n    deleteSourceConfirm: \"确定要删除此来源吗？\",\n    deleteConfirm: \"确定要删除吗？\",\n    deleteConfirmWithTitle: \"确定要删除 \\\"{title}\\\" 吗？\",\n    deleteSuccess: \"来源删除成功。注意：要从存储中删除文件，必须在设置页面中启用“删除文件”选项。\",\n    failedToDelete: \"删除来源失败\",\n    sourceQueued: \"来源已加入队列\",\n    sourceQueuedDesc: \"来源已提交进行后台处理。您可以在来源列表中监控进度。\",\n    sourceAddedSuccess: \"来源添加成功\",\n    failedToAddSource: \"添加来源失败\",\n    sourceUpdatedSuccess: \"来源更新成功\",\n    failedToUpdateSource: \"更新来源失败\",\n    sourceDeletedSuccess: \"来源删除成功\",\n    failedToDeleteSource: \"删除来源失败\",\n    fileUploadedSuccess: \"文件上传成功\",\n    failedToUploadFile: \"文件上传失败\",\n    sourceRequeued: \"来源重试已加入队列\",\n    sourceRequeuedDesc: \"来源已重新加入处理队列。\",\n    failedToRetry: \"重试失败\",\n    sourcesAddedToNotebook: \"{count} 个来源已添加到笔记本\",\n    failedToAddSourcesToNotebook: \"添加来源到笔记本失败\",\n    partialAddSuccess: \"{success} 个来源已添加，{failed} 个失败\",\n    sourceRemovedFromNotebook: \"来源已成功从笔记本中移除\",\n    failedToRemoveSourceFromNotebook: \"从笔记本中移除来源失败\",\n    removeConfirm: \"确定要从此笔记本移除吗？\",\n    checking: \"正在检查...\",\n    untitledSource: \"未命名来源\",\n    maxItems: \"最多 {count} 个\",\n    insightsCount: \"{count} 条见解\",\n    details: \"详情\",\n    detailsTitle: \"来源详情\",\n    content: \"内容\",\n    metadata: \"元数据\",\n    type: {\n      link: \"链接\",\n      file: \"文件\",\n      text: \"文本\",\n    },\n    id: \"来源 ID\",\n    topics: \"主题\",\n    embedded: \"已嵌入向量\",\n    notEmbedded: \"未嵌入向量\",\n    embedContent: \"嵌入内容\",\n    embedding: \"正在嵌入...\",\n    alreadyEmbedded: \"已嵌入\",\n    downloadFile: \"下载文件\",\n    fileUnavailable: \"文件不可用\",\n    preparing: \"正在准备...\",\n    generateNewInsight: \"生成新见解\",\n    selectTransformation: \"选择转换规则...\",\n    noInsightsYet: \"暂无见解\",\n    createFirstInsight: \"使用上方的转换规则创建您的第一个见解\",\n    viewInsight: \"查看见解\",\n    deleteInsight: \"删除见解\",\n    deleteInsightConfirm: \"确定要删除此见解吗？此操作无法撤销。\",\n    insightGenerationStarted: \"见解生成已开始，稍后将显示。\",\n    editNote: \"编辑笔记\",\n    createNote: \"创建笔记\",\n    addTitle: \"添加标题...\",\n    untitledNote: \"无标题笔记\",\n    writeNotePlaceholder: \"在此处编写您的笔记内容...\",\n    saveNote: \"保存笔记\",\n    createNoteBtn: \"创建笔记\",\n    createFirstNote: \"创建您的第一条笔记，记录见解与观察。\",\n    urlLabel: \"URL(s) *\",\n    fileLabel: \"文件(s) *\",\n    textContentLabel: \"文本内容 *\",\n    enterUrlsPlaceholder: \"每行输入一个 URL\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"粘贴多个 URL（每行一个）进行批量导入\",\n    invalidUrlsDetected: \"检测到无效的 URL：\",\n    lineLabel: \"第 {line} 行\",\n    fixInvalidUrls: \"请修正或移除无效的 URL 以继续\",\n    selectMultipleFilesHint: \"选择多个文件进行批量导入。支持：文档 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD)，媒体 (MP4, MP3, WAV, M4A)，图片 (JPG, PNG)，归档 (ZIP)\",\n    selectedFiles: \"已选择文件：\",\n    textPlaceholder: \"在此处粘贴或输入您的内容...\",\n    htmlDetected: \"检测到 HTML 内容。处理后将转换为 Markdown。\",\n    titlePlaceholder: \"为您的来源起一个描述性的标题\",\n    batchTitlesAuto: \"将为每个来源自动生成标题。\",\n    batchCommonSettings: \"同样的笔记本和转换将应用于所有项目。\",\n    urlsCount: \"{count} 个 URL\",\n    filesCount: \"{count} 个文件\",\n    addSource: \"添加来源\",\n    notEmbeddedAlert: \"内容未嵌入向量\",\n    notEmbeddedDesc: \"此内容尚未为了向量搜索进行嵌入。嵌入可以启用高级搜索功能并更好地发现内容。\",\n    openOnYoutube: \"在 YouTube 上打开\",\n    urlCopied: \"URL 已复制到剪贴板\",\n    viewSource: \"查看来源\",\n    noInsightSelected: \"未选择见解\",\n    sourceInsight: \"来源见解\",\n    manageNotebooks: \"管理所属笔记本\",\n    manageNotebooksDesc: \"管理包含此来源的笔记本\",\n    noNotebooksAvailable: \"暂无可用笔记本\",\n    loadFailed: \"加载来源详情失败\",\n    removeFromNotebook: \"从笔记本移除\",\n    retryProcessing: \"重试处理\",\n    deleteSource: \"删除来源\",\n    retry: \"重试\",\n    addExistingTitle: \"添加现有来源\",\n    addExistingDesc: \"从您的所有笔记本中选择已有的来源添加到当前笔记本。\",\n    searchPlaceholder: \"通过名称或 URL 搜索来源...\",\n    noNotebooksFound: \"未找到笔记本。\",\n    showingFirst100: \"仅显示前 100 个来源。请使用搜索功能查找特定来源。\",\n    selectedCount: \"已选择 {count} 个来源\",\n    added: \"已添加于 {date}\",\n    addUrl: \"添加 URL\",\n    uploadFile: \"上传文件\",\n    enterText: \"输入文本\",\n    processDescription: \"内容将经过处理并由 AI 分析。\",\n    processingFiles: \"正在处理您的文件...\",\n    titleRequired: \"文本内容必须提供标题\",\n    titleGenerated: \"如果留空，将根据内容自动生成标题\",\n    batchCount: \"将处理 {count} 个{type}\",\n    enableEmbedding: \"启用搜索向量嵌入\",\n    embeddingDesc: \"允许此来源在向量搜索和 AI 查询中被检索\",\n    embeddingAlways: \"已自动启用嵌入\",\n    embeddingAlwaysDesc: \"您的设置已配置为始终对内容进行向量嵌入。\",\n    embeddingNever: \"嵌入已禁用\",\n    embeddingNeverDesc: \"您的设置已配置为跳过嵌入。此来源将无法进行向量搜索。\",\n    changeInSettings: \"您可以在此处更改设置：\",\n    notFound: \"未找到来源\",\n    noContent: \"暂无内容\",\n    insightsDesc: \"根据模型分析生成的见解\",\n    uploadedFile: \"已上传文件\",\n    fileUnavailableDesc: \"由于存储系统原因，此文件目前不可用。\",\n    batchSuccess: \"成功创建 {count} 个来源\",\n    batchFailed: \"全部 {count} 个来源创建失败\",\n    batchPartial: \"{success} 个成功，{failed} 个失败\",\n    submittingSource: \"正在提交来源进行处理...\",\n    processingBatchSources: \"正在处理 {count} 个来源，请稍候...\",\n    processingSource: \"正在处理您的来源，请稍候...\",\n    maxFilesAllowed: \"每批最多允许 {count} 个文件\",\n  },\n  chat: {\n    sessions: \"会话\",\n    sessionTitlePlaceholder: \"在此输入标题...\",\n    noSessions: \"暂无会话\",\n    deleteSession: \"删除会话\",\n    deleteSessionDesc: \"确定要删除此聊天会话吗？此操作无法撤销。\",\n    sendPlaceholder: \"向您的来源提问...\",\n    sessionsTitle: \"对话列表\",\n    chatWith: \"与{name}对话\",\n    startConversation: \"开始针对{type}进行对话\",\n    askQuestions: \"提出问题以更好地理解内容\",\n    pressToSend: \"按 {key} 发送\",\n    model: \"模型\",\n    createToStart: \"创建一个会话以开始。\",\n    chatWithNotebook: \"与笔记本对话\",\n    unableToLoadChat: \"无法加载聊天\",\n    noDescription: \"暂无描述\",\n    startByCreating: \"从创建您的第一个笔记本开始，组织您的研究。\",\n    messagesCount: \"{count} 条消息\",\n    sessionCreated: \"聊天会话已创建\",\n    sessionUpdated: \"会话已更新\",\n    sessionDeleted: \"会话已删除\",\n  },\n  searchPage: {\n    askAndSearch: \"提问与搜索\",\n    chooseAMode: \"选择模式\",\n    askBeta: \"提问 (beta)\",\n    search: \"搜索\",\n    askYourKb: \"向您的知识库提问 (beta)\",\n    askYourKbDesc: \"大语言模型将根据您知识库中的文档回答您的查询。\",\n    question: \"问题\",\n    enterQuestionPlaceholder: \"输入您的问题...\",\n    pressToSubmit: \"按 Cmd/Ctrl+Enter 提交\",\n    noEmbeddingModel: \"您无法使用此功能，因为尚未选择嵌入模型。请在模型页面设置一个。\",\n    usingCustomModels: \"正在使用自定义模型\",\n    usingDefaultModels: \"正在使用默认模型\",\n    advanced: \"高级\",\n    strategy: \"策略\",\n    answer: \"回答\",\n    final: \"最终\",\n    ask: \"提问\",\n    processing: \"处理中...\",\n    saveToNotebooks: \"保存到笔记本\",\n    searchDesc: \"在您的知识库中搜索特定的关键字或概念\",\n    enterSearchPlaceholder: \"输入搜索查询...\",\n    pressToSearch: \"按 Enter 键搜索\",\n    searchType: \"搜索类型\",\n    vectorSearchWarning: \"向量搜索需要嵌入模型。目前仅文本搜索可用。\",\n    textSearch: \"文本搜索\",\n    vectorSearch: \"向量搜索\",\n    searchIn: \"搜索范围\",\n    searchSources: \"搜索来源\",\n    searchNotes: \"搜索笔记\",\n    resultsFound: \"{count} 个结果\",\n    matches: \"匹配项 ({count})\",\n    noResultsFor: \"未找到 “{query}” 的结果\",\n    notSet: \"未设置\",\n    saveToNotebook: \"保存到笔记本\",\n    saveSuccess: \"成功保存到笔记本\",\n    saveError: \"保存到笔记本失败\",\n    selectNotebook: \"选择笔记本\",\n    searchAndAsk: \"搜索与提问\",\n    searchResultsFor: \"搜索 “{query}”\",\n    askAbout: \"提问关于 “{query}”\",\n    orSearchKb: \"或搜索您的知识库\",\n    saving: \"保存中...\",\n    advancedModelTitle: \"高级模型选择\",\n    advancedModelDesc: \"为提问过程的每个阶段选择特定的模型\",\n    strategyModel: \"策略模型\",\n    answerModel: \"回答模型\",\n    finalAnswerModel: \"最终回答模型\",\n    selectStrategyPlaceholder: \"选择策略模型\",\n    selectAnswerPlaceholder: \"选择回答模型\",\n    selectFinalPlaceholder: \"选择最终回答模型\",\n    saveChanges: \"保存更改\",\n    processingQuestion: \"正在处理您的问题...\",\n  },\n  podcasts: {\n    generateEpisode: \"生成播客单集\",\n    generateEpisodeDesc: \"在生成新的播客单集之前，选择要包含的内容并配置单集详情。\",\n    content: \"内容\",\n    contentDesc: \"选择要包含在此单集中的笔记本、来源和笔记。\",\n    itemsSelected: \"{count} 个项目已选择\",\n    tokens: \"{count} token\",\n    chars: \"{count} 字符\",\n    loadingNotebooks: \"正在加载笔记本...\",\n    noNotebooksFoundInPodcasts: \"未找到笔记本。在生成播客之前，请先创建一个笔记本并添加内容。\",\n    noContentSelected: \"未选择内容\",\n    summary: \"摘要\",\n    fullContent: \"全部内容\",\n    untitledSource: \"未命名来源\",\n    untitledNote: \"未命名笔记\",\n    episodeSettings: \"单集设置\",\n    episodeProfile: \"单集简介\",\n    episodeProfilePlaceholder: \"选择单集简介\",\n    episodeName: \"单集名称\",\n    episodeNamePlaceholder: \"例如：AI 与工作的未来\",\n    additionalInstructions: \"附加指令\",\n    instructionsPlaceholder: \"任何要追加到单集简讯的补充建议...\",\n    generating: \"正在生成...\",\n    generate: \"生成\",\n    hostPlaceholder: \"主持人 {number}\",\n    profileRequired: \"需要单集简介\",\n    profileRequiredDesc: \"在生成播客之前，请选择一个单集简介。\",\n    nameRequired: \"需要单集名称\",\n    nameRequiredDesc: \"请为单集提供一个名称。\",\n    addContext: \"添加上下文\",\n    addContextDesc: \"至少选择一个来源或笔记包含在单集中。\",\n    generationFailed: \"播客生成失败\",\n    speakerProfile: \"主持人简介\",\n    usesSpeakerProfile: \"使用主持人简介\",\n    sources: \"来源\",\n    notes: \"笔记\",\n    noSources: \"此笔记本中没有可用的来源。\",\n    noNotes: \"此笔记本中没有可用的笔记。\",\n    selectMode: \"选择模式\",\n    buildContextFailed: \"构建上下文失败。请检查您的选择。\",\n    podcastTaskStarted: \"播客生成任务已开始\",\n    loadingProfiles: \"正在加载单集简介...\",\n    noProfilesFound: \"未找到单集简介。在生成播客之前，请先创建一个单集简介。\",\n    listTitle: \"播客\",\n    listDesc: \"跟踪生成的单集并管理可重复使用的简介。\",\n    chooseAView: \"选择视图\",\n    episodesTab: \"单集\",\n    templatesTab: \"配置\",\n    overviewTitle: \"单集概览\",\n    overviewDesc: \"监控播客生成任务并查看最终成品。\",\n    generateBtn: \"生成播客\",\n    total: \"总计\",\n    processingLabel: \"处理中\",\n    completedLabel: \"已完成\",\n    failedLabel: \"失败\",\n    pendingLabel: \"排队中\",\n    loadErrorTitle: \"加载单集失败\",\n    loadErrorDesc: \"无法获取最新的播客单集。请稍后重试。\",\n    loadingEpisodes: \"正在加载单集...\",\n    noEpisodesYet: \"暂无播客单集。从笔记本或来源聊天界面开始生成您的第一个单集。\",\n    statusRunningTitle: \"正在处理中\",\n    statusRunningDesc: \"正在活跃生成资产的单集。\",\n    statusPendingTitle: \"排队中 / 待处理\",\n    statusPendingDesc: \"已提交并在等待开始处理的单集。\",\n    statusCompletedTitle: \"已完成单集\",\n    statusCompletedDesc: \"可以查看、下载或发布。\",\n    statusFailedTitle: \"失败单集\",\n    statusFailedDesc: \"在生成过程中遇到问题的单集。\",\n    templatesWorkspaceTitle: \"简介工作区\",\n    templatesWorkspaceDesc: \"构建可重复使用的单集和发言人配置，以实现快速的播客制作。\",\n    howTemplatesPowerTitle: \"简介如何驱动播客生成\",\n    howTemplatesPowerDesc: \"简介将播客工作流拆分为两个可重复使用的构建块。在生成新单集时可以随时混合搭配它们。\",\n    episodeProfilesSetFormat: \"单集简介设定格式\",\n    episodeProfilesList1: \"概述分段数量及故事流向\",\n    episodeProfilesList2: \"选择用于简报、大纲和脚本编写的语言模型\",\n    episodeProfilesList3: \"存储默认简报，以便每个单集都以一致的基调开始\",\n    speakerProfilesBringVoices: \"发言人简介赋予声音生命\",\n    speakerProfilesList1: \"选择文字转语音库及模型\",\n    speakerProfilesList2: \"记录每个发言人的性格、背景故事和发音说明\",\n    speakerProfilesList3: \"在不同的单集格式中重复使用相同的主持人或嘉宾声音\",\n    recommendedWorkflow: \"推荐工作流\",\n    workflowStep1: \"为您需要的每个声音创建发言人简介\",\n    workflowStep2: \"构建按名称引用这些发言人的单集简介\",\n    workflowStep3: \"通过选择适合故事的单集简介来生成播客\",\n    workflowHint: \"单集简介按名称引用发言人简介，因此从发言人开始可以避免以后缺少声音指派。\",\n    failedToLoadTemplates: \"加载简介数据失败\",\n    failedToLoadTemplatesDesc: \"请确保 API 正在运行并重试。某些部分可能不完整。\",\n    loadingTemplates: \"正在加载简介...\",\n    speakerProfilesTitle: \"发言人简介\",\n    speakerProfilesDesc: \"为生成的单集配置声音和性格。\",\n    createSpeaker: \"创建发言人\",\n    noSpeakerProfiles: \"暂无发言人简介。创建一个以使单集简介可用。\",\n    noDescription: \"未提供描述。\",\n    usedByCount_one: \"被 1 个单集使用\",\n    usedByCount_other: \"被 {count} 个单集使用\",\n    usedByCount: \"被 {count} 个单集使用\",\n    unused: \"未使用\",\n    voiceId: \"声音 ID\",\n    backstory: \"背景故事\",\n    personality: \"性格\",\n    edit: \"编辑\",\n    duplicate: \"复制\",\n    deleteSpeakerProfileTitle: \"删除发言人简介？\",\n    deleteSpeakerProfileDesc: \"删除 “{name}” 无法撤销。\",\n    deleteSpeakerDisabledHint: \"在删除之前，请先从单集简介中移除该发言人。\",\n    deleting: \"正在删除...\",\n    episodeProfilesTitle: \"单集简介\",\n    episodeProfilesDesc: \"为您播客定义可重复使用的生成设置。\",\n    createProfile: \"创建简介\",\n    createSpeakerFirst: \"在添加单集简介之前，请先创建一个发言人简介。\",\n    noEpisodeProfiles: \"暂无单集简介。创建一个以启动播客生成。\",\n    speakerCreated: \"发言人配置已创建\",\n    speakerCreatedDesc: \"发言人配置已准备就绪。\",\n    failedToCreateSpeaker: \"创建发言人配置失败\",\n    speakerUpdated: \"发言人配置已更新\",\n    speakerUpdatedDesc: \"更改已成功保存。\",\n    failedToUpdateSpeaker: \"更新发言人配置失败\",\n    speakerDeleted: \"发言人配置已删除\",\n    speakerDeletedDesc: \"配置已成功移除。\",\n    failedToDeleteSpeaker: \"删除发言人配置失败\",\n    speakerDuplicated: \"发言人配置已复制\",\n    speakerDuplicatedDesc: \"已创建配置副本。\",\n    failedToDuplicateSpeaker: \"复制发言人配置失败\",\n    generationStarted: \"播客启动生成\",\n    generationStartedDesc: \"剧集 \\\"{name}\\\" 正在创建中。\",\n    failedToStartGeneration: \"启动播客生成失败\",\n    tryAgainMoment: \"请稍后再试。\",\n    deleteProfileTitle: \"删除简介？\",\n    deleteProfileDesc: \"这将移除 “{name}”。现有单集将保留其数据，但新单集将不再使用此配置。\",\n    profileCreated: \"剧集配置已创建\",\n    profileCreatedDesc: \"新的剧集配置已准备就绪。\",\n    failedToCreateProfile: \"创建剧集配置失败\",\n    profileUpdated: \"剧集配置已更新\",\n    profileUpdatedDesc: \"更改已成功保存。\",\n    failedToUpdateProfile: \"更新剧集配置失败\",\n    profileDeleted: \"剧集配置已删除\",\n    profileDeletedDesc: \"配置已成功移除。\",\n    failedToDeleteProfile: \"删除剧集配置失败\",\n    failedToDeleteProfileDesc: \"请确保配置未在使用中并重试。\",\n    profileDuplicated: \"剧集配置已复制\",\n    profileDuplicatedDesc: \"已创建配置副本。\",\n    failedToDuplicateProfile: \"复制剧集配置失败\",\n    episodeDeleted: \"剧集已删除\",\n    episodeDeletedDesc: \"播客剧集已成功移除。\",\n    failedToDeleteEpisode: \"删除剧集失败\",\n    failedToDeleteSpeakerDesc: \"请确保配置未在使用中并重试。\",\n    outlineModel: \"大纲模型\",\n    transcriptModel: \"脚本模型\",\n    segments: \"分段数量\",\n    defaultBriefingTitle: \"默认简报\",\n    created: \"创建于 {time}\",\n    details: \"详情\",\n    summaryTab: \"总结\",\n    outlineTab: \"大纲\",\n    transcriptTab: \"脚本\",\n    briefing: \"内容简报\",\n    noOutline: \"暂无大纲。\",\n    noTranscript: \"暂无脚本。\",\n    deleteEpisodeTitle: \"删除单集？\",\n    deleteEpisodeDesc: \"这将永久移除 “{name}” 及其音频文件。\",\n    audioUnavailable: \"音频不可用\",\n    segment: \"分段\",\n    speaker: \"发言人\",\n    profile: \"简介\",\n    link: \"链接\",\n    file: \"文件\",\n    embedded: \"已嵌入\",\n    notEmbedded: \"未嵌入\",\n    noSpeakerProfilesAvailable: \"没有可用的发言人简介\",\n    editEpisodeProfile: \"编辑单集简介\",\n    createEpisodeProfile: \"创建单集简介\",\n    episodeProfileFormDesc: \"定义单集生成的规则及默认使用的发言人配置。\",\n    noSpeakerProfilesDesc: \"在配置单集简介之前，请先创建一个发言人简介。\",\n    profileName: \"简介名称\",\n    profileNamePlaceholder: \"例如：技术讨论\",\n    descriptionPlaceholder: \"简要说明何时使用此简介\",\n    speakerConfig: \"发言人配置\",\n    selectSpeakerProfile: \"选择发言人简介\",\n    outlineGeneration: \"大纲生成\",\n    transcriptGeneration: \"文稿生成\",\n    defaultBriefingPlaceholder: \"概述此单集格式的结构、语气和目标\",\n    editSpeakerProfile: \"编辑发言人简介\",\n    createSpeakerProfile: \"创建发言人简介\",\n    speakerProfileFormDesc: \"配置文字转语音设置并定义最多四名发言人。\",\n    speakers: \"发言人\",\n    speakersDesc: \"为此简介配置一到四种声音。\",\n    addSpeaker: \"添加发言人\",\n    speakerNumber: \"发言人 {number}\",\n    backstoryPlaceholder: \"发言人的简要传记或背景信息\",\n    personalityPlaceholder: \"描述风格和语气\",\n    outlineModelRequired: \"必须选择大纲模型\",\n    transcriptModelRequired: \"必须选择文稿模型\",\n    defaultBriefingRequired: \"必须填写默认简介\",\n    segmentsInteger: \"必须是整数\",\n    segmentsMin: \"至少包含 3 个分段\",\n    segmentsMax: \"最多包含 20 个分段\",\n    voiceIdRequired: \"必须填写声音 ID\",\n    backstoryRequired: \"必须填写背景故事\",\n    personalityRequired: \"必须填写性格描述\",\n    speakerCountMin: \"至少需要一个发言人\",\n    speakerCountMax: \"最多只能配置 4 个发言人\",\n    delete: \"删除\",\n    failedToDelete: \"删除播客失败\",\n    retry: \"重试\",\n    retrying: \"重试中…\",\n    retryStarted: \"已开始重试\",\n    retryStartedDesc: \"已提交新的播客生成任务。\",\n    failedToRetry: \"重试失败\",\n    errorDetails: \"错误详情\",\n    language: \"语言\",\n    languagePlaceholder: \"选择语言（可选）\",\n    podcastLanguage: \"播客语言\",\n    selectOutlineModel: \"选择大纲模型\",\n    selectTranscriptModel: \"选择转录模型\",\n    voiceModel: \"语音模型\",\n    voiceModelRequired: \"语音模型为必填项\",\n    selectVoiceModel: \"选择语音模型\",\n    perSpeakerTtsOverride: \"每个发言人的TTS覆盖（可选）\",\n    useProfileDefault: \"使用配置默认值\",\n    setupRequired: \"需要配置\",\n    setupRequiredDesc: \"部分配置尚未设置模型。请编辑它们以在生成播客之前选择模型。\",\n    notConfigured: \"未配置\",\n  },\n  settings: {\n    contentProcessing: \"内容处理\",\n    contentProcessingDesc: \"配置文档和 URL 的处理方式\",\n    docEngine: \"文档处理引擎\",\n    docEnginePlaceholder: \"选择文档处理引擎\",\n    urlEngine: \"URL 处理引擎\",\n    urlEnginePlaceholder: \"选择 URL 处理引擎\",\n    autoRecommended: \"自动 (推荐)\",\n    simple: \"Simple\",\n    docling: \"Docling\",\n    helpMeChoose: \"帮助我选择\",\n    docHelp: \"· Docling: 速度稍慢但更准确，特别是包含表格和图像的文档。 · Simple: 直接提取内容而不进行格式化。 · 自动 (推荐): 优先尝试 Docling，失败则回退至 Simple。\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl: 强大的付费服务（有免费额度）。 · Jina: 优秀的备选方案，同样提供免费额度。 · Simple: 基础 HTTP 提取，在 JS 渲染的网站上可能会丢失内容。 · 自动 (推荐): 优先尝试 Firecrawl，其次 Jina，最后回退至 Simple。\",\n    embeddingAndSearch: \"嵌入与搜索\",\n    embeddingAndSearchDesc: \"配置搜索和向量嵌入选项\",\n    defaultEmbeddingOption: \"默认嵌入选项\",\n    embeddingOptionPlaceholder: \"选择嵌入选项\",\n    ask: \"询问\",\n    always: \"始终\",\n    never: \"从不\",\n    embeddingHelp: \"将内容进行向量嵌入可以让您和您的 AI 助手更容易找到它。如果您运行本地嵌入模型（如 Ollama），建议开启。对于在线服务商，只有在每天处理数百个文档时才需考虑成本。\",\n    fileManagement: \"文件管理\",\n    fileManagementDesc: \"配置文件的处理和存储选项\",\n    autoDeleteFiles: \"自动删除文件\",\n    autoDeletePlaceholder: \"选择自动删除选项\",\n    filesHelp: \"文件处理完成后，原始件不再需要。建议开启自动删除以节省存储空间。除非您将其作为主要存储位置（不建议），否则请选择“是”。\",\n    loadFailed: \"加载设置失败\",\n  },\n  advanced: {\n    title: \"高级工具\",\n    desc: \"面向进阶用户的调试和实用工具\",\n    systemInfo: \"系统信息\",\n    rebuildEmbeddings: \"重建索引\",\n    rebuildEmbeddingsDesc: \"为所有来源重建向量索引\",\n    currentVersion: \"当前版本\",\n    latestVersion: \"最新版本\",\n    status: \"状态\",\n    updateAvailable: \"版本 {version} 可用\",\n    updateAvailableDesc: \"Open Notebook 的新版本可用。\",\n    upToDate: \"已是最新\",\n    unknown: \"未知\",\n    viewOnGithub: \"在 GitHub 上查看\",\n    updateCheckFailed: \"无法检查更新。GitHub 可能无法访问。\",\n    rebuild: {\n      mode: \"重建模式\",\n      existing: \"仅现有项\",\n      all: \"全部项\",\n      existingDesc: \"仅重新嵌入已有向量的项（速度较快，适用于切换模型）\",\n      allDesc: \"重新嵌入已有项 + 为缺失向量的项补全（速度较慢，较全面）\",\n      include: \"包含在重建中\",\n      selectOneError: \"请至少选择一种重建类型\",\n      starting: \"正在启动重建...\",\n      startBtn: \"开始重建\",\n      queued: \"排队中\",\n      running: \"正在提交任务...\",\n      completed: \"任务已提交!\",\n      failed: \"失败\",\n      leavePageHint: \"您可以离开此页面，后台将继续运行\",\n      startNew: \"开始新的重建\",\n      itemsProcessed: \"{processed}/{total} 任务已提交 ({percent}%)\",\n      failedItems: \"{count} 任务提交失败\",\n      time: \"耗时\",\n      whenToRebuild: \"我该何时重建索引？\",\n      whenToRebuildAns: \"当您切换嵌入模型、升级模型版本、怀疑数据损坏或进行了大批量内容导入后，建议执行重建。\",\n      howLong: \"重建需要多长时间？\",\n      howLongAns: \"耗时取决于项目总数、模型速度和 API 速率限制。本地模型（如 Ollama）通常非常快。\",\n      isSafe: \"在使用应用时重建安全吗？\",\n      isSafeAns: \"是的，重建过程是安全的。它不会删除您的原始内容，仅会逐步替换向量数据。在大批量处理时，搜索速度可能会有轻微抖动。\",\n    },\n  },\n  transformations: {\n    title: \"内容转换规则\",\n    desc: \"转换规则是用于让大模型处理来源并提取见解、摘要等的提示词。\",\n    workspace: \"选择工作区\",\n    playground: \"实验室\",\n    defaultPrompt: \"默认全局提示词\",\n    defaultPromptDesc: \"该提示词将被添加到您所有的转换提示词中\",\n    defaultPromptPlaceholder: \"输入您的默认转换指令...\",\n    listTitle: \"自定义转换\",\n    createNew: \"新建转换\",\n    inputLabel: \"输入文本\",\n    inputPlaceholder: \"请输入要转换的文本...\",\n    outputLabel: \"输出\",\n    runTest: \"运行转换\",\n    running: \"运行中...\",\n    selectToStart: \"选择一个转换规则开始\",\n    name: \"名称\",\n    namePlaceholder: \"唯一标识符，例如 key_topics\",\n    titlePlaceholder: \"显示名称，默认为名称\",\n    promptPlaceholder: \"编写驱动此转换的提示词...\",\n    descriptionPlaceholder: \"描述此转换的作用。\",\n    suggestDefault: \"新来源默认建议\",\n    promptHint: \"提示词应根据源内容编写。您可以要求模型总结、提取见解或生成表格等结构化输出。\",\n    createSuccess: \"转换规则创建成功\",\n    updateSuccess: \"转换规则更新成功\",\n    deleteSuccess: \"转换规则删除成功\",\n    noTransformations: \"暂无转换规则\",\n    createOne: \"创建一个转换规则以开始\",\n    selectModel: \"选择模型\",\n    deleteConfirm: \"确定要删除此转换规则吗？\",\n    model: \"模型\",\n    systemPrompt: \"系统提示词\",\n    overrideModelDesc: \"为此聊天会话覆盖默认模型。留空则使用系统默认。\",\n    sessionUseReplacement: \"此会话将使用 {name} 而不是默认模型。\",\n    systemDefault: \"系统默认\",\n  },\n  models: {\n    embedding: \"嵌入模型\",\n    tts: \"文字转语音\",\n    stt: \"语音转文字\",\n    apiKey: \"API 密钥\",\n    deleteSuccess: \"模型删除成功\",\n    saveSuccess: \"模型保存成功\",\n    noModels: \"暂无模型\",\n    discoverModels: \"发现模型\",\n    noModelsFound: \"未从此提供商找到模型\",\n    modelType: \"模型类型\",\n    modelTypeHint: \"选择要添加的模型类型。如果需要不同类型，请分批添加。\",\n    deleteModel: \"删除模型\",\n    defaultAssignments: \"默认模型分配\",\n    defaultAssignmentsDesc: \"配置用于 Open Notebook 不同用途的默认模型\",\n    missingRequiredModels: \"缺少必需的模型：{models}。如果没有这些模型，Open Notebook 可能无法正常运行。\",\n    selectModelPlaceholder: \"选择一个模型\",\n    requiredModelPlaceholder: \"⚠️ 必需 - 请选择一个模型\",\n    chatModelLabel: \"聊天模型\",\n    chatModelDesc: \"用于聊天对话\",\n    transformationModelLabel: \"转换模型\",\n    transformationModelDesc: \"用于摘要、见解和内容转换\",\n    toolsModelLabel: \"工具模型\",\n    toolsModelDesc: \"用于函数调用 - 推荐 OpenAI 或 Anthropic\",\n    largeContextModelLabel: \"大上下文模型\",\n    largeContextModelDesc: \"用于处理大文档 - 推荐 Gemini\",\n    embeddingModelLabel: \"嵌入模型\",\n    embeddingModelDesc: \"用于语义搜索和向量嵌入\",\n    ttsModelLabel: \"文字转语音模型\",\n    ttsModelDesc: \"用于生成播客\",\n    sttModelLabel: \"语音转文字模型\",\n    sttModelDesc: \"用于音频转录\",\n    embeddingChangeTitle: \"嵌入模型变更\",\n    embeddingChangeConfirm: \"您即将将嵌入模型从 {from} 更改为 {to}。\",\n    rebuildRequired: \"重要提示：需要重建索引\",\n    rebuildReason: \"更改嵌入模型需要重建所有现有嵌入以保持一致性。如果不重建，您的搜索可能会返回错误或不完整的结果。\",\n    whatHappensNext: \"接下来会发生什么：\",\n    step1: \"您的默认嵌入模型将被更新\",\n    step2: \"在重新构建之前，现有的嵌入将保持不变\",\n    step3: \"新内容将使用新的嵌入模型\",\n    step4: \"您应该尽快重新构建嵌入\",\n    proceedToRebuildPrompt: \"您想现在前往“高级设置”页面开始重建索引吗？\",\n    changeModelOnly: \"仅更改模型\",\n    changeAndRebuild: \"更改并前往重建\",\n    autoAssign: \"自动分配默认值\",\n    autoAssigning: \"正在分配...\",\n    autoAssignSuccess: \"已自动分配 {count} 个默认模型\",\n    autoAssignNoModels: \"没有可分配的模型。请先同步模型。\",\n    autoAssignAlreadySet: \"所有默认模型已配置\",\n    testModel: \"测试模型\",\n    testModelSuccess: \"模型测试通过\",\n    testModelFailed: \"模型测试失败\",\n    searchOrAddModel: \"搜索或输入模型名称...\",\n    addCustomModel: \"添加 \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"使用您自己的 API 密钥配置 AI\",\n    description: \"将 API 密钥安全地存储在数据库中，以在 Open Notebook 中启用 AI 服务商。\",\n    encryptionRequired: \"未配置加密密钥\",\n    encryptionRequiredDescription: \"请将 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量设置为任意密钥字符串，以启用将 API 密钥存储到数据库。\",\n    configured: \"已配置\",\n    notConfigured: \"未配置\",\n    migrationAvailable: \"检测到环境变量\",\n    migrationDescription: \"{count} 个 API 密钥通过环境变量配置，可以迁移到数据库以便于管理。\",\n    migrateToDatabase: \"迁移到数据库\",\n    migrating: \"迁移中...\",\n    migrationSuccess: \"{count} 个 API 密钥迁移成功\",\n    migrationErrors: \"{count} 个密钥迁移失败\",\n    migrationNothingToMigrate: \"所有密钥已在数据库中\",\n    learnMore: \"了解如何配置 API 密钥 →\",\n    testConnection: \"测试连接\",\n    testSuccess: \"连接成功\",\n    testFailed: \"连接测试失败\",\n    syncModels: \"同步模型\",\n    syncSuccess: \"发现 {discovered} 个模型，新增 {new} 个\",\n    syncNoNew: \"发现 {count} 个模型，全部已注册\",\n    syncFailed: \"同步模型失败\",\n    getApiKey: \"获取 API 密钥\",\n    vertexProject: \"GCP 项目 ID\",\n    vertexLocation: \"区域\",\n    vertexCredentials: \"服务账户 JSON 路径\",\n    addConfig: \"添加配置\",\n    editConfig: \"编辑配置\",\n    deleteConfig: \"删除配置\",\n    configName: \"配置名称\",\n    configNameHint: \"此配置的描述性名称（例如：'生产环境'、'开发环境'）\",\n    baseUrl: \"基础 URL\",\n    baseUrlOverrideHint: \"仅在需要覆盖提供商默认 API 端点时更改此项。\",\n    deleteConfigConfirm: \"确定要删除 '{name}' 吗？此操作无法撤销。\",\n    configSaveSuccess: \"配置保存成功\",\n    configUpdateSuccess: \"配置更新成功\",\n    configDeleteSuccess: \"配置删除成功\",\n    apiKeyEditHint: \"留空以保留现有 API 密钥\",\n  },\n  setupBanner: {\n    encryptionRequired: \"未配置加密密钥\",\n    encryptionRequiredDescription: \"请设置 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量以启用安全凭据存储。\",\n    migrationAvailable: \"API 密钥迁移可用\",\n    migrationDescription: \"{count} 个服务商的 API 密钥通过环境变量设置。将它们迁移到数据库以便于管理。\",\n    goToSettings: \"前往设置\",\n    viewDocs: \"查看文档\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/locales/zh-TW/index.ts",
    "content": "export const zhTW = {\n  common: {\n    search: \"搜尋...\",\n    create: \"新增\",\n    new: \"新建\",\n    cancel: \"取消\",\n    delete: \"刪除\",\n    edit: \"編輯\",\n    theme: \"主題\",\n    signOut: \"登出\",\n    noMatches: \"沒有找到匹配項\",\n    tryDifferentSearch: \"請嘗試使用不同的關鍵詞搜尋。\",\n    light: \"亮色\",\n    dark: \"暗色\",\n    system: \"系統\",\n    loading: \"載入中...\",\n    note: \"筆記\",\n    insight: \"洞察\",\n    newSource: \"新增來源\",\n    newNotebook: \"新增筆記本\",\n    newPodcast: \"新增播客\",\n    language: \"語言\",\n    english: \"English\",\n    chinese: \"簡體中文\",\n    japanese: \"日本語\",\n    french: \"Français\",\n    russian: \"Русский\",\n    bengali: \"বাংলা\",\n    source: \"來源\",\n    notebook: \"筆記本\",\n    podcast: \"播客\",\n    quickActions: \"快捷操作\",\n    quickActionsDesc: \"導覽、搜尋、提問、主題\",\n    appName: \"Open Notebook\",\n    add: \"新增\",\n    remove: \"移除\",\n    confirm: \"確認\",\n    warning: \"警告\",\n    error: \"錯誤\",\n    success: \"成功\",\n    model: \"模型\",\n    back: \"返回\",\n    next: \"下一步\",\n    done: \"完成\",\n    processing: \"處理中...\",\n    creating: \"正在新增...\",\n    linked: \"已連結\",\n    adding: \"正在新增...\",\n    addSelected: \"新增所選\",\n    customModel: \"自訂模型\",\n    failed: \"失敗\",\n    current: \"目前\",\n    save: \"儲存\",\n    writeNote: \"撰寫筆記\",\n    batchMode: \"批次模式\",\n    optional: \"可選\",\n    type: \"類型\",\n    title: \"標題\",\n    created: \"建立於 {time}\",\n    updated: \"更新於 {time}\",\n    actions: \"快捷操作\",\n    noResults: \"未找到結果\",\n    references: \"引用\",\n    refreshPage: \"請嘗試重新整理頁面\",\n    refresh: \"重新整理\",\n    aiGenerated: \"AI 生成\",\n    human: \"人類\",\n    unknown: \"未知\",\n    notes: \"筆記\",\n    chat: \"聊天\",\n    deleteForever: \"永久刪除\",\n    connectionError: \"連線錯誤\",\n    unableToConnect: \"無法連線至 API 伺服器\",\n    retryConnection: \"重試連線\",\n    diagnosticInfo: \"診斷資訊\",\n    version: \"版本\",\n    built: \"構建時間\",\n    apiUrl: \"API 位址\",\n    frontendUrl: \"前端位址\",\n    checkConsoleLogs: \"請檢查瀏覽器主控台以獲取詳細日誌（搜尋 🔧 [Config] 訊息）\",\n    yes: \"是\",\n    no: \"否\",\n    saving: \"正在儲存...\",\n    description: \"描述\",\n    saveToNote: \"儲存到筆記\",\n    copyToClipboard: \"複製到剪貼簿\",\n    close: \"關閉\",\n    insights: \"見解\",\n    progress: \"進度\",\n    deleting: \"正在刪除...\",\n    created_label: \"建立時間\",\n    updated_label: \"更新時間\",\n    download: \"下載\",\n    saveChanges: \"儲存更改\",\n    name: \"名稱\",\n    default: \"預設\",\n    nameRequired: \"此為必填項\",\n    modelConfiguration: \"模型設定\",\n    resetToDefault: \"重置為預設\",\n    reasoning: \"推理過程\",\n    searchTerms: \"搜尋詞\",\n    strategy: \"策略\",\n    individualAnswers: \"獨立回答 ({count})\",\n    finalAnswer: \"最終回答\",\n    notebookLabel: \"筆記本: {name}\",\n    itemNotFound: \"未找到該 {type}\",\n    accessibility: {\n      transformationViews: \"轉換視圖\",\n      searchKB: \"向知識庫提問或搜尋\",\n      enterQuestion: \"輸入您的問題以詢問知識庫\",\n      enterSearch: \"輸入搜尋詞\",\n      searchKBBtn: \"搜尋知識庫\",\n      podcastViews: \"播客視圖\",\n      ytVideo: \"YouTube 影片\",\n      askResponse: \"提問回答\",\n      searchNotebooks: \"搜尋筆記本\",\n    },\n    url: \"URL\",\n    errorDetails: \"錯誤詳情\",\n    editTransformation: \"編輯轉換規則\",\n    retry: \"重試\",\n    traditionalChinese: \"繁體中文\",\n    portuguese: \"葡萄牙語\",\n    completed: \"已完成\",\n    saveSuccess: \"儲存成功\",\n    contextModes: {\n      off: \"未包含在聊天中\",\n      insights: \"僅限見解\",\n      full: \"全部內容\",\n      clickToCycle: \"點擊循環切換\",\n    },\n    clickToEdit: \"點擊編輯\",\n  },\n  apiErrors: {\n    notebookNotFound: \"找不到筆記本\",\n    sourceNotFound: \"找不到源檔案\",\n    transformationNotFound: \"找不到轉換規則\",\n    fileUploadFailed: \"檔案上傳失敗\",\n    urlRequired: \"連結類型需要提供 URL\",\n    contentRequired: \"文本類型需要提供內容\",\n    invalidSourceType: \"無效的源類型\",\n    processingFailed: \"處理失敗\",\n    failedToQueue: \"排隊處理失敗\",\n    invalidSortBy: \"排序欄位必須是 'created' 或 'updated'\",\n    invalidSortOrder: \"排序方向必須是 'asc' 或 'desc'\",\n    accessDenied: \"檔案存取被拒絕\",\n    fileNotFoundOnServer: \"伺服器上找不到該檔案\",\n    searchFailed: \"搜尋失敗\",\n    askFailed: \"提問失敗\",\n    pleaseEnterQuestion: \"請輸入問題\",\n    pleaseConfigureModels: \"請設定所有必選模型\",\n    failedToCreateSession: \"新增對話失敗\",\n    failedToUpdateSession: \"更新對話失敗\",\n    failedToDeleteSession: \"刪除對話失敗\",\n    failedToSendMessage: \"發送訊息失敗\",\n    unauthorized: \"無權存取，請檢查您的密碼\",\n    invalidPassword: \"密碼錯誤\",\n    embeddingModelRequired: \"此功能需要嵌入模型。請在模型設定中設定一個。\",\n    strategyModelNotFound: \"未找到策略模型\",\n    answerModelNotFound: \"未找到回答模型\",\n    finalAnswerModelNotFound: \"未找到最終回答模型\",\n    noAnswerGenerated: \"未能生成回答\",\n    genericError: \"發生了意外錯誤\",\n  },\n  connectionErrors: {\n    apiTitle: \"無法連線到 API 伺服器\",\n    apiDesc: \"無法存取 Open Notebook API 伺服器\",\n    dbTitle: \"資料庫連線失敗\",\n    dbDesc: \"API 伺服器正在執行，但無法存取資料庫\",\n    troubleshooting: \"這通常意味着：\",\n    apiUnreachable1: \"API 伺服器未運行\",\n    apiUnreachable2: \"API 伺服器運行在不同的位址\",\n    apiUnreachable3: \"網路連線問題\",\n    dbFailed1: \"SurrealDB 未運行\",\n    dbFailed2: \"資料庫連線設定不正確\",\n    dbFailed3: \"API 與資料庫之間的網路問題\",\n    quickFixes: \"快速修復：\",\n    setApiUrl: \"設定 API_URL 環境變數：\",\n    checkSurreal: \"檢查 SurrealDB 是否運行：\",\n    seeDocumentation: \"有關詳細設定說明，請參閱：\",\n    docLink: \"Open Notebook 文件\",\n    showTechnical: \"顯示技術細節\",\n    attemptedUrl: \"嘗試的 URL\",\n    message: \"訊息\",\n    technicalDetails: \"技術細節\",\n    stackTrace: \"堆疊追蹤\",\n    retryLabel: \"重試連線\",\n    retryHint: \"按 R 或點擊按鈕重試\",\n    dockerLabel: \"對於 Docker\",\n    localDevLabel: \"對於本地開發\",\n  },\n  auth: {\n    loginTitle: \"Open Notebook\",\n    loginDesc: \"輸入密碼以存取應用程式\",\n    passwordPlaceholder: \"密碼\",\n    signingIn: \"正在登入...\",\n    signIn: \"登入\",\n    connectErrorHint: \"無法連線至伺服器。請檢查 API 是否正在運行。\",\n  },\n  navigation: {\n    collect: \"採集\",\n    process: \"處理\",\n    create: \"創作\",\n    manage: \"管理\",\n    sources: \"來源\",\n    notebooks: \"筆記本\",\n    askAndSearch: \"詢問與搜尋\",\n    podcasts: \"播客\",\n    models: \"模型\",\n    transformations: \"轉換\",\n    transformation: \"轉換\",\n    settings: \"設定\",\n    advanced: \"進階\",\n    nav: \"導覽\",\n    language: \"切換語言\",\n    theme: \"主題\",\n    ask: \"提問\",\n  },\n  notebooks: {\n    title: \"筆記本\",\n    newNotebook: \"新增筆記本\",\n    searchPlaceholder: \"搜尋筆記本...\",\n    archived: \"已封存\",\n    archive: \"封存\",\n    unarchive: \"取消封存\",\n    deleteNotebook: \"刪除筆記本\",\n    deleteNotebookDesc: \"您確定要刪除 \\\"{name}\\\" 嗎？此操作無法復原。\",\n    deleteNotebookLoading: \"正在載入刪除預覽...\",\n    deleteNotebookNotes: \"{count} 個筆記將被永久刪除。\",\n    deleteNotebookNoNotes: \"沒有要刪除的筆記。\",\n    deleteNotebookExclusiveSources: \"{count} 個來源僅存在於此筆記本中。\",\n    deleteNotebookSharedSources: \"{count} 個來源與其他筆記本共享，將被取消關聯。\",\n    deleteNotebookNoSources: \"此筆記本中沒有來源。\",\n    deleteExclusiveSourcesLabel: \"刪除專屬來源\",\n    keepExclusiveSourcesLabel: \"取消關聯並保留\",\n    activeNotebooks: \"活動中的筆記本\",\n    archivedNotebooks: \"封存的筆記本\",\n    notFound: \"未找到筆記本\",\n    notFoundDesc: \"請求的筆記本不存在。\",\n    updated: \"已更新\",\n    namePlaceholder: \"筆記本名稱\",\n    addDescription: \"新增描述...\",\n    noNotesYet: \"暫無筆記\",\n    deleteNote: \"刪除筆記\",\n    deleteNoteConfirm: \"確定要刪除此筆記嗎？此操作無法撤銷。\",\n    noteCreatedSuccess: \"筆記創建成功\",\n    failedToCreateNote: \"創建筆記失敗\",\n    noteUpdatedSuccess: \"筆記更新成功\",\n    failedToUpdateNote: \"更新筆記失敗\",\n    noteDeletedSuccess: \"筆記刪除成功\",\n    failedToDeleteNote: \"刪除筆記失敗\",\n    createNew: \"新增筆記本\",\n    createNewDesc: \"輸入名稱和可選描述以開始。\",\n    descPlaceholder: \"在此新增有關此筆記本的更多資訊...\",\n    createSuccess: \"筆記本新增成功\",\n    updateSuccess: \"筆記本更新成功\",\n    deleteSuccess: \"筆記本刪除成功\",\n  },\n  sources: {\n    title: \"來源\",\n    add: \"新增來源\",\n    addNew: \"新增新來源\",\n    addExisting: \"新增現有來源\",\n    delete: \"刪除來源\",\n    statusPreparing: \"正在準備\",\n    statusQueued: \"已排隊\",\n    statusProcessing: \"正在處理\",\n    statusCompleted: \"已完成\",\n    statusFailed: \"處理失敗\",\n    statusPreparingDesc: \"準備處理中\",\n    statusQueuedDesc: \"等待處理\",\n    statusProcessingDesc: \"正在處理內容\",\n    statusCompletedDesc: \"處理成功\",\n    statusFailedDesc: \"處理失敗\",\n    failedToLoad: \"載入來源失敗\",\n    allSourcesDesc: \"在此檢視所有來源。您可以新增新來源或管理現有來源。\",\n    allSources: \"所有來源\",\n    insights: \"見解\",\n    yes: \"是\",\n    no: \"否\",\n    loadingMore: \"正在載入更多...\",\n    noSourcesYet: \"暫無來源\",\n    allSourcesDescShort: \"在此檢視所有來源。\",\n    cannotSaveNoteNoNotebook: \"無法儲存筆記：缺少筆記本 ID\",\n    createFirstSource: \"新增您的第一個來源開始構建知識庫。\",\n    deleteSourceConfirm: \"確定要刪除此來源嗎？\",\n    deleteConfirm: \"確定要刪除嗎？\",\n    deleteConfirmWithTitle: \"確定要刪除 \\\"{title}\\\" 嗎？\",\n    deleteSuccess: \"來源刪除成功。注意：要從儲存中刪除檔案，必須在設定頁面中啟用「刪除檔案」選項。\",\n    failedToDelete: \"刪除來源失敗\",\n    sourceQueued: \"來源已加入隊列\",\n    sourceQueuedDesc: \"來源已提交進行後台處理。您可以在來源列表中監控進度。\",\n    sourceAddedSuccess: \"來源新增成功\",\n    failedToAddSource: \"新增來源失敗\",\n    sourceUpdatedSuccess: \"來源更新成功\",\n    failedToUpdateSource: \"更新來源失敗\",\n    sourceDeletedSuccess: \"來源刪除成功\",\n    failedToDeleteSource: \"刪除來源失敗\",\n    fileUploadedSuccess: \"檔案上傳成功\",\n    failedToUploadFile: \"檔案上傳失敗\",\n    sourceRequeued: \"來源重試已加入隊列\",\n    sourceRequeuedDesc: \"來源已重新加入處理隊列。\",\n    failedToRetry: \"重試失敗\",\n    sourcesAddedToNotebook: \"{count} 個來源已新增到筆記本\",\n    failedToAddSourcesToNotebook: \"新增來源到筆記本失敗\",\n    partialAddSuccess: \"{success} 個來源已新增，{failed} 個失敗\",\n    sourceRemovedFromNotebook: \"來源已成功從筆記本中移除\",\n    failedToRemoveSourceFromNotebook: \"從筆記本中移除來源失敗\",\n    removeConfirm: \"確定要從此筆記本移除嗎？\",\n    checking: \"正在檢查...\",\n    untitledSource: \"未命名來源\",\n    maxItems: \"最多 {count} 個\",\n    insightsCount: \"{count} 條見解\",\n    details: \"詳情\",\n    detailsTitle: \"來源詳情\",\n    content: \"內容\",\n    metadata: \"元資料\",\n    type: {\n      link: \"連結\",\n      file: \"檔案\",\n      text: \"文字\",\n    },\n    id: \"來源 ID\",\n    topics: \"主題\",\n    embedded: \"已嵌入向量\",\n    notEmbedded: \"未嵌入向量\",\n    embedContent: \"嵌入內容\",\n    embedding: \"正在嵌入...\",\n    alreadyEmbedded: \"已嵌入\",\n    downloadFile: \"下載檔案\",\n    fileUnavailable: \"檔案不可用\",\n    preparing: \"正在準備...\",\n    generateNewInsight: \"生成新見解\",\n    selectTransformation: \"選擇轉換規則...\",\n    noInsightsYet: \"暫無見解\",\n    createFirstInsight: \"使用上方的轉換規則新增您的第一個見解\",\n    viewInsight: \"查看見解\",\n    deleteInsight: \"刪除見解\",\n    deleteInsightConfirm: \"確定要刪除此見解嗎？此操作無法撤銷。\",\n    insightGenerationStarted: \"見解生成已開始，稍後將顯示。\",\n    editNote: \"編輯筆記\",\n    createNote: \"新增筆記\",\n    addTitle: \"新增標題...\",\n    untitledNote: \"無標題筆記\",\n    writeNotePlaceholder: \"在此處編寫您的筆記內容...\",\n    saveNote: \"儲存筆記\",\n    createNoteBtn: \"新增筆記\",\n    createFirstNote: \"新增您的第一條筆記，記錄見解與觀察。\",\n    urlLabel: \"URL(s) *\",\n    fileLabel: \"檔案(s) *\",\n    textContentLabel: \"文字內容 *\",\n    enterUrlsPlaceholder: \"每行輸入一個 URL\\nhttps://example.com/article1\\nhttps://example.com/article2\",\n    batchUrlHint: \"貼上多個 URL（每行一個）進行批次導入\",\n    invalidUrlsDetected: \"檢測到無效的 URL：\",\n    lineLabel: \"第 {line} 行\",\n    fixInvalidUrls: \"請修正或移除無效的 URL 以繼續\",\n    selectMultipleFilesHint: \"選擇多個檔案進行批次導入。支援：文件 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD)，媒體 (MP4, MP3, WAV, M4A)，圖片 (JPG, PNG)，歸檔 (ZIP)\",\n    selectedFiles: \"已選擇檔案：\",\n    textPlaceholder: \"在此處貼上或輸入您的內容...\",\n    htmlDetected: \"偵測到 HTML 內容。處理後將轉換為 Markdown。\",\n    titlePlaceholder: \"為您的來源取一個描述性的標題\",\n    batchTitlesAuto: \"將為每個來源自動生成標題。\",\n    batchCommonSettings: \"相同的筆記本和轉換將應用於所有項目。\",\n    urlsCount: \"{count} 個 URL\",\n    filesCount: \"{count} 個檔案\",\n    addSource: \"新增來源\",\n    notEmbeddedAlert: \"內容未嵌入向量\",\n    notEmbeddedDesc: \"此內容尚未為了向量搜尋進行嵌入。嵌入可以啟用進階搜尋功能並更好地發現內容。\",\n    openOnYoutube: \"在 YouTube 上開啟\",\n    urlCopied: \"URL 已複製到剪貼簿\",\n    viewSource: \"查看來源\",\n    noInsightSelected: \"未選擇見解\",\n    sourceInsight: \"來源見解\",\n    manageNotebooks: \"管理所屬筆記本\",\n    manageNotebooksDesc: \"管理包含此來源的筆記本\",\n    noNotebooksAvailable: \"暫無可用筆記本\",\n    loadFailed: \"載入來源詳情失敗\",\n    removeFromNotebook: \"從筆記本移除\",\n    retryProcessing: \"重試處理\",\n    deleteSource: \"刪除來源\",\n    retry: \"重試\",\n    addExistingTitle: \"新增現有來源\",\n    addExistingDesc: \"從您的所有筆記本中選擇已有的來源新增到當前筆記本。\",\n    searchPlaceholder: \"通過名稱或 URL 搜尋來源...\",\n    noNotebooksFound: \"未找到筆記本。\",\n    showingFirst100: \"僅顯示前 100 個來源。請使用搜尋功能查找特定來源。\",\n    selectedCount: \"已選擇 {count} 個來源\",\n    added: \"已新增於 {date}\",\n    addUrl: \"新增 URL\",\n    uploadFile: \"上傳檔案\",\n    enterText: \"輸入文字\",\n    processDescription: \"內容將經過處理並由 AI 分析。\",\n    processingFiles: \"正在處理您的檔案...\",\n    titleRequired: \"文字內容必須提供標題\",\n    titleGenerated: \"如果留空，將根據內容自動生成標題\",\n    batchCount: \"將處理 {count} 個{type}\",\n    enableEmbedding: \"啟用搜尋向量嵌入\",\n    embeddingDesc: \"允許此來源在向量搜尋和 AI 查詢中被檢索\",\n    embeddingAlways: \"已自動啟用嵌入\",\n    embeddingAlwaysDesc: \"您的設定已設定為始終對內容進行向量嵌入。\",\n    embeddingNever: \"嵌入已禁用\",\n    embeddingNeverDesc: \"您的設定已設定為跳過嵌入。此來源將無法進行向量搜尋。\",\n    changeInSettings: \"您可以在此處更改設定：\",\n    notFound: \"未找到來源\",\n    noContent: \"暫無內容\",\n    insightsDesc: \"根據模型分析生成的見解\",\n    uploadedFile: \"已上傳檔案\",\n    fileUnavailableDesc: \"由於儲存系統原因，此檔案目前不可用。\",\n    batchSuccess: \"成功新增 {count} 個來源\",\n    batchFailed: \"全部 {count} 個來源新增失敗\",\n    batchPartial: \"{success} 個成功，{failed} 個失敗\",\n    submittingSource: \"正在提交來源進行處理...\",\n    processingBatchSources: \"正在處理 {count} 個來源，請稍候...\",\n    processingSource: \"正在處理您的來源，請稍候...\",\n    maxFilesAllowed: \"每批最多允許 {count} 個檔案\",\n  },\n  chat: {\n    sessions: \"對話\",\n    sessionTitlePlaceholder: \"在此輸入標題...\",\n    noSessions: \"暫無對話\",\n    deleteSession: \"刪除對話\",\n    deleteSessionDesc: \"確定要刪除此聊天會話嗎？此操作無法撤銷。\",\n    sendPlaceholder: \"向您的來源提問...\",\n    sessionsTitle: \"對話列表\",\n    chatWith: \"與 {name} 對話\",\n    startConversation: \"開始針對 {type} 進行對話\",\n    askQuestions: \"提出問題以更好地理解內容\",\n    pressToSend: \"按 {key} 發送\",\n    model: \"模型\",\n    createToStart: \"新增一個會話以開始。\",\n    chatWithNotebook: \"與筆記本對話\",\n    unableToLoadChat: \"無法載入聊天\",\n    noDescription: \"暫無描述\",\n    startByCreating: \"從新增您的第一個筆記本開始，組織您的研究。\",\n    messagesCount: \"{count} 條訊息\",\n    sessionCreated: \"聊天會話已建立\",\n    sessionUpdated: \"會話已更新\",\n    sessionDeleted: \"會話已刪除\",\n  },\n  searchPage: {\n    askAndSearch: \"提問與搜尋\",\n    chooseAMode: \"選擇模式\",\n    askBeta: \"提問 (beta)\",\n    search: \"搜尋\",\n    askYourKb: \"向您的知識庫提問 (beta)\",\n    askYourKbDesc: \"大語言模型將根據您知識庫中的文件回答您的查詢。\",\n    question: \"問題\",\n    enterQuestionPlaceholder: \"輸入您的問題...\",\n    pressToSubmit: \"按 Cmd/Ctrl+Enter 提交\",\n    noEmbeddingModel: \"您無法使用此功能，因為尚未選擇嵌入模型集。請在模型頁面設定一個。\",\n    usingCustomModels: \"正在使用自訂模型\",\n    usingDefaultModels: \"正在使用預設模型\",\n    advanced: \"進階\",\n    strategy: \"策略\",\n    answer: \"回答\",\n    final: \"最終\",\n    ask: \"提問\",\n    processing: \"處理中...\",\n    saveToNotebooks: \"儲存到筆記本\",\n    searchDesc: \"在您的知識庫中搜尋特定的關鍵字或概念\",\n    enterSearchPlaceholder: \"輸入搜尋查詢...\",\n    pressToSearch: \"按 Enter 鍵搜尋\",\n    searchType: \"搜尋類型\",\n    vectorSearchWarning: \"向量搜尋需要嵌入模型。目前僅文本搜尋可用。\",\n    textSearch: \"文本搜尋\",\n    vectorSearch: \"向量搜尋\",\n    searchIn: \"搜尋範圍\",\n    searchSources: \"搜尋來源\",\n    searchNotes: \"搜尋筆記\",\n    resultsFound: \"{count} 個結果\",\n    matches: \"匹配項 ({count})\",\n    noResultsFor: \"未找到 “{query}” 的結果\",\n    notSet: \"未設定\",\n    saveToNotebook: \"儲存到筆記本\",\n    saveSuccess: \"成功儲存到筆記本\",\n    saveError: \"儲存到筆記本失敗\",\n    selectNotebook: \"選擇筆記本\",\n    searchAndAsk: \"搜尋與提問\",\n    searchResultsFor: \"搜尋 “{query}”\",\n    askAbout: \"提問關於 “{query}”\",\n    orSearchKb: \"或搜尋您的知識庫\",\n    saving: \"儲存中...\",\n    advancedModelTitle: \"進階模型選擇\",\n    advancedModelDesc: \"為提問過程的每個階段選擇模型\",\n    strategyModel: \"策略模型\",\n    answerModel: \"回答模型\",\n    finalAnswerModel: \"最終回答模型\",\n    selectStrategyPlaceholder: \"選擇策略模型\",\n    selectAnswerPlaceholder: \"選擇回答模型\",\n    selectFinalPlaceholder: \"選擇最終回答模型\",\n    saveChanges: \"儲存更改\",\n    processingQuestion: \"正在處理您的問題...\",\n  },\n  podcasts: {\n    generateEpisode: \"生成播客單集\",\n    generateEpisodeDesc: \"在生成新的播客單集之前，選擇要包含的內容並設定單集詳情。\",\n    content: \"內容\",\n    contentDesc: \"選擇要包含在此單集中的筆記本、來源和筆記。\",\n    itemsSelected: \"{count} 個項目已選擇\",\n    tokens: \"{count} token\",\n    chars: \"{count} 字元\",\n    loadingNotebooks: \"正在載入筆記本...\",\n    noNotebooksFoundInPodcasts: \"未找到筆記本。在生成播客之前，請先建立一個筆記本並新增內容。\",\n    noContentSelected: \"未選擇內容\",\n    summary: \"摘要\",\n    fullContent: \"全部內容\",\n    untitledSource: \"未命名來源\",\n    untitledNote: \"未命名筆記\",\n    episodeSettings: \"單集設定\",\n    episodeProfile: \"單集簡介\",\n    episodeProfilePlaceholder: \"選擇單集簡介\",\n    episodeName: \"單集名稱\",\n    episodeNamePlaceholder: \"例如：AI 與工作的未來\",\n    additionalInstructions: \"附加指令\",\n    instructionsPlaceholder: \"任何要追加到單集簡訊的補充建議...\",\n    generating: \"正在生成...\",\n    generate: \"生成\",\n    hostPlaceholder: \"主持人 {number}\",\n    profileRequired: \"需要單集簡介\",\n    profileRequiredDesc: \"在生成播客之前，請選擇一個單集簡介。\",\n    nameRequired: \"需要單集名稱\",\n    nameRequiredDesc: \"請為單集提供一個名稱。\",\n    addContext: \"新增上下文\",\n    addContextDesc: \"至少選擇一個來源或筆記包含在單集中。\",\n    generationFailed: \"播客生成失敗\",\n    speakerProfile: \"主持人簡介\",\n    usesSpeakerProfile: \"使用主持人簡介\",\n    sources: \"來源\",\n    notes: \"筆記\",\n    noSources: \"此筆記本中沒有可用的來源。\",\n    noNotes: \"此筆記本中沒有可用的筆記。\",\n    selectMode: \"選擇模式\",\n    buildContextFailed: \"構建上下文失敗。請檢查您的選擇。\",\n    podcastTaskStarted: \"播客生成任務已開始\",\n    loadingProfiles: \"正在載入單集簡介...\",\n    noProfilesFound: \"未找到單集簡介。在生成播客之前，請先建立一個單集簡介。\",\n    listTitle: \"播客\",\n    listDesc: \"跟踪生成的單集並管理可重複使用的簡介。\",\n    chooseAView: \"選擇視圖\",\n    episodesTab: \"單集\",\n    templatesTab: \"設定檔\",\n    overviewTitle: \"單集概覽\",\n    overviewDesc: \"監控播客生成任務並查看最終成品。\",\n    generateBtn: \"生成播客\",\n    total: \"總計\",\n    processingLabel: \"處理中\",\n    completedLabel: \"已完成\",\n    failedLabel: \"失敗\",\n    pendingLabel: \"排隊中\",\n    loadErrorTitle: \"載入單集失敗\",\n    loadErrorDesc: \"無法獲取最新的播客單集。請稍後重試。\",\n    loadingEpisodes: \"正在載入單集...\",\n    noEpisodesYet: \"暫無播客單集。從筆記本或來源聊天介面開始生成您的第一個單集。\",\n    statusRunningTitle: \"正在處理中\",\n    statusRunningDesc: \"正在活躍生成資產的單集。\",\n    statusPendingTitle: \"排隊中 / 待處理\",\n    statusPendingDesc: \"已提交并在等待開始處理的單集。\",\n    statusCompletedTitle: \"已完成單集\",\n    statusCompletedDesc: \"可以查看、下載或發布。\",\n    statusFailedTitle: \"失敗單集\",\n    statusFailedDesc: \"在生成過程中遇到問題的單集。\",\n    templatesWorkspaceTitle: \"簡介工作區\",\n    templatesWorkspaceDesc: \"構建可重複使用的單集和發言人設定，以實現快速的播客製作。\",\n    howTemplatesPowerTitle: \"簡介如何驅動播客生成\",\n    howTemplatesPowerDesc: \"簡介將播客工作流拆分為兩個可重複使用的構建塊。在生成新單集時可以隨時混合搭配它們。\",\n    episodeProfilesSetFormat: \"單集簡介設定格式\",\n    episodeProfilesList1: \"概述分段數量及故事流向\",\n    episodeProfilesList2: \"選擇用於簡報、大綱和腳本編寫的語言模型\",\n    episodeProfilesList3: \"儲存預設簡報，以便每個單集都以一致的基調開始\",\n    speakerProfilesBringVoices: \"發言人簡介賦予聲音生命\",\n    speakerProfilesList1: \"選擇文字轉語音庫及模型\",\n    speakerProfilesList2: \"記錄每個發言人的性格、背景故事和發音說明\",\n    speakerProfilesList3: \"在不同的單集格式中重複使用相同的主持人或嘉賓聲音\",\n    recommendedWorkflow: \"推薦工作流\",\n    workflowStep1: \"為您需要的每個聲音建立發言人簡介\",\n    workflowStep2: \"構建按名稱引用這些發言人的單集簡介\",\n    workflowStep3: \"通過選擇適合故事的單集簡介來生成播客\",\n    workflowHint: \"單集簡介按名稱引用發言人簡介，因此從發言人開始可以避免以後缺少聲音指派。\",\n    failedToLoadTemplates: \"載入簡介資料失敗\",\n    failedToLoadTemplatesDesc: \"請確保 API 正在運行並重試。某些部分可能不完整。\",\n    loadingTemplates: \"正在載入簡介...\",\n    speakerProfilesTitle: \"發言人簡介\",\n    speakerProfilesDesc: \"為生成的單集設定聲音和性格。\",\n    createSpeaker: \"建立發言人\",\n    noSpeakerProfiles: \"暫無發言人簡介。建立一個以使單集簡介可用。\",\n    noDescription: \"未提供描述。\",\n    usedByCount_one: \"被 1 個單集使用\",\n    usedByCount_other: \"被 {count} 個單集使用\",\n    usedByCount: \"被 {count} 個單集使用\",\n    unused: \"未使用\",\n    voiceId: \"聲音 ID\",\n    backstory: \"背景故事\",\n    personality: \"性格\",\n    edit: \"編輯\",\n    duplicate: \"複製\",\n    deleteSpeakerProfileTitle: \"刪除發言人簡介？\",\n    deleteSpeakerProfileDesc: \"刪除 “{name}” 無法撤銷。\",\n    deleteSpeakerDisabledHint: \"在刪除之前，請先從單集簡介中移除該發言人。\",\n    deleting: \"正在刪除...\",\n    episodeProfilesTitle: \"單集簡介\",\n    episodeProfilesDesc: \"為您播客定義可重複使用的生成設定。\",\n    createProfile: \"建立簡介\",\n    createSpeakerFirst: \"在新增單集簡介之前，請先建立一個發言人簡介。\",\n    noEpisodeProfiles: \"暫無單集簡介。建立一個以啟動播客生成。\",\n    speakerCreated: \"發言人設定已建立\",\n    speakerCreatedDesc: \"發言人設定已準備就緒。\",\n    failedToCreateSpeaker: \"建立發言人設定失敗\",\n    speakerUpdated: \"發言人設定已更新\",\n    speakerUpdatedDesc: \"更改已成功儲存。\",\n    failedToUpdateSpeaker: \"更新發言人設定失敗\",\n    speakerDeleted: \"發言人設定已刪除\",\n    speakerDeletedDesc: \"設定已成功移除。\",\n    failedToDeleteSpeaker: \"刪除發言人設定失敗\",\n    speakerDuplicated: \"發言人設定已複製\",\n    speakerDuplicatedDesc: \"已建立設定副本。\",\n    failedToDuplicateSpeaker: \"複製發言人設定失敗\",\n    generationStarted: \"播客啟動生成\",\n    generationStartedDesc: \"劇集 \\\"{name}\\\" 正在建立中。\",\n    failedToStartGeneration: \"啟動播客生成失敗\",\n    tryAgainMoment: \"請稍後再試。\",\n    deleteProfileTitle: \"刪除簡介？\",\n    deleteProfileDesc: \"這將移除 “{name}”。現有單集將保留其資料，但新單集將不再使用此設定。\",\n    profileCreated: \"劇集設定已建立\",\n    profileCreatedDesc: \"新的劇集設定已準備就緒。\",\n    failedToCreateProfile: \"建立劇集設定失敗\",\n    profileUpdated: \"劇集設定已更新\",\n    profileUpdatedDesc: \"更改已成功儲存。\",\n    failedToUpdateProfile: \"更新劇集設定失敗\",\n    profileDeleted: \"劇集設定已刪除\",\n    profileDeletedDesc: \"設定已成功移除。\",\n    failedToDeleteProfile: \"刪除劇集設定失敗\",\n    failedToDeleteProfileDesc: \"請確保設定未在使用中並重試。\",\n    profileDuplicated: \"劇集設定已複製\",\n    profileDuplicatedDesc: \"已建立設定副本。\",\n    failedToDuplicateProfile: \"複製劇集設定失敗\",\n    episodeDeleted: \"劇集已刪除\",\n    episodeDeletedDesc: \"播客劇集已成功移除。\",\n    failedToDeleteEpisode: \"刪除劇集失敗\",\n    failedToDeleteSpeakerDesc: \"請確保設定未在使用中並重試。\",\n    outlineModel: \"大綱模型\",\n    transcriptModel: \"腳本模型\",\n    segments: \"分段數量\",\n    defaultBriefingTitle: \"預設簡報\",\n    created: \"建立於 {time}\",\n    details: \"詳情\",\n    summaryTab: \"總結\",\n    outlineTab: \"大綱\",\n    transcriptTab: \"腳本\",\n    briefing: \"內容簡報\",\n    noOutline: \"暫無大綱。\",\n    noTranscript: \"暫無腳本。\",\n    deleteEpisodeTitle: \"刪除單集？\",\n    deleteEpisodeDesc: \"這將永久移除 “{name}” 及其音訊檔案。\",\n    audioUnavailable: \"音訊不可用\",\n    segment: \"分段\",\n    speaker: \"發言人\",\n    profile: \"簡介\",\n    link: \"連結\",\n    file: \"檔案\",\n    embedded: \"已嵌入\",\n    notEmbedded: \"未嵌入\",\n    noSpeakerProfilesAvailable: \"沒有可用的發言人簡介\",\n    editEpisodeProfile: \"編輯單集簡介\",\n    createEpisodeProfile: \"建立單集簡介\",\n    episodeProfileFormDesc: \"定義單集生成的規則及預設使用的發言人設定。\",\n    noSpeakerProfilesDesc: \"在設定單集簡介之前，請先建立一個發言人簡介。\",\n    profileName: \"簡介名稱\",\n    profileNamePlaceholder: \"例如：技術討論\",\n    descriptionPlaceholder: \"簡要說明何時使用此簡介\",\n    speakerConfig: \"發言人設定\",\n    selectSpeakerProfile: \"選擇發言人簡介\",\n    outlineGeneration: \"大綱生成\",\n    transcriptGeneration: \"文稿生成\",\n    defaultBriefingPlaceholder: \"概述此單集格式的結構、語氣和目標\",\n    editSpeakerProfile: \"編輯發言人簡介\",\n    createSpeakerProfile: \"建立發言人簡介\",\n    speakerProfileFormDesc: \"設定文字轉語音設定並定義最多四名發言人。\",\n    speakers: \"發言人\",\n    speakersDesc: \"為此簡介設定一到四種聲音。\",\n    addSpeaker: \"新增發言人\",\n    speakerNumber: \"發言人 {number}\",\n    backstoryPlaceholder: \"發言人的簡要傳記或背景資訊\",\n    personalityPlaceholder: \"描述風格和語氣\",\n    outlineModelRequired: \"必須選擇大綱模型\",\n    transcriptModelRequired: \"必須選擇文稿模型\",\n    defaultBriefingRequired: \"必須填寫預設簡介\",\n    segmentsInteger: \"必須是整數\",\n    segmentsMin: \"至少包含 3 個分段\",\n    segmentsMax: \"最多包含 20 個分段\",\n    voiceIdRequired: \"必須填寫聲音 ID\",\n    backstoryRequired: \"必須填寫背景故事\",\n    personalityRequired: \"必須填寫性格描述\",\n    speakerCountMin: \"至少需要一個發言人\",\n    speakerCountMax: \"最多只能設定 4 個發言人\",\n    delete: \"刪除\",\n    failedToDelete: \"刪除播客失敗\",\n    retry: \"重試\",\n    retrying: \"重試中…\",\n    retryStarted: \"已開始重試\",\n    retryStartedDesc: \"已提交新的播客生成任務。\",\n    failedToRetry: \"重試失敗\",\n    errorDetails: \"錯誤詳情\",\n    language: \"語言\",\n    languagePlaceholder: \"選擇語言（可選）\",\n    podcastLanguage: \"播客語言\",\n    selectOutlineModel: \"選擇大綱模型\",\n    selectTranscriptModel: \"選擇轉錄模型\",\n    voiceModel: \"語音模型\",\n    voiceModelRequired: \"語音模型為必填項\",\n    selectVoiceModel: \"選擇語音模型\",\n    perSpeakerTtsOverride: \"每位發言人的TTS覆蓋（可選）\",\n    useProfileDefault: \"使用設定檔預設值\",\n    setupRequired: \"需要設定\",\n    setupRequiredDesc: \"部分設定檔尚未設定模型。請編輯它們以在生成播客之前選擇模型。\",\n    notConfigured: \"未設定\",\n  },\n  settings: {\n    contentProcessing: \"內容處理\",\n    contentProcessingDesc: \"設定文件和 URL 的處理方式\",\n    docEngine: \"文件處理引擎\",\n    docEnginePlaceholder: \"選擇文件處理引擎\",\n    urlEngine: \"URL 處理引擎\",\n    urlEnginePlaceholder: \"選擇 URL 處理引擎\",\n    autoRecommended: \"自動 (推薦)\",\n    simple: \"Simple\",\n    docling: \"Docling\",\n    helpMeChoose: \"幫助我選擇\",\n    docHelp: \"· Docling: 速度稍慢但更準確，特別是包含表格和圖片的文件。 · Simple: 直接提取內容而不進行格式化。 · 自動 (推薦): 優先嘗試 Docling，失敗則回退至 Simple。\",\n    firecrawl: \"Firecrawl\",\n    jina: \"Jina\",\n    urlHelp: \"· Firecrawl: 強大的付費服務（有免費額度）。 · Jina: 優秀的備選方案，同樣提供免費額度。 · Simple: 基礎 HTTP 提取，在 JS 渲染的網站上可能會丟失內容。 · 自動 (推薦): 優先嘗試 Firecrawl，其次 Jina，最後回退至 Simple。\",\n    embeddingAndSearch: \"嵌入與搜尋\",\n    embeddingAndSearchDesc: \"設定搜尋和向量嵌入選項\",\n    defaultEmbeddingOption: \"預設嵌入選項\",\n    embeddingOptionPlaceholder: \"選擇嵌入選項\",\n    ask: \"詢問\",\n    always: \"始終\",\n    never: \"從不\",\n    embeddingHelp: \"將內容進行向量嵌入可以讓您和您的 AI 助手更容易找到它。如果您運行本地嵌入模型（如 Ollama），建議開啟。對於線上服務商，只有在每天處理數百個文件時才需考慮成本。\",\n    fileManagement: \"檔案管理\",\n    fileManagementDesc: \"設定檔案的處理和儲存選項\",\n    autoDeleteFiles: \"自動刪除檔案\",\n    autoDeletePlaceholder: \"選擇自動刪除選項\",\n    filesHelp: \"檔案處理完成後，原始件不再需要。建議開啟自動刪除以節省儲存空間。除非您將其作為主要儲存位置（不建議），否則請選擇“是”。\",\n    loadFailed: \"載入設定失敗\",\n  },\n  advanced: {\n    title: \"進階工具\",\n    desc: \"針對進階使用者的調試和實用工具\",\n    systemInfo: \"系統資訊\",\n    rebuildEmbeddings: \"重建索引\",\n    rebuildEmbeddingsDesc: \"為所有來源重建向量索引\",\n    currentVersion: \"目前版本\",\n    latestVersion: \"最新版本\",\n    status: \"狀態\",\n    updateAvailable: \"版本 {version} 可用\",\n    updateAvailableDesc: \"Open Notebook 的新版本可用。\",\n    upToDate: \"已是最新\",\n    unknown: \"未知\",\n    viewOnGithub: \"在 GitHub 上查看\",\n    updateCheckFailed: \"無法檢查更新。GitHub 可能無法存取。\",\n    rebuild: {\n      mode: \"重建模式\",\n      existing: \"僅現有項\",\n      all: \"全部項\",\n      existingDesc: \"僅重新嵌入已有向量的項（速度較快，適用於切換模型）\",\n      allDesc: \"重新嵌入已有項 + 為缺失向量的項補全（速度較慢，較全面）\",\n      include: \"包含在重建中\",\n      selectOneError: \"請至少選擇一種重建類型\",\n      starting: \"正在啟動重建...\",\n      startBtn: \"開始重建\",\n      queued: \"排隊中\",\n      running: \"正在提交任務...\",\n      completed: \"任務已提交!\",\n      failed: \"失敗\",\n      leavePageHint: \"您可以離開此頁面，後台將繼續運行\",\n      startNew: \"開始新的重建\",\n      itemsProcessed: \"{processed}/{total} 任務已提交 ({percent}%)\",\n      failedItems: \"{count} 任務提交失敗\",\n      time: \"耗時\",\n      whenToRebuild: \"我該何時重建索引？\",\n      whenToRebuildAns: \"當您切換嵌入模型、升級模型版本、懷疑資料損壞或進行了大批次內容導入後，建議執行重建。\",\n      howLong: \"重建需要多長時間？\",\n      howLongAns: \"耗時取決於項目總數、模型速度和 API 速率限制。本地模型（如 Ollama）通常非常快。\",\n      isSafe: \"在使用應用時重建安全嗎？\",\n      isSafeAns: \"是的，重建過程是安全的。它不會刪除您的原始内容，僅會逐步替換向量資料。在大批次處理時，搜尋速度可能會有輕微抖動。\",\n    },\n  },\n  transformations: {\n    title: \"內容轉換規則\",\n    desc: \"轉換規則是用於讓大模型處理來源並提取見解、摘要等的提示詞。\",\n    workspace: \"選擇工作區\",\n    playground: \"實驗室\",\n    defaultPrompt: \"預設全局提示詞\",\n    defaultPromptDesc: \"該提示詞將被新增到您所有的轉換提示詞中\",\n    defaultPromptPlaceholder: \"輸入您的預設轉換指令...\",\n    listTitle: \"自訂轉換\",\n    createNew: \"新建轉換\",\n    inputLabel: \"輸入文本\",\n    inputPlaceholder: \"請輸入要轉換的文本...\",\n    outputLabel: \"輸出\",\n    runTest: \"運行轉換\",\n    running: \"運行中...\",\n    selectToStart: \"選擇一個轉換規則開始\",\n    name: \"名稱\",\n    namePlaceholder: \"唯一標識符，例如 key_topics\",\n    titlePlaceholder: \"顯示名稱，預設為名稱\",\n    promptPlaceholder: \"編寫驅動此轉換的提示詞...\",\n    descriptionPlaceholder: \"描述此轉換的作用。\",\n    suggestDefault: \"新來源預設建議\",\n    promptHint: \"提示詞應根據源內容編寫。您可以要求模型總結、提取見解或生成表格等結構化輸出。\",\n    createSuccess: \"轉換規則建立成功\",\n    updateSuccess: \"轉換規則更新成功\",\n    deleteSuccess: \"轉換規則刪除成功\",\n    noTransformations: \"暫無轉換規則\",\n    createOne: \"建立一個轉換規則以開始\",\n    selectModel: \"選擇模型\",\n    deleteConfirm: \"確定要刪除此轉換規則嗎？\",\n    model: \"模型\",\n    systemPrompt: \"系統提示詞\",\n    overrideModelDesc: \"為此對話會話覆蓋預設模型。留空則使用系統預設。\",\n    sessionUseReplacement: \"此會話將使用 {name} 而不是預設模型。\",\n    systemDefault: \"系統預設\",\n  },\n  models: {\n    embedding: \"嵌入模型\",\n    tts: \"文字轉語音\",\n    stt: \"語音轉文字\",\n    apiKey: \"API 密鑰\",\n    deleteSuccess: \"模型刪除成功\",\n    saveSuccess: \"模型儲存成功\",\n    noModels: \"暫無模型\",\n    discoverModels: \"探索模型\",\n    noModelsFound: \"未從此提供商找到模型\",\n    modelType: \"模型類型\",\n    modelTypeHint: \"選擇要新增的模型類型。如果需要不同類型，請分批新增。\",\n    deleteModel: \"刪除模型\",\n    defaultAssignments: \"預設模型分配\",\n    defaultAssignmentsDesc: \"設定用於 Open Notebook 不同用途的預設模型\",\n    missingRequiredModels: \"缺少必需的模型：{models}。如果没有這些模型，Open Notebook 可能無法正常運行。\",\n    selectModelPlaceholder: \"選擇一個模型\",\n    requiredModelPlaceholder: \"⚠️ 必需 - 請選擇一個模型\",\n    chatModelLabel: \"聊天模型\",\n    chatModelDesc: \"用於聊天對話\",\n    transformationModelLabel: \"轉換模型\",\n    transformationModelDesc: \"用於摘要、見解和內容轉換\",\n    toolsModelLabel: \"工具模型\",\n    toolsModelDesc: \"用於函數調用 - 推薦 OpenAI 或 Anthropic\",\n    largeContextModelLabel: \"大上下文模型\",\n    largeContextModelDesc: \"用於處理大文件 - 推薦 Gemini\",\n    embeddingModelLabel: \"嵌入模型\",\n    embeddingModelDesc: \"用於語義搜尋和向量嵌入\",\n    ttsModelLabel: \"文字轉語音模型\",\n    ttsModelDesc: \"用於生成播客\",\n    sttModelLabel: \"語音轉文字模型\",\n    sttModelDesc: \"用於音訊轉錄\",\n    embeddingChangeTitle: \"嵌入模型變更\",\n    embeddingChangeConfirm: \"您即將將嵌入模型從 {from} 更改為 {to}。\",\n    rebuildRequired: \"重要提示：需要重建索引\",\n    rebuildReason: \"更改嵌入模型需要重建所有現有嵌入以保持一致性。如果不重建，您的搜尋可能會返回錯誤或不完整的结果。\",\n    whatHappensNext: \"接下來會發生什麼：\",\n    step1: \"您的預設嵌入模型将被更新\",\n    step2: \"在重新構建之前，現有的嵌入將保持不變\",\n    step3: \"新內容將使用新的嵌入模型\",\n    step4: \"您應該儘快重新構建嵌入\",\n    proceedToRebuildPrompt: \"您想現在前往“進階設定”頁面開始重建索引嗎？\",\n    changeModelOnly: \"僅更改模型\",\n    changeAndRebuild: \"更改並前往重建\",\n    autoAssign: \"自動指派預設值\",\n    autoAssigning: \"正在指派...\",\n    autoAssignSuccess: \"已自動指派 {count} 個預設模型\",\n    autoAssignNoModels: \"沒有可指派的模型。請先同步模型。\",\n    autoAssignAlreadySet: \"所有預設模型已設定\",\n    testModel: \"測試模型\",\n    testModelSuccess: \"模型測試通過\",\n    testModelFailed: \"模型測試失敗\",\n    searchOrAddModel: \"搜尋或輸入模型名稱...\",\n    addCustomModel: \"新增 \\\"{name}\\\"\",\n  },\n  apiKeys: {\n    title: \"使用您自己的 API 金鑰設定 AI\",\n    description: \"將 API 金鑰安全地儲存在資料庫中，以在 Open Notebook 中啟用 AI 服務商。\",\n    encryptionRequired: \"未設定加密金鑰\",\n    encryptionRequiredDescription: \"請將 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數設定為任意密鑰字串，以啟用將 API 金鑰儲存至資料庫。\",\n    configured: \"已設定\",\n    notConfigured: \"未設定\",\n    migrationAvailable: \"偵測到環境變數\",\n    migrationDescription: \"{count} 個 API 金鑰通過環境變數設定，可以遷移到資料庫以便於管理。\",\n    migrateToDatabase: \"遷移到資料庫\",\n    migrating: \"遷移中...\",\n    migrationSuccess: \"{count} 個 API 金鑰遷移成功\",\n    migrationErrors: \"{count} 個金鑰遷移失敗\",\n    migrationNothingToMigrate: \"所有金鑰已在資料庫中\",\n    learnMore: \"瞭解如何設定 API 金鑰 →\",\n    testConnection: \"測試連線\",\n    testSuccess: \"連線成功\",\n    testFailed: \"連線測試失敗\",\n    syncModels: \"同步模型\",\n    syncSuccess: \"發現 {discovered} 個模型，新增 {new} 個\",\n    syncNoNew: \"發現 {count} 個模型，全部已註冊\",\n    syncFailed: \"同步模型失敗\",\n    getApiKey: \"取得 API 金鑰\",\n    vertexProject: \"GCP 專案 ID\",\n    vertexLocation: \"區域\",\n    vertexCredentials: \"服務帳戶 JSON 路徑\",\n    addConfig: \"新增設定\",\n    editConfig: \"編輯設定\",\n    deleteConfig: \"刪除設定\",\n    configName: \"設定名稱\",\n    configNameHint: \"此設定的描述性名稱（例如：'生產環境'、'開發環境'）\",\n    baseUrl: \"基礎 URL\",\n    baseUrlOverrideHint: \"僅在需要覆蓋提供商預設 API 端點時更改此項。\",\n    deleteConfigConfirm: \"確定要刪除 '{name}' 嗎？此操作無法撤銷。\",\n    configSaveSuccess: \"設定儲存成功\",\n    configUpdateSuccess: \"設定更新成功\",\n    configDeleteSuccess: \"設定刪除成功\",\n    apiKeyEditHint: \"留空以保留現有 API 金鑰\",\n  },\n  setupBanner: {\n    encryptionRequired: \"未設定加密金鑰\",\n    encryptionRequiredDescription: \"請設定 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數以啟用安全憑據儲存。\",\n    migrationAvailable: \"API 金鑰遷移可用\",\n    migrationDescription: \"{count} 個供應商的 API 金鑰透過環境變數設定。將它們遷移到資料庫以便於管理。\",\n    goToSettings: \"前往設定\",\n    viewDocs: \"查看文件\",\n  },\n}\n"
  },
  {
    "path": "frontend/src/lib/stores/CLAUDE.md",
    "content": "# Stores Module\n\nZustand-based state management for authentication, modals, and application-level settings with localStorage persistence.\n\n## Key Components\n\n- **`auth-store.ts`**: Authentication state (token, isAuthenticated) with login, logout, auth checking, and Zustand persistence\n- **Modal stores** (imported via hooks): Modal visibility and data state management\n- **Settings persistence**: Auto-saves sensitive state (token, auth status) to localStorage via Zustand persist middleware\n\n## Important Patterns\n\n- **Zustand create + persist**: State + actions combined in single store; `persist` middleware auto-syncs to localStorage\n- **Selective persistence**: `partialize` option limits what's saved (e.g., only `token` and `isAuthenticated`, not `isLoading`)\n- **Hydration tracking**: `setHasHydrated()` marks when localStorage data loaded; used to avoid hydration mismatch in SSR\n- **Auth caching**: 30-second cache on `checkAuth()` to avoid excessive API calls; stores `lastAuthCheck` timestamp\n- **Network resilience**: Handles 401 globally in API interceptor; graceful degradation if API unreachable\n- **API validation**: Uses actual API call (`/notebooks` endpoint) to validate token instead of parsing JWT\n\n## Key Dependencies\n\n- `zustand`: State management library\n- `@/lib/config`: `getApiUrl()` for dynamic server discovery\n- localStorage: Browser persistence API\n\n## How to Add New Stores\n\n1. Create new file (e.g., `settings-store.ts`)\n2. Define interface extending store state and actions\n3. Use `create<Interface>()(persist(...))`  for persistence, or plain `create<Interface>()` for ephemeral state:\n   ```typescript\n   export const useSettingsStore = create<SettingsState>()(\n     persist((set) => ({\n       theme: 'dark',\n       setTheme: (theme) => set({ theme })\n     }), {\n       name: 'settings-storage'\n     })\n   )\n   ```\n\n## Important Quirks & Gotchas\n\n- **Hydration mismatch**: Server-side rendered stores must check `hasHydrated` before rendering to prevent SSR mismatches\n- **localStorage key collision**: Persist middleware uses `name` option as localStorage key; ensure unique per store\n- **Token not validated**: `login()` only checks HTTP 200 response; doesn't decode or validate JWT structure\n- **Auth check race condition**: Multiple simultaneous `checkAuth()` calls return early if one already in progress (`isCheckingAuth`)\n- **Error messages from HTTP**: Shows 401/403/5xx status codes to user; helps with debugging but may leak info\n- **Network timeout handling**: Network errors in `checkAuthRequired()` set `authRequired: null` (safe default); `login()` shows generic message\n- **Logout doesn't invalidate session**: Client-side logout only clears local token; server session may still be valid\n- **Double authentication**: Both `login()` and `checkAuth()` test same `/notebooks` endpoint; could be optimized with dedicated endpoint\n\n## Testing Patterns\n\n```typescript\n// Mock store\nconst mockAuthStore = {\n  isAuthenticated: true,\n  token: 'test-token',\n  checkAuth: vi.fn().mockResolvedValue(true),\n  login: vi.fn().mockResolvedValue(true),\n  logout: vi.fn()\n}\n\n// Test store mutations\nact(() => store.setState({ theme: 'light' }))\nexpect(store.getState().theme).toBe('light')\n```\n"
  },
  {
    "path": "frontend/src/lib/stores/auth-store.ts",
    "content": "import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\nimport { getApiUrl } from '@/lib/config'\n\ninterface AuthState {\n  isAuthenticated: boolean\n  token: string | null\n  isLoading: boolean\n  error: string | null\n  lastAuthCheck: number | null\n  isCheckingAuth: boolean\n  hasHydrated: boolean\n  authRequired: boolean | null\n  setHasHydrated: (state: boolean) => void\n  checkAuthRequired: () => Promise<boolean>\n  login: (password: string) => Promise<boolean>\n  logout: () => void\n  checkAuth: () => Promise<boolean>\n}\n\nexport const useAuthStore = create<AuthState>()(\n  persist(\n    (set, get) => ({\n      isAuthenticated: false,\n      token: null,\n      isLoading: false,\n      error: null,\n      lastAuthCheck: null,\n      isCheckingAuth: false,\n      hasHydrated: false,\n      authRequired: null,\n\n      setHasHydrated: (state: boolean) => {\n        set({ hasHydrated: state })\n      },\n\n      checkAuthRequired: async () => {\n        try {\n          const apiUrl = await getApiUrl()\n          const response = await fetch(`${apiUrl}/api/auth/status`, {\n            cache: 'no-store',\n          })\n\n          if (!response.ok) {\n            throw new Error(`Auth status check failed: ${response.status}`)\n          }\n\n          const data = await response.json()\n          const required = data.auth_enabled || false\n          set({ authRequired: required })\n\n          // If auth is not required, mark as authenticated\n          if (!required) {\n            set({ isAuthenticated: true, token: 'not-required' })\n          }\n\n          return required\n        } catch (error) {\n          console.error('Failed to check auth status:', error)\n\n          // If it's a network error, set a more helpful error message\n          if (error instanceof TypeError && error.message.includes('Failed to fetch')) {\n            set({\n              error: 'Unable to connect to server. Please check if the API is running.',\n              authRequired: null  // Don't assume auth is required if we can't connect\n            })\n          } else {\n            // For other errors, default to requiring auth to be safe\n            set({ authRequired: true })\n          }\n\n          // Re-throw the error so the UI can handle it\n          throw error\n        }\n      },\n\n      login: async (password: string) => {\n        set({ isLoading: true, error: null })\n        try {\n          const apiUrl = await getApiUrl()\n\n          // Test auth with notebooks endpoint\n          const response = await fetch(`${apiUrl}/api/notebooks`, {\n            method: 'GET',\n            headers: {\n              'Authorization': `Bearer ${password}`,\n              'Content-Type': 'application/json'\n            }\n          })\n          \n          if (response.ok) {\n            set({ \n              isAuthenticated: true, \n              token: password, \n              isLoading: false,\n              lastAuthCheck: Date.now(),\n              error: null\n            })\n            return true\n          } else {\n            let errorMessage = 'Authentication failed'\n            if (response.status === 401) {\n              errorMessage = 'Invalid password. Please try again.'\n            } else if (response.status === 403) {\n              errorMessage = 'Access denied. Please check your credentials.'\n            } else if (response.status >= 500) {\n              errorMessage = 'Server error. Please try again later.'\n            } else {\n              errorMessage = `Authentication failed (${response.status})`\n            }\n            \n            set({ \n              error: errorMessage,\n              isLoading: false,\n              isAuthenticated: false,\n              token: null\n            })\n            return false\n          }\n        } catch (error) {\n          console.error('Network error during auth:', error)\n          let errorMessage = 'Authentication failed'\n          \n          if (error instanceof TypeError && error.message.includes('Failed to fetch')) {\n            errorMessage = 'Unable to connect to server. Please check if the API is running.'\n          } else if (error instanceof Error) {\n            errorMessage = `Network error: ${error.message}`\n          } else {\n            errorMessage = 'An unexpected error occurred during authentication'\n          }\n          \n          set({ \n            error: errorMessage,\n            isLoading: false,\n            isAuthenticated: false,\n            token: null\n          })\n          return false\n        }\n      },\n      \n      logout: () => {\n        set({ \n          isAuthenticated: false, \n          token: null, \n          error: null \n        })\n      },\n      \n      checkAuth: async () => {\n        const state = get()\n        const { token, lastAuthCheck, isCheckingAuth, isAuthenticated } = state\n\n        // If already checking, return current auth state\n        if (isCheckingAuth) {\n          return isAuthenticated\n        }\n\n        // If no token, not authenticated\n        if (!token) {\n          return false\n        }\n\n        // If we checked recently (within 30 seconds) and are authenticated, skip\n        const now = Date.now()\n        if (isAuthenticated && lastAuthCheck && (now - lastAuthCheck) < 30000) {\n          return true\n        }\n\n        set({ isCheckingAuth: true })\n\n        try {\n          const apiUrl = await getApiUrl()\n\n          const response = await fetch(`${apiUrl}/api/notebooks`, {\n            method: 'GET',\n            headers: {\n              'Authorization': `Bearer ${token}`,\n              'Content-Type': 'application/json'\n            }\n          })\n          \n          if (response.ok) {\n            set({ \n              isAuthenticated: true, \n              lastAuthCheck: now,\n              isCheckingAuth: false \n            })\n            return true\n          } else {\n            set({\n              isAuthenticated: false,\n              token: null,\n              lastAuthCheck: null,\n              isCheckingAuth: false\n            })\n            return false\n          }\n        } catch (error) {\n          console.error('checkAuth error:', error)\n          set({ \n            isAuthenticated: false, \n            token: null,\n            lastAuthCheck: null,\n            isCheckingAuth: false \n          })\n          return false\n        }\n      }\n    }),\n    {\n      name: 'auth-storage',\n      partialize: (state) => ({\n        token: state.token,\n        isAuthenticated: state.isAuthenticated\n      }),\n      onRehydrateStorage: () => (state) => {\n        state?.setHasHydrated(true)\n      }\n    }\n  )\n)"
  },
  {
    "path": "frontend/src/lib/stores/navigation-store.ts",
    "content": "import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\ninterface NavigationState {\n  returnTo?: {\n    path: string\n    label: string\n    preserveState?: {\n      scrollPosition?: number\n      highlightItemId?: string\n      timestamp?: number\n    }\n  }\n  setReturnTo: (path: string, label: string, preserveState?: object) => void\n  clearReturnTo: () => void\n  getReturnPath: () => string\n  getReturnLabel: () => string\n}\n\nexport const useNavigationStore = create<NavigationState>()(\n  persist(\n    (set, get) => ({\n      returnTo: undefined,\n\n      setReturnTo: (path, label, preserveState) => set({\n        returnTo: {\n          path,\n          label,\n          preserveState: {\n            ...preserveState,\n            timestamp: Date.now()\n          }\n        }\n      }),\n\n      clearReturnTo: () => set({ returnTo: undefined }),\n\n      getReturnPath: () => {\n        const state = get()\n        const returnTo = state.returnTo\n\n        // Check if context is stale (older than 1 hour)\n        if (returnTo?.preserveState?.timestamp) {\n          const isStale = Date.now() - returnTo.preserveState.timestamp > 3600000\n          if (isStale) {\n            set({ returnTo: undefined })\n            return '/sources'\n          }\n        }\n\n        return returnTo?.path || '/sources'\n      },\n\n      getReturnLabel: () => {\n        const state = get()\n        const returnTo = state.returnTo\n\n        // Check if context is stale (older than 1 hour)\n        if (returnTo?.preserveState?.timestamp) {\n          const isStale = Date.now() - returnTo.preserveState.timestamp > 3600000\n          if (isStale) {\n            set({ returnTo: undefined })\n            return 'Back to Sources'\n          }\n        }\n\n        return returnTo?.label || 'Back to Sources'\n      }\n    }),\n    {\n      name: 'navigation-storage',\n      storage: {\n        getItem: (name: string) => {\n          try {\n            const value = sessionStorage.getItem(name)\n            return value\n          } catch {\n            return null\n          }\n        },\n        setItem: (name: string, value: string) => {\n          try {\n            sessionStorage.setItem(name, value)\n          } catch {\n            // Silently fail if sessionStorage is not available\n          }\n        },\n        removeItem: (name: string) => {\n          try {\n            sessionStorage.removeItem(name)\n          } catch {\n            // Silently fail if sessionStorage is not available\n          }\n        }\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      } as any\n    }\n  )\n)"
  },
  {
    "path": "frontend/src/lib/stores/notebook-columns-store.ts",
    "content": "import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\ninterface NotebookColumnsState {\n  sourcesCollapsed: boolean\n  notesCollapsed: boolean\n  toggleSources: () => void\n  toggleNotes: () => void\n  setSources: (collapsed: boolean) => void\n  setNotes: (collapsed: boolean) => void\n}\n\nexport const useNotebookColumnsStore = create<NotebookColumnsState>()(\n  persist(\n    (set) => ({\n      sourcesCollapsed: false,\n      notesCollapsed: false,\n      toggleSources: () => set((state) => ({ sourcesCollapsed: !state.sourcesCollapsed })),\n      toggleNotes: () => set((state) => ({ notesCollapsed: !state.notesCollapsed })),\n      setSources: (collapsed) => set({ sourcesCollapsed: collapsed }),\n      setNotes: (collapsed) => set({ notesCollapsed: collapsed }),\n    }),\n    {\n      name: 'notebook-columns-storage',\n    }\n  )\n)\n"
  },
  {
    "path": "frontend/src/lib/stores/sidebar-store.ts",
    "content": "import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\ninterface SidebarState {\n  isCollapsed: boolean\n  toggleCollapse: () => void\n  setCollapsed: (collapsed: boolean) => void\n}\n\nexport const useSidebarStore = create<SidebarState>()(\n  persist(\n    (set) => ({\n      isCollapsed: false,\n      toggleCollapse: () => set((state) => ({ isCollapsed: !state.isCollapsed })),\n      setCollapsed: (collapsed) => set({ isCollapsed: collapsed }),\n    }),\n    {\n      name: 'sidebar-storage',\n    }\n  )\n)"
  },
  {
    "path": "frontend/src/lib/stores/theme-store.ts",
    "content": "import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\nexport type Theme = 'light' | 'dark' | 'system'\n\ninterface ThemeState {\n  theme: Theme\n  setTheme: (theme: Theme) => void\n  getSystemTheme: () => 'light' | 'dark'\n  getEffectiveTheme: () => 'light' | 'dark'\n}\n\nexport const useThemeStore = create<ThemeState>()(\n  persist(\n    (set, get) => ({\n      theme: 'system',\n      \n      setTheme: (theme: Theme) => {\n        set({ theme })\n        \n        // Apply theme to document immediately\n        if (typeof window !== 'undefined') {\n          const root = window.document.documentElement\n          const effectiveTheme = theme === 'system' ? get().getSystemTheme() : theme\n          \n          root.classList.remove('light', 'dark')\n          root.classList.add(effectiveTheme)\n          root.setAttribute('data-theme', effectiveTheme)\n        }\n      },\n      \n      getSystemTheme: () => {\n        if (typeof window !== 'undefined') {\n          return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\n        }\n        return 'light'\n      },\n      \n      getEffectiveTheme: () => {\n        const { theme } = get()\n        return theme === 'system' ? get().getSystemTheme() : theme\n      }\n    }),\n    {\n      name: 'theme-storage',\n      partialize: (state) => ({ theme: state.theme })\n    }\n  )\n)\n\n// Hook for components to use theme\nexport function useTheme() {\n  const { theme, setTheme, getEffectiveTheme } = useThemeStore()\n  \n  return {\n    theme,\n    setTheme,\n    effectiveTheme: getEffectiveTheme(),\n    isDark: getEffectiveTheme() === 'dark'\n  }\n}"
  },
  {
    "path": "frontend/src/lib/theme-script.ts",
    "content": "// This script runs before React hydration to prevent theme flash\nexport const themeScript = `\n(function() {\n  try {\n    var theme = JSON.parse(localStorage.getItem('theme-storage') || '{}').state?.theme || 'system';\n    var systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n    var effectiveTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;\n    \n    document.documentElement.classList.remove('light', 'dark');\n    document.documentElement.classList.add(effectiveTheme);\n    document.documentElement.setAttribute('data-theme', effectiveTheme);\n  } catch (e) {\n    // Fallback to light theme\n    document.documentElement.classList.add('light');\n    document.documentElement.setAttribute('data-theme', 'light');\n  }\n})();\n`"
  },
  {
    "path": "frontend/src/lib/types/api.ts",
    "content": "export interface NotebookResponse {\n  id: string\n  name: string\n  description: string\n  archived: boolean\n  created: string\n  updated: string\n  source_count: number\n  note_count: number\n}\n\nexport interface NoteResponse {\n  id: string\n  title: string | null\n  content: string | null\n  note_type: string | null\n  created: string\n  updated: string\n}\n\nexport interface SourceListResponse {\n  id: string\n  title: string | null\n  topics?: string[]                  // Make optional to match Python API\n  asset: {\n    file_path?: string\n    url?: string\n  } | null\n  embedded: boolean\n  embedded_chunks: number            // ADD: From Python API\n  insights_count: number\n  created: string\n  updated: string\n  file_available?: boolean\n  // ADD: Async processing fields from Python API\n  command_id?: string\n  status?: string\n  processing_info?: Record<string, unknown>\n}\n\nexport interface SourceDetailResponse extends SourceListResponse {\n  full_text: string\n  notebooks?: string[]  // List of notebook IDs this source is linked to\n}\n\nexport type SourceResponse = SourceDetailResponse\n\nexport interface SourceStatusResponse {\n  status?: string\n  message: string\n  processing_info?: Record<string, unknown>\n  command_id?: string\n}\n\nexport interface SettingsResponse {\n  default_content_processing_engine_doc?: string\n  default_content_processing_engine_url?: string\n  default_embedding_option?: string\n  auto_delete_files?: string\n  youtube_preferred_languages?: string[]\n}\n\nexport interface CreateNotebookRequest {\n  name: string\n  description?: string\n}\n\nexport interface UpdateNotebookRequest {\n  name?: string\n  description?: string\n  archived?: boolean\n}\n\nexport interface NotebookDeletePreview {\n  notebook_id: string\n  notebook_name: string\n  note_count: number\n  exclusive_source_count: number\n  shared_source_count: number\n}\n\nexport interface NotebookDeleteResponse {\n  message: string\n  deleted_notes: number\n  deleted_sources: number\n  unlinked_sources: number\n}\n\nexport interface CreateNoteRequest {\n  title?: string\n  content: string\n  note_type?: string\n  notebook_id?: string\n}\n\nexport interface CreateSourceRequest {\n  // Backward compatibility: support old single notebook_id\n  notebook_id?: string\n  // New multi-notebook support\n  notebooks?: string[]\n  // Required fields\n  type: 'link' | 'upload' | 'text'\n  url?: string\n  file_path?: string\n  content?: string\n  title?: string\n  transformations?: string[]\n  embed?: boolean\n  delete_source?: boolean\n  // New async processing support\n  async_processing?: boolean\n}\n\nexport interface UpdateNoteRequest {\n  title?: string\n  content?: string\n  note_type?: string\n}\n\nexport interface UpdateSourceRequest {\n  title?: string\n  type?: 'link' | 'upload' | 'text'\n  url?: string\n  content?: string\n}\n\nexport interface APIError {\n  detail: string\n}\n\n// Source Chat Types\n// Base session interface with common fields\nexport interface BaseChatSession {\n  id: string\n  title: string\n  created: string\n  updated: string\n  message_count?: number\n  model_override?: string | null\n}\n\nexport interface SourceChatSession extends BaseChatSession {\n  source_id: string\n  model_override?: string\n}\n\nexport interface SourceChatMessage {\n  id: string\n  type: 'human' | 'ai'\n  content: string\n  timestamp?: string\n}\n\nexport interface SourceChatContextIndicator {\n  sources: string[]\n  insights: string[]\n  notes: string[]\n}\n\nexport interface SourceChatSessionWithMessages extends SourceChatSession {\n  messages: SourceChatMessage[]\n  context_indicators?: SourceChatContextIndicator\n}\n\nexport interface CreateSourceChatSessionRequest {\n  source_id: string\n  title?: string\n  model_override?: string\n}\n\nexport interface UpdateSourceChatSessionRequest {\n  title?: string\n  model_override?: string\n}\n\nexport interface SendMessageRequest {\n  message: string\n  model_override?: string\n}\n\nexport interface SourceChatStreamEvent {\n  type: 'user_message' | 'ai_message' | 'context_indicators' | 'complete' | 'error'\n  content?: string\n  data?: unknown\n  message?: string\n  timestamp?: string\n}\n\n// Notebook Chat Types\nexport interface NotebookChatSession extends BaseChatSession {\n  notebook_id: string\n}\n\nexport interface NotebookChatMessage {\n  id: string\n  type: 'human' | 'ai'\n  content: string\n  timestamp?: string\n}\n\nexport interface NotebookChatSessionWithMessages extends NotebookChatSession {\n  messages: NotebookChatMessage[]\n}\n\nexport interface CreateNotebookChatSessionRequest {\n  notebook_id: string\n  title?: string\n  model_override?: string\n}\n\nexport interface UpdateNotebookChatSessionRequest {\n  title?: string\n  model_override?: string | null\n}\n\nexport interface SendNotebookChatMessageRequest {\n  session_id: string\n  message: string\n  context: {\n    sources: Array<Record<string, unknown>>\n    notes: Array<Record<string, unknown>>\n  }\n  model_override?: string\n}\n\nexport interface BuildContextRequest {\n  notebook_id: string\n  context_config: {\n    sources: Record<string, string>\n    notes: Record<string, string>\n  }\n}\n\nexport interface BuildContextResponse {\n  context: {\n    sources: Array<Record<string, unknown>>\n    notes: Array<Record<string, unknown>>\n  }\n  token_count: number\n  char_count: number\n}\n"
  },
  {
    "path": "frontend/src/lib/types/auth.ts",
    "content": "export interface AuthState {\n  isAuthenticated: boolean\n  token: string | null\n  isLoading: boolean\n  error: string | null\n}\n\nexport interface LoginCredentials {\n  password: string\n}"
  },
  {
    "path": "frontend/src/lib/types/common.ts",
    "content": "import type { ComponentType, SVGProps } from 'react'\n\nexport interface NavItem {\n  name: string\n  href: string\n  icon: ComponentType<SVGProps<SVGSVGElement>>\n}\n\nexport interface PageProps {\n  params: { [key: string]: string }\n  searchParams: { [key: string]: string | string[] | undefined }\n}\n"
  },
  {
    "path": "frontend/src/lib/types/config.ts",
    "content": "/**\n * Backend configuration response from Python API /api/config endpoint.\n * Note: apiUrl is determined by the Next.js runtime-config endpoint,\n * not returned by the Python backend.\n */\nexport interface BackendConfigResponse {\n  version: string\n  latestVersion?: string | null\n  hasUpdate?: boolean\n  dbStatus?: \"online\" | \"offline\"\n}\n\n/**\n * Complete application configuration used by the frontend.\n * This is constructed from the backend response + runtime-config.\n */\nexport interface AppConfig {\n  apiUrl: string\n  version: string\n  buildTime: string\n  latestVersion?: string | null\n  hasUpdate?: boolean\n  dbStatus?: \"online\" | \"offline\"\n}\n\n/**\n * Connection error state\n */\nexport interface ConnectionError {\n  type: \"api-unreachable\" | \"database-offline\"\n  details?: {\n    message?: string\n    technicalMessage?: string\n    stack?: string\n    attemptedUrl?: string\n  }\n}\n"
  },
  {
    "path": "frontend/src/lib/types/models.ts",
    "content": "export interface Model {\n  id: string\n  name: string\n  provider: string\n  type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'\n  credential?: string | null\n  created: string\n  updated: string\n}\n\nexport interface CreateModelRequest {\n  name: string\n  provider: string\n  type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'\n  credential?: string\n}\n\nexport interface ModelDefaults {\n  default_chat_model?: string | null\n  default_transformation_model?: string | null\n  large_context_model?: string | null\n  default_text_to_speech_model?: string | null\n  default_speech_to_text_model?: string | null\n  default_embedding_model?: string | null\n  default_tools_model?: string | null\n}\n\nexport interface ProviderAvailability {\n  available: string[]\n  unavailable: string[]\n  supported_types: Record<string, string[]>\n}\n\n// Model Discovery Types\nexport interface DiscoveredModel {\n  name: string\n  provider: string\n  model_type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'\n  description?: string\n}\n\nexport interface ProviderSyncResult {\n  provider: string\n  discovered: number\n  new: number\n  existing: number\n}\n\nexport interface AllProvidersSyncResult {\n  results: Record<string, ProviderSyncResult>\n  total_discovered: number\n  total_new: number\n}\n\nexport interface ProviderModelCount {\n  provider: string\n  counts: Record<string, number>\n  total: number\n}\n\nexport interface AutoAssignResult {\n  assigned: Record<string, string>  // slot_name -> model_id\n  skipped: string[]  // slots already assigned\n  missing: string[]  // slots with no available models\n}\n\nexport interface ModelTestResult {\n  success: boolean\n  message: string\n  details?: string\n}"
  },
  {
    "path": "frontend/src/lib/types/podcasts.ts",
    "content": "export type EpisodeStatus =\n  | 'running'\n  | 'processing'\n  | 'completed'\n  | 'failed'\n  | 'error'\n  | 'pending'\n  | 'submitted'\n  | 'unknown'\n\nexport interface EpisodeProfile {\n  id: string\n  name: string\n  description: string\n  speaker_config: string\n  outline_llm?: string | null\n  transcript_llm?: string | null\n  language?: string | null\n  default_briefing: string\n  num_segments: number\n  // Legacy fields (app ignores, kept in DB for migration)\n  outline_provider?: string | null\n  outline_model?: string | null\n  transcript_provider?: string | null\n  transcript_model?: string | null\n}\n\nexport interface SpeakerVoiceConfig {\n  name: string\n  voice_id: string\n  backstory: string\n  personality: string\n  voice_model?: string | null\n}\n\nexport interface SpeakerProfile {\n  id: string\n  name: string\n  description: string\n  voice_model?: string | null\n  speakers: SpeakerVoiceConfig[]\n  // Legacy fields\n  tts_provider?: string | null\n  tts_model?: string | null\n}\n\nexport interface Language {\n  code: string\n  name: string\n}\n\nexport interface PodcastEpisode {\n  id: string\n  name: string\n  episode_profile: EpisodeProfile\n  speaker_profile: SpeakerProfile\n  briefing: string\n  audio_file?: string | null\n  audio_url?: string | null\n  transcript?: Record<string, unknown> | null\n  outline?: Record<string, unknown> | null\n  created?: string | null\n  job_status?: EpisodeStatus | null\n  error_message?: string | null\n}\n\nexport interface PodcastGenerationRequest {\n  episode_profile: string\n  speaker_profile: string\n  episode_name: string\n  content?: string\n  notebook_id?: string\n  briefing_suffix?: string | null\n}\n\nexport interface PodcastGenerationResponse {\n  job_id: string\n  status: string\n  message: string\n  episode_profile: string\n  episode_name: string\n}\n\nexport type EpisodeStatusGroup = 'running' | 'completed' | 'failed' | 'pending'\n\nexport type EpisodeStatusGroups = Record<EpisodeStatusGroup, PodcastEpisode[]>\n\nexport const ACTIVE_EPISODE_STATUSES: EpisodeStatus[] = [\n  'running',\n  'processing',\n  'pending',\n  'submitted',\n]\n\nexport const FAILED_EPISODE_STATUSES: EpisodeStatus[] = ['failed', 'error']\n\nexport function groupEpisodesByStatus(episodes: PodcastEpisode[]): EpisodeStatusGroups {\n  return episodes.reduce<EpisodeStatusGroups>(\n    (groups, episode) => {\n      const status = episode.job_status || 'unknown'\n\n      if (status === 'running' || status === 'processing') {\n        groups.running.push(episode)\n        return groups\n      }\n\n      if (status === 'completed') {\n        groups.completed.push(episode)\n        return groups\n      }\n\n      if (FAILED_EPISODE_STATUSES.includes(status)) {\n        groups.failed.push(episode)\n        return groups\n      }\n\n      groups.pending.push(episode)\n      return groups\n    },\n    { running: [], completed: [], failed: [], pending: [] }\n  )\n}\n\nexport function speakerUsageMap(\n  speakerProfiles: SpeakerProfile[] | undefined,\n  episodeProfiles: EpisodeProfile[] | undefined\n): Record<string, number> {\n  if (!speakerProfiles || !episodeProfiles) {\n    return {}\n  }\n\n  const usage: Record<string, number> = {}\n\n  for (const profile of speakerProfiles) {\n    usage[profile.name] = 0\n  }\n\n  for (const episodeProfile of episodeProfiles) {\n    const key = episodeProfile.speaker_config\n    if (key in usage) {\n      usage[key] += 1\n    }\n  }\n\n  return usage\n}\n\n/** Check if a profile needs model configuration (missing required model references) */\nexport function needsModelSetup(profile: EpisodeProfile | SpeakerProfile): boolean {\n  if ('outline_llm' in profile) {\n    const ep = profile as EpisodeProfile\n    return !ep.outline_llm || !ep.transcript_llm\n  }\n  const sp = profile as SpeakerProfile\n  return !sp.voice_model\n}\n"
  },
  {
    "path": "frontend/src/lib/types/search.ts",
    "content": "// Search types\nexport interface SearchRequest {\n  query: string\n  type: 'text' | 'vector'\n  limit: number\n  search_sources: boolean\n  search_notes: boolean\n  minimum_score: number\n}\n\nexport interface SearchResult {\n  id: string\n  title: string\n  parent_id: string\n  final_score: number\n  matches?: string[]\n  relevance?: number\n  similarity?: number\n  score?: number\n  type?: string\n  source_type?: string\n  created: string\n  updated: string\n}\n\nexport interface SearchResponse {\n  results: SearchResult[]\n  total_count: number\n  search_type: string\n}\n\n// Ask types\nexport interface AskRequest {\n  question: string\n  strategy_model: string\n  answer_model: string\n  final_answer_model: string\n}\n\nexport interface AskResponse {\n  answer: string\n  question: string\n}\n\n// SSE Streaming types\nexport interface StrategyData {\n  reasoning: string\n  searches: Array<{\n    term: string\n    instructions: string\n  }>\n}\n\nexport interface AskStreamEvent {\n  type: 'strategy' | 'answer' | 'final_answer' | 'complete' | 'error'\n  reasoning?: string\n  searches?: Array<{ term: string; instructions: string }>\n  content?: string\n  final_answer?: string\n  message?: string\n}\n"
  },
  {
    "path": "frontend/src/lib/types/transformations.ts",
    "content": "export interface Transformation {\n  id: string\n  name: string\n  title: string\n  description: string\n  prompt: string\n  apply_default: boolean\n  created: string\n  updated: string\n}\n\nexport interface CreateTransformationRequest {\n  name: string\n  title: string\n  description: string\n  prompt: string\n  apply_default?: boolean\n}\n\nexport interface UpdateTransformationRequest {\n  name?: string\n  title?: string\n  description?: string\n  prompt?: string\n  apply_default?: boolean\n}\n\nexport interface ExecuteTransformationRequest {\n  transformation_id: string\n  input_text: string\n  model_id: string\n}\n\nexport interface ExecuteTransformationResponse {\n  output: string\n  transformation_id: string\n  model_id: string\n}\n\nexport interface DefaultPrompt {\n  transformation_instructions: string\n}"
  },
  {
    "path": "frontend/src/lib/utils/date-locale.ts",
    "content": "import { zhCN, enUS, zhTW, ptBR, ja, fr, ru, bn, Locale } from 'date-fns/locale'\n\n/**\n * Mapping of language codes to date-fns locales.\n * Add new languages here as needed.\n */\nconst LOCALE_MAP: Record<string, Locale> = {\n  'zh-CN': zhCN,\n  'zh-TW': zhTW,\n  'en-US': enUS,\n  'pt-BR': ptBR,\n  'ja-JP': ja,\n  'fr-FR': fr,\n  'ru-RU': ru,\n  'bn-IN': bn,\n}\n\n/**\n * Get the date-fns locale for a given language code.\n * Falls back to English (en-US) if the language is not found.\n * \n * @param language - The language code (e.g., 'zh-CN', 'en-US')\n * @returns The corresponding date-fns Locale object\n */\nexport function getDateLocale(language: string): Locale {\n  return LOCALE_MAP[language] || enUS\n}\n"
  },
  {
    "path": "frontend/src/lib/utils/error-handler.ts",
    "content": "/**\n * Utility to map backend English error messages to i18n keys.\n */\nexport const ERROR_MAP: Record<string, string> = {\n  \"Notebook not found\": \"apiErrors.notebookNotFound\",\n  \"Source not found\": \"apiErrors.sourceNotFound\",\n  \"Transformation not found\": \"apiErrors.transformationNotFound\",\n  \"File upload failed\": \"apiErrors.fileUploadFailed\",\n  \"URL is required for link type\": \"apiErrors.urlRequired\",\n  \"Content is required for text type\": \"apiErrors.contentRequired\",\n  \"Invalid source type\": \"apiErrors.invalidSourceType\",\n  \"Processing failed\": \"apiErrors.processingFailed\",\n  \"Failed to queue processing\": \"apiErrors.failedToQueue\",\n  \"sort_by must be 'created' or 'updated'\": \"apiErrors.invalidSortBy\",\n  \"sort_order must be 'asc' or 'desc'\": \"apiErrors.invalidSortOrder\",\n  \"Access to file denied\": \"apiErrors.accessDenied\",\n  \"File not found on server\": \"apiErrors.fileNotFoundOnServer\",\n  \"Missing authorization\": \"apiErrors.unauthorized\",\n  \"Invalid password\": \"apiErrors.invalidPassword\",\n  \"Invalid authorization header format\": \"apiErrors.unauthorized\",\n  \"Missing authorization header\": \"apiErrors.unauthorized\",\n  \"Vector search requires an embedding model\": \"apiErrors.embeddingModelRequired\",\n  \"Ask feature requires an embedding model\": \"apiErrors.embeddingModelRequired\",\n  \"Strategy model\": \"apiErrors.strategyModelNotFound\",\n  \"Answer model\": \"apiErrors.answerModelNotFound\",\n  \"Final answer model\": \"apiErrors.finalAnswerModelNotFound\",\n  \"No answer generated\": \"apiErrors.noAnswerGenerated\",\n};\n\n/**\n * Translates a backend error message using the ERROR_MAP.\n * If no mapping exists, returns the fallback key or generic error key.\n */\nexport function getApiErrorKey(errorOrMessage: unknown, fallbackKey?: string): string {\n  const message = formatApiError(errorOrMessage);\n  \n  if (!message) return fallbackKey || \"apiErrors.genericError\";\n\n  // Try exact match first\n  if (ERROR_MAP[message]) {\n    return ERROR_MAP[message];\n  }\n\n  // Try partial match for dynamic messages (e.g., \"File upload failed: ...\")\n  for (const [key, value] of Object.entries(ERROR_MAP)) {\n    if (message.startsWith(key)) {\n      return value;\n    }\n  }\n\n  return fallbackKey || \"apiErrors.genericError\";\n}\n\n/**\n * Extracts the error message, looks up i18n mapping, and falls back to the\n * backend-provided message when no mapping exists. This ensures user-friendly\n * error messages from the backend are displayed directly in the UI.\n */\nexport function getApiErrorMessage(\n  errorOrMessage: unknown,\n  t: (key: string) => string,\n  fallbackKey?: string\n): string {\n  const message = formatApiError(errorOrMessage);\n  if (!message) return fallbackKey ? t(fallbackKey) : t(\"apiErrors.genericError\");\n\n  // Try exact match\n  if (ERROR_MAP[message]) return t(ERROR_MAP[message]);\n\n  // Try partial match for dynamic messages (e.g., \"Strategy model ...\")\n  for (const [key, value] of Object.entries(ERROR_MAP)) {\n    if (message.startsWith(key)) return t(value);\n  }\n\n  // No mapping: return backend message directly (backend is responsible for making it user-friendly)\n  return message;\n}\n\n/**\n * Formats a raw error from the API into a user-friendly (potentially translated) string.\n */\nexport function formatApiError(error: unknown): string {\n  if (typeof error === 'string') return error;\n  \n  const err = error as { response?: { data?: { detail?: string } }, detail?: string, message?: string };\n  const detail = err?.response?.data?.detail || err?.detail || err?.message;\n  \n  if (typeof detail === 'string') {\n    return detail; // We'll handle the actual translation using the key in the hook/component\n  }\n  \n  return \"An unexpected error occurred\";\n}\n"
  },
  {
    "path": "frontend/src/lib/utils/source-references.tsx",
    "content": "import React from 'react'\nimport { FileText, Lightbulb, FileEdit } from 'lucide-react'\n\nexport type ReferenceType = 'source' | 'note' | 'source_insight'\n\nexport interface ParsedReference {\n  type: ReferenceType\n  id: string\n  originalText: string\n  startIndex: number\n  endIndex: number\n}\n\n// ExtractedReference and ExtractedReferences are kept for backward compatibility\n// but not currently used in the codebase\nexport interface ExtractedReference {\n  type: ReferenceType\n  id: string\n  originalText: string\n  placeholder: string\n}\n\nexport interface ExtractedReferences {\n  processedText: string\n  references: ExtractedReference[]\n}\n\nexport interface ReferenceData {\n  number: number\n  type: ReferenceType\n  id: string\n}\n\n/**\n * Parse source references from text\n *\n * Handles various formats:\n * - [source:abc123] → single reference\n * - [note:a], [note:b] → multiple references\n * - [note:a, note:b] → comma-separated references (edge case from LLM)\n * - Mixed: [source:x, note:y, source_insight:z]\n *\n * @param text - Text containing references\n * @returns Array of parsed references\n */\nexport function parseSourceReferences(text: string): ParsedReference[] {\n  // Match pattern: (source_insight|note|source):alphanumeric_id\n  // This handles references both inside and outside brackets\n  const pattern = /(source_insight|note|source):([a-zA-Z0-9_]+)/g\n  const matches: ParsedReference[] = []\n\n  let match\n  while ((match = pattern.exec(text)) !== null) {\n    const type = match[1] as ReferenceType\n    const id = match[2]\n\n    matches.push({\n      type,\n      id,\n      originalText: match[0],\n      startIndex: match.index,\n      endIndex: pattern.lastIndex\n    })\n  }\n\n  return matches\n}\n\n/**\n * Convert source references in text to clickable React elements\n *\n * @param text - Text containing references\n * @param onReferenceClick - Callback when reference is clicked (type, id)\n * @returns React nodes with clickable reference buttons\n */\nexport function convertSourceReferences(\n  text: string,\n  onReferenceClick: (type: ReferenceType, id: string) => void\n): React.ReactNode {\n  const matches = parseSourceReferences(text)\n\n  if (matches.length === 0) return text\n\n  const parts: React.ReactNode[] = []\n  let lastIndex = 0\n\n  matches.forEach((match, idx) => {\n    // Check if there are brackets before the match\n    const beforeMatch = text.substring(Math.max(0, match.startIndex - 2), match.startIndex)\n    const hasDoubleBracketBefore = beforeMatch === '[['\n    const hasSingleBracketBefore = beforeMatch.endsWith('[') && !hasDoubleBracketBefore\n\n    // Determine where to start including text\n    let textStartIndex = lastIndex\n    if (hasDoubleBracketBefore && lastIndex === match.startIndex - 2) {\n      textStartIndex = match.startIndex - 2\n    } else if (hasSingleBracketBefore && lastIndex === match.startIndex - 1) {\n      textStartIndex = match.startIndex - 1\n    }\n\n    // Add text before match (excluding brackets we'll include in the button)\n    if (textStartIndex < match.startIndex && lastIndex < textStartIndex) {\n      parts.push(text.substring(lastIndex, textStartIndex))\n    } else if (lastIndex < match.startIndex && !hasSingleBracketBefore && !hasDoubleBracketBefore) {\n      parts.push(text.substring(lastIndex, match.startIndex))\n    }\n\n    // Check if there are brackets after the match\n    const afterMatch = text.substring(match.endIndex, Math.min(text.length, match.endIndex + 2))\n    const hasDoubleBracketAfter = afterMatch === ']]'\n    const hasSingleBracketAfter = afterMatch.startsWith(']') && !hasDoubleBracketAfter\n\n    // Determine the display text with appropriate brackets\n    let displayText = match.originalText\n    if (hasDoubleBracketBefore && hasDoubleBracketAfter) {\n      displayText = `[[${match.originalText}]]`\n    } else if (hasSingleBracketBefore && hasSingleBracketAfter) {\n      displayText = `[${match.originalText}]`\n    } else {\n      displayText = match.originalText\n    }\n\n    // Add clickable reference button\n    parts.push(\n      <button\n        key={`ref-${idx}-${match.type}-${match.id}`}\n        onClick={(e) => {\n          e.preventDefault()\n          e.stopPropagation()\n          onReferenceClick(match.type, match.id)\n        }}\n        className=\"text-primary hover:underline cursor-pointer inline font-medium\"\n        type=\"button\"\n      >\n        {displayText}\n      </button>\n    )\n\n    // Update lastIndex to skip the closing brackets\n    if (hasDoubleBracketAfter) {\n      lastIndex = match.endIndex + 2\n    } else if (hasSingleBracketAfter) {\n      lastIndex = match.endIndex + 1\n    } else {\n      lastIndex = match.endIndex\n    }\n  })\n\n  // Add remaining text\n  if (lastIndex < text.length) {\n    parts.push(text.substring(lastIndex))\n  }\n\n  return <>{parts}</>\n}\n\n/**\n * Convert references in text to markdown links\n * Use this BEFORE passing text to ReactMarkdown\n *\n * Handles complex patterns including:\n * - Plain references: source:abc → [source:abc](#ref-source-abc)\n * - Bracketed: [source:abc] → [[source:abc]](#ref-source-abc)\n * - Double brackets: [[source:abc]] → [[[source:abc]]](#ref-source-abc)\n * - With bold: [**source:abc**] → [**source:abc**](#ref-source-abc)\n * - After commas: [source:a, note:b] → each converted separately\n * - Nested: [**source:a**, [source_insight:b]] → both converted\n *\n * Uses greedy matching to catch all references regardless of surrounding context.\n *\n * @param text - Original text with references\n * @returns Text with references converted to markdown links\n */\nexport function convertReferencesToMarkdownLinks(text: string): string {\n  // Step 1: Find ALL references using simple greedy pattern\n  const refPattern = /(source_insight|note|source):([a-zA-Z0-9_]+)/g\n  const references: Array<{ type: string; id: string; index: number; length: number }> = []\n\n  let match\n  while ((match = refPattern.exec(text)) !== null) {\n    const type = match[1]\n    const id = match[2]\n\n    // Validate the reference\n    const validTypes = ['source', 'source_insight', 'note']\n    if (!validTypes.includes(type) || !id || id.length === 0 || id.length > 100) {\n      continue // Skip invalid references\n    }\n\n    references.push({\n      type,\n      id,\n      index: match.index,\n      length: match[0].length\n    })\n  }\n\n  // If no references found, return original text\n  if (references.length === 0) return text\n\n  // Step 2: Process references from end to start (to preserve indices)\n  let result = text\n  for (let i = references.length - 1; i >= 0; i--) {\n    const ref = references[i]\n    const refStart = ref.index\n    const refEnd = refStart + ref.length\n    const refText = `${ref.type}:${ref.id}`\n\n    // Step 3: Analyze context around the reference\n    // Look back up to 50 chars for opening brackets/bold markers\n    const contextBefore = result.substring(Math.max(0, refStart - 50), refStart)\n    // Look ahead up to 50 chars for closing brackets/bold markers\n    const contextAfter = result.substring(refEnd, Math.min(result.length, refEnd + 50))\n\n    // Determine display text by checking immediate surroundings\n    let displayText = refText\n    let replaceStart = refStart\n    let replaceEnd = refEnd\n\n    // Check for double brackets [[ref]]\n    if (contextBefore.endsWith('[[') && contextAfter.startsWith(']]')) {\n      displayText = `[[${refText}]]`\n      replaceStart = refStart - 2\n      replaceEnd = refEnd + 2\n    }\n    // Check for single brackets [ref]\n    else if (contextBefore.endsWith('[') && contextAfter.startsWith(']')) {\n      displayText = `[${refText}]`\n      replaceStart = refStart - 1\n      replaceEnd = refEnd + 1\n    }\n    // Check for bold with brackets [**ref**]\n    else if (contextBefore.endsWith('[**') && contextAfter.startsWith('**]')) {\n      displayText = `[**${refText}**]`\n      replaceStart = refStart - 3\n      replaceEnd = refEnd + 3\n    }\n    // Check for just bold **ref**\n    else if (contextBefore.endsWith('**') && contextAfter.startsWith('**')) {\n      displayText = `**${refText}**`\n      replaceStart = refStart - 2\n      replaceEnd = refEnd + 2\n    }\n    // Plain reference (no brackets)\n    else {\n      displayText = refText\n    }\n\n    // Step 4: Build the markdown link\n    const href = `#ref-${ref.type}-${ref.id}`\n    const markdownLink = `[${displayText}](${href})`\n\n    // Step 5: Replace in the result string\n    result = result.substring(0, replaceStart) + markdownLink + result.substring(replaceEnd)\n  }\n\n  return result\n}\n\n/**\n * Create a custom link component for ReactMarkdown that handles reference links\n *\n * @param onReferenceClick - Callback for when a reference link is clicked\n * @returns React component for rendering links\n */\nexport function createReferenceLinkComponent(\n  onReferenceClick: (type: ReferenceType, id: string) => void\n) {\n  const ReferenceLinkComponent = ({\n    href,\n    children,\n    ...props\n  }: React.AnchorHTMLAttributes<HTMLAnchorElement> & {\n    href?: string\n    children?: React.ReactNode\n  }) => {\n    // Check if this is a reference link (starts with #ref-)\n    if (href?.startsWith('#ref-')) {\n      // Parse: #ref-source-abc123 → type=source, id=abc123\n      const parts = href.substring(5).split('-') // Remove '#ref-'\n      const type = parts[0] as ReferenceType\n      const id = parts.slice(1).join('-') // Rejoin in case ID has dashes\n\n      // Select appropriate icon based on reference type\n      const IconComponent =\n        type === 'source' ? FileText :\n        type === 'source_insight' ? Lightbulb :\n        FileEdit // note\n\n      return (\n        <button\n          onClick={(e) => {\n            e.preventDefault()\n            e.stopPropagation()\n            onReferenceClick(type, id)\n          }}\n          className=\"text-primary hover:underline cursor-pointer inline font-medium\"\n          type=\"button\"\n        >\n          <IconComponent className=\"h-3 w-3 inline mr-1\" aria-hidden=\"true\" />\n          {children}\n        </button>\n      )\n    }\n\n    // Regular link - open in new tab\n    return (\n      <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" {...props} className=\"text-primary hover:underline\">\n        {children}\n      </a>\n    )\n  }\n\n  ReferenceLinkComponent.displayName = 'ReferenceLinkComponent'\n  return ReferenceLinkComponent\n}\n\n/**\n * Convert references in text to compact numbered format with reference list\n *\n * This function transforms verbose inline references like [source:abc123] into\n * compact numbered citations [1], [2], etc., and appends a \"References:\" section\n * at the bottom of the message with the full reference details.\n *\n * Algorithm:\n * 1. Parse all references using parseSourceReferences()\n * 2. Build a reference map to deduplicate and assign numbers\n * 3. Replace inline references with numbered citations\n * 4. Append reference list at the bottom\n *\n * @param text - Original text with references\n * @param referencesLabel - Locales label for \"References\" title (default: \"References\")\n * @returns Text with numbered citations and reference list appended\n *\n * @example\n * Input: \"See [source:abc] and [note:xyz]. Also [source:abc] again.\"\n * Output: \"See [1] and [2]. Also [1] again.\\n\\nReferences:\\n[1] - [source:abc]\\n[2] - [note:xyz]\"\n */\nexport function convertReferencesToCompactMarkdown(text: string, referencesLabel: string = 'References'): string {\n  // Step 1: Parse all references using existing function\n  const references = parseSourceReferences(text)\n\n  // Step 2: If no references found, return original text\n  if (references.length === 0) {\n    return text\n  }\n\n  // Step 3: Build reference map (deduplicate and assign numbers)\n  const referenceMap = new Map<string, ReferenceData>()\n  let nextNumber = 1\n\n  for (const reference of references) {\n    const key = `${reference.type}:${reference.id}`\n    if (!referenceMap.has(key)) {\n      referenceMap.set(key, {\n        number: nextNumber++,\n        type: reference.type,\n        id: reference.id\n      })\n    }\n  }\n\n  // Step 4: Replace references with numbered citations (process from end to start)\n  let result = text\n  for (let i = references.length - 1; i >= 0; i--) {\n    const reference = references[i]\n    const key = `${reference.type}:${reference.id}`\n    const refData = referenceMap.get(key)!\n    const number = refData.number\n\n    // Analyze context around the reference\n    const refStart = reference.startIndex\n    const refEnd = reference.endIndex\n    const contextBefore = result.substring(Math.max(0, refStart - 2), refStart)\n    const contextAfter = result.substring(refEnd, Math.min(result.length, refEnd + 2))\n\n    // Determine what to replace based on bracket context\n    let replaceStart = refStart\n    let replaceEnd = refEnd\n\n    // Check for double brackets [[ref]]\n    if (contextBefore === '[[' && contextAfter.startsWith(']]')) {\n      replaceStart = refStart - 2\n      replaceEnd = refEnd + 2\n    }\n    // Check for single brackets [ref]\n    else if (contextBefore.endsWith('[') && contextAfter.startsWith(']')) {\n      replaceStart = refStart - 1\n      replaceEnd = refEnd + 1\n    }\n\n    // Build the numbered citation with full reference in href\n    const citationLink = `[${number}](#ref-${reference.type}-${reference.id})`\n\n    // Replace in the result string\n    result = result.substring(0, replaceStart) + citationLink + result.substring(replaceEnd)\n  }\n\n  // Step 5: Build reference list\n  const refListLines: string[] = [`\\n\\n${referencesLabel}:`]\n\n  // Iterate through reference map in insertion order (Map preserves order)\n  for (const [, refData] of referenceMap) {\n    const refListItem = `[${refData.number}] - [${refData.type}:${refData.id}](#ref-${refData.type}-${refData.id})`\n    refListLines.push(refListItem)\n  }\n\n  // Step 6: Append reference list to result\n  result = result + refListLines.join('\\n')\n\n  return result\n}\n\n/**\n * Create a custom link component for ReactMarkdown that handles compact reference links\n *\n * This component handles two types of reference links:\n * 1. Numbered citations in text: [1](#ref-source-abc123)\n * 2. Reference list items: [source:abc123](#ref-source-abc123)\n *\n * Both use the same href format: #ref-{type}-{id}\n * The component extracts the type and id from the href and triggers the click handler.\n *\n * @param onReferenceClick - Callback for when a reference link is clicked\n * @returns React component for rendering links in ReactMarkdown\n *\n * @example\n * const LinkComponent = createCompactReferenceLinkComponent((type, id) => openModal(type, id))\n * <ReactMarkdown components={{ a: LinkComponent }}>...</ReactMarkdown>\n */\nexport function createCompactReferenceLinkComponent(\n  onReferenceClick: (type: ReferenceType, id: string) => void\n) {\n  const CompactReferenceLinkComponent = ({\n    href,\n    children,\n    ...props\n  }: React.AnchorHTMLAttributes<HTMLAnchorElement> & {\n    href?: string\n    children?: React.ReactNode\n  }) => {\n    // Check if this is a reference link (starts with #ref-)\n    if (href?.startsWith('#ref-')) {\n      // Parse: #ref-source-abc123 → type=source, id=abc123\n      const parts = href.substring(5).split('-') // Remove '#ref-'\n      const type = parts[0] as ReferenceType\n      const id = parts.slice(1).join('-') // Rejoin in case ID has dashes\n\n      return (\n        <button\n          onClick={(e) => {\n            e.preventDefault()\n            e.stopPropagation()\n            onReferenceClick(type, id)\n          }}\n          className=\"text-primary hover:underline cursor-pointer inline font-medium\"\n          type=\"button\"\n        >\n          {children}\n        </button>\n      )\n    }\n\n    // Regular link - open in new tab\n    return (\n      <a href={href} target=\"_blank\" rel=\"noopener noreferrer\" {...props} className=\"text-primary hover:underline\">\n        {children}\n      </a>\n    )\n  }\n\n  CompactReferenceLinkComponent.displayName = 'CompactReferenceLinkComponent'\n  return CompactReferenceLinkComponent\n}\n\n/**\n * Legacy function for backward compatibility\n * Converts old Link-based references to new click handler approach\n *\n * @deprecated Use extractReferences + replacePlaceholdersWithButtons instead\n */\nexport function convertSourceReferencesLegacy(text: string): React.ReactNode {\n  // For legacy support, just return text as-is\n  // Components should migrate to new convertSourceReferences function\n  return text\n}\n"
  },
  {
    "path": "frontend/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "frontend/src/proxy.ts",
    "content": "import { NextResponse } from 'next/server'\nimport type { NextRequest } from 'next/server'\n\nexport function proxy(request: NextRequest) {\n  const { pathname } = request.nextUrl\n\n  // Redirect root to notebooks\n  if (pathname === '/') {\n    return NextResponse.redirect(new URL('/notebooks', request.url))\n  }\n\n  return NextResponse.next()\n}\n\nexport const config = {\n  matcher: [\n    '/((?!api|_next/static|_next/image|favicon.ico).*)',\n  ],\n}\n"
  },
  {
    "path": "frontend/src/test/jest-dom.d.ts",
    "content": "/// <reference types=\"@testing-library/jest-dom\" />\n"
  },
  {
    "path": "frontend/src/test/setup.ts",
    "content": "import '@testing-library/jest-dom'\nimport { vi } from 'vitest'\nimport { enUS } from '../lib/locales/en-US'\n\n// Mock next/navigation\nvi.mock('next/navigation', () => ({\n  useRouter: () => ({\n    push: vi.fn(),\n    replace: vi.fn(),\n    prefetch: vi.fn(),\n  }),\n  usePathname: () => '',\n  useSearchParams: () => new URLSearchParams(),\n}))\n\n// Mock window.matchMedia\nObject.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: vi.fn().mockImplementation(query => ({\n    matches: false,\n    media: query,\n    onchange: null,\n    addListener: vi.fn(), // Deprecated\n    removeListener: vi.fn(), // Deprecated\n    addEventListener: vi.fn(),\n    removeEventListener: vi.fn(),\n    dispatchEvent: vi.fn(),\n  })),\n})\n\n// Mock @/lib/hooks/use-translation with full locale structure\nvi.mock('../lib/hooks/use-translation', () => {\n  const t = (key: string) => key\n  Object.assign(t, enUS)\n  \n  return {\n    useTranslation: () => ({\n      t,\n      language: 'en-US',\n      setLanguage: vi.fn(),\n    }),\n  }\n})\n\n// Mock @/lib/hooks/use-auth\nvi.mock('@/lib/hooks/use-auth', () => ({\n  useAuth: vi.fn(() => ({\n    user: { id: '1', email: 'test@example.com' },\n    logout: vi.fn(),\n    isLoading: false,\n  })),\n}))\n\n// Mock @/lib/stores/sidebar-store\nvi.mock('@/lib/stores/sidebar-store', () => ({\n  useSidebarStore: vi.fn(() => ({\n    isCollapsed: false,\n    toggleCollapse: vi.fn(),\n  })),\n}))\n\n// Mock @/lib/hooks/use-create-dialogs\nvi.mock('@/lib/hooks/use-create-dialogs', () => ({\n  useCreateDialogs: vi.fn(() => ({\n    openSourceDialog: vi.fn(),\n    openNotebookDialog: vi.fn(),\n    openPodcastDialog: vi.fn(),\n  })),\n}))\n"
  },
  {
    "path": "frontend/start-server.js",
    "content": "#!/usr/bin/env node\n\n// Set default PORT if not already set\nif (!process.env.PORT) {\n  process.env.PORT = '8502';\n}\n\n// Start the Next.js standalone server\nrequire('./.next/standalone/server.js');\n"
  },
  {
    "path": "frontend/tailwind.config.ts",
    "content": "import typography from \"@tailwindcss/typography\";\nimport type { Config } from \"tailwindcss\";\n\nconst config: Config = {\n  content: [\n    \"./src/pages/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./src/components/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./src/app/**/*.{js,ts,jsx,tsx,mdx}\",\n  ],\n  darkMode: \"class\",\n  theme: {\n    extend: {},\n  },\n  plugins: [typography],\n};\n\nexport default config;"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2017\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\n        \"./src/*\"\n      ]\n    }\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\",\n    \"vitest.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "frontend/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config'\nimport react from '@vitejs/plugin-react'\nimport path from 'path'\n\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    environment: 'jsdom',\n    globals: true,\n    setupFiles: ['./src/test/setup.ts'],\n    alias: {\n      '@': path.resolve(__dirname, './src')\n    }\n  }\n})\n"
  },
  {
    "path": "mypy.ini",
    "content": "[mypy]\n# Only check for syntax errors, not type errors\n# This allows the codebase to gradually add type hints\nwarn_return_any = False\nwarn_unused_configs = True\nignore_missing_imports = True\nno_implicit_optional = False\n\ncheck_untyped_defs = True\nexplicit_package_bases = True\nmypy_path = .\n\n# Disable type checking for files with many errors\n[mypy-api.client]\nignore_errors = True\n\n[mypy-api.podcast_api_service]\nignore_errors = True\n\n[mypy-api.auth]\nignore_errors = True\n\n[mypy-api.routers.models]\nignore_errors = True\n\n[mypy-open_notebook.domain.base]\nignore_errors = True\n\n[mypy-open_notebook.domain.notebook]\nignore_errors = True\n\n[mypy-open_notebook.graphs.transformation]\nignore_errors = True\n\n[mypy-open_notebook.graphs.ask]\nignore_errors = True\n"
  },
  {
    "path": "open_notebook/CLAUDE.md",
    "content": "# Open Notebook - Root CLAUDE.md\n\nThis file provides architectural guidance for contributors working on Open Notebook at the project level.\n\n## Project Overview\n\n**Open Notebook** is an open-source, privacy-focused alternative to Google's Notebook LM. It's an AI-powered research assistant enabling users to upload multi-modal content (PDFs, audio, video, web pages), generate intelligent notes, search semantically, chat with AI models, and produce professional podcasts—all with complete control over data and choice of AI providers.\n\n**Key Values**: Privacy-first, multi-provider AI support, fully self-hosted option, open-source transparency.\n\n---\n\n## Three-Tier Architecture\n\n```\n┌─────────────────────────────────────────────────────────┐\n│              Frontend (React/Next.js)                    │\n│              frontend/ @ port 3000                       │\n├─────────────────────────────────────────────────────────┤\n│ - Notebooks, sources, notes, chat, podcasts, search UI  │\n│ - Zustand state management, TanStack Query (React Query)│\n│ - Shadcn/ui component library with Tailwind CSS         │\n└────────────────────────┬────────────────────────────────┘\n                         │ HTTP REST\n┌────────────────────────▼────────────────────────────────┐\n│              API (FastAPI)                              │\n│              api/ @ port 5055                           │\n├─────────────────────────────────────────────────────────┤\n│ - REST endpoints for notebooks, sources, notes, chat    │\n│ - LangGraph workflow orchestration                      │\n│ - Job queue for async operations (podcasts)             │\n│ - Multi-provider AI provisioning via Esperanto          │\n└────────────────────────┬────────────────────────────────┘\n                         │ SurrealQL\n┌────────────────────────▼────────────────────────────────┐\n│         Database (SurrealDB)                            │\n│         Graph database @ port 8000                      │\n├─────────────────────────────────────────────────────────┤\n│ - Records: Notebook, Source, Note, ChatSession, Credential│\n│ - Relationships: source-to-notebook, note-to-source     │\n│ - Vector embeddings for semantic search                 │\n└─────────────────────────────────────────────────────────┘\n```\n\n---\n\n## Useful sources\n\nUser documentation is at @docs/\n\n## Tech Stack\n\n### Frontend (`frontend/`)\n- **Framework**: Next.js 16 (React 19)\n- **Language**: TypeScript\n- **State Management**: Zustand\n- **Data Fetching**: TanStack Query (React Query)\n- **Styling**: Tailwind CSS + Shadcn/ui\n- **Build Tool**: Webpack (via Next.js)\n- **i18n compatible**: All front-end changes must also consider the translation keys\n\n### API Backend (`api/` + `open_notebook/`)\n- **Framework**: FastAPI 0.104+\n- **Language**: Python 3.11+\n- **Workflows**: LangGraph state machines\n- **Database**: SurrealDB async driver\n- **AI Providers**: Esperanto library (8+ providers: OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI)\n- **Job Queue**: Surreal-Commands for async jobs (podcasts)\n- **Logging**: Loguru\n- **Validation**: Pydantic v2\n- **Testing**: Pytest\n\n### Database\n- **SurrealDB**: Graph database with built-in embedding storage and vector search\n- **Schema Migrations**: Automatic on API startup via AsyncMigrationManager\n\n### Additional Services\n- **Content Processing**: content-core library (file/URL extraction)\n- **Prompts**: AI-Prompter with Jinja2 templating\n- **Podcast Generation**: podcast-creator library\n- **Embeddings**: Multi-provider via Esperanto\n\n---\n\n## Architecture Highlights\n\n### 1. Async-First Design\n- All database queries, graph invocations, and API calls are async (await)\n- SurrealDB async driver with connection pooling\n- FastAPI handles concurrent requests efficiently\n\n### 2. LangGraph Workflows\n- **source.py**: Content ingestion (extract → embed → save)\n- **chat.py**: Conversational agent with message history\n- **ask.py**: Search + synthesis (retrieve relevant sources → LLM)\n- **transformation.py**: Custom transformations on sources\n- All use `provision_langchain_model()` for smart model selection\n\n### 3. Multi-Provider AI\n- **Esperanto library**: Unified interface to 8+ AI providers\n- **Credential system**: Individual encrypted credential records per provider; models link to credentials for direct config\n- **ModelManager**: Factory pattern with fallback logic; uses credential config when available, env vars as fallback\n- **Smart selection**: Detects large contexts, prefers long-context models\n- **Override support**: Per-request model configuration\n\n### 4. Database Schema\n- **Automatic migrations**: AsyncMigrationManager runs on API startup\n- **SurrealDB graph model**: Records with relationships and embeddings\n- **Vector search**: Built-in semantic search across all content\n- **Transactions**: Repo functions handle ACID operations\n\n### 5. Error Handling\n- **Custom exceptions** (`exceptions.py`): Hierarchy rooted at `OpenNotebookError` with typed subclasses (`AuthenticationError`, `ConfigurationError`, `RateLimitError`, `ExternalServiceError`, `NetworkError`, etc.)\n- **Error classification** (`utils/error_classifier.py`): `classify_error()` maps raw LLM provider exceptions to typed exceptions with user-friendly messages via keyword matching\n- **Global handlers**: FastAPI exception handlers in `api/main.py` convert typed exceptions to appropriate HTTP status codes (401, 422, 429, 502, etc.)\n\n### 6. Authentication\n- **Current**: Simple password middleware (insecure, dev-only)\n- **Production**: Replace with OAuth/JWT (see CONFIGURATION.md)\n\n---\n\n## Important Quirks & Gotchas\n\n### API Startup\n- **Migrations run automatically** on startup; check logs for errors\n- **Must start API before UI**: UI depends on API for all data\n- **SurrealDB must be running**: API fails without database connection\n\n### Frontend-Backend Communication\n- **Base API URL**: Configured in `.env.local` (default: http://localhost:5055)\n- **CORS enabled**: Configured in `api/main.py` (allow all origins in dev)\n- **Rate limiting**: Not built-in; add at proxy layer for production\n\n### LangGraph Workflows\n- **Blocking operations**: Chat/podcast workflows may take minutes; no timeout\n- **State persistence**: Uses SQLite checkpoint storage in `/data/sqlite-db/`\n- **Model fallback**: If primary model fails, falls back to cheaper/smaller model\n\n### Podcast Generation\n- **Async job queue**: `podcast_service.py` submits jobs but doesn't wait\n- **Track status**: Use `/commands/{command_id}` endpoint to poll status\n- **Failure handling**: Failed jobs are marked as \"failed\" with error messages; retry via `POST /podcasts/episodes/{id}/retry`\n- **No automatic retries**: Podcast jobs use `max_attempts: 1` to prevent duplicate episode records\n- **TTS failures**: Fall back to silent audio if speech synthesis fails\n\n### Content Processing\n- **File extraction**: Uses content-core library; supports 50+ file types\n- **URL handling**: Extracts text + metadata from web pages\n- **Large files**: Content processing is sync; may block API briefly\n\n---\n\n## Component References\n\nSee dedicated CLAUDE.md files for detailed guidance:\n\n- **[frontend/CLAUDE.md](../frontend/CLAUDE.md)**: React/Next.js architecture, state management, API integration\n- **[api/CLAUDE.md](../api/CLAUDE.md)**: FastAPI structure, service pattern, endpoint development\n- **[domain/CLAUDE.md](domain/CLAUDE.md)**: Data models, repository pattern, search functions\n- **[ai/CLAUDE.md](ai/CLAUDE.md)**: ModelManager, AI provider integration, Esperanto usage\n- **[graphs/CLAUDE.md](graphs/CLAUDE.md)**: LangGraph workflow design, state machines\n- **[database/CLAUDE.md](database/CLAUDE.md)**: SurrealDB operations, migrations, async patterns\n\n---\n\n## Documentation Map\n\n- **[README.md](../README.md)**: Project overview, features, quick start\n- **[docs/index.md](../docs/index.md)**: Complete user & deployment documentation\n- **[CONFIGURATION.md](../CONFIGURATION.md)**: Environment variables, model configuration\n- **[CONTRIBUTING.md](../CONTRIBUTING.md)**: Contribution guidelines\n- **[MAINTAINER_GUIDE.md](../MAINTAINER_GUIDE.md)**: Release & maintenance procedures\n\n---\n\n## Testing Strategy\n\n- **Unit tests**: `tests/test_domain.py`, `test_models_api.py`\n- **Graph tests**: `tests/test_graphs.py` (workflow integration)\n- **Utils tests**: `tests/test_utils.py`, `tests/test_chunking.py`, `tests/test_embedding.py`\n- **Run all**: `uv run pytest tests/`\n- **Coverage**: Check with `pytest --cov`\n\n---\n\n## Common Tasks\n\n### Add a New API Endpoint\n1. Create router in `api/routers/feature.py`\n2. Create service in `api/feature_service.py`\n3. Define schemas in `api/models.py`\n4. Register router in `api/main.py`\n5. Test via http://localhost:5055/docs\n\n### Add a New LangGraph Workflow\n1. Create `open_notebook/graphs/workflow_name.py`\n2. Define StateDict and node functions\n3. Build graph with `.add_node()` / `.add_edge()`\n4. Invoke in service: `graph.ainvoke({\"input\": ...}, config={\"...\"})`\n5. Test with sample data in `tests/`\n\n### Add Database Migration\n1. Create `migrations/XXX_description.surql`\n2. Write SurrealQL schema changes\n3. Create `migrations/XXX_description_down.surql` (optional rollback)\n4. API auto-detects on startup; migration runs if newer than recorded version\n\n### Deploy to Production\n1. Review [CONFIGURATION.md](CONFIGURATION.md) for security settings\n2. Use `make docker-release` for multi-platform image\n3. Push to Docker Hub / GitHub Container Registry\n4. Deploy `docker compose --profile multi up`\n5. Verify migrations via API logs\n\n---\n\n## Support & Community\n\n- **Documentation**: https://open-notebook.ai\n- **Discord**: https://discord.gg/37XJPXfz2w\n- **Issues**: https://github.com/lfnovo/open-notebook/issues\n- **License**: MIT (see LICENSE)\n\n\n"
  },
  {
    "path": "open_notebook/__init__.py",
    "content": ""
  },
  {
    "path": "open_notebook/ai/CLAUDE.md",
    "content": "# AI Module\n\nModel configuration, provisioning, and management for multi-provider AI integration via Esperanto.\n\n## Purpose\n\nCentralizes AI model lifecycle: database models for model metadata (provider, type), default model configuration, and factory for instantiating LLM/embedding/speech models at runtime with fallback logic.\n\n## Architecture Overview\n\n**Two-tier system**:\n1. **Database models** (`Model`, `DefaultModels`): Metadata storage and default configuration\n2. **ModelManager**: Factory for provisioning models with intelligent fallback (large context detection, config override)\n\nAll models use Esperanto library as provider abstraction (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI, OpenRouter).\n\n## Component Catalog\n\n### models.py\n\n#### Model (ObjectModel)\n- Database record: name, provider, type (language/embedding/speech_to_text/text_to_speech), credential (optional link to Credential record)\n- `get_models_by_type()`: Async query to fetch all models of a specific type\n- `get_credential_obj()`: Fetches linked Credential object (if credential field set)\n- `get_by_credential(credential_id)`: Class method to find all models linked to a credential\n- Stores provider-model pairs for AI factory instantiation\n\n#### DefaultModels (RecordModel)\n- Singleton configuration record (record_id: `open_notebook:default_models`)\n- Fields: default_chat_model, default_transformation_model, large_context_model, default_text_to_speech_model, default_speech_to_text_model, default_embedding_model, default_tools_model\n- `get_instance()`: Always fetches fresh from database (overrides parent caching for real-time updates)\n- Returns fresh instance on each call (no singleton cache)\n\n#### ModelManager\n- Stateless factory for instantiating AI models\n- `get_model(model_id)`: Retrieves Model by ID; if model has linked credential, uses `credential.to_esperanto_config()` for provider config; otherwise falls back to env var provisioning via `key_provider`\n- `get_defaults()`: Fetches DefaultModels configuration\n- `get_default_model(model_type)`: Smart lookup (e.g., \"chat\" → default_chat_model, \"transformation\" → default_transformation_model with fallback to chat)\n- `get_speech_to_text()`, `get_text_to_speech()`, `get_embedding_model()`: Type-specific convenience methods with assertions\n- **Global instance**: `model_manager` singleton exported for use throughout app\n\n### provision.py\n\n#### provision_langchain_model()\n- Factory for LangGraph nodes needing LLM provisioning\n- **Smart fallback logic**:\n  - If tokens > 105,000: Use `large_context_model`\n  - Elif `model_id` specified: Use specific model\n  - Else: Use default model for type (e.g., \"chat\", \"transformation\")\n- Returns LangChain-compatible model via `.to_langchain()`\n- Logs model selection decision\n\n### key_provider.py\n\n#### API Key Provider (Credential→Env Fallback)\n- **Purpose**: Provides API keys from database first, falls back to environment variables\n- **Pattern**: Before Esperanto creates a model, keys are loaded from `Credential` records and set as environment variables\n- **Integration point**: Called by `ModelManager.get_model()` as fallback when model has no linked credential\n\n#### Key Functions\n- `get_api_key(provider)`: Get single API key (DB first, then env var)\n- `provision_provider_keys(provider)`: Set env vars from DB config for a provider\n- `provision_all_keys()`: Load all provider keys from DB into env vars (useful at startup)\n\n#### Provider Configuration Maps\n- `PROVIDER_CONFIG`: Simple providers (openai, anthropic, google, groq, etc.)\n- `VERTEX_CONFIG`: Google Vertex AI (project, location, credentials)\n- `AZURE_CONFIG`: Azure OpenAI (api_key, endpoint, api_version, mode-specific endpoints)\n- `OPENAI_COMPATIBLE_CONFIG`: Generic OpenAI-compatible (generic + mode-specific for LLM/EMBEDDING/STT/TTS)\n\n## Common Patterns\n\n- **Type dispatch**: Model.type field drives factory logic (4 model types)\n- **Provider abstraction**: Esperanto handles provider differences; ModelManager unaware of provider specifics\n- **Fresh defaults**: DefaultModels.get_instance() always fetches from database (not cached) for live config updates\n- **Config override**: provision_langchain_model() accepts kwargs passed to AIFactory.create_* methods\n- **Token-based selection**: provision_langchain_model() detects large contexts and upgrades model automatically\n- **Type assertions**: get_speech_to_text(), get_embedding_model() assert returned type (safety check)\n- **Credential→Env fallback**: If model has linked credential, config from `credential.to_esperanto_config()` is used directly; otherwise keys checked in database via key_provider, then environment variables; enables UI-based key management while maintaining backward compatibility\n\n## Key Dependencies\n\n- `esperanto`: AIFactory.create_language(), create_embedding(), create_speech_to_text(), create_text_to_speech()\n- `open_notebook.database.repository`: repo_query, ensure_record_id\n- `open_notebook.domain.base`: ObjectModel, RecordModel base classes\n- `open_notebook.domain.credential`: Credential for database-stored API keys\n- `open_notebook.utils`: token_count() for context size detection\n- `loguru`: Logging for model selection decisions\n\n## Important Quirks & Gotchas\n\n- **Token counting rough estimate**: provision_langchain_model() uses token_count() which estimates via cl100k_base encoding (may differ 5-10% from actual model)\n- **Large context threshold hard-coded**: 105,000 token threshold for large_context_model upgrade (not configurable)\n- **DefaultModels.get_instance() fresh fetch**: Intentionally bypasses parent singleton cache to pick up live config changes; creates new instance each call\n- **Type-specific getters use assertions**: get_speech_to_text() asserts isinstance (catches misconfiguration early)\n- **ConfigurationError on missing model**: ModelManager.get_model() and provision_langchain_model() raise `ConfigurationError` (not ValueError) when a model is not found or not configured, so the global exception handler returns HTTP 422 with a descriptive message\n- **Esperanto caching**: Actual model instances cached by Esperanto (not by ModelManager); ModelManager stateless\n- **Fallback chain specificity**: \"transformation\" type falls back to default_chat_model if not explicitly set (convention-based)\n- **kwargs passed through**: provision_langchain_model() passes kwargs to AIFactory but doesn't validate what's accepted\n- **Key provider sets env vars**: `provision_provider_keys()` modifies `os.environ` to inject DB-stored keys (from `Credential` records); Esperanto reads from env vars (only used as fallback when model has no linked credential)\n\n## How to Extend\n\n1. **Add new model type**: Add type string to Model.type enum, add create_* method in AIFactory, handle in ModelManager.get_model()\n2. **Add new default configuration**: Extend DefaultModels with new field (e.g., default_vision_model), add getter in ModelManager\n3. **Change fallback logic**: Modify provision_langchain_model() token threshold or fallback chain\n4. **Add model filtering**: Extend Model.get_models_by_type() with additional filters (e.g., by provider)\n5. **Implement model caching**: Wrap ModelManager methods with functools.lru_cache (be aware of kwargs mutability)\n\n## Usage Example\n\n```python\nfrom open_notebook.ai.models import model_manager\n\n# Get default chat model\nchat_model = await model_manager.get_default_model(\"chat\")\n\n# Get specific model by ID\nembedding_model = await model_manager.get_model(\"model:openai_embedding\")\n\n# Get embedding model with config override\nembedding_model = await model_manager.get_embedding_model(temperature=0.1)\n\n# Provision model for LangGraph (auto-detects large context)\nfrom open_notebook.ai.provision import provision_langchain_model\nlangchain_model = await provision_langchain_model(\n    content=long_text,\n    model_id=None,  # Use default\n    default_type=\"chat\",\n    temperature=0.7\n)\n```\n\n---\n\n## Connection Testing (connection_tester.py)\n\n### Purpose\n\nProvides functionality to test if a provider's API key is valid by making minimal API calls. Used by the API Configuration UI to validate user-entered credentials before saving.\n\n### test_provider_connection()\n\nMain entry point for testing provider connectivity.\n\n```python\nasync def test_provider_connection(\n    provider: str, model_type: str = \"language\",\n    config_id: Optional[str] = None\n) -> Tuple[bool, str]\n```\n\n**Returns**: `(success: bool, message: str)` - Success status and human-readable message.\n\n**Flow**:\n1. If `config_id` provided: Loads credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()` for provider config\n2. Looks up test model from `TEST_MODELS` dict\n3. For URL-based providers (ollama, openai_compatible): Tests server connectivity\n4. For Azure: Tests `/openai/models` endpoint with api_version\n5. For API-based providers: Creates minimal model via Esperanto and makes test call\n6. Returns user-friendly error messages for common failures\n\n### test_individual_model()\n\nTests a specific Model instance by loading its linked credential (if any) and making a minimal API call.\n\n### TEST_MODELS Configuration\n\nMaps each provider to `(model_name, model_type)` for testing:\n\n```python\nTEST_MODELS = {\n    \"openai\": (\"gpt-3.5-turbo\", \"language\"),\n    \"anthropic\": (\"claude-3-haiku-20240307\", \"language\"),\n    \"google\": (\"gemini-1.5-flash\", \"language\"),\n    \"groq\": (\"llama-3.1-8b-instant\", \"language\"),\n    \"voyage\": (\"voyage-3-lite\", \"embedding\"),\n    \"elevenlabs\": (\"eleven_multilingual_v2\", \"text_to_speech\"),\n    \"ollama\": (None, \"language\"),  # Dynamic\n    # ... more providers\n}\n```\n\n### Special Provider Handlers\n\n- **`_test_ollama_connection(base_url)`**: Tests Ollama server via `/api/tags` endpoint, returns model count\n- **`_test_openai_compatible_connection(base_url, api_key)`**: Tests OpenAI-compatible servers via `/models` endpoint\n- **`_get_ollama_models(base_url)`**: Fetches available models from Ollama server\n\n### Error Message Normalization\n\nThe tester normalizes error messages for user-friendly display:\n- `401/unauthorized` -> \"Invalid API key\"\n- `403/forbidden` -> \"API key lacks required permissions\"\n- `rate limit` -> \"Rate limited - but connection works\" (success)\n- `model not found` -> \"API key valid (test model not available)\" (success)\n- Connection/timeout errors -> Helpful troubleshooting messages\n\n---\n\n## Key Provider (key_provider.py)\n\n### Purpose\n\nUnified interface for retrieving API keys with database-first, environment-fallback strategy. Enables UI-based key management while maintaining backward compatibility with `.env` files. Used as fallback when models don't have a directly linked credential.\n\n### Core Functions\n\n#### get_api_key(provider)\n\n```python\nasync def get_api_key(provider: str) -> Optional[str]\n```\n\nGets API key for a provider. Checks database (`Credential` records) first, then environment variable.\n\n**Fallback Chain**:\n1. Query `Credential` records from database for the given provider\n2. Get api_key from default credential\n3. Handle `SecretStr` (call `.get_secret_value()`) vs regular strings\n4. If DB value exists and is non-empty, return it\n5. Otherwise, return `os.environ.get(env_var)`\n\n#### provision_provider_keys(provider)\n\n```python\nasync def provision_provider_keys(provider: str) -> bool\n```\n\nMain entry point for DB->Env fallback. Sets environment variables from database config for a provider. Called before model provisioning to ensure Esperanto can read keys from env vars.\n\n**Returns**: `True` if any keys were set from database.\n\n**Usage**:\n```python\n# Before creating a model, ensure DB keys are in env vars\nawait provision_provider_keys(\"openai\")\nmodel = AIFactory.create_language(model_name=\"gpt-4\", provider=\"openai\")\n```\n\n#### provision_all_keys()\n\n```python\nasync def provision_all_keys() -> dict[str, bool]\n```\n\nProvisions all providers at once. Useful at application startup.\n\n### Provider Configuration Maps\n\n#### PROVIDER_CONFIG (Simple Providers)\n\nSingle-field providers with API key only:\n\n```python\nPROVIDER_CONFIG = {\n    \"openai\": {\"env_var\": \"OPENAI_API_KEY\", \"config_field\": \"openai_api_key\"},\n    \"anthropic\": {\"env_var\": \"ANTHROPIC_API_KEY\", \"config_field\": \"anthropic_api_key\"},\n    \"google\": {\"env_var\": \"GOOGLE_API_KEY\", \"config_field\": \"google_api_key\"},\n    \"groq\": {\"env_var\": \"GROQ_API_KEY\", \"config_field\": \"groq_api_key\"},\n    \"mistral\": {\"env_var\": \"MISTRAL_API_KEY\", \"config_field\": \"mistral_api_key\"},\n    \"deepseek\": {\"env_var\": \"DEEPSEEK_API_KEY\", \"config_field\": \"deepseek_api_key\"},\n    \"xai\": {\"env_var\": \"XAI_API_KEY\", \"config_field\": \"xai_api_key\"},\n    \"openrouter\": {\"env_var\": \"OPENROUTER_API_KEY\", \"config_field\": \"openrouter_api_key\"},\n    \"voyage\": {\"env_var\": \"VOYAGE_API_KEY\", \"config_field\": \"voyage_api_key\"},\n    \"elevenlabs\": {\"env_var\": \"ELEVENLABS_API_KEY\", \"config_field\": \"elevenlabs_api_key\"},\n    \"ollama\": {\"env_var\": \"OLLAMA_API_BASE\", \"config_field\": \"ollama_api_base\"},\n}\n```\n\n#### VERTEX_CONFIG (Google Vertex AI)\n\nMulti-field configuration for Vertex AI:\n\n```python\nVERTEX_CONFIG = {\n    \"project\": {\"env_var\": \"VERTEX_PROJECT\", \"config_field\": \"vertex_project\"},\n    \"location\": {\"env_var\": \"VERTEX_LOCATION\", \"config_field\": \"vertex_location\"},\n    \"credentials\": {\"env_var\": \"GOOGLE_APPLICATION_CREDENTIALS\", \"config_field\": \"google_application_credentials\"},\n}\n```\n\n#### AZURE_CONFIG (Azure OpenAI)\n\nGeneric and mode-specific endpoints for Azure:\n\n```python\nAZURE_CONFIG = {\n    \"api_key\": {\"env_var\": \"AZURE_OPENAI_API_KEY\", \"config_field\": \"azure_openai_api_key\"},\n    \"api_version\": {\"env_var\": \"AZURE_OPENAI_API_VERSION\", \"config_field\": \"azure_openai_api_version\"},\n    \"endpoint\": {\"env_var\": \"AZURE_OPENAI_ENDPOINT\", \"config_field\": \"azure_openai_endpoint\"},\n    # Mode-specific endpoints\n    \"endpoint_llm\": {\"env_var\": \"AZURE_OPENAI_ENDPOINT_LLM\", \"config_field\": \"azure_openai_endpoint_llm\"},\n    \"endpoint_embedding\": {\"env_var\": \"AZURE_OPENAI_ENDPOINT_EMBEDDING\", \"config_field\": \"azure_openai_endpoint_embedding\"},\n    \"endpoint_stt\": {\"env_var\": \"AZURE_OPENAI_ENDPOINT_STT\", \"config_field\": \"azure_openai_endpoint_stt\"},\n    \"endpoint_tts\": {\"env_var\": \"AZURE_OPENAI_ENDPOINT_TTS\", \"config_field\": \"azure_openai_endpoint_tts\"},\n}\n```\n\n#### OPENAI_COMPATIBLE_CONFIG\n\nGeneric and mode-specific configuration for OpenAI-compatible providers:\n\n```python\nOPENAI_COMPATIBLE_CONFIG = {\n    # Generic\n    \"api_key\": {\"env_var\": \"OPENAI_COMPATIBLE_API_KEY\", \"config_field\": \"openai_compatible_api_key\"},\n    \"base_url\": {\"env_var\": \"OPENAI_COMPATIBLE_BASE_URL\", \"config_field\": \"openai_compatible_base_url\"},\n    # Mode-specific: LLM, Embedding, STT, TTS\n    \"api_key_llm\": {\"env_var\": \"OPENAI_COMPATIBLE_API_KEY_LLM\", \"config_field\": \"openai_compatible_api_key_llm\"},\n    \"base_url_llm\": {\"env_var\": \"OPENAI_COMPATIBLE_BASE_URL_LLM\", \"config_field\": \"openai_compatible_base_url_llm\"},\n    # ... similar for embedding, stt, tts\n}\n```\n\n### Internal Helper Functions\n\n- **`_provision_simple_provider(provider)`**: Sets single env var for simple providers\n- **`_provision_vertex()`**: Sets all Vertex AI env vars\n- **`_provision_azure()`**: Sets all Azure OpenAI env vars (handles SecretStr)\n- **`_provision_openai_compatible()`**: Sets all OpenAI-compatible env vars\n\n### Integration with ModelManager\n\nThe credential system integrates with model provisioning in two ways:\n\n1. **Credential-linked models** (preferred): Model has `credential` field pointing to a Credential record. `ModelManager.get_model()` calls `credential.to_esperanto_config()` and passes config directly to Esperanto's `AIFactory.create_*` methods\n2. **Env var fallback**: If model has no linked credential, `provision_provider_keys(provider)` sets env vars from DB credentials; Esperanto reads from env vars\n3. **ConnectionTester** loads Credential directly via `Credential.get(config_id)` for testing\n\nThe credential-linked approach is preferred as it allows multiple credentials per provider and avoids env var mutation.\n"
  },
  {
    "path": "open_notebook/ai/__init__.py",
    "content": "# AI infrastructure module\n# Contains model configuration, provisioning, and management\n"
  },
  {
    "path": "open_notebook/ai/connection_tester.py",
    "content": "\"\"\"\nConnection testing for AI providers.\n\nThis module provides functionality to test if a provider's API key is valid\nby making minimal API calls to each provider, and to test individual model\nconfigurations end-to-end.\n\"\"\"\nimport io\nimport os\nimport struct\nfrom typing import List, Optional, Tuple\n\nimport httpx\nfrom esperanto.factory import AIFactory\nfrom loguru import logger\n\nfrom open_notebook.domain.credential import Credential\n\n# Test models for each provider - uses minimal/cheapest models for testing\n# Format: (model_name, model_type)\nTEST_MODELS = {\n    \"openai\": (\"gpt-3.5-turbo\", \"language\"),\n    \"anthropic\": (\"claude-3-haiku-20240307\", \"language\"),\n    \"google\": (\"gemini-2.0-flash\", \"language\"),\n    \"groq\": (\"llama-3.1-8b-instant\", \"language\"),\n    \"mistral\": (\"mistral-small-latest\", \"language\"),\n    \"deepseek\": (\"deepseek-chat\", \"language\"),\n    \"xai\": (\"grok-beta\", \"language\"),\n    \"openrouter\": (\"openai/gpt-3.5-turbo\", \"language\"),\n    \"voyage\": (\"voyage-3-lite\", \"embedding\"),\n    \"elevenlabs\": (\"eleven_multilingual_v2\", \"text_to_speech\"),\n    \"ollama\": (None, \"language\"),  # Dynamic - will use first available model\n    # Complex providers with additional configuration\n    \"vertex\": (\"gemini-2.0-flash\", \"language\"),  # Uses Google Vertex AI\n    \"azure\": (\"gpt-35-turbo\", \"language\"),  # Azure OpenAI deployment name\n    \"openai_compatible\": (None, \"language\"),  # Dynamic - will use first available model\n}\n\n\nasync def _test_azure_connection(\n    endpoint: Optional[str] = None,\n    api_key: Optional[str] = None,\n    api_version: Optional[str] = None,\n) -> Tuple[bool, str]:\n    \"\"\"\n    Test Azure OpenAI connectivity by listing models.\n\n    Azure requires deployment names which vary per user, so instead of\n    invoking a model, we list available models to validate credentials.\n    \"\"\"\n    test_endpoint = endpoint or os.environ.get(\"AZURE_OPENAI_ENDPOINT\")\n    test_api_key = api_key or os.environ.get(\"AZURE_OPENAI_API_KEY\")\n    test_api_version = api_version or os.environ.get(\"AZURE_OPENAI_API_VERSION\", \"2024-10-21\")\n\n    if not test_endpoint:\n        return False, \"No Azure endpoint configured\"\n    if not test_api_key:\n        return False, \"No Azure API key configured\"\n\n    # Strip trailing slash to avoid double-slash in URL\n    test_endpoint = test_endpoint.rstrip(\"/\")\n\n    try:\n        async with httpx.AsyncClient(timeout=10.0) as client:\n            response = await client.get(\n                f\"{test_endpoint}/openai/models?api-version={test_api_version}\",\n                headers={\"api-key\": test_api_key},\n            )\n\n            if response.status_code == 200:\n                data = response.json()\n                models = data.get(\"data\", [])\n                count = len(models)\n                if count > 0:\n                    names = [m.get(\"id\", \"unknown\") for m in models[:3]]\n                    name_list = \", \".join(names)\n                    if count > 3:\n                        name_list += f\" (+{count - 3} more)\"\n                    return True, f\"Connected. {count} models: {name_list}\"\n                else:\n                    return True, \"Connected successfully (no models found)\"\n            elif response.status_code == 401:\n                return False, \"Invalid API key\"\n            elif response.status_code == 403:\n                return False, \"API key lacks required permissions\"\n            else:\n                return False, f\"Azure returned status {response.status_code}\"\n\n    except httpx.ConnectError:\n        return False, \"Cannot connect to Azure endpoint. Check the URL.\"\n    except httpx.TimeoutException:\n        return False, \"Connection timed out. Check the endpoint URL.\"\n    except Exception as e:\n        return False, f\"Connection error: {str(e)[:100]}\"\n\n\nasync def _test_ollama_connection(base_url: str) -> Tuple[bool, str]:\n    \"\"\"Test Ollama server connectivity.\"\"\"\n    try:\n        async with httpx.AsyncClient(timeout=10.0) as client:\n            # Try /api/tags endpoint (standard Ollama)\n            response = await client.get(f\"{base_url}/api/tags\")\n\n            if response.status_code == 200:\n                data = response.json()\n                models = data.get(\"models\", [])\n                model_count = len(models)\n\n                if model_count > 0:\n                    model_names = [m.get(\"name\", \"unknown\") for m in models[:3]]\n                    model_list = \", \".join(model_names)\n                    if model_count > 3:\n                        model_list += f\" (+{model_count - 3} more)\"\n                    return True, f\"Connected. {model_count} models available: {model_list}\"\n                else:\n                    return True, \"Connected successfully (no models listed)\"\n            elif response.status_code == 401:\n                return False, \"Invalid API key\"\n            elif response.status_code == 403:\n                return False, \"API key lacks required permissions\"\n            else:\n                return False, f\"Server returned status {response.status_code}\"\n\n    except httpx.ConnectError:\n        return False, \"Cannot connect to Ollama. Check if Ollama server is running.\"\n    except httpx.TimeoutException:\n        return False, \"Connection timed out. Check if Ollama server is accessible.\"\n    except Exception as e:\n        return False, f\"Connection error: {str(e)[:100]}\"\n\n\nasync def _test_openai_compatible_connection(base_url: str, api_key: Optional[str] = None) -> Tuple[bool, str]:\n    \"\"\"Test OpenAI-compatible server connectivity.\"\"\"\n    try:\n        headers = {}\n        if api_key:\n            headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n        async with httpx.AsyncClient(timeout=10.0) as client:\n            # Try /models endpoint (standard OpenAI-compatible)\n            response = await client.get(f\"{base_url}/models\", headers=headers)\n\n            if response.status_code == 200:\n                data = response.json()\n                models = data.get(\"data\", [])\n                model_count = len(models)\n\n                if model_count > 0:\n                    model_names = [m.get(\"id\", \"unknown\") for m in models[:3]]\n                    model_list = \", \".join(model_names)\n                    if model_count > 3:\n                        model_list += f\" (+{model_count - 3} more)\"\n                    return True, f\"Connected. {model_count} models available: {model_list}\"\n                else:\n                    return True, \"Connected successfully (no models listed)\"\n            elif response.status_code == 401:\n                return False, \"Invalid API key\"\n            elif response.status_code == 403:\n                return False, \"API key lacks required permissions\"\n            else:\n                return False, f\"Server returned status {response.status_code}\"\n\n    except httpx.ConnectError:\n        return False, \"Cannot connect to server. Check the URL is correct.\"\n    except httpx.TimeoutException:\n        return False, \"Connection timed out. Check if server is accessible.\"\n    except Exception as e:\n        return False, f\"Connection error: {str(e)[:100]}\"\n\nasync def test_provider_connection(\n    provider: str, model_type: str = \"language\", config_id: Optional[str] = None\n) -> Tuple[bool, str]:\n    \"\"\"\n    Test if a provider's API key is valid by making a minimal API call.\n\n    Args:\n        provider: Provider name (openai, anthropic, etc.)\n        model_type: Type of model to test (language, embedding, etc.)\n                   Note: This is overridden by TEST_MODELS if provider is in that dict.\n        config_id: Optional specific configuration ID to test (format: configId)\n                   If provided, uses the configuration from ProviderConfig for this specific config.\n\n    Returns:\n        Tuple of (success: bool, message: str)\n    \"\"\"\n    try:\n        # Get configuration - either specific config or default\n        api_key: Optional[str] = None\n        base_url: Optional[str] = None\n        endpoint: Optional[str] = None\n        api_version: Optional[str] = None\n        model_name: Optional[str] = None\n\n        if config_id:\n            # Load specific credential from database\n            try:\n                cred = await Credential.get(config_id)\n                config = cred.to_esperanto_config()\n                api_key = config.get(\"api_key\")\n                base_url = config.get(\"base_url\")\n                endpoint = config.get(\"endpoint\")\n                api_version = config.get(\"api_version\")\n            except Exception:\n                return False, f\"Credential not found: {config_id}\"\n\n        # Normalize provider name (handle hyphenated aliases)\n        normalized_provider = provider.replace(\"-\", \"_\")\n\n        # Special handling for URL-based providers (no API key, just connectivity)\n        if normalized_provider == \"ollama\":\n            # Use base_url from specific config, or environment variable\n            test_base_url = base_url or os.environ.get(\"OLLAMA_API_BASE\", \"http://localhost:11434\")\n            return await _test_ollama_connection(test_base_url)\n\n        if normalized_provider == \"openai_compatible\":\n            # Use base_url from specific config, or environment variable\n            test_base_url = base_url or os.environ.get(\"OPENAI_COMPATIBLE_BASE_URL\")\n            test_api_key = api_key or os.environ.get(\"OPENAI_COMPATIBLE_API_KEY\")\n            if not test_base_url:\n                return False, \"No base URL configured for OpenAI-compatible provider\"\n            return await _test_openai_compatible_connection(test_base_url, test_api_key)\n\n        if normalized_provider == \"azure\":\n            return await _test_azure_connection(endpoint, api_key, api_version)\n\n        # Get test model for provider\n        if normalized_provider not in TEST_MODELS:\n            return False, f\"Unknown provider: {provider}\"\n\n        test_model, test_model_type = TEST_MODELS[normalized_provider]\n\n        # Use model from config if provided, otherwise use TEST_MODELS default\n        model_to_use = model_name if model_name else test_model\n\n        # For providers with dynamic model detection\n        if model_to_use is None:\n            if normalized_provider == \"openai_compatible\":\n                # OpenAI-compatible servers should already be tested via _test_openai_compatible_connection\n                test_base_url = base_url or os.environ.get(\"OPENAI_COMPATIBLE_BASE_URL\", \"\")\n                test_api_key = api_key or os.environ.get(\"OPENAI_COMPATIBLE_API_KEY\")\n                return await _test_openai_compatible_connection(test_base_url, test_api_key)\n            else:\n                return False, f\"No test model configured for {provider}\"\n\n        # If we have a specific API key, set it in environment for this test\n        if api_key:\n            os.environ[f\"{provider.upper()}_API_KEY\"] = api_key\n\n        # Try to create the model and make a minimal call\n        if test_model_type == \"language\":\n            model = AIFactory.create_language(model_name=model_to_use, provider=provider)\n            # Convert to LangChain and make a minimal call\n            lc_model = model.to_langchain()\n            await lc_model.ainvoke(\"Hi\")\n            return True, \"Connection successful\"\n\n        elif test_model_type == \"embedding\":\n            model = AIFactory.create_embedding(model_name=model_to_use, provider=provider)\n            # Embed a single short test string\n            await model.aembed([\"test\"])\n            return True, \"Connection successful\"\n\n        elif test_model_type == \"text_to_speech\":\n            # For TTS, we just verify the model can be created\n            # Making an actual TTS call would be more expensive\n            # Most TTS providers validate the key on model creation\n            AIFactory.create_text_to_speech(\n                model_name=model_to_use, provider=provider\n            )\n            return True, \"Connection successful (key format valid)\"\n\n        else:\n            return False, f\"Unsupported model type for testing: {test_model_type}\"\n\n    except Exception as e:\n        error_msg = str(e)\n\n        # Clean up common error messages for user-friendly display\n        if \"401\" in error_msg or \"unauthorized\" in error_msg.lower():\n            return False, \"Invalid API key\"\n        elif \"403\" in error_msg or \"forbidden\" in error_msg.lower():\n            return False, \"API key lacks required permissions\"\n        elif \"rate\" in error_msg.lower() and \"limit\" in error_msg.lower():\n            # Rate limit means the key is valid but we hit limits\n            return True, \"Rate limited - but connection works\"\n        elif \"connection\" in error_msg.lower() or \"network\" in error_msg.lower():\n            return False, \"Connection error - check network/endpoint\"\n        elif \"timeout\" in error_msg.lower():\n            return False, \"Connection timed out - check network/endpoint\"\n        elif \"not found\" in error_msg.lower() and \"model\" in error_msg.lower():\n            # Model not found but auth worked - this is actually a success for connectivity\n            return True, \"API key valid (test model not available)\"\n        elif provider == \"ollama\" and \"connection refused\" in error_msg.lower():\n            return False, \"Ollama not running - check if Ollama server is started\"\n        else:\n            logger.debug(f\"Test connection error for {provider}: {e}\")\n            # Truncate long error messages\n            truncated = error_msg[:100] + \"...\" if len(error_msg) > 100 else error_msg\n            return False, f\"Error: {truncated}\"\n\n\n# Default voices for TTS testing per provider\n# ElevenLabs excluded: uses voice_id (not name), looked up dynamically\nDEFAULT_TEST_VOICES = {\n    \"openai\": \"alloy\",\n    \"azure\": \"alloy\",\n    \"google\": \"Kore\",\n    \"vertex\": \"Kore\",\n    \"openai_compatible\": \"alloy\",\n}\n\n\ndef _generate_test_wav() -> io.BytesIO:\n    \"\"\"Generate a minimal 0.5s silence WAV file in memory (16kHz, 16-bit mono).\"\"\"\n    sample_rate = 16000\n    num_samples = sample_rate // 2  # 0.5 seconds\n    bits_per_sample = 16\n    num_channels = 1\n    byte_rate = sample_rate * num_channels * bits_per_sample // 8\n    block_align = num_channels * bits_per_sample // 8\n    data_size = num_samples * block_align\n\n    buf = io.BytesIO()\n    # RIFF header\n    buf.write(b\"RIFF\")\n    buf.write(struct.pack(\"<I\", 36 + data_size))\n    buf.write(b\"WAVE\")\n    # fmt chunk\n    buf.write(b\"fmt \")\n    buf.write(struct.pack(\"<I\", 16))  # chunk size\n    buf.write(struct.pack(\"<H\", 1))  # PCM format\n    buf.write(struct.pack(\"<H\", num_channels))\n    buf.write(struct.pack(\"<I\", sample_rate))\n    buf.write(struct.pack(\"<I\", byte_rate))\n    buf.write(struct.pack(\"<H\", block_align))\n    buf.write(struct.pack(\"<H\", bits_per_sample))\n    # data chunk\n    buf.write(b\"data\")\n    buf.write(struct.pack(\"<I\", data_size))\n    buf.write(b\"\\x00\" * data_size)  # silence\n\n    buf.seek(0)\n    buf.name = \"test.wav\"\n    return buf\n\n\ndef _normalize_error_message(error_msg: str) -> Tuple[bool, str]:\n    \"\"\"Normalize common error patterns into user-friendly messages.\"\"\"\n    lower = error_msg.lower()\n\n    if \"401\" in error_msg or \"unauthorized\" in lower:\n        return False, \"Invalid API key\"\n    elif \"403\" in error_msg or \"forbidden\" in lower:\n        return False, \"API key lacks required permissions\"\n    elif \"rate\" in lower and \"limit\" in lower:\n        return True, \"Rate limited - but connection works\"\n    elif \"not found\" in lower and \"model\" in lower:\n        return False, \"Model not found on this provider\"\n    elif \"connection\" in lower or \"network\" in lower:\n        return False, \"Connection error - check network/endpoint\"\n    elif \"timeout\" in lower:\n        return False, \"Connection timed out - check network/endpoint\"\n\n    return False, error_msg\n\n\nasync def test_individual_model(model) -> Tuple[bool, str]:\n    \"\"\"\n    Test a specific model configuration end-to-end by making a real API call.\n\n    Args:\n        model: A Model instance (from open_notebook.ai.models)\n\n    Returns:\n        Tuple of (success: bool, message: str)\n    \"\"\"\n    from open_notebook.ai.models import ModelManager\n\n    try:\n        manager = ModelManager()\n        esp_model = await manager.get_model(model.id)\n\n        if esp_model is None:\n            return False, \"Could not create model instance\"\n\n        if model.type == \"language\":\n            response = await esp_model.achat_complete(\n                messages=[{\"role\": \"user\", \"content\": \"Hi!\"}]\n            )\n            text = response.content[:100] if response.content else \"(empty response)\"\n            return True, f\"Response: {text}\"\n\n        elif model.type == \"embedding\":\n            result = await esp_model.aembed([\"This is a test.\"])\n            if result and len(result) > 0:\n                dims = len(result[0])\n                return True, f\"Embedding dimensions: {dims}\"\n            return True, \"Embedding successful\"\n\n        elif model.type == \"text_to_speech\":\n            # For ElevenLabs, look up first available voice (API uses voice_id, not name)\n            voice = DEFAULT_TEST_VOICES.get(model.provider)\n            if not voice and hasattr(esp_model, \"available_voices\"):\n                try:\n                    voices = esp_model.available_voices\n                    if voices:\n                        voice = next(iter(voices.keys()))\n                except Exception:\n                    pass\n            if not voice:\n                voice = \"alloy\"  # fallback\n\n            result = await esp_model.agenerate_speech(\n                text=\"Hello from Open Notebook\", voice=voice\n            )\n            if result and hasattr(result, \"content\"):\n                size = len(result.content)\n                return True, f\"Audio generated: {size} bytes\"\n            return True, \"Speech generation successful\"\n\n        elif model.type == \"speech_to_text\":\n            audio_file = _generate_test_wav()\n            result = await esp_model.atranscribe(\n                audio_file=audio_file, language=\"en\"\n            )\n            text = str(result.text) if hasattr(result, \"text\") else str(result)\n            return True, f\"Transcription: {text[:100]}\"\n\n        else:\n            return False, f\"Unsupported model type: {model.type}\"\n\n    except Exception as e:\n        error_msg = str(e)\n        success, normalized = _normalize_error_message(error_msg)\n        if success:\n            return True, normalized\n        logger.debug(f\"Test individual model error for {model.id}: {e}\")\n        return False, normalized\n"
  },
  {
    "path": "open_notebook/ai/key_provider.py",
    "content": "\"\"\"\nAPI Key Provider - Database-first with environment fallback.\n\nThis module provides a unified interface for retrieving API keys and provider\nconfiguration. It reads from Credential records (individual per-provider\ncredentials) and falls back to environment variables for backward compatibility.\n\nUsage:\n    from open_notebook.ai.key_provider import provision_provider_keys\n\n    # Call before model provisioning to set env vars from DB\n    await provision_provider_keys(\"openai\")\n\"\"\"\n\nimport os\nfrom typing import Optional\n\nfrom loguru import logger\n\nfrom open_notebook.domain.credential import Credential\n\n\n# =============================================================================\n# Provider Configuration Mapping\n# =============================================================================\n# Maps provider names to their environment variable names.\n# This is the single source of truth for provider-to-env-var mapping.\n\nPROVIDER_CONFIG = {\n    # Simple providers (just API key)\n    \"openai\": {\n        \"env_var\": \"OPENAI_API_KEY\",\n    },\n    \"anthropic\": {\n        \"env_var\": \"ANTHROPIC_API_KEY\",\n    },\n    \"google\": {\n        \"env_var\": \"GOOGLE_API_KEY\",\n    },\n    \"groq\": {\n        \"env_var\": \"GROQ_API_KEY\",\n    },\n    \"mistral\": {\n        \"env_var\": \"MISTRAL_API_KEY\",\n    },\n    \"deepseek\": {\n        \"env_var\": \"DEEPSEEK_API_KEY\",\n    },\n    \"xai\": {\n        \"env_var\": \"XAI_API_KEY\",\n    },\n    \"openrouter\": {\n        \"env_var\": \"OPENROUTER_API_KEY\",\n    },\n    \"voyage\": {\n        \"env_var\": \"VOYAGE_API_KEY\",\n    },\n    \"elevenlabs\": {\n        \"env_var\": \"ELEVENLABS_API_KEY\",\n    },\n    # URL-based providers\n    \"ollama\": {\n        \"env_var\": \"OLLAMA_API_BASE\",\n    },\n}\n\n\nasync def _get_default_credential(provider: str) -> Optional[Credential]:\n    \"\"\"Get the first credential for a provider from the database.\"\"\"\n    try:\n        credentials = await Credential.get_by_provider(provider)\n        if credentials:\n            return credentials[0]\n    except Exception as e:\n        logger.debug(f\"Could not load credential from database for {provider}: {e}\")\n    return None\n\n\nasync def get_api_key(provider: str) -> Optional[str]:\n    \"\"\"\n    Get API key for a provider. Checks database first, then env var.\n\n    Args:\n        provider: Provider name (openai, anthropic, etc.)\n\n    Returns:\n        API key string or None if not configured\n    \"\"\"\n    cred = await _get_default_credential(provider)\n    if cred and cred.api_key:\n        logger.debug(f\"Using {provider} API key from Credential\")\n        return cred.api_key.get_secret_value()\n\n    # Fall back to environment variable\n    config_info = PROVIDER_CONFIG.get(provider.lower())\n    if config_info:\n        env_value = os.environ.get(config_info[\"env_var\"])\n        if env_value:\n            logger.debug(f\"Using {provider} API key from environment variable\")\n        return env_value\n\n    return None\n\n\nasync def _provision_simple_provider(provider: str) -> bool:\n    \"\"\"\n    Set environment variable for a simple provider from DB config.\n\n    Returns:\n        True if key was set from database, False otherwise\n    \"\"\"\n    provider_lower = provider.lower()\n    config_info = PROVIDER_CONFIG.get(provider_lower)\n    if not config_info:\n        return False\n\n    env_var = config_info[\"env_var\"]\n\n    cred = await _get_default_credential(provider_lower)\n    if not cred:\n        return False\n\n    # Set API key / primary env var\n    if cred.api_key:\n        os.environ[env_var] = cred.api_key.get_secret_value()\n        logger.debug(f\"Set {env_var} from Credential\")\n\n    # Set base URL if present\n    if cred.base_url:\n        provider_upper = provider_lower.upper()\n        os.environ[f\"{provider_upper}_API_BASE\"] = cred.base_url\n        logger.debug(f\"Set {provider_upper}_API_BASE from Credential\")\n\n    return True\n\n\nasync def _provision_vertex() -> bool:\n    \"\"\"\n    Set environment variables for Google Vertex AI from DB config.\n\n    Returns:\n        True if any keys were set from database\n    \"\"\"\n    any_set = False\n\n    cred = await _get_default_credential(\"vertex\")\n    if not cred:\n        return False\n\n    if cred.project:\n        os.environ[\"VERTEX_PROJECT\"] = cred.project\n        logger.debug(\"Set VERTEX_PROJECT from Credential\")\n        any_set = True\n    if cred.location:\n        os.environ[\"VERTEX_LOCATION\"] = cred.location\n        logger.debug(\"Set VERTEX_LOCATION from Credential\")\n        any_set = True\n    if cred.credentials_path:\n        os.environ[\"GOOGLE_APPLICATION_CREDENTIALS\"] = cred.credentials_path\n        logger.debug(\"Set GOOGLE_APPLICATION_CREDENTIALS from Credential\")\n        any_set = True\n\n    return any_set\n\n\nasync def _provision_azure() -> bool:\n    \"\"\"\n    Set environment variables for Azure OpenAI from DB config.\n\n    Returns:\n        True if any keys were set from database\n    \"\"\"\n    any_set = False\n\n    cred = await _get_default_credential(\"azure\")\n    if not cred:\n        return False\n\n    if cred.api_key:\n        os.environ[\"AZURE_OPENAI_API_KEY\"] = cred.api_key.get_secret_value()\n        logger.debug(\"Set AZURE_OPENAI_API_KEY from Credential\")\n        any_set = True\n    if cred.api_version:\n        os.environ[\"AZURE_OPENAI_API_VERSION\"] = cred.api_version\n        logger.debug(\"Set AZURE_OPENAI_API_VERSION from Credential\")\n        any_set = True\n    if cred.endpoint:\n        os.environ[\"AZURE_OPENAI_ENDPOINT\"] = cred.endpoint\n        logger.debug(\"Set AZURE_OPENAI_ENDPOINT from Credential\")\n        any_set = True\n    if cred.endpoint_llm:\n        os.environ[\"AZURE_OPENAI_ENDPOINT_LLM\"] = cred.endpoint_llm\n        logger.debug(\"Set AZURE_OPENAI_ENDPOINT_LLM from Credential\")\n        any_set = True\n    if cred.endpoint_embedding:\n        os.environ[\"AZURE_OPENAI_ENDPOINT_EMBEDDING\"] = cred.endpoint_embedding\n        logger.debug(\"Set AZURE_OPENAI_ENDPOINT_EMBEDDING from Credential\")\n        any_set = True\n    if cred.endpoint_stt:\n        os.environ[\"AZURE_OPENAI_ENDPOINT_STT\"] = cred.endpoint_stt\n        logger.debug(\"Set AZURE_OPENAI_ENDPOINT_STT from Credential\")\n        any_set = True\n    if cred.endpoint_tts:\n        os.environ[\"AZURE_OPENAI_ENDPOINT_TTS\"] = cred.endpoint_tts\n        logger.debug(\"Set AZURE_OPENAI_ENDPOINT_TTS from Credential\")\n        any_set = True\n\n    return any_set\n\n\nasync def _provision_openai_compatible() -> bool:\n    \"\"\"\n    Set environment variables for OpenAI-Compatible providers from DB config.\n\n    Returns:\n        True if any keys were set from database\n    \"\"\"\n    any_set = False\n\n    cred = await _get_default_credential(\"openai_compatible\")\n    if not cred:\n        return False\n\n    if cred.api_key:\n        os.environ[\"OPENAI_COMPATIBLE_API_KEY\"] = cred.api_key.get_secret_value()\n        logger.debug(\"Set OPENAI_COMPATIBLE_API_KEY from Credential\")\n        any_set = True\n    if cred.base_url:\n        os.environ[\"OPENAI_COMPATIBLE_BASE_URL\"] = cred.base_url\n        logger.debug(\"Set OPENAI_COMPATIBLE_BASE_URL from Credential\")\n        any_set = True\n\n    return any_set\n\n\nasync def provision_provider_keys(provider: str) -> bool:\n    \"\"\"\n    Provision environment variables from database for a specific provider.\n\n    This function checks if the provider has a Credential record stored in the\n    database and sets the corresponding environment variables. If the database\n    doesn't have the configuration, existing environment variables remain unchanged.\n\n    This is the main entry point for the DB->Env fallback mechanism.\n\n    Args:\n        provider: Provider name (openai, anthropic, azure, vertex,\n                  openai-compatible, etc.)\n\n    Returns:\n        True if any keys were set from database, False otherwise\n\n    Example:\n        # Before provisioning a model, ensure DB keys are in env vars\n        await provision_provider_keys(\"openai\")\n        model = AIFactory.create_language(model_name=\"gpt-4\", provider=\"openai\")\n    \"\"\"\n    # Normalize provider name\n    provider_lower = provider.lower()\n\n    # Handle complex providers with multiple config fields\n    if provider_lower == \"vertex\":\n        return await _provision_vertex()\n    elif provider_lower == \"azure\":\n        return await _provision_azure()\n    elif provider_lower in (\"openai-compatible\", \"openai_compatible\"):\n        return await _provision_openai_compatible()\n\n    # Handle simple providers\n    return await _provision_simple_provider(provider_lower)\n\n\nasync def provision_all_keys() -> dict[str, bool]:\n    \"\"\"\n    Provision environment variables from database for all providers.\n\n    NOTE: This function is deprecated for request-time use because it can leave\n    stale env vars after key deletion. Keys should only be provisioned at startup\n    or via provision_provider_keys() for specific providers.\n\n    Useful at application startup to load all DB-stored keys into environment.\n\n    Returns:\n        Dict mapping provider names to whether keys were set from DB\n    \"\"\"\n    results: dict[str, bool] = {}\n\n    # Simple providers\n    for provider in PROVIDER_CONFIG.keys():\n        results[provider] = await provision_provider_keys(provider)\n\n    # Complex providers\n    results[\"vertex\"] = await provision_provider_keys(\"vertex\")\n    results[\"azure\"] = await provision_provider_keys(\"azure\")\n    results[\"openai_compatible\"] = await provision_provider_keys(\"openai_compatible\")\n\n    return results\n"
  },
  {
    "path": "open_notebook/ai/model_discovery.py",
    "content": "\"\"\"\nModel Discovery - Automatic model fetching from AI providers.\n\nThis module provides functionality to discover available models from configured\nAI providers and automatically register them in the database.\n\"\"\"\n\nimport asyncio\nimport os\nfrom dataclasses import dataclass\nfrom typing import Dict, List, Optional, Tuple\n\nimport httpx\nfrom loguru import logger\n\nfrom open_notebook.ai.models import Model\nfrom open_notebook.domain.credential import Credential\nfrom open_notebook.database.repository import repo_query\n\n\n@dataclass\nclass DiscoveredModel:\n    \"\"\"Represents a model discovered from a provider.\"\"\"\n\n    name: str\n    provider: str\n    model_type: str  # language, embedding, speech_to_text, text_to_speech\n    description: Optional[str] = None\n\n\n# =============================================================================\n# Provider-Specific Model Type Classification\n# =============================================================================\n# These mappings help classify models by their capabilities based on naming patterns\n\nOPENAI_MODEL_TYPES = {\n    \"language\": [\n        \"gpt-4\",\n        \"gpt-3.5\",\n        \"o1\",\n        \"o3\",\n        \"chatgpt\",\n        \"text-davinci\",\n        \"davinci\",\n        \"curie\",\n        \"babbage\",\n        \"ada\",\n    ],\n    \"embedding\": [\"text-embedding\", \"embedding\"],\n    \"speech_to_text\": [\"whisper\"],\n    \"text_to_speech\": [\"tts\"],\n}\n\nANTHROPIC_MODELS = {\n    # Static list since Anthropic doesn't have a model listing API\n    \"language\": [\n        \"claude-opus-4-20250514\",\n        \"claude-sonnet-4-20250514\",\n        \"claude-3-5-sonnet-20241022\",\n        \"claude-3-5-haiku-20241022\",\n        \"claude-3-opus-20240229\",\n        \"claude-3-sonnet-20240229\",\n        \"claude-3-haiku-20240307\",\n    ],\n}\n\nGOOGLE_MODEL_TYPES = {\n    \"language\": [\"gemini\", \"palm\", \"bison\", \"chat\"],\n    \"embedding\": [\"embedding\", \"textembedding\"],\n}\n\nOLLAMA_MODEL_TYPES = {\n    # Ollama models can do multiple things, classify by common names\n    \"language\": [\n        \"llama\",\n        \"mistral\",\n        \"mixtral\",\n        \"codellama\",\n        \"phi\",\n        \"gemma\",\n        \"qwen\",\n        \"deepseek\",\n        \"vicuna\",\n        \"falcon\",\n        \"orca\",\n        \"neural\",\n        \"dolphin\",\n        \"openchat\",\n        \"starling\",\n        \"solar\",\n        \"yi\",\n        \"nous\",\n        \"wizard\",\n        \"zephyr\",\n        \"tinyllama\",\n    ],\n    \"embedding\": [\"nomic-embed\", \"mxbai-embed\", \"all-minilm\", \"bge-\", \"e5-\"],\n}\n\nMISTRAL_MODEL_TYPES = {\n    \"language\": [\n        \"mistral\",\n        \"mixtral\",\n        \"codestral\",\n        \"ministral\",\n        \"pixtral\",\n        \"open-mistral\",\n        \"open-mixtral\",\n    ],\n    \"embedding\": [\"mistral-embed\"],\n}\n\nGROQ_MODEL_TYPES = {\n    \"language\": [\"llama\", \"mixtral\", \"gemma\", \"whisper\"],\n    \"speech_to_text\": [\"whisper\"],\n}\n\nDEEPSEEK_MODEL_TYPES = {\n    \"language\": [\"deepseek-chat\", \"deepseek-reasoner\", \"deepseek-coder\"],\n}\n\nXAI_MODEL_TYPES = {\n    \"language\": [\"grok\"],\n}\n\nVOYAGE_MODEL_TYPES = {\n    \"embedding\": [\"voyage\"],\n}\n\nELEVENLABS_MODEL_TYPES = {\n    \"text_to_speech\": [\"eleven\"],\n}\n\n\ndef classify_model_type(model_name: str, provider: str) -> str:\n    \"\"\"\n    Classify a model into a type based on its name and provider.\n\n    Returns one of: language, embedding, speech_to_text, text_to_speech\n    \"\"\"\n    name_lower = model_name.lower()\n\n    type_mappings = {\n        \"openai\": OPENAI_MODEL_TYPES,\n        \"google\": GOOGLE_MODEL_TYPES,\n        \"ollama\": OLLAMA_MODEL_TYPES,\n        \"mistral\": MISTRAL_MODEL_TYPES,\n        \"groq\": GROQ_MODEL_TYPES,\n        \"deepseek\": DEEPSEEK_MODEL_TYPES,\n        \"xai\": XAI_MODEL_TYPES,\n        \"voyage\": VOYAGE_MODEL_TYPES,\n        \"elevenlabs\": ELEVENLABS_MODEL_TYPES,\n    }\n\n    mapping = type_mappings.get(provider, {})\n\n    # Check each type in order of specificity\n    for model_type in [\"speech_to_text\", \"text_to_speech\", \"embedding\", \"language\"]:\n        patterns = mapping.get(model_type, [])\n        for pattern in patterns:\n            if pattern in name_lower:\n                return model_type\n\n    # Default to language for unknown models\n    return \"language\"\n\n\n# =============================================================================\n# Provider-Specific Model Discovery Functions\n# =============================================================================\n\n\nasync def discover_openai_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from OpenAI API.\"\"\"\n    api_key = os.environ.get(\"OPENAI_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://api.openai.com/v1/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    model_type = classify_model_type(model_id, \"openai\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"openai\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover OpenAI models: {e}\")\n\n    return models\n\n\nasync def discover_anthropic_models() -> List[DiscoveredModel]:\n    \"\"\"Return static list of Anthropic models (no discovery API available).\"\"\"\n    api_key = os.environ.get(\"ANTHROPIC_API_KEY\")\n    if not api_key:\n        return []\n\n    # Anthropic doesn't have a model listing API, so we use a static list\n    models = []\n    for model_name in ANTHROPIC_MODELS.get(\"language\", []):\n        models.append(\n            DiscoveredModel(\n                name=model_name,\n                provider=\"anthropic\",\n                model_type=\"language\",\n            )\n        )\n    return models\n\n\nasync def discover_google_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from Google Gemini API.\"\"\"\n    api_key = os.environ.get(\"GOOGLE_API_KEY\") or os.environ.get(\"GEMINI_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            # Build URL without logging the key to avoid exposure\n            url = \"https://generativelanguage.googleapis.com/v1/models\"\n            headers = {\"X-Goog-Api-Key\": api_key}\n            response = await client.get(url, headers=headers, timeout=30.0)\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"models\", []):\n                # Google returns full path like \"models/gemini-1.5-flash\"\n                model_name = model.get(\"name\", \"\").replace(\"models/\", \"\")\n                if model_name:\n                    model_type = classify_model_type(model_name, \"google\")\n                    # Check supported generation methods for better classification\n                    methods = model.get(\"supportedGenerationMethods\", [])\n                    if \"embedContent\" in methods:\n                        model_type = \"embedding\"\n                    elif \"generateContent\" in methods:\n                        model_type = \"language\"\n\n                    models.append(\n                        DiscoveredModel(\n                            name=model_name,\n                            provider=\"google\",\n                            model_type=model_type,\n                            description=model.get(\"displayName\"),\n                        )\n                    )\n    except Exception as e:\n        # Log without exposing the API key in the message\n        logger.warning(f\"Failed to discover Google models: {type(e).__name__}\")\n\n    return models\n\n\nasync def discover_ollama_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from local Ollama instance.\"\"\"\n    base_url = os.environ.get(\"OLLAMA_API_BASE\", \"http://localhost:11434\")\n    if not base_url:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                f\"{base_url}/api/tags\",\n                timeout=10.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"models\", []):\n                model_name = model.get(\"name\", \"\")\n                if model_name:\n                    model_type = classify_model_type(model_name, \"ollama\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_name,\n                            provider=\"ollama\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover Ollama models: {e}\")\n\n    return models\n\n\nasync def discover_groq_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from Groq API.\"\"\"\n    api_key = os.environ.get(\"GROQ_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://api.groq.com/openai/v1/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    model_type = classify_model_type(model_id, \"groq\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"groq\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover Groq models: {e}\")\n\n    return models\n\n\nasync def discover_mistral_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from Mistral API.\"\"\"\n    api_key = os.environ.get(\"MISTRAL_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://api.mistral.ai/v1/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    model_type = classify_model_type(model_id, \"mistral\")\n                    # Check capabilities if available\n                    capabilities = model.get(\"capabilities\", {})\n                    if capabilities.get(\"completion_chat\"):\n                        model_type = \"language\"\n\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"mistral\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover Mistral models: {e}\")\n\n    return models\n\n\nasync def discover_deepseek_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from DeepSeek API.\"\"\"\n    api_key = os.environ.get(\"DEEPSEEK_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://api.deepseek.com/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    model_type = classify_model_type(model_id, \"deepseek\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"deepseek\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover DeepSeek models: {e}\")\n\n    return models\n\n\nasync def discover_xai_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from xAI API.\"\"\"\n    api_key = os.environ.get(\"XAI_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://api.x.ai/v1/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    model_type = classify_model_type(model_id, \"xai\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"xai\",\n                            model_type=model_type,\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover xAI models: {e}\")\n\n    return models\n\n\nasync def discover_openrouter_models() -> List[DiscoveredModel]:\n    \"\"\"Fetch available models from OpenRouter API.\"\"\"\n    api_key = os.environ.get(\"OPENROUTER_API_KEY\")\n    if not api_key:\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            response = await client.get(\n                \"https://openrouter.ai/api/v1/models\",\n                headers={\"Authorization\": f\"Bearer {api_key}\"},\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    # OpenRouter models are typically language models\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"openrouter\",\n                            model_type=\"language\",\n                            description=model.get(\"name\"),\n                        )\n                    )\n    except Exception as e:\n        logger.warning(f\"Failed to discover OpenRouter models: {e}\")\n\n    return models\n\n\nasync def discover_voyage_models() -> List[DiscoveredModel]:\n    \"\"\"Return static list of Voyage AI models (embedding only).\"\"\"\n    api_key = os.environ.get(\"VOYAGE_API_KEY\")\n    if not api_key:\n        return []\n\n    # Voyage AI specializes in embeddings\n    voyage_models = [\n        \"voyage-3\",\n        \"voyage-3-lite\",\n        \"voyage-code-3\",\n        \"voyage-finance-2\",\n        \"voyage-law-2\",\n        \"voyage-multilingual-2\",\n    ]\n\n    return [\n        DiscoveredModel(name=m, provider=\"voyage\", model_type=\"embedding\")\n        for m in voyage_models\n    ]\n\n\nasync def discover_elevenlabs_models() -> List[DiscoveredModel]:\n    \"\"\"Return static list of ElevenLabs TTS models.\"\"\"\n    api_key = os.environ.get(\"ELEVENLABS_API_KEY\")\n    if not api_key:\n        return []\n\n    # ElevenLabs specializes in TTS\n    elevenlabs_models = [\n        \"eleven_multilingual_v2\",\n        \"eleven_turbo_v2_5\",\n        \"eleven_turbo_v2\",\n        \"eleven_monolingual_v1\",\n        \"eleven_multilingual_v1\",\n    ]\n\n    return [\n        DiscoveredModel(name=m, provider=\"elevenlabs\", model_type=\"text_to_speech\")\n        for m in elevenlabs_models\n    ]\n\n\nasync def discover_openai_compatible_models() -> List[DiscoveredModel]:\n    \"\"\"\n    Fetch available models from an OpenAI-compatible API endpoint.\n    Uses the configured base_url from the database or environment variable.\n    \"\"\"\n    api_key = None\n    base_url = None\n\n    # Try to get config from Credential database first\n    try:\n        credentials = await Credential.get_by_provider(\"openai_compatible\")\n        if credentials:\n            cred = credentials[0]\n            config = cred.to_esperanto_config()\n            api_key = config.get(\"api_key\")\n            base_url = config.get(\"base_url\", \"\").rstrip(\"/\")\n    except Exception as e:\n        logger.warning(f\"Failed to read openai_compatible config from Credential: {e}\")\n\n    # Fall back to environment variables\n    if not api_key:\n        api_key = os.environ.get(\"OPENAI_COMPATIBLE_API_KEY\")\n    if not base_url:\n        base_url = os.environ.get(\"OPENAI_COMPATIBLE_BASE_URL\", \"\").rstrip(\"/\")\n\n    if not base_url:\n        logger.warning(\"No base_url configured for openai_compatible provider\")\n        return []\n\n    models = []\n    try:\n        async with httpx.AsyncClient() as client:\n            headers = {}\n            if api_key:\n                headers[\"Authorization\"] = f\"Bearer {api_key}\"\n\n            response = await client.get(\n                f\"{base_url}/models\",\n                headers=headers,\n                timeout=30.0,\n            )\n            response.raise_for_status()\n            data = response.json()\n\n            for model in data.get(\"data\", []):\n                model_id = model.get(\"id\", \"\")\n                if model_id:\n                    # Classify based on model name patterns\n                    model_type = classify_model_type(model_id, \"openai\")\n                    models.append(\n                        DiscoveredModel(\n                            name=model_id,\n                            provider=\"openai_compatible\",\n                            model_type=model_type,\n                        )\n                    )\n    except httpx.HTTPStatusError as e:\n        logger.warning(f\"Failed to discover openai_compatible models: HTTP {e.response.status_code}\")\n    except Exception as e:\n        logger.warning(f\"Failed to discover openai_compatible models: {e}\")\n\n    return models\n\n\n# =============================================================================\n# Main Discovery Functions\n# =============================================================================\n\n# Map provider names to their discovery functions\nPROVIDER_DISCOVERY_FUNCTIONS = {\n    \"openai\": discover_openai_models,\n    \"anthropic\": discover_anthropic_models,\n    \"google\": discover_google_models,\n    \"ollama\": discover_ollama_models,\n    \"groq\": discover_groq_models,\n    \"mistral\": discover_mistral_models,\n    \"deepseek\": discover_deepseek_models,\n    \"xai\": discover_xai_models,\n    \"openrouter\": discover_openrouter_models,\n    \"voyage\": discover_voyage_models,\n    \"elevenlabs\": discover_elevenlabs_models,\n    \"openai_compatible\": discover_openai_compatible_models,\n    \"azure\": None,  # Azure requires credential-based discovery (different auth)\n    \"vertex\": None,  # Vertex requires credential-based discovery (service account)\n}\n\n\nasync def discover_provider_models(provider: str) -> List[DiscoveredModel]:\n    \"\"\"\n    Discover available models for a specific provider.\n\n    Args:\n        provider: Provider name (openai, anthropic, etc.)\n\n    Returns:\n        List of discovered models\n    \"\"\"\n    discover_func = PROVIDER_DISCOVERY_FUNCTIONS.get(provider)\n    if discover_func is None:\n        if provider in PROVIDER_DISCOVERY_FUNCTIONS:\n            logger.info(\n                f\"Provider '{provider}' requires credential-based discovery. \"\n                f\"Use the /credentials/{{id}}/discover endpoint instead.\"\n            )\n        else:\n            logger.warning(f\"No discovery function for provider: {provider}\")\n        return []\n\n    return await discover_func()\n\n\nasync def sync_provider_models(\n    provider: str, auto_register: bool = True\n) -> Tuple[int, int, int]:\n    \"\"\"\n    Sync models for a provider: discover and optionally register in database.\n\n    Args:\n        provider: Provider name\n        auto_register: If True, automatically create Model records in database\n\n    Returns:\n        Tuple of (discovered_count, new_count, existing_count)\n    \"\"\"\n    discovered = await discover_provider_models(provider)\n    discovered_count = len(discovered)\n    new_count = 0\n    existing_count = 0\n\n    if not auto_register:\n        return discovered_count, 0, 0\n\n    if not discovered:\n        return 0, 0, 0\n\n    # Batch fetch existing models to avoid N+1 query pattern\n    try:\n        existing_models = await repo_query(\n            \"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model \"\n            \"WHERE string::lowercase(provider) = $provider\",\n            {\"provider\": provider.lower()},\n        )\n        # Create a set of (name, type) tuples for O(1) lookup\n        existing_keys = set()\n        for m in existing_models:\n            existing_keys.add((m.get(\"name\", \"\"), m.get(\"type\", \"\")))\n    except Exception as e:\n        logger.warning(f\"Failed to fetch existing models for {provider}: {e}\")\n        existing_keys = set()\n\n    for model in discovered:\n        model_key = (model.name.lower(), model.model_type.lower())\n\n        # Check if model already exists using pre-fetched data\n        if model_key in existing_keys:\n            existing_count += 1\n            continue\n\n        # Create new model\n        try:\n            new_model = Model(\n                name=model.name,\n                provider=model.provider,\n                type=model.model_type,\n            )\n            await new_model.save()\n            new_count += 1\n            logger.info(f\"Registered new model: {model.provider}/{model.name} ({model.model_type})\")\n        except Exception as e:\n            logger.warning(f\"Failed to register model {model.name}: {e}\")\n\n    logger.info(\n        f\"Synced {provider}: {discovered_count} discovered, \"\n        f\"{new_count} new, {existing_count} existing\"\n    )\n    return discovered_count, new_count, existing_count\n\n\nasync def sync_all_providers() -> Dict[str, Tuple[int, int, int]]:\n    \"\"\"\n    Sync models for all configured providers.\n\n    Returns:\n        Dict mapping provider names to (discovered, new, existing) tuples\n    \"\"\"\n    results = {}\n\n    # Run discovery for all providers in parallel\n    tasks = []\n    providers = list(PROVIDER_DISCOVERY_FUNCTIONS.keys())\n\n    for provider in providers:\n        tasks.append(sync_provider_models(provider, auto_register=True))\n\n    task_results = await asyncio.gather(*tasks, return_exceptions=True)\n\n    for provider, result in zip(providers, task_results):\n        if isinstance(result, Exception):\n            logger.error(f\"Error syncing {provider}: {result}\")\n            results[provider] = (0, 0, 0)\n        else:\n            results[provider] = result\n\n    return results\n\n\nasync def get_provider_model_count(provider: str) -> Dict[str, int]:\n    \"\"\"\n    Get count of registered models for a provider, grouped by type.\n\n    Args:\n        provider: Provider name (case-insensitive)\n\n    Returns:\n        Dict mapping model type to count\n    \"\"\"\n    # Use case-insensitive comparison by lowercasing the provider\n    result = await repo_query(\n        \"SELECT type, count() as count FROM model WHERE string::lowercase(provider) = string::lowercase($provider) GROUP BY type\",\n        {\"provider\": provider},\n    )\n\n    counts = {\n        \"language\": 0,\n        \"embedding\": 0,\n        \"speech_to_text\": 0,\n        \"text_to_speech\": 0,\n    }\n\n    for row in result:\n        model_type = row.get(\"type\")\n        count = row.get(\"count\", 0)\n        if model_type in counts:\n            counts[model_type] = count\n\n    return counts\n"
  },
  {
    "path": "open_notebook/ai/models.py",
    "content": "from typing import Any, ClassVar, Dict, Optional, Union\n\nfrom esperanto import (\n    AIFactory,\n    EmbeddingModel,\n    LanguageModel,\n    SpeechToTextModel,\n    TextToSpeechModel,\n)\nfrom loguru import logger\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.base import ObjectModel, RecordModel\nfrom open_notebook.exceptions import ConfigurationError\n\nModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeechModel]\n\n\nclass Model(ObjectModel):\n    table_name: ClassVar[str] = \"model\"\n    nullable_fields: ClassVar[set[str]] = {\"credential\"}\n    name: str\n    provider: str\n    type: str\n    credential: Optional[str] = None\n\n    @classmethod\n    async def get_models_by_type(cls, model_type):\n        models = await repo_query(\n            \"SELECT * FROM model WHERE type=$model_type;\", {\"model_type\": model_type}\n        )\n        return [Model(**model) for model in models]\n\n    @classmethod\n    async def get_by_credential(cls, credential_id: str):\n        \"\"\"Get all models linked to a specific credential.\"\"\"\n        models = await repo_query(\n            \"SELECT * FROM model WHERE credential=$cred_id;\",\n            {\"cred_id\": ensure_record_id(credential_id)},\n        )\n        return [Model(**model) for model in models]\n\n    def _prepare_save_data(self) -> Dict[str, Any]:\n        data = super()._prepare_save_data()\n        if data.get(\"credential\"):\n            data[\"credential\"] = ensure_record_id(data[\"credential\"])\n        return data\n\n    async def get_credential_obj(self):\n        \"\"\"Get the Credential object linked to this model, if any.\"\"\"\n        if not self.credential:\n            return None\n        from open_notebook.domain.credential import Credential\n\n        try:\n            return await Credential.get(self.credential)\n        except Exception:\n            logger.warning(f\"Could not load credential {self.credential} for model {self.id}\")\n            return None\n\n\nclass DefaultModels(RecordModel):\n    record_id: ClassVar[str] = \"open_notebook:default_models\"\n    default_chat_model: Optional[str] = None\n    default_transformation_model: Optional[str] = None\n    large_context_model: Optional[str] = None\n    default_text_to_speech_model: Optional[str] = None\n    default_speech_to_text_model: Optional[str] = None\n    # default_vision_model: Optional[str]\n    default_embedding_model: Optional[str] = None\n    default_tools_model: Optional[str] = None\n\n    @classmethod\n    async def get_instance(cls) -> \"DefaultModels\":\n        \"\"\"Always fetch fresh defaults from database (override parent caching behavior)\"\"\"\n        result = await repo_query(\n            \"SELECT * FROM ONLY $record_id\",\n            {\"record_id\": ensure_record_id(cls.record_id)},\n        )\n\n        if result:\n            if isinstance(result, list) and len(result) > 0:\n                data = result[0]\n            elif isinstance(result, dict):\n                data = result\n            else:\n                data = {}\n        else:\n            data = {}\n\n        # Create new instance with fresh data (bypass singleton cache)\n        instance = object.__new__(cls)\n        object.__setattr__(instance, \"__dict__\", {})\n        super(RecordModel, instance).__init__(**data)\n        return instance\n\n\nclass ModelManager:\n    def __init__(self):\n        pass  # No caching needed\n\n    async def get_model(self, model_id: str, **kwargs) -> Optional[ModelType]:\n        \"\"\"Get a model by ID. Esperanto will cache the actual model instance.\"\"\"\n        if not model_id:\n            return None\n\n        try:\n            model: Model = await Model.get(model_id)\n        except Exception:\n            raise ConfigurationError(f\"Model with ID {model_id} not found\")\n\n        if not model.type or model.type not in [\n            \"language\",\n            \"embedding\",\n            \"speech_to_text\",\n            \"text_to_speech\",\n        ]:\n            raise ConfigurationError(f\"Invalid model type: {model.type}\")\n\n        # Build config from credential if linked, otherwise fall back to env vars\n        config: dict = {}\n        if model.credential:\n            credential = await model.get_credential_obj()\n            if credential:\n                config = credential.to_esperanto_config()\n                logger.debug(\n                    f\"Using credential '{credential.name}' for model {model.name}\"\n                )\n            else:\n                logger.warning(\n                    f\"Model {model.id} has credential {model.credential} but it could not be loaded. \"\n                    f\"Falling back to env vars.\"\n                )\n                # Fall back to env var provisioning\n                from open_notebook.ai.key_provider import provision_provider_keys\n\n                await provision_provider_keys(model.provider)\n        else:\n            # No credential linked - use env var fallback\n            from open_notebook.ai.key_provider import provision_provider_keys\n\n            await provision_provider_keys(model.provider)\n\n        # Merge any additional kwargs (e.g. temperature)\n        config.update(kwargs)\n\n        # Normalize provider name: DB stores underscores but Esperanto expects hyphens\n        provider = model.provider.replace(\"_\", \"-\")\n\n        # Create model based on type (Esperanto will cache the instance)\n        if model.type == \"language\":\n            return AIFactory.create_language(\n                model_name=model.name,\n                provider=provider,\n                config=config,\n            )\n        elif model.type == \"embedding\":\n            return AIFactory.create_embedding(\n                model_name=model.name,\n                provider=provider,\n                config=config,\n            )\n        elif model.type == \"speech_to_text\":\n            return AIFactory.create_speech_to_text(\n                model_name=model.name,\n                provider=provider,\n                config=config,\n            )\n        elif model.type == \"text_to_speech\":\n            return AIFactory.create_text_to_speech(\n                model_name=model.name,\n                provider=provider,\n                config=config,\n            )\n        else:\n            raise ConfigurationError(f\"Invalid model type: {model.type}\")\n\n    async def get_defaults(self) -> DefaultModels:\n        \"\"\"Get the default models configuration from database\"\"\"\n        defaults = await DefaultModels.get_instance()\n        if not defaults:\n            raise RuntimeError(\"Failed to load default models configuration\")\n        return defaults\n\n    async def get_speech_to_text(self, **kwargs) -> Optional[SpeechToTextModel]:\n        \"\"\"Get the default speech-to-text model\"\"\"\n        defaults = await self.get_defaults()\n        model_id = defaults.default_speech_to_text_model\n        if not model_id:\n            return None\n        model = await self.get_model(model_id, **kwargs)\n        assert model is None or isinstance(model, SpeechToTextModel), (\n            f\"Expected SpeechToTextModel but got {type(model)}\"\n        )\n        return model\n\n    async def get_text_to_speech(self, **kwargs) -> Optional[TextToSpeechModel]:\n        \"\"\"Get the default text-to-speech model\"\"\"\n        defaults = await self.get_defaults()\n        model_id = defaults.default_text_to_speech_model\n        if not model_id:\n            return None\n        model = await self.get_model(model_id, **kwargs)\n        assert model is None or isinstance(model, TextToSpeechModel), (\n            f\"Expected TextToSpeechModel but got {type(model)}\"\n        )\n        return model\n\n    async def get_embedding_model(self, **kwargs) -> Optional[EmbeddingModel]:\n        \"\"\"Get the default embedding model\"\"\"\n        defaults = await self.get_defaults()\n        model_id = defaults.default_embedding_model\n        if not model_id:\n            return None\n        model = await self.get_model(model_id, **kwargs)\n        assert model is None or isinstance(model, EmbeddingModel), (\n            f\"Expected EmbeddingModel but got {type(model)}\"\n        )\n        return model\n\n    async def get_default_model(self, model_type: str, **kwargs) -> Optional[ModelType]:\n        \"\"\"\n        Get the default model for a specific type.\n\n        Args:\n            model_type: The type of model to retrieve (e.g., 'chat', 'embedding', etc.)\n            **kwargs: Additional arguments to pass to the model constructor\n        \"\"\"\n        defaults = await self.get_defaults()\n        model_id = None\n\n        if model_type == \"chat\":\n            model_id = defaults.default_chat_model\n        elif model_type == \"transformation\":\n            model_id = (\n                defaults.default_transformation_model or defaults.default_chat_model\n            )\n        elif model_type == \"tools\":\n            model_id = defaults.default_tools_model or defaults.default_chat_model\n        elif model_type == \"embedding\":\n            model_id = defaults.default_embedding_model\n        elif model_type == \"text_to_speech\":\n            model_id = defaults.default_text_to_speech_model\n        elif model_type == \"speech_to_text\":\n            model_id = defaults.default_speech_to_text_model\n        elif model_type == \"large_context\":\n            model_id = defaults.large_context_model\n\n        if not model_id:\n            logger.warning(\n                f\"No default model configured for type '{model_type}'. \"\n                f\"Please go to Settings → Models and set a default model.\"\n            )\n            return None\n\n        try:\n            return await self.get_model(model_id, **kwargs)\n        except (ValueError, ConfigurationError) as e:\n            logger.error(\n                f\"Failed to load default model for type '{model_type}': {e}. \"\n                f\"The configured model_id '{model_id}' may have been deleted or misconfigured. \"\n                f\"Please go to Settings → Models and reconfigure the default model.\"\n            )\n            return None\n\n\nmodel_manager = ModelManager()\n"
  },
  {
    "path": "open_notebook/ai/provision.py",
    "content": "from esperanto import LanguageModel\nfrom langchain_core.language_models.chat_models import BaseChatModel\nfrom loguru import logger\n\nfrom open_notebook.ai.models import model_manager\nfrom open_notebook.exceptions import ConfigurationError\nfrom open_notebook.utils import token_count\n\n\nasync def provision_langchain_model(\n    content, model_id, default_type, **kwargs\n) -> BaseChatModel:\n    \"\"\"\n    Returns the best model to use based on the context size and on whether there is a specific model being requested in Config.\n    If context > 105_000, returns the large_context_model\n    If model_id is specified in Config, returns that model\n    Otherwise, returns the default model for the given type\n    \"\"\"\n    tokens = token_count(content)\n    model = None\n    selection_reason = \"\"\n\n    if tokens > 105_000:\n        selection_reason = f\"large_context (content has {tokens} tokens)\"\n        logger.debug(\n            f\"Using large context model because the content has {tokens} tokens\"\n        )\n        model = await model_manager.get_default_model(\"large_context\", **kwargs)\n    elif model_id:\n        selection_reason = f\"explicit model_id={model_id}\"\n        model = await model_manager.get_model(model_id, **kwargs)\n    else:\n        selection_reason = f\"default for type={default_type}\"\n        model = await model_manager.get_default_model(default_type, **kwargs)\n\n    logger.debug(f\"Using model: {model}\")\n\n    if model is None:\n        logger.error(\n            f\"Model provisioning failed: No model found. \"\n            f\"Selection reason: {selection_reason}. \"\n            f\"model_id={model_id}, default_type={default_type}. \"\n            f\"Please check Settings → Models and ensure a default model is configured for '{default_type}'.\"\n        )\n        raise ConfigurationError(\n            f\"No model configured for {selection_reason}. \"\n            f\"Please go to Settings → Models and configure a default model for '{default_type}'.\"\n        )\n\n    if not isinstance(model, LanguageModel):\n        logger.error(\n            f\"Model type mismatch: Expected LanguageModel but got {type(model).__name__}. \"\n            f\"Selection reason: {selection_reason}. \"\n            f\"model_id={model_id}, default_type={default_type}.\"\n        )\n        raise ConfigurationError(\n            f\"Model is not a LanguageModel: {model}. \"\n            f\"Please check that the model configured for '{default_type}' is a language model, not an embedding or speech model.\"\n        )\n\n    return model.to_langchain()\n"
  },
  {
    "path": "open_notebook/config.py",
    "content": "import os\n\n# ROOT DATA FOLDER\nDATA_FOLDER = \"./data\"\n\n# LANGGRAPH CHECKPOINT FILE\nsqlite_folder = f\"{DATA_FOLDER}/sqlite-db\"\nos.makedirs(sqlite_folder, exist_ok=True)\nLANGGRAPH_CHECKPOINT_FILE = f\"{sqlite_folder}/checkpoints.sqlite\"\n\n# UPLOADS FOLDER\nUPLOADS_FOLDER = f\"{DATA_FOLDER}/uploads\"\nos.makedirs(UPLOADS_FOLDER, exist_ok=True)\n\n# TIKTOKEN CACHE FOLDER\n# Reads TIKTOKEN_CACHE_DIR from the environment so Docker can redirect the cache\n# to a path outside /data/ (which is typically volume-mounted and would hide the\n# pre-baked encoding baked into the image at build time).\nTIKTOKEN_CACHE_DIR = os.environ.get(\"TIKTOKEN_CACHE_DIR\", \"\").strip() or f\"{DATA_FOLDER}/tiktoken-cache\"\nos.makedirs(TIKTOKEN_CACHE_DIR, exist_ok=True)\n"
  },
  {
    "path": "open_notebook/database/CLAUDE.md",
    "content": "# Database Module\n\nSurrealDB abstraction layer providing repository pattern for CRUD operations and async migration management.\n\n## Purpose\n\nEncapsulates all database interactions: connection pooling, async CRUD operations, relationship management, and schema migrations. Provides clean interface for domain models and API endpoints to interact with SurrealDB without direct query knowledge.\n\n## Architecture Overview\n\nTwo-tier system:\n1. **Repository Layer** (repository.py): Raw async CRUD operations on SurrealDB via AsyncSurreal client\n2. **Migration Layer** (async_migrate.py): Schema versioning and migration execution\n\nBoth leverage connection context manager for lifecycle management and automatic cleanup.\n\n## Component Catalog\n\n### repository.py\n\n**Connection Management**\n- `get_database_url()`: Resolves `SURREAL_URL` or constructs from `SURREAL_ADDRESS`/`SURREAL_PORT` (backward compatible)\n- `get_database_password()`: Falls back from `SURREAL_PASSWORD` to legacy `SURREAL_PASS` env var\n- `db_connection()`: Async context manager handling sign-in, namespace/database selection, and cleanup\n  - Opens AsyncSurreal, authenticates, selects namespace/database, yields connection, closes on exit\n\n**Query Operations**\n- `repo_query(query_str, vars)`: Execute raw SurrealQL with parameter substitution; returns list of dicts\n- `repo_create(table, data)`: Insert record; auto-adds `created`/`updated` timestamps; removes any existing `id` field\n- `repo_insert(table, data_list, ignore_duplicates)`: Bulk insert multiple records; optionally ignores \"already contains\" errors\n- `repo_upsert(table, id, data, add_timestamp)`: MERGE operation for create-or-update; optionally adds `updated` timestamp\n- `repo_update(table, id, data)`: Update existing record by table+id or full record_id; auto-adds `updated`, parses ISO dates\n- `repo_delete(record_id)`: Delete record by RecordID\n- `repo_relate(source, relationship, target, data)`: Create graph relationship; optional relationship data\n\n**Utilities**\n- `parse_record_ids(obj)`: Recursively converts SurrealDB RecordID objects to strings (deep tree traversal)\n- `ensure_record_id(value)`: Coerces string or RecordID to RecordID type\n\n### async_migrate.py\n\n**Migration Classes**\n- `AsyncMigration`: Single migration wrapper\n  - `from_file(path)`: Load .surrealql file; strips comments and whitespace\n  - `run(bump)`: Execute SQL; call bump_version() on success (bump=True) or lower_version() (bump=False)\n\n- `AsyncMigrationRunner`: Sequences multiple migrations\n  - `run_all()`: Execute pending migrations from current_version to end\n  - `run_one_up()`: Run next migration\n  - `run_one_down()`: Rollback latest migration\n\n- `AsyncMigrationManager`: Main orchestrator\n  - Loads 14 up migrations + 14 down migrations (hard-coded in __init__; migrations 11-12 add credential system, 13 adds model-credential link, 14 adds podcast model registry fields)\n  - `get_current_version()`: Query max version from _sbl_migrations table\n  - `needs_migration()`: Boolean check (current < total migrations available)\n  - `run_migration_up()`: Run all pending migrations with logging\n\n**Version Tracking**\n- `get_latest_version()`: Query max version; returns 0 if _sbl_migrations table missing\n- `get_all_versions()`: Fetch all migration records; returns empty list on error\n- `bump_version()`: INSERT new entry into _sbl_migrations with version + applied_at timestamp\n- `lower_version()`: DELETE latest migration record (rollback)\n\n### migrate.py\n\n**Backward Compatibility**\n- `MigrationManager`: Sync wrapper around AsyncMigrationManager\n  - `get_current_version()`: Wraps async call with asyncio.run()\n  - `needs_migration` property: Checks if migration pending\n  - `run_migration_up()`: Execute migrations synchronously\n\n## Common Patterns\n\n- **Async-first design**: All operations async via AsyncSurreal; sync wrapper provided for legacy code\n- **Connection per operation**: Each repo_* function opens/closes connection (no pooling); designed for serverless/stateless API\n- **Auto-timestamping**: repo_create() and repo_update() auto-set `created`/`updated` fields\n- **Error resilience**: RuntimeError for transaction conflicts (retriable, logged at DEBUG level); catches and re-raises other exceptions\n- **RecordID polymorphism**: Functions accept string or RecordID; coerced to consistent type\n- **Graceful degradation**: Migration queries catch exceptions and treat table-not-found as version 0\n\n## Key Dependencies\n\n- `surrealdb`: AsyncSurreal client, RecordID type\n- `loguru`: Logging with context (debug/error/success levels)\n- Python stdlib: `os` (env vars), `datetime` (timestamps), `contextlib` (async context manager)\n\n## Important Quirks & Gotchas\n\n- **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads\n- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-14 explicitly; adding new migration requires code change (not auto-discovery)\n- **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle\n- **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format\n- **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport\n- **Transaction conflict handling**: RuntimeError from transaction conflicts logged at DEBUG level without stack trace (prevents log spam during concurrent operations)\n- **Graceful null returns**: get_all_versions() returns [] on table missing; allows migration system to bootstrap cleanly\n\n## How to Extend\n\n1. **Add new CRUD operation**: Follow repo_* pattern (open connection, execute query, handle errors, close)\n2. **Add migration**: Create migration file in `/migrations/N.surrealql` and `/migrations/N_down.surrealql`; update AsyncMigrationManager to load new files\n3. **Change timestamp behavior**: Modify repo_create()/repo_update() to not auto-set `updated` field if caller-provided\n4. **Implement connection pooling**: Replace db_connection context manager with pool.acquire() pattern (for high-throughput scenarios)\n\n## Integration Points\n\n- **API startup** (api/main.py): FastAPI lifespan handler calls AsyncMigrationManager.run_migration_up() on server start\n- **Domain models** (domain/*.py): All models call repo_* functions for persistence\n- **Commands** (commands/*.py): Background jobs use repo_* for state updates\n- **Streamlit UI** (pages/*.py): Deprecated migration check; relies on API to run migrations\n\n## Usage Example\n\n```python\nfrom open_notebook.database.repository import repo_create, repo_query, repo_update\n\n# Create\nrecord = await repo_create(\"notebooks\", {\"title\": \"Research\"})\n\n# Query\nresults = await repo_query(\"SELECT * FROM notebooks WHERE title = $title\", {\"title\": \"Research\"})\n\n# Update\nawait repo_update(\"notebooks\", record[\"id\"], {\"title\": \"Updated Research\"})\n```\n"
  },
  {
    "path": "open_notebook/database/async_migrate.py",
    "content": "\"\"\"\nAsync migration system for SurrealDB using the official Python client.\nBased on patterns from sblpy migration system.\n\"\"\"\n\nfrom typing import List\n\nfrom loguru import logger\n\nfrom .repository import db_connection, repo_query\n\n\nclass AsyncMigration:\n    \"\"\"\n    Handles individual migration operations with async support.\n    \"\"\"\n\n    def __init__(self, sql: str) -> None:\n        \"\"\"Initialize migration with SQL content.\"\"\"\n        self.sql = sql\n\n    @classmethod\n    def from_file(cls, file_path: str) -> \"AsyncMigration\":\n        \"\"\"Create migration from SQL file.\"\"\"\n        with open(file_path, \"r\", encoding=\"utf-8\") as file:\n            raw_content = file.read()\n            # Clean up SQL content\n            lines = []\n            for line in raw_content.split(\"\\n\"):\n                line = line.strip()\n                if line and not line.startswith(\"--\"):\n                    lines.append(line)\n            sql = \" \".join(lines)\n            return cls(sql)\n\n    async def run(self, bump: bool = True) -> None:\n        \"\"\"Run the migration.\"\"\"\n        try:\n            async with db_connection() as connection:\n                await connection.query(self.sql)\n\n            if bump:\n                await bump_version()\n            else:\n                await lower_version()\n\n        except Exception as e:\n            logger.error(f\"Migration failed: {str(e)}\")\n            raise\n\n\nclass AsyncMigrationRunner:\n    \"\"\"\n    Handles running multiple migrations in sequence.\n    \"\"\"\n\n    def __init__(\n        self,\n        up_migrations: List[AsyncMigration],\n        down_migrations: List[AsyncMigration],\n    ) -> None:\n        \"\"\"Initialize runner with migration lists.\"\"\"\n        self.up_migrations = up_migrations\n        self.down_migrations = down_migrations\n\n    async def run_all(self) -> None:\n        \"\"\"Run all pending up migrations.\"\"\"\n        current_version = await get_latest_version()\n\n        for i in range(current_version, len(self.up_migrations)):\n            logger.info(f\"Running migration {i + 1}\")\n            await self.up_migrations[i].run(bump=True)\n\n    async def run_one_up(self) -> None:\n        \"\"\"Run one up migration.\"\"\"\n        current_version = await get_latest_version()\n\n        if current_version < len(self.up_migrations):\n            logger.info(f\"Running migration {current_version + 1}\")\n            await self.up_migrations[current_version].run(bump=True)\n\n    async def run_one_down(self) -> None:\n        \"\"\"Run one down migration.\"\"\"\n        current_version = await get_latest_version()\n\n        if current_version > 0:\n            logger.info(f\"Rolling back migration {current_version}\")\n            await self.down_migrations[current_version - 1].run(bump=False)\n\n\nclass AsyncMigrationManager:\n    \"\"\"\n    Main migration manager with async support.\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"Initialize migration manager.\"\"\"\n        self.up_migrations = [\n            AsyncMigration.from_file(\"open_notebook/database/migrations/1.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/2.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/3.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/4.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/5.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/6.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/7.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/8.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/9.surrealql\"),\n            AsyncMigration.from_file(\"open_notebook/database/migrations/10.surrealql\"),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/11.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/12.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/13.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/14.surrealql\"\n            ),\n        ]\n        self.down_migrations = [\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/1_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/2_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/3_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/4_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/5_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/6_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/7_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/8_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/9_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/10_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/11_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/12_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/13_down.surrealql\"\n            ),\n            AsyncMigration.from_file(\n                \"open_notebook/database/migrations/14_down.surrealql\"\n            ),\n        ]\n        self.runner = AsyncMigrationRunner(\n            up_migrations=self.up_migrations,\n            down_migrations=self.down_migrations,\n        )\n\n    async def get_current_version(self) -> int:\n        \"\"\"Get current database version.\"\"\"\n        return await get_latest_version()\n\n    async def needs_migration(self) -> bool:\n        \"\"\"Check if migration is needed.\"\"\"\n        current_version = await self.get_current_version()\n        return current_version < len(self.up_migrations)\n\n    async def run_migration_up(self):\n        \"\"\"Run all pending migrations.\"\"\"\n        current_version = await self.get_current_version()\n        logger.info(f\"Current version before migration: {current_version}\")\n\n        if await self.needs_migration():\n            try:\n                await self.runner.run_all()\n                new_version = await self.get_current_version()\n                logger.info(f\"Migration successful. New version: {new_version}\")\n            except Exception as e:\n                logger.error(f\"Migration failed: {str(e)}\")\n                raise\n        else:\n            logger.info(\"Database is already at the latest version\")\n\n\n# Database version management functions\nasync def get_latest_version() -> int:\n    \"\"\"Get the latest version from the migrations table.\"\"\"\n    try:\n        versions = await get_all_versions()\n        if not versions:\n            return 0\n        return max(version[\"version\"] for version in versions)\n    except Exception:\n        # If migrations table doesn't exist, we're at version 0\n        return 0\n\n\nasync def get_all_versions() -> List[dict]:\n    \"\"\"Get all versions from the migrations table.\"\"\"\n    try:\n        result = await repo_query(\"SELECT * FROM _sbl_migrations ORDER BY version;\")\n        return result\n    except Exception:\n        # If table doesn't exist, return empty list\n        return []\n\n\nasync def bump_version() -> None:\n    \"\"\"Bump the version by adding a new entry to migrations table.\"\"\"\n    current_version = await get_latest_version()\n    new_version = current_version + 1\n\n    await repo_query(\n        f\"CREATE _sbl_migrations:{new_version} SET version = {new_version}, applied_at = time::now();\",\n    )\n\n\nasync def lower_version() -> None:\n    \"\"\"Lower the version by removing the latest entry from migrations table.\"\"\"\n    current_version = await get_latest_version()\n    if current_version > 0:\n        await repo_query(f\"DELETE _sbl_migrations:{current_version};\")\n"
  },
  {
    "path": "open_notebook/database/migrate.py",
    "content": "import asyncio\n\nfrom .async_migrate import AsyncMigrationManager\n\n\nclass MigrationManager:\n    \"\"\"\n    Synchronous wrapper around AsyncMigrationManager for backward compatibility.\n    \"\"\"\n\n    def __init__(self):\n        \"\"\"Initialize with async migration manager.\"\"\"\n        self._async_manager = AsyncMigrationManager()\n\n    def get_current_version(self) -> int:\n        \"\"\"Get current database version (sync wrapper).\"\"\"\n        return asyncio.run(self._async_manager.get_current_version())\n\n    @property\n    def needs_migration(self) -> bool:\n        \"\"\"Check if migration is needed (sync wrapper).\"\"\"\n        return asyncio.run(self._async_manager.needs_migration())\n\n    def run_migration_up(self):\n        \"\"\"Run migrations (sync wrapper).\"\"\"\n        asyncio.run(self._async_manager.run_migration_up())\n"
  },
  {
    "path": "open_notebook/database/migrations/1.surrealql",
    "content": "\nDEFINE TABLE IF NOT EXISTS source SCHEMAFULL;\n\nDEFINE FIELD IF NOT EXISTS\n    asset\n    ON TABLE source\n    FLEXIBLE TYPE option<object>;\n\nDEFINE FIELD IF NOT EXISTS title ON TABLE source TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS topics ON TABLE source TYPE option<array<string>>;\nDEFINE FIELD IF NOT EXISTS full_text ON TABLE source TYPE option<string>;\n\nDEFINE FIELD IF NOT EXISTS created ON source DEFAULT time::now() VALUE $before OR time::now();\nDEFINE FIELD IF NOT EXISTS updated ON source DEFAULT time::now() VALUE time::now();\n\nDEFINE TABLE IF NOT EXISTS source_embedding SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS source ON TABLE source_embedding TYPE record<source>;\nDEFINE FIELD IF NOT EXISTS order ON TABLE source_embedding TYPE int;\nDEFINE FIELD IF NOT EXISTS content ON TABLE source_embedding TYPE string;\nDEFINE FIELD IF NOT EXISTS embedding ON TABLE source_embedding TYPE array<float>;\n\nDEFINE TABLE IF NOT EXISTS source_insight SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS source ON TABLE source_insight TYPE record<source>;\nDEFINE FIELD IF NOT EXISTS insight_type ON TABLE source_insight TYPE string;\nDEFINE FIELD IF NOT EXISTS content ON TABLE source_insight TYPE string;\nDEFINE FIELD IF NOT EXISTS embedding ON TABLE source_insight TYPE array<float>;\n\n\nDEFINE EVENT IF NOT EXISTS source_delete ON TABLE source WHEN ($after == NONE) THEN {\n    delete source_embedding where source == $before.id;\n    delete source_insight where source == $before.id;\n};\n\nDEFINE TABLE IF NOT EXISTS note SCHEMAFULL;\n\nDEFINE FIELD IF NOT EXISTS title ON TABLE note TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS summary ON TABLE note TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS content ON TABLE note TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS embedding ON TABLE note TYPE array<float>;\n\nDEFINE FIELD IF NOT EXISTS created ON note DEFAULT time::now() VALUE $before OR time::now();\nDEFINE FIELD IF NOT EXISTS updated ON note DEFAULT time::now() VALUE time::now();\n\nDEFINE TABLE IF NOT EXISTS notebook SCHEMAFULL;\n\nDEFINE FIELD IF NOT EXISTS name ON TABLE notebook TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS description ON TABLE notebook TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS archived ON TABLE notebook TYPE option<bool> DEFAULT False;\n\n\nDEFINE FIELD IF NOT EXISTS created ON notebook DEFAULT time::now() VALUE $before OR time::now();\nDEFINE FIELD IF NOT EXISTS updated ON notebook DEFAULT time::now() VALUE time::now();\n\nDEFINE TABLE IF NOT EXISTS reference\nTYPE RELATION \nFROM source TO notebook;\n\nDEFINE TABLE IF NOT EXISTS artifact\nTYPE RELATION \nFROM note TO notebook;\n\nDEFINE TABLE IF NOT EXISTS podcast_config SCHEMALESS;\n\n-- entender o analyzer\nDEFINE ANALYZER IF NOT EXISTS my_analyzer TOKENIZERS blank,class,camel,punct FILTERS snowball(english), lowercase;\n\nDEFINE INDEX IF NOT EXISTS idx_source_title ON TABLE source COLUMNS title SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\nDEFINE INDEX IF NOT EXISTS idx_source_full_text ON TABLE source COLUMNS full_text SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\nDEFINE INDEX IF NOT EXISTS idx_source_embed_chunk ON TABLE source_embedding COLUMNS content SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\nDEFINE INDEX IF NOT EXISTS idx_source_insight ON TABLE source_insight COLUMNS content SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\nDEFINE INDEX IF NOT EXISTS idx_note ON TABLE note COLUMNS content SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\nDEFINE INDEX IF NOT EXISTS idx_note_title ON TABLE note COLUMNS title SEARCH ANALYZER my_analyzer BM25 HIGHLIGHTS;\n\nDEFINE FUNCTION IF NOT EXISTS fn::text_search($query_text: string, $match_count: int, $sources:bool, $show_notes:bool) {\n  \n    let $source_title_search = \n        IF $sources {(\n            SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE title @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n    \n    let $source_embedding_search = \n         IF $sources {(\n             SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source_embedding\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $source_full_search = \n         IF $sources {(\n            SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE full_text @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source_insight\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $note_title_search = \n         IF $show_notes {(\n             SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE title @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n     let $note_content_search = \n         IF $show_notes {(\n             SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_full_search);\n    \n    let $source_asset_results = array::union($source_title_search, $source_insight_search);\n\n    let $source_results = array::union($source_chunk_results, $source_asset_results );\n    let $note_results = array::union($note_title_search, $note_content_search );\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT item_id, math::max(relevance) as relevance from $final_results\n        group by item_id ORDER BY relevance DESC LIMIT $match_count);\n    \n    \n};\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources:bool, $show_notes:bool) {\n   \n    let $source_embedding_search = \n         IF $sources {(\n            SELECT source as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding LIMIT $match_count)}\n        ELSE { [] };\n\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT source as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n                FROM source_insight LIMIT $match_count)}\n        ELSE { [] };\n\n    \n     let $note_content_search = \n         IF $show_notes {(\n                SELECT id as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n                FROM note LIMIT $match_count)}\n\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_insight_search);\n    \n    let $source_results = array::union($source_chunk_results, $source_insight_search);\n\n    let $note_results = $note_content_search;\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT item_id, math::max(similarity) as similarity from $final_results\n        group by item_id ORDER BY similarity DESC LIMIT $match_count);\n    \n    \n};\n\nIF array::len(select * from open_notebook:default_models) == 0 THEN\n    CREATE open_notebook:default_models SET\n    default_chat_model= \"\"\nEND;\n"
  },
  {
    "path": "open_notebook/database/migrations/10.surrealql",
    "content": "-- Migration 10: Add indexes for source_insight and source_embedding source field\n-- These indexes significantly improve performance of source listing queries\n-- that count insights and check embedding existence per source\n\nDEFINE INDEX IF NOT EXISTS idx_source_insight_source ON source_insight FIELDS source CONCURRENTLY;\nDEFINE INDEX IF NOT EXISTS idx_source_embedding_source ON source_embedding FIELDS source CONCURRENTLY;\n\nDEFINE FIELD OVERWRITE embedding ON TABLE source_insight TYPE option<array<float>>;\nDEFINE FIELD OVERWRITE embedding ON TABLE note TYPE option<array<float>>;\n\n-- delete orphan records\nDELETE from source_embedding WHERE source.id=NONE;\nDELETE from source_insight WHERE source.id=NONE;\n"
  },
  {
    "path": "open_notebook/database/migrations/10_down.surrealql",
    "content": "-- Rollback Migration 10: Remove source field indexes\n\nREMOVE INDEX IF EXISTS idx_source_insight_source ON TABLE source_insight;\nREMOVE INDEX IF EXISTS idx_source_embedding_source ON TABLE source_embedding;\n"
  },
  {
    "path": "open_notebook/database/migrations/11.surrealql",
    "content": "-- Migration 11: Create provider configuration singleton record\n-- This record stores multiple API key configurations per provider\n-- The data is managed by the ProviderConfig RecordModel class\n\n-- Create the provider configs singleton record for multi-config support\n-- This record stores multiple API key configurations per provider\n-- The data is managed by the ProviderConfig RecordModel class\nUPSERT open_notebook:provider_configs CONTENT {\n    credentials: {}\n};\n"
  },
  {
    "path": "open_notebook/database/migrations/11_down.surrealql",
    "content": "-- Rollback Migration 11: Remove provider configuration records\n\n-- Remove provider configs singleton (if exists)\nDELETE open_notebook:provider_configs;\n"
  },
  {
    "path": "open_notebook/database/migrations/12.surrealql",
    "content": "-- Migration 12: Create credential table and add credential link to model table\n-- Individual credential records replace the ProviderConfig singleton\n-- Each credential stores API key and provider-specific configuration\n\n\nDEFINE TABLE credential SCHEMAFULL;\nDEFINE FIELD name ON credential TYPE string;\nDEFINE FIELD provider ON credential TYPE string;\nDEFINE FIELD modalities ON credential TYPE array DEFAULT [];\nDEFINE FIELD modalities.* ON credential TYPE string;\nDEFINE FIELD api_key ON credential TYPE option<string>;\nDEFINE FIELD base_url ON credential TYPE option<string>;\nDEFINE FIELD endpoint ON credential TYPE option<string>;\nDEFINE FIELD api_version ON credential TYPE option<string>;\nDEFINE FIELD endpoint_llm ON credential TYPE option<string>;\nDEFINE FIELD endpoint_embedding ON credential TYPE option<string>;\nDEFINE FIELD endpoint_stt ON credential TYPE option<string>;\nDEFINE FIELD endpoint_tts ON credential TYPE option<string>;\nDEFINE FIELD project ON credential TYPE option<string>;\nDEFINE FIELD location ON credential TYPE option<string>;\nDEFINE FIELD credentials_path ON credential TYPE option<string>;\nDEFINE FIELD created ON credential TYPE option<datetime> DEFAULT time::now();\nDEFINE FIELD updated ON credential TYPE option<datetime> DEFAULT time::now();\n\n-- Index for fast provider lookups\nDEFINE INDEX idx_credential_provider ON credential FIELDS provider;\n\n-- Add optional credential link to model table\nDEFINE FIELD credential ON model TYPE option<record<credential>>;\n"
  },
  {
    "path": "open_notebook/database/migrations/12_down.surrealql",
    "content": "-- Rollback Migration 12: Remove credential table and credential field from model\n\nREMOVE FIELD credential ON TABLE model;\nREMOVE INDEX idx_credential_provider ON credential;\nREMOVE TABLE credential;\n"
  },
  {
    "path": "open_notebook/database/migrations/13.surrealql",
    "content": "\nDEFINE FIELD OVERWRITE embedding ON TABLE source_insight TYPE option<array<float>>;\nDEFINE FIELD OVERWRITE embedding ON TABLE note TYPE option<array<float>>;\n"
  },
  {
    "path": "open_notebook/database/migrations/13_down.surrealql",
    "content": "DEFINE FIELD OVERWRITE embedding ON TABLE source_insight TYPE array<float>;\nDEFINE FIELD OVERWRITE embedding ON TABLE note TYPE array<float>;"
  },
  {
    "path": "open_notebook/database/migrations/14.surrealql",
    "content": "-- Migration 14: Podcast profiles model registry integration\n-- Adds record<model> references to replace loose provider/model strings\n-- Adds language field to episode_profile\n-- Adds per-speaker TTS override support\n\n-- EPISODE PROFILE\n-- Legacy fields: make optional (app ignores, preserved for data migration)\nDEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE option<string>;\nDEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE option<string>;\nDEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE option<string>;\nDEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE option<string>;\n\n-- New fields: reference to Model registry\nDEFINE FIELD IF NOT EXISTS outline_llm ON TABLE episode_profile TYPE option<record<model>>;\nDEFINE FIELD IF NOT EXISTS transcript_llm ON TABLE episode_profile TYPE option<record<model>>;\nDEFINE FIELD IF NOT EXISTS language ON TABLE episode_profile TYPE option<string>;\n\n-- SPEAKER PROFILE\n-- Legacy fields: make optional\nDEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE option<string>;\nDEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE option<string>;\n\n-- New field: reference to Model registry (profile-level)\nDEFINE FIELD IF NOT EXISTS voice_model ON TABLE speaker_profile TYPE option<record<model>>;\n\n-- Per-speaker TTS override\nDEFINE FIELD IF NOT EXISTS speakers.*.voice_model ON TABLE speaker_profile TYPE option<record<model>>;\n"
  },
  {
    "path": "open_notebook/database/migrations/14_down.surrealql",
    "content": "-- Migration 14 rollback: Remove model registry fields from podcast profiles\n\n-- Remove new fields from episode_profile\nREMOVE FIELD IF EXISTS outline_llm ON TABLE episode_profile;\nREMOVE FIELD IF EXISTS transcript_llm ON TABLE episode_profile;\nREMOVE FIELD IF EXISTS language ON TABLE episode_profile;\n\n-- Restore episode_profile legacy fields as required strings\nDEFINE FIELD OVERWRITE outline_provider ON TABLE episode_profile TYPE string;\nDEFINE FIELD OVERWRITE outline_model ON TABLE episode_profile TYPE string;\nDEFINE FIELD OVERWRITE transcript_provider ON TABLE episode_profile TYPE string;\nDEFINE FIELD OVERWRITE transcript_model ON TABLE episode_profile TYPE string;\n\n-- Remove new fields from speaker_profile\nREMOVE FIELD IF EXISTS voice_model ON TABLE speaker_profile;\nREMOVE FIELD IF EXISTS speakers.*.voice_model ON TABLE speaker_profile;\n\n-- Restore speaker_profile legacy fields as required strings\nDEFINE FIELD OVERWRITE tts_provider ON TABLE speaker_profile TYPE string;\nDEFINE FIELD OVERWRITE tts_model ON TABLE speaker_profile TYPE string;\n"
  },
  {
    "path": "open_notebook/database/migrations/1_down.surrealql",
    "content": "REMOVE TABLE IF EXISTS source;\nREMOVE TABLE IF EXISTS source_embedding;\nREMOVE TABLE IF EXISTS source_insight;\nREMOVE TABLE IF EXISTS note;\nREMOVE TABLE IF EXISTS notebook;\nREMOVE TABLE IF EXISTS reference;\nREMOVE TABLE IF EXISTS artifact;\nREMOVE TABLE IF EXISTS podcast_config;\n\nREMOVE EVENT IF EXISTS source_delete ON TABLE source;\n\nREMOVE ANALYZER IF EXISTS my_analyzer;\n\nREMOVE INDEX IF EXISTS idx_source_title ON TABLE source;\nREMOVE INDEX IF EXISTS idx_source_full_text ON TABLE source;\nREMOVE INDEX IF EXISTS idx_source_embed_chunk ON TABLE source_embedding;\nREMOVE INDEX IF EXISTS idx_source_insight ON TABLE source_insight;\nREMOVE INDEX IF EXISTS idx_note ON TABLE note;\nREMOVE INDEX IF EXISTS idx_note_title ON TABLE note;\n\nREMOVE FUNCTION IF EXISTS fn::text_search;\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDELETE open_notebook:default_models;\n"
  },
  {
    "path": "open_notebook/database/migrations/2.surrealql",
    "content": "DEFINE FIELD IF NOT EXISTS note_type ON TABLE note TYPE option<string>;\n"
  },
  {
    "path": "open_notebook/database/migrations/2_down.surrealql",
    "content": "REMOVE FIELD IF EXISTS note_type ON TABLE note;\n"
  },
  {
    "path": "open_notebook/database/migrations/3.surrealql",
    "content": "\nDEFINE TABLE IF NOT EXISTS chat_session SCHEMALESS;\n\nDEFINE TABLE IF NOT EXISTS refers_to\nTYPE RELATION \nFROM chat_session TO notebook;\n\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources: bool, $show_notes: bool, $min_similarity: float) {\n    let $source_embedding_search = \n        IF $sources {(\n            SELECT \n                id,\n                source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding \n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n    let $source_insight_search = \n        IF $sources {(\n            SELECT \n                id,\n                insight_type + ' - ' + source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_insight\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $note_content_search = \n        IF $show_notes {(\n            SELECT \n                id,\n                title,\n                content,\n                id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM note\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $all_results = array::union(\n        array::union($source_embedding_search, $source_insight_search),\n        $note_content_search\n    );\n\n\n    RETURN (\n        SELECT \n            id, title, content, parent_id,\n            math::max(similarity) as similarity\n        FROM $all_results\n        GROUP BY id\n        ORDER BY similarity DESC\n        LIMIT $match_count\n    );\n};\n\n\nREMOVE FUNCTION IF EXISTS fn::text_search;\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::text_search($query_text: string, $match_count: int, $sources:bool, $show_notes:bool) {\n  \n    let $source_title_search = \n        IF $sources {(\n            SELECT id, title, \n            search::highlight('`', '`', 1) as content,\n            id as parent_id,\n            math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_embedding_search = \n         IF $sources {(\n            SELECT id as id, source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source_embedding\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_full_search = \n         IF $sources {(\n            SELECT source.id as id, source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE full_text @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT id, insight_type + \" - \" + source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id,  math::max(search::score(1)) AS relevance\n            FROM source_insight\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $note_title_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n     let $note_content_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_full_search);\n    \n    let $source_asset_results = array::union($source_title_search, $source_insight_search);\n\n    let $source_results = array::union($source_chunk_results, $source_asset_results );\n    let $note_results = array::union($note_title_search, $note_content_search );\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT id, title, content, parent_id, math::max(relevance) as relevance from $final_results\n        where id is not None        \ngroup by id, title, content, parent_id ORDER BY relevance DESC LIMIT $match_count);\n    \n    \n};\n"
  },
  {
    "path": "open_notebook/database/migrations/3_down.surrealql",
    "content": "REMOVE TABLE IF EXISTS chat_session;\n\nREMOVE TABLE IF EXISTS refers_to;\n\n\nREMOVE FUNCTION fn::vector_search;\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources:bool, $show_notes:bool) {\n   \n    let $source_embedding_search = \n         IF $sources {(\n            SELECT source as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding LIMIT $match_count)}\n        ELSE { [] };\n\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT source as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n                FROM source_insight LIMIT $match_count)}\n        ELSE { [] };\n\n    \n     let $note_content_search = \n         IF $show_notes {(\n                SELECT id as item_id, content, vector::similarity::cosine(embedding, $query) as similarity\n                FROM note LIMIT $match_count)}\n\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_insight_search);\n    \n    let $source_results = array::union($source_chunk_results, $source_insight_search);\n\n    let $note_results = $note_content_search;\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT item_id, math::max(similarity) as similarity from $final_results\n        group by item_id ORDER BY similarity DESC LIMIT $match_count);\n    \n    \n};\n\nREMOVE FUNCTION fn::text_search;\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::text_search($query_text: string, $match_count: int, $sources:bool, $show_notes:bool) {\n  \n    let $source_title_search = \n        IF $sources {(\n            SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE title @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n    \n    let $source_embedding_search = \n         IF $sources {(\n             SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source_embedding\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $source_full_search = \n         IF $sources {(\n            SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE full_text @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT source as item_id, math::max(search::score(1)) AS relevance\n            FROM source_insight\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $note_title_search = \n         IF $show_notes {(\n             SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE title @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n     let $note_content_search = \n         IF $show_notes {(\n             SELECT id as item_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE content @1@ $query_text\n            GROUP BY item_id)}\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_full_search);\n    \n    let $source_asset_results = array::union($source_title_search, $source_insight_search);\n\n    let $source_results = array::union($source_chunk_results, $source_asset_results );\n    let $note_results = array::union($note_title_search, $note_content_search );\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT item_id, math::max(relevance) as relevance from $final_results\n        group by item_id ORDER BY relevance DESC LIMIT $match_count);\n    \n    \n};\n"
  },
  {
    "path": "open_notebook/database/migrations/4.surrealql",
    "content": "\nREMOVE FUNCTION IF EXISTS fn::text_search;\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::text_search($query_text: string, $match_count: int, $sources:bool, $show_notes:bool) {\n  \n    let $source_title_search = \n        IF $sources {(\n            SELECT id, title, \n            search::highlight('`', '`', 1) as content,\n            id as parent_id,\n            math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_embedding_search = \n         IF $sources {(\n            SELECT source.id as id, source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source_embedding\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_full_search = \n         IF $sources {(\n            SELECT id, title, search::highlight('`', '`', 1) as content, id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE full_text @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT id, insight_type + \" - \" + (source.title OR '') as title, search::highlight('`', '`', 1) as content, id as parent_id,  math::max(search::score(1)) AS relevance\n            FROM source_insight\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $note_title_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n     let $note_content_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_full_search);\n    \n    let $source_asset_results = array::union($source_title_search, $source_insight_search);\n\n    let $source_results = array::union($source_chunk_results, $source_asset_results );\n    let $note_results = array::union($note_title_search, $note_content_search );\n    let $final_results = array::union($source_results, $note_results );\n\n        RETURN (select id, parent_id, title, math::max(relevance) as relevance\n        from $final_results where id is not None\n        group by id, parent_id, title ORDER BY relevance DESC LIMIT $match_count);\n\n};\n\n\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources: bool, $show_notes: bool, $min_similarity: float) {\n    let $source_embedding_search = \n        IF $sources {(\n            SELECT \n                source.id as id,\n                source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding \n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n    let $source_insight_search = \n        IF $sources {(\n            SELECT \n                id,\n                insight_type + ' - ' + (source.title OR '') as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_insight\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $note_content_search = \n        IF $show_notes {(\n            SELECT \n                id,\n                title,\n                content,\n                id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM note\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $all_results = array::union(\n        array::union($source_embedding_search, $source_insight_search),\n        $note_content_search\n    );\n\n\n    RETURN (select id, parent_id, title, math::max(similarity) as similarity,\n    array::flatten(content) as matches\n    from $all_results where id is not None\n    group by id, parent_id, title ORDER BY similarity DESC LIMIT $match_count);\n\n};"
  },
  {
    "path": "open_notebook/database/migrations/4_down.surrealql",
    "content": "\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources: bool, $show_notes: bool, $min_similarity: float) {\n    let $source_embedding_search = \n        IF $sources {(\n            SELECT \n                id,\n                source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding \n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n    let $source_insight_search = \n        IF $sources {(\n            SELECT \n                id,\n                insight_type + ' - ' + source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_insight\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $note_content_search = \n        IF $show_notes {(\n            SELECT \n                id,\n                title,\n                content,\n                id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM note\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $all_results = array::union(\n        array::union($source_embedding_search, $source_insight_search),\n        $note_content_search\n    );\n\n\n    RETURN (\n        SELECT \n            id, title, content, parent_id,\n            math::max(similarity) as similarity\n        FROM $all_results\n        GROUP BY id\n        ORDER BY similarity DESC\n        LIMIT $match_count\n    );\n};\n\n\nREMOVE FUNCTION IF EXISTS fn::text_search;\n\n\nDEFINE FUNCTION IF NOT EXISTS fn::text_search($query_text: string, $match_count: int, $sources:bool, $show_notes:bool) {\n  \n    let $source_title_search = \n        IF $sources {(\n            SELECT id, title, \n            search::highlight('`', '`', 1) as content,\n            id as parent_id,\n            math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_embedding_search = \n         IF $sources {(\n            SELECT id as id, source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source_embedding\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_full_search = \n         IF $sources {(\n            SELECT source.id as id, source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id, math::max(search::score(1)) AS relevance\n            FROM source\n            WHERE full_text @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n    \n    let $source_insight_search = \n         IF $sources {(\n             SELECT id, insight_type + \" - \" + source.title as title, search::highlight('`', '`', 1) as content, source.id as parent_id,  math::max(search::score(1)) AS relevance\n            FROM source_insight\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $note_title_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE title @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n     let $note_content_search = \n         IF $show_notes {(\n             SELECT id, title, search::highlight('`', '`', 1) as content,  id as parent_id, math::max(search::score(1)) AS relevance\n            FROM note\n            WHERE content @1@ $query_text\n            GROUP BY id)}\n        ELSE { [] };\n\n    let $source_chunk_results = array::union($source_embedding_search, $source_full_search);\n    \n    let $source_asset_results = array::union($source_title_search, $source_insight_search);\n\n    let $source_results = array::union($source_chunk_results, $source_asset_results );\n    let $note_results = array::union($note_title_search, $note_content_search );\n    let $final_results = array::union($source_results, $note_results );\n\n    RETURN (SELECT id, title, content, parent_id, math::max(relevance) as relevance from $final_results\n        where id is not None        \ngroup by id, title, content, parent_id ORDER BY relevance DESC LIMIT $match_count);\n    \n    \n};\n"
  },
  {
    "path": "open_notebook/database/migrations/5.surrealql",
    "content": "\n-- remove old transformation defaults\n\nDELETE open_notebook:default_transformations;\n\n-- set up the default transformations\n\nDEFINE TABLE IF NOT EXISTS transformation SCHEMAFULL;\n\nDEFINE FIELD IF NOT EXISTS name ON TABLE transformation TYPE string;\nDEFINE FIELD IF NOT EXISTS title ON TABLE transformation TYPE string;\nDEFINE FIELD IF NOT EXISTS description ON TABLE transformation TYPE string;\nDEFINE FIELD IF NOT EXISTS prompt ON TABLE transformation TYPE string;\nDEFINE FIELD IF NOT EXISTS apply_default ON TABLE transformation TYPE bool DEFAULT False;\nDEFINE FIELD IF NOT EXISTS created ON transformation DEFAULT time::now() VALUE $before OR time::now();\nDEFINE FIELD IF NOT EXISTS updated ON transformation DEFAULT time::now() VALUE time::now();\n\n\ninsert into transformation  [\n   { \n       name: \"Analyze Paper\",\n       title: \"Paper Analysis\", \n       description: \"Analyses a technical/scientific paper\", \n       prompt:\"# IDENTITY and PURPOSE\n\nYou are an insightful and analytical reader of academic papers, extracting the key components, significance, and broader implications. Your focus is to uncover the core contributions, practical applications, methodological strengths or weaknesses, and any surprising findings. You are especially attuned to the clarity of arguments, the relevance to existing literature, and potential impacts on both the specific field and broader contexts.\n\n# STEPS\n\n1. **READ AND UNDERSTAND THE PAPER**: Thoroughly read the paper, identifying its main focus, arguments, methods, results, and conclusions.\n\n2. **IDENTIFY CORE ELEMENTS**:\n   - **Purpose**: What is the main goal or research question?\n   - **Contribution**: What new knowledge or innovation does this paper bring to the field?\n   - **Methods**: What methods are used, and are they novel or particularly effective?\n   - **Key Findings**: What are the most critical results, and why do they matter?\n   - **Limitations**: Are there any notable limitations or areas for further research?\n\n3. **SYNTHESIZE THE MAIN POINTS**:\n   - Extract the key elements and organize them into insightful observations.\n   - Highlight the broader impact and potential applications.\n   - Note any aspects that challenge established views or introduce new questions.\n\n# OUTPUT INSTRUCTIONS\n\n- Structure the output as follows: \n  - **PURPOSE**: A concise summary of the main research question or goal (1-2 sentences).\n  - **CONTRIBUTION**: A bullet list of 2-3 points that describe what the paper adds to the field.\n  - **KEY FINDINGS**: A bullet list of 2-3 points summarizing the critical outcomes of the study.\n  - **IMPLICATIONS**: A bullet list of 2-3 points discussing the significance or potential impact of the findings on the field or broader context.\n  - **LIMITATIONS**: A bullet list of 1-2 points identifying notable limitations or areas for future work.\n\n- **Bullet Points** should be between 15-20 words.\n- Avoid starting each bullet point with the same word to maintain variety.\n- Use clear and concise language that conveys the key ideas effectively.\n- Do not include warnings, disclaimers, or personal opinions.\n- Output only the requested sections with their respective labels.\", \n       apply_default: False \n   },\n  { \n       name: \"Key Insights\",\n       title: \"Key Insights\", \n       description: \"Extracts important insights and actionable items\", \n       prompt:\"# IDENTITY and PURPOSE\n\nYou extract surprising, powerful, and interesting insights from text content. You are interested in insights related to the purpose and meaning of life, human flourishing, the role of technology in the future of humanity, artificial intelligence and its affect on humans, memes, learning, reading, books, continuous improvement, and similar topics.\nYou create 15 word bullet points that capture the most important insights from the input.\nTake a step back and think step-by-step about how to achieve the best possible results by following the steps below.\n\n# STEPS\n\n- Extract 20 to 50 of the most surprising, insightful, and/or interesting ideas from the input in a section called IDEAS, and write them on a virtual whiteboard in your mind using 15 word bullets. If there are less than 50 then collect all of them. Make sure you extract at least 20.\n\n- From those IDEAS, extract the most powerful and insightful of them and write them in a section called INSIGHTS. Make sure you extract at least 10 and up to 25.\n\n# OUTPUT INSTRUCTIONS\n\n- INSIGHTS are essentially higher-level IDEAS that are more abstracted and wise.\n- Output the INSIGHTS section only.\n- Each bullet should be about 15 words in length.\n- Do not give warnings or notes; only output the requested sections.\n- You use bulleted lists for output, not numbered lists.\n- Do not start items with the same opening words.\n- Ensure you follow ALL these instructions when creating your output.\n\", \n       apply_default: False \n   },\n{ \n       name: \"Dense Summary\",\n       title: \"Dense Summary\", \n       description: \"Creates a rich, deep summary of the content\", \n       prompt:\"# MISSION\nYou are a Sparse Priming Representation (SPR) writer. An SPR is a particular kind of use of language for advanced NLP, NLU, and NLG tasks, particularly useful for the latest generation of Large Language Models (LLMs). You will be given information by the USER which you are to render as an SPR.\n\n# THEORY\nLLMs are a kind of deep neural network. They have been demonstrated to embed knowledge, abilities, and concepts, ranging from reasoning to planning, and even to theory of mind. These are called latent abilities and latent content, collectively referred to as latent space. The latent space of an LLM can be activated with the correct series of words as inputs, which will create a useful internal state of the neural network. This is not unlike how the right shorthand cues can prime a human mind to think in a certain way. Like human minds, LLMs are associative, meaning you only need to use the correct associations to 'prime' another model to think in the same way.\n\n# METHODOLOGY\nRender the input as a distilled list of succinct statements, assertions, associations, concepts, analogies, and metaphors. The idea is to capture as much, conceptually, as possible but with as few words as possible. Write it in a way that makes sense to you, as the future audience will be another language model, not a human. Use complete sentences.\n\", \n       apply_default: True \n   },\n{ \n       name: \"Reflections\",\n       title: \"Reflection Questions\", \n       description: \"Generates reflection questions from the document to help explore it further\", \n       prompt:\"# IDENTITY and PURPOSE\n\nYou extract deep, thought-provoking, and meaningful reflections from text content. You are especially focused on themes related to the human experience, such as the purpose of life, personal growth, the intersection of technology and humanity, artificial intelligence's societal impact, human potential, collective evolution, and transformative learning. Your reflections aim to provoke new ways of thinking, challenge assumptions, and provide a thoughtful synthesis of the content.\n\n# STEPS\n\n- Extract 3 to 5 of the most profound, thought-provoking, and/or meaningful ideas from the input in a section called REFLECTIONS.\n- Each reflection should aim to explore underlying implications, connections to broader human experiences, or highlight a transformative perspective.\n- Take a step back and consider the deeper significance or questions that arise from the content.\n\n# OUTPUT INSTRUCTIONS\n\n- The output section should be labeled as REFLECTIONS.\n- Each bullet point should be between 20-25 words.\n- Avoid repetition in the phrasing and ensure variety in sentence structure.\n- The reflections should encourage deeper inquiry and provide a synthesis that transcends surface-level observations.\n- Use bullet points, not numbered lists.\n- Every bullet should be formatted as a question that elicits contemplation or a statement that offers a profound insight.\n- Do not give warnings or notes; only output the requested section.\", \n       apply_default: False \n   },\n{ \n       name: \"Table of Contents\",\n       title: \"Table of Contents\", \n       description: \"Describes the different topics of the document\", \n       prompt:\"# SYSTEM ROLE\nYou are a content analysis assistant that reads through documents and provides a Table of Contents (ToC) to help users identify what the document covers more easily.\nYour ToC should capture all major topics and transitions in the content and should mention them in the order theh appear. \n\n# TASK\nAnalyze the provided content and create a Table of Contents:\n- Captures the core topics included in the text\n- Gives a small description of what is covered\", \n       apply_default: False \n   },\n{ \n       name: \"Simple Summary\",\n       title: \"Simple Summary\", \n       description: \"Generates a small summary of the content\", \n       prompt:\"# SYSTEM ROLE\nYou are a content summarization assistant that creates dense, information-rich summaries optimized for machine understanding. Your summaries should capture key concepts with minimal words while maintaining complete, clear sentences.\n\n# TASK\nAnalyze the provided content and create a summary that:\n- Captures the core concepts and key information\n- Uses clear, direct language\n- Maintains context from any previous summaries\", \n       apply_default: False \n   },\n];\n\n-- Sets the default transformation instructions prompt\nUPSERT open_notebook:default_prompts \n    CONTENT {transformation_instructions: \"# INSTRUCTIONS\n\n        You are my learning assistant and you help me process and transform content so that I can extract insights from them.\n\n        # IMPORTANT\n        - You are working on my editorial projects. The text below is my own. Do not give me any warnings about copyright or plagiarism.\n        - Output ONLY the requested content, without acknowledgements of the task and additional chatting. Don't start with \\\"Sure, I can help you with that.\\\" or \\\"Here is the information you requested:\\\". Just provide the content.\n        - Do not stop in the middle of the generation to ask me questions. Execute my request completely. \n        \"};\n\n"
  },
  {
    "path": "open_notebook/database/migrations/5_down.surrealql",
    "content": "\nREMOVE TABLE IF EXISTS transformation SCHEMAFULL;\n"
  },
  {
    "path": "open_notebook/database/migrations/6.surrealql",
    "content": "update model set provider='vertex' where provider='vertexai';"
  },
  {
    "path": "open_notebook/database/migrations/6_down.surrealql",
    "content": "update model set provider='vertexai' where provider='vertex';"
  },
  {
    "path": "open_notebook/database/migrations/7.surrealql",
    "content": "DEFINE TABLE IF NOT EXISTS episode_profile SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS name ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS description ON TABLE episode_profile TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS speaker_config ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS outline_provider ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS outline_model ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS transcript_provider ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS transcript_model ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS default_briefing ON TABLE episode_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS num_segments ON TABLE episode_profile TYPE int DEFAULT 5;\nDEFINE FIELD IF NOT EXISTS created ON TABLE episode_profile TYPE datetime DEFAULT time::now();\nDEFINE FIELD IF NOT EXISTS updated ON TABLE episode_profile TYPE datetime DEFAULT time::now();\n\n-- Create Speaker Profile table\nremove table speaker_profile;\nDEFINE TABLE IF NOT EXISTS speaker_profile SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS name ON TABLE speaker_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS description ON TABLE speaker_profile TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS tts_provider ON TABLE speaker_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS tts_model ON TABLE speaker_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS speakers ON TABLE speaker_profile TYPE array<object>;\nDEFINE FIELD IF NOT EXISTS speakers.*.name ON TABLE speaker_profile TYPE string;\nDEFINE FIELD IF NOT EXISTS speakers.*.voice_id ON TABLE speaker_profile TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS speakers.*.backstory ON TABLE speaker_profile TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS speakers.*.personality ON TABLE speaker_profile TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS created ON TABLE speaker_profile TYPE datetime DEFAULT time::now();\nDEFINE FIELD IF NOT EXISTS updated ON TABLE speaker_profile TYPE datetime DEFAULT time::now();\n\n\n-- Enhance PodcastEpisode table\nDEFINE TABLE IF NOT EXISTS episode SCHEMAFULL;\nDEFINE FIELD IF NOT EXISTS created ON episode DEFAULT time::now() VALUE $before OR time::now();\nDEFINE FIELD IF NOT EXISTS updated ON episode DEFAULT time::now() VALUE time::now();\nDEFINE FIELD IF NOT EXISTS name ON TABLE episode TYPE string;\nDEFINE FIELD IF NOT EXISTS briefing ON TABLE episode TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS episode_profile ON TABLE episode FLEXIBLE TYPE object;\nDEFINE FIELD IF NOT EXISTS speaker_profile ON TABLE episode FLEXIBLE TYPE object;\nDEFINE FIELD IF NOT EXISTS transcript ON TABLE episode FLEXIBLE TYPE option<object>;\nDEFINE FIELD IF NOT EXISTS outline ON TABLE episode FLEXIBLE TYPE option<object>;\nDEFINE FIELD IF NOT EXISTS command ON TABLE episode TYPE option<record<command>>;\nDEFINE FIELD IF NOT EXISTS content ON TABLE episode TYPE option<string>;\nDEFINE FIELD IF NOT EXISTS audio_file ON TABLE episode TYPE option<string>;\n\n-- Create indexes for better performance\nDEFINE INDEX IF NOT EXISTS idx_episode_profile_name ON TABLE episode_profile COLUMNS name UNIQUE CONCURRENTLY;\nDEFINE INDEX IF NOT EXISTS idx_speaker_profile_name ON TABLE speaker_profile COLUMNS name UNIQUE CONCURRENTLY;\nDEFINE INDEX IF NOT EXISTS idx_episode_profile ON TABLE episode COLUMNS episode_profile CONCURRENTLY;\nDEFINE INDEX IF NOT EXISTS idx_episode_command ON TABLE episode COLUMNS command CONCURRENTLY;\n\n\n--Sample data\n\ninsert into episode_profile \n[\n            {\n                name: \"tech_discussion\",\n                description: \"Technical discussion between 2 experts\",\n                speaker_config: \"tech_experts\",\n                outline_provider: \"openai\",\n                outline_model: \"gpt-5-mini\",\n                transcript_provider: \"openai\", \n                transcript_model: \"gpt-5-mini\",\n                default_briefing: \"Create an engaging technical discussion about the provided content. Focus on practical insights, real-world applications, and detailed explanations that would interest developers and technical professionals.\",\n                num_segments: 5\n            },\n            {\n                name: \"solo_expert\",\n                description: \"Single expert explaining complex topics\",\n                speaker_config: \"solo_expert\",\n                outline_provider: \"openai\",\n                outline_model: \"gpt-5-mini\",\n                transcript_provider: \"openai\",\n                transcript_model: \"gpt-5-mini\", \n                default_briefing: \"Create an educational explanation of the provided content. Break down complex concepts into digestible segments, use analogies and examples, and maintain an engaging teaching style.\",\n                \"num_segments\":4            },\n            {\n                name: \"business_analysis\",\n                description: \"Business-focused analysis and discussion\",\n                speaker_config: \"business_panel\",\n                outline_provider: \"openai\",\n                outline_model: \"gpt-5-mini\",\n                transcript_provider: \"openai\",\n                transcript_model: \"gpt-5-mini\",\n                default_briefing: \"Analyze the provided content from a business perspective. Discuss market implications, strategic insights, competitive advantages, and actionable business intelligence.\",\n                \"num_segments\":6            }\n        ];\n\ninsert into speaker_profile\n[\n            {\n                name: \"tech_experts\",\n                description: \"Two technical experts for tech discussions\",\n                tts_provider: \"openai\",\n                tts_model: \"gpt-4o-mini-tts\",\n                speakers: [\n                    {\n                        name: \"Dr. Alex Chen\",\n                        voice_id: \"nova\",\n                        backstory: \"Senior AI researcher and former tech lead at major companies. Specializes in making complex technical concepts accessible.\",\n                        personality: \"Analytical, clear communicator, asks probing questions to dig deeper into technical details\"\n                    },\n                    {\n                        name: \"Jamie Rodriguez\",\n                        voice_id: \"alloy\", \n                        backstory: \"Full-stack engineer and tech entrepreneur. Loves practical applications and real-world implementations.\",\n                        personality: \"Enthusiastic, practical-minded, great at explaining implementation details and trade-offs\"\n                    }\n                ]\n            },\n            {\n                name: \"solo_expert\",\n                description: \"Single expert for educational content\",\n                tts_provider: \"openai\",\n                tts_model: \"gpt-4o-mini-tts\",\n                speakers: [\n                    {\n                        name: \"Professor Sarah Kim\",\n                        voice_id: \"nova\",\n                        backstory: \"Distinguished professor and researcher. Has a gift for making complex topics accessible to broad audiences.\",\n                        personality: \"Patient teacher, uses analogies and examples, breaks down complex concepts step by step\"\n                    }\n                ]\n            },\n            {\n                name: \"business_panel\",\n                description: \"Business analysis panel with diverse perspectives\",\n                tts_provider: \"openai\", \n                tts_model: \"gpt-4o-mini-tts\",\n                speakers: [\n                    {\n                        name: \"Marcus Thompson\",\n                        voice_id: \"echo\",\n                        backstory: \"Former McKinsey consultant, now startup advisor. Expert in strategic analysis and market dynamics.\",\n                        personality: \"Strategic thinker, data-driven, excellent at identifying key insights and implications\"\n                    },\n                    {\n                        name: \"Elena Vasquez\", \n                        voice_id: \"shimmer\",\n                        backstory: \"Serial entrepreneur and investor. Focuses on practical implementation and execution.\",\n                        personality: \"Action-oriented, pragmatic, brings startup experience and execution focus\"\n                    },\n                    {\n                        name: \"Johny Bing\", \n                        voice_id: \"ash\",\n                        backstory: \"Youtube celebrity and business mogul. Focuses on practical implementation and execution.\",\n                        personality: \"Controversial, likes to question ideas and concepts. He brings a fresh perspective and always has a point to make.\"\n                    }\n                ]\n            }\n        ];\n\n\n"
  },
  {
    "path": "open_notebook/database/migrations/7_down.surrealql",
    "content": "REMOVE TABLE IF EXISTS episode_profile;\nREMOVE TABLE IF EXISTS speaker_profile;\nREMOVE TABLE IF EXISTS episode;\n"
  },
  {
    "path": "open_notebook/database/migrations/8.surrealql",
    "content": "\n-- Migration 8: Support chat sessions for both notebooks and sources\n-- This migration allows chat_session to refer to either a notebook or a source\n\nDEFINE TABLE OVERWRITE refers_to\nTYPE RELATION \nFROM chat_session TO notebook|source;\n\n-- Add model_override field to chat_session for per-session model selection\nDEFINE FIELD model_override ON chat_session TYPE option<string>;\n\nDEFINE FIELD command ON source TYPE option<record<command>>;"
  },
  {
    "path": "open_notebook/database/migrations/8_down.surrealql",
    "content": "\n-- Rollback Migration 8: Revert to notebook-only chat sessions\n\nDEFINE TABLE OVERWRITE refers_to\nTYPE RELATION \nFROM chat_session TO notebook;\n\n-- Remove model_override field from chat_session\nREMOVE FIELD model_override ON chat_session;\n\nREMOVE FIELD command ON source;\n"
  },
  {
    "path": "open_notebook/database/migrations/9.surrealql",
    "content": "\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources: bool, $show_notes: bool, $min_similarity: float) {\n    let $source_embedding_search = \n        IF $sources {(\n            SELECT \n                source.id as id,\n                source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding \n            WHERE embedding != none and array::len(embedding)=array::len($query) AND\n                 vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n    let $source_insight_search = \n        IF $sources {(\n            SELECT \n                id,\n                insight_type + ' - ' + (source.title OR '') as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_insight\n             WHERE embedding != none and array::len(embedding)=array::len($query) AND\n            vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $note_content_search = \n        IF $show_notes {(\n            SELECT \n                id,\n                title,\n                content,\n                id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM note\n            WHERE embedding != none and array::len(embedding)=array::len($query) AND\n            vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $all_results = array::union(\n        array::union($source_embedding_search, $source_insight_search),\n        $note_content_search\n    );\n\n\n    RETURN (select id, parent_id, title, math::max(similarity) as similarity,\n    array::flatten(content) as matches\n    from $all_results where id is not None\n    group by id, parent_id, title ORDER BY similarity DESC LIMIT $match_count);\n\n};"
  },
  {
    "path": "open_notebook/database/migrations/9_down.surrealql",
    "content": "\nREMOVE FUNCTION IF EXISTS fn::vector_search;\n\nDEFINE FUNCTION IF NOT EXISTS fn::vector_search($query: array<float>, $match_count: int, $sources: bool, $show_notes: bool, $min_similarity: float) {\n    let $source_embedding_search = \n        IF $sources {(\n            SELECT \n                source.id as id,\n                source.title as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_embedding \n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n    let $source_insight_search = \n        IF $sources {(\n            SELECT \n                id,\n                insight_type + ' - ' + (source.title OR '') as title,\n                content,\n                source.id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM source_insight\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $note_content_search = \n        IF $show_notes {(\n            SELECT \n                id,\n                title,\n                content,\n                id as parent_id,\n                vector::similarity::cosine(embedding, $query) as similarity\n            FROM note\n            WHERE vector::similarity::cosine(embedding, $query) >= $min_similarity\n            ORDER BY similarity DESC\n            LIMIT $match_count\n        )}\n        ELSE { [] };\n\n\n    let $all_results = array::union(\n        array::union($source_embedding_search, $source_insight_search),\n        $note_content_search\n    );\n\n\n    RETURN (select id, parent_id, title, math::max(similarity) as similarity,\n    array::flatten(content) as matches\n    from $all_results where id is not None\n    group by id, parent_id, title ORDER BY similarity DESC LIMIT $match_count);\n\n};"
  },
  {
    "path": "open_notebook/database/repository.py",
    "content": "import os\nfrom contextlib import asynccontextmanager\nfrom datetime import datetime, timezone\nfrom typing import Any, Dict, List, Optional, TypeVar, Union\n\nfrom loguru import logger\nfrom surrealdb import AsyncSurreal, RecordID  # type: ignore\n\nT = TypeVar(\"T\", Dict[str, Any], List[Dict[str, Any]])\n\n\ndef get_database_url():\n    \"\"\"Get database URL with backward compatibility\"\"\"\n    surreal_url = os.getenv(\"SURREAL_URL\")\n    if surreal_url:\n        return surreal_url\n\n    # Fallback to old format - WebSocket URL format\n    address = os.getenv(\"SURREAL_ADDRESS\", \"localhost\")\n    port = os.getenv(\"SURREAL_PORT\", \"8000\")\n    return f\"ws://{address}/rpc:{port}\"\n\n\ndef get_database_password():\n    \"\"\"Get password with backward compatibility\"\"\"\n    return os.getenv(\"SURREAL_PASSWORD\") or os.getenv(\"SURREAL_PASS\")\n\n\ndef parse_record_ids(obj: Any) -> Any:\n    \"\"\"Recursively parse and convert RecordIDs into strings.\"\"\"\n    if isinstance(obj, dict):\n        return {k: parse_record_ids(v) for k, v in obj.items()}\n    elif isinstance(obj, list):\n        return [parse_record_ids(item) for item in obj]\n    elif isinstance(obj, RecordID):\n        return str(obj)\n    return obj\n\n\ndef ensure_record_id(value: Union[str, RecordID]) -> RecordID:\n    \"\"\"Ensure a value is a RecordID.\"\"\"\n    if isinstance(value, RecordID):\n        return value\n    return RecordID.parse(value)\n\n\n@asynccontextmanager\nasync def db_connection():\n    db = AsyncSurreal(get_database_url())\n    await db.signin(\n        {\n            \"username\": os.environ.get(\"SURREAL_USER\"),\n            \"password\": get_database_password(),\n        }\n    )\n    await db.use(\n        os.environ.get(\"SURREAL_NAMESPACE\"), os.environ.get(\"SURREAL_DATABASE\")\n    )\n    try:\n        yield db\n    finally:\n        await db.close()\n\n\nasync def repo_query(\n    query_str: str, vars: Optional[Dict[str, Any]] = None\n) -> List[Dict[str, Any]]:\n    \"\"\"Execute a SurrealQL query and return the results\"\"\"\n\n    async with db_connection() as connection:\n        try:\n            result = parse_record_ids(await connection.query(query_str, vars))\n            if isinstance(result, str):\n                raise RuntimeError(result)\n            return result\n        except RuntimeError as e:\n            # RuntimeError is raised for retriable transaction conflicts - log at debug to avoid noise\n            logger.debug(str(e))\n            raise\n        except Exception as e:\n            logger.exception(e)\n            raise\n\n\nasync def repo_create(table: str, data: Dict[str, Any]) -> Dict[str, Any]:\n    \"\"\"Create a new record in the specified table\"\"\"\n    # Remove 'id' attribute if it exists in data\n    data.pop(\"id\", None)\n    data[\"created\"] = datetime.now(timezone.utc)\n    data[\"updated\"] = datetime.now(timezone.utc)\n    try:\n        async with db_connection() as connection:\n            result = parse_record_ids(await connection.insert(table, data))\n            # SurrealDB may return a string error message instead of the expected record\n            if isinstance(result, str):\n                raise RuntimeError(result)\n            return result\n    except RuntimeError as e:\n        logger.error(str(e))\n        raise\n    except Exception as e:\n        logger.exception(e)\n        raise RuntimeError(\"Failed to create record\")\n\n\nasync def repo_relate(\n    source: str, relationship: str, target: str, data: Optional[Dict[str, Any]] = None\n) -> List[Dict[str, Any]]:\n    \"\"\"Create a relationship between two records with optional data\"\"\"\n    if data is None:\n        data = {}\n    query = f\"RELATE {source}->{relationship}->{target} CONTENT $data;\"\n    # logger.debug(f\"Relate query: {query}\")\n\n    return await repo_query(\n        query,\n        {\n            \"data\": data,\n        },\n    )\n\n\nasync def repo_upsert(\n    table: str, id: Optional[str], data: Dict[str, Any], add_timestamp: bool = False\n) -> List[Dict[str, Any]]:\n    \"\"\"Create or update a record in the specified table\"\"\"\n    data.pop(\"id\", None)\n    if add_timestamp:\n        data[\"updated\"] = datetime.now(timezone.utc)\n    query = f\"UPSERT {id if id else table} MERGE $data;\"\n    return await repo_query(query, {\"data\": data})\n\n\nasync def repo_update(\n    table: str, id: str, data: Dict[str, Any]\n) -> List[Dict[str, Any]]:\n    \"\"\"Update an existing record by table and id\"\"\"\n    # If id already contains the table name, use it as is\n    try:\n        if isinstance(id, RecordID) or (\":\" in id and id.startswith(f\"{table}:\")):\n            record_id = id\n        else:\n            record_id = f\"{table}:{id}\"\n        data.pop(\"id\", None)\n        if \"created\" in data and isinstance(data[\"created\"], str):\n            data[\"created\"] = datetime.fromisoformat(data[\"created\"])\n        data[\"updated\"] = datetime.now(timezone.utc)\n        query = f\"UPDATE {record_id} MERGE $data;\"\n        # logger.debug(f\"Update query: {query}\")\n        result = await repo_query(query, {\"data\": data})\n        # if isinstance(result, list):\n        #     return [_return_data(item) for item in result]\n        return parse_record_ids(result)\n    except Exception as e:\n        raise RuntimeError(f\"Failed to update record: {str(e)}\")\n\n\nasync def repo_delete(record_id: Union[str, RecordID]):\n    \"\"\"Delete a record by record id\"\"\"\n\n    try:\n        async with db_connection() as connection:\n            return await connection.delete(ensure_record_id(record_id))\n    except Exception as e:\n        logger.exception(e)\n        raise RuntimeError(f\"Failed to delete record: {str(e)}\")\n\n\nasync def repo_insert(\n    table: str, data: List[Dict[str, Any]], ignore_duplicates: bool = False\n) -> List[Dict[str, Any]]:\n    \"\"\"Create a new record in the specified table\"\"\"\n    try:\n        async with db_connection() as connection:\n            result = parse_record_ids(await connection.insert(table, data))\n            # SurrealDB may return a string error message instead of the expected records\n            if isinstance(result, str):\n                raise RuntimeError(result)\n            return result\n    except RuntimeError as e:\n        if ignore_duplicates and \"already contains\" in str(e):\n            return []\n        # Log transaction conflicts at debug level (they are expected during concurrent operations)\n        error_str = str(e).lower()\n        if \"transaction\" in error_str or \"conflict\" in error_str:\n            logger.debug(str(e))\n        else:\n            logger.error(str(e))\n        raise\n    except Exception as e:\n        if ignore_duplicates and \"already contains\" in str(e):\n            return []\n        logger.exception(e)\n        raise RuntimeError(\"Failed to create record\")\n"
  },
  {
    "path": "open_notebook/domain/CLAUDE.md",
    "content": "# Domain Module\n\nCore data models for notebooks, sources, notes, and settings with async SurrealDB persistence, auto-embedding, and relationship management.\n\n## Purpose\n\nTwo base classes support different persistence patterns: **ObjectModel** (mutable records with auto-increment IDs) and **RecordModel** (singleton configuration with fixed IDs).\n\n## Key Components\n\n### base.py\n- **ObjectModel**: Base for notebooks, sources, notes\n  - `save()`: Create/update with auto-embedding for searchable content\n  - `delete()`: Remove by ID\n  - `relate(relationship, target_id)`: Create graph relationships (reference, artifact, refers_to)\n  - `get(id)`: Polymorphic fetch; resolves subclass from ID prefix\n  - `get_all(order_by)`: Fetch all records from table\n  - Integrates with ModelManager for automatic embedding\n\n- **RecordModel**: Singleton configuration (ContentSettings, DefaultPrompts)\n  - Fixed record_id per subclass\n  - `update()`: Upsert to database\n  - Lazy DB loading via `_load_from_db()`\n\n### notebook.py\n- **Notebook**: Research project container\n  - `get_sources()`, `get_notes()`, `get_chat_sessions()`: Navigate relationships\n  - `get_delete_preview()`: Returns counts of notes, exclusive sources, and shared sources that would be affected by deletion\n  - `delete(delete_exclusive_sources)`: Cascade deletion - always deletes notes, optionally deletes exclusive sources, always unlinks all sources\n\n- **Source**: Content item (file/URL)\n  - `vectorize()`: Submit async embedding job (returns command_id, fire-and-forget)\n  - `get_status()`, `get_processing_progress()`: Track job via surreal_commands\n  - `get_context()`: Returns summary for LLM context\n  - `add_insight()`: Submit async insight creation via `create_insight_command` (fire-and-forget, returns command_id)\n\n- **Note**: Standalone or linked notes\n  - `save()`: Submits `embed_note` command after save (fire-and-forget)\n  - `add_to_notebook()`: Link to notebook\n\n- **SourceInsight, SourceEmbedding**: Derived content models\n- **ChatSession**: Conversation container with optional model_override\n- **Asset**: File/URL reference helper\n\n- **Search functions**:\n  - `text_search()`: Full-text keyword search\n  - `vector_search()`: Semantic search via embeddings (default minimum_score=0.2)\n\n### content_settings.py\n- **ContentSettings**: Singleton for processing engines, embedding strategy, file deletion, YouTube languages\n\n### transformation.py\n- **Transformation**: Reusable prompts for content transformation\n- **DefaultPrompts**: Singleton with transformation instructions\n\n### credential.py\n- **Credential**: Individual credential records for API keys and provider configuration\n  - **One record per credential**: Each credential (e.g., \"My OpenAI Key\", \"Work Anthropic\") is a separate `Credential` record in SurrealDB\n  - **Fields**: name, provider, modalities (list), api_key (SecretStr), base_url, endpoint, api_version, endpoint_llm/embedding/stt/tts, project, location, credentials_path\n  - **SecretStr protection**: API key field uses Pydantic's `SecretStr` (values masked in logs/repr)\n  - **Encryption integration**: Uses `encrypt_value()`/`decrypt_value()` from `open_notebook.utils.encryption`\n    - Keys encrypted with Fernet before database storage\n    - Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` environment variable (warns if not set)\n  - **Key methods**:\n    - `to_esperanto_config()`: Builds config dict for Esperanto's AIFactory methods\n    - `get_by_provider(provider)`: Class method to fetch all credentials for a provider\n    - `get_linked_models()`: Returns all Model records linked to this credential\n  - **Custom serialization**: `_prepare_save_data()` extracts SecretStr values and encrypts before storage\n  - **Decryption on read**: `get()` and `get_all()` overridden to decrypt api_key after fetch\n\n- **Note**: `provider_config.py` still exists for legacy migration support (migrating old ProviderConfig records to Credential)\n\n## Important Patterns\n\n- **Async/await**: All DB operations async; always use await\n- **Polymorphic get()**: `ObjectModel.get(id)` determines subclass from ID prefix (table:id format)\n- **Fire-and-forget embedding**: Models submit embed_* commands after save via `submit_command()` (non-blocking)\n- **Nullable fields**: Declare via `nullable_fields` ClassVar to allow None in database\n- **Timestamps**: `created` and `updated` auto-managed as ISO strings\n- **Fire-and-forget jobs**: `source.vectorize()` returns command_id without waiting\n\n## Key Dependencies\n\n- `surrealdb`: RecordID type for relationships\n- `pydantic`: Validation and field_validator decorators\n- `open_notebook.database.repository`: CRUD and relationship functions\n- `open_notebook.ai.models`: ModelManager for embeddings\n- `surreal_commands`: Async job submission (vectorization, insights)\n- `loguru`: Logging\n\n## Quirks & Gotchas\n\n- **Polymorphic resolution**: `ObjectModel.get()` fails if subclass not imported (search subclasses list)\n- **RecordModel singleton**: __new__ returns existing instance; call `clear_instance()` in tests\n- **Source.command field**: Stored as RecordID; auto-parsed from strings via field_validator\n- **Text truncation**: `Note.get_context(short)` hardcodes 100-char limit\n- **Auto-embedding behavior**:\n  - `Note.save()` → auto-submits `embed_note` command\n  - `Source.save()` → does NOT auto-submit (must call `vectorize()` explicitly)\n  - `Source.add_insight()` → submits `create_insight_command` which handles DB insert + `embed_insight` command (all fire-and-forget)\n- **Relationship strings**: Must match SurrealDB schema (reference, artifact, refers_to)\n\n## How to Add New Model\n\n1. Inherit from ObjectModel with table_name ClassVar\n2. Define Pydantic fields with validators\n3. Override `save()` to submit embedding command if searchable (use `submit_command(\"embed_*\", id)`)\n4. Add custom methods for domain logic (get_X, add_to_Y)\n5. Implement `_prepare_save_data()` if custom serialization needed\n\n## Usage\n\n```python\nnotebook = Notebook(name=\"Research\", description=\"My project\")\nawait notebook.save()\n\nobj = await ObjectModel.get(\"notebook:123\")  # Polymorphic fetch\n\n# Search\nawait text_search(\"quantum\", results=5)\nawait vector_search(\"quantum computing\", results=10, minimum_score=0.3)\n```\n"
  },
  {
    "path": "open_notebook/domain/__init__.py",
    "content": "\"\"\"\nDomain models for Open Notebook.\n\nThis module exports the core domain models used throughout the application.\n\"\"\"\n\n__all__: list[str] = []\n"
  },
  {
    "path": "open_notebook/domain/base.py",
    "content": "from datetime import datetime\nfrom typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union, cast\n\nfrom loguru import logger\nfrom pydantic import (\n    BaseModel,\n    ConfigDict,\n    ValidationError,\n    field_validator,\n    model_validator,\n)\n\nfrom open_notebook.database.repository import (\n    ensure_record_id,\n    repo_create,\n    repo_delete,\n    repo_query,\n    repo_relate,\n    repo_update,\n    repo_upsert,\n)\nfrom open_notebook.exceptions import (\n    DatabaseOperationError,\n    InvalidInputError,\n    NotFoundError,\n)\n\nT = TypeVar(\"T\", bound=\"ObjectModel\")\n\n\nclass ObjectModel(BaseModel):\n    id: Optional[str] = None\n    table_name: ClassVar[str] = \"\"\n    nullable_fields: ClassVar[set[str]] = set()  # Fields that can be saved as None\n    created: Optional[datetime] = None\n    updated: Optional[datetime] = None\n\n    @classmethod\n    async def get_all(cls: Type[T], order_by=None) -> List[T]:\n        try:\n            # If called from a specific subclass, use its table_name\n            if cls.table_name:\n                target_class = cls\n                table_name = cls.table_name\n            else:\n                # This path is taken if called directly from ObjectModel\n                raise InvalidInputError(\n                    \"get_all() must be called from a specific model class\"\n                )\n            if order_by:\n                query = f\"SELECT * FROM {table_name} ORDER BY {order_by}\"\n            else:\n                query = f\"SELECT * FROM {table_name}\"\n\n            result = await repo_query(query)\n            objects = []\n            for obj in result:\n                try:\n                    objects.append(target_class(**obj))\n                except Exception as e:\n                    logger.critical(f\"Error creating object: {str(e)}\")\n\n            return objects\n        except Exception as e:\n            logger.error(f\"Error fetching all {cls.table_name}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    @classmethod\n    async def get(cls: Type[T], id: str) -> T:\n        if not id:\n            raise InvalidInputError(\"ID cannot be empty\")\n        try:\n            # Get the table name from the ID (everything before the first colon)\n            table_name = id.split(\":\")[0] if \":\" in id else id\n\n            # If we're calling from a specific subclass and IDs match, use that class\n            if cls.table_name and cls.table_name == table_name:\n                target_class: Type[T] = cls\n            else:\n                # Otherwise, find the appropriate subclass based on table_name\n                found_class = cls._get_class_by_table_name(table_name)\n                if not found_class:\n                    raise InvalidInputError(f\"No class found for table {table_name}\")\n                target_class = cast(Type[T], found_class)\n\n            result = await repo_query(\"SELECT * FROM $id\", {\"id\": ensure_record_id(id)})\n            if result:\n                return target_class(**result[0])\n            else:\n                raise NotFoundError(f\"{table_name} with id {id} not found\")\n        except Exception as e:\n            logger.error(f\"Error fetching object with id {id}: {str(e)}\")\n            logger.exception(e)\n            raise NotFoundError(f\"Object with id {id} not found - {str(e)}\")\n\n    @classmethod\n    def _get_class_by_table_name(cls, table_name: str) -> Optional[Type[\"ObjectModel\"]]:\n        \"\"\"Find the appropriate subclass based on table_name.\"\"\"\n\n        def get_all_subclasses(c: Type[\"ObjectModel\"]) -> List[Type[\"ObjectModel\"]]:\n            all_subclasses: List[Type[\"ObjectModel\"]] = []\n            for subclass in c.__subclasses__():\n                all_subclasses.append(subclass)\n                all_subclasses.extend(get_all_subclasses(subclass))\n            return all_subclasses\n\n        for subclass in get_all_subclasses(ObjectModel):\n            if hasattr(subclass, \"table_name\") and subclass.table_name == table_name:\n                return subclass\n        return None\n\n    async def save(self) -> None:\n        \"\"\"\n        Save the model to the database.\n\n        Note: Embedding is no longer generated inline. Subclasses that need\n        embedding should override save() to submit the appropriate embed_*\n        command after calling super().save().\n        \"\"\"\n        try:\n            self.model_validate(self.model_dump(), strict=True)\n            data = self._prepare_save_data()\n            data[\"updated\"] = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            repo_result: Union[List[Dict[str, Any]], Dict[str, Any]]\n            if self.id is None:\n                data[\"created\"] = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                repo_result = await repo_create(self.__class__.table_name, data)\n            else:\n                data[\"created\"] = (\n                    self.created.strftime(\"%Y-%m-%d %H:%M:%S\")\n                    if isinstance(self.created, datetime)\n                    else self.created\n                )\n                logger.debug(f\"Updating record with id {self.id}\")\n                repo_result = await repo_update(\n                    self.__class__.table_name, self.id, data\n                )\n            # Update the current instance with the result\n            # repo_result is a list of dictionaries\n            result_list: List[Dict[str, Any]] = (\n                repo_result if isinstance(repo_result, list) else [repo_result]\n            )\n            for key, value in result_list[0].items():\n                if hasattr(self, key):\n                    if isinstance(getattr(self, key), BaseModel):\n                        setattr(self, key, type(getattr(self, key))(**value))\n                    else:\n                        setattr(self, key, value)\n\n        except ValidationError as e:\n            logger.error(f\"Validation failed: {e}\")\n            raise\n        except RuntimeError:\n            # Transaction conflicts should propagate for retry\n            raise\n        except Exception as e:\n            logger.error(f\"Error saving record: {e}\")\n            raise DatabaseOperationError(e)\n\n    def _prepare_save_data(self) -> Dict[str, Any]:\n        data = self.model_dump()\n        return {\n            key: value\n            for key, value in data.items()\n            if value is not None or key in self.__class__.nullable_fields\n        }\n\n    async def delete(self) -> bool:\n        if self.id is None:\n            raise InvalidInputError(\"Cannot delete object without an ID\")\n        try:\n            logger.debug(f\"Deleting record with id {self.id}\")\n            return await repo_delete(self.id)\n        except Exception as e:\n            logger.error(\n                f\"Error deleting {self.__class__.table_name} with id {self.id}: {str(e)}\"\n            )\n            raise DatabaseOperationError(\n                f\"Failed to delete {self.__class__.table_name}\"\n            )\n\n    async def relate(\n        self, relationship: str, target_id: str, data: Optional[Dict] = {}\n    ) -> Any:\n        if not relationship or not target_id or not self.id:\n            raise InvalidInputError(\"Relationship and target ID must be provided\")\n        try:\n            return await repo_relate(\n                source=self.id, relationship=relationship, target=target_id, data=data\n            )\n        except Exception as e:\n            logger.error(f\"Error creating relationship: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    @field_validator(\"created\", \"updated\", mode=\"before\")\n    @classmethod\n    def parse_datetime(cls, value):\n        if isinstance(value, str):\n            return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n        return value\n\n\nclass RecordModel(BaseModel):\n    model_config = ConfigDict(\n        validate_assignment=True,\n        arbitrary_types_allowed=True,\n        extra=\"allow\",\n        from_attributes=True,\n        defer_build=True,\n    )\n\n    record_id: ClassVar[str]\n    auto_save: ClassVar[bool] = (\n        False  # Default to False, can be overridden in subclasses\n    )\n    _instances: ClassVar[Dict[str, \"RecordModel\"]] = {}  # Store instances by record_id\n\n    def __new__(cls, **kwargs):\n        # If an instance already exists for this record_id, return it\n        if cls.record_id in cls._instances:\n            instance = cls._instances[cls.record_id]\n            # Update instance with any new kwargs if provided\n            if kwargs:\n                for key, value in kwargs.items():\n                    setattr(instance, key, value)\n            return instance\n\n        # If no instance exists, create a new one\n        instance = super().__new__(cls)\n        cls._instances[cls.record_id] = instance\n        return instance\n\n    def __init__(self, **kwargs):\n        # Only initialize if this is a new instance\n        if not hasattr(self, \"_initialized\"):\n            object.__setattr__(self, \"__dict__\", {})\n\n            # For RecordModel, we need to handle async initialization differently\n            # Initialize with provided kwargs only for now\n            super().__init__(**kwargs)\n\n            # Mark as initialized but not loaded from DB yet\n            object.__setattr__(self, \"_initialized\", True)\n            object.__setattr__(self, \"_db_loaded\", False)\n\n    async def _load_from_db(self):\n        \"\"\"Load data from database if not already loaded\"\"\"\n        if not getattr(self, \"_db_loaded\", False):\n            result = await repo_query(\n                \"SELECT * FROM ONLY $record_id\",\n                {\"record_id\": ensure_record_id(self.record_id)},\n            )\n\n            # Handle case where record doesn't exist yet\n            if result:\n                if isinstance(result, list) and len(result) > 0:\n                    # Standard list response\n                    row = result[0]\n                    if isinstance(row, dict):\n                        for key, value in row.items():\n                            if hasattr(self, key):\n                                object.__setattr__(self, key, value)\n                elif isinstance(result, dict):\n                    # Direct dict response\n                    for key, value in result.items():\n                        if hasattr(self, key):\n                            object.__setattr__(self, key, value)\n\n            object.__setattr__(self, \"_db_loaded\", True)\n\n    @classmethod\n    async def get_instance(cls) -> \"RecordModel\":\n        \"\"\"Get or create the singleton instance and load from DB\"\"\"\n        instance = cls()\n        await instance._load_from_db()\n        return instance\n\n    @model_validator(mode=\"after\")\n    def auto_save_validator(self):\n        if self.__class__.auto_save:\n            # Auto-save can't work with async - log warning\n            logger.warning(\n                f\"Auto-save is enabled for {self.__class__.__name__} but update() is now async. Call await instance.update() manually.\"\n            )\n        return self\n\n    async def update(self):\n        # Get all non-ClassVar fields and their values\n        data = {\n            field_name: getattr(self, field_name)\n            for field_name, field_info in self.model_fields.items()\n            if not str(field_info.annotation).startswith(\"typing.ClassVar\")\n        }\n\n        await repo_upsert(\n            self.__class__.table_name\n            if hasattr(self.__class__, \"table_name\")\n            else \"record\",\n            self.record_id,\n            data,\n        )\n\n        result = await repo_query(\n            \"SELECT * FROM $record_id\", {\"record_id\": ensure_record_id(self.record_id)}\n        )\n        if result:\n            for key, value in result[0].items():\n                if hasattr(self, key):\n                    object.__setattr__(\n                        self, key, value\n                    )  # Use object.__setattr__ to avoid triggering validation again\n\n        return self\n\n    @classmethod\n    def clear_instance(cls):\n        \"\"\"Clear the singleton instance (useful for testing)\"\"\"\n        if cls.record_id in cls._instances:\n            del cls._instances[cls.record_id]\n\n    async def patch(self, model_dict: dict):\n        \"\"\"Update model attributes from dictionary and save\"\"\"\n        for key, value in model_dict.items():\n            setattr(self, key, value)\n        await self.update()\n"
  },
  {
    "path": "open_notebook/domain/content_settings.py",
    "content": "from typing import ClassVar, List, Literal, Optional\n\nfrom pydantic import Field\n\nfrom open_notebook.domain.base import RecordModel\n\n\nclass ContentSettings(RecordModel):\n    record_id: ClassVar[str] = \"open_notebook:content_settings\"\n    default_content_processing_engine_doc: Optional[\n        Literal[\"auto\", \"docling\", \"simple\"]\n    ] = Field(\"auto\", description=\"Default Content Processing Engine for Documents\")\n    default_content_processing_engine_url: Optional[\n        Literal[\"auto\", \"firecrawl\", \"jina\", \"simple\"]\n    ] = Field(\"auto\", description=\"Default Content Processing Engine for URLs\")\n    default_embedding_option: Optional[Literal[\"ask\", \"always\", \"never\"]] = Field(\n        \"ask\", description=\"Default Embedding Option for Vector Search\"\n    )\n    auto_delete_files: Optional[Literal[\"yes\", \"no\"]] = Field(\n        \"yes\", description=\"Auto Delete Uploaded Files\"\n    )\n    youtube_preferred_languages: Optional[List[str]] = Field(\n        [\"en\", \"pt\", \"es\", \"de\", \"nl\", \"en-GB\", \"fr\", \"de\", \"hi\", \"ja\"],\n        description=\"Preferred languages for YouTube transcripts\",\n    )\n"
  },
  {
    "path": "open_notebook/domain/credential.py",
    "content": "\"\"\"\nCredential domain model for storing individual provider credentials.\n\nEach credential is a standalone record in the 'credential' table, replacing\nthe old ProviderConfig singleton. Credentials store API keys (encrypted at\nrest) and provider-specific configuration fields.\n\nUsage:\n    cred = Credential(\n        name=\"Production\",\n        provider=\"openai\",\n        modalities=[\"language\", \"embedding\"],\n        api_key=SecretStr(\"sk-...\"),\n    )\n    await cred.save()\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any, ClassVar, Dict, List, Optional\n\nfrom loguru import logger\nfrom pydantic import SecretStr\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.base import ObjectModel\nfrom open_notebook.utils.encryption import decrypt_value, encrypt_value\n\n\nclass Credential(ObjectModel):\n    \"\"\"\n    Individual credential record for an AI provider.\n\n    Each record stores authentication and configuration for a single provider\n    account. Models link to credentials via the credential field.\n    \"\"\"\n\n    table_name: ClassVar[str] = \"credential\"\n    nullable_fields: ClassVar[set[str]] = {\n        \"api_key\",\n        \"base_url\",\n        \"endpoint\",\n        \"api_version\",\n        \"endpoint_llm\",\n        \"endpoint_embedding\",\n        \"endpoint_stt\",\n        \"endpoint_tts\",\n        \"project\",\n        \"location\",\n        \"credentials_path\",\n    }\n\n    name: str\n    provider: str\n    modalities: List[str] = []\n    api_key: Optional[SecretStr] = None\n    base_url: Optional[str] = None\n    endpoint: Optional[str] = None\n    api_version: Optional[str] = None\n    endpoint_llm: Optional[str] = None\n    endpoint_embedding: Optional[str] = None\n    endpoint_stt: Optional[str] = None\n    endpoint_tts: Optional[str] = None\n    project: Optional[str] = None\n    location: Optional[str] = None\n    credentials_path: Optional[str] = None\n\n    def to_esperanto_config(self) -> Dict[str, Any]:\n        \"\"\"\n        Build config dict for AIFactory.create_*() calls.\n\n        Returns a dict that can be passed as the 'config' parameter to\n        Esperanto's AIFactory methods, overriding env var lookup.\n        \"\"\"\n        config: Dict[str, Any] = {}\n        if self.api_key:\n            config[\"api_key\"] = self.api_key.get_secret_value()\n        if self.base_url:\n            config[\"base_url\"] = self.base_url\n        if self.endpoint:\n            config[\"endpoint\"] = self.endpoint\n        if self.api_version:\n            config[\"api_version\"] = self.api_version\n        if self.endpoint_llm:\n            config[\"endpoint_llm\"] = self.endpoint_llm\n        if self.endpoint_embedding:\n            config[\"endpoint_embedding\"] = self.endpoint_embedding\n        if self.endpoint_stt:\n            config[\"endpoint_stt\"] = self.endpoint_stt\n        if self.endpoint_tts:\n            config[\"endpoint_tts\"] = self.endpoint_tts\n        if self.project:\n            config[\"project\"] = self.project\n        if self.location:\n            config[\"location\"] = self.location\n        if self.credentials_path:\n            config[\"credentials_path\"] = self.credentials_path\n        return config\n\n    @classmethod\n    async def get_by_provider(cls, provider: str) -> List[\"Credential\"]:\n        \"\"\"Get all credentials for a provider.\"\"\"\n        results = await repo_query(\n            \"SELECT * FROM credential WHERE string::lowercase(provider) = string::lowercase($provider) ORDER BY created ASC\",\n            {\"provider\": provider},\n        )\n        credentials = []\n        for row in results:\n            try:\n                cred = cls._from_db_row(row)\n                credentials.append(cred)\n            except Exception as e:\n                logger.warning(f\"Skipping invalid credential: {e}\")\n        return credentials\n\n    @classmethod\n    async def get(cls, id: str) -> \"Credential\":\n        \"\"\"Override get() to handle api_key decryption.\"\"\"\n        instance = await super().get(id)\n        # Pydantic auto-wraps the raw DB string in SecretStr, so we need\n        # to extract, decrypt, and re-wrap regardless of type.\n        if instance.api_key:\n            raw = (\n                instance.api_key.get_secret_value()\n                if isinstance(instance.api_key, SecretStr)\n                else instance.api_key\n            )\n            decrypted = decrypt_value(raw)\n            object.__setattr__(instance, \"api_key\", SecretStr(decrypted))\n        return instance\n\n    @classmethod\n    async def get_all(cls, order_by=None) -> List[\"Credential\"]:\n        \"\"\"Override get_all() to handle api_key decryption.\"\"\"\n        instances = await super().get_all(order_by=order_by)\n        for instance in instances:\n            if instance.api_key:\n                raw = (\n                    instance.api_key.get_secret_value()\n                    if isinstance(instance.api_key, SecretStr)\n                    else instance.api_key\n                )\n                decrypted = decrypt_value(raw)\n                object.__setattr__(instance, \"api_key\", SecretStr(decrypted))\n        return instances\n\n    async def get_linked_models(self) -> list:\n        \"\"\"Get all models linked to this credential.\"\"\"\n        if not self.id:\n            return []\n        from open_notebook.ai.models import Model\n\n        results = await repo_query(\n            \"SELECT * FROM model WHERE credential = $cred_id\",\n            {\"cred_id\": ensure_record_id(self.id)},\n        )\n        return [Model(**row) for row in results]\n\n    def _prepare_save_data(self) -> Dict[str, Any]:\n        \"\"\"Override to encrypt api_key before storage.\"\"\"\n        data = {}\n        for key, value in self.model_dump().items():\n            if key == \"api_key\":\n                # Handle SecretStr: extract, encrypt, store\n                if self.api_key:\n                    secret_value = self.api_key.get_secret_value()\n                    data[\"api_key\"] = encrypt_value(secret_value)\n                else:\n                    data[\"api_key\"] = None\n            elif value is not None or key in self.__class__.nullable_fields:\n                data[key] = value\n\n        return data\n\n    async def save(self) -> None:\n        \"\"\"Save credential, handling api_key re-hydration after DB round-trip.\"\"\"\n        # Remember the original SecretStr before save\n        original_api_key = self.api_key\n\n        await super().save()\n\n        # After save, the api_key field may be set to the encrypted string\n        # from the DB result. Restore the original SecretStr.\n        if original_api_key:\n            object.__setattr__(self, \"api_key\", original_api_key)\n        elif self.api_key and isinstance(self.api_key, str):\n            # Decrypt if DB returned an encrypted string\n            decrypted = decrypt_value(self.api_key)\n            object.__setattr__(self, \"api_key\", SecretStr(decrypted))\n\n    @classmethod\n    def _from_db_row(cls, row: dict) -> \"Credential\":\n        \"\"\"Create a Credential from a database row, decrypting api_key.\"\"\"\n        api_key_val = row.get(\"api_key\")\n        if api_key_val and isinstance(api_key_val, str):\n            decrypted = decrypt_value(api_key_val)\n            row[\"api_key\"] = SecretStr(decrypted)\n        elif api_key_val is None:\n            row[\"api_key\"] = None\n        return cls(**row)\n"
  },
  {
    "path": "open_notebook/domain/notebook.py",
    "content": "import asyncio\nimport os\nfrom pathlib import Path\nfrom typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Union\n\nfrom loguru import logger\nfrom pydantic import BaseModel, ConfigDict, Field, field_validator\nfrom surreal_commands import submit_command\nfrom surrealdb import RecordID\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.base import ObjectModel\nfrom open_notebook.exceptions import DatabaseOperationError, InvalidInputError\n\n\nclass Notebook(ObjectModel):\n    table_name: ClassVar[str] = \"notebook\"\n    name: str\n    description: str\n    archived: Optional[bool] = False\n\n    @field_validator(\"name\")\n    @classmethod\n    def name_must_not_be_empty(cls, v):\n        if not v.strip():\n            raise InvalidInputError(\"Notebook name cannot be empty\")\n        return v\n\n    async def get_sources(self) -> List[\"Source\"]:\n        try:\n            srcs = await repo_query(\n                \"\"\"\n                select * omit source.full_text from (\n                select in as source from reference where out=$id\n                fetch source\n            ) order by source.updated desc\n            \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return [Source(**src[\"source\"]) for src in srcs] if srcs else []\n        except Exception as e:\n            logger.error(f\"Error fetching sources for notebook {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def get_notes(self) -> List[\"Note\"]:\n        try:\n            srcs = await repo_query(\n                \"\"\"\n            select * omit note.content, note.embedding from (\n                select in as note from artifact where out=$id\n                fetch note\n            ) order by note.updated desc\n            \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return [Note(**src[\"note\"]) for src in srcs] if srcs else []\n        except Exception as e:\n            logger.error(f\"Error fetching notes for notebook {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def get_chat_sessions(self) -> List[\"ChatSession\"]:\n        try:\n            srcs = await repo_query(\n                \"\"\"\n                select * from (\n                    select\n                    <- chat_session as chat_session\n                    from refers_to\n                    where out=$id\n                    fetch chat_session\n                )\n                order by chat_session.updated desc\n            \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return (\n                [ChatSession(**src[\"chat_session\"][0]) for src in srcs] if srcs else []\n            )\n        except Exception as e:\n            logger.error(\n                f\"Error fetching chat sessions for notebook {self.id}: {str(e)}\"\n            )\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def get_delete_preview(self) -> Dict[str, Any]:\n        \"\"\"\n        Get counts of items that would be affected by deleting this notebook.\n\n        Returns a dict with:\n        - note_count: Number of notes that will be deleted\n        - exclusive_source_count: Sources only in this notebook (can be deleted)\n        - shared_source_count: Sources in other notebooks (will be unlinked only)\n        \"\"\"\n        try:\n            notebook_id = ensure_record_id(self.id)\n\n            # Count notes\n            note_result = await repo_query(\n                \"SELECT count() as count FROM artifact WHERE out = $notebook_id GROUP ALL\",\n                {\"notebook_id\": notebook_id},\n            )\n            note_count = note_result[0][\"count\"] if note_result else 0\n\n            # Get sources with count of references to OTHER notebooks\n            # If assigned_others = 0, source is exclusive to this notebook\n            # If assigned_others > 0, source is shared with other notebooks\n            source_counts = await repo_query(\n                \"\"\"\n                SELECT\n                    id,\n                    count(->reference[WHERE out != $notebook_id].out) as assigned_others\n                FROM (SELECT VALUE <-reference.in AS sources FROM $notebook_id)[0]\n                \"\"\",\n                {\"notebook_id\": notebook_id},\n            )\n\n            exclusive_count = 0\n            shared_count = 0\n            for src in source_counts:\n                if src.get(\"assigned_others\", 0) == 0:\n                    exclusive_count += 1\n                else:\n                    shared_count += 1\n\n            return {\n                \"note_count\": note_count,\n                \"exclusive_source_count\": exclusive_count,\n                \"shared_source_count\": shared_count,\n            }\n        except Exception as e:\n            logger.error(f\"Error getting delete preview for notebook {self.id}: {e}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def delete(self, delete_exclusive_sources: bool = False) -> Dict[str, int]:\n        \"\"\"\n        Delete notebook with cascade deletion of notes and optional source deletion.\n\n        Args:\n            delete_exclusive_sources: If True, also delete sources that belong\n                                     only to this notebook. Default is False.\n\n        Returns:\n            Dict with counts: deleted_notes, deleted_sources, unlinked_sources\n        \"\"\"\n        if self.id is None:\n            raise InvalidInputError(\"Cannot delete notebook without an ID\")\n\n        try:\n            notebook_id = ensure_record_id(self.id)\n            deleted_notes = 0\n            deleted_sources = 0\n            unlinked_sources = 0\n\n            # 1. Get and delete all notes linked to this notebook\n            notes = await self.get_notes()\n            for note in notes:\n                await note.delete()\n                deleted_notes += 1\n            logger.info(f\"Deleted {deleted_notes} notes for notebook {self.id}\")\n\n            # Delete artifact relationships\n            await repo_query(\n                \"DELETE artifact WHERE out = $notebook_id\",\n                {\"notebook_id\": notebook_id},\n            )\n\n            # 2. Handle sources\n            if delete_exclusive_sources:\n                # Find sources with count of references to OTHER notebooks\n                # If assigned_others = 0, source is exclusive to this notebook\n                source_counts = await repo_query(\n                    \"\"\"\n                    SELECT\n                        id,\n                        count(->reference[WHERE out != $notebook_id].out) as assigned_others\n                    FROM (SELECT VALUE <-reference.in AS sources FROM $notebook_id)[0]\n                    \"\"\",\n                    {\"notebook_id\": notebook_id},\n                )\n\n                for src in source_counts:\n                    source_id = src.get(\"id\")\n                    if source_id and src.get(\"assigned_others\", 0) == 0:\n                        # Exclusive source - delete it\n                        try:\n                            source = await Source.get(str(source_id))\n                            await source.delete()\n                            deleted_sources += 1\n                        except Exception as e:\n                            logger.warning(\n                                f\"Failed to delete exclusive source {source_id}: {e}\"\n                            )\n                    else:\n                        unlinked_sources += 1\n            else:\n                # Just count sources that will be unlinked\n                source_result = await repo_query(\n                    \"SELECT count() as count FROM reference WHERE out = $notebook_id GROUP ALL\",\n                    {\"notebook_id\": notebook_id},\n                )\n                unlinked_sources = source_result[0][\"count\"] if source_result else 0\n\n            # Delete reference relationships (unlink all sources)\n            await repo_query(\n                \"DELETE reference WHERE out = $notebook_id\",\n                {\"notebook_id\": notebook_id},\n            )\n            logger.info(\n                f\"Unlinked {unlinked_sources} sources, deleted {deleted_sources} \"\n                f\"exclusive sources for notebook {self.id}\"\n            )\n\n            # 3. Delete the notebook record itself\n            await super().delete()\n            logger.info(f\"Deleted notebook {self.id}\")\n\n            return {\n                \"deleted_notes\": deleted_notes,\n                \"deleted_sources\": deleted_sources,\n                \"unlinked_sources\": unlinked_sources,\n            }\n\n        except Exception as e:\n            logger.error(f\"Error deleting notebook {self.id}: {e}\")\n            logger.exception(e)\n            raise DatabaseOperationError(f\"Failed to delete notebook: {e}\")\n\n\nclass Asset(BaseModel):\n    file_path: Optional[str] = None\n    url: Optional[str] = None\n\n\nclass SourceEmbedding(ObjectModel):\n    table_name: ClassVar[str] = \"source_embedding\"\n    content: str\n\n    async def get_source(self) -> \"Source\":\n        try:\n            src = await repo_query(\n                \"\"\"\n            select source.* from $id fetch source\n            \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return Source(**src[0][\"source\"])\n        except Exception as e:\n            logger.error(f\"Error fetching source for embedding {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n\nclass SourceInsight(ObjectModel):\n    table_name: ClassVar[str] = \"source_insight\"\n    insight_type: str\n    content: str\n\n    async def get_source(self) -> \"Source\":\n        try:\n            src = await repo_query(\n                \"\"\"\n            select source.* from $id fetch source\n            \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return Source(**src[0][\"source\"])\n        except Exception as e:\n            logger.error(f\"Error fetching source for insight {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def save_as_note(self, notebook_id: Optional[str] = None) -> Any:\n        source = await self.get_source()\n        note = Note(\n            title=f\"{self.insight_type} from source {source.title}\",\n            content=self.content,\n        )\n        await note.save()\n        if notebook_id:\n            await note.add_to_notebook(notebook_id)\n        return note\n\n\nclass Source(ObjectModel):\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    table_name: ClassVar[str] = \"source\"\n    asset: Optional[Asset] = None\n    title: Optional[str] = None\n    topics: Optional[List[str]] = Field(default_factory=list)\n    full_text: Optional[str] = None\n    command: Optional[Union[str, RecordID]] = Field(\n        default=None, description=\"Link to surreal-commands processing job\"\n    )\n\n    @field_validator(\"command\", mode=\"before\")\n    @classmethod\n    def parse_command(cls, value):\n        \"\"\"Parse command field to ensure RecordID format\"\"\"\n        if isinstance(value, str) and value:\n            return ensure_record_id(value)\n        return value\n\n    @field_validator(\"id\", mode=\"before\")\n    @classmethod\n    def parse_id(cls, value):\n        \"\"\"Parse id field to handle both string and RecordID inputs\"\"\"\n        if value is None:\n            return None\n        if isinstance(value, RecordID):\n            return str(value)\n        return str(value) if value else None\n\n    async def get_status(self) -> Optional[str]:\n        \"\"\"Get the processing status of the associated command\"\"\"\n        if not self.command:\n            return None\n\n        try:\n            from surreal_commands import get_command_status\n\n            status = await get_command_status(str(self.command))\n            return status.status if status else \"unknown\"\n        except Exception as e:\n            logger.warning(f\"Failed to get command status for {self.command}: {e}\")\n            return \"unknown\"\n\n    async def get_processing_progress(self) -> Optional[Dict[str, Any]]:\n        \"\"\"Get detailed processing information for the associated command\"\"\"\n        if not self.command:\n            return None\n\n        try:\n            from surreal_commands import get_command_status\n\n            status_result = await get_command_status(str(self.command))\n            if not status_result:\n                return None\n\n            # Extract execution metadata if available\n            result = getattr(status_result, \"result\", None)\n            execution_metadata = (\n                result.get(\"execution_metadata\", {}) if isinstance(result, dict) else {}\n            )\n\n            return {\n                \"status\": status_result.status,\n                \"started_at\": execution_metadata.get(\"started_at\"),\n                \"completed_at\": execution_metadata.get(\"completed_at\"),\n                \"error\": getattr(status_result, \"error_message\", None),\n                \"result\": result,\n            }\n        except Exception as e:\n            logger.warning(f\"Failed to get command progress for {self.command}: {e}\")\n            return None\n\n    async def get_context(\n        self, context_size: Literal[\"short\", \"long\"] = \"short\"\n    ) -> Dict[str, Any]:\n        insights_list = await self.get_insights()\n        insights = [insight.model_dump() for insight in insights_list]\n        if context_size == \"long\":\n            return dict(\n                id=self.id,\n                title=self.title,\n                insights=insights,\n                full_text=self.full_text,\n            )\n        else:\n            return dict(id=self.id, title=self.title, insights=insights)\n\n    async def get_embedded_chunks(self) -> int:\n        try:\n            result = await repo_query(\n                \"\"\"\n                select count() as chunks from source_embedding where source=$id GROUP ALL\n                \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            if len(result) == 0:\n                return 0\n            return result[0][\"chunks\"]\n        except Exception as e:\n            logger.error(f\"Error fetching chunks count for source {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(f\"Failed to count chunks for source: {str(e)}\")\n\n    async def get_insights(self) -> List[SourceInsight]:\n        try:\n            result = await repo_query(\n                \"\"\"\n                SELECT * FROM source_insight WHERE source=$id\n                \"\"\",\n                {\"id\": ensure_record_id(self.id)},\n            )\n            return [SourceInsight(**insight) for insight in result]\n        except Exception as e:\n            logger.error(f\"Error fetching insights for source {self.id}: {str(e)}\")\n            logger.exception(e)\n            raise DatabaseOperationError(\"Failed to fetch insights for source\")\n\n    async def add_to_notebook(self, notebook_id: str) -> Any:\n        if not notebook_id:\n            raise InvalidInputError(\"Notebook ID must be provided\")\n        return await self.relate(\"reference\", notebook_id)\n\n    async def vectorize(self) -> str:\n        \"\"\"\n        Submit vectorization as a background job using the embed_source command.\n\n        This method leverages the job-based architecture to prevent HTTP connection\n        pool exhaustion when processing large documents. The embed_source command:\n        1. Detects content type from file path\n        2. Chunks text using content-type aware splitter\n        3. Generates all embeddings in batches\n        4. Bulk inserts source_embedding records\n\n        Returns:\n            str: The command/job ID that can be used to track progress via the commands API\n\n        Raises:\n            ValueError: If source has no text to vectorize\n            DatabaseOperationError: If job submission fails\n        \"\"\"\n        logger.info(f\"Submitting embed_source job for source {self.id}\")\n\n        try:\n            if not self.full_text or not self.full_text.strip():\n                raise ValueError(f\"Source {self.id} has no text to vectorize\")\n\n            # Submit the embed_source command\n            command_id = submit_command(\n                \"open_notebook\",\n                \"embed_source\",\n                {\"source_id\": str(self.id)},\n            )\n\n            command_id_str = str(command_id)\n            logger.info(\n                f\"Embed source job submitted for source {self.id}: \"\n                f\"command_id={command_id_str}\"\n            )\n\n            return command_id_str\n\n        except ValueError:\n            raise\n        except Exception as e:\n            logger.error(\n                f\"Failed to submit embed_source job for source {self.id}: {e}\"\n            )\n            logger.exception(e)\n            raise DatabaseOperationError(e)\n\n    async def add_insight(self, insight_type: str, content: str) -> Optional[str]:\n        \"\"\"\n        Submit insight creation as an async command (fire-and-forget).\n\n        Submits a create_insight command that handles database operations with\n        automatic retry logic for transaction conflicts. The command also submits\n        an embed_insight command for async embedding.\n\n        This method returns immediately after submitting the command - it does NOT\n        wait for the insight to be created. Use this for batch operations where\n        throughput is more important than immediate confirmation.\n\n        Args:\n            insight_type: Type/category of the insight\n            content: The insight content text\n\n        Returns:\n            command_id for optional tracking, or None if submission failed\n\n        Raises:\n            InvalidInputError: If insight_type or content is empty\n        \"\"\"\n        if not insight_type or not content:\n            raise InvalidInputError(\"Insight type and content must be provided\")\n\n        try:\n            # Submit create_insight command (fire-and-forget)\n            # Command handles retries internally for transaction conflicts\n            command_id = submit_command(\n                \"open_notebook\",\n                \"create_insight\",\n                {\n                    \"source_id\": str(self.id),\n                    \"insight_type\": insight_type,\n                    \"content\": content,\n                },\n            )\n            logger.info(\n                f\"Submitted create_insight command {command_id} for source {self.id} \"\n                f\"(type={insight_type})\"\n            )\n            return str(command_id)\n\n        except Exception as e:\n            logger.error(f\"Error submitting create_insight for source {self.id}: {e}\")\n            return None\n\n    def _prepare_save_data(self) -> dict:\n        \"\"\"Override to ensure command field is always RecordID format for database\"\"\"\n        data = super()._prepare_save_data()\n\n        # Ensure command field is RecordID format if not None\n        if data.get(\"command\") is not None:\n            data[\"command\"] = ensure_record_id(data[\"command\"])\n\n        return data\n\n    async def delete(self) -> bool:\n        \"\"\"Delete source and clean up associated file, embeddings, and insights.\"\"\"\n        # Clean up uploaded file if it exists\n        if self.asset and self.asset.file_path:\n            file_path = Path(self.asset.file_path)\n            if file_path.exists():\n                try:\n                    os.unlink(file_path)\n                    logger.info(f\"Deleted file for source {self.id}: {file_path}\")\n                except Exception as e:\n                    logger.warning(\n                        f\"Failed to delete file {file_path} for source {self.id}: {e}. \"\n                        \"Continuing with database deletion.\"\n                    )\n            else:\n                logger.debug(\n                    f\"File {file_path} not found for source {self.id}, skipping cleanup\"\n                )\n\n        # Delete associated embeddings and insights to prevent orphaned records\n        try:\n            source_id = ensure_record_id(self.id)\n            await repo_query(\n                \"DELETE source_embedding WHERE source = $source_id\",\n                {\"source_id\": source_id},\n            )\n            await repo_query(\n                \"DELETE source_insight WHERE source = $source_id\",\n                {\"source_id\": source_id},\n            )\n            logger.debug(f\"Deleted embeddings and insights for source {self.id}\")\n        except Exception as e:\n            logger.warning(\n                f\"Failed to delete embeddings/insights for source {self.id}: {e}. \"\n                \"Continuing with source deletion.\"\n            )\n\n        # Call parent delete to remove database record\n        return await super().delete()\n\n\nclass Note(ObjectModel):\n    table_name: ClassVar[str] = \"note\"\n    title: Optional[str] = None\n    note_type: Optional[Literal[\"human\", \"ai\"]] = None\n    content: Optional[str] = None\n\n    @field_validator(\"content\")\n    @classmethod\n    def content_must_not_be_empty(cls, v):\n        if v is not None and not v.strip():\n            raise InvalidInputError(\"Note content cannot be empty\")\n        return v\n\n    async def save(self) -> Optional[str]:\n        \"\"\"\n        Save the note and submit embedding command.\n\n        Overrides ObjectModel.save() to submit an async embed_note command\n        after saving, instead of inline embedding.\n\n        Returns:\n            Optional[str]: The command_id if embedding was submitted, None otherwise\n        \"\"\"\n        # Call parent save (without embedding)\n        await super().save()\n\n        # Submit embedding command (fire-and-forget) if note has content\n        if self.id and self.content and self.content.strip():\n            command_id = submit_command(\n                \"open_notebook\",\n                \"embed_note\",\n                {\"note_id\": str(self.id)},\n            )\n            logger.debug(f\"Submitted embed_note command {command_id} for {self.id}\")\n            return command_id\n\n        return None\n\n    async def add_to_notebook(self, notebook_id: str) -> Any:\n        if not notebook_id:\n            raise InvalidInputError(\"Notebook ID must be provided\")\n        return await self.relate(\"artifact\", notebook_id)\n\n    def get_context(\n        self, context_size: Literal[\"short\", \"long\"] = \"short\"\n    ) -> Dict[str, Any]:\n        if context_size == \"long\":\n            return dict(id=self.id, title=self.title, content=self.content)\n        else:\n            return dict(\n                id=self.id,\n                title=self.title,\n                content=self.content[:100] if self.content else None,\n            )\n\n\nclass ChatSession(ObjectModel):\n    table_name: ClassVar[str] = \"chat_session\"\n    nullable_fields: ClassVar[set[str]] = {\"model_override\"}\n    title: Optional[str] = None\n    model_override: Optional[str] = None\n\n    async def relate_to_notebook(self, notebook_id: str) -> Any:\n        if not notebook_id:\n            raise InvalidInputError(\"Notebook ID must be provided\")\n        return await self.relate(\"refers_to\", notebook_id)\n\n    async def relate_to_source(self, source_id: str) -> Any:\n        if not source_id:\n            raise InvalidInputError(\"Source ID must be provided\")\n        return await self.relate(\"refers_to\", source_id)\n\n\nasync def text_search(\n    keyword: str, results: int, source: bool = True, note: bool = True\n):\n    if not keyword:\n        raise InvalidInputError(\"Search keyword cannot be empty\")\n    try:\n        search_results = await repo_query(\n            \"\"\"\n            select *\n            from fn::text_search($keyword, $results, $source, $note)\n            \"\"\",\n            {\"keyword\": keyword, \"results\": results, \"source\": source, \"note\": note},\n        )\n        return search_results\n    except Exception as e:\n        logger.error(f\"Error performing text search: {str(e)}\")\n        logger.exception(e)\n        raise DatabaseOperationError(e)\n\n\nasync def vector_search(\n    keyword: str,\n    results: int,\n    source: bool = True,\n    note: bool = True,\n    minimum_score=0.2,\n):\n    if not keyword:\n        raise InvalidInputError(\"Search keyword cannot be empty\")\n    try:\n        from open_notebook.utils.embedding import generate_embedding\n\n        # Use unified embedding function (handles chunking if query is very long)\n        embed = await generate_embedding(keyword)\n        search_results = await repo_query(\n            \"\"\"\n            SELECT * FROM fn::vector_search($embed, $results, $source, $note, $minimum_score);\n            \"\"\",\n            {\n                \"embed\": embed,\n                \"results\": results,\n                \"source\": source,\n                \"note\": note,\n                \"minimum_score\": minimum_score,\n            },\n        )\n        return search_results\n    except Exception as e:\n        logger.error(f\"Error performing vector search: {str(e)}\")\n        logger.exception(e)\n        raise DatabaseOperationError(e)\n"
  },
  {
    "path": "open_notebook/domain/provider_config.py",
    "content": "\"\"\"\nProvider Configuration domain model for storing multiple credentials per provider.\n\nThis module provides the ProviderConfig singleton model that stores multiple\nAPI key configurations per provider. Each ProviderCredential contains a complete\nset of configuration options for a provider (api_key, base_url, model, etc.).\n\nEncryption is enabled when OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable\nis set. If not set, keys are stored as plain text with a warning logged.\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import ClassVar, Dict, List, Optional\n\nfrom pydantic import Field, SecretStr, field_validator\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query, repo_upsert\nfrom open_notebook.domain.base import RecordModel\nfrom open_notebook.utils.encryption import decrypt_value, encrypt_value\n\n\nclass ProviderCredential:\n    \"\"\"\n    A single provider configuration item containing api_key and related settings.\n\n    This class represents one complete configuration for an AI provider.\n    Multiple configurations can exist for the same provider, allowing users\n    to have different credentials for different environments (dev, prod, etc.).\n\n    Attributes:\n        id: Unique identifier for this configuration\n        name: Human-readable name for this configuration\n        provider: Provider name (e.g., \"openai\", \"anthropic\")\n        is_default: Whether this is the default configuration for the provider\n        api_key: The API key (stored as SecretStr for in-memory protection)\n        base_url: Base URL for the provider API\n        model: Default model to use for this provider\n        api_version: API version string (for providers that need it)\n        endpoint: Generic endpoint URL\n        endpoint_llm: Endpoint URL for LLM service\n        endpoint_embedding: Endpoint URL for embedding service\n        endpoint_stt: Endpoint URL for speech-to-text service\n        endpoint_tts: Endpoint URL for text-to-speech service\n        project: Project ID (for Vertex AI)\n        location: Location/region (for Vertex AI)\n        credentials_path: Path to credentials file (for Vertex AI)\n        created: Timestamp when this config was created\n        updated: Timestamp when this config was last updated\n    \"\"\"\n\n    def __init__(\n        self,\n        id: str,\n        name: str,\n        provider: str,\n        is_default: bool = False,\n        api_key: Optional[SecretStr] = None,\n        base_url: Optional[str] = None,\n        model: Optional[str] = None,\n        api_version: Optional[str] = None,\n        endpoint: Optional[str] = None,\n        endpoint_llm: Optional[str] = None,\n        endpoint_embedding: Optional[str] = None,\n        endpoint_stt: Optional[str] = None,\n        endpoint_tts: Optional[str] = None,\n        project: Optional[str] = None,\n        location: Optional[str] = None,\n        credentials_path: Optional[str] = None,\n        created: Optional[str] = None,\n        updated: Optional[str] = None,\n    ):\n        self.id = id\n        self.name = name\n        self.provider = provider\n        self.is_default = is_default\n        self.api_key = api_key\n        self.base_url = base_url\n        self.model = model\n        self.api_version = api_version\n        self.endpoint = endpoint\n        self.endpoint_llm = endpoint_llm\n        self.endpoint_embedding = endpoint_embedding\n        self.endpoint_stt = endpoint_stt\n        self.endpoint_tts = endpoint_tts\n        self.project = project\n        self.location = location\n        self.credentials_path = credentials_path\n        self.created = created or datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        self.updated = updated or datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n    def to_dict(self, encrypted: bool = False) -> dict:\n        \"\"\"\n        Convert the credential to a dictionary for storage.\n\n        Args:\n            encrypted: If True, api_key is encrypted; otherwise it's a SecretStr\n\n        Returns:\n            Dictionary representation of the credential\n        \"\"\"\n        data = {\n            \"id\": self.id,\n            \"name\": self.name,\n            \"provider\": self.provider,\n            \"is_default\": self.is_default,\n            \"base_url\": self.base_url,\n            \"model\": self.model,\n            \"api_version\": self.api_version,\n            \"endpoint\": self.endpoint,\n            \"endpoint_llm\": self.endpoint_llm,\n            \"endpoint_embedding\": self.endpoint_embedding,\n            \"endpoint_stt\": self.endpoint_stt,\n            \"endpoint_tts\": self.endpoint_tts,\n            \"project\": self.project,\n            \"location\": self.location,\n            \"credentials_path\": self.credentials_path,\n            \"created\": self.created,\n            \"updated\": self.updated,\n        }\n\n        if self.api_key:\n            if encrypted:\n                data[\"api_key\"] = encrypt_value(self.api_key.get_secret_value())\n            else:\n                data[\"api_key\"] = self.api_key.get_secret_value()\n\n        return data\n\n    @classmethod\n    def from_dict(cls, data: dict, decrypted: bool = False) -> \"ProviderCredential\":\n        \"\"\"\n        Create a ProviderCredential from a dictionary.\n\n        Args:\n            data: Dictionary containing credential data\n            decrypted: If True, api_key is already decrypted; otherwise wrap in SecretStr\n\n        Returns:\n            ProviderCredential instance\n        \"\"\"\n        api_key = None\n        if \"api_key\" in data and data[\"api_key\"]:\n            if isinstance(data[\"api_key\"], SecretStr):\n                # Already a SecretStr - use as-is\n                api_key = data[\"api_key\"]\n            elif decrypted:\n                # Decrypted string from DB - wrap in SecretStr\n                api_key = SecretStr(data[\"api_key\"])\n            else:\n                # Encrypted string from DB - wrap in SecretStr (will be decrypted later)\n                api_key = SecretStr(data[\"api_key\"])\n\n        return cls(\n            id=data[\"id\"],\n            name=data[\"name\"],\n            provider=data[\"provider\"],\n            is_default=data.get(\"is_default\", False),\n            api_key=api_key,\n            base_url=data.get(\"base_url\"),\n            model=data.get(\"model\"),\n            api_version=data.get(\"api_version\"),\n            endpoint=data.get(\"endpoint\"),\n            endpoint_llm=data.get(\"endpoint_llm\"),\n            endpoint_embedding=data.get(\"endpoint_embedding\"),\n            endpoint_stt=data.get(\"endpoint_stt\"),\n            endpoint_tts=data.get(\"endpoint_tts\"),\n            project=data.get(\"project\"),\n            location=data.get(\"location\"),\n            credentials_path=data.get(\"credentials_path\"),\n            created=data.get(\"created\"),\n            updated=data.get(\"updated\"),\n        )\n\n\nclass ProviderConfig(RecordModel):\n    \"\"\"\n    Singleton configuration for multiple provider credentials.\n\n    Uses RecordModel pattern with a fixed record_id. Stores a dictionary\n    of ProviderCredential objects organized by provider name.\n\n    Usage:\n        config = await ProviderConfig.get_instance()\n        credentials = config.credentials.get(\"openai\", [])\n        default = config.get_default_config(\"openai\")\n    \"\"\"\n\n    record_id: ClassVar[str] = \"open_notebook:provider_configs\"\n\n    # Store credentials organized by provider name\n    # Structure: {\"openai\": [ProviderCredential, ...], \"anthropic\": [...], ...}\n    credentials: Dict[str, List[ProviderCredential]] = Field(\n        default_factory=dict,\n        description=\"Provider credentials organized by provider name\",\n    )\n\n    @classmethod\n    async def get_instance(cls) -> \"ProviderConfig\":\n        \"\"\"\n        Always fetch fresh configuration from database.\n\n        Overrides parent caching behavior to ensure we always get the latest\n        configuration values.\n\n        Returns:\n            ProviderConfig: Fresh instance with current database values\n        \"\"\"\n        result = await repo_query(\n            \"SELECT * FROM ONLY $record_id\",\n            {\"record_id\": ensure_record_id(cls.record_id)},\n        )\n\n        if result:\n            if isinstance(result, list) and len(result) > 0:\n                data = result[0]\n            elif isinstance(result, dict):\n                data = result\n            else:\n                data = {}\n        else:\n            data = {}\n\n        # Initialize credentials from database data\n        credentials: Dict[str, List[ProviderCredential]] = {}\n        creds_data = data.get(\"credentials\")\n        if creds_data and isinstance(creds_data, dict):\n            for provider, provider_creds in creds_data.items():\n                if isinstance(provider_creds, list):\n                    credentials[provider] = []\n                    for cred_data in provider_creds:\n                        try:\n                            # Decrypt api_key if it's a string\n                            api_key_val = cred_data.get(\"api_key\")\n                            if api_key_val and isinstance(api_key_val, str):\n                                decrypted = decrypt_value(api_key_val)\n                                cred_data[\"api_key\"] = SecretStr(decrypted)\n                            else:\n                                # Keep as SecretStr or None\n                                if api_key_val:\n                                    cred_data[\"api_key\"] = SecretStr(api_key_val)\n                                else:\n                                    cred_data[\"api_key\"] = None\n\n                            credentials[provider].append(\n                                ProviderCredential(\n                                    id=cred_data.get(\"id\", \"\"),\n                                    name=cred_data.get(\"name\", \"Default\"),\n                                    provider=cred_data.get(\"provider\", provider),\n                                    is_default=cred_data.get(\"is_default\", False),\n                                    api_key=cred_data.get(\"api_key\"),\n                                    base_url=cred_data.get(\"base_url\"),\n                                    model=cred_data.get(\"model\"),\n                                    api_version=cred_data.get(\"api_version\"),\n                                    endpoint=cred_data.get(\"endpoint\"),\n                                    endpoint_llm=cred_data.get(\"endpoint_llm\"),\n                                    endpoint_embedding=cred_data.get(\n                                        \"endpoint_embedding\"\n                                    ),\n                                    endpoint_stt=cred_data.get(\"endpoint_stt\"),\n                                    endpoint_tts=cred_data.get(\"endpoint_tts\"),\n                                    project=cred_data.get(\"project\"),\n                                    location=cred_data.get(\"location\"),\n                                    credentials_path=cred_data.get(\"credentials_path\"),\n                                    created=cred_data.get(\"created\"),\n                                    updated=cred_data.get(\"updated\"),\n                                )\n                            )\n                        except Exception:\n                            # Skip invalid credentials\n                            continue\n\n        # Create instance using model_validate to properly initialize Pydantic model\n        instance = cls.model_validate({\"credentials\": credentials})\n\n        # Mark as loaded from database\n        object.__setattr__(instance, \"_db_loaded\", True)\n\n        return instance\n\n    def get_default_config(self, provider: str) -> Optional[ProviderCredential]:\n        \"\"\"\n        Get the default configuration for a provider.\n\n        Args:\n            provider: Provider name (e.g., \"openai\", \"anthropic\")\n\n        Returns:\n            The default ProviderCredential, or None if not found\n        \"\"\"\n        provider_lower = provider.lower()\n        credentials = self.credentials.get(provider_lower, [])\n\n        # First, try to find explicitly marked default\n        for cred in credentials:\n            if cred.is_default:\n                return cred\n\n        # If no explicit default, return first config\n        if credentials:\n            return credentials[0]\n\n        return None\n\n    def get_config(\n        self, provider: str, config_id: str\n    ) -> Optional[ProviderCredential]:\n        \"\"\"\n        Get a specific configuration by ID.\n\n        Args:\n            provider: Provider name\n            config_id: Configuration ID\n\n        Returns:\n            The ProviderCredential if found, None otherwise\n        \"\"\"\n        provider_lower = provider.lower()\n        credentials = self.credentials.get(provider_lower, [])\n\n        for cred in credentials:\n            if cred.id == config_id:\n                return cred\n\n        return None\n\n    def add_config(self, provider: str, credential: ProviderCredential) -> None:\n        \"\"\"\n        Add a new configuration for a provider.\n\n        If this is the first config for the provider, it becomes the default.\n        When adding a new config to an existing provider, the new config becomes\n        the default and previous default is unset.\n\n        Args:\n            provider: Provider name (normalized to lowercase)\n            credential: ProviderCredential to add\n        \"\"\"\n        provider_lower = provider.lower()\n        credential.provider = provider_lower\n\n        if provider_lower not in self.credentials:\n            self.credentials[provider_lower] = []\n\n        # When adding a new config to an existing provider, make it the default\n        # and unset the previous default\n        if self.credentials[provider_lower]:\n            for cred in self.credentials[provider_lower]:\n                cred.is_default = False\n            credential.is_default = True\n\n        # If this is the first config, make it default\n        if not self.credentials[provider_lower]:\n            credential.is_default = True\n\n        self.credentials[provider_lower].append(credential)\n\n    def delete_config(self, provider: str, config_id: str) -> bool:\n        \"\"\"\n        Delete a configuration.\n\n        Cannot delete the default configuration unless it's the only one.\n\n        Args:\n            provider: Provider name\n            config_id: Configuration ID to delete\n\n        Returns:\n            True if deleted, False if not found\n        \"\"\"\n        provider_lower = provider.lower()\n        credentials = self.credentials.get(provider_lower, [])\n\n        for i, cred in enumerate(credentials):\n            if cred.id == config_id:\n                # Cannot delete default if there are other configs\n                if cred.is_default and len(credentials) > 1:\n                    return False\n\n                del credentials[i]\n                return True\n\n        return False\n\n    def set_default_config(self, provider: str, config_id: str) -> bool:\n        \"\"\"\n        Set a configuration as the default for a provider.\n\n        Args:\n            provider: Provider name\n            config_id: Configuration ID to make default\n\n        Returns:\n            True if successful, False if config not found\n        \"\"\"\n        provider_lower = provider.lower()\n        credentials = self.credentials.get(provider_lower, [])\n\n        for cred in credentials:\n            if cred.id == config_id:\n                # Unset all other defaults\n                for other in credentials:\n                    other.is_default = False\n\n                # Set this one as default\n                cred.is_default = True\n                cred.updated = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                return True\n\n        return False\n\n    def _prepare_save_data(self) -> dict:\n        \"\"\"\n        Prepare data for database storage.\n\n        SecretStr values are extracted, encrypted, and stored as strings.\n        Encryption is performed using Fernet symmetric encryption if\n        OPEN_NOTEBOOK_ENCRYPTION_KEY is configured.\n        \"\"\"\n        data = {\"credentials\": {}}\n\n        for provider, credentials in self.credentials.items():\n            data[\"credentials\"][provider] = []\n            for cred in credentials:\n                cred.updated = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                data[\"credentials\"][provider].append(cred.to_dict(encrypted=True))\n\n        return data\n\n    async def save(self) -> \"ProviderConfig\":\n        \"\"\"\n        Save the configuration to the database.\n\n        Uses _prepare_save_data() to properly handle SecretStr conversion\n        and encryption.\n        \"\"\"\n        data = self._prepare_save_data()\n        await repo_upsert(\"open_notebook\", self.record_id, data)\n        return self\n\n    @classmethod\n    def _clear_for_test(cls) -> None:\n        \"\"\"Clear the singleton instance for testing purposes.\"\"\"\n        if cls.record_id in cls._instances:\n            del cls._instances[cls.record_id]\n"
  },
  {
    "path": "open_notebook/domain/transformation.py",
    "content": "from typing import ClassVar, Optional\n\nfrom pydantic import Field\n\nfrom open_notebook.domain.base import ObjectModel, RecordModel\n\n\nclass Transformation(ObjectModel):\n    table_name: ClassVar[str] = \"transformation\"\n    name: str\n    title: str\n    description: str\n    prompt: str\n    apply_default: bool\n\n\nclass DefaultPrompts(RecordModel):\n    record_id: ClassVar[str] = \"open_notebook:default_prompts\"\n    transformation_instructions: Optional[str] = Field(\n        None, description=\"Instructions for executing a transformation\"\n    )\n"
  },
  {
    "path": "open_notebook/exceptions.py",
    "content": "class OpenNotebookError(Exception):\n    \"\"\"Base exception class for Open Notebook errors.\"\"\"\n\n    pass\n\n\nclass DatabaseOperationError(OpenNotebookError):\n    \"\"\"Raised when a database operation fails.\"\"\"\n\n    pass\n\n\nclass UnsupportedTypeException(OpenNotebookError):\n    \"\"\"Raised when an unsupported type is provided.\"\"\"\n\n    pass\n\n\nclass InvalidInputError(OpenNotebookError):\n    \"\"\"Raised when invalid input is provided.\"\"\"\n\n    pass\n\n\nclass NotFoundError(OpenNotebookError):\n    \"\"\"Raised when a requested resource is not found.\"\"\"\n\n    pass\n\n\nclass AuthenticationError(OpenNotebookError):\n    \"\"\"Raised when there's an authentication problem.\"\"\"\n\n    pass\n\n\nclass ConfigurationError(OpenNotebookError):\n    \"\"\"Raised when there's a configuration problem.\"\"\"\n\n    pass\n\n\nclass ExternalServiceError(OpenNotebookError):\n    \"\"\"Raised when an external service (e.g., AI model) fails.\"\"\"\n\n    pass\n\n\nclass RateLimitError(OpenNotebookError):\n    \"\"\"Raised when a rate limit is exceeded.\"\"\"\n\n    pass\n\n\nclass FileOperationError(OpenNotebookError):\n    \"\"\"Raised when a file operation fails.\"\"\"\n\n    pass\n\n\nclass NetworkError(OpenNotebookError):\n    \"\"\"Raised when a network operation fails.\"\"\"\n\n    pass\n\n\nclass NoTranscriptFound(OpenNotebookError):\n    \"\"\"Raised when no transcript is found for a video.\"\"\"\n\n    pass\n"
  },
  {
    "path": "open_notebook/graphs/CLAUDE.md",
    "content": "# Graphs Module\n\nLangGraph-based workflow orchestration for content processing, chat interactions, and AI-powered transformations.\n\n## Key Components\n\n- **`chat.py`**: Conversational agent with message history, notebook context, and model override support\n- **`source_chat.py`**: Source-focused chat with ContextBuilder for insights/content injection and context tracking\n- **`ask.py`**: Multi-search strategy agent (generates search terms, retrieves results, synthesizes answers)\n- **`source.py`**: Content ingestion pipeline (extract → save → transform with content-core)\n- **`transformation.py`**: Single-node transformation executor with prompt templating via ai_prompter\n- **`prompt.py`**: Generic pattern chain for arbitrary prompt-based LLM calls\n- **`tools.py`**: Minimal tool library (currently just `get_current_timestamp()`)\n\n## Important Patterns\n\n- **Async/sync bridging in graphs**: Both `chat.py` and `source_chat.py` use `asyncio.new_event_loop()` workaround because LangGraph nodes are sync but `provision_langchain_model()` is async\n- **State machines via StateGraph**: Each graph compiles to stateful runnable; conditional edges fan out work (ask.py, source.py do parallel transforms)\n- **Prompt templating**: `ai_prompter.Prompter` with Jinja2 templates referenced by path (\"chat/system\", \"ask/entry\", etc.)\n- **Model provisioning via context**: Config dict passed to node via `RunnableConfig`; defaults fall back to state overrides\n- **Checkpointing**: `chat.py` and `source_chat.py` use SqliteSaver for message history (LangGraph's built-in persistence)\n- **Content extraction**: `source.py` uses content-core library with provider/model from DefaultModels; URLs and files both supported\n\n## Error Handling in Graphs\n\nAll graph nodes use `classify_error()` from `open_notebook.utils.error_classifier` to catch raw LLM provider exceptions and re-raise them as typed `OpenNotebookError` subclasses with user-friendly messages. This ensures that errors from any AI provider (authentication failures, rate limits, model not found, network issues) are surfaced to the user with actionable messages instead of opaque stack traces.\n\n**Pattern in nodes**:\n```python\nfrom open_notebook.utils.error_classifier import classify_error\n\ntry:\n    result = await model.ainvoke(...)\nexcept Exception as e:\n    exc_class, message = classify_error(e)\n    raise exc_class(message) from e\n```\n\n---\n\n## Quirks & Edge Cases\n\n- **Async loop gymnastics**: ThreadPoolExecutor workaround needed because LangGraph invokes sync nodes but we call async functions; fragile if event loop state changes\n- **`clean_thinking_content()` ubiquitous**: Strips `<think>...</think>` tags from model responses (handles extended thinking models)\n- **source_chat.py builds context twice**: ContextBuilder runs during node execution to fetch source/insights; rebuilds list from context_data (inefficient but safe)\n- **source.py embedding is async**: `source.vectorize()` returns job command ID; not awaited (fire-and-forget)\n- **transformation.py nullable source**: Accepts `input_text` or `source.full_text` (falls back to second if first missing)\n- **ask.py hard-coded vector_search**: No fallback to text search despite commented code suggesting it was planned\n- **SqliteSaver location**: Checkpoints stored in path from `LANGGRAPH_CHECKPOINT_FILE` env var; connection shared across graphs\n\n## Key Dependencies\n\n- `langgraph`: StateGraph, Send, END, START, SqliteSaver checkpoint persistence\n- `langchain_core`: Messages, OutputParser, RunnableConfig\n- `ai_prompter`: Prompter for Jinja2 template rendering\n- `content_core`: `extract_content()` for file/URL processing\n- `open_notebook.ai.provision`: `provision_langchain_model()` (async factory with fallback logic)\n- `open_notebook.utils.error_classifier`: `classify_error()` for user-friendly LLM error messages\n- `open_notebook.domain.notebook`: Domain models (Source, Note, SourceInsight, vector_search)\n- `loguru`: Logging\n\n## Usage Example\n\n```python\n# Invoke a graph with config override\nconfig = {\"configurable\": {\"model_id\": \"model:custom_id\"}}\nresult = await chat_graph.ainvoke(\n    {\"messages\": [HumanMessage(content=\"...\")], \"notebook\": notebook},\n    config=config\n)\n\n# Source processing (content → save → transform)\nresult = await source_graph.ainvoke({\n    \"content_state\": {...},  # ProcessSourceState from content-core\n    \"apply_transformations\": [t1, t2],\n    \"source_id\": \"source:123\",\n    \"embed\": True\n})\n```\n"
  },
  {
    "path": "open_notebook/graphs/ask.py",
    "content": "import operator\nfrom typing import Annotated, List\n\nfrom ai_prompter import Prompter\nfrom langchain_core.output_parsers.pydantic import PydanticOutputParser\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.types import Send\nfrom pydantic import BaseModel, Field\nfrom typing_extensions import TypedDict\n\nfrom open_notebook.ai.provision import provision_langchain_model\nfrom open_notebook.domain.notebook import vector_search\nfrom open_notebook.exceptions import OpenNotebookError\nfrom open_notebook.utils import clean_thinking_content\nfrom open_notebook.utils.error_classifier import classify_error\nfrom open_notebook.utils.text_utils import extract_text_content\n\n\nclass SubGraphState(TypedDict):\n    question: str\n    term: str\n    instructions: str\n    results: dict\n    answer: str\n    ids: list  # Added for provide_answer function\n\n\nclass Search(BaseModel):\n    term: str\n    instructions: str = Field(\n        description=\"Tell the answeting LLM what information you need extracted from this search\"\n    )\n\n\nclass Strategy(BaseModel):\n    reasoning: str\n    searches: List[Search] = Field(\n        default_factory=list,\n        description=\"You can add up to five searches to this strategy\",\n    )\n\n\nclass ThreadState(TypedDict):\n    question: str\n    strategy: Strategy\n    answers: Annotated[list, operator.add]\n    final_answer: str\n\n\nasync def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:\n    try:\n        parser = PydanticOutputParser(pydantic_object=Strategy)\n        system_prompt = Prompter(prompt_template=\"ask/entry\", parser=parser).render(  # type: ignore[arg-type]\n            data=state  # type: ignore[arg-type]\n        )\n        model = await provision_langchain_model(\n            system_prompt,\n            config.get(\"configurable\", {}).get(\"strategy_model\"),\n            \"tools\",\n            max_tokens=2000,\n            structured=dict(type=\"json\"),\n        )\n        # model = model.bind_tools(tools)\n        # First get the raw response from the model\n        ai_message = await model.ainvoke(system_prompt)\n\n        # Clean the thinking content from the response\n        message_content = extract_text_content(ai_message.content)\n        cleaned_content = clean_thinking_content(message_content)\n\n        # Parse the cleaned JSON content\n        strategy = parser.parse(cleaned_content)\n\n        return {\"strategy\": strategy}\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\nasync def trigger_queries(state: ThreadState, config: RunnableConfig):\n    return [\n        Send(\n            \"provide_answer\",\n            {\n                \"question\": state[\"question\"],\n                \"instructions\": s.instructions,\n                \"term\": s.term,\n                # \"type\": s.type,\n            },\n        )\n        for s in state[\"strategy\"].searches\n    ]\n\n\nasync def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:\n    try:\n        payload = state\n        # if state[\"type\"] == \"text\":\n        #     results = text_search(state[\"term\"], 10, True, True)\n        # else:\n        results = await vector_search(state[\"term\"], 10, True, True)\n        if len(results) == 0:\n            return {\"answers\": []}\n        payload[\"results\"] = results\n        ids = [r[\"id\"] for r in results]\n        payload[\"ids\"] = ids\n        system_prompt = Prompter(prompt_template=\"ask/query_process\").render(data=payload)  # type: ignore[arg-type]\n        model = await provision_langchain_model(\n            system_prompt,\n            config.get(\"configurable\", {}).get(\"answer_model\"),\n            \"tools\",\n            max_tokens=2000,\n        )\n        ai_message = await model.ainvoke(system_prompt)\n        ai_content = extract_text_content(ai_message.content)\n        return {\"answers\": [clean_thinking_content(ai_content)]}\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\nasync def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict:\n    try:\n        system_prompt = Prompter(prompt_template=\"ask/final_answer\").render(data=state)  # type: ignore[arg-type]\n        model = await provision_langchain_model(\n            system_prompt,\n            config.get(\"configurable\", {}).get(\"final_answer_model\"),\n            \"tools\",\n            max_tokens=2000,\n        )\n        ai_message = await model.ainvoke(system_prompt)\n        final_content = extract_text_content(ai_message.content)\n        return {\"final_answer\": clean_thinking_content(final_content)}\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\nagent_state = StateGraph(ThreadState)\nagent_state.add_node(\"agent\", call_model_with_messages)\nagent_state.add_node(\"provide_answer\", provide_answer)\nagent_state.add_node(\"write_final_answer\", write_final_answer)\nagent_state.add_edge(START, \"agent\")\nagent_state.add_conditional_edges(\"agent\", trigger_queries, [\"provide_answer\"])\nagent_state.add_edge(\"provide_answer\", \"write_final_answer\")\nagent_state.add_edge(\"write_final_answer\", END)\n\ngraph = agent_state.compile()\n"
  },
  {
    "path": "open_notebook/graphs/chat.py",
    "content": "import asyncio\nimport sqlite3\nfrom typing import Annotated, Optional\n\nfrom ai_prompter import Prompter\nfrom langchain_core.messages import AIMessage, SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.checkpoint.sqlite import SqliteSaver\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.graph.message import add_messages\nfrom typing_extensions import TypedDict\n\nfrom open_notebook.ai.provision import provision_langchain_model\nfrom open_notebook.config import LANGGRAPH_CHECKPOINT_FILE\nfrom open_notebook.domain.notebook import Notebook\nfrom open_notebook.exceptions import OpenNotebookError\nfrom open_notebook.utils import clean_thinking_content\nfrom open_notebook.utils.error_classifier import classify_error\nfrom open_notebook.utils.text_utils import extract_text_content\n\n\nclass ThreadState(TypedDict):\n    messages: Annotated[list, add_messages]\n    notebook: Optional[Notebook]\n    context: Optional[str]\n    context_config: Optional[dict]\n    model_override: Optional[str]\n\n\ndef call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:\n    try:\n        system_prompt = Prompter(prompt_template=\"chat/system\").render(data=state)  # type: ignore[arg-type]\n        payload = [SystemMessage(content=system_prompt)] + state.get(\"messages\", [])\n        model_id = config.get(\"configurable\", {}).get(\"model_id\") or state.get(\n            \"model_override\"\n        )\n\n        # Handle async model provisioning from sync context\n        def run_in_new_loop():\n            \"\"\"Run the async function in a new event loop\"\"\"\n            new_loop = asyncio.new_event_loop()\n            try:\n                asyncio.set_event_loop(new_loop)\n                return new_loop.run_until_complete(\n                    provision_langchain_model(\n                        str(payload), model_id, \"chat\", max_tokens=8192\n                    )\n                )\n            finally:\n                new_loop.close()\n                asyncio.set_event_loop(None)\n\n        try:\n            # Try to get the current event loop\n            asyncio.get_running_loop()\n            # If we're in an event loop, run in a thread with a new loop\n            import concurrent.futures\n\n            with concurrent.futures.ThreadPoolExecutor() as executor:\n                future = executor.submit(run_in_new_loop)\n                model = future.result()\n        except RuntimeError:\n            # No event loop running, safe to use asyncio.run()\n            model = asyncio.run(\n                provision_langchain_model(\n                    str(payload),\n                    model_id,\n                    \"chat\",\n                    max_tokens=8192,\n                )\n            )\n\n        ai_message = model.invoke(payload)\n\n        # Clean thinking content from AI response (e.g., <think>...</think> tags)\n        content = extract_text_content(ai_message.content)\n        cleaned_content = clean_thinking_content(content)\n        cleaned_message = ai_message.model_copy(update={\"content\": cleaned_content})\n\n        return {\"messages\": cleaned_message}\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\nconn = sqlite3.connect(\n    LANGGRAPH_CHECKPOINT_FILE,\n    check_same_thread=False,\n)\nmemory = SqliteSaver(conn)\n\nagent_state = StateGraph(ThreadState)\nagent_state.add_node(\"agent\", call_model_with_messages)\nagent_state.add_edge(START, \"agent\")\nagent_state.add_edge(\"agent\", END)\ngraph = agent_state.compile(checkpointer=memory)\n"
  },
  {
    "path": "open_notebook/graphs/prompt.py",
    "content": "from typing import Any, Optional\n\nfrom ai_prompter import Prompter\nfrom langchain_core.messages import HumanMessage, SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import END, START, StateGraph\nfrom typing_extensions import TypedDict\n\nfrom open_notebook.ai.provision import provision_langchain_model\nfrom open_notebook.utils.text_utils import clean_thinking_content, extract_text_content\n\n\nclass PatternChainState(TypedDict):\n    prompt: str\n    parser: Optional[Any]\n    input_text: str\n    output: str\n\n\nasync def call_model(state: dict, config: RunnableConfig) -> dict:\n    content = state[\"input_text\"]\n    system_prompt = Prompter(\n        template_text=state[\"prompt\"], parser=state.get(\"parser\")\n    ).render(data=state)\n    payload = [SystemMessage(content=system_prompt)] + [HumanMessage(content=content)]\n    chain = await provision_langchain_model(\n        str(payload),\n        config.get(\"configurable\", {}).get(\"model_id\"),\n        \"transformation\",\n        max_tokens=5000,\n    )\n\n    response = await chain.ainvoke(payload)\n\n    # Clean thinking tags from response (handles extended thinking models)\n    output = clean_thinking_content(extract_text_content(response.content))\n    return {\"output\": output}\n\n\nagent_state = StateGraph(PatternChainState)\nagent_state.add_node(\"agent\", call_model)  # type: ignore[type-var]\nagent_state.add_edge(START, \"agent\")\nagent_state.add_edge(\"agent\", END)\n\ngraph = agent_state.compile()\n"
  },
  {
    "path": "open_notebook/graphs/source.py",
    "content": "import operator\nfrom typing import Any, Dict, List, Optional\n\nfrom content_core import extract_content\nfrom content_core.common import ProcessSourceState\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.types import Send\nfrom loguru import logger\nfrom typing_extensions import Annotated, TypedDict\n\nfrom open_notebook.ai.models import Model, ModelManager\nfrom open_notebook.domain.content_settings import ContentSettings\nfrom open_notebook.domain.notebook import Asset, Source\nfrom open_notebook.domain.transformation import Transformation\nfrom open_notebook.graphs.transformation import graph as transform_graph\n\n\nclass SourceState(TypedDict):\n    content_state: ProcessSourceState\n    apply_transformations: List[Transformation]\n    source_id: str\n    notebook_ids: List[str]\n    source: Source\n    transformation: Annotated[list, operator.add]\n    embed: bool\n\n\nclass TransformationState(TypedDict):\n    source: Source\n    transformation: Transformation\n\n\nasync def content_process(state: SourceState) -> dict:\n    content_settings = ContentSettings(\n        default_content_processing_engine_doc=\"auto\",\n        default_content_processing_engine_url=\"auto\",\n        default_embedding_option=\"ask\",\n        auto_delete_files=\"yes\",\n        youtube_preferred_languages=[\n            \"en\",\n            \"pt\",\n            \"es\",\n            \"de\",\n            \"nl\",\n            \"en-GB\",\n            \"fr\",\n            \"hi\",\n            \"ja\",\n        ],\n    )\n    content_state: Dict[str, Any] = state[\"content_state\"]  # type: ignore[assignment]\n\n    content_state[\"url_engine\"] = (\n        content_settings.default_content_processing_engine_url or \"auto\"\n    )\n    content_state[\"document_engine\"] = (\n        content_settings.default_content_processing_engine_doc or \"auto\"\n    )\n    content_state[\"output_format\"] = \"markdown\"\n\n    # Add speech-to-text model configuration from Default Models\n    try:\n        model_manager = ModelManager()\n        defaults = await model_manager.get_defaults()\n        if defaults.default_speech_to_text_model:\n            stt_model = await Model.get(defaults.default_speech_to_text_model)\n            if stt_model:\n                content_state[\"audio_provider\"] = stt_model.provider\n                content_state[\"audio_model\"] = stt_model.name\n                logger.debug(\n                    f\"Using speech-to-text model: {stt_model.provider}/{stt_model.name}\"\n                )\n    except Exception as e:\n        logger.warning(f\"Failed to retrieve speech-to-text model configuration: {e}\")\n        # Continue without custom audio model (content-core will use its default)\n\n    processed_state = await extract_content(content_state)\n\n    if not processed_state.content or not processed_state.content.strip():\n        url = processed_state.url or \"\"\n        if url and (\"youtube.com\" in url or \"youtu.be\" in url):\n            raise ValueError(\n                \"Could not extract content from this YouTube video. \"\n                \"No transcript or subtitles are available. \"\n                \"Try configuring a Speech-to-Text model in Settings \"\n                \"to transcribe the audio instead.\"\n            )\n        raise ValueError(\n            \"Could not extract any text content from this source. \"\n            \"The content may be empty, inaccessible, or in an unsupported format.\"\n        )\n\n    return {\"content_state\": processed_state}\n\n\nasync def save_source(state: SourceState) -> dict:\n    content_state = state[\"content_state\"]\n\n    # Get existing source using the provided source_id\n    source = await Source.get(state[\"source_id\"])\n    if not source:\n        raise ValueError(f\"Source with ID {state['source_id']} not found\")\n\n    # Update the source with processed content\n    source.asset = Asset(url=content_state.url, file_path=content_state.file_path)\n    source.full_text = content_state.content\n\n    # Preserve existing title if none provided in processed content\n    if content_state.title:\n        source.title = content_state.title\n\n    await source.save()\n\n    # NOTE: Notebook associations are created by the API immediately for UI responsiveness\n    # No need to create them here to avoid duplicate edges\n\n    if state[\"embed\"]:\n        if source.full_text and source.full_text.strip():\n            logger.debug(\"Embedding content for vector search\")\n            await source.vectorize()\n        else:\n            logger.warning(\n                f\"Source {source.id} has no text content to embed, skipping vectorization\"\n            )\n\n    return {\"source\": source}\n\n\ndef trigger_transformations(state: SourceState, config: RunnableConfig) -> List[Send]:\n    if len(state[\"apply_transformations\"]) == 0:\n        return []\n\n    to_apply = state[\"apply_transformations\"]\n    logger.debug(f\"Applying transformations {to_apply}\")\n\n    return [\n        Send(\n            \"transform_content\",\n            {\n                \"source\": state[\"source\"],\n                \"transformation\": t,\n            },\n        )\n        for t in to_apply\n    ]\n\n\nasync def transform_content(state: TransformationState) -> Optional[dict]:\n    source = state[\"source\"]\n    content = source.full_text\n    if not content:\n        return None\n    transformation: Transformation = state[\"transformation\"]\n\n    logger.debug(f\"Applying transformation {transformation.name}\")\n    result = await transform_graph.ainvoke(\n        dict(input_text=content, transformation=transformation)  # type: ignore[arg-type]\n    )\n    await source.add_insight(transformation.title, result[\"output\"])\n    return {\n        \"transformation\": [\n            {\n                \"output\": result[\"output\"],\n                \"transformation_name\": transformation.name,\n            }\n        ]\n    }\n\n\n# Create and compile the workflow\nworkflow = StateGraph(SourceState)\n\n# Add nodes\nworkflow.add_node(\"content_process\", content_process)\nworkflow.add_node(\"save_source\", save_source)\nworkflow.add_node(\"transform_content\", transform_content)\n# Define the graph edges\nworkflow.add_edge(START, \"content_process\")\nworkflow.add_edge(\"content_process\", \"save_source\")\nworkflow.add_conditional_edges(\n    \"save_source\", trigger_transformations, [\"transform_content\"]\n)\nworkflow.add_edge(\"transform_content\", END)\n\n# Compile the graph\nsource_graph = workflow.compile()\n"
  },
  {
    "path": "open_notebook/graphs/source_chat.py",
    "content": "import asyncio\nimport sqlite3\nfrom typing import Annotated, Dict, List, Optional\n\nfrom ai_prompter import Prompter\nfrom langchain_core.messages import AIMessage, SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.checkpoint.sqlite import SqliteSaver\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.graph.message import add_messages\nfrom typing_extensions import TypedDict\n\nfrom open_notebook.ai.provision import provision_langchain_model\nfrom open_notebook.config import LANGGRAPH_CHECKPOINT_FILE\nfrom open_notebook.domain.notebook import Source, SourceInsight\nfrom open_notebook.exceptions import OpenNotebookError\nfrom open_notebook.utils import clean_thinking_content\nfrom open_notebook.utils.context_builder import ContextBuilder\nfrom open_notebook.utils.error_classifier import classify_error\nfrom open_notebook.utils.text_utils import extract_text_content\n\n\nclass SourceChatState(TypedDict):\n    messages: Annotated[list, add_messages]\n    source_id: str\n    source: Optional[Source]\n    insights: Optional[List[SourceInsight]]\n    context: Optional[str]\n    model_override: Optional[str]\n    context_indicators: Optional[Dict[str, List[str]]]\n\n\ndef call_model_with_source_context(\n    state: SourceChatState, config: RunnableConfig\n) -> dict:\n    \"\"\"\n    Main function that builds source context and calls the model.\n\n    This function:\n    1. Uses ContextBuilder to build source-specific context\n    2. Applies the source_chat Jinja2 prompt template\n    3. Handles model provisioning with override support\n    4. Tracks context indicators for referenced insights/content\n    \"\"\"\n    try:\n        return _call_model_with_source_context_inner(state, config)\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\ndef _call_model_with_source_context_inner(\n    state: SourceChatState, config: RunnableConfig\n) -> dict:\n    source_id = state.get(\"source_id\")\n    if not source_id:\n        raise ValueError(\"source_id is required in state\")\n\n    # Build source context using ContextBuilder (run async code in new loop)\n    def build_context():\n        \"\"\"Build context in a new event loop\"\"\"\n        new_loop = asyncio.new_event_loop()\n        try:\n            asyncio.set_event_loop(new_loop)\n            context_builder = ContextBuilder(\n                source_id=source_id,\n                include_insights=True,\n                include_notes=False,  # Focus on source-specific content\n                max_tokens=50000,  # Reasonable limit for source context\n            )\n            return new_loop.run_until_complete(context_builder.build())\n        finally:\n            new_loop.close()\n            asyncio.set_event_loop(None)\n\n    # Get the built context\n    try:\n        # Try to get the current event loop\n        asyncio.get_running_loop()\n        # If we're in an event loop, run in a thread with a new loop\n        import concurrent.futures\n\n        with concurrent.futures.ThreadPoolExecutor() as executor:\n            future = executor.submit(build_context)\n            context_data = future.result()\n    except RuntimeError:\n        # No event loop running, safe to create a new one\n        context_data = build_context()\n\n    # Extract source and insights from context\n    source = None\n    insights = []\n    context_indicators: dict[str, list[str | None]] = {\n        \"sources\": [],\n        \"insights\": [],\n        \"notes\": [],\n    }\n\n    if context_data.get(\"sources\"):\n        source_info = context_data[\"sources\"][0]  # First source\n        source = Source(**source_info) if isinstance(source_info, dict) else source_info\n        context_indicators[\"sources\"].append(source.id)\n\n    if context_data.get(\"insights\"):\n        for insight_data in context_data[\"insights\"]:\n            insight = (\n                SourceInsight(**insight_data)\n                if isinstance(insight_data, dict)\n                else insight_data\n            )\n            insights.append(insight)\n            context_indicators[\"insights\"].append(insight.id)\n\n    # Format context for the prompt\n    formatted_context = _format_source_context(context_data)\n\n    # Build prompt data for the template\n    prompt_data = {\n        \"source\": source.model_dump() if source else None,\n        \"insights\": [insight.model_dump() for insight in insights] if insights else [],\n        \"context\": formatted_context,\n        \"context_indicators\": context_indicators,\n    }\n\n    # Apply the source_chat prompt template\n    system_prompt = Prompter(prompt_template=\"source_chat/system\").render(\n        data=prompt_data\n    )\n    payload = [SystemMessage(content=system_prompt)] + state.get(\"messages\", [])\n\n    # Handle async model provisioning from sync context\n    def run_in_new_loop():\n        \"\"\"Run the async function in a new event loop\"\"\"\n        new_loop = asyncio.new_event_loop()\n        try:\n            asyncio.set_event_loop(new_loop)\n            return new_loop.run_until_complete(\n                provision_langchain_model(\n                    str(payload),\n                    config.get(\"configurable\", {}).get(\"model_id\")\n                    or state.get(\"model_override\"),\n                    \"chat\",\n                    max_tokens=8192,\n                )\n            )\n        finally:\n            new_loop.close()\n            asyncio.set_event_loop(None)\n\n    try:\n        # Try to get the current event loop\n        asyncio.get_running_loop()\n        # If we're in an event loop, run in a thread with a new loop\n        import concurrent.futures\n\n        with concurrent.futures.ThreadPoolExecutor() as executor:\n            future = executor.submit(run_in_new_loop)\n            model = future.result()\n    except RuntimeError:\n        # No event loop running, safe to use asyncio.run()\n        model = asyncio.run(\n            provision_langchain_model(\n                str(payload),\n                config.get(\"configurable\", {}).get(\"model_id\")\n                or state.get(\"model_override\"),\n                \"chat\",\n                max_tokens=8192,\n            )\n        )\n\n    ai_message = model.invoke(payload)\n\n    # Clean thinking content from AI response (e.g., <think>...</think> tags)\n    content = extract_text_content(ai_message.content)\n    cleaned_content = clean_thinking_content(content)\n    cleaned_message = ai_message.model_copy(update={\"content\": cleaned_content})\n\n    # Update state with context information\n    return {\n        \"messages\": cleaned_message,\n        \"source\": source,\n        \"insights\": insights,\n        \"context\": formatted_context,\n        \"context_indicators\": context_indicators,\n    }\n\n\ndef _format_source_context(context_data: Dict) -> str:\n    \"\"\"\n    Format the context data into a readable string for the prompt.\n\n    Args:\n        context_data: Context data from ContextBuilder\n\n    Returns:\n        Formatted context string\n    \"\"\"\n    context_parts = []\n\n    # Add source information\n    if context_data.get(\"sources\"):\n        context_parts.append(\"## SOURCE CONTENT\")\n        for source in context_data[\"sources\"]:\n            if isinstance(source, dict):\n                context_parts.append(f\"**Source ID:** {source.get('id', 'Unknown')}\")\n                context_parts.append(f\"**Title:** {source.get('title', 'No title')}\")\n                if source.get(\"full_text\"):\n                    # Truncate full text if too long\n                    full_text = source[\"full_text\"]\n                    if len(full_text) > 5000:\n                        full_text = full_text[:5000] + \"...\\n[Content truncated]\"\n                    context_parts.append(f\"**Content:**\\n{full_text}\")\n                context_parts.append(\"\")  # Empty line for separation\n\n    # Add insights\n    if context_data.get(\"insights\"):\n        context_parts.append(\"## SOURCE INSIGHTS\")\n        for insight in context_data[\"insights\"]:\n            if isinstance(insight, dict):\n                context_parts.append(f\"**Insight ID:** {insight.get('id', 'Unknown')}\")\n                context_parts.append(\n                    f\"**Type:** {insight.get('insight_type', 'Unknown')}\"\n                )\n                context_parts.append(\n                    f\"**Content:** {insight.get('content', 'No content')}\"\n                )\n                context_parts.append(\"\")  # Empty line for separation\n\n    # Add metadata\n    if context_data.get(\"metadata\"):\n        metadata = context_data[\"metadata\"]\n        context_parts.append(\"## CONTEXT METADATA\")\n        context_parts.append(f\"- Source count: {metadata.get('source_count', 0)}\")\n        context_parts.append(f\"- Insight count: {metadata.get('insight_count', 0)}\")\n        context_parts.append(f\"- Total tokens: {context_data.get('total_tokens', 0)}\")\n        context_parts.append(\"\")\n\n    return \"\\n\".join(context_parts)\n\n\n# Create SQLite checkpointer\nconn = sqlite3.connect(\n    LANGGRAPH_CHECKPOINT_FILE,\n    check_same_thread=False,\n)\nmemory = SqliteSaver(conn)\n\n# Create the StateGraph\nsource_chat_state = StateGraph(SourceChatState)\nsource_chat_state.add_node(\"source_chat_agent\", call_model_with_source_context)\nsource_chat_state.add_edge(START, \"source_chat_agent\")\nsource_chat_state.add_edge(\"source_chat_agent\", END)\nsource_chat_graph = source_chat_state.compile(checkpointer=memory)\n"
  },
  {
    "path": "open_notebook/graphs/tools.py",
    "content": "from datetime import datetime\n\nfrom langchain.tools import tool\n\n\n# todo: turn this into a system prompt variable\n@tool\ndef get_current_timestamp() -> str:\n    \"\"\"\n    name: get_current_timestamp\n    Returns the current timestamp in the format YYYYMMDDHHmmss.\n    \"\"\"\n    return datetime.now().strftime(\"%Y%m%d%H%M%S\")\n"
  },
  {
    "path": "open_notebook/graphs/transformation.py",
    "content": "from ai_prompter import Prompter\nfrom langchain_core.messages import HumanMessage, SystemMessage\nfrom langchain_core.runnables import RunnableConfig\nfrom langgraph.graph import END, START, StateGraph\nfrom typing_extensions import TypedDict\n\nfrom open_notebook.ai.provision import provision_langchain_model\nfrom open_notebook.domain.notebook import Source\nfrom open_notebook.domain.transformation import DefaultPrompts, Transformation\nfrom open_notebook.exceptions import OpenNotebookError\nfrom open_notebook.utils import clean_thinking_content\nfrom open_notebook.utils.error_classifier import classify_error\nfrom open_notebook.utils.text_utils import extract_text_content\n\n\nclass TransformationState(TypedDict):\n    input_text: str\n    source: Source\n    transformation: Transformation\n    output: str\n\n\nasync def run_transformation(state: dict, config: RunnableConfig) -> dict:\n    source_obj = state.get(\"source\")\n    source: Source = source_obj if isinstance(source_obj, Source) else None  # type: ignore[assignment]\n    content = state.get(\"input_text\")\n    assert source or content, \"No content to transform\"\n    transformation: Transformation = state[\"transformation\"]\n\n    try:\n        if not content:\n            content = source.full_text\n        transformation_template_text = transformation.prompt\n        default_prompts: DefaultPrompts = DefaultPrompts(transformation_instructions=None)\n        if default_prompts.transformation_instructions:\n            transformation_template_text = f\"{default_prompts.transformation_instructions}\\n\\n{transformation_template_text}\"\n\n        transformation_template_text = f\"{transformation_template_text}\\n\\n# INPUT\"\n\n        system_prompt = Prompter(template_text=transformation_template_text).render(\n            data=state\n        )\n        content_str = str(content) if content else \"\"\n        payload = [SystemMessage(content=system_prompt), HumanMessage(content=content_str)]\n        chain = await provision_langchain_model(\n            str(payload),\n            config.get(\"configurable\", {}).get(\"model_id\"),\n            \"transformation\",\n            max_tokens=8192,\n        )\n\n        response = await chain.ainvoke(payload)\n\n        # Clean thinking content from the response\n        response_content = extract_text_content(response.content)\n        cleaned_content = clean_thinking_content(response_content)\n\n        if source:\n            await source.add_insight(transformation.title, cleaned_content)\n\n        return {\n            \"output\": cleaned_content,\n        }\n    except OpenNotebookError:\n        raise\n    except Exception as e:\n        error_class, user_message = classify_error(e)\n        raise error_class(user_message) from e\n\n\nagent_state = StateGraph(TransformationState)\nagent_state.add_node(\"agent\", run_transformation)  # type: ignore[type-var]\nagent_state.add_edge(START, \"agent\")\nagent_state.add_edge(\"agent\", END)\ngraph = agent_state.compile()\n"
  },
  {
    "path": "open_notebook/podcasts/CLAUDE.md",
    "content": "# Podcasts Module\n\nDomain models for podcast generation featuring speaker and episode profile management with job tracking.\n\n## Purpose\n\nEncapsulates podcast metadata and configuration: speaker profiles (voice/personality config), episode profiles (generation settings), and podcast episodes (with job status tracking via surreal-commands).\n\n## Architecture Overview\n\nTwo-tier profile system using the **model registry** for AI model references:\n- **SpeakerProfile**: `voice_model` (record<model> reference) + 1-4 speaker configurations (name, voice_id, backstory, personality). Per-speaker `voice_model` overrides supported.\n- **EpisodeProfile**: `outline_llm`/`transcript_llm` (record<model> references) for LLM selection, `language` field (BCP 47 locale code), segment count, briefing template.\n- **PodcastEpisode**: Generated episode record linking profiles, content, and async job.\n\nAll inherit from `ObjectModel` (SurrealDB base class with table_name and save/load).\n\n## Component Catalog\n\n### models.py\n\n#### `_resolve_model_config(model_id)` (module-level helper)\n- Loads a Model record by ID, resolves its credential, returns `(provider, model_name, config_dict)` tuple.\n- Used by `resolve_outline_config()`, `resolve_transcript_config()`, `resolve_tts_config()`, and per-speaker TTS overrides in `podcast_commands.py`.\n- Falls back to `provision_provider_keys()` if no credential is linked.\n\n#### SpeakerProfile\n- `voice_model`: Optional `record<model>` reference for TTS (replaces legacy `tts_provider`/`tts_model` strings).\n- Legacy fields `tts_provider`/`tts_model` kept as optional for migration compatibility.\n- `nullable_fields` ClassVar lists fields that may be null in the database.\n- Validates 1-4 speakers with required fields: name, voice_id, backstory, personality.\n- Per-speaker `voice_model` override: individual speakers can reference a different TTS model.\n- `_prepare_save_data()` converts `voice_model` (and per-speaker overrides) to RecordID before save.\n- `resolve_tts_config()` resolves `voice_model` via `_resolve_model_config()`. Raises ValueError if not set.\n- `get_by_name()` async query by profile name.\n\n#### EpisodeProfile\n- `outline_llm`/`transcript_llm`: Optional `record<model>` references (replace legacy `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` strings).\n- `language`: Optional BCP 47 locale code for podcast language (e.g. `pt-BR`, `en-US`).\n- Legacy fields kept as optional for migration compatibility.\n- `nullable_fields` ClassVar lists fields that may be null in the database.\n- `num_segments` validated between 3 and 20.\n- References `speaker_config` by name.\n- `_prepare_save_data()` converts `outline_llm`/`transcript_llm` to RecordID before save.\n- `resolve_outline_config()` / `resolve_transcript_config()` resolve model references via `_resolve_model_config()`. Raise ValueError if not set.\n- `get_by_name()` async query.\n\n#### PodcastEpisode\n- Stores episode_profile and speaker_profile as dicts (snapshots of config at generation time).\n- Optional audio_file path, transcript/outline dicts.\n- **Job tracking**: command field links to surreal-commands RecordID.\n- `get_job_status()` fetches async job status via surreal-commands library.\n- `get_job_detail()` returns both status and error_message from the job (used for retry validation and UI error display).\n- `_prepare_save_data()` ensures command field is always RecordID format for database.\n\n### migration.py\n\nData migration for podcast profiles: maps legacy provider/model strings to Model registry record IDs. Runs on API startup after SQL migrations (called from `api/main.py` lifespan).\n\n- `_find_model_record()`: Finds an existing Model record matching provider + name + type.\n- `_find_or_create_model()`: Finds existing Model record or auto-creates one linked to a provider credential.\n- `migrate_podcast_profiles()`: Migrates all episode and speaker profiles. Idempotent -- skips profiles where new fields are already populated. Logs counts of migrated/skipped/failed profiles.\n\n## Common Patterns\n\n- **Model registry references**: Profile fields reference `record<model>` IDs instead of raw provider/model strings. Credentials are resolved at runtime via `_resolve_model_config()`.\n- **Profile snapshots**: episode_profile and speaker_profile stored as dicts on PodcastEpisode to freeze config at generation time.\n- **Field validation**: Pydantic validators enforce constraints (segment count, speaker count, required fields).\n- **Async database access**: `get_by_name()` queries via repo_query.\n- **Job tracking**: command field delegates to surreal-commands; get_job_status() returns \"unknown\" on failure.\n- **Record ID handling**: `_prepare_save_data()` converts model ID strings to RecordID before save; `ensure_record_id()` handles both string and RecordID inputs.\n- **nullable_fields ClassVar**: Declares fields that may be null/absent in the database, allowing ObjectModel to handle them during deserialization.\n\n## Key Dependencies\n\n- `pydantic`: Field validators, ObjectModel inheritance\n- `surrealdb`: RecordID type for job and model references\n- `open_notebook.database.repository`: repo_query, ensure_record_id\n- `open_notebook.domain.base`: ObjectModel base class\n- `open_notebook.ai.models`: Model class (for `_resolve_model_config`)\n- `open_notebook.ai.key_provider`: provision_provider_keys (fallback)\n- `open_notebook.domain.credential`: Credential (for migration)\n- `surreal_commands` (optional): get_command_status() for job status\n\n## Important Quirks & Gotchas\n\n- **Legacy fields preserved**: `tts_provider`/`tts_model` on SpeakerProfile and `outline_provider`/`outline_model`/`transcript_provider`/`transcript_model` on EpisodeProfile are kept as optional nullable fields for backward compatibility with the data migration. The app ignores them at runtime.\n- **Snapshot approach**: Episode/speaker profiles stored as dicts (not references), so profile updates don't retroactively affect past episodes.\n- **Job status resilience**: get_job_status() catches all exceptions and returns \"unknown\" (no error propagation).\n- **No automatic retries**: Podcast generation commands use `retry={\"max_attempts\": 1}` to prevent duplicate episode records on failure; retry is user-initiated via `POST /podcasts/episodes/{id}/retry`.\n- **validate_speakers executes late**: Validators run at instantiation; bulk inserts may not trigger full validation.\n- **RecordID coercion**: `_prepare_save_data()` converts model ID strings to RecordID; command field parsed during deserialization.\n- **No cascade delete**: Removing a profile doesn't cascade to episodes using it.\n- **Migration is idempotent**: `migrate_podcast_profiles()` skips profiles that already have new fields populated. Safe to run multiple times.\n- **Migration auto-creates models**: If a legacy provider/model string has no matching Model record but a credential exists for that provider, the migration auto-creates a Model record linked to the credential.\n\n## How to Extend\n\n1. **Add new speaker field**: Add to required_fields list in validate_speakers()\n2. **Add episode config field**: Validate in EpisodeProfile, update briefing generation code; add to nullable_fields if optional\n3. **Add job metadata**: Extend PodcastEpisode with new fields (e.g., progress tracking)\n4. **Change job provider**: Replace surreal-commands with alternative job queue library; update get_job_status()\n5. **Add new model reference field**: Add field, add to nullable_fields, add RecordID conversion in `_prepare_save_data()`, add resolve method using `_resolve_model_config()`\n"
  },
  {
    "path": "open_notebook/podcasts/__init__.py",
    "content": "# Podcasts module\n# Contains podcast episode models, profiles, and generation logic\n"
  },
  {
    "path": "open_notebook/podcasts/migration.py",
    "content": "\"\"\"\nData migration for podcast profiles: maps legacy provider/model strings\nto Model registry record IDs.\n\nRuns on API startup after SQL migrations. Idempotent - skips profiles\nthat already have the new fields populated.\n\"\"\"\n\nfrom loguru import logger\n\nfrom open_notebook.database.repository import repo_query\n\n\nasync def _find_model_record(\n    provider: str, model_name: str, model_type: str\n) -> str | None:\n    \"\"\"Find an existing Model record matching provider + name + type.\"\"\"\n    results = await repo_query(\n        \"SELECT * FROM model WHERE provider = $provider AND name = $name AND type = $type\",\n        {\"provider\": provider, \"name\": model_name, \"type\": model_type},\n    )\n    if results:\n        return str(results[0][\"id\"])\n    return None\n\n\nasync def _find_or_create_model(\n    provider: str, model_name: str, model_type: str\n) -> str | None:\n    \"\"\"Find existing Model record or auto-create one linked to provider credential.\"\"\"\n    # Try exact match first\n    model_id = await _find_model_record(provider, model_name, model_type)\n    if model_id:\n        return model_id\n\n    # Try to find a credential for this provider and auto-create the model\n    from open_notebook.domain.credential import Credential\n\n    credentials = await Credential.get_by_provider(provider)\n    if not credentials:\n        logger.warning(\n            f\"No credential found for provider '{provider}'. \"\n            f\"Cannot auto-create model '{model_name}'. Profile needs manual migration.\"\n        )\n        return None\n\n    # Use the first credential for the provider\n    credential = credentials[0]\n    from open_notebook.ai.models import Model\n\n    model = Model(\n        name=model_name,\n        provider=provider,\n        type=model_type,\n        credential=str(credential.id),\n    )\n    await model.save()\n    logger.info(\n        f\"Auto-created model '{model_name}' ({model_type}) \"\n        f\"linked to credential '{credential.name}'\"\n    )\n    return str(model.id)\n\n\nasync def migrate_podcast_profiles() -> None:\n    \"\"\"Migrate episode and speaker profiles from legacy strings to Model record IDs.\n\n    Idempotent: skips profiles where new fields are already populated.\n    \"\"\"\n    logger.info(\"Starting podcast profile data migration...\")\n\n    ep_migrated = 0\n    ep_skipped = 0\n    ep_failed = 0\n\n    # Migrate EpisodeProfiles\n    episode_profiles = await repo_query(\"SELECT * FROM episode_profile\")\n    for raw in episode_profiles:\n        profile_name = raw.get(\"name\", raw.get(\"id\", \"unknown\"))\n        try:\n            outline_llm = raw.get(\"outline_llm\")\n            transcript_llm = raw.get(\"transcript_llm\")\n\n            needs_outline = not outline_llm\n            needs_transcript = not transcript_llm\n\n            if not needs_outline and not needs_transcript:\n                ep_skipped += 1\n                continue\n\n            updates = {}\n\n            if needs_outline:\n                outline_provider = raw.get(\"outline_provider\")\n                outline_model = raw.get(\"outline_model\")\n                if outline_provider and outline_model:\n                    model_id = await _find_or_create_model(\n                        outline_provider, outline_model, \"language\"\n                    )\n                    if model_id:\n                        from open_notebook.database.repository import ensure_record_id\n\n                        updates[\"outline_llm\"] = ensure_record_id(model_id)\n\n            if needs_transcript:\n                transcript_provider = raw.get(\"transcript_provider\")\n                transcript_model = raw.get(\"transcript_model\")\n                if transcript_provider and transcript_model:\n                    model_id = await _find_or_create_model(\n                        transcript_provider, transcript_model, \"language\"\n                    )\n                    if model_id:\n                        from open_notebook.database.repository import ensure_record_id\n\n                        updates[\"transcript_llm\"] = ensure_record_id(model_id)\n\n            if updates:\n                from open_notebook.database.repository import repo_update\n\n                await repo_update(\"episode_profile\", str(raw[\"id\"]), updates)\n                ep_migrated += 1\n                logger.info(\n                    f\"Migrated episode profile '{profile_name}': {list(updates.keys())}\"\n                )\n            else:\n                ep_failed += 1\n                logger.warning(\n                    f\"Could not migrate episode profile '{profile_name}': \"\n                    \"no matching models found\"\n                )\n\n        except Exception as e:\n            ep_failed += 1\n            logger.error(f\"Failed to migrate episode profile '{profile_name}': {e}\")\n\n    # Migrate SpeakerProfiles\n    sp_migrated = 0\n    sp_skipped = 0\n    sp_failed = 0\n\n    speaker_profiles = await repo_query(\"SELECT * FROM speaker_profile\")\n    for raw in speaker_profiles:\n        profile_name = raw.get(\"name\", raw.get(\"id\", \"unknown\"))\n        try:\n            voice_model = raw.get(\"voice_model\")\n\n            if voice_model:\n                sp_skipped += 1\n                continue\n\n            tts_provider = raw.get(\"tts_provider\")\n            tts_model = raw.get(\"tts_model\")\n\n            if not tts_provider or not tts_model:\n                sp_failed += 1\n                logger.warning(\n                    f\"Speaker profile '{profile_name}' has no legacy TTS config\"\n                )\n                continue\n\n            model_id = await _find_or_create_model(\n                tts_provider, tts_model, \"text_to_speech\"\n            )\n            if model_id:\n                from open_notebook.database.repository import ensure_record_id, repo_update\n\n                await repo_update(\n                    \"speaker_profile\",\n                    str(raw[\"id\"]),\n                    {\"voice_model\": ensure_record_id(model_id)},\n                )\n                sp_migrated += 1\n                logger.info(f\"Migrated speaker profile '{profile_name}'\")\n            else:\n                sp_failed += 1\n                logger.warning(\n                    f\"Could not migrate speaker profile '{profile_name}': \"\n                    \"no matching model found\"\n                )\n\n        except Exception as e:\n            sp_failed += 1\n            logger.error(f\"Failed to migrate speaker profile '{profile_name}': {e}\")\n\n    logger.info(\n        f\"Podcast profile migration complete. \"\n        f\"Episodes: {ep_migrated} migrated, {ep_skipped} skipped, {ep_failed} failed. \"\n        f\"Speakers: {sp_migrated} migrated, {sp_skipped} skipped, {sp_failed} failed.\"\n    )\n"
  },
  {
    "path": "open_notebook/podcasts/models.py",
    "content": "from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union\n\nfrom loguru import logger\nfrom pydantic import ConfigDict, Field, field_validator\nfrom surrealdb import RecordID\n\nfrom open_notebook.database.repository import ensure_record_id, repo_query\nfrom open_notebook.domain.base import ObjectModel\n\n\nasync def _resolve_model_config(model_id: str) -> Tuple[str, str, dict]:\n    \"\"\"Load Model record, resolve credential -> (provider, model_name, config_dict).\n\n    Used by resolve_outline_config, resolve_transcript_config, resolve_tts_config,\n    and per-speaker TTS overrides.\n    \"\"\"\n    from open_notebook.ai.models import Model\n\n    model = await Model.get(model_id)\n    config: dict = {}\n    if model.credential:\n        credential = await model.get_credential_obj()\n        if credential:\n            config = credential.to_esperanto_config()\n    if not config:\n        from open_notebook.ai.key_provider import provision_provider_keys\n\n        await provision_provider_keys(model.provider)\n    return (model.provider, model.name, config)\n\n\nclass EpisodeProfile(ObjectModel):\n    \"\"\"\n    Episode Profile - Simplified podcast configuration.\n    Replaces complex 15+ field configuration with user-friendly profiles.\n    \"\"\"\n\n    table_name: ClassVar[str] = \"episode_profile\"\n    nullable_fields: ClassVar[set[str]] = {\n        \"description\",\n        \"outline_provider\",\n        \"outline_model\",\n        \"transcript_provider\",\n        \"transcript_model\",\n        \"outline_llm\",\n        \"transcript_llm\",\n        \"language\",\n    }\n\n    name: str = Field(..., description=\"Unique profile name\")\n    description: Optional[str] = Field(None, description=\"Profile description\")\n    speaker_config: str = Field(..., description=\"Reference to speaker profile name\")\n\n    # Legacy fields (kept for migration, app ignores)\n    outline_provider: Optional[str] = Field(\n        None, description=\"[Legacy] AI provider for outline generation\"\n    )\n    outline_model: Optional[str] = Field(\n        None, description=\"[Legacy] AI model for outline generation\"\n    )\n    transcript_provider: Optional[str] = Field(\n        None, description=\"[Legacy] AI provider for transcript generation\"\n    )\n    transcript_model: Optional[str] = Field(\n        None, description=\"[Legacy] AI model for transcript generation\"\n    )\n\n    # New fields: Model registry references\n    outline_llm: Optional[str] = Field(\n        None, description=\"Model record ID for outline generation\"\n    )\n    transcript_llm: Optional[str] = Field(\n        None, description=\"Model record ID for transcript generation\"\n    )\n    language: Optional[str] = Field(\n        None, description=\"Podcast language (BCP 47 locale code, e.g. pt-BR, en-US)\"\n    )\n\n    default_briefing: str = Field(..., description=\"Default briefing template\")\n    num_segments: int = Field(default=5, description=\"Number of podcast segments\")\n\n    @field_validator(\"num_segments\")\n    @classmethod\n    def validate_segments(cls, v):\n        if not 3 <= v <= 20:\n            raise ValueError(\"Number of segments must be between 3 and 20\")\n        return v\n\n    def _prepare_save_data(self) -> dict:\n        data = super()._prepare_save_data()\n        if data.get(\"outline_llm\"):\n            data[\"outline_llm\"] = ensure_record_id(data[\"outline_llm\"])\n        if data.get(\"transcript_llm\"):\n            data[\"transcript_llm\"] = ensure_record_id(data[\"transcript_llm\"])\n        return data\n\n    async def resolve_outline_config(self) -> Tuple[str, str, dict]:\n        \"\"\"Resolve outline model -> (provider, model_name, config_dict)\"\"\"\n        if not self.outline_llm:\n            raise ValueError(\n                f\"Episode profile '{self.name}' has no outline model configured. \"\n                \"Please update the profile to select an outline model.\"\n            )\n        return await _resolve_model_config(self.outline_llm)\n\n    async def resolve_transcript_config(self) -> Tuple[str, str, dict]:\n        \"\"\"Resolve transcript model -> (provider, model_name, config_dict)\"\"\"\n        if not self.transcript_llm:\n            raise ValueError(\n                f\"Episode profile '{self.name}' has no transcript model configured. \"\n                \"Please update the profile to select a transcript model.\"\n            )\n        return await _resolve_model_config(self.transcript_llm)\n\n    @classmethod\n    async def get_by_name(cls, name: str) -> Optional[\"EpisodeProfile\"]:\n        \"\"\"Get episode profile by name\"\"\"\n        result = await repo_query(\n            \"SELECT * FROM episode_profile WHERE name = $name\", {\"name\": name}\n        )\n        if result:\n            return cls(**result[0])\n        return None\n\n\nclass SpeakerProfile(ObjectModel):\n    \"\"\"\n    Speaker Profile - Voice and personality configuration.\n    Supports 1-4 speakers for flexible podcast formats.\n    \"\"\"\n\n    table_name: ClassVar[str] = \"speaker_profile\"\n    nullable_fields: ClassVar[set[str]] = {\n        \"description\",\n        \"tts_provider\",\n        \"tts_model\",\n        \"voice_model\",\n    }\n\n    name: str = Field(..., description=\"Unique profile name\")\n    description: Optional[str] = Field(None, description=\"Profile description\")\n\n    # Legacy fields (kept for migration, app ignores)\n    tts_provider: Optional[str] = Field(\n        None, description=\"[Legacy] TTS provider (openai, elevenlabs, etc.)\"\n    )\n    tts_model: Optional[str] = Field(None, description=\"[Legacy] TTS model name\")\n\n    # New field: Model registry reference\n    voice_model: Optional[str] = Field(\n        None, description=\"Model record ID for TTS\"\n    )\n\n    speakers: List[Dict[str, Any]] = Field(\n        ..., description=\"Array of speaker configurations\"\n    )\n\n    @field_validator(\"speakers\")\n    @classmethod\n    def validate_speakers(cls, v):\n        if not 1 <= len(v) <= 4:\n            raise ValueError(\"Must have between 1 and 4 speakers\")\n\n        required_fields = [\"name\", \"voice_id\", \"backstory\", \"personality\"]\n        for speaker in v:\n            for field in required_fields:\n                if field not in speaker:\n                    raise ValueError(f\"Speaker missing required field: {field}\")\n        return v\n\n    def _prepare_save_data(self) -> dict:\n        data = super()._prepare_save_data()\n        if data.get(\"voice_model\"):\n            data[\"voice_model\"] = ensure_record_id(data[\"voice_model\"])\n        # Handle per-speaker voice_model overrides\n        if data.get(\"speakers\"):\n            for speaker in data[\"speakers\"]:\n                if speaker.get(\"voice_model\"):\n                    speaker[\"voice_model\"] = ensure_record_id(speaker[\"voice_model\"])\n        return data\n\n    async def resolve_tts_config(self) -> Tuple[str, str, dict]:\n        \"\"\"Resolve TTS model -> (provider, model_name, config_dict)\"\"\"\n        if not self.voice_model:\n            raise ValueError(\n                f\"Speaker profile '{self.name}' has no voice model configured. \"\n                \"Please update the profile to select a voice model.\"\n            )\n        return await _resolve_model_config(self.voice_model)\n\n    @classmethod\n    async def get_by_name(cls, name: str) -> Optional[\"SpeakerProfile\"]:\n        \"\"\"Get speaker profile by name\"\"\"\n        result = await repo_query(\n            \"SELECT * FROM speaker_profile WHERE name = $name\", {\"name\": name}\n        )\n        if result:\n            return cls(**result[0])\n        return None\n\n\nclass PodcastEpisode(ObjectModel):\n    \"\"\"Enhanced PodcastEpisode with job tracking and metadata\"\"\"\n\n    table_name: ClassVar[str] = \"episode\"\n\n    name: str = Field(..., description=\"Episode name\")\n    episode_profile: Dict[str, Any] = Field(\n        ..., description=\"Episode profile used (stored as object)\"\n    )\n    speaker_profile: Dict[str, Any] = Field(\n        ..., description=\"Speaker profile used (stored as object)\"\n    )\n    briefing: str = Field(..., description=\"Full briefing used for generation\")\n    content: str = Field(..., description=\"Source content\")\n    audio_file: Optional[str] = Field(\n        default=None, description=\"Path to generated audio file\"\n    )\n    transcript: Optional[Dict[str, Any]] = Field(\n        default_factory=dict, description=\"Generated transcript\"\n    )\n    outline: Optional[Dict[str, Any]] = Field(\n        default_factory=dict, description=\"Generated outline\"\n    )\n    command: Optional[Union[str, RecordID]] = Field(\n        default=None, description=\"Link to surreal-commands job\"\n    )\n\n    model_config = ConfigDict(arbitrary_types_allowed=True)\n\n    async def get_job_status(self) -> Optional[str]:\n        \"\"\"Get the status of the associated command\"\"\"\n        if not self.command:\n            return None\n\n        try:\n            from surreal_commands import get_command_status\n\n            status = await get_command_status(str(self.command))\n            return status.status if status else \"unknown\"\n        except Exception:\n            return \"unknown\"\n\n    async def get_job_detail(self) -> dict:\n        \"\"\"Get status and error_message of the associated command\"\"\"\n        if not self.command:\n            return {\"status\": None, \"error_message\": None}\n\n        try:\n            from surreal_commands import get_command_status\n\n            status = await get_command_status(str(self.command))\n            if not status:\n                return {\"status\": \"unknown\", \"error_message\": None}\n            return {\n                \"status\": status.status,\n                \"error_message\": getattr(status, \"error_message\", None),\n            }\n        except Exception:\n            return {\"status\": \"unknown\", \"error_message\": None}\n\n    @field_validator(\"command\", mode=\"before\")\n    @classmethod\n    def parse_command(cls, value):\n        if isinstance(value, str):\n            return ensure_record_id(value)\n        return value\n\n    def _prepare_save_data(self) -> dict:\n        \"\"\"Override to ensure command field is always RecordID format for database\"\"\"\n        data = super()._prepare_save_data()\n\n        # Ensure command field is RecordID format if not None\n        if data.get(\"command\") is not None:\n            data[\"command\"] = ensure_record_id(data[\"command\"])\n\n        return data\n"
  },
  {
    "path": "open_notebook/utils/CLAUDE.md",
    "content": "# Utils Module\n\nUtility functions and helpers for context building, text processing, chunking, embedding, tokenization, and versioning.\n\n## Purpose\n\nProvides cross-cutting concerns: building LLM context from sources/insights, content-type aware text chunking, unified embedding generation with mean pooling, token counting, and version management.\n\n## Architecture Overview\n\n**Six core utilities**:\n1. **context_builder.py**: Flexible context assembly from sources, notes, insights with token budgeting\n2. **chunking.py**: Content-type detection and smart text chunking for embedding operations\n3. **embedding.py**: Unified embedding generation with mean pooling for large content\n4. **text_utils.py**: Text cleaning and thinking content extraction\n5. **token_utils.py**: Token counting for LLM context windows (wrapper around encoding library)\n6. **version_utils.py**: Version parsing, comparison, and schema compatibility checks\n\nEach utility is stateless and can be imported independently.\n\n## Configuration\n\n### Chunking Configuration (chunking.py)\n\nThe chunking behavior can be configured via environment variables:\n\n- **OPEN_NOTEBOOK_CHUNK_SIZE**: Maximum chunk size in characters (default: 1200)\n  - Minimum: 100 characters\n  - Warnings: Values > 8192 characters or invalid values\n  - Use case: Smaller models (e.g., mxbai-embed-large with limited context window)\n\n- **OPEN_NOTEBOOK_CHUNK_OVERLAP**: Overlap between chunks in characters (default: 15% of CHUNK_SIZE)\n  - Must be: >= 0 and < CHUNK_SIZE\n  - Warnings: Invalid values or values >= CHUNK_SIZE\n  - Use case: Control how much context is shared between adjacent chunks\n\nExample for models with small context windows:\n```bash\nexport OPEN_NOTEBOOK_CHUNK_SIZE=512\nexport OPEN_NOTEBOOK_CHUNK_OVERLAP=50\n```\n\nNote: Changes require restart of the application.\n\n## Component Catalog\n\n### context_builder.py\n- **ContextItem**: Dataclass for individual context piece (id, type, content, priority, token_count)\n- **ContextConfig**: Configuration for context building (sources/notes/insights selection, max tokens, priority weights)\n- **ContextBuilder**: Main class assembling context\n  - `add_source()`: Include source by ID with inclusion level\n  - `add_note()`: Include note by ID\n  - `add_insight()`: Include insight by ID\n  - `build()`: Assemble context respecting token budget and priorities\n  - Uses vector_search to fetch source/insight content from SurrealDB\n  - Returns list of ContextItem objects sorted by priority\n\n**Key behavior**:\n- Token counting is automatic (calculated in ContextItem.__post_init__)\n- Max token enforcement via priority weighting (higher priority items included first)\n- Type-specific fetching: sources → Source.full_text, notes → Note.content, insights → SourceInsight.content\n- Raises DatabaseOperationError if source/note fetch fails\n\n### chunking.py\n- **ContentType**: Enum (HTML, MARKDOWN, PLAIN)\n- **CHUNK_SIZE**: Configurable via `OPEN_NOTEBOOK_CHUNK_SIZE` env var (default: 1200)\n- **CHUNK_OVERLAP**: Configurable via `OPEN_NOTEBOOK_CHUNK_OVERLAP` env var (default: 15% of CHUNK_SIZE)\n- **detect_content_type_from_extension(file_path)**: Detect type from file extension\n- **detect_content_type_from_heuristics(text)**: Detect type from content patterns (returns type + confidence)\n- **detect_content_type(text, file_path)**: Combined detection (extension primary, heuristics fallback)\n- **chunk_text(text, content_type, file_path)**: Split text using appropriate splitter\n\n**Key behavior**:\n- Uses LangChain splitters: HTMLHeaderTextSplitter, MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter\n- Extension-based detection is primary; heuristics can override PLAIN extensions with 0.8+ confidence\n- Secondary chunking applied when HTML/Markdown splitters produce oversized chunks\n- Returns list of strings, each ≤ CHUNK_SIZE characters\n\n### embedding.py\n- **mean_pool_embeddings(embeddings)**: Combine multiple embeddings via normalized mean pooling\n- **generate_embeddings(texts)**: Batch embedding with automatic batching (default 50 texts per batch) and per-batch retry\n- **generate_embedding(text, content_type, file_path)**: Unified embedding with automatic chunking + mean pooling\n\n**Key behavior**:\n- Uses model_manager.get_model(\"embedding\") for embedding model\n- Short text (≤ CHUNK_SIZE): direct embedding\n- Long text: chunk → embed each → mean pool results\n- Mean pooling: normalize each → mean → normalize result (using numpy)\n- Raises ValueError for empty/whitespace-only text\n\n### text_utils.py\n- **remove_non_ascii(text)**: Remove non-ASCII characters from text\n- **remove_non_printable(text)**: Remove non-printable characters, preserving newlines/tabs\n- **parse_thinking_content(content)**: Extract `<think>` tags content from AI responses\n- **clean_thinking_content(content)**: Remove `<think>` blocks, return cleaned content only\n\n**Key behavior**:\n- parse_thinking_content handles malformed output (missing opening `<think>` tag)\n- Large content (>100KB) bypasses thinking extraction for performance\n- Non-string input returns empty thinking and stringified content\n\n### token_utils.py\n- **token_count(text)**: Returns estimated token count for string (via tiktoken)\n- **token_cost(text, model)**: Calculate cost estimate for text with given model\n\n**Key behavior**: Uses cl100k_base encoding; may differ slightly from actual model tokenization\n\n### version_utils.py\n- **compare_versions(v1, v2)**: Returns -1 (v1 < v2), 0 (equal), 1 (v1 > v2)\n- **get_installed_version(package)**: Get version of installed Python package\n- **get_version_from_github(url)**: Fetch latest version from GitHub releases\n\n**Key behavior**: Uses packaging library for version parsing; supports pre-release tags\n\n## Common Patterns\n\n- **Dataclass-driven config**: ContextConfig used by ContextBuilder (immutable after init)\n- **Token budgeting**: ContextBuilder respects max_tokens constraint; prioritizes high-priority items\n- **Content-type aware processing**: Chunking uses appropriate splitter based on detected content type\n- **Mean pooling for large content**: Embedding handles arbitrarily large text via chunking + pooling\n- **Error handling resilience**: token_count() returns estimate; context_builder catches DB errors gracefully\n- **Pure text functions**: text_utils functions are stateless utilities (no class needed)\n- **Lazy evaluation**: ContextBuilder doesn't fetch items until build() called\n- **Type hints throughout**: All functions use Optional, List, Dict for clarity\n\n## Key Dependencies\n\n- `open_notebook.domain.notebook`: Source, Note, SourceInsight models; vector_search function\n- `open_notebook.ai.models`: model_manager for embedding model access\n- `open_notebook.exceptions`: DatabaseOperationError, NotFoundError\n- `langchain_text_splitters`: HTMLHeaderTextSplitter, MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter\n- `numpy`: Mean pooling calculations\n- `tiktoken`: Token encoding for GPT models\n- `loguru`: Logging throughout\n\n## Important Quirks & Gotchas\n\n- **Token count estimation**: Uses cl100k_base encoding; may differ 5-10% from actual model tokens\n- **Chunk size for Ollama**: 1500 chars chosen to fit within Ollama embedding model context limits\n- **Content type detection order**: Extension checked first, then heuristics; high-confidence heuristics (≥0.8) can override PLAIN extensions\n- **Mean pooling normalization**: Each embedding normalized before mean, result normalized after\n- **Priority weights default**: If not specified, ContextConfig uses default weights (source=1, note=0.8, insight=1.2)\n- **Vector search required**: ContextBuilder assumes vector_search is available on Notebook model; fails if not\n- **Circular import risk**: context_builder imports from domain.notebook; avoid domain importing utils\n- **Max tokens hard limit**: ContextBuilder stops adding items once max_tokens exceeded (not prorated)\n- **No caching**: Every build() call re-fetches from database (use cache layer if needed)\n\n## How to Extend\n\n1. **Add new context source type**: Create fetch method in ContextBuilder; update ContextConfig.sources dict\n2. **Add content type**: Add to ContentType enum; create splitter getter; update chunk_text()\n3. **Change chunk size**: Set OPEN_NOTEBOOK_CHUNK_SIZE and OPEN_NOTEBOOK_CHUNK_OVERLAP environment variables\n4. **Add text preprocessing**: Add new function to text_utils (e.g., remove_urls, extract_keywords)\n5. **Change tokenization**: Replace tiktoken with alternative library in token_utils; update all calls\n6. **Add context filtering**: Extend ContextConfig with filter_by_date, filter_by_topic fields\n\n## Usage Examples\n\n### Chunking\n```python\nfrom open_notebook.utils.chunking import chunk_text, detect_content_type, ContentType\n\n# Auto-detect content type and chunk\nchunks = chunk_text(long_text, file_path=\"document.md\")\n\n# Explicit content type\nchunks = chunk_text(html_content, content_type=ContentType.HTML)\n```\n\n### Embedding\n```python\nfrom open_notebook.utils.embedding import generate_embedding, generate_embeddings\n\n# Single text (handles chunking + mean pooling automatically)\nembedding = await generate_embedding(long_text)\n\n# Batch embedding (more efficient for multiple texts)\nembeddings = await generate_embeddings([\"text1\", \"text2\", \"text3\"])\n```\n\n### Context Building\n```python\nfrom open_notebook.utils.context_builder import ContextBuilder, ContextConfig\n\nconfig = ContextConfig(\n    sources={\"source:123\": \"full\", \"source:456\": \"summary\"},\n    max_tokens=2000,\n)\nbuilder = ContextBuilder(notebook, config)\ncontext_items = await builder.build()\n\nfor item in context_items:\n    print(f\"{item.type}:{item.id} ({item.token_count} tokens)\")\n```\n\n### encryption.py\n- **get_secret_from_env(var_name)**: Retrieve secret from environment with Docker secrets support (checks VAR_FILE first, then VAR)\n- **get_fernet()**: Get Fernet instance if encryption key is configured\n- **encrypt_value(value)**: Encrypt a string using Fernet symmetric encryption\n- **decrypt_value(value)**: Decrypt a Fernet-encrypted string; gracefully falls back to original value for legacy/unencrypted data\n**Purpose**: Provides field-level encryption for sensitive data (API keys) stored in the database. Uses Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256) for authenticated encryption.\n\n**Key behavior**:\n- Key source: OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets) → OPEN_NOTEBOOK_ENCRYPTION_KEY (env var)\n- Accepts **any string**: always derived to a Fernet key via SHA-256\n- No default key — encryption is unavailable until the env var is set\n- Graceful fallback on decryption: InvalidToken errors (legacy unencrypted data) return the original value\n- Lazy-loaded key: initialized on first use, not at import time\n\n**Security considerations**:\n- OPEN_NOTEBOOK_ENCRYPTION_KEY must be set explicitly (no default)\n- Docker secrets pattern supported for secure key injection in containerized environments\n- Key rotation would require re-encrypting all stored keys (not currently implemented)\n- Encryption is transparent to callers; unencrypted legacy data continues to work\n\n**Usage Example**:\n```python\nfrom open_notebook.utils.encryption import encrypt_value, decrypt_value\n\n# Encrypt before storing in database\nencrypted_api_key = encrypt_value(api_key)\n\n# Decrypt when reading from database\ndecrypted_api_key = decrypt_value(encrypted_api_key)\n\n# Set any string as encryption key:\n# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase\n```\n"
  },
  {
    "path": "open_notebook/utils/README.md",
    "content": "# ContextBuilder\n\nA flexible and generic ContextBuilder class for the Open Notebook project that can handle any parameters and build context from sources, notebooks, insights, and notes.\n\n## Features\n\n- **Flexible Parameters**: Accepts any parameters via `**kwargs` for future extensibility\n- **Priority-based Management**: Automatic prioritization and sorting of context items\n- **Token Counting**: Built-in token counting and truncation to fit limits\n- **Deduplication**: Automatic removal of duplicate items based on ID\n- **Type-based Grouping**: Separates sources, notes, and insights in output\n- **Async Support**: Fully async for database operations\n\n## Basic Usage\n\n```python\nfrom open_notebook.utils.context_builder import ContextBuilder, ContextConfig\n\n# Simple notebook context\nbuilder = ContextBuilder(notebook_id=\"notebook:123\")\ncontext = await builder.build()\n\n# Single source with insights\nbuilder = ContextBuilder(\n    source_id=\"source:456\",\n    include_insights=True,\n    max_tokens=2000\n)\ncontext = await builder.build()\n```\n\n## Convenience Functions\n\n```python\nfrom open_notebook.utils.context_builder import (\n    build_notebook_context,\n    build_source_context,\n    build_mixed_context\n)\n\n# Build notebook context\ncontext = await build_notebook_context(\n    notebook_id=\"notebook:123\",\n    max_tokens=5000\n)\n\n# Build single source context\ncontext = await build_source_context(\n    source_id=\"source:456\",\n    include_insights=True\n)\n\n# Build mixed context\ncontext = await build_mixed_context(\n    source_ids=[\"source:1\", \"source:2\"],\n    note_ids=[\"note:1\", \"note:2\"],\n    max_tokens=3000\n)\n```\n\n## Advanced Configuration\n\n```python\nfrom open_notebook.utils.context_builder import ContextConfig\n\n# Custom configuration\nconfig = ContextConfig(\n    sources={\n        \"source:doc1\": \"insights\",\n        \"source:doc2\": \"full content\", \n        \"source:doc3\": \"not in\"  # Exclude\n    },\n    notes={\n        \"note:summary\": \"full content\",\n        \"note:draft\": \"not in\"  # Exclude\n    },\n    include_insights=True,\n    max_tokens=3000,\n    priority_weights={\n        \"source\": 120,  # Higher priority\n        \"note\": 80,     # Medium priority  \n        \"insight\": 100  # High priority\n    }\n)\n\nbuilder = ContextBuilder(\n    notebook_id=\"notebook:project\",\n    context_config=config\n)\ncontext = await builder.build()\n```\n\n## Programmatic Item Management\n\n```python\nfrom open_notebook.utils.context_builder import ContextItem\n\nbuilder = ContextBuilder()\n\n# Add custom items\nitem = ContextItem(\n    id=\"source:important\",\n    type=\"source\",\n    content={\"title\": \"Key Document\", \"summary\": \"...\"},\n    priority=150  # Very high priority\n)\nbuilder.add_item(item)\n\n# Apply management operations\nbuilder.remove_duplicates()\nbuilder.prioritize()\nbuilder.truncate_to_fit(1000)\n\ncontext = builder._format_response()\n```\n\n## Flexible Parameters\n\nThe ContextBuilder accepts any parameters via `**kwargs`, making it extensible for future features:\n\n```python\nbuilder = ContextBuilder(\n    notebook_id=\"notebook:123\",\n    include_insights=True,\n    max_tokens=2000,\n    \n    # Custom parameters for future extensions\n    user_id=\"user:456\",\n    custom_filter=\"advanced\",\n    experimental_feature=True\n)\n\n# Access custom parameters\nuser_id = builder.params.get('user_id')\n```\n\n## Output Format\n\nThe ContextBuilder returns a structured response:\n\n```python\n{\n    \"sources\": [...],           # List of source contexts\n    \"notes\": [...],             # List of note contexts  \n    \"insights\": [...],          # List of insight contexts\n    \"total_tokens\": 1234,       # Total token count\n    \"total_items\": 10,          # Total number of items\n    \"notebook_id\": \"notebook:123\",  # If provided\n    \"metadata\": {\n        \"source_count\": 5,\n        \"note_count\": 3,\n        \"insight_count\": 2,\n        \"config\": {\n            \"include_insights\": true,\n            \"include_notes\": true,\n            \"max_tokens\": 2000\n        }\n    }\n}\n```\n\n## Architecture\n\nThe ContextBuilder follows these design principles:\n\n1. **Separation of Concerns**: Context building, item management, and formatting are separate\n2. **Extensibility**: Uses `**kwargs` and flexible configuration for future features\n3. **Performance**: Token-aware truncation and efficient deduplication\n4. **Type Safety**: Proper type hints and data classes for structure\n5. **Error Handling**: Graceful handling of missing items and database errors\n\n## Integration\n\nThe ContextBuilder integrates seamlessly with the existing Open Notebook architecture:\n\n- Uses existing domain models (`Source`, `Notebook`, `Note`)\n- Leverages the repository pattern for database access\n- Follows the same async patterns as other services\n- Integrates with the token counting utilities\n\n## Error Handling\n\nThe ContextBuilder handles errors gracefully:\n\n- Missing notebooks/sources/notes are logged but don't stop execution\n- Database errors are wrapped in `DatabaseOperationError`\n- Invalid parameters raise `InvalidInputError`\n- All errors include detailed context information"
  },
  {
    "path": "open_notebook/utils/__init__.py",
    "content": "\"\"\"\nUtils package for Open Notebook.\n\nTo avoid circular imports, import functions directly:\n- from open_notebook.utils.context_builder import ContextBuilder\n- from open_notebook.utils import token_count, compare_versions\n- from open_notebook.utils.chunking import chunk_text, detect_content_type, ContentType\n- from open_notebook.utils.embedding import generate_embedding, generate_embeddings\n- from open_notebook.utils.encryption import encrypt_value, decrypt_value\n\"\"\"\n\nfrom .chunking import (\n    CHUNK_SIZE,\n    ContentType,\n    chunk_text,\n    detect_content_type,\n    detect_content_type_from_extension,\n    detect_content_type_from_heuristics,\n)\nfrom .embedding import (\n    generate_embedding,\n    generate_embeddings,\n    mean_pool_embeddings,\n)\nfrom .encryption import (\n    decrypt_value,\n    encrypt_value,\n)\nfrom .text_utils import (\n    clean_thinking_content,\n    parse_thinking_content,\n    remove_non_ascii,\n    remove_non_printable,\n)\nfrom .token_utils import token_cost, token_count\nfrom .version_utils import (\n    compare_versions,\n    get_installed_version,\n    get_version_from_github,\n)\n\n__all__ = [\n    # Chunking\n    \"CHUNK_SIZE\",\n    \"ContentType\",\n    \"chunk_text\",\n    \"detect_content_type\",\n    \"detect_content_type_from_extension\",\n    \"detect_content_type_from_heuristics\",\n    # Embedding\n    \"generate_embedding\",\n    \"generate_embeddings\",\n    \"mean_pool_embeddings\",\n    # Text utils\n    \"remove_non_ascii\",\n    \"remove_non_printable\",\n    \"parse_thinking_content\",\n    \"clean_thinking_content\",\n    # Token utils\n    \"token_count\",\n    \"token_cost\",\n    # Version utils\n    \"compare_versions\",\n    \"get_installed_version\",\n    \"get_version_from_github\",\n    # Encryption utils\n    \"decrypt_value\",\n    \"encrypt_value\",\n]\n"
  },
  {
    "path": "open_notebook/utils/chunking.py",
    "content": "\"\"\"\nChunking utilities for Open Notebook.\n\nProvides content-type detection and smart text chunking for embedding operations.\nSupports HTML, Markdown, and plain text with appropriate splitters for each type.\n\nKey functions:\n- detect_content_type(): Detects content type from file extension or content heuristics\n- chunk_text(): Splits text into chunks using appropriate splitter for content type\n\nEnvironment Variables:\n    OPEN_NOTEBOOK_CHUNK_SIZE: Maximum chunk size in characters (default: 1200)\n    OPEN_NOTEBOOK_CHUNK_OVERLAP: Overlap between chunks in characters (default: 15% of CHUNK_SIZE)\n\"\"\"\n\nimport os\nimport re\nfrom enum import Enum\nfrom pathlib import Path\nfrom typing import List, Optional, Tuple\n\nfrom langchain_text_splitters import (\n    HTMLHeaderTextSplitter,\n    MarkdownHeaderTextSplitter,\n    RecursiveCharacterTextSplitter,\n)\nfrom loguru import logger\n\n\ndef _get_chunk_size() -> int:\n    \"\"\"Get chunk size from environment variable or use default.\"\"\"\n    chunk_size_str = os.getenv(\"OPEN_NOTEBOOK_CHUNK_SIZE\")\n    if chunk_size_str:\n        try:\n            chunk_size = int(chunk_size_str)\n            if chunk_size < 100:\n                logger.warning(\n                    f\"OPEN_NOTEBOOK_CHUNK_SIZE ({chunk_size}) is too small. \"\n                    f\"Using minimum value of 100.\"\n                )\n                return 100\n            if chunk_size > 8192:\n                logger.warning(\n                    f\"OPEN_NOTEBOOK_CHUNK_SIZE ({chunk_size}) is very large. \"\n                    f\"This may cause issues with some embedding models.\"\n                )\n            logger.info(f\"Using custom chunk size: {chunk_size} characters\")\n            return chunk_size\n        except ValueError:\n            logger.warning(\n                f\"Invalid OPEN_NOTEBOOK_CHUNK_SIZE value: '{chunk_size_str}'. \"\n                f\"Using default: 1200\"\n            )\n    return 1200\n\n\ndef _get_chunk_overlap(chunk_size: int) -> int:\n    \"\"\"Get chunk overlap from environment variable or calculate default (15% of chunk size).\"\"\"\n    overlap_str = os.getenv(\"OPEN_NOTEBOOK_CHUNK_OVERLAP\")\n    if overlap_str:\n        try:\n            overlap = int(overlap_str)\n            if overlap < 0:\n                logger.warning(\n                    f\"OPEN_NOTEBOOK_CHUNK_OVERLAP ({overlap}) cannot be negative. \"\n                    f\"Using 0.\"\n                )\n                return 0\n            if overlap >= chunk_size:\n                logger.warning(\n                    f\"OPEN_NOTEBOOK_CHUNK_OVERLAP ({overlap}) cannot be >= chunk size ({chunk_size}). \"\n                    f\"Using 15% of chunk size: {int(chunk_size * 0.15)}\"\n                )\n                return int(chunk_size * 0.15)\n            logger.info(f\"Using custom chunk overlap: {overlap} characters\")\n            return overlap\n        except ValueError:\n            logger.warning(\n                f\"Invalid OPEN_NOTEBOOK_CHUNK_OVERLAP value: '{overlap_str}'. \"\n                f\"Using default: 15% of chunk size\"\n            )\n    return int(chunk_size * 0.15)\n\n\n# Constants (computed at import time from environment variables)\nCHUNK_SIZE = _get_chunk_size()\nCHUNK_OVERLAP = _get_chunk_overlap(CHUNK_SIZE)\nHIGH_CONFIDENCE_THRESHOLD = 0.8  # Threshold for heuristics to override extension\n\nlogger.debug(\n    f\"Chunking configuration: CHUNK_SIZE={CHUNK_SIZE}, CHUNK_OVERLAP={CHUNK_OVERLAP}\"\n)\n\n\nclass ContentType(Enum):\n    \"\"\"Content type for chunking strategy selection.\"\"\"\n\n    HTML = \"html\"\n    MARKDOWN = \"markdown\"\n    PLAIN = \"plain\"\n\n\n# File extension mappings\n_EXTENSION_TO_CONTENT_TYPE = {\n    # HTML\n    \".html\": ContentType.HTML,\n    \".htm\": ContentType.HTML,\n    \".xhtml\": ContentType.HTML,\n    # Markdown\n    \".md\": ContentType.MARKDOWN,\n    \".markdown\": ContentType.MARKDOWN,\n    \".mdown\": ContentType.MARKDOWN,\n    \".mkd\": ContentType.MARKDOWN,\n    # Plain text (explicit)\n    \".txt\": ContentType.PLAIN,\n    \".text\": ContentType.PLAIN,\n    # Code files (treat as plain)\n    \".py\": ContentType.PLAIN,\n    \".js\": ContentType.PLAIN,\n    \".ts\": ContentType.PLAIN,\n    \".java\": ContentType.PLAIN,\n    \".c\": ContentType.PLAIN,\n    \".cpp\": ContentType.PLAIN,\n    \".go\": ContentType.PLAIN,\n    \".rs\": ContentType.PLAIN,\n    \".rb\": ContentType.PLAIN,\n    \".php\": ContentType.PLAIN,\n    \".sh\": ContentType.PLAIN,\n    \".bash\": ContentType.PLAIN,\n    \".zsh\": ContentType.PLAIN,\n    \".sql\": ContentType.PLAIN,\n    \".json\": ContentType.PLAIN,\n    \".yaml\": ContentType.PLAIN,\n    \".yml\": ContentType.PLAIN,\n    \".xml\": ContentType.PLAIN,\n    \".csv\": ContentType.PLAIN,\n    \".tsv\": ContentType.PLAIN,\n}\n\n\ndef detect_content_type_from_extension(\n    file_path: Optional[str],\n) -> Optional[ContentType]:\n    \"\"\"\n    Detect content type from file extension.\n\n    Args:\n        file_path: Path to the file (can be full path or just filename)\n\n    Returns:\n        ContentType if extension is recognized, None otherwise\n    \"\"\"\n    if not file_path:\n        return None\n\n    try:\n        extension = Path(file_path).suffix.lower()\n        return _EXTENSION_TO_CONTENT_TYPE.get(extension)\n    except Exception:\n        return None\n\n\ndef detect_content_type_from_heuristics(text: str) -> Tuple[ContentType, float]:\n    \"\"\"\n    Detect content type using content heuristics.\n\n    Args:\n        text: The text content to analyze\n\n    Returns:\n        Tuple of (ContentType, confidence_score) where confidence is 0.0-1.0\n    \"\"\"\n    if not text or len(text) < 10:\n        return ContentType.PLAIN, 0.5\n\n    # Sample first 5000 chars for efficiency\n    sample = text[:5000]\n\n    # Check HTML first (most specific patterns)\n    html_score = _calculate_html_score(sample)\n    if html_score >= HIGH_CONFIDENCE_THRESHOLD:\n        return ContentType.HTML, html_score\n\n    # Check Markdown\n    markdown_score = _calculate_markdown_score(sample)\n    if markdown_score >= HIGH_CONFIDENCE_THRESHOLD:\n        return ContentType.MARKDOWN, markdown_score\n\n    # Return the higher scoring type, or PLAIN if both are low\n    if html_score > markdown_score and html_score > 0.3:\n        return ContentType.HTML, html_score\n    elif markdown_score > 0.3:\n        return ContentType.MARKDOWN, markdown_score\n    else:\n        return ContentType.PLAIN, 0.6\n\n\ndef _calculate_html_score(text: str) -> float:\n    \"\"\"Calculate confidence score for HTML content.\"\"\"\n    score = 0.0\n    indicators = 0\n\n    # Strong indicators\n    if re.search(r\"<!DOCTYPE\\s+html\", text, re.IGNORECASE):\n        score += 0.4\n        indicators += 1\n\n    if re.search(r\"<html[\\s>]\", text, re.IGNORECASE):\n        score += 0.3\n        indicators += 1\n\n    # Structural tags\n    structural_tags = [\"<head\", \"<body\", \"<div\", \"<span\", \"<p>\", \"<table\", \"<form\"]\n    for tag in structural_tags:\n        if tag.lower() in text.lower():\n            score += 0.1\n            indicators += 1\n            if indicators >= 5:\n                break\n\n    # Header tags\n    if re.search(r\"<h[1-6][\\s>]\", text, re.IGNORECASE):\n        score += 0.15\n        indicators += 1\n\n    # Closing tags pattern\n    if re.search(r\"</\\w+>\", text):\n        score += 0.1\n        indicators += 1\n\n    return min(score, 1.0)\n\n\ndef _calculate_markdown_score(text: str) -> float:\n    \"\"\"Calculate confidence score for Markdown content.\"\"\"\n    score = 0.0\n    indicators = 0\n\n    # Headers (# ## ###) - strong indicator\n    header_matches = len(re.findall(r\"^#{1,6}\\s+.+\", text, re.MULTILINE))\n    if header_matches >= 3:\n        score += 0.35\n        indicators += 1\n    elif header_matches >= 1:\n        score += 0.2\n        indicators += 1\n\n    # Links [text](url) - strong indicator\n    link_matches = len(re.findall(r\"\\[.+?\\]\\(.+?\\)\", text))\n    if link_matches >= 2:\n        score += 0.25\n        indicators += 1\n    elif link_matches >= 1:\n        score += 0.15\n        indicators += 1\n\n    # Code blocks ``` - strong indicator\n    if re.search(r\"^```\", text, re.MULTILINE):\n        score += 0.2\n        indicators += 1\n\n    # Inline code `code`\n    if re.search(r\"`[^`]+`\", text):\n        score += 0.1\n        indicators += 1\n\n    # Lists (-, *, +, or numbered)\n    list_matches = len(re.findall(r\"^[\\*\\-\\+]\\s+\", text, re.MULTILINE))\n    list_matches += len(re.findall(r\"^\\d+\\.\\s+\", text, re.MULTILINE))\n    if list_matches >= 3:\n        score += 0.15\n        indicators += 1\n    elif list_matches >= 1:\n        score += 0.08\n        indicators += 1\n\n    # Bold/italic\n    if re.search(r\"\\*\\*.+?\\*\\*|__.+?__\", text):\n        score += 0.1\n        indicators += 1\n\n    # Blockquotes\n    if re.search(r\"^>\\s+\", text, re.MULTILINE):\n        score += 0.1\n        indicators += 1\n\n    return min(score, 1.0)\n\n\ndef detect_content_type(text: str, file_path: Optional[str] = None) -> ContentType:\n    \"\"\"\n    Detect content type using file extension (primary) and heuristics (fallback).\n\n    Strategy:\n    1. If file extension is available and recognized, use it as primary\n    2. If no extension or generic extension (.txt), use heuristics\n    3. Heuristics can override extension only with very high confidence\n\n    Args:\n        text: The text content\n        file_path: Optional file path for extension-based detection\n\n    Returns:\n        Detected ContentType\n    \"\"\"\n    # Try extension-based detection first\n    extension_type = detect_content_type_from_extension(file_path)\n\n    # Get heuristic-based detection\n    heuristic_type, confidence = detect_content_type_from_heuristics(text)\n\n    # If no extension or generic extension, use heuristics\n    if extension_type is None:\n        logger.debug(\n            f\"No file extension, using heuristics: {heuristic_type.value} \"\n            f\"(confidence: {confidence:.2f})\"\n        )\n        return heuristic_type\n\n    # If extension suggests plain text but heuristics are very confident, override\n    if extension_type == ContentType.PLAIN and confidence >= HIGH_CONFIDENCE_THRESHOLD:\n        logger.debug(\n            f\"Extension suggests plain, but heuristics override with \"\n            f\"{heuristic_type.value} (confidence: {confidence:.2f})\"\n        )\n        return heuristic_type\n\n    # Otherwise trust the extension\n    logger.debug(f\"Using extension-based content type: {extension_type.value}\")\n    return extension_type\n\n\ndef _get_html_splitter() -> HTMLHeaderTextSplitter:\n    \"\"\"Get HTML header splitter configured for h1, h2, h3.\"\"\"\n    headers_to_split_on = [\n        (\"h1\", \"Header 1\"),\n        (\"h2\", \"Header 2\"),\n        (\"h3\", \"Header 3\"),\n    ]\n    return HTMLHeaderTextSplitter(headers_to_split_on=headers_to_split_on)\n\n\ndef _get_markdown_splitter() -> MarkdownHeaderTextSplitter:\n    \"\"\"Get Markdown header splitter configured for #, ##, ###.\"\"\"\n    headers_to_split_on = [\n        (\"#\", \"Header 1\"),\n        (\"##\", \"Header 2\"),\n        (\"###\", \"Header 3\"),\n    ]\n    return MarkdownHeaderTextSplitter(\n        headers_to_split_on=headers_to_split_on,\n        strip_headers=False,\n    )\n\n\ndef _get_plain_splitter() -> RecursiveCharacterTextSplitter:\n    \"\"\"Get plain text splitter using CHUNK_SIZE and CHUNK_OVERLAP constants.\"\"\"\n    return RecursiveCharacterTextSplitter(\n        chunk_size=CHUNK_SIZE,\n        chunk_overlap=CHUNK_OVERLAP,\n        length_function=len,\n        separators=[\"\\n\\n\", \"\\n\", \". \", \", \", \" \", \"\"],\n    )\n\n\ndef _apply_secondary_chunking(chunks: List[str]) -> List[str]:\n    \"\"\"\n    Apply secondary chunking to ensure no chunk exceeds CHUNK_SIZE.\n\n    Used when primary splitters (HTML/Markdown) produce oversized chunks.\n    \"\"\"\n    result = []\n    secondary_splitter = _get_plain_splitter()\n\n    for chunk in chunks:\n        if len(chunk) > CHUNK_SIZE:\n            # Split oversized chunk\n            sub_chunks = secondary_splitter.split_text(chunk)\n            result.extend(sub_chunks)\n        else:\n            result.append(chunk)\n\n    return result\n\n\ndef chunk_text(\n    text: str,\n    content_type: Optional[ContentType] = None,\n    file_path: Optional[str] = None,\n) -> List[str]:\n    \"\"\"\n    Split text into chunks using appropriate splitter for content type.\n\n    Args:\n        text: The text to chunk\n        content_type: Optional explicit content type (auto-detected if not provided)\n        file_path: Optional file path for content type detection\n\n    Returns:\n        List of text chunks, each <= CHUNK_SIZE characters\n    \"\"\"\n    if not text or not text.strip():\n        return []\n\n    # Short text doesn't need chunking\n    if len(text) <= CHUNK_SIZE:\n        return [text]\n\n    # Detect content type if not provided\n    if content_type is None:\n        content_type = detect_content_type(text, file_path)\n\n    logger.debug(f\"Chunking text with content type: {content_type.value}\")\n\n    # Select appropriate splitter\n    if content_type == ContentType.HTML:\n        splitter = _get_html_splitter()\n        # HTML splitter returns Document objects\n        docs = splitter.split_text(text)\n        chunks = [\n            doc.page_content if hasattr(doc, \"page_content\") else str(doc)\n            for doc in docs\n        ]\n    elif content_type == ContentType.MARKDOWN:\n        splitter = _get_markdown_splitter()\n        # Markdown splitter returns Document objects\n        docs = splitter.split_text(text)\n        chunks = [\n            doc.page_content if hasattr(doc, \"page_content\") else str(doc)\n            for doc in docs\n        ]\n    else:\n        # Plain text - use recursive splitter directly\n        splitter = _get_plain_splitter()\n        chunks = splitter.split_text(text)\n\n    # Apply secondary chunking if needed (for HTML/Markdown that may produce large chunks)\n    if content_type in (ContentType.HTML, ContentType.MARKDOWN):\n        chunks = _apply_secondary_chunking(chunks)\n\n    # Filter out empty chunks\n    chunks = [c.strip() for c in chunks if c and c.strip()]\n\n    logger.debug(f\"Created {len(chunks)} chunks from {len(text)} characters\")\n    return chunks\n"
  },
  {
    "path": "open_notebook/utils/context_builder.py",
    "content": "\"\"\"\nGeneric ContextBuilder for the Open Notebook project.\n\nThis module provides a flexible ContextBuilder class that can handle any parameters\nand build context from sources, notebooks, insights, and notes.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, List, Literal, Optional\n\nfrom loguru import logger\n\nfrom open_notebook.domain.notebook import Note, Notebook, Source\nfrom open_notebook.exceptions import DatabaseOperationError, NotFoundError\n\nfrom .token_utils import token_count\n\n\n@dataclass\nclass ContextItem:\n    \"\"\"Represents a single item in the context.\"\"\"\n\n    id: str\n    type: Literal[\"source\", \"note\", \"insight\"]\n    content: Dict[str, Any]\n    priority: int = 0\n    token_count: Optional[int] = None\n\n    def __post_init__(self):\n        \"\"\"Calculate token count for the content if not provided.\"\"\"\n        if self.token_count is None:\n            content_str = str(self.content)\n            self.token_count = token_count(content_str)\n\n\n@dataclass\nclass ContextConfig:\n    \"\"\"Configuration for context building.\"\"\"\n\n    sources: Optional[Dict[str, str]] = None  # {source_id: inclusion_level}\n    notes: Optional[Dict[str, str]] = None  # {note_id: inclusion_level}\n    include_insights: bool = True\n    include_notes: bool = True\n    max_tokens: Optional[int] = None\n    priority_weights: Optional[Dict[str, int]] = None  # {type: weight}\n\n    def __post_init__(self):\n        \"\"\"Initialize default values.\"\"\"\n        if self.sources is None:\n            self.sources = {}\n        if self.notes is None:\n            self.notes = {}\n        if self.priority_weights is None:\n            self.priority_weights = {\"source\": 100, \"note\": 50, \"insight\": 75}\n\n\nclass ContextBuilder:\n    \"\"\"\n    Generic ContextBuilder that can handle any parameters and build context\n    from sources, notebooks, insights, and notes.\n    \"\"\"\n\n    def __init__(self, **kwargs):\n        \"\"\"\n        Initialize ContextBuilder with flexible parameters.\n\n        Supported parameters:\n        - source_id: str - Include specific source\n        - notebook_id: str - Include notebook content\n        - include_insights: bool - Include source insights\n        - include_notes: bool - Include notes\n        - context_config: ContextConfig - Custom context configuration\n        - max_tokens: int - Maximum token limit\n        - priority_order: List[str] - Custom priority order\n        \"\"\"\n        # Store all parameters for flexibility\n        self.params = kwargs\n\n        # Extract commonly used parameters\n        self.source_id: Optional[str] = kwargs.get(\"source_id\")\n        self.notebook_id: Optional[str] = kwargs.get(\"notebook_id\")\n        self.include_insights: bool = kwargs.get(\"include_insights\", True)\n        self.include_notes: bool = kwargs.get(\"include_notes\", True)\n        self.max_tokens: Optional[int] = kwargs.get(\"max_tokens\")\n\n        # Context configuration\n        context_config_arg: Optional[ContextConfig] = kwargs.get(\"context_config\")\n        self.context_config: ContextConfig\n        if context_config_arg is None:\n            self.context_config = ContextConfig(\n                include_insights=self.include_insights,\n                include_notes=self.include_notes,\n                max_tokens=self.max_tokens,\n            )\n        else:\n            self.context_config = context_config_arg\n\n        # Items storage\n        self.items: List[ContextItem] = []\n\n        logger.debug(f\"ContextBuilder initialized with params: {list(kwargs.keys())}\")\n\n    async def build(self) -> Dict[str, Any]:\n        \"\"\"\n        Build context based on provided parameters.\n\n        Returns:\n            Dict containing the built context with metadata\n        \"\"\"\n        try:\n            logger.info(\"Starting context building\")\n\n            # Clear existing items\n            self.items = []\n\n            # Build context based on parameters\n            if self.source_id:\n                await self._add_source_context(self.source_id)\n\n            if self.notebook_id:\n                await self._add_notebook_context(self.notebook_id)\n\n            # Process any additional custom parameters\n            await self._process_custom_params()\n\n            # Apply post-processing\n            self.remove_duplicates()\n            self.prioritize()\n\n            if self.max_tokens:\n                self.truncate_to_fit(self.max_tokens)\n\n            # Format and return response\n            return self._format_response()\n\n        except Exception as e:\n            logger.error(f\"Error building context: {str(e)}\")\n            raise DatabaseOperationError(f\"Failed to build context: {str(e)}\")\n\n    async def _add_source_context(\n        self, source_id: str, inclusion_level: str = \"insights\"\n    ) -> None:\n        \"\"\"\n        Add source and its insights to context.\n\n        Args:\n            source_id: ID of the source\n            inclusion_level: \"insights\", \"full content\", or \"not in\"\n        \"\"\"\n        if inclusion_level == \"not in\":\n            return\n\n        try:\n            # Ensure source ID has table prefix\n            full_source_id = (\n                source_id if source_id.startswith(\"source:\") else f\"source:{source_id}\"\n            )\n\n            source = await Source.get(full_source_id)\n            if not source:\n                logger.warning(f\"Source {source_id} not found\")\n                return\n\n            # Determine context size based on inclusion level\n            context_size: Literal[\"short\", \"long\"] = (\n                \"long\" if \"full content\" in inclusion_level else \"short\"\n            )\n            source_context = await source.get_context(context_size=context_size)\n\n            # Add source item\n            priority = (self.context_config.priority_weights or {}).get(\"source\", 100)\n            item = ContextItem(\n                id=source.id or \"\",\n                type=\"source\",\n                content=source_context,\n                priority=priority,\n            )\n            self.add_item(item)\n\n            # Add insights if requested and available\n            if self.include_insights and \"insights\" in inclusion_level:\n                insights = await source.get_insights()\n                for insight in insights:\n                    insight_priority = (self.context_config.priority_weights or {}).get(\n                        \"insight\", 75\n                    )\n                    insight_item = ContextItem(\n                        id=insight.id or \"\",\n                        type=\"insight\",\n                        content={\n                            \"id\": insight.id,\n                            \"source_id\": source.id,\n                            \"insight_type\": insight.insight_type,\n                            \"content\": insight.content,\n                        },\n                        priority=insight_priority,\n                    )\n                    self.add_item(insight_item)\n\n            logger.debug(f\"Added source context for {source_id}\")\n\n        except NotFoundError:\n            logger.warning(f\"Source {source_id} not found\")\n        except Exception as e:\n            logger.error(f\"Error adding source context for {source_id}: {str(e)}\")\n            raise\n\n    async def _add_notebook_context(self, notebook_id: str) -> None:\n        \"\"\"\n        Add notebook content based on context configuration.\n\n        Args:\n            notebook_id: ID of the notebook\n        \"\"\"\n        try:\n            notebook = await Notebook.get(notebook_id)\n            if not notebook:\n                raise NotFoundError(f\"Notebook {notebook_id} not found\")\n\n            # Process sources from context config or get all\n            config_sources = self.context_config.sources\n            if config_sources:\n                for source_id, status in config_sources.items():\n                    await self._add_source_context(source_id, status)\n            else:\n                # Default: get all sources with insights\n                sources = await notebook.get_sources()\n                for source in sources:\n                    if source.id:\n                        await self._add_source_context(source.id, \"insights\")\n\n            # Process notes from context config or get all\n            if self.include_notes:\n                config_notes = self.context_config.notes\n                if config_notes:\n                    for note_id, status in config_notes.items():\n                        if \"not in\" not in status:\n                            await self._add_note_context(note_id, status)\n                else:\n                    # Default: get all notes with short content\n                    notes = await notebook.get_notes()\n                    for note in notes:\n                        if note.id:\n                            await self._add_note_context(note.id, \"full content\")\n\n            logger.debug(f\"Added notebook context for {notebook_id}\")\n\n        except Exception as e:\n            logger.error(f\"Error adding notebook context for {notebook_id}: {str(e)}\")\n            raise\n\n    async def _add_note_context(\n        self, note_id: str, inclusion_level: str = \"full content\"\n    ) -> None:\n        \"\"\"\n        Add note to context.\n\n        Args:\n            note_id: ID of the note\n            inclusion_level: \"full content\" or \"not in\"\n        \"\"\"\n        if inclusion_level == \"not in\":\n            return\n\n        try:\n            # Ensure note ID has table prefix\n            full_note_id = note_id if note_id.startswith(\"note:\") else f\"note:{note_id}\"\n\n            note = await Note.get(full_note_id)\n            if not note:\n                logger.warning(f\"Note {note_id} not found\")\n                return\n\n            # Get note context\n            context_size: Literal[\"short\", \"long\"] = (\n                \"long\" if \"full content\" in inclusion_level else \"short\"\n            )\n            note_context = note.get_context(context_size=context_size)\n\n            # Add note item\n            priority = (self.context_config.priority_weights or {}).get(\"note\", 50)\n            item = ContextItem(\n                id=note.id or \"\", type=\"note\", content=note_context, priority=priority\n            )\n            self.add_item(item)\n\n            logger.debug(f\"Added note context for {note_id}\")\n\n        except NotFoundError:\n            logger.warning(f\"Note {note_id} not found\")\n        except Exception as e:\n            logger.error(f\"Error adding note context for {note_id}: {str(e)}\")\n\n    async def _process_custom_params(self) -> None:\n        \"\"\"Process any additional custom parameters.\"\"\"\n        # Hook for future extensions - can be overridden in subclasses\n        # or used to process additional kwargs\n        for key, value in self.params.items():\n            if key.startswith(\"custom_\"):\n                logger.debug(f\"Processing custom parameter: {key}={value}\")\n                # Custom processing logic can be added here\n\n    def add_item(self, item: ContextItem) -> None:\n        \"\"\"\n        Add a ContextItem to the builder.\n\n        Args:\n            item: ContextItem to add\n        \"\"\"\n        self.items.append(item)\n        logger.debug(f\"Added item {item.id} with priority {item.priority}\")\n\n    def prioritize(self) -> None:\n        \"\"\"Sort items by priority (higher priority first).\"\"\"\n        self.items.sort(key=lambda x: x.priority, reverse=True)\n        logger.debug(f\"Prioritized {len(self.items)} items\")\n\n    def truncate_to_fit(self, max_tokens: int) -> None:\n        \"\"\"\n        Remove items if total token count exceeds limit.\n\n        Args:\n            max_tokens: Maximum allowed tokens\n        \"\"\"\n        if not max_tokens:\n            return\n\n        total_tokens = sum(item.token_count or 0 for item in self.items)\n\n        if total_tokens <= max_tokens:\n            logger.debug(f\"Token count {total_tokens} within limit {max_tokens}\")\n            return\n\n        logger.info(f\"Truncating from {total_tokens} to {max_tokens} tokens\")\n\n        # Remove items from the end (lowest priority) until under limit\n        current_tokens = total_tokens\n        removed_count = 0\n\n        while current_tokens > max_tokens and self.items:\n            removed_item = self.items.pop()\n            current_tokens -= removed_item.token_count or 0\n            removed_count += 1\n\n        logger.info(\n            f\"Removed {removed_count} items, final token count: {current_tokens}\"\n        )\n\n    def remove_duplicates(self) -> None:\n        \"\"\"Remove duplicate items based on ID.\"\"\"\n        seen_ids = set()\n        deduplicated_items = []\n\n        for item in self.items:\n            if item.id not in seen_ids:\n                deduplicated_items.append(item)\n                seen_ids.add(item.id)\n\n        removed_count = len(self.items) - len(deduplicated_items)\n        self.items = deduplicated_items\n\n        if removed_count > 0:\n            logger.debug(f\"Removed {removed_count} duplicate items\")\n\n    def _format_response(self) -> Dict[str, Any]:\n        \"\"\"\n        Format the final response.\n\n        Returns:\n            Formatted context response\n        \"\"\"\n        # Group items by type\n        sources = []\n        notes = []\n        insights = []\n\n        for item in self.items:\n            if item.type == \"source\":\n                sources.append(item.content)\n            elif item.type == \"note\":\n                notes.append(item.content)\n            elif item.type == \"insight\":\n                insights.append(item.content)\n\n        # Calculate total tokens\n        total_tokens = sum(item.token_count or 0 for item in self.items)\n\n        response = {\n            \"sources\": sources,\n            \"notes\": notes,\n            \"insights\": insights,\n            \"total_tokens\": total_tokens,\n            \"total_items\": len(self.items),\n            \"metadata\": {\n                \"source_count\": len(sources),\n                \"note_count\": len(notes),\n                \"insight_count\": len(insights),\n                \"config\": {\n                    \"include_insights\": self.include_insights,\n                    \"include_notes\": self.include_notes,\n                    \"max_tokens\": self.max_tokens,\n                },\n            },\n        }\n\n        # Add notebook_id if provided\n        if self.notebook_id:\n            response[\"notebook_id\"] = self.notebook_id\n\n        logger.info(\n            f\"Built context with {len(self.items)} items, {total_tokens} tokens\"\n        )\n\n        return response\n\n\n# Convenience functions for common use cases\n\n\nasync def build_notebook_context(\n    notebook_id: str,\n    context_config: Optional[ContextConfig] = None,\n    max_tokens: Optional[int] = None,\n) -> Dict[str, Any]:\n    \"\"\"\n    Build context for a notebook.\n\n    Args:\n        notebook_id: ID of the notebook\n        context_config: Optional context configuration\n        max_tokens: Optional token limit\n\n    Returns:\n        Built context\n    \"\"\"\n    builder = ContextBuilder(\n        notebook_id=notebook_id, context_config=context_config, max_tokens=max_tokens\n    )\n    return await builder.build()\n\n\nasync def build_source_context(\n    source_id: str, include_insights: bool = True, max_tokens: Optional[int] = None\n) -> Dict[str, Any]:\n    \"\"\"\n    Build context for a single source.\n\n    Args:\n        source_id: ID of the source\n        include_insights: Whether to include insights\n        max_tokens: Optional token limit\n\n    Returns:\n        Built context\n    \"\"\"\n    builder = ContextBuilder(\n        source_id=source_id, include_insights=include_insights, max_tokens=max_tokens\n    )\n    return await builder.build()\n\n\nasync def build_mixed_context(\n    source_ids: Optional[List[str]] = None,\n    note_ids: Optional[List[str]] = None,\n    notebook_id: Optional[str] = None,\n    max_tokens: Optional[int] = None,\n) -> Dict[str, Any]:\n    \"\"\"\n    Build context from mixed sources.\n\n    Args:\n        source_ids: List of source IDs\n        note_ids: List of note IDs\n        notebook_id: Optional notebook ID\n        max_tokens: Optional token limit\n\n    Returns:\n        Built context\n    \"\"\"\n    context_config = ContextConfig(max_tokens=max_tokens)\n\n    # Configure sources\n    if source_ids:\n        context_config.sources = {sid: \"insights\" for sid in source_ids}\n\n    # Configure notes\n    if note_ids:\n        context_config.notes = {nid: \"full content\" for nid in note_ids}\n\n    builder = ContextBuilder(\n        notebook_id=notebook_id, context_config=context_config, max_tokens=max_tokens\n    )\n    return await builder.build()\n"
  },
  {
    "path": "open_notebook/utils/embedding.py",
    "content": "\"\"\"\nUnified embedding utilities for Open Notebook.\n\nProvides centralized embedding generation with support for:\n- Single text embedding (with automatic chunking and mean pooling for large texts)\n- Batch text embedding (multiple texts with automatic batching)\n- Mean pooling for combining multiple embeddings into one\n\nAll embedding operations in the application should use these functions\nto ensure consistent behavior and proper handling of large content.\n\"\"\"\n\nimport asyncio\nfrom typing import TYPE_CHECKING, List, Optional\n\nimport numpy as np\nfrom loguru import logger\n\nfrom .chunking import CHUNK_SIZE, ContentType, chunk_text\n\nEMBEDDING_BATCH_SIZE = 50\nEMBEDDING_MAX_RETRIES = 3\nEMBEDDING_RETRY_DELAY = 2  # seconds\n\n# Lazy import to avoid circular dependency:\n# utils -> embedding -> models -> key_provider -> provider_config -> utils\nif TYPE_CHECKING:\n    from open_notebook.ai.models import ModelManager\n\n\nasync def mean_pool_embeddings(embeddings: List[List[float]]) -> List[float]:\n    \"\"\"\n    Combine multiple embeddings into a single embedding using mean pooling.\n\n    Algorithm:\n    1. Normalize each embedding to unit length\n    2. Compute element-wise mean\n    3. Normalize the result to unit length\n\n    This approach ensures the final embedding has the same properties as\n    individual embeddings (unit length) regardless of input count.\n\n    Args:\n        embeddings: List of embedding vectors (each is a list of floats)\n\n    Returns:\n        Single embedding vector (mean pooled and normalized)\n\n    Raises:\n        ValueError: If embeddings list is empty or embeddings have different dimensions\n    \"\"\"\n    if not embeddings:\n        raise ValueError(\"Cannot mean pool empty list of embeddings\")\n\n    if len(embeddings) == 1:\n        # Single embedding - just normalize and return\n        arr = np.array(embeddings[0], dtype=np.float64)\n        norm = np.linalg.norm(arr)\n        if norm > 0:\n            arr = arr / norm\n        return arr.tolist()\n\n    # Convert to numpy array\n    arr = np.array(embeddings, dtype=np.float64)\n\n    # Verify all embeddings have same dimension\n    if arr.ndim != 2:\n        raise ValueError(f\"Expected 2D array, got shape {arr.shape}\")\n\n    # Normalize each embedding to unit length\n    norms = np.linalg.norm(arr, axis=1, keepdims=True)\n    # Avoid division by zero\n    norms = np.where(norms > 0, norms, 1.0)\n    normalized = arr / norms\n\n    # Compute mean\n    mean = np.mean(normalized, axis=0)\n\n    # Normalize the result\n    mean_norm = np.linalg.norm(mean)\n    if mean_norm > 0:\n        mean = mean / mean_norm\n\n    return mean.tolist()\n\n\nasync def generate_embeddings(\n    texts: List[str], command_id: Optional[str] = None\n) -> List[List[float]]:\n    \"\"\"\n    Generate embeddings for multiple texts with automatic batching and retry.\n\n    Texts are split into batches of EMBEDDING_BATCH_SIZE to avoid exceeding\n    provider payload limits. Each batch is retried up to EMBEDDING_MAX_RETRIES\n    times on transient failures.\n\n    Args:\n        texts: List of text strings to embed\n        command_id: Optional command ID for error logging context\n\n    Returns:\n        List of embedding vectors, one per input text\n\n    Raises:\n        ValueError: If no embedding model is configured\n        RuntimeError: If embedding generation fails\n    \"\"\"\n    if not texts:\n        return []\n\n    # Lazy import to avoid circular dependency\n    from open_notebook.ai.models import model_manager\n\n    embedding_model = await model_manager.get_embedding_model()\n    if not embedding_model:\n        raise ValueError(\n            \"No embedding model configured. Please configure one in the Models section.\"\n        )\n\n    model_name = getattr(embedding_model, \"model_name\", \"unknown\")\n\n    # Log text sizes for debugging\n    text_sizes = [len(t) for t in texts]\n    logger.debug(\n        f\"Generating embeddings for {len(texts)} texts \"\n        f\"(sizes: min={min(text_sizes)}, max={max(text_sizes)}, \"\n        f\"total={sum(text_sizes)} chars)\"\n    )\n\n    all_embeddings: List[List[float]] = []\n    total_batches = (len(texts) + EMBEDDING_BATCH_SIZE - 1) // EMBEDDING_BATCH_SIZE\n\n    for batch_idx in range(total_batches):\n        start = batch_idx * EMBEDDING_BATCH_SIZE\n        end = start + EMBEDDING_BATCH_SIZE\n        batch = texts[start:end]\n\n        for attempt in range(1, EMBEDDING_MAX_RETRIES + 1):\n            try:\n                batch_embeddings = await embedding_model.aembed(batch)\n                all_embeddings.extend(batch_embeddings)\n                break\n            except Exception as e:\n                cmd_context = f\" (command: {command_id})\" if command_id else \"\"\n                if attempt < EMBEDDING_MAX_RETRIES:\n                    logger.debug(\n                        f\"Embedding batch {batch_idx + 1}/{total_batches} \"\n                        f\"attempt {attempt}/{EMBEDDING_MAX_RETRIES} failed \"\n                        f\"using model '{model_name}'{cmd_context}: {e}. Retrying...\"\n                    )\n                    await asyncio.sleep(EMBEDDING_RETRY_DELAY)\n                else:\n                    logger.debug(\n                        f\"Embedding batch {batch_idx + 1}/{total_batches} \"\n                        f\"failed after {EMBEDDING_MAX_RETRIES} attempts \"\n                        f\"using model '{model_name}'{cmd_context}: {e}\"\n                    )\n                    raise RuntimeError(\n                        f\"Failed to generate embeddings using model '{model_name}' \"\n                        f\"(batch {batch_idx + 1}/{total_batches}, \"\n                        f\"{len(batch)} texts): {e}\"\n                    ) from e\n\n    logger.debug(f\"Generated {len(all_embeddings)} embeddings in {total_batches} batch(es)\")\n    return all_embeddings\n\n\nasync def generate_embedding(\n    text: str,\n    content_type: Optional[ContentType] = None,\n    file_path: Optional[str] = None,\n    command_id: Optional[str] = None,\n) -> List[float]:\n    \"\"\"\n    Generate a single embedding for text, handling large content via chunking and mean pooling.\n\n    For short text (<= CHUNK_SIZE):\n        - Embeds directly and returns the embedding\n\n    For long text (> CHUNK_SIZE):\n        - Chunks the text using appropriate splitter for content type\n        - Embeds all chunks in batches\n        - Combines embeddings via mean pooling\n\n    Args:\n        text: The text to embed\n        content_type: Optional explicit content type for chunking\n        file_path: Optional file path for content type detection\n        command_id: Optional command ID for error logging context\n\n    Returns:\n        Single embedding vector (list of floats)\n\n    Raises:\n        ValueError: If text is empty or no embedding model configured\n        RuntimeError: If embedding generation fails\n    \"\"\"\n    if not text or not text.strip():\n        raise ValueError(\"Cannot generate embedding for empty text\")\n\n    text = text.strip()\n\n    # Check if chunking is needed\n    if len(text) <= CHUNK_SIZE:\n        # Short text - embed directly\n        logger.debug(f\"Embedding short text ({len(text)} chars) directly\")\n        embeddings = await generate_embeddings([text], command_id=command_id)\n        return embeddings[0]\n\n    # Long text - chunk and mean pool\n    logger.debug(f\"Text exceeds chunk size ({len(text)} chars), chunking...\")\n\n    chunks = chunk_text(text, content_type=content_type, file_path=file_path)\n\n    if not chunks:\n        raise ValueError(\"Text chunking produced no chunks\")\n\n    if len(chunks) == 1:\n        # Single chunk after splitting\n        embeddings = await generate_embeddings(chunks, command_id=command_id)\n        return embeddings[0]\n\n    logger.debug(f\"Embedding {len(chunks)} chunks and mean pooling\")\n\n    # Embed all chunks in batches\n    embeddings = await generate_embeddings(chunks, command_id=command_id)\n\n    # Mean pool to get single embedding\n    pooled = await mean_pool_embeddings(embeddings)\n\n    logger.debug(f\"Mean pooled {len(embeddings)} embeddings into single vector\")\n    return pooled\n"
  },
  {
    "path": "open_notebook/utils/encryption.py",
    "content": "\"\"\"\nField-level encryption for sensitive data using API keys.\n\nThis module provides encryption/decryption for API keys stored in the database.\nFernet uses AES-128-CBC with HMAC-SHA256 for authenticated encryption.\n\nOPEN_NOTEBOOK_ENCRYPTION_KEY accepts **any string**. A Fernet key is derived\nfrom it via SHA-256, so users can set a simple passphrase like\n``OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret`` and it will work.\n\nUsage:\n    # Encrypt before storing\n    encrypted = encrypt_value(api_key)\n\n    # Decrypt when reading\n    decrypted = decrypt_value(encrypted)\n\"\"\"\n\nimport base64\nimport hashlib\nimport os\nfrom pathlib import Path\nfrom typing import Optional\n\nfrom cryptography.fernet import Fernet, InvalidToken\nfrom loguru import logger\n\n\ndef get_secret_from_env(var_name: str) -> Optional[str]:\n    \"\"\"\n    Get a secret from environment, supporting Docker secrets pattern.\n\n    Checks for VAR_FILE first (Docker secrets), then falls back to VAR.\n\n    Args:\n        var_name: Base name of the environment variable (e.g., \"OPEN_NOTEBOOK_ENCRYPTION_KEY\")\n\n    Returns:\n        The secret value, or None if not configured.\n    \"\"\"\n    # Check for _FILE variant first (Docker secrets)\n    file_path = os.environ.get(f\"{var_name}_FILE\")\n    if file_path:\n        try:\n            path = Path(file_path)\n            if path.exists() and path.is_file():\n                secret = path.read_text().strip()\n                if secret:\n                    logger.debug(f\"Loaded {var_name} from file: {file_path}\")\n                    return secret\n                else:\n                    logger.warning(f\"{var_name}_FILE points to empty file: {file_path}\")\n            else:\n                logger.warning(f\"{var_name}_FILE path does not exist: {file_path}\")\n        except Exception as e:\n            logger.error(f\"Failed to read {var_name} from file {file_path}: {e}\")\n\n    # Fall back to direct environment variable\n    return os.environ.get(var_name)\n\n\ndef _get_or_create_encryption_key() -> str:\n    \"\"\"\n    Get encryption key from environment, requires explicit configuration.\n\n    Priority:\n    1. OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets)\n    2. OPEN_NOTEBOOK_ENCRYPTION_KEY (environment variable)\n\n    For production deployments, you MUST set OPEN_NOTEBOOK_ENCRYPTION_KEY explicitly!\n\n    Returns:\n        Encryption key string.\n\n    Raises:\n        ValueError: If no encryption key is configured.\n    \"\"\"\n    # First check environment/Docker secrets\n    key = get_secret_from_env(\"OPEN_NOTEBOOK_ENCRYPTION_KEY\")\n    if key:\n        return key\n\n    raise ValueError(\n        \"OPEN_NOTEBOOK_ENCRYPTION_KEY is not set. \"\n        \"Set this environment variable to any secret string to enable \"\n        \"encrypted storage of API keys in the database.\"\n    )\n\n\n# Lazy-loaded encryption key: initialized on first use, not at import time.\n# This prevents the entire app from crashing if the key is not yet configured\n# when other modules import from this file.\n_ENCRYPTION_KEY: Optional[str] = None\n\n\ndef _get_encryption_key() -> str:\n    \"\"\"Get the encryption key, initializing lazily on first call.\"\"\"\n    global _ENCRYPTION_KEY\n    if _ENCRYPTION_KEY is None:\n        _ENCRYPTION_KEY = _get_or_create_encryption_key()\n    return _ENCRYPTION_KEY\n\n\ndef _ensure_fernet_key(key: str) -> str:\n    \"\"\"\n    Derive a valid Fernet key from an arbitrary string via SHA-256.\n\n    Any string is accepted as input. The key is derived by hashing it with\n    SHA-256 and encoding the result as URL-safe base64.\n    \"\"\"\n    derived = hashlib.sha256(key.encode()).digest()\n    return base64.urlsafe_b64encode(derived).decode()\n\n\ndef get_fernet() -> Fernet:\n    \"\"\"\n    Get Fernet instance with the configured encryption key.\n\n    Returns:\n        Fernet instance.\n\n    Raises:\n        ValueError: If encryption key is not configured.\n    \"\"\"\n    return Fernet(_ensure_fernet_key(_get_encryption_key()).encode())\n\n\ndef encrypt_value(value: str) -> str:\n    \"\"\"\n    Encrypt a string value using Fernet symmetric encryption.\n\n    Args:\n        value: The plain text string to encrypt.\n\n    Returns:\n        Base64-encoded encrypted string.\n\n    Raises:\n        ValueError: If encryption is not configured.\n    \"\"\"\n    fernet = get_fernet()\n    return fernet.encrypt(value.encode()).decode()\n\n\ndef looks_like_fernet_token(s: str) -> bool:\n    \"\"\"\n    Check if string looks like a Fernet encrypted token.\n\n    Fernet tokens are versioned (1 byte) + timestamp (8 bytes) + IV (16 bytes)\n    + ciphertext (variable, multiple of 16 with PKCS7 padding) + HMAC (32 bytes).\n    Minimum decoded size is 73 bytes (1+8+16+16+32) for the smallest payload.\n    \"\"\"\n    if len(s) < 100:  # Base64 of 73 bytes = ~100 chars minimum\n        return False\n    try:\n        decoded = base64.urlsafe_b64decode(s)\n        # Fernet: version(1) + timestamp(8) + IV(16) + ciphertext(>=16) + HMAC(32)\n        # Minimum 73 bytes, ciphertext must be multiple of 16 (AES block size)\n        if len(decoded) < 73:\n            return False\n        ciphertext_len = len(decoded) - 1 - 8 - 16 - 32\n        return ciphertext_len > 0 and ciphertext_len % 16 == 0\n    except Exception:\n        return False\n\n\ndef decrypt_value(value: str) -> str:\n    \"\"\"\n    Decrypt a Fernet-encrypted string value.\n\n    Handles graceful fallback for legacy unencrypted data.\n\n    Args:\n        value: The encrypted string (or plain text for legacy data).\n\n    Returns:\n        Decrypted plain text string, or original value if not encrypted.\n\n    Raises:\n        ValueError: If encryption is not configured or if decryption fails\n            for what appears to be encrypted data (wrong key).\n    \"\"\"\n    fernet = get_fernet()\n\n    try:\n        return fernet.decrypt(value.encode()).decode()\n    except InvalidToken:\n        if looks_like_fernet_token(value):\n            # Looks like encrypted data but failed to decrypt - likely wrong key\n            raise ValueError(\n                \"Decryption failed: data appears to be encrypted but key is incorrect. \"\n                \"Check OPEN_NOTEBOOK_ENCRYPTION_KEY configuration.\"\n            )\n        # Not a valid token - treat as legacy plaintext\n        return value\n    except Exception as e:\n        logger.error(f\"Decryption failed: {e}\")\n        raise ValueError(f\"Decryption failed: {str(e)}\")\n"
  },
  {
    "path": "open_notebook/utils/error_classifier.py",
    "content": "\"\"\"\nError classification utility for LLM provider errors.\n\nMaps raw exceptions from AI providers/Esperanto/LangChain to user-friendly\nerror messages and appropriate exception types.\n\"\"\"\n\nfrom loguru import logger\n\nfrom open_notebook.exceptions import (\n    AuthenticationError,\n    ConfigurationError,\n    ExternalServiceError,\n    NetworkError,\n    OpenNotebookError,\n    RateLimitError,\n)\n\n# Classification rules: (keywords, exception_class, user_message or None to pass through)\n_CLASSIFICATION_RULES: list[tuple[list[str], type[OpenNotebookError], str | None]] = [\n    # Authentication errors\n    (\n        [\"authentication\", \"unauthorized\", \"invalid api key\", \"invalid_api_key\", \"401\"],\n        AuthenticationError,\n        \"Authentication failed. Please check your API key in Settings -> Credentials.\",\n    ),\n    # Rate limit errors\n    (\n        [\"rate limit\", \"rate_limit\", \"429\", \"too many requests\", \"quota exceeded\"],\n        RateLimitError,\n        \"Rate limit exceeded. Please wait a moment and try again.\",\n    ),\n    # Model not found (pass through original message)\n    (\n        [\"model not found\", \"does not exist\", \"model_not_found\"],\n        ConfigurationError,\n        None,\n    ),\n    # Configuration errors from provision.py (pass through)\n    (\n        [\"no model configured\", \"please go to settings\"],\n        ConfigurationError,\n        None,\n    ),\n    # Network errors\n    (\n        [\"connecterror\", \"timeoutexception\", \"connection refused\", \"connection error\", \"timed out\", \"timeout\"],\n        NetworkError,\n        \"Could not connect to the AI provider. Please check your network connection and provider URL.\",\n    ),\n    # Context length errors\n    (\n        [\"context length\", \"token limit\", \"maximum context\", \"context_length_exceeded\", \"max_tokens\"],\n        ExternalServiceError,\n        \"Content too large for the selected model. Try using a smaller selection or a model with a larger context window.\",\n    ),\n    # Payload too large errors\n    (\n        [\"413\", \"payload too large\", \"request entity too large\"],\n        ExternalServiceError,\n        \"The request payload is too large for the AI provider. Try reducing the content size or using a different model.\",\n    ),\n    # Provider availability errors\n    (\n        [\"500\", \"502\", \"503\", \"service unavailable\", \"overloaded\", \"internal server error\"],\n        ExternalServiceError,\n        \"The AI provider is temporarily unavailable. Please try again in a few minutes.\",\n    ),\n]\n\n\ndef classify_error(exception: BaseException) -> tuple[type[OpenNotebookError], str]:\n    \"\"\"\n    Classify a raw exception into a user-friendly error type and message.\n\n    Args:\n        exception: Any exception from LLM providers/Esperanto/LangChain\n\n    Returns:\n        Tuple of (exception_class, user_friendly_message)\n    \"\"\"\n    error_str = str(exception).lower()\n    error_type_name = type(exception).__name__.lower()\n    combined = f\"{error_type_name}: {error_str}\"\n\n    for keywords, exc_class, message in _CLASSIFICATION_RULES:\n        for keyword in keywords:\n            if keyword in combined:\n                user_message = message if message is not None else _truncate(str(exception))\n                return exc_class, user_message\n\n    # Unclassified error - log for future improvement\n    logger.warning(\n        f\"Unclassified LLM error ({type(exception).__name__}): {exception}\"\n    )\n    return ExternalServiceError, f\"AI service error: {_truncate(str(exception))}\"\n\n\ndef _truncate(text: str, max_length: int = 200) -> str:\n    \"\"\"Truncate text to max_length to avoid leaking verbose internal details.\"\"\"\n    if len(text) <= max_length:\n        return text\n    return text[:max_length] + \"...\"\n"
  },
  {
    "path": "open_notebook/utils/graph_utils.py",
    "content": "import asyncio\n\nfrom langchain_core.runnables import RunnableConfig\nfrom loguru import logger\n\n\nasync def get_session_message_count(graph, session_id: str) -> int:\n    \"\"\"Get message count from LangGraph state, returns 0 on error.\"\"\"\n    try:\n        # Use sync get_state() in a thread (SqliteSaver doesn't support async)\n        thread_state = await asyncio.to_thread(\n            graph.get_state,\n            config=RunnableConfig(configurable={\"thread_id\": session_id}),\n        )\n        if (\n            thread_state\n            and thread_state.values\n            and \"messages\" in thread_state.values\n        ):\n            return len(thread_state.values[\"messages\"])\n    except Exception as e:\n        logger.warning(f\"Could not fetch message count for session {session_id}: {e}\")\n    return 0\n"
  },
  {
    "path": "open_notebook/utils/text_utils.py",
    "content": "\"\"\"\nText utilities for Open Notebook.\nExtracted from main utils to avoid circular imports.\n\"\"\"\n\nimport re\nimport unicodedata\nfrom typing import Tuple\n\n# Patterns for matching thinking content in AI responses\n# Standard pattern: <think>...</think>\nTHINK_PATTERN = re.compile(r\"<think>(.*?)</think>\", re.DOTALL)\n# Pattern for malformed output: content</think> (missing opening tag)\nTHINK_PATTERN_NO_OPEN = re.compile(r\"^(.*?)</think>\", re.DOTALL)\n\n\ndef remove_non_ascii(text: str) -> str:\n    \"\"\"Remove non-ASCII characters from text.\"\"\"\n    return re.sub(r\"[^\\x00-\\x7F]+\", \"\", text)\n\n\ndef remove_non_printable(text: str) -> str:\n    \"\"\"Remove non-printable characters from text.\"\"\"\n    # Replace any special Unicode whitespace characters with a regular space\n    text = re.sub(r\"[\\u2000-\\u200B\\u202F\\u205F\\u3000]\", \" \", text)\n\n    # Replace unusual line terminators with a single newline\n    text = re.sub(r\"[\\u2028\\u2029\\r]\", \"\\n\", text)\n\n    # Remove control characters, except newlines and tabs\n    text = \"\".join(\n        char for char in text if unicodedata.category(char)[0] != \"C\" or char in \"\\n\\t\"\n    )\n\n    # Replace non-breaking spaces with regular spaces\n    text = text.replace(\"\\xa0\", \" \").strip()\n\n    # Keep letters (including accented ones), numbers, spaces, newlines, tabs, and basic punctuation\n    return re.sub(r\"[^\\w\\s.,!?\\-\\n\\t]\", \"\", text, flags=re.UNICODE)\n\n\ndef parse_thinking_content(content: str) -> Tuple[str, str]:\n    \"\"\"\n    Parse message content to extract thinking content from <think> tags.\n\n    Handles both well-formed tags and malformed output where the opening\n    <think> tag is missing but </think> is present.\n\n    Args:\n        content (str): The original message content\n\n    Returns:\n        Tuple[str, str]: (thinking_content, cleaned_content)\n            - thinking_content: Content from within <think> tags\n            - cleaned_content: Original content with <think> blocks removed\n\n    Example:\n        >>> content = \"<think>Let me analyze this</think>Here's my answer\"\n        >>> thinking, cleaned = parse_thinking_content(content)\n        >>> print(thinking)\n        \"Let me analyze this\"\n        >>> print(cleaned)\n        \"Here's my answer\"\n    \"\"\"\n    # Input validation\n    if not isinstance(content, str):\n        return \"\", str(content) if content is not None else \"\"\n\n    # Limit processing for very large content (100KB limit)\n    if len(content) > 100000:\n        return \"\", content\n\n    # Find all well-formed thinking blocks\n    thinking_matches = THINK_PATTERN.findall(content)\n\n    if thinking_matches:\n        # Join all thinking content with double newlines\n        thinking_content = \"\\n\\n\".join(match.strip() for match in thinking_matches)\n\n        # Remove all <think>...</think> blocks from the original content\n        cleaned_content = THINK_PATTERN.sub(\"\", content)\n\n        # Clean up extra whitespace\n        cleaned_content = re.sub(r\"\\n\\s*\\n\\s*\\n\", \"\\n\\n\", cleaned_content).strip()\n\n        return thinking_content, cleaned_content\n\n    # Handle malformed output: content</think> (missing opening tag)\n    # Some models like Nemotron output thinking without the opening <think> tag\n    malformed_match = THINK_PATTERN_NO_OPEN.match(content)\n    if malformed_match:\n        thinking_content = malformed_match.group(1).strip()\n        # Remove the thinking content and </think> tag\n        cleaned_content = content[malformed_match.end() :].strip()\n        return thinking_content, cleaned_content\n\n    return \"\", content\n\n\ndef clean_thinking_content(content: str) -> str:\n    \"\"\"\n    Remove thinking content from AI responses, returning only the cleaned content.\n\n    This is a convenience function for cases where you only need the cleaned\n    content and don't need access to the thinking process.\n\n    Args:\n        content (str): The original message content with potential <think> tags\n\n    Returns:\n        str: Content with <think> blocks removed and whitespace cleaned\n\n    Example:\n        >>> content = \"<think>Let me think...</think>Here's the answer\"\n        >>> clean_thinking_content(content)\n        \"Here's the answer\"\n    \"\"\"\n    _, cleaned_content = parse_thinking_content(content)\n    return cleaned_content\n\n\ndef extract_text_content(content) -> str:\n    \"\"\"Extract text from LLM response content.\n\n    Handles both plain string responses and structured content formats\n    (e.g. Gemini's envelope format):\n    [{'type': 'text', 'text': '...', 'extras': {...}}]\n\n    Args:\n        content: The content from an AI message, either a string or a list of parts.\n\n    Returns:\n        The extracted text content as a string.\n    \"\"\"\n    if isinstance(content, str):\n        return content\n    if isinstance(content, list):\n        text_parts = []\n        for part in content:\n            if isinstance(part, dict) and \"text\" in part:\n                text_parts.append(part[\"text\"])\n            elif isinstance(part, str):\n                text_parts.append(part)\n        return \"\".join(text_parts)\n    return str(content)\n"
  },
  {
    "path": "open_notebook/utils/token_utils.py",
    "content": "\"\"\"\nToken utilities for Open Notebook.\nHandles token counting and cost calculations for language models.\n\"\"\"\n\nimport os\n\nfrom open_notebook.config import TIKTOKEN_CACHE_DIR\n\n# Set tiktoken cache directory before importing tiktoken to ensure\n# tokenizer encodings are cached persistently in the data folder\nos.environ[\"TIKTOKEN_CACHE_DIR\"] = TIKTOKEN_CACHE_DIR\n\n\ndef token_count(input_string: str) -> int:\n    \"\"\"\n    Count the number of tokens in the input string using the 'o200k_base' encoding.\n\n    Args:\n        input_string (str): The input string to count tokens for.\n\n    Returns:\n        int: The number of tokens in the input string.\n    \"\"\"\n    try:\n        import tiktoken\n\n        encoding = tiktoken.get_encoding(\"o200k_base\")\n        tokens = encoding.encode(input_string)\n        return len(tokens)\n    except (ImportError, OSError) as e:\n        # Fallback: handles ImportError (tiktoken not installed) AND network/OS\n        # errors such as urllib.error.URLError or ConnectionError raised in\n        # offline environments when the encoding file cannot be downloaded.\n        from loguru import logger\n\n        logger.warning(\n            \"tiktoken unavailable, falling back to word-count estimation: {}\", e\n        )\n        return int(len(input_string.split()) * 1.3)\n\n\ndef token_cost(token_count: int, cost_per_million: float = 0.150) -> float:\n    \"\"\"\n    Calculate the cost of tokens based on the token count and cost per million tokens.\n\n    Args:\n        token_count (int): The number of tokens.\n        cost_per_million (float): The cost per million tokens. Default is 0.150.\n\n    Returns:\n        float: The calculated cost for the given token count.\n    \"\"\"\n    return cost_per_million * (token_count / 1_000_000)\n"
  },
  {
    "path": "open_notebook/utils/version_utils.py",
    "content": "\"\"\"\nVersion utilities for Open Notebook.\nHandles version comparison, GitHub version fetching, and package version management.\n\"\"\"\n\nfrom importlib.metadata import PackageNotFoundError, version\nfrom urllib.parse import urlparse\n\nimport requests  # type: ignore\nimport tomli\nfrom packaging.version import parse as parse_version\n\n\nasync def get_version_from_github_async(repo_url: str, branch: str = \"main\") -> str:\n    \"\"\"\n    Fetch and parse the version from pyproject.toml in a public GitHub repository (async).\n    \"\"\"\n    from urllib.parse import urlparse\n\n    import httpx\n    import tomli\n\n    # Parse the GitHub URL\n    parsed_url = urlparse(repo_url)\n    if \"github.com\" not in parsed_url.netloc:\n        raise ValueError(\"Not a GitHub URL\")\n\n    # Extract owner and repo name from path\n    path_parts = parsed_url.path.strip(\"/\").split(\"/\")\n    if len(path_parts) < 2:\n        raise ValueError(\"Invalid GitHub repository URL\")\n\n    owner, repo = path_parts[0], path_parts[1]\n\n    # Construct raw content URL for pyproject.toml\n    raw_url = f\"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/pyproject.toml\"\n\n    # Fetch the file with timeout using httpx\n    async with httpx.AsyncClient(timeout=10.0) as client:\n        response = await client.get(raw_url)\n        response.raise_for_status()\n\n    # Parse TOML content\n    pyproject_data = tomli.loads(response.text)\n\n    # Try to find version\n    try:\n        # Check tool.poetry.version\n        version_str = pyproject_data[\"tool\"][\"poetry\"][\"version\"]\n    except KeyError:\n        try:\n            # Check project.version\n            version_str = pyproject_data[\"project\"][\"version\"]\n        except KeyError:\n            raise KeyError(\"Version not found in pyproject.toml\")\n\n    return version_str\n\ndef get_version_from_github(repo_url: str, branch: str = \"main\") -> str:\n    \"\"\"\n    Fetch and parse the version from pyproject.toml in a public GitHub repository.\n\n    Args:\n        repo_url (str): URL of the GitHub repository\n        branch (str): Branch name to fetch from (defaults to \"main\")\n\n    Returns:\n        str: Version string from pyproject.toml\n\n    Raises:\n        ValueError: If the URL is not a valid GitHub repository URL\n        requests.RequestException: If there's an error fetching the file\n        KeyError: If version information is not found in pyproject.toml\n    \"\"\"\n    # Parse the GitHub URL\n    parsed_url = urlparse(repo_url)\n    if \"github.com\" not in parsed_url.netloc:\n        raise ValueError(\"Not a GitHub URL\")\n\n    # Extract owner and repo name from path\n    path_parts = parsed_url.path.strip(\"/\").split(\"/\")\n    if len(path_parts) < 2:\n        raise ValueError(\"Invalid GitHub repository URL\")\n\n    owner, repo = path_parts[0], path_parts[1]\n\n    # Construct raw content URL for pyproject.toml\n    raw_url = (\n        f\"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/pyproject.toml\"\n    )\n\n    # Fetch the file with timeout\n    response = requests.get(raw_url, timeout=10)\n    response.raise_for_status()\n\n    # Parse TOML content\n    pyproject_data = tomli.loads(response.text)\n\n    # Try to find version in different possible locations\n    try:\n        # Check project.version first (poetry style)\n        version = pyproject_data[\"tool\"][\"poetry\"][\"version\"]\n    except KeyError:\n        try:\n            # Check project.version (standard style)\n            version = pyproject_data[\"project\"][\"version\"]\n        except KeyError:\n            raise KeyError(\"Version not found in pyproject.toml\")\n\n    return version\n\n\ndef get_installed_version(package_name: str) -> str:\n    \"\"\"\n    Get the version of an installed package.\n\n    Args:\n        package_name (str): Name of the installed package\n\n    Returns:\n        str: Version string of the installed package\n\n    Raises:\n        PackageNotFoundError: If the package is not installed\n    \"\"\"\n    try:\n        return version(package_name)\n    except PackageNotFoundError:\n        raise PackageNotFoundError(f\"Package '{package_name}' not found\")\n\n\ndef compare_versions(version1: str, version2: str) -> int:\n    \"\"\"\n    Compare two semantic versions.\n\n    Args:\n        version1 (str): First version string\n        version2 (str): Second version string\n\n    Returns:\n        int: -1 if version1 < version2\n              0 if version1 == version2\n              1 if version1 > version2\n    \"\"\"\n    v1 = parse_version(version1)\n    v2 = parse_version(version2)\n\n    if v1 < v2:\n        return -1\n    elif v1 > v2:\n        return 1\n    else:\n        return 0\n"
  },
  {
    "path": "prompts/CLAUDE.md",
    "content": "# Prompts Module\n\nJinja2 prompt templates for multi-provider AI workflows in Open Notebook.\n\n## Purpose\n\nCentralized prompt repository using `ai_prompter` library to:\n1. Separate prompt engineering from Python application logic\n2. Provide reusable Jinja2 templates with variable injection\n3. Support multi-stage prompt chains (orchestrated by LangGraph workflows)\n4. Ensure consistency across similar workflows (chat, search, content generation)\n\n## Architecture Overview\n\n**Template Organization by Workflow**:\n- **`ask/`**: Multi-stage search synthesis (entry → query_process → final_answer)\n- **`chat/`**: Conversational agent with notebook context (system prompt only)\n- **`source_chat/`**: Source-focused chat with insight injection (system prompt only)\n- **`podcast/`**: Podcast generation pipeline (outline → transcript)\n\n**Rendering Pattern** (all workflows):\n```python\nfrom ai_prompter import Prompter\n\n# Load template + render with variables\nsystem_prompt = Prompter(prompt_template=\"ask/entry\", parser=parser).render(\n    data=state\n)\n\n# Then invoke LLM\nmodel = await provision_langchain_model(system_prompt, ...)\nresponse = await model.ainvoke(system_prompt)\n```\n\nSee detailed workflow integration in `open_notebook/graphs/CLAUDE.md` for how each template fits into chat.py, ask.py, source_chat.py.\n\n## Prompt Engineering Patterns\n\n### 1. Multi-Stage Chain (Ask Workflow)\n\nThree-template chain for intelligent search:\n\n```\nentry.jinja (user question → search strategy)\n    ↓\nquery_process.jinja (run each search, generate sub-answer)\n    ↓ (multiple parallel)\nfinal_answer.jinja (synthesize all results into final response)\n```\n\n**Key pattern**: `entry.jinja` generates JSON-structured reasoning (via PydanticOutputParser). Each `query_process.jinja` invocation receives one search term + retrieved results. `final_answer.jinja` combines all answers with proper source citation.\n\n### 2. Conditional Variable Injection (Podcast Workflow)\n\nTemplates accept optional variables for context assembly:\n\n```jinja\n{% if notebook %}\n# PROJECT INFORMATION\n{{ notebook }}\n{% endif %}\n\n{% if context %}\n# CONTEXT\n{{ context }}\n{% endif %}\n```\n\nEnabled by Jinja2's conditional blocks. Critical for podcast outline (handles list or string context) and source_chat (injects variable notebook/insight data).\n\n### 3. Repeated Emphasis on Citation Format (Ask & Chat)\n\nAll response-generating templates emphasize source citation rules:\n- Document ID syntax: `[source:id]`, `[note:id]`, `[insight:id]`\n- \"Do not make up document IDs\" repeated multiple times\n- Example citations provided inline\n\n**Rationale**: LLMs naturally hallucinate citations without explicit guidance; repetition + examples reduce hallucination.\n\n### 4. Format Instructions Delegation\n\nTemplates accept external `{{ format_instructions }}` variable:\n\n```jinja\n# OUTPUT FORMATTING\n{{ format_instructions }}\n```\n\nAllows caller to inject JSON schema, XML format, or other output constraints without modifying template. Decouples prompt from output format evolution.\n\n### 5. JSON Output with Extended Thinking Support\n\nPodcast templates include extended thinking pattern:\n\n```jinja\nIMPORTANT OUTPUT FORMAT:\n- If you use extended thinking with <think> tags, put ALL your reasoning inside <think></think> tags\n- Put the final JSON output OUTSIDE and AFTER any <think> tags\n```\n\nGuides models with extended thinking capability to separate reasoning from output (cleaner parsing downstream).\n\n## File Catalog\n\n**`ask/` - Search Synthesis Pipeline**:\n- **entry.jinja**: Analyzes user question, generates search strategy with JSON output (term + instructions per search)\n- **query_process.jinja**: Accepts one search term + retrieved results, generates sub-answer with citations\n- **final_answer.jinja**: Combines all sub-answers into coherent final response, enforces source citation\n\n**`chat/` - Conversational Agent**:\n- **system.jinja**: Single system prompt for general chat. Uses conditional blocks for optional notebook context. Emphasizes citation format.\n\n**`source_chat/` - Source-Focused Chat**:\n- **system.jinja**: Single system prompt for source-specific discussion. Injects source metadata (ID, title, topics) + selected context. Conditional blocks for optional notebook/context data.\n\n**`podcast/` - Podcast Generation**:\n- **outline.jinja**: Takes briefing + content + speaker profiles (list support via Jinja2 for-loop). Generates JSON outline with segments (name, description, size).\n- **transcript.jinja**: Takes outline + segment index + optional existing transcript. Generates JSON dialogue array (speaker name + dialogue). Iterates speakers with for-loop.\n\n## Key Dependencies\n\n- **ai_prompter**: Prompter class for Jinja2 template rendering with optional OutputParser binding\n- **Jinja2** (transitive via ai_prompter): Template syntax (if/for, filters, variable interpolation)\n- **No external AI calls**: Templates are pure text; LLM invocation happens in calling code (graphs/)\n\n## How to Add New Template\n\n1. **Create subdirectory** in `prompts/` matching workflow name (e.g., `prompts/new_workflow/`)\n2. **Define .jinja file(s)** with Jinja2 syntax:\n   - Use `{{ variable_name }}` for scalar injection\n   - Use `{% if condition %} ... {% endif %}` for optional sections\n   - Use `{% for item in list %} ... {% endfor %}` for iteration\n3. **Document template variables** as inline comments (follow existing templates)\n4. **Reference in calling code** (graphs/):\n   ```python\n   from ai_prompter import Prompter\n   prompt = Prompter(prompt_template=\"new_workflow/template_name\").render(data=context_dict)\n   ```\n5. **If structured output needed**: Pass `parser=PydanticOutputParser(...)` to Prompter\n6. **Document in graphs/CLAUDE.md** how new template fits into workflow chain\n\n## Important Quirks & Gotchas\n\n1. **Template path syntax**: Uses forward slashes without `.jinja` extension in Prompter. `\"ask/entry\"` maps to `/prompts/ask/entry.jinja`\n2. **Variable key convention**: All data passed as `data=dict` arg to `.render()`. Template accesses variables directly (e.g., `{{ question }}`). Ensure dict keys match template variable names.\n3. **OutputParser binding**: When using PydanticOutputParser, Prompter auto-injects `{{ format_instructions }}` into template. If template doesn't have this placeholder, parser is ignored.\n4. **Jinja2 whitespace sensitivity**: Template indentation doesn't affect output, but raw newlines do. Use explicit `\\n` or trim filters if output formatting matters.\n5. **Conditional blocks are loose**: Jinja2 if-condition evaluates any truthy value (non-empty string, list, dict). `{% if variable %}` is False for empty string/\"\" but True for any non-empty content.\n6. **For-loop list assumption**: Templates using `{% for item in list %}` don't validate list type. If caller passes string instead of list, iteration happens character-by-character (bug risk).\n7. **No template composition/inheritance**: Templates are flat (no `{% extends %}` or `{% include %}`). Each workflow keeps templates independent to avoid coupling.\n8. **Citation ID format is caller's responsibility**: Templates emphasize citation rules but don't validate. If caller returns wrong ID format, template can't catch it upstream.\n9. **Parser extraction happens post-render**: OutputParser.parse() is called AFTER `.render()` returns string. If template has syntax errors, render fails before parsing logic runs.\n10. **Template cache**: Prompter likely caches loaded templates. File edits require app restart if using cached instance.\n\n## Testing Patterns\n\n**Manual render test**:\n```python\nfrom ai_prompter import Prompter\n\nprompt = Prompter(prompt_template=\"ask/entry\").render(\n    data={\"question\": \"What is RAG?\"}\n)\nprint(prompt)  # Inspect Jinja2 output before sending to LLM\n```\n\n**With parser**:\n```python\nfrom pydantic import BaseModel\nfrom langchain_core.output_parsers.pydantic import PydanticOutputParser\n\nclass Strategy(BaseModel):\n    reasoning: str\n    searches: list\n\nparser = PydanticOutputParser(pydantic_object=Strategy)\nprompt = Prompter(prompt_template=\"ask/entry\", parser=parser).render(\n    data={\"question\": \"...\"}\n)\n# prompt now includes {{ format_instructions }} substitution\n```\n\n**Integration test** (invoke full graph):\nSee `open_notebook/graphs/ask.py` for how entry.jinja is invoked inside ask_graph workflow.\n\n## Reference Documentation\n\n- **Jinja2 syntax guide**: See existing templates for for-loop, if-conditional, variable interpolation patterns\n- **Graph integration**: `open_notebook/graphs/CLAUDE.md` documents which template is used in which workflow\n- **Sub-directory CLAUDE.md files**: `ask/CLAUDE.md`, `chat/CLAUDE.md`, `podcast/CLAUDE.md` (if created) provide template-specific implementation notes\n"
  },
  {
    "path": "prompts/ask/entry.jinja",
    "content": "# SYSTEM ROLE\n\nYou are a cognitive study assistant that helps users research and learn by engaging in focused discussions about documents in their workspace. \n\nThe first step in the process is receiving the user's question and formulating a research strategy to find the most relevant information.\n\n# YOUR JOB\n\nBased on the user question, you need to analyze the key concepts and terms to determine the appropriate search strategy. \n\nStep 1: develop your search strategy (reasoning)\nStep 2: formulate your search queries (searches)\n\nReturn both the reasoning and searches as a JSON object, like in the EXAMPLE below.\n\n# EXAMPLE\n\nUser: Can you tell me more about the concept of \"RAG\" and how it can be applied to generate answers to user questions via LLM?\n\nYour answer could be something like:\n\n```json\n{ \n    \"reasoning\": \"The user is asking about the concept of RAG and its application in generating answers to user questions via LLM. I should search for documents related to RAG, retrieval augmented generation, and vector search to provide a comprehensive response.\", \n    \"searches\": [\n        { \"term\": \"RAG\", \"instructions\": \"Describe the concept and utility of RAG.\" },\n        { \"term\": \"Retrieval Augmented Generation\", \"instructions\": \"Describe the concept and utility of RAG.\" },\n        { \"term\": \"Vector Search\", \"instructions\": \"Describe how RAG utilizes vector search.\" }\n    ]\n}\n```\n\n# OUTPUT FORMATTING\n\n{{format_instructions}}\n\n- Do not include any text other than the JSON object\n- Do not include ```json``` in the response\n\n# USER QUESTION\n\n{{question}}\n\n# ANSWER\n\n"
  },
  {
    "path": "prompts/ask/final_answer.jinja",
    "content": "# SYSTEM ROLE\n\nYou are a cognitive study assistant that helps users research and learn by engaging in focused discussions about documents in their workspace. \n\nYou are responsible for the last step of the process, which is to provide the final answer to the user's question. You should provide accurate, factual responses based on the available documents and knowledge, while avoiding speculation or making up information. If you are unsure about something, acknowledge the uncertainty rather than guessing.\n\n# QUESTION\n\nThis is the question originally made by the user:\n\n{{question}}\n\n# REASONS\n\nBased on the question, you derived the following reasonsing and search strategies:\n\n{{strategy}}\n\n# RESULTS\n\nHere are the answers you received for each of your queries.\n\n{{answers}}\n\n# YOUR JOB\n\nBased on the user question, the context and the retrieved answers, please formulate a final response to the user. \n\n# CITING SOURCES\n\nIt's very important that your response contains references to the searched documents so the user can follow-up and read more about the topic. The way you do that is by adding the id of the specific document in between brackets like this: [document_id]. The references will be present on all the answers you have been provided.\n\n## IMPORTANT\n\n- Do not make up documents or document ids. Only use the ids of the documents that you can see on the answers you received.\n- The ID is composed of the type of document and a random string, such as \"source:randomstring\", \"note:randomstring\", or \"insight:randomstring\". There are various types of documents, including notes, insights, and sources. **Always use the complete ID exactly as it is provided, including its type prefix. Do not add, remove, or modify any part of the ID.**\n- **Use document IDs exactly as they are returned in the answers. Do not add any prefixes or modify them in any way.**\n\n# YOUR ANSWER\n\n"
  },
  {
    "path": "prompts/ask/query_process.jinja",
    "content": "# SYSTEM ROLE\n\nYou are a research assistant that helps users research and learn by engaging in focused discussions about documents in their workspace. \n\n# QUESTION\n\nThis is the question originally made by the user:\n\n{{question}}\n\n# SEARCH STRATEGY\n\nThe main answer agent has developed the following search strategy to find the most relevant information:\n\n{{term}}\n\nAnd provided you with the following instructions to formulate the answer:\n\n{{instructions}}\n\n# YOUR JOB\n\nBased on the user question, the context and the retrieved results, please formulate the appropriate answer. \n\n# RESULTS\n\n{{results}}\n\n# CITING SOURCES\n\nIt's very important that your response contains references to the searched documents so the user can follow-up and read more about the topic. The way you do that is by adding the id of the specific document in between brackets like this: [document_id].\n\n## EXAMPLE\n\nUser: Can you tell me more about the concept of \"Deep Learning\"?\n\nAssistant: Deep learning is a subset of machine learning in artificial intelligence (AI) that enables networks to learn unsupervised from unstructured or unlabeled data. [note:iuiodadalknda]. It can also be categorized into three main types: supervised, unsupervised, and reinforcement learning. [insight:adadadadadadad].\n\nPlease note, \"note:iuiodadalknda\" and \"insight:adadadadadadad\" are examples of document IDs with different prefixes. You should not make up document IDs or copy the IDs from this example. You should use the IDs of the documents that you have access to through the search tool.\n\n## IMPORTANT\n\n- Do not make up documents or document ids. Only use the ids of the documents that you have access through the query you made.\n- The ID is composed of the type of document and a random string, such as \"source:randomstring\", \"note:randomstring\", or \"insight:randomstring\". There are various types of documents, including notes, insights, and sources. **Always use the complete ID exactly as it is provided, including its type prefix. Do not add, remove, or modify any part of the ID.**\n- Do not assume or change the type prefix of any document ID. If a document ID is \"note:xyz\", use it exactly as \"note:xyz\". Do not change it to \"source:xyz\" or any other variation.\n- **Use document IDs exactly as they are returned from the search tool. Do not add any prefixes or modify them in any way.**\n\n## IDs PROVIDED IN THIS QUERY\n\nYou have been given the following content ids to work from: {{ids}}\nSo, if you are citing some document, it should be one of these.\n\n# YOUR ANSWER\n\n"
  },
  {
    "path": "prompts/chat/system.jinja",
    "content": "# SYSTEM ROLE\nYou are a cognitive study assistant that helps users research and learn by engaging in focused discussions about documents in their workspace. You have access to project context and can analyze documents in detail using specialized tools.\n\n# CAPABILITIES\n- Access to project information and selected documents (CONTEXT)\n- Can engage in natural dialogue while maintaining academic rigor\n\n# YOUR OPERATING METHOD\nWhenever a user asks you a question, you need to identify the query context and the user intent. The user might be continuing a previous conversation or asking a new question. Looking at the CONTEXT will probably give you a hint of what the user is looking for. Once you identify the user intent, formulate your answer accordingly paying attention to the CITING INSTRUCTIONS below.\n\n{% if notebook %}\n# PROJECT INFORMATION\n\n{{notebook}}\n{% endif %}\n\n{% if context %}\n# CONTEXT\n\nThe user has selected this context to help you with your response:\n\n{{context}}\n{% endif %}\n\n# CITING INSTRUCTIONS\n\nIf your answer is based off of any item in the context, it's very important that your response contains references to the searched documents so the user can follow-up and read more about the topic. The way you do that is by adding the id of the specific document in between brackets like this: [document_id].\n\n## EXAMPLE\n\nUser: Can you tell me more about the concept of \"Deep Learning\"?\n\nAssistant: Deep learning is a subset of machine learning in artificial intelligence (AI) that enables networks to learn unsupervised from unstructured or unlabeled data. [note:iuiodadalknda]. It can also be categorized into three main types: supervised, unsupervised, and reinforcement learning. [insight:adadadadadadad].\n\nPlease note, \"note:iuiodadalknda\" and \"insight:adadadadadadad\" are examples of document IDs with different prefixes. You should not make up document IDs or copy the IDs from this example. You should use the IDs of the documents that you have access to through the search tool.\n\n## IMPORTANT\n\n- Do not make up documents or document ids. Only use the ids of the documents that you have access through the query you made.\n- The ID is composed of the type of document and a random string, such as \"source:randomstring\", \"note:randomstring\", or \"insight:randomstring\". There are various types of documents, including notes, insights, and sources. **Always use the complete ID exactly as it is provided, including its type prefix. Do not add, remove, or modify any part of the ID.**\n- Do not assume or change the type prefix of any document ID. If a document ID is \"note:xyz\", use it exactly as \"note:xyz\". Do not change it to \"source:xyz\" or any other variation.\n- **Use document IDs exactly as they are returned from the search tool. Do not add any prefixes or modify them in any way.**\n"
  },
  {
    "path": "prompts/podcast/outline.jinja",
    "content": "You are an AI assistant specialized in creating podcast outlines. Your task is to create a detailed outline for a podcast episode based on a provided briefing. The outline you create will be used to generate the podcast transcript.\n\nHere is the briefing for the podcast episode:\n<briefing>\n{{ briefing }}\n</briefing>\n\nThe user has provided content to be used as the context for this podcast episode:\n<context>\n{% if context is string %}\n{{ context }}\n{% else %}\n{% for item in context %}\n<content_piece>\n{{ item }}\n</content_piece>\n{% endfor %}\n{% endif %}\n</context>\n\nThe podcast will feature the following speakers:\n<speakers>\n{% for speaker in speakers %}\n- **{{ speaker.name }}**: {{ speaker.backstory }}\n  Personality: {{ speaker.personality }}\n{% endfor %}\n</speakers>\n\nPlease create an outline based on this briefing. Your outline should consist of {{ num_segments }} main segments for the podcast episode, along with a description of each segment. Follow these guidelines:\n\n1. Read the briefing carefully and identify the main topics and themes.\n2. Create {{ num_segments }} distinct segments that cover the entire scope of the briefing.\n3. For each segment, provide a clear and concise name that reflects its content.\n4. Write a detailed description for each segment, explaining what will be discussed and provide suggestions of topics according to the context given. The writer will use your suggestion to design the dialogs.\n5. Consider the speaker personalities and backstories when planning segments - match content to speaker expertise.\n6. Ensure that the segments flow logically from one to the next.\n7. This is a whole podcast so no need to reintroduce speakers or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.\n8. Include an introduction segment at the beginning and a conclusion or wrap-up segment at the end.\n\nFormat your outline using the following structure:\n\n```json\n{\n    \"segments\": [\n        {\n            \"name\": \"[Segment Name]\",\n            \"description\": \"[Description of the segment content]\",\n            \"size\": \"short\"\n        },\n        {\n            \"name\": \"[Segment Name]\",\n            \"description\": \"[Description of the segment content]\",\n            \"size\": \"medium\"\n        },\n        {\n            \"name\": \"[Segment Name]\",\n            \"description\": \"[Description of the segment content]\",\n            \"size\": \"long\"\n        },\n    ...\n    ]\n}\n```\n\nFormatting instructions:\n{{ format_instructions}}\n\nAdditional tips:\n- Make sure the segment names are catchy and informative.\n- In the descriptions, include key points or questions that will be addressed in each segment.\n- Consider the target audience mentioned in the briefing when crafting your outline.\n- If the briefing mentions a guest, include segments for introducing the guest and featuring their expertise.\n- The size of the segment should be short, medium or long. Think about the content of the segment and how important it is to the episode.\n\nIMPORTANT OUTPUT FORMAT:\n- If you use extended thinking with <think> tags, put ALL your reasoning inside <think></think> tags\n- Put the final JSON output OUTSIDE and AFTER any <think> tags\n- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only\n- Example correct format:\n  <think>Let me analyze the briefing...</think>\n  {\"segments\": [...]}\n\nPlease provide your outline now, following the format and guidelines provided above.\n"
  },
  {
    "path": "prompts/podcast/transcript.jinja",
    "content": "You are an AI assistant specialized in creating podcast transcripts.\nYour task is to generate a transcript for a specific segment of a podcast episode based on a provided briefing and outline.\nThe transcript will be used to generate podcast audio. Follow these instructions carefully:\n\nFirst, review the briefing for the podcast episode:\n<briefing>\n{{ briefing }}\n</briefing>\n\nThe user has provided content to be used as the context for this podcast episode:\n<context>\n{% if context is string %}\n{{ context }}\n{% else %}\n{% for item in context %}\n<content_piece>\n{{ item }}\n</content_piece>\n{% endfor %}\n{% endif %}\n</context>\n\nThe podcast features the following speakers:\n<speakers>\n{% for speaker in speakers %}\n- **{{ speaker.name }}**: {{ speaker.backstory }}\n  Personality: {{ speaker.personality }}\n{% endfor %}\n</speakers>\n\nNext, examine the outline produced by our director:\n<outline>\n{{ outline }}\n</outline>\n\n{% if transcript %}\nHere is the current transcript so far:\n<transcript>\n{{ transcript }}\n</transcript>\n{% endif %}\n\n{% if is_final %}\n{% if speakers|length == 1 %}\nThis is the final segment of the podcast. Make sure to wrap up the presentation and provide a conclusion.\n{% else %}\nThis is the final segment of the podcast. Make sure to wrap up the conversation and provide a conclusion.\n{% endif %}\n{% endif %}\n\n\nYou will focus on creating the dialogue for the following segment ONLY:\n<segment>\n{{ segment }}\n</segment>\n\n{% if speakers|length == 1 %}\nIMPORTANT: This is a SOLO podcast with only ONE speaker ({{ speaker_names[0] }}). Do NOT invent or add any other speakers.\nAll dialogue entries must use \"{{ speaker_names[0] }}\" as the speaker name.\n\nFollow these format requirements strictly:\n   - Use ONLY the speaker name \"{{ speaker_names[0] }}\" for all dialogue entries.\n   - Do NOT create or invent any additional speakers.\n   - Stick to the segment, do not go further than what's requested. Other agents will do the rest of the podcast.\n   - The transcript must have at least {{ turns }} dialogue segments from the speaker.\n   - The speaker should present the content in an engaging, educational manner.\n{% else %}\nFollow these format requirements strictly:\n   - Use the actual speaker names ({{ speaker_names|join(', ') }}) to denote speakers.\n   - Choose which speaker should speak based on their personality, backstory, and the content being discussed.\n   - Stick to the segment, do not go further than what's requested. Other agents will do the rest of the podcast.\n   - The transcript must have at least {{ turns }} turns of messages between the speakers.\n   - Each speaker should contribute meaningfully based on their expertise and personality.\n{% endif %}\n\n\n```json\n{\n    \"transcript\": [\n        {\n            \"speaker\": \"[Actual Speaker Name]\",\n            \"dialogue\": \"[Speaker's dialogue based on their personality and expertise]\"\n        },\n    ...\n    ]\n}\n```\n\nFormatting instructions:\n{{ format_instructions}}\n\n\n{% if speakers|length == 1 %}\nGuidelines for creating the transcript:\n   - Ensure the presentation flows naturally and covers all points in the outline.\n   - Ensure you return the root \"transcript\" key in your response.\n   - Make the content sound engaging and educational.\n   - Include relevant details from the briefing.\n   - Break up the content into digestible segments with natural transitions.\n   - Use appropriate transitions between topics.\n   - Match the speaker's dialogue to their personality and expertise.\n   - This is a whole podcast so no need to reintroduce the speaker or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.\n   - CRITICAL: There is only ONE speaker. Use ONLY: {{ speaker_names[0] }}. Do NOT invent additional speakers.\n{% else %}\nGuidelines for creating the transcript:\n   - Ensure the conversation flows naturally and covers all points in the outline.\n   - Ensure you return the root \"transcript\" key in your response.\n   - Make the dialogue sound conversational and engaging.\n   - Include relevant details from the briefing.\n   - Avoid long monologues; keep exchanges between speakers balanced.\n   - Use appropriate transitions between topics.\n   - Match each speaker's dialogue to their personality and expertise.\n   - Choose speakers strategically based on who would naturally contribute to each topic.\n   - This is a whole podcast so no need to reintroduce speakers or topics on each segment. Segments are just markers for us to know to change the topics, nothing else.\n   - IMPORTANT: Only use the provided speaker names: {{ speaker_names|join(', ') }}\n{% endif %}\n\nIMPORTANT OUTPUT FORMAT:\n- If you use extended thinking with <think> tags, put ALL your reasoning inside <think></think> tags\n- Put the final JSON output OUTSIDE and AFTER any <think> tags\n- Do NOT wrap the JSON in ```json code blocks - return the raw JSON object only\n- Example correct format:\n  <think>Let me plan the dialogue...</think>\n  {\"transcript\": [...]}\n\nWhen you're ready, provide the transcript.\n{% if speakers|length == 1 %}\nRemember, you are creating a realistic solo podcast presentation based on the given information.\nMake it informative, engaging, and natural-sounding while adhering to the format requirements.\nThere is only ONE speaker - do not add any other speakers.\n{% else %}\nRemember, you are creating a realistic podcast conversation based on the given information.\nMake it informative, engaging, and natural-sounding while adhering to the format requirements.\n{% endif %}\n"
  },
  {
    "path": "prompts/source_chat/system.jinja",
    "content": "# SYSTEM ROLE\nYou are a specialized research assistant focused on helping users deeply understand and analyze a specific source document. You have access to the source content and its generated insights, and you can engage in detailed discussions about this material.\n\n# CAPABILITIES\n- Deep analysis of the specific source document and its content\n- Access to AI-generated insights and analysis from this source\n- Can answer questions, explain concepts, and provide detailed analysis\n- Can reference specific sections and insights from the source\n\n# YOUR OPERATING METHOD\nWhen a user asks you a question, analyze both the source content and the available insights to provide comprehensive, accurate responses. Focus on helping the user understand the material, make connections, and explore ideas related to this specific source.\n\n{% if source %}\n# SOURCE INFORMATION\n\n**Source ID:** {{ source.id }}\n**Title:** {{ source.title or \"No title\" }}\n\n{% if source.topics %}\n**Topics:** {{ source.topics | join(\", \") }}\n{% endif %}\n{% endif %}\n\n{% if context %}\n# SOURCE CONTEXT\n\n{{ context }}\n{% endif %}\n\n# CITING INSTRUCTIONS\n\nWhen referencing information from the source or its insights, always include citations using the document IDs. This helps users track the specific content you're referencing.\n\n## Citation Format\n- For source content: [{{ source.id if source else \"source:id\" }}]\n- For insights: [insight_id] (use the specific insight ID)\n\n## EXAMPLE\n\nUser: What are the main themes in this document?\nAssistant: Based on the source content, I can identify several key themes [source:specific_id]:\n\n1. **Theme 1**: The document discusses X, which appears in several insights [insight:specific_insight_id]\n2. **Theme 2**: Another important concept is Y, as shown in [source:specific_id]\n\nEach theme is supported by specific insights and passages from the source material.\n\n## IMPORTANT\n\n- **Do not make up document IDs or insight IDs.** Only use the IDs that are actually available in the context.\n- **Use complete IDs exactly as provided**, including their type prefix (source:, insight:, etc.)\n- **Always reference specific content** when citing to help users locate the information\n- **Focus on the specific source** - this chat is dedicated to understanding this particular document\n- **Leverage insights** to provide deeper analysis beyond just the raw content\n\n# CONVERSATION FOCUS\n\nThis conversation is specifically about the source document provided in the context. Help users:\n- Understand complex concepts within the document\n- Make connections between different parts of the source\n- Explore implications and deeper meanings\n- Ask follow-up questions to deepen their understanding\n- Navigate through the available insights for different perspectives\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"open-notebook\"\nversion = \"1.8.1\"\ndescription = \"An open source implementation of a research assistant, inspired by Google Notebook LM\"\nauthors = [\n    {name = \"Luis Novo\", email = \"lfnovo@gmail.com\"}\n]\nreadme = \"README.md\"\nclassifiers = [\n    \"License :: OSI Approved :: MIT License\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3.11\",\n]\nrequires-python = \">=3.11,<3.13\"\ndependencies = [\n    \"fastapi>=0.104.0\",\n    \"uvicorn>=0.24.0\",\n    \"pydantic>=2.9.2\",\n    \"loguru>=0.7.2\",\n    \"langchain>=1.2.0\",\n    \"langgraph>=1.0.5\",\n    \"tiktoken>=0.12.0\",\n    \"langgraph-checkpoint-sqlite>=3.0.1\",\n    \"langchain-community>=0.4.1\",\n    \"langchain-openai>=1.1.6\",\n    \"langchain-anthropic>=1.3.0\",\n    \"langchain-ollama>=1.0.1\",\n    \"langchain-google-genai>=4.1.2\",\n    \"langchain-groq>=1.1.1\",\n    \"langchain_mistralai>=1.1.1\",\n    \"langchain_deepseek>=1.0.0\",\n    \"tomli>=2.0.2\",\n    \"python-dotenv>=1.0.1\",\n    \"httpx[socks]>=0.27.0\",\n    \"content-core>=1.14.1,<2\",\n    \"ai-prompter>=0.3,<1\",\n    \"esperanto>=2.19.7,<3\",\n    \"surrealdb>=1.0.4\",\n    \"podcast-creator>=0.12.0,<1\",\n    \"surreal-commands>=1.3.1,<2\",\n    \"numpy>=2.4.1\",\n    \"pycountry>=26.2.16\",\n    \"babel>=2.18.0\",\n]\n\n[tool.setuptools]\npackage-dir = {\"open_notebook\" = \"open_notebook\"}\n\n\n[project.optional-dependencies]\ndev = [\n    \"ipykernel>=6.29.5\",\n    \"ruff>=0.5.5\",\n    \"mypy>=1.11.1\",\n    \"types-requests>=2.32.0.20241016\",\n    \"ipywidgets>=8.1.5\",\n    \"pre-commit>=4.0.1\",\n    \"pytest>=8.0.0\",\n]\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[dependency-groups]\ndev = [\n    \"pre-commit>=4.1.0\",\n    \"pytest-asyncio>=1.2.0\",\n    \"ruff>=0.14.13\",\n    \"types-requests>=2.32.4.20250913\",\n]\n\n[tool.isort]\nprofile = \"black\"\nline_length = 88\n\n[tool.ruff]\nline-length = 88\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\"]\nignore = [\n    \"E501\",  # line too long\n    \"E402\",  # module level import not at top of file (Streamlit requires this pattern)\n    \"E722\",  # do not use bare except (legacy code pattern)\n    \"F401\",  # imported but unused (may be used in type hints or re-exports)\n    \"F541\",  # f-string without placeholders\n    \"F841\",  # local variable assigned but never used\n]\n\n[tool.ruff.lint.per-file-ignores]\n# Streamlit files need nest_asyncio.apply() before imports\n\"app_home.py\" = [\"E402\"]\n\"pages/**/*.py\" = [\"E402\"]\n\n[tool.mypy]\n# Exclude Streamlit UI pages from type checking\n[[tool.mypy.overrides]]\nmodule = \"pages.*\"\nignore_errors = true\n"
  },
  {
    "path": "run_api.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nStartup script for Open Notebook API server.\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\nimport uvicorn\n\n# Add the current directory to Python path so imports work\ncurrent_dir = Path(__file__).parent\nsys.path.insert(0, str(current_dir))\n\nif __name__ == \"__main__\":\n    # Default configuration\n    host = os.getenv(\"API_HOST\", \"127.0.0.1\")\n    port = int(os.getenv(\"API_PORT\", \"5055\"))\n    reload = os.getenv(\"API_RELOAD\", \"true\").lower() == \"true\"\n\n    print(f\"Starting Open Notebook API server on {host}:{port}\")\n    print(f\"Reload mode: {reload}\")\n\n    uvicorn.run(\n        \"api.main:app\",\n        host=host,\n        port=port,\n        reload=reload,\n        reload_dirs=[str(current_dir)] if reload else None,\n    )\n"
  },
  {
    "path": "scripts/README.md",
    "content": "# Scripts Documentation\n\n## export_docs.py\n\nConsolidates markdown documentation files for use with ChatGPT or other platforms with file upload limits.\n\n### What It Does\n\n- Scans all subdirectories in the `docs/` folder\n- For each subdirectory, combines all `.md` files (excluding `index.md` files)\n- Creates one consolidated markdown file per subdirectory\n- Saves all exported files to `doc_exports/` in the project root\n\n### Usage\n\n```bash\n# Using Makefile (recommended)\nmake export-docs\n\n# Or run directly with uv\nuv run python scripts/export_docs.py\n\n# Or run with standard Python\npython scripts/export_docs.py\n```\n\n### Output\n\nThe script creates `doc_exports/` directory with consolidated files like:\n\n- `getting-started.md` - All getting-started documentation\n- `user-guide.md` - All user guide content\n- `features.md` - All feature documentation\n- `development.md` - All development documentation\n- etc.\n\nEach exported file includes:\n- A main header with the folder name\n- Section headers for each source file\n- Source file attribution\n- The complete content from each markdown file\n- Visual separators between sections\n\n### Example Output Structure\n\n```markdown\n# Getting Started\n\nThis document consolidates all content from the getting-started documentation folder.\n\n---\n\n## Installation\n\n*Source: installation.md*\n\n[Full content of installation.md]\n\n---\n\n## Quick Start\n\n*Source: quick-start.md*\n\n[Full content of quick-start.md]\n\n---\n```\n\n### Notes\n\n- The `doc_exports/` directory is gitignored and safe to regenerate anytime\n- Index files (`index.md`) are automatically excluded\n- Files are sorted alphabetically for consistent output\n- The script handles subdirectories only (ignores files in the root `docs/` folder)\n"
  },
  {
    "path": "scripts/export_docs.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nExport documentation by consolidating markdown files from each docs folder.\n\nThis script:\n1. Scans all subdirectories in the docs/ folder\n2. For each subdirectory, concatenates all .md files (except index.md)\n3. Saves the consolidated content to doc_exports/{folder_name}.md\n\"\"\"\n\nimport logging\nfrom pathlib import Path\nfrom typing import List\n\n# Configure logging\nlogging.basicConfig(level=logging.INFO, format=\"%(levelname)s: %(message)s\")\nlogger = logging.getLogger(__name__)\n\n\ndef get_markdown_files(folder: Path) -> List[Path]:\n    \"\"\"Get all markdown files in a folder, excluding index.md files.\"\"\"\n    md_files = [f for f in folder.glob(\"*.md\") if f.name.lower() != \"index.md\"]\n    return sorted(md_files)  # Sort for consistent ordering\n\n\ndef consolidate_folder(folder: Path, output_dir: Path) -> None:\n    \"\"\"Consolidate all markdown files from a folder into a single file.\"\"\"\n    md_files = get_markdown_files(folder)\n\n    if not md_files:\n        logger.info(f\"  Skipping {folder.name} - no markdown files found\")\n        return\n\n    output_file = output_dir / f\"{folder.name}.md\"\n\n    with output_file.open(\"w\", encoding=\"utf-8\") as outf:\n        # Write header\n        outf.write(f\"# {folder.name.replace('-', ' ').title()}\\n\\n\")\n        outf.write(\n            f\"This document consolidates all content from the {folder.name} documentation folder.\\n\\n\"\n        )\n        outf.write(\"---\\n\\n\")\n\n        # Process each markdown file\n        for md_file in md_files:\n            logger.info(f\"  Adding {md_file.name}\")\n\n            # Add section header with filename\n            outf.write(f\"## {md_file.stem.replace('-', ' ').title()}\\n\\n\")\n            outf.write(f\"*Source: {md_file.name}*\\n\\n\")\n\n            # Add file content\n            content = md_file.read_text(encoding=\"utf-8\")\n            outf.write(content)\n            outf.write(\"\\n\\n---\\n\\n\")\n\n    logger.info(f\"  ✓ Created {output_file.name} ({len(md_files)} files)\")\n\n\ndef main():\n    \"\"\"Main function to export documentation.\"\"\"\n    # Define paths\n    docs_dir = Path(\"docs\")\n    output_dir = Path(\"doc_exports\")\n\n    # Validate docs directory exists\n    if not docs_dir.exists():\n        logger.error(f\"Documentation directory '{docs_dir}' not found\")\n        return\n\n    # Create output directory\n    output_dir.mkdir(exist_ok=True)\n    logger.info(f\"Output directory: {output_dir.absolute()}\")\n\n    # Get all subdirectories in docs/\n    subdirs = [\n        d for d in docs_dir.iterdir() if d.is_dir() and not d.name.startswith(\".\")\n    ]\n\n    if not subdirs:\n        logger.warning(\"No subdirectories found in docs/\")\n        return\n\n    logger.info(f\"Found {len(subdirs)} documentation folders\\n\")\n\n    # Process each subdirectory\n    for subdir in sorted(subdirs):\n        logger.info(f\"Processing {subdir.name}...\")\n        consolidate_folder(subdir, output_dir)\n\n    logger.info(f\"\\n✓ Documentation export complete!\")\n    logger.info(f\"Exported files are in: {output_dir.absolute()}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/wait-for-api.sh",
    "content": "#!/bin/bash\n# Wait for the API to be healthy before starting the frontend\n# This prevents the \"Unable to Connect to API Server\" error during startup\n\nAPI_URL=\"${INTERNAL_API_URL:-http://localhost:5055}\"\nMAX_RETRIES=60  # 60 retries * 5 seconds = 5 minutes max wait\nRETRY_INTERVAL=5\n\necho \"Waiting for API to be ready at ${API_URL}/health...\"\n\nfor i in $(seq 1 $MAX_RETRIES); do\n    if curl -s -f \"${API_URL}/health\" > /dev/null 2>&1; then\n        echo \"API is ready! Starting frontend...\"\n        exit 0\n    fi\n    echo \"Attempt $i/$MAX_RETRIES: API not ready yet, waiting ${RETRY_INTERVAL}s...\"\n    sleep $RETRY_INTERVAL\ndone\n\necho \"ERROR: API did not become ready within $((MAX_RETRIES * RETRY_INTERVAL)) seconds\"\necho \"Starting frontend anyway - users may see connection errors initially\"\nexit 0  # Exit 0 so frontend still starts (better than nothing)\n"
  },
  {
    "path": "supervisord.conf",
    "content": "[supervisord]\nnodaemon=true\nlogfile=/dev/stdout\nlogfile_maxbytes=0\npidfile=/tmp/supervisord.pid\n\n[program:api]\ncommand=uv run --no-sync uvicorn api.main:app --host 0.0.0.0 --port 5055\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=10\nautostart=true\n\n[program:worker]\ncommand=uv run --no-sync surreal-commands-worker --import-modules commands\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=20\nautostart=true\nstartsecs=3\n\n[program:frontend]\ncommand=bash -c \"/app/scripts/wait-for-api.sh && node server.js\"\ndirectory=/app/frontend\nenvironment=NODE_ENV=\"production\",PORT=\"8502\"\npassenv=API_URL,NEXT_PUBLIC_API_URL,INTERNAL_API_URL\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=30\nautostart=true\nstartsecs=10\n"
  },
  {
    "path": "supervisord.single.conf",
    "content": "[supervisord]\nnodaemon=true\nlogfile=/dev/stdout\nlogfile_maxbytes=0\npidfile=/tmp/supervisord.pid\n\n[program:surrealdb]\ncommand=surreal start --log trace --user root --pass root rocksdb:/mydata/mydatabase.db\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=5\nautostart=true\nstartsecs=5\n\n[program:api]\ncommand=uv run uvicorn api.main:app --host 0.0.0.0 --port 5055\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=10\nautostart=true\nstartsecs=3\n\n[program:worker]\ncommand=uv run surreal-commands-worker --import-modules commands\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=20\nautostart=true\nstartsecs=3\n\n[program:frontend]\ncommand=bash -c \"/app/scripts/wait-for-api.sh && node server.js\"\ndirectory=/app/frontend\nenvironment=NODE_ENV=\"production\",PORT=\"8502\"\npassenv=API_URL,NEXT_PUBLIC_API_URL,INTERNAL_API_URL\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautorestart=true\npriority=30\nautostart=true\nstartsecs=10"
  },
  {
    "path": "tests/README.md",
    "content": "Coming Soon"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"\nPytest configuration file.\n\nThis file ensures that the project root is in the Python path,\nallowing tests to import from the api and open_notebook modules.\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\n# Ensure password auth is disabled for tests BEFORE any imports\n# The PasswordAuthMiddleware skips auth when this env var is not set\n# Set to empty string instead of deleting to prevent it from being reloaded\nos.environ[\"OPEN_NOTEBOOK_PASSWORD\"] = \"\"\n\n# Load environment variables from .env file\n# This must be done BEFORE any imports that depend on environment variables\nfrom dotenv import load_dotenv\n\n# Load .env file from project root\ndotenv_path = Path(__file__).parent.parent / \".env\"\nif dotenv_path.exists():\n    load_dotenv(dotenv_path)\n    print(f\"Loaded environment variables from {dotenv_path}\")\nelse:\n    print(f\"Warning: .env file not found at {dotenv_path}\")\n\n# Add the project root to the Python path\nproject_root = Path(__file__).parent.parent\nsys.path.insert(0, str(project_root))\n"
  },
  {
    "path": "tests/test_chunking.py",
    "content": "\"\"\"\nUnit tests for the open_notebook.utils.chunking module.\n\nTests content type detection and text chunking functionality.\n\"\"\"\n\nimport pytest\n\nfrom open_notebook.utils.chunking import (\n    CHUNK_SIZE,\n    ContentType,\n    chunk_text,\n    detect_content_type,\n    detect_content_type_from_extension,\n    detect_content_type_from_heuristics,\n)\n\n# ============================================================================\n# TEST SUITE 1: Content Type Detection from Extension\n# ============================================================================\n\n\nclass TestDetectContentTypeFromExtension:\n    \"\"\"Test suite for extension-based content type detection.\"\"\"\n\n    def test_html_extensions(self):\n        \"\"\"Test HTML file extensions.\"\"\"\n        assert detect_content_type_from_extension(\"file.html\") == ContentType.HTML\n        assert detect_content_type_from_extension(\"file.htm\") == ContentType.HTML\n        assert detect_content_type_from_extension(\"file.xhtml\") == ContentType.HTML\n        assert detect_content_type_from_extension(\"/path/to/file.HTML\") == ContentType.HTML\n\n    def test_markdown_extensions(self):\n        \"\"\"Test Markdown file extensions.\"\"\"\n        assert detect_content_type_from_extension(\"file.md\") == ContentType.MARKDOWN\n        assert detect_content_type_from_extension(\"file.markdown\") == ContentType.MARKDOWN\n        assert detect_content_type_from_extension(\"file.mdown\") == ContentType.MARKDOWN\n        assert detect_content_type_from_extension(\"/path/to/README.MD\") == ContentType.MARKDOWN\n\n    def test_plain_text_extensions(self):\n        \"\"\"Test plain text file extensions.\"\"\"\n        assert detect_content_type_from_extension(\"file.txt\") == ContentType.PLAIN\n        assert detect_content_type_from_extension(\"file.text\") == ContentType.PLAIN\n\n    def test_code_extensions_as_plain(self):\n        \"\"\"Test code file extensions are treated as plain text.\"\"\"\n        assert detect_content_type_from_extension(\"file.py\") == ContentType.PLAIN\n        assert detect_content_type_from_extension(\"file.js\") == ContentType.PLAIN\n        assert detect_content_type_from_extension(\"file.json\") == ContentType.PLAIN\n        assert detect_content_type_from_extension(\"file.yaml\") == ContentType.PLAIN\n\n    def test_unknown_extensions(self):\n        \"\"\"Test unknown extensions return None.\"\"\"\n        assert detect_content_type_from_extension(\"file.xyz\") is None\n        assert detect_content_type_from_extension(\"file.docx\") is None\n        assert detect_content_type_from_extension(\"file.pdf\") is None\n\n    def test_no_extension(self):\n        \"\"\"Test files without extension.\"\"\"\n        assert detect_content_type_from_extension(\"Makefile\") is None\n        assert detect_content_type_from_extension(\"README\") is None\n\n    def test_none_input(self):\n        \"\"\"Test None input.\"\"\"\n        assert detect_content_type_from_extension(None) is None\n\n    def test_empty_string(self):\n        \"\"\"Test empty string input.\"\"\"\n        assert detect_content_type_from_extension(\"\") is None\n\n\n# ============================================================================\n# TEST SUITE 2: Content Type Detection from Heuristics\n# ============================================================================\n\n\nclass TestDetectContentTypeFromHeuristics:\n    \"\"\"Test suite for heuristics-based content type detection.\"\"\"\n\n    def test_html_detection_doctype(self):\n        \"\"\"Test HTML detection with DOCTYPE.\"\"\"\n        html_text = \"<!DOCTYPE html><html><body>Content</body></html>\"\n        content_type, confidence = detect_content_type_from_heuristics(html_text)\n        assert content_type == ContentType.HTML\n        assert confidence >= 0.8\n\n    def test_html_detection_tags(self):\n        \"\"\"Test HTML detection with structural tags.\"\"\"\n        html_text = \"<html><head><title>Test</title></head><body><div><p>Content</p></div></body></html>\"\n        content_type, confidence = detect_content_type_from_heuristics(html_text)\n        assert content_type == ContentType.HTML\n        assert confidence >= 0.5\n\n    def test_markdown_detection_headers(self):\n        \"\"\"Test Markdown detection with headers.\"\"\"\n        md_text = \"\"\"# Main Title\n\n## Section 1\n\nSome content here.\n\n## Section 2\n\nMore content.\n\n### Subsection\n\nDetails here.\n\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(md_text)\n        assert content_type == ContentType.MARKDOWN\n        assert confidence >= 0.3  # 4 headers give ~0.35 confidence\n\n    def test_markdown_detection_links(self):\n        \"\"\"Test Markdown detection with links and headers for stronger signal.\"\"\"\n        md_text = \"\"\"# Documentation\n\nCheck out [this link](https://example.com) and [another one](https://test.com).\n\n## References\n\nHere's some more text with [links](url) and `inline code`.\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(md_text)\n        assert content_type == ContentType.MARKDOWN\n        assert confidence >= 0.4\n\n    def test_markdown_detection_code_blocks(self):\n        \"\"\"Test Markdown detection with code blocks.\"\"\"\n        md_text = \"\"\"# Code Example\n\n```python\ndef hello():\n    print(\"Hello, World!\")\n```\n\nSome explanation text.\n\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(md_text)\n        assert content_type == ContentType.MARKDOWN\n        assert confidence >= 0.5\n\n    def test_plain_text_detection(self):\n        \"\"\"Test plain text detection.\"\"\"\n        plain_text = \"\"\"This is just regular plain text.\nIt has multiple lines but no special formatting.\nNo headers, no links, no HTML tags.\nJust regular sentences and paragraphs.\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(plain_text)\n        assert content_type == ContentType.PLAIN\n\n    def test_short_text(self):\n        \"\"\"Test short text defaults to plain.\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(\"Hi\")\n        assert content_type == ContentType.PLAIN\n\n    def test_empty_text(self):\n        \"\"\"Test empty text defaults to plain.\"\"\"\n        content_type, confidence = detect_content_type_from_heuristics(\"\")\n        assert content_type == ContentType.PLAIN\n\n\n# ============================================================================\n# TEST SUITE 3: Combined Content Type Detection\n# ============================================================================\n\n\nclass TestDetectContentType:\n    \"\"\"Test suite for combined content type detection.\"\"\"\n\n    def test_extension_takes_priority(self):\n        \"\"\"Test that file extension takes priority over heuristics.\"\"\"\n        # Text looks like markdown but file is .txt\n        md_text = \"# Header\\n\\nSome [link](url) content\"\n        content_type = detect_content_type(md_text, \"file.txt\")\n        # Should use extension (plain) unless heuristics are very high confidence\n        # In this case, markdown confidence might override\n        assert content_type in (ContentType.PLAIN, ContentType.MARKDOWN)\n\n    def test_no_extension_uses_heuristics(self):\n        \"\"\"Test that heuristics are used when no extension is available.\"\"\"\n        html_text = \"<!DOCTYPE html><html><body>Test</body></html>\"\n        content_type = detect_content_type(html_text, None)\n        assert content_type == ContentType.HTML\n\n    def test_extension_html(self):\n        \"\"\"Test HTML extension detection.\"\"\"\n        content_type = detect_content_type(\"some text\", \"file.html\")\n        assert content_type == ContentType.HTML\n\n    def test_extension_markdown(self):\n        \"\"\"Test Markdown extension detection.\"\"\"\n        content_type = detect_content_type(\"some text\", \"file.md\")\n        assert content_type == ContentType.MARKDOWN\n\n    def test_high_confidence_override(self):\n        \"\"\"Test that very high confidence heuristics can override plain extension.\"\"\"\n        # Strong HTML indicators in a .txt file\n        html_text = \"<!DOCTYPE html><html><head><title>Test</title></head><body><div><p>Content</p></div></body></html>\"\n        content_type = detect_content_type(html_text, \"file.txt\")\n        # High confidence HTML should override .txt extension\n        assert content_type == ContentType.HTML\n\n\n# ============================================================================\n# TEST SUITE 4: Text Chunking\n# ============================================================================\n\n\nclass TestChunkText:\n    \"\"\"Test suite for text chunking functionality.\"\"\"\n\n    def test_empty_text(self):\n        \"\"\"Test chunking empty text.\"\"\"\n        assert chunk_text(\"\") == []\n        assert chunk_text(\"   \") == []\n\n    def test_short_text_no_chunking(self):\n        \"\"\"Test that short text is not chunked.\"\"\"\n        text = \"This is a short text.\"\n        chunks = chunk_text(text)\n        assert len(chunks) == 1\n        assert chunks[0] == text\n\n    def test_text_at_chunk_limit(self):\n        \"\"\"Test text at exactly chunk size limit.\"\"\"\n        text = \"x\" * CHUNK_SIZE\n        chunks = chunk_text(text)\n        assert len(chunks) == 1\n\n    def test_long_text_is_chunked(self):\n        \"\"\"Test that long text is chunked.\"\"\"\n        # Create text longer than chunk size\n        text = \"This is a sentence. \" * 200  # ~4000 chars\n        chunks = chunk_text(text)\n        assert len(chunks) > 1\n        # Each chunk should be <= CHUNK_SIZE\n        for chunk in chunks:\n            assert len(chunk) <= CHUNK_SIZE + 100  # Allow some flexibility for overlap\n\n    def test_explicit_content_type_html(self):\n        \"\"\"Test chunking with explicit HTML content type.\"\"\"\n        html_text = \"\"\"<html>\n<body>\n<h1>Main Title</h1>\n<p>First paragraph with lots of content.</p>\n<h2>Section</h2>\n<p>Second paragraph.</p>\n</body>\n</html>\"\"\"\n        chunks = chunk_text(html_text, content_type=ContentType.HTML)\n        assert len(chunks) >= 1\n\n    def test_explicit_content_type_markdown(self):\n        \"\"\"Test chunking with explicit Markdown content type.\"\"\"\n        md_text = \"\"\"# Main Title\n\nIntroduction paragraph.\n\n## Section 1\n\nContent for section 1.\n\n## Section 2\n\nContent for section 2.\n\"\"\"\n        chunks = chunk_text(md_text, content_type=ContentType.MARKDOWN)\n        assert len(chunks) >= 1\n\n    def test_explicit_content_type_plain(self):\n        \"\"\"Test chunking with explicit plain content type.\"\"\"\n        plain_text = \"Word \" * 500  # ~2500 chars\n        chunks = chunk_text(plain_text, content_type=ContentType.PLAIN)\n        assert len(chunks) >= 1\n\n    def test_file_path_detection(self):\n        \"\"\"Test chunking with file path for content type detection.\"\"\"\n        text = \"Some content here\"\n        chunks = chunk_text(text, file_path=\"document.md\")\n        assert len(chunks) == 1\n\n    def test_secondary_chunking_for_large_sections(self):\n        \"\"\"Test that large sections from HTML/MD splitters are further chunked.\"\"\"\n        # Create text that would produce a single large section\n        large_section = \"x\" * 3000  # Larger than CHUNK_SIZE\n        md_text = f\"# Title\\n\\n{large_section}\"\n        chunks = chunk_text(md_text, content_type=ContentType.MARKDOWN)\n        # Should have multiple chunks due to secondary chunking\n        assert len(chunks) >= 1\n        for chunk in chunks:\n            # Allow some flexibility but chunks should be reasonable size\n            assert len(chunk) <= CHUNK_SIZE + 300\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_domain.py",
    "content": "\"\"\"\nUnit tests for the open_notebook.domain module.\n\nThis test suite focuses on validation logic, business rules, and data structures\nthat can be tested without database mocking.\n\"\"\"\n\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom pydantic import ValidationError\n\nfrom open_notebook.ai.models import ModelManager\nfrom open_notebook.domain.base import RecordModel\nfrom open_notebook.domain.content_settings import ContentSettings\nfrom open_notebook.domain.notebook import Asset, Note, Notebook, Source\nfrom open_notebook.domain.transformation import Transformation\nfrom open_notebook.exceptions import InvalidInputError\nfrom open_notebook.podcasts.models import EpisodeProfile, SpeakerProfile\n\n# ============================================================================\n# TEST SUITE 1: RecordModel Singleton Pattern\n# ============================================================================\n\n\nclass TestRecordModelSingleton:\n    \"\"\"Test suite for RecordModel singleton behavior.\"\"\"\n\n    def test_recordmodel_singleton_behavior(self):\n        \"\"\"Test that same instance is returned for same record_id.\"\"\"\n\n        class TestRecord(RecordModel):\n            record_id = \"test:singleton\"\n            value: int = 0\n\n        # Clear any existing instance\n        TestRecord.clear_instance()\n\n        # Create first instance\n        instance1 = TestRecord(value=42)\n        assert instance1.value == 42\n\n        # Create second instance - should return same object\n        instance2 = TestRecord(value=99)\n        assert instance1 is instance2\n        assert instance2.value == 99  # Value was updated\n\n        # Cleanup\n        TestRecord.clear_instance()\n\n\n# ============================================================================\n# TEST SUITE 2: ModelManager Instance Isolation\n# ============================================================================\n\n\nclass TestModelManager:\n    \"\"\"Test suite for ModelManager instance behavior.\"\"\"\n\n    def test_model_manager_instance_isolation(self):\n        \"\"\"Test that each ModelManager instance is independent (not a singleton).\"\"\"\n        manager1 = ModelManager()\n        manager2 = ModelManager()\n\n        # Each instance should be independent (not a singleton)\n        assert manager1 is not manager2\n        assert id(manager1) != id(manager2)\n\n\n# ============================================================================\n# TEST SUITE 3: Notebook Domain Logic\n# ============================================================================\n\n\nclass TestNotebookDomain:\n    \"\"\"Test suite for Notebook validation and business rules.\"\"\"\n\n    def test_notebook_name_validation(self):\n        \"\"\"Test empty/whitespace names are rejected.\"\"\"\n        # Empty name should raise error\n        with pytest.raises(InvalidInputError, match=\"Notebook name cannot be empty\"):\n            Notebook(name=\"\", description=\"Test\")\n\n        # Whitespace-only name should raise error\n        with pytest.raises(InvalidInputError, match=\"Notebook name cannot be empty\"):\n            Notebook(name=\"   \", description=\"Test\")\n\n        # Valid name should work\n        notebook = Notebook(name=\"Valid Name\", description=\"Test\")\n        assert notebook.name == \"Valid Name\"\n\n    def test_notebook_archived_flag(self):\n        \"\"\"Test archived flag defaults to False.\"\"\"\n        notebook = Notebook(name=\"Test\", description=\"Test\")\n        assert notebook.archived is False\n\n        notebook_archived = Notebook(name=\"Test\", description=\"Test\", archived=True)\n        assert notebook_archived.archived is True\n\n\n# ============================================================================\n# TEST SUITE 4: Source Domain\n# ============================================================================\n\n\nclass TestSourceDomain:\n    \"\"\"Test suite for Source domain model.\"\"\"\n\n    def test_source_command_field_parsing(self):\n        \"\"\"Test RecordID parsing for command field.\"\"\"\n        # Test with string command\n        source = Source(title=\"Test\", command=\"command:123\")\n        assert source.command is not None\n\n        # Test with None command\n        source2 = Source(title=\"Test\", command=None)\n        assert source2.command is None\n\n        # Test command is included in save data prep\n        source3 = Source(id=\"source:123\", title=\"Test\", command=\"command:456\")\n        save_data = source3._prepare_save_data()\n        assert \"command\" in save_data\n\n    @pytest.mark.asyncio\n    async def test_source_delete_cleans_up_file(self):\n        \"\"\"Test that deleting a source removes the associated file.\"\"\"\n        # Create a temporary file\n        with tempfile.NamedTemporaryFile(delete=False, suffix=\".txt\") as tmp_file:\n            tmp_file.write(b\"Test content\")\n            tmp_path = Path(tmp_file.name)\n\n        try:\n            # Create source with file asset\n            source = Source(\n                id=\"source:test_delete\",\n                title=\"Test Source\",\n                asset=Asset(file_path=str(tmp_path)),\n            )\n\n            # Verify file exists\n            assert tmp_path.exists()\n\n            # Mock the parent delete method to avoid database operations\n            with patch.object(\n                Source.__bases__[0], \"delete\", new_callable=AsyncMock\n            ) as mock_delete:\n                mock_delete.return_value = True\n\n                # Delete the source\n                result = await source.delete()\n\n                # Verify parent delete was called\n                mock_delete.assert_called_once()\n                assert result is True\n\n            # Verify file was deleted\n            assert not tmp_path.exists()\n\n        finally:\n            # Cleanup in case test fails\n            if tmp_path.exists():\n                tmp_path.unlink()\n\n    @pytest.mark.asyncio\n    async def test_source_delete_without_file(self):\n        \"\"\"Test that deleting a source without a file doesn't fail.\"\"\"\n        # Create source without file asset\n        source = Source(id=\"source:test_no_file\", title=\"Test Source\", asset=None)\n\n        # Mock the parent delete method\n        with patch.object(\n            Source.__bases__[0], \"delete\", new_callable=AsyncMock\n        ) as mock_delete:\n            mock_delete.return_value = True\n\n            # Delete should complete without error\n            result = await source.delete()\n            assert result is True\n            mock_delete.assert_called_once()\n\n    @pytest.mark.asyncio\n    async def test_source_delete_continues_on_file_error(self):\n        \"\"\"Test that source deletion continues even if file deletion fails.\"\"\"\n        # Create source with non-existent file\n        source = Source(\n            id=\"source:test_missing_file\",\n            title=\"Test Source\",\n            asset=Asset(file_path=\"/nonexistent/path/file.txt\"),\n        )\n\n        # Mock the parent delete method\n        with patch.object(\n            Source.__bases__[0], \"delete\", new_callable=AsyncMock\n        ) as mock_delete:\n            mock_delete.return_value = True\n\n            # Delete should complete even though file doesn't exist\n            result = await source.delete()\n            assert result is True\n            mock_delete.assert_called_once()\n\n\n    @pytest.mark.asyncio\n    async def test_vectorize_raises_valueerror_when_no_text(self):\n        \"\"\"Test that vectorize() raises ValueError (not DatabaseOperationError) for empty text.\"\"\"\n        source = Source(id=\"source:test_empty\", title=\"Test\", full_text=None)\n        with pytest.raises(ValueError, match=\"has no text to vectorize\"):\n            await source.vectorize()\n\n    @pytest.mark.asyncio\n    async def test_vectorize_raises_valueerror_when_empty_string(self):\n        \"\"\"Test that vectorize() raises ValueError for empty string.\"\"\"\n        source = Source(id=\"source:test_empty_str\", title=\"Test\", full_text=\"\")\n        with pytest.raises(ValueError, match=\"has no text to vectorize\"):\n            await source.vectorize()\n\n    @pytest.mark.asyncio\n    async def test_vectorize_raises_valueerror_when_whitespace_only(self):\n        \"\"\"Test that vectorize() raises ValueError for whitespace-only text.\"\"\"\n        source = Source(id=\"source:test_ws\", title=\"Test\", full_text=\"   \\n\\t  \")\n        with pytest.raises(ValueError, match=\"has no text to vectorize\"):\n            await source.vectorize()\n\n    @pytest.mark.asyncio\n    async def test_vectorize_submits_command_with_valid_text(self):\n        \"\"\"Test that vectorize() submits embed_source command when text is valid.\"\"\"\n        source = Source(id=\"source:test_valid\", title=\"Test\", full_text=\"Real content\")\n        with patch(\n            \"open_notebook.domain.notebook.submit_command\", return_value=\"command:123\"\n        ) as mock_submit:\n            result = await source.vectorize()\n            mock_submit.assert_called_once_with(\n                \"open_notebook\",\n                \"embed_source\",\n                {\"source_id\": \"source:test_valid\"},\n            )\n            assert result == \"command:123\"\n\n\n# ============================================================================\n# TEST SUITE 5: Note Domain\n# ============================================================================\n\n\nclass TestNoteDomain:\n    \"\"\"Test suite for Note validation.\"\"\"\n\n    def test_note_content_validation(self):\n        \"\"\"Test empty content is rejected.\"\"\"\n        # None content is allowed\n        note = Note(title=\"Test\", content=None)\n        assert note.content is None\n\n        # Non-empty content is valid\n        note2 = Note(title=\"Test\", content=\"Valid content\")\n        assert note2.content == \"Valid content\"\n\n        # Empty string should raise error\n        with pytest.raises(InvalidInputError, match=\"Note content cannot be empty\"):\n            Note(title=\"Test\", content=\"\")\n\n        # Whitespace-only should raise error\n        with pytest.raises(InvalidInputError, match=\"Note content cannot be empty\"):\n            Note(title=\"Test\", content=\"   \")\n\n    def test_note_content_for_embedding(self):\n        \"\"\"Test notes can hold content for embedding.\n\n        Note: Embedding is now handled via command submission in Note.save(),\n        not via needs_embedding() method. This test verifies basic content handling.\n        \"\"\"\n        note = Note(title=\"Test\", content=\"Test content\")\n        assert note.content == \"Test content\"\n\n        # Test with None content - valid, no embedding will be submitted\n        note2 = Note(title=\"Test\", content=None)\n        assert note2.content is None\n\n\n# ============================================================================\n# TEST SUITE 6: Podcast Domain Validation\n# ============================================================================\n\n\nclass TestPodcastDomain:\n    \"\"\"Test suite for Podcast domain validation.\"\"\"\n\n    def test_speaker_profile_validation(self):\n        \"\"\"Test speaker profile validates count and required fields.\"\"\"\n        # Test invalid - no speakers\n        with pytest.raises(ValidationError):\n            SpeakerProfile(\n                name=\"Test\",\n                tts_provider=\"openai\",\n                tts_model=\"tts-1\",\n                speakers=[],\n            )\n\n        # Test invalid - too many speakers (> 4)\n        with pytest.raises(ValidationError):\n            SpeakerProfile(\n                name=\"Test\",\n                tts_provider=\"openai\",\n                tts_model=\"tts-1\",\n                speakers=[{\"name\": f\"Speaker{i}\"} for i in range(5)],\n            )\n\n        # Test invalid - missing required fields\n        with pytest.raises(ValidationError):\n            SpeakerProfile(\n                name=\"Test\",\n                tts_provider=\"openai\",\n                tts_model=\"tts-1\",\n                speakers=[\n                    {\"name\": \"Speaker 1\"}\n                ],  # Missing voice_id, backstory, personality\n            )\n\n        # Test valid - single speaker with all fields\n        profile = SpeakerProfile(\n            name=\"Test\",\n            tts_provider=\"openai\",\n            tts_model=\"tts-1\",\n            speakers=[\n                {\n                    \"name\": \"Host\",\n                    \"voice_id\": \"voice123\",\n                    \"backstory\": \"A friendly host\",\n                    \"personality\": \"Enthusiastic and welcoming\",\n                }\n            ],\n        )\n        assert len(profile.speakers) == 1\n        assert profile.speakers[0][\"name\"] == \"Host\"\n\n\n# ============================================================================\n# TEST SUITE 7: Transformation Domain\n# ============================================================================\n\n\nclass TestTransformationDomain:\n    \"\"\"Test suite for Transformation domain model.\"\"\"\n\n    def test_transformation_creation(self):\n        \"\"\"Test transformation model creation.\"\"\"\n        transform = Transformation(\n            name=\"summarize\",\n            title=\"Summarize Content\",\n            description=\"Creates a summary\",\n            prompt=\"Summarize the following text: {content}\",\n            apply_default=True,\n        )\n\n        assert transform.name == \"summarize\"\n        assert transform.apply_default is True\n\n\n# ============================================================================\n# TEST SUITE 8: Content Settings\n# ============================================================================\n\n\nclass TestContentSettings:\n    \"\"\"Test suite for ContentSettings defaults.\"\"\"\n\n    def test_content_settings_defaults(self):\n        \"\"\"Test ContentSettings has proper defaults.\"\"\"\n        settings = ContentSettings()\n\n        assert settings.record_id == \"open_notebook:content_settings\"\n        assert settings.default_content_processing_engine_doc == \"auto\"\n        assert settings.default_embedding_option == \"ask\"\n        assert settings.auto_delete_files == \"yes\"\n        assert len(settings.youtube_preferred_languages) > 0\n\n\n# ============================================================================\n# TEST SUITE 9: Episode Profile Validation\n# ============================================================================\n\n\nclass TestEpisodeProfile:\n    \"\"\"Test suite for EpisodeProfile validation.\"\"\"\n\n    def test_episode_profile_segment_validation(self):\n        \"\"\"Test segment count validation (3-20).\"\"\"\n        # Test invalid - too few segments\n        with pytest.raises(\n            ValidationError, match=\"Number of segments must be between 3 and 20\"\n        ):\n            EpisodeProfile(\n                name=\"Test\",\n                speaker_config=\"default\",\n                outline_provider=\"openai\",\n                outline_model=\"gpt-4\",\n                transcript_provider=\"openai\",\n                transcript_model=\"gpt-4\",\n                default_briefing=\"Test briefing\",\n                num_segments=2,\n            )\n\n        # Test invalid - too many segments\n        with pytest.raises(\n            ValidationError, match=\"Number of segments must be between 3 and 20\"\n        ):\n            EpisodeProfile(\n                name=\"Test\",\n                speaker_config=\"default\",\n                outline_provider=\"openai\",\n                outline_model=\"gpt-4\",\n                transcript_provider=\"openai\",\n                transcript_model=\"gpt-4\",\n                default_briefing=\"Test briefing\",\n                num_segments=21,\n            )\n\n        # Test valid segment count\n        profile = EpisodeProfile(\n            name=\"Test\",\n            speaker_config=\"default\",\n            outline_provider=\"openai\",\n            outline_model=\"gpt-4\",\n            transcript_provider=\"openai\",\n            transcript_model=\"gpt-4\",\n            default_briefing=\"Test briefing\",\n            num_segments=5,\n        )\n        assert profile.num_segments == 5\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_embedding.py",
    "content": "\"\"\"\nUnit tests for the open_notebook.utils.embedding module.\n\nTests embedding generation and mean pooling functionality.\n\"\"\"\n\nimport pytest\n\nfrom open_notebook.utils.embedding import (\n    generate_embedding,\n    generate_embeddings,\n    mean_pool_embeddings,\n)\n\n# ============================================================================\n# TEST SUITE 1: Mean Pooling\n# ============================================================================\n\n\nclass TestMeanPoolEmbeddings:\n    \"\"\"Test suite for mean pooling functionality.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_single_embedding(self):\n        \"\"\"Test mean pooling with single embedding returns normalized version.\"\"\"\n        embedding = [1.0, 0.0, 0.0]\n        result = await mean_pool_embeddings([embedding])\n        assert len(result) == 3\n        # Should be normalized (already unit length)\n        assert abs(result[0] - 1.0) < 0.001\n        assert abs(result[1]) < 0.001\n        assert abs(result[2]) < 0.001\n\n    @pytest.mark.asyncio\n    async def test_two_embeddings(self):\n        \"\"\"Test mean pooling with two embeddings.\"\"\"\n        embeddings = [\n            [1.0, 0.0, 0.0],\n            [0.0, 1.0, 0.0],\n        ]\n        result = await mean_pool_embeddings(embeddings)\n        assert len(result) == 3\n        # Mean of normalized vectors, then normalized\n        # Result should be roughly [0.707, 0.707, 0]\n        assert abs(result[0] - result[1]) < 0.001  # x and y should be equal\n        assert abs(result[2]) < 0.001  # z should be ~0\n\n    @pytest.mark.asyncio\n    async def test_identical_embeddings(self):\n        \"\"\"Test mean pooling with identical embeddings.\"\"\"\n        embedding = [0.5, 0.5, 0.5, 0.5]\n        embeddings = [embedding, embedding, embedding]\n        result = await mean_pool_embeddings(embeddings)\n        assert len(result) == 4\n        # Result should be same direction, just normalized\n        # Original is already normalized if we normalize it\n        import numpy as np\n        orig_norm = np.linalg.norm(embedding)\n        expected = [v / orig_norm for v in embedding]\n        for i in range(4):\n            assert abs(result[i] - expected[i]) < 0.001\n\n    @pytest.mark.asyncio\n    async def test_empty_list_raises(self):\n        \"\"\"Test that empty list raises ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"empty\"):\n            await mean_pool_embeddings([])\n\n    @pytest.mark.asyncio\n    async def test_normalization(self):\n        \"\"\"Test that result is normalized to unit length.\"\"\"\n        embeddings = [\n            [3.0, 4.0, 0.0],  # Not unit length\n            [0.0, 5.0, 0.0],  # Not unit length\n        ]\n        result = await mean_pool_embeddings(embeddings)\n        # Check result is unit length\n        import numpy as np\n        norm = np.linalg.norm(result)\n        assert abs(norm - 1.0) < 0.001\n\n    @pytest.mark.asyncio\n    async def test_high_dimensional(self):\n        \"\"\"Test mean pooling with high-dimensional embeddings.\"\"\"\n        import numpy as np\n        # Create random embeddings of dimension 768 (typical embedding size)\n        np.random.seed(42)\n        embeddings = [\n            np.random.randn(768).tolist(),\n            np.random.randn(768).tolist(),\n            np.random.randn(768).tolist(),\n        ]\n        result = await mean_pool_embeddings(embeddings)\n        assert len(result) == 768\n        # Check result is normalized\n        norm = np.linalg.norm(result)\n        assert abs(norm - 1.0) < 0.001\n\n\n# ============================================================================\n# TEST SUITE 2: Generate Embeddings (requires mocking)\n# ============================================================================\n\n\nclass TestGenerateEmbeddings:\n    \"\"\"Test suite for batch embedding generation.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_empty_list(self):\n        \"\"\"Test that empty list returns empty list.\"\"\"\n        result = await generate_embeddings([])\n        assert result == []\n\n    @pytest.mark.asyncio\n    async def test_no_model_raises(self):\n        \"\"\"Test that missing model raises ValueError.\"\"\"\n        from unittest.mock import AsyncMock, patch\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=None,\n        ):\n            with pytest.raises(ValueError, match=\"No embedding model configured\"):\n                await generate_embeddings([\"test text\"])\n\n    @pytest.mark.asyncio\n    async def test_successful_embedding(self):\n        \"\"\"Test successful embedding generation with mocked model.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        mock_model = MagicMock()\n        mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=mock_model,\n        ):\n            result = await generate_embeddings([\"text1\", \"text2\"])\n            assert len(result) == 2\n            assert result[0] == [0.1, 0.2, 0.3]\n            assert result[1] == [0.4, 0.5, 0.6]\n            mock_model.aembed.assert_called_once_with([\"text1\", \"text2\"])\n\n\n# ============================================================================\n# TEST SUITE 3: Generate Single Embedding (requires mocking)\n# ============================================================================\n\n\nclass TestGenerateEmbedding:\n    \"\"\"Test suite for single embedding generation.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_empty_text_raises(self):\n        \"\"\"Test that empty text raises ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"empty\"):\n            await generate_embedding(\"\")\n\n        with pytest.raises(ValueError, match=\"empty\"):\n            await generate_embedding(\"   \")\n\n    @pytest.mark.asyncio\n    async def test_short_text_direct_embedding(self):\n        \"\"\"Test that short text is embedded directly without chunking.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        mock_model = MagicMock()\n        mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3]])\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=mock_model,\n        ):\n            result = await generate_embedding(\"Short text\")\n            assert result == [0.1, 0.2, 0.3]\n            # Should be called with single text\n            mock_model.aembed.assert_called_once_with([\"Short text\"])\n\n    @pytest.mark.asyncio\n    async def test_long_text_chunked_and_pooled(self):\n        \"\"\"Test that long text is chunked and mean pooled.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        # Create text longer than chunk size\n        long_text = \"This is a sentence. \" * 200  # ~4000 chars\n\n        mock_model = MagicMock()\n        # Return multiple embeddings (one per chunk)\n        mock_model.aembed = AsyncMock(\n            return_value=[\n                [1.0, 0.0, 0.0],\n                [0.0, 1.0, 0.0],\n            ]\n        )\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=mock_model,\n        ):\n            result = await generate_embedding(long_text)\n            # Should return mean pooled result\n            assert len(result) == 3\n            # Model should have been called with multiple chunks\n            assert mock_model.aembed.called\n\n    @pytest.mark.asyncio\n    async def test_content_type_parameter(self):\n        \"\"\"Test that content type parameter is passed through.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        from open_notebook.utils.chunking import ContentType\n\n        mock_model = MagicMock()\n        mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3]])\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=mock_model,\n        ):\n            result = await generate_embedding(\n                \"# Markdown Header\\n\\nContent\",\n                content_type=ContentType.MARKDOWN,\n            )\n            assert len(result) == 3\n\n\n    @pytest.mark.asyncio\n    async def test_batching(self):\n        \"\"\"Test that large input is split into batches of EMBEDDING_BATCH_SIZE.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, call, patch\n\n        from open_notebook.utils.embedding import EMBEDDING_BATCH_SIZE\n\n        num_texts = 120\n        texts = [f\"text_{i}\" for i in range(num_texts)]\n\n        mock_model = MagicMock()\n        mock_model.model_name = \"test-model\"\n\n        def make_embeddings(batch):\n            return [[float(i)] * 3 for i in range(len(batch))]\n\n        mock_model.aembed = AsyncMock(side_effect=lambda batch: make_embeddings(batch))\n\n        with patch(\n            \"open_notebook.ai.models.model_manager.get_embedding_model\",\n            new_callable=AsyncMock,\n            return_value=mock_model,\n        ):\n            result = await generate_embeddings(texts)\n\n            assert len(result) == num_texts\n            # 120 texts / 50 batch size = 3 batches (50, 50, 20)\n            assert mock_model.aembed.call_count == 3\n            assert len(mock_model.aembed.call_args_list[0][0][0]) == EMBEDDING_BATCH_SIZE\n            assert len(mock_model.aembed.call_args_list[1][0][0]) == EMBEDDING_BATCH_SIZE\n            assert len(mock_model.aembed.call_args_list[2][0][0]) == 20\n\n    @pytest.mark.asyncio\n    async def test_batch_retry_on_transient_failure(self):\n        \"\"\"Test that a transient failure is retried and succeeds.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        texts = [\"text_a\", \"text_b\"]\n        mock_model = MagicMock()\n        mock_model.model_name = \"test-model\"\n\n        # Fail once, then succeed\n        mock_model.aembed = AsyncMock(\n            side_effect=[\n                RuntimeError(\"transient error\"),\n                [[0.1, 0.2], [0.3, 0.4]],\n            ]\n        )\n\n        with (\n            patch(\n                \"open_notebook.ai.models.model_manager.get_embedding_model\",\n                new_callable=AsyncMock,\n                return_value=mock_model,\n            ),\n            patch(\"open_notebook.utils.embedding.EMBEDDING_RETRY_DELAY\", 0),\n        ):\n            result = await generate_embeddings(texts)\n            assert result == [[0.1, 0.2], [0.3, 0.4]]\n            assert mock_model.aembed.call_count == 2\n\n    @pytest.mark.asyncio\n    async def test_batch_retry_exhaustion(self):\n        \"\"\"Test that RuntimeError is raised after all retries are exhausted.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock, patch\n\n        from open_notebook.utils.embedding import EMBEDDING_MAX_RETRIES\n\n        texts = [\"text_a\"]\n        mock_model = MagicMock()\n        mock_model.model_name = \"test-model\"\n        mock_model.aembed = AsyncMock(side_effect=RuntimeError(\"persistent error\"))\n\n        with (\n            patch(\n                \"open_notebook.ai.models.model_manager.get_embedding_model\",\n                new_callable=AsyncMock,\n                return_value=mock_model,\n            ),\n            patch(\"open_notebook.utils.embedding.EMBEDDING_RETRY_DELAY\", 0),\n        ):\n            with pytest.raises(RuntimeError, match=\"Failed to generate embeddings\"):\n                await generate_embeddings(texts)\n            assert mock_model.aembed.call_count == EMBEDDING_MAX_RETRIES\n\n\n# ============================================================================\n# TEST SUITE 4: Error Classification for 413\n# ============================================================================\n\n\nclass TestErrorClassifier413:\n    \"\"\"Test that 413 payload-too-large errors are classified correctly.\"\"\"\n\n    def test_413_status_code(self):\n        from open_notebook.exceptions import ExternalServiceError\n        from open_notebook.utils.error_classifier import classify_error\n\n        exc = Exception(\"HTTP 413: Payload Too Large\")\n        exc_class, message = classify_error(exc)\n        assert exc_class is ExternalServiceError\n        assert \"payload is too large\" in message\n\n    def test_request_entity_too_large(self):\n        from open_notebook.exceptions import ExternalServiceError\n        from open_notebook.utils.error_classifier import classify_error\n\n        exc = Exception(\"Request Entity Too Large\")\n        exc_class, message = classify_error(exc)\n        assert exc_class is ExternalServiceError\n        assert \"payload is too large\" in message\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_graphs.py",
    "content": "\"\"\"\nUnit tests for the open_notebook.graphs module.\n\nThis test suite focuses on testing graph structures, tools, and validation\nwithout heavy mocking of the actual processing logic.\n\"\"\"\n\nfrom datetime import datetime\n\nimport pytest\n\nfrom open_notebook.graphs.prompt import PatternChainState, graph\nfrom open_notebook.graphs.tools import get_current_timestamp\nfrom open_notebook.graphs.transformation import (\n    TransformationState,\n    run_transformation,\n)\nfrom open_notebook.graphs.transformation import (\n    graph as transformation_graph,\n)\n\n# ============================================================================\n# TEST SUITE 1: Graph Tools\n# ============================================================================\n\n\nclass TestGraphTools:\n    \"\"\"Test suite for graph tool definitions.\"\"\"\n\n    def test_get_current_timestamp_format(self):\n        \"\"\"Test timestamp tool returns correct format.\"\"\"\n        timestamp = get_current_timestamp.func()\n\n        assert isinstance(timestamp, str)\n        assert len(timestamp) == 14  # YYYYMMDDHHmmss format\n        assert timestamp.isdigit()\n\n    def test_get_current_timestamp_validity(self):\n        \"\"\"Test timestamp represents valid datetime.\"\"\"\n        timestamp = get_current_timestamp.func()\n\n        # Parse it back to datetime to verify validity\n        year = int(timestamp[0:4])\n        month = int(timestamp[4:6])\n        day = int(timestamp[6:8])\n        hour = int(timestamp[8:10])\n        minute = int(timestamp[10:12])\n        second = int(timestamp[12:14])\n\n        # Should be valid date components\n        assert 2020 <= year <= 2100\n        assert 1 <= month <= 12\n        assert 1 <= day <= 31\n        assert 0 <= hour <= 23\n        assert 0 <= minute <= 59\n        assert 0 <= second <= 59\n\n        # Should parse as datetime\n        dt = datetime.strptime(timestamp, \"%Y%m%d%H%M%S\")\n        assert isinstance(dt, datetime)\n\n    def test_get_current_timestamp_is_tool(self):\n        \"\"\"Test that function is properly decorated as a tool.\"\"\"\n        # Check it has tool attributes\n        assert hasattr(get_current_timestamp, \"name\")\n        assert hasattr(get_current_timestamp, \"description\")\n\n\n# ============================================================================\n# TEST SUITE 2: Prompt Graph State\n# ============================================================================\n\n\nclass TestPromptGraph:\n    \"\"\"Test suite for prompt pattern chain graph.\"\"\"\n\n    def test_pattern_chain_state_structure(self):\n        \"\"\"Test PatternChainState structure and fields.\"\"\"\n        state = PatternChainState(\n            prompt=\"Test prompt\", parser=None, input_text=\"Test input\", output=\"\"\n        )\n\n        assert state[\"prompt\"] == \"Test prompt\"\n        assert state[\"parser\"] is None\n        assert state[\"input_text\"] == \"Test input\"\n        assert state[\"output\"] == \"\"\n\n    def test_prompt_graph_compilation(self):\n        \"\"\"Test that prompt graph compiles correctly.\"\"\"\n        assert graph is not None\n\n        # Graph should have the expected structure\n        assert hasattr(graph, \"invoke\")\n        assert hasattr(graph, \"ainvoke\")\n\n\n# ============================================================================\n# TEST SUITE 3: Transformation Graph\n# ============================================================================\n\n\nclass TestTransformationGraph:\n    \"\"\"Test suite for transformation graph workflows.\"\"\"\n\n    def test_transformation_state_structure(self):\n        \"\"\"Test TransformationState structure and fields.\"\"\"\n        from unittest.mock import MagicMock\n\n        from open_notebook.domain.notebook import Source\n        from open_notebook.domain.transformation import Transformation\n\n        mock_source = MagicMock(spec=Source)\n        mock_transformation = MagicMock(spec=Transformation)\n\n        state = TransformationState(\n            input_text=\"Test text\",\n            source=mock_source,\n            transformation=mock_transformation,\n            output=\"\",\n        )\n\n        assert state[\"input_text\"] == \"Test text\"\n        assert state[\"source\"] == mock_source\n        assert state[\"transformation\"] == mock_transformation\n        assert state[\"output\"] == \"\"\n\n    @pytest.mark.asyncio\n    async def test_run_transformation_assertion_no_content(self):\n        \"\"\"Test transformation raises assertion with no content.\"\"\"\n        from unittest.mock import MagicMock\n\n        from open_notebook.domain.transformation import Transformation\n\n        mock_transformation = MagicMock(spec=Transformation)\n\n        state = {\n            \"input_text\": None,\n            \"transformation\": mock_transformation,\n            \"source\": None,\n        }\n\n        config = {\"configurable\": {\"model_id\": None}}\n\n        with pytest.raises(AssertionError, match=\"No content to transform\"):\n            await run_transformation(state, config)\n\n    def test_transformation_graph_compilation(self):\n        \"\"\"Test that transformation graph compiles correctly.\"\"\"\n        assert transformation_graph is not None\n        assert hasattr(transformation_graph, \"invoke\")\n        assert hasattr(transformation_graph, \"ainvoke\")\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/test_models_api.py",
    "content": "from unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture\ndef client():\n    \"\"\"Create test client after environment variables have been cleared by conftest.\"\"\"\n    from api.main import app\n\n    return TestClient(app)\n\n\nclass TestModelCreation:\n    \"\"\"Test suite for Model Creation endpoint.\"\"\"\n\n    @pytest.mark.asyncio\n    @patch(\"open_notebook.database.repository.repo_query\")\n    @patch(\"api.routers.models.Model.save\")\n    async def test_create_duplicate_model_same_case(\n        self, mock_save, mock_repo_query, client\n    ):\n        \"\"\"Test that creating a duplicate model with same case returns 400.\"\"\"\n        # Mock repo_query to return a duplicate model\n        mock_repo_query.return_value = [\n            {\n                \"id\": \"model:123\",\n                \"name\": \"gpt-4\",\n                \"provider\": \"openai\",\n                \"type\": \"language\",\n            }\n        ]\n\n        # Attempt to create duplicate\n        response = client.post(\n            \"/api/models\",\n            json={\"name\": \"gpt-4\", \"provider\": \"openai\", \"type\": \"language\"},\n        )\n\n        assert response.status_code == 400\n        assert (\n            response.json()[\"detail\"]\n            == \"Model 'gpt-4' already exists for provider 'openai' with type 'language'\"\n        )\n\n    @pytest.mark.asyncio\n    @patch(\"open_notebook.database.repository.repo_query\")\n    @patch(\"api.routers.models.Model.save\")\n    async def test_create_duplicate_model_different_case(\n        self, mock_save, mock_repo_query, client\n    ):\n        \"\"\"Test that creating a duplicate model with different case returns 400.\"\"\"\n        # Mock repo_query to return a duplicate model (case-insensitive match)\n        mock_repo_query.return_value = [\n            {\n                \"id\": \"model:123\",\n                \"name\": \"gpt-4\",\n                \"provider\": \"openai\",\n                \"type\": \"language\",\n            }\n        ]\n\n        # Attempt to create duplicate with different case\n        response = client.post(\n            \"/api/models\",\n            json={\"name\": \"GPT-4\", \"provider\": \"OpenAI\", \"type\": \"language\"},\n        )\n\n        assert response.status_code == 400\n        assert (\n            response.json()[\"detail\"]\n            == \"Model 'GPT-4' already exists for provider 'OpenAI' with type 'language'\"\n        )\n\n    @pytest.mark.asyncio\n    @patch(\"open_notebook.database.repository.repo_query\")\n    async def test_create_same_model_name_different_provider(\n        self, mock_repo_query, client\n    ):\n        \"\"\"Test that creating a model with same name but different provider is allowed.\"\"\"\n        from open_notebook.ai.models import Model\n\n        # Mock repo_query to return empty (no duplicate found for different provider)\n        mock_repo_query.return_value = []\n\n        # Patch the save method on the Model class\n        with patch.object(Model, \"save\", new_callable=AsyncMock) as mock_save:\n            # Attempt to create same model name with different provider (anthropic)\n            response = client.post(\n                \"/api/models\",\n                json={\"name\": \"gpt-4\", \"provider\": \"anthropic\", \"type\": \"language\"},\n            )\n\n            # Should succeed because provider is different\n            assert response.status_code == 200\n\n    @pytest.mark.asyncio\n    @patch(\"open_notebook.database.repository.repo_query\")\n    async def test_create_same_model_name_different_type(self, mock_repo_query, client):\n        \"\"\"Test that creating a model with same name but different type is allowed.\"\"\"\n        from open_notebook.ai.models import Model\n\n        # Mock repo_query to return empty (no duplicate found for different type)\n        mock_repo_query.return_value = []\n\n        # Patch the save method on the Model class\n        with patch.object(Model, \"save\", new_callable=AsyncMock) as mock_save:\n            # Attempt to create same model name with different type (embedding instead of language)\n            response = client.post(\n                \"/api/models\",\n                json={\"name\": \"gpt-4\", \"provider\": \"openai\", \"type\": \"embedding\"},\n            )\n\n            # Should succeed because type is different\n            assert response.status_code == 200\n\n\nclass TestModelsProviderAvailability:\n    \"\"\"Test suite for Models Provider Availability endpoint.\"\"\"\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_generic_env_var_enables_all_modes(self, mock_esperanto, mock_env, client):\n        \"\"\"Test that OPENAI_COMPATIBLE_BASE_URL enables all 4 modes.\"\"\"\n\n        # Mock environment: only generic var is set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL\":\n                return \"http://localhost:1234/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # openai-compatible should be available\n        assert \"openai-compatible\" in data[\"available\"]\n\n        # Should support all 4 types\n        assert \"openai-compatible\" in data[\"supported_types\"]\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert \"language\" in supported\n        assert \"embedding\" in supported\n        assert \"speech_to_text\" in supported\n        assert \"text_to_speech\" in supported\n        assert len(supported) == 4\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_mode_specific_env_vars_llm_embedding(\n        self, mock_esperanto, mock_env, client\n    ):\n        \"\"\"Test mode-specific env vars (LLM + EMBEDDING) enable only those 2 modes.\"\"\"\n\n        # Mock environment: only LLM and EMBEDDING specific vars are set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_LLM\":\n                return \"http://localhost:1234/v1\"\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_EMBEDDING\":\n                return \"http://localhost:8080/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # openai-compatible should be available\n        assert \"openai-compatible\" in data[\"available\"]\n\n        # Should support only language and embedding\n        assert \"openai-compatible\" in data[\"supported_types\"]\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert \"language\" in supported\n        assert \"embedding\" in supported\n        assert \"speech_to_text\" not in supported\n        assert \"text_to_speech\" not in supported\n        assert len(supported) == 2\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_no_env_vars_set(self, mock_esperanto, mock_env, client):\n        \"\"\"Test that openai-compatible is not available when no env vars are set.\"\"\"\n\n        # Mock environment: no openai-compatible vars are set\n        def env_side_effect(key):\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # openai-compatible should NOT be available\n        assert \"openai-compatible\" not in data[\"available\"]\n        assert \"openai-compatible\" in data[\"unavailable\"]\n\n        # Should not have supported_types entry\n        assert \"openai-compatible\" not in data[\"supported_types\"]\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_mixed_config_generic_and_mode_specific(\n        self, mock_esperanto, mock_env, client\n    ):\n        \"\"\"Test mixed config: generic + mode-specific (generic should enable all).\"\"\"\n\n        # Mock environment: both generic and mode-specific vars are set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL\":\n                return \"http://localhost:1234/v1\"\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_LLM\":\n                return \"http://localhost:5678/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # openai-compatible should be available\n        assert \"openai-compatible\" in data[\"available\"]\n\n        # Generic var enables all, so all 4 should be supported\n        assert \"openai-compatible\" in data[\"supported_types\"]\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert \"language\" in supported\n        assert \"embedding\" in supported\n        assert \"speech_to_text\" in supported\n        assert \"text_to_speech\" in supported\n        assert len(supported) == 4\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_individual_mode_llm_only(self, mock_esperanto, mock_env, client):\n        \"\"\"Test individual mode-specific var (LLM only).\"\"\"\n\n        # Mock environment: only LLM specific var is set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_LLM\":\n                return \"http://localhost:1234/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # Should support only language\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert supported == [\"language\"]\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_individual_mode_embedding_only(self, mock_esperanto, mock_env, client):\n        \"\"\"Test individual mode-specific var (EMBEDDING only).\"\"\"\n\n        # Mock environment: only EMBEDDING specific var is set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_EMBEDDING\":\n                return \"http://localhost:8080/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # Should support only embedding\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert supported == [\"embedding\"]\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_individual_mode_stt_only(self, mock_esperanto, mock_env, client):\n        \"\"\"Test individual mode-specific var (STT only).\"\"\"\n\n        # Mock environment: only STT specific var is set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_STT\":\n                return \"http://localhost:9000/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # Should support only speech_to_text\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert supported == [\"speech_to_text\"]\n\n    @patch(\"api.routers.models.os.environ.get\")\n    @patch(\"api.routers.models.AIFactory.get_available_providers\")\n    def test_individual_mode_tts_only(self, mock_esperanto, mock_env, client):\n        \"\"\"Test individual mode-specific var (TTS only).\"\"\"\n\n        # Mock environment: only TTS specific var is set\n        def env_side_effect(key):\n            if key == \"OPENAI_COMPATIBLE_BASE_URL_TTS\":\n                return \"http://localhost:9000/v1\"\n            return None\n\n        mock_env.side_effect = env_side_effect\n\n        # Mock Esperanto response\n        mock_esperanto.return_value = {\n            \"language\": [\"openai-compatible\"],\n            \"embedding\": [\"openai-compatible\"],\n            \"speech_to_text\": [\"openai-compatible\"],\n            \"text_to_speech\": [\"openai-compatible\"],\n        }\n\n        response = client.get(\"/api/models/providers\")\n\n        assert response.status_code == 200\n        data = response.json()\n\n        # Should support only text_to_speech\n        supported = data[\"supported_types\"][\"openai-compatible\"]\n        assert supported == [\"text_to_speech\"]\n"
  },
  {
    "path": "tests/test_notes_api.py",
    "content": "from unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\n\n@pytest.fixture\ndef client():\n    \"\"\"Create test client after environment variables have been cleared by conftest.\"\"\"\n    from api.main import app\n\n    return TestClient(app)\n\n\nclass TestNoteCreation:\n    \"\"\"Test suite for Note API endpoints.\"\"\"\n\n    @patch(\"api.routers.notes.Note\")\n    def test_create_note_returns_command_id(self, mock_note_cls, client):\n        \"\"\"Test that creating a note returns the embed command_id.\"\"\"\n        mock_note = AsyncMock()\n        mock_note.id = \"note:abc123\"\n        mock_note.title = \"Test Note\"\n        mock_note.content = \"Some content\"\n        mock_note.note_type = \"human\"\n        mock_note.created = \"2026-01-01T00:00:00Z\"\n        mock_note.updated = \"2026-01-01T00:00:00Z\"\n        mock_note.save.return_value = \"command:embed123\"\n        mock_note.add_to_notebook = AsyncMock()\n        mock_note_cls.return_value = mock_note\n\n        response = client.post(\n            \"/api/notes\",\n            json={\"content\": \"Some content\", \"note_type\": \"human\"},\n        )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"command_id\"] == \"command:embed123\"\n        assert data[\"id\"] == \"note:abc123\"\n\n    @patch(\"api.routers.notes.Note\")\n    def test_create_note_command_id_none_when_no_content_embedding(\n        self, mock_note_cls, client\n    ):\n        \"\"\"Test that command_id is None when save returns None (no embedding).\"\"\"\n        mock_note = AsyncMock()\n        mock_note.id = \"note:abc456\"\n        mock_note.title = \"Empty Note\"\n        mock_note.content = \"Some content\"\n        mock_note.note_type = \"human\"\n        mock_note.created = \"2026-01-01T00:00:00Z\"\n        mock_note.updated = \"2026-01-01T00:00:00Z\"\n        mock_note.save.return_value = None\n        mock_note.add_to_notebook = AsyncMock()\n        mock_note_cls.return_value = mock_note\n\n        response = client.post(\n            \"/api/notes\",\n            json={\"content\": \"Some content\", \"note_type\": \"human\"},\n        )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"command_id\"] is None\n\n\nclass TestNoteUpdate:\n    \"\"\"Test suite for Note update endpoint.\"\"\"\n\n    @patch(\"api.routers.notes.Note\")\n    def test_update_note_returns_command_id(self, mock_note_cls, client):\n        \"\"\"Test that updating a note returns the embed command_id.\"\"\"\n        mock_note = AsyncMock()\n        mock_note.id = \"note:abc123\"\n        mock_note.title = \"Test Note\"\n        mock_note.content = \"Original content\"\n        mock_note.note_type = \"human\"\n        mock_note.created = \"2026-01-01T00:00:00Z\"\n        mock_note.updated = \"2026-01-01T00:00:00Z\"\n        mock_note.save.return_value = \"command:embed789\"\n        mock_note_cls.get = AsyncMock(return_value=mock_note)\n\n        response = client.put(\n            \"/api/notes/note:abc123\",\n            json={\"content\": \"Updated content\"},\n        )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"command_id\"] == \"command:embed789\"\n\n    @patch(\"api.routers.notes.Note\")\n    def test_update_note_command_id_none_when_no_embedding(\n        self, mock_note_cls, client\n    ):\n        \"\"\"Test that command_id is None on update when no embedding is triggered.\"\"\"\n        mock_note = AsyncMock()\n        mock_note.id = \"note:abc123\"\n        mock_note.title = \"Test Note\"\n        mock_note.content = \"Some content\"\n        mock_note.note_type = \"human\"\n        mock_note.created = \"2026-01-01T00:00:00Z\"\n        mock_note.updated = \"2026-01-01T00:00:00Z\"\n        mock_note.save.return_value = None\n        mock_note_cls.get = AsyncMock(return_value=mock_note)\n\n        response = client.put(\n            \"/api/notes/note:abc123\",\n            json={\"title\": \"Updated Title\"},\n        )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"command_id\"] is None\n"
  },
  {
    "path": "tests/test_podcast_path.py",
    "content": "\"\"\"\nTests for podcast episode directory path generation.\n\nVerifies that episode output directories use UUID-based names\ninstead of raw episode names, preventing filesystem issues with\nspaces and special characters (GitHub issue #663).\n\"\"\"\n\nimport uuid\nfrom pathlib import PurePosixPath\n\nfrom commands.podcast_commands import build_episode_output_dir\n\n\nclass TestBuildEpisodeOutputDir:\n    \"\"\"Test the actual production helper that builds episode output paths.\"\"\"\n\n    def test_directory_name_is_valid_uuid(self):\n        dir_name, _ = build_episode_output_dir(\"/data\")\n        parsed = uuid.UUID(dir_name)\n        assert str(parsed) == dir_name\n\n    def test_path_structure(self):\n        dir_name, output_dir = build_episode_output_dir(\"/data\")\n        assert str(output_dir) == f\"/data/podcasts/episodes/{dir_name}\"\n\n    def test_no_collision_between_calls(self):\n        dir1, _ = build_episode_output_dir(\"/data\")\n        dir2, _ = build_episode_output_dir(\"/data\")\n        assert dir1 != dir2\n\n    def test_path_is_independent_of_episode_name(self):\n        \"\"\"The returned path must never contain user-supplied episode names.\n\n        Since build_episode_output_dir does not accept an episode name at all,\n        any name the user types is structurally excluded from the path.\n        \"\"\"\n        problematic_names = [\n            \"My Episode Name\",\n            \"Episode: Part 1\",\n            'test \"quotes\"',\n            \"path/traversal\",\n            \"café résumé\",\n            \"   spaces   \",\n            \"?*<>|\",\n        ]\n        for name in problematic_names:\n            _, output_dir = build_episode_output_dir(\"/data\")\n            path_str = str(output_dir)\n            # The episode name must not appear anywhere in the path\n            assert name not in path_str\n            # UUID paths contain only hex digits and hyphens after the base\n            dir_component = output_dir.name\n            assert all(c in \"0123456789abcdef-\" for c in dir_component), (\n                f\"Unexpected chars in directory name: {dir_component}\"\n            )\n\n    def test_path_works_on_posix(self):\n        dir_name, output_dir = build_episode_output_dir(\"/data\")\n        posix = PurePosixPath(str(output_dir))\n        assert posix.parts == (\"/\", \"data\", \"podcasts\", \"episodes\", dir_name)\n\n    def test_directory_can_be_created(self, tmp_path):\n        \"\"\"Create the directory on the real filesystem.\"\"\"\n        _, output_dir = build_episode_output_dir(str(tmp_path))\n        output_dir.mkdir(parents=True, exist_ok=True)\n        assert output_dir.exists()\n        assert output_dir.is_dir()\n"
  },
  {
    "path": "tests/test_url_validation.py",
    "content": "\"\"\"\nTest URL validation for SSRF protection in API key configuration.\n\nNote: The validation is intentionally permissive for self-hosted scenarios.\nIt only blocks:\n- Invalid schemes (must be http or https)\n- Malformed URLs\n- Link-local addresses (169.254.x.x) - used for cloud metadata endpoints\n\nLocalhost and private IPs are ALLOWED because this is a self-hosted application\nwhere users commonly run local services (Ollama, LM Studio, etc.).\n\"\"\"\n\nimport pytest\n\nfrom api.credentials_service import validate_url\n\n\nclass TestUrlValidation:\n    \"\"\"Test suite for URL validation to prevent SSRF attacks.\"\"\"\n\n    def test_valid_https_url(self):\n        \"\"\"Valid HTTPS URLs should pass.\"\"\"\n        validate_url(\"https://api.openai.com\", \"openai\")\n        validate_url(\"https://example.com/api\", \"anthropic\")\n        # Should not raise\n\n    def test_valid_http_url(self):\n        \"\"\"Valid HTTP URLs should pass.\"\"\"\n        validate_url(\"http://example.com\", \"openai\")\n        # Should not raise\n\n    def test_invalid_scheme(self):\n        \"\"\"URLs with invalid schemes should be rejected.\"\"\"\n        with pytest.raises(ValueError, match=\"Invalid URL scheme\"):\n            validate_url(\"ftp://example.com\", \"openai\")\n\n        with pytest.raises(ValueError, match=\"Invalid URL scheme\"):\n            validate_url(\"file:///etc/passwd\", \"openai\")\n\n    def test_localhost_allowed_for_self_hosted(self):\n        \"\"\"Localhost should be allowed for self-hosted services.\"\"\"\n        # This is a self-hosted app, localhost is valid for local services\n        validate_url(\"http://localhost:8000\", \"openai\")\n        validate_url(\"http://127.0.0.1:8000\", \"azure\")\n        # Should not raise\n\n    def test_localhost_allowed_for_ollama(self):\n        \"\"\"Localhost should be allowed for Ollama provider.\"\"\"\n        validate_url(\"http://localhost:11434\", \"ollama\")\n        validate_url(\"http://127.0.0.1:11434\", \"ollama\")\n        # Should not raise\n\n    def test_private_ip_allowed_for_self_hosted(self):\n        \"\"\"Private IP addresses should be allowed for self-hosted scenarios.\"\"\"\n        # This is a self-hosted app, private IPs are valid for internal services\n        validate_url(\"http://10.0.0.1\", \"openai\")\n        validate_url(\"http://172.16.0.1:8080\", \"anthropic\")\n        validate_url(\"http://192.168.1.1\", \"azure\")\n        # Should not raise\n\n    def test_private_ip_allowed_for_ollama(self):\n        \"\"\"Private IP addresses should be allowed for Ollama provider.\"\"\"\n        validate_url(\"http://192.168.1.100:11434\", \"ollama\")\n        validate_url(\"http://10.0.0.50:11434\", \"ollama\")\n        # Should not raise\n\n    def test_loopback_allowed_for_self_hosted(self):\n        \"\"\"Loopback addresses should be allowed for self-hosted scenarios.\"\"\"\n        validate_url(\"http://127.0.0.2\", \"openai\")\n        # Should not raise\n\n    def test_link_local_rejection(self):\n        \"\"\"Link-local addresses should be rejected (cloud metadata protection).\"\"\"\n        with pytest.raises(ValueError, match=\"Link-local addresses\"):\n            validate_url(\"http://169.254.169.254\", \"openai\")\n\n        # Also reject for ollama - link-local is never valid\n        with pytest.raises(ValueError, match=\"Link-local addresses\"):\n            validate_url(\"http://169.254.169.254\", \"ollama\")\n\n    def test_ipv6_localhost_allowed(self):\n        \"\"\"IPv6 localhost should be allowed for self-hosted scenarios.\"\"\"\n        validate_url(\"http://[::1]:8000\", \"openai\")\n        # Should not raise\n\n    def test_empty_url(self):\n        \"\"\"Empty URLs should not raise (handled elsewhere).\"\"\"\n        validate_url(\"\", \"openai\")\n        # None is handled by the function's early return check\n        # Should not raise\n\n    def test_invalid_url_format(self):\n        \"\"\"Malformed URLs should be rejected.\"\"\"\n        with pytest.raises(ValueError):\n            validate_url(\"not-a-url\", \"openai\")\n\n    def test_public_hostnames_allowed(self):\n        \"\"\"Public hostnames should be allowed.\"\"\"\n        validate_url(\"https://api.openai.com/v1\", \"openai\")\n        validate_url(\"https://api.anthropic.com\", \"anthropic\")\n        validate_url(\"https://generativelanguage.googleapis.com\", \"google\")\n        validate_url(\"https://api.groq.com\", \"groq\")\n        # Should not raise\n\n    def test_azure_specific_urls(self):\n        \"\"\"Azure OpenAI endpoints should be validated.\"\"\"\n        validate_url(\n            \"https://my-resource.openai.azure.com\", \"azure\"\n        )\n        # Localhost is allowed for self-hosted\n        validate_url(\"http://localhost:8000\", \"azure\")\n        # Should not raise\n\n    def test_openai_compatible_urls(self):\n        \"\"\"OpenAI-compatible provider URLs should be validated.\"\"\"\n        validate_url(\"https://api.together.xyz\", \"openai_compatible\")\n        # Private IPs are allowed for self-hosted\n        validate_url(\"http://192.168.1.1:8080\", \"openai_compatible\")\n        # Should not raise\n\n    def test_ipv4_mapped_ipv6_link_local_rejected(self):\n        \"\"\"IPv4-mapped IPv6 addresses pointing to link-local should be rejected.\"\"\"\n        with pytest.raises(ValueError, match=\"Link-local addresses\"):\n            validate_url(\"http://[::ffff:169.254.169.254]\", \"openai\")\n\n    def test_ipv4_mapped_ipv6_private_allowed(self):\n        \"\"\"IPv4-mapped IPv6 addresses pointing to private IPs should be allowed.\"\"\"\n        validate_url(\"http://[::ffff:192.168.1.1]\", \"openai\")\n        # Should not raise - private IPs allowed for self-hosted\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "\"\"\"\nUnit tests for the open_notebook.utils module.\n\nThis test suite focuses on testing utility functions that perform actual logic\nwithout heavy mocking - string processing, validation, and algorithms.\n\"\"\"\n\nimport pytest\n\nfrom open_notebook.utils import (\n    clean_thinking_content,\n    compare_versions,\n    get_installed_version,\n    parse_thinking_content,\n    remove_non_ascii,\n    remove_non_printable,\n    token_count,\n)\nfrom open_notebook.utils.context_builder import ContextBuilder, ContextConfig\n\n# ============================================================================\n# TEST SUITE 1: Text Utilities\n# ============================================================================\n\n\nclass TestTextUtilities:\n    \"\"\"Test suite for text utility functions.\"\"\"\n\n    def test_remove_non_ascii(self):\n        \"\"\"Test removal of non-ASCII characters.\"\"\"\n        # Text with various non-ASCII characters\n        text_with_unicode = \"Hello 世界 café naïve émoji 🎉\"\n        result = remove_non_ascii(text_with_unicode)\n\n        # Should only contain ASCII characters\n        assert result == \"Hello  caf nave moji \"\n        # All characters should be in ASCII range\n        assert all(ord(char) < 128 for char in result)\n\n    def test_remove_non_ascii_pure_ascii(self):\n        \"\"\"Test that pure ASCII text is unchanged.\"\"\"\n        text = \"Hello World 123 !@#\"\n        result = remove_non_ascii(text)\n        assert result == text\n\n    def test_remove_non_printable(self):\n        \"\"\"Test removal of non-printable characters.\"\"\"\n        # Text with various Unicode whitespace and control chars\n        text = \"Hello\\u2000World\\u200b\\u202fTest\"\n        result = remove_non_printable(text)\n\n        # Should have regular spaces and printable chars only\n        assert \"Hello\" in result\n        assert \"World\" in result\n        assert \"Test\" in result\n\n    def test_remove_non_printable_preserves_newlines(self):\n        \"\"\"Test that newlines and tabs are preserved.\"\"\"\n        text = \"Line1\\nLine2\\tTabbed\"\n        result = remove_non_printable(text)\n        assert \"\\n\" in result\n        assert \"\\t\" in result\n\n    def test_parse_thinking_content_basic(self):\n        \"\"\"Test parsing single thinking block.\"\"\"\n        content = \"<think>This is my thinking</think>Here is my answer\"\n        thinking, cleaned = parse_thinking_content(content)\n\n        assert thinking == \"This is my thinking\"\n        assert cleaned == \"Here is my answer\"\n\n    def test_parse_thinking_content_multiple_tags(self):\n        \"\"\"Test parsing multiple thinking blocks.\"\"\"\n        content = \"<think>First thought</think>Answer<think>Second thought</think>More\"\n        thinking, cleaned = parse_thinking_content(content)\n\n        assert \"First thought\" in thinking\n        assert \"Second thought\" in thinking\n        assert \"<think>\" not in cleaned\n        assert \"Answer\" in cleaned\n        assert \"More\" in cleaned\n\n    def test_parse_thinking_content_no_tags(self):\n        \"\"\"Test parsing content without thinking tags.\"\"\"\n        content = \"Just regular content\"\n        thinking, cleaned = parse_thinking_content(content)\n\n        assert thinking == \"\"\n        assert cleaned == \"Just regular content\"\n\n    def test_parse_thinking_content_malformed_no_open_tag(self):\n        \"\"\"Test parsing malformed output where opening <think> tag is missing.\"\"\"\n        content = \"Some thinking content</think>Here is my answer\"\n        thinking, cleaned = parse_thinking_content(content)\n\n        assert thinking == \"Some thinking content\"\n        assert cleaned == \"Here is my answer\"\n\n    def test_parse_thinking_content_invalid_input(self):\n        \"\"\"Test parsing with invalid input types.\"\"\"\n        # Non-string input\n        thinking, cleaned = parse_thinking_content(None)\n        assert thinking == \"\"\n        assert cleaned == \"\"\n\n        # Integer input\n        thinking, cleaned = parse_thinking_content(123)\n        assert thinking == \"\"\n        assert cleaned == \"123\"\n\n    def test_parse_thinking_content_large_content(self):\n        \"\"\"Test that very large content is not processed.\"\"\"\n        large_content = \"x\" * 200000  # > 100KB limit\n        thinking, cleaned = parse_thinking_content(large_content)\n\n        # Should return unchanged due to size limit\n        assert thinking == \"\"\n        assert cleaned == large_content\n\n    def test_clean_thinking_content(self):\n        \"\"\"Test convenience function for cleaning thinking content.\"\"\"\n        content = \"<think>Internal thoughts</think>Public response\"\n        result = clean_thinking_content(content)\n\n        assert \"<think>\" not in result\n        assert \"Public response\" in result\n        assert \"Internal thoughts\" not in result\n\n\n# ============================================================================\n# TEST SUITE 2: Token Utilities\n# ============================================================================\n\n\nclass TestTokenUtilities:\n    \"\"\"Test suite for token counting fallback behavior.\"\"\"\n\n    def test_token_count_fallback(self):\n        \"\"\"Test fallback when tiktoken raises an error.\"\"\"\n        from unittest.mock import patch\n\n        # Make tiktoken raise an ImportError to trigger fallback\n        with patch(\n            \"tiktoken.get_encoding\", side_effect=ImportError(\"tiktoken not available\")\n        ):\n            text = \"one two three four five\"\n            count = token_count(text)\n\n            # Fallback uses word count * 1.3\n            # 5 words * 1.3 = 6.5 -> 6\n            assert isinstance(count, int)\n            assert count > 0\n\n    def test_token_count_network_error_fallback(self):\n        \"\"\"Test fallback when tiktoken raises a network error (issue #264).\n\n        In offline environments tiktoken.get_encoding() tries to download the\n        encoding file and raises a URLError/OSError, not an ImportError.\n        The except clause must catch Exception (not only ImportError) so that\n        these network failures also fall through to the word-count estimate.\n        \"\"\"\n        import urllib.error\n        from unittest.mock import patch\n\n        with patch(\n            \"tiktoken.get_encoding\",\n            side_effect=urllib.error.URLError(\"No network (simulated offline)\"),\n        ):\n            text = \"one two three four five\"\n            count = token_count(text)\n\n            # Must not raise; must return a positive int via the fallback\n            assert isinstance(count, int)\n            assert count > 0\n\n\n# ============================================================================\n# TEST SUITE 3: Version Utilities\n# ============================================================================\n\n\nclass TestVersionUtilities:\n    \"\"\"Test suite for version management functions.\"\"\"\n\n    def test_compare_versions_equal(self):\n        \"\"\"Test comparing equal versions.\"\"\"\n        result = compare_versions(\"1.0.0\", \"1.0.0\")\n        assert result == 0\n\n    def test_compare_versions_less_than(self):\n        \"\"\"Test comparing when first version is less.\"\"\"\n        result = compare_versions(\"1.0.0\", \"2.0.0\")\n        assert result == -1\n\n        result = compare_versions(\"1.0.0\", \"1.1.0\")\n        assert result == -1\n\n        result = compare_versions(\"1.0.0\", \"1.0.1\")\n        assert result == -1\n\n    def test_compare_versions_greater_than(self):\n        \"\"\"Test comparing when first version is greater.\"\"\"\n        result = compare_versions(\"2.0.0\", \"1.0.0\")\n        assert result == 1\n\n        result = compare_versions(\"1.1.0\", \"1.0.0\")\n        assert result == 1\n\n        result = compare_versions(\"1.0.1\", \"1.0.0\")\n        assert result == 1\n\n    def test_compare_versions_prerelease(self):\n        \"\"\"Test comparing versions with pre-release tags.\"\"\"\n        result = compare_versions(\"1.0.0\", \"1.0.0-alpha\")\n        assert result == 1  # Release > pre-release\n\n        result = compare_versions(\"1.0.0-beta\", \"1.0.0-alpha\")\n        assert result == 1  # beta > alpha\n\n    def test_get_installed_version_success(self):\n        \"\"\"Test getting installed package version.\"\"\"\n        # Test with a known installed package\n        version = get_installed_version(\"pytest\")\n        assert isinstance(version, str)\n        assert len(version) > 0\n        # Should look like a version (has dots)\n        assert \".\" in version\n\n    def test_get_installed_version_not_found(self):\n        \"\"\"Test getting version of non-existent package.\"\"\"\n        from importlib.metadata import PackageNotFoundError\n\n        with pytest.raises(PackageNotFoundError):\n            get_installed_version(\"this-package-does-not-exist-12345\")\n\n    def test_get_version_from_github_invalid_url(self):\n        \"\"\"Test GitHub version fetch with invalid URL.\"\"\"\n        from open_notebook.utils.version_utils import get_version_from_github\n\n        with pytest.raises(ValueError, match=\"Not a GitHub URL\"):\n            get_version_from_github(\"https://example.com/repo\")\n\n        with pytest.raises(ValueError, match=\"Invalid GitHub repository URL\"):\n            get_version_from_github(\"https://github.com/\")\n\n\n# ============================================================================\n# TEST SUITE 4: Context Builder Configuration\n# ============================================================================\n\n\nclass TestContextBuilder:\n    \"\"\"Test suite for ContextBuilder initialization and configuration.\"\"\"\n\n    def test_context_config_defaults(self):\n        \"\"\"Test ContextConfig default values.\"\"\"\n        config = ContextConfig()\n\n        assert config.sources == {}\n        assert config.notes == {}\n        assert config.include_insights is True\n        assert config.include_notes is True\n        assert config.priority_weights is not None\n        assert \"source\" in config.priority_weights\n        assert \"note\" in config.priority_weights\n        assert \"insight\" in config.priority_weights\n\n    def test_context_builder_initialization(self):\n        \"\"\"Test ContextBuilder initialization with various params.\"\"\"\n        builder = ContextBuilder(\n            source_id=\"source:123\",\n            notebook_id=\"notebook:456\",\n            max_tokens=1000,\n            include_insights=False,\n        )\n\n        assert builder.source_id == \"source:123\"\n        assert builder.notebook_id == \"notebook:456\"\n        assert builder.max_tokens == 1000\n        assert builder.include_insights is False\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  }
]