Compare commits

..

6 Commits

Author SHA1 Message Date
Enrico Ros 343c89074a Support Unset System Purpose 2023-08-17 00:08:34 -07:00
Enrico Ros fc1306eb96 Remove Default (first) Conversation - useEffect instead (is this correct?) 2023-08-17 00:08:34 -07:00
Enrico Ros 5e11575b6a Remove Default SystemPurposeID 2023-08-17 00:08:34 -07:00
Enrico Ros fb8791aff4 Remove Hidden purposes (and edit stuff) 2023-08-17 00:08:34 -07:00
Enrico Ros 9353666154 Remove Custom Purposes 2023-08-17 00:08:34 -07:00
Enrico Ros bcc31d5ab9 Remove purpose Finder 2023-08-17 00:08:28 -07:00
355 changed files with 8329 additions and 20755 deletions
-38
View File
@@ -1,38 +0,0 @@
# big-AGI non-code files
/docs/
README.md
# Node build artifacts
/node_modules
/.pnp
.pnp.js
# next.js
/.next/
/out/
# production
/build
# versioning
.git/
.github/
# IDEs
.idea/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+28
View File
@@ -0,0 +1,28 @@
# [Recommended for local deployments] Backend API key for OpenAI, so that users don't need one (UI > this > '')
OPENAI_API_KEY=
# [Optional] Sets the "OpenAI-Organization" header field to support organization users (UI > this > '')
OPENAI_API_ORG_ID=
# [Optional] Set the backend host for the OpenAI API, to enable platforms such as Helicone (UI > this > api.openai.com)
OPENAI_API_HOST=
# [Optional, Helicone] Helicone API key: https://www.helicone.ai/keys
HELICONE_API_KEY=
# [Optional] Anthropic credentials for the server-side
ANTHROPIC_API_KEY=
ANTHROPIC_API_HOST=
# [Optional] Enables ElevenLabs credentials on the server side - for optional text-to-speech
ELEVENLABS_API_KEY=
ELEVENLABS_API_HOST=
ELEVENLABS_VOICE_ID=
# [Optional] Prodia credentials on the server side - for optional image generation
PRODIA_API_KEY=
# [Optional, Search] Google Cloud API Key
# https://console.cloud.google.com/apis/credentials -
GOOGLE_CLOUD_API_KEY=
# [Optional, Search] Google Custom/Programmable Search Engine ID
# https://programmablesearchengine.google.com/
GOOGLE_CSE_ID=
-13
View File
@@ -1,13 +0,0 @@
# These are supported funding model platforms
github: enricoros # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-25
View File
@@ -1,25 +0,0 @@
---
name: Bug report
about: Omg what's happening?
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
Where is it happening?
- Which device [Mobile/Desktop, os version]:
- Which browser:
- Which website:
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots / context**
If applicable, please add screenshots or additional context
@@ -1,75 +0,0 @@
---
name: Maintainers-Release
about: Maintainers
title: Release 1.2.3
labels: ''
assignees: enricoros
---
## Release checklist:
- [ ] Update the [Roadmap](https://github.com/users/enricoros/projects/4/views/2) calling out shipped features
- [ ] Create and update a [Milestone](https://github.com/enricoros/big-agi/milestones) for the release
- [ ] Assign this task
- [ ] Assign all the shipped roadmap Issues
- [ ] Assign the relevant [recently closed Isssues](https://github.com/enricoros/big-agi/issues?q=is%3Aclosed+sort%3Aupdated-desc)
- Code changes:
- [ ] Create a release branch 'release-x.y.z': `git checkout -b release-1.2.3`
- [ ] Create a temporary tag `git tag v1.2.3 && git push opensource --tags`
- [ ] Create a [New Draft GitHub Release](https://github.com/enricoros/big-agi/releases/new), and generate the automated changelog (for new contributors)
- [ ] Update the release version in package.json, and `npm i`
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
- [ ] Update the in-app News version number
- [ ] Update the readme with the new release
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
- [ ] merge onto main
- [ ] verify deployment on Vercel
- [ ] verify container on GitHub Packages
- create a GitHub release
- [ ] name it 'vX.Y.Z'
- [ ] copy the release notes and link appropriate artifacts
- Announce:
- [ ] Discord announcement
- [ ] Twitter announcement
## Links
Milestone:
Former release task:
GitHub release:
## Artifacts Generation
1) The following is my opensource application
- paste README.md
2) I am announcing a new version, 1.7.0. The following were the announcements for 1.6.0. Discord announcement, GitHub Release, in-app news.data.tsx, changelog.md.
- paste the former: `discord announcement`, `GitHub release`, `news.data.tsx`, `changelog.md`
3) The following is the new data I have for 1.7.0
- paste the link to the milestone (closed) and each individual issue (content will be downloaded)
- paste the git changelog `git log v1.6.0..v1.7.0 | clip`
### news.data.TSX
```markdown
I need the following from you:
1. a table summarizing all the new features in 1.2.3 (description, significance, usefulness, do not link the commit, but have the issue number), which will be used for the artifacts later
2. after the table score each feature from a user impact and magnitude point of view
3. Improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
4. I want you then to update the news.data.tsx for the new release
```
### GitHub release
Now paste the former release (or 1.5.0 which was accurate and great), including the new contributors and
some stats (# of commits, etc.), and roll it for the new release.
### Discord announcement
```markdown
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement, and the in-app News?
```
-17
View File
@@ -1,17 +0,0 @@
---
name: Roadmap request
about: Suggest a roadmap item
title: "[Roadmap]"
labels: ''
assignees: ''
---
**Why**
The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
**Concise description**
A clear and concise description of what you want to happen.
**Requirements**
If you can, please detail the changes you expect in UX, user workflows, technology, architecture (if not, the reviewers will do it for you)
+4 -14
View File
@@ -7,15 +7,11 @@
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Create and publish Docker images
name: Create and publish a Docker image
on:
push:
branches:
- main
#- main-stable # Disabled as the v* tag is used for stable releases
tags:
- 'v*' # Trigger on version tags (e.g., v1.7.0)
branches: ['main']
env:
REGISTRY: ghcr.io
@@ -30,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
@@ -44,17 +40,11 @@ jobs:
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=development,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main-stable' }}
type=ref,event=tag # Use the tag name as a tag for tag builds
type=semver,pattern={{version}} # Generate semantic versioning tags for tag builds
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
-1
View File
@@ -27,7 +27,6 @@ yarn-error.log*
# local env files
.env
.env.*
# vercel
.vercel
+25 -39
View File
@@ -1,56 +1,42 @@
# Base
FROM node:18-alpine AS base
ENV NEXT_TELEMETRY_DISABLED 1
# Test
FROM node:18-alpine as test-target
ENV NODE_ENV=development
ENV PATH $PATH:/usr/src/app/node_modules/.bin
# Dependencies
FROM base AS deps
WORKDIR /app
WORKDIR /usr/src/app
# Dependency files
COPY package*.json ./
COPY prisma ./prisma
# Install dependencies, including dev (release builds should use npm ci)
ENV NODE_ENV development
RUN npm ci
# CI and release builds should use npm ci to fully respect the lockfile.
# Local development may use npm install for opportunistic package updates.
ARG npm_install_command=ci
RUN npm $npm_install_command
# Builder
FROM base AS builder
WORKDIR /app
# Copy development deps and source
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
ENV NODE_ENV production
# Build
FROM test-target as build-target
ENV NODE_ENV=production
# Use build tools, installed as development packages, to produce a release build.
RUN npm run build
# Reduce installed packages to production-only
# Reduce installed packages to production-only.
RUN npm prune --production
# Runner
FROM base AS runner
WORKDIR /app
# Archive
FROM node:18-alpine as archive-target
ENV NODE_ENV=production
ENV PATH $PATH:/usr/src/app/node_modules/.bin
# As user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
WORKDIR /usr/src/app
# Copy Built app
COPY --from=builder --chown=nextjs:nodejs /app/public public
COPY --from=builder --chown=nextjs:nodejs /app/.next .next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules node_modules
# Minimal ENV for production
ENV NODE_ENV production
ENV PATH $PATH:/app/node_modules/.bin
# Run as non-root user
USER nextjs
# Include only the release build and production packages.
COPY --from=build-target /usr/src/app/node_modules node_modules
COPY --from=build-target /usr/src/app/.next .next
COPY --from=build-target /usr/src/app/public public
# Expose port 3000 for the application to listen on
EXPOSE 3000
# Start the application
CMD ["next", "start"]
CMD ["next", "start"]
+108 -109
View File
@@ -1,80 +1,29 @@
# BIG-AGI 🧠✨
# `BIG-AGI` 🤖💬
Welcome to big-AGI 👋, the GPT application for professionals that need function, form,
simplicity, and speed. Powered by the latest models from 7 vendors and
open-source model servers, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
Welcome to `big-AGI` 👋 your personal AGI application
powered by OpenAI GPT-4 and beyond. Designed for smart humans and super-heroes,
this responsive web app comes with Personas, Drawing, Code Execution, PDF imports, Voice support,
data Rendering, AGI functions, chats and much more. Comes with plenty of `#big-AGI-energy` 🚀
Pros use big-AGI. 🚀 Developers love big-AGI. 🤖
[![Official Website](https://img.shields.io/badge/BIG--AGI.com-%23096bde?style=for-the-badge&logo=vercel&label=launch)](https://big-agi.com)
[![Official Website](https://img.shields.io/badge/BIG--AGI.com-%23096bde?style=for-the-badge&logo=vercel&label=demo)](https://big-agi.com)
Or fork & run on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,OPENAI_API_HOST&envDescription=OpenAI%20KEY%20for%20your%20deployment.%20Set%20HOST%20only%20if%20non-default.)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2)
big-AGI is an open book; our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)**
shows the current developments and future ideas.
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
### What's New in 1.7.3 · Dec 13, 2023 · Attachment Theory 🌟
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
- Optimized Voice Input and Performance
- Latest Ollama and Oobabooga models
- For developers: **Password Protection**: HTTP Basic Auth. [Learn How](https://github.com/enricoros/big-agi/blob/main/docs/deploy-authentication.md)
- [1.7.1]: Improved Ollama chats. [#270](https://github.com/enricoros/big-agi/issues/270)
- [1.7.2]: OpenRouter login & free models 🎁
- [1.7.3]: Mistral Platform support. [#273](https://github.com/enricoros/big-agi/issues/273)
### What's New in 1.6.0 - Nov 28, 2023
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Branching Discussions**: Create new conversations from any message
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
- **Performance Boost**: Faster rendering for a smoother experience
- **UI Enhancements**: Refined interface based on user feedback
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
- **For Developers**: Code quality upgrades and snackbar notifications
### What's New in 1.5.0 - Nov 19, 2023
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
- **Visualization Tool**: Create data representations with our new visualization capabilities
- **Ollama Local Models**: Leverage local models support with our comprehensive guide
- **Text Tools**: Enjoy tools including highlight differences to refine your content
- **Mermaid Diagramming**: Render complex diagrams with our Mermaid language support
- **OpenAI 1106 Chat Models**: Experience the cutting-edge capabilities of the latest OpenAI models
- **SDXL Support**: Enhance your image generation with SDXL support for Prodia
- **Cloudflare OpenAI API Gateway**: Integrate with Cloudflare for a robust API gateway
- **Helicone for Anthropic**: Utilize Helicone's tools for Anthropic models
Check out the [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), or
the [past releases changelog](docs/changelog.md).
## ✨ Key Features 👊
## Useful 👊
![Ask away, paste a ton, copy the gems](docs/pixels/big-AGI-compo1.png)
[More](docs/pixels/big-AGI-compo2b.png), [screenshots](docs/pixels).
- **AI Personas**: Tailor your AI interactions with customizable personas
- **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface
- **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads
- **Multiple AI Models**: Choose from a variety of leading AI providers
- **Privacy First**: Self-host and use your own API keys for full control
- **Advanced Tools**: Execute code, import PDFs, and summarize documents
- **Seamless Integrations**: Enhance functionality with various third-party services
- **Open Roadmap**: Contribute to the progress of big-AGI
- Engaging AI Personas
- Clean UX, w/ tokens counters
- Private: user-owned API keys and localStorage, self-hostable if you like
- Human I/O: Advanced voice support (TTS, STT)
- Machine I/O: PDF import & Summarization, code execution
- Many more updates & integrations: ElevenLabs, Helicone, Paste.gg, Prodia
- Coming up: automatic-AGI reasoning (Reason+Act) and more
## 💖 Support
## Support 🙌
[//]: # ([![Official Discord](https://img.shields.io/discord/1098796266906980422?label=discord&logo=discord&logoColor=%23fff&style=for-the-badge)](https://discord.gg/MkH4qj2Jp9))
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
@@ -90,14 +39,86 @@ the [past releases changelog](docs/changelog.md).
<br/>
## 🧩 Develop
## Latest Drops 💧🎁
#### 🚨 July/Aug: Back with the Cool features 🧠
- 🎉 **Camera OCR** - real-world AI - take a picture of a text, and chat with it
- 🎉 **Backup/Restore** - save chats, and restore them later
- 🎉 **[Local model support with Oobabooga server](docs/local-llm-text-web-ui.md)** - run your own LLMs!
- 🎉 **Flatten conversations** - conversations summarizer with 4 modes
- 🎉 **Fork conversations** - create a new chat, to expriment with different endings
- 🎉 New commands: /s to add a System message, and /a for an Assistant message
- 🎉 New Chat modes: Write-only - just appends the message, without assistant response
- 🎉 Fix STOP generation - in sync with the Vercel team to fix a long-standing NextJS issue
- 🎉 Fixes on the HTML block - particularly useful to see error pages
#### June: scale UP 🚀
- 🎉 **[New OpenAI Models](https://openai.com/blog/function-calling-and-other-api-updates) support** - 0613 models, including 16k and 32k
- 🎉 **Cleaner UI** - with rationalized Settings, Modals, and Configurators
- 🎉 **Dynamic Models Configurator** - easy connection with different model vendors
- 🎉 **Multiple Model Vendors Support** framework to support many LLM vendors
- 🎉 **Per-model Options** (temperature, tokens, etc.) for fine-tuning AI behavior to your needs
- 🎉 Support for GPT-4-32k
- 🎉 Improved Dialogs and Messages
- 🎉 Much Enhanced DX: TRPC integration, modularization, pluggable UI, etc
#### April / May: more #big-agi-energy
- 🎉 **[Google Search](docs/pixels/feature_react_google.png)** active in ReAct - add your keys to Settings > Google
Search
- 🎉 **[Reason+Act](docs/pixels/feature_react_turn_on.png)** preview feature - activate with 2-taps on the 'Chat' button
- 🎉 **[Image Generation](docs/pixels/feature_imagine_command.png)** using Prodia (BYO Keys) - /imagine - or menu option
- 🎉 **[Voice Synthesis](docs/pixels/feature_voice_1.png)** 📣 with ElevenLabs, including selection of custom voices
- 🎉 **[Precise Token Counter](docs/pixels/feature_token_counter.png)** 📈 extra-useful to pack the context window
- 🎉 **[Install Mobile APP](docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
- 🎉 **[UI language](docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
- 🎉 **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
- 🎉 **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
- 🎉 **[SVG Drawing](docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
- 🎉 Chats: multiple chats, AI titles, Import/Export, Selection mode
- 🎉 Rendering: Markdown, SVG, improved Code blocks
- 🎉 Integrations: OpenAI organization ID
- 🎉 [Cloudflare deployment instructions](docs/deploy-cloudflare.md),
[awesome-agi](https://github.com/enricoros/awesome-agi)
- 🎉 [Typing Avatars](docs/pixels/gif_typing_040123.gif) ⌨️
<!-- p><a href="docs/pixels/gif_typing_040123.gif"><img src="docs/pixels/gif_typing_040123.gif" width='700' alt="New Typing Avatars"/></a></p -->
#### March: first release
- 🎉 **[AI Personas](docs/pixels/feature_purpose_two.png)** - including Code, Science, Corporate, and Chat 🎭
- 🎉 **Privacy**: user-owned API keys 🔑 and localStorage 🛡️
- 🎉 **Context** - Attach or [Drag & Drop files](docs/pixels/feature_drop_target.png) to add them to the prompt 📁
- 🎉 **Syntax highlighting** - for multiple languages 🌈
- 🎉 **Code Execution: Sandpack** -
[now on branch]((https://github.com/enricoros/big-agi/commit/f678a0d463d5e9cf0733f577e11bd612b7902d89)) `variant-code-execution`
- 🎉 Chat with GPT-4 and 3.5 Turbo 🧠💨
- 🎉 Real-time streaming of AI responses ⚡
- 🎉 **Voice Input** 🎙️ - works great on Chrome / Windows
- 🎉 Integration: **[Paste.gg](docs/pixels/feature_paste_gg.png)** integration for chat sharing 📥
- 🎉 Integration: **[Helicone](https://www.helicone.ai/)** integration for API observability 📊
- 🌙 Dark model - Wide mode ⛶
<br/>
## Why this? 💡
Because the official Chat ___lacks important features___, is ___more limited than the api___, at times
___slow or unavailable___, and you cannot deploy it yourself, remix it, add features, or share it with
your friends.
Our users report that ___big-AGI is faster___, ___more reliable___, and ___features rich___
with features that matter to them.
![Much features, so fun](docs/pixels/big-AGI-compo2b.png)
## Develop 🧩
![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black)
![Next.js](https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white)
Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the
files for changes):
Clone this repo, install the dependencies, and run the development server:
```bash
git clone https://github.com/enricoros/big-agi.git
@@ -106,58 +127,36 @@ npm install
npm run dev
```
The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring
a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions.
Now the app should be running on `http://localhost:3000`
## 🌐 Deploy manually
### Integrations:
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
after installing the required dependencies.
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Settings > Advanced > API Host: 'oai.hconeai.com'
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
## Deploy with Docker 🐳
Specific docker information on [docs/deploy-docker.md](docs/deploy-docker.md). In short:
#### Pre-built image
Add your OpenAI API key to the `.env` file, then in a terminal run:
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
npm run start --port 3000
docker-compose up
```
The app will be running on the specified port, e.g. `http://localhost:3000`.
#### Locally built image
Want to deploy with username/password? See the [Authentication](docs/deploy-authentication.md) guide.
## 🐳 Deploy with Docker
For more detailed information on deploying with Docker, please refer to the [docker deployment documentation](docs/deploy-docker.md).
Build and run:
If you wish to build the image yourself, run
```bash
docker build -t big-agi .
docker run -d -p 3000:3000 big-agi
docker run --detach 'big-agi'
```
Or run the official container:
- manually: `docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi`
- or, with docker-compose: `docker-compose up` or see [the documentation](docs/deploy-docker.md) for a composer file with integrated browsing
## ☁️ Deploy on Cloudflare Pages
Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare.md).
## 🚀 Deploy on Vercel
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,OPENAI_API_HOST&envDescription=OpenAI%20KEY%20for%20your%20deployment.%20Set%20HOST%20only%20if%20non-default.)
## Integrations:
* Local models: Ollama, Oobabooga, LocalAi, etc.
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Models > OpenAI > Advanced > API Host: 'oai.hconeai.com'
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
<br/>
This project is licensed under the MIT License.
-52
View File
@@ -1,52 +0,0 @@
import { createEmptyReadableStream, safeErrorString, serverFetchOrThrow } from '~/server/wire';
import { elevenlabsAccess, elevenlabsVoiceId, ElevenlabsWire, speechInputSchema } from '~/modules/elevenlabs/elevenlabs.router';
/* NOTE: Why does this file even exist?
This file is a workaround for a limitation in tRPC; it does not support ArrayBuffer responses,
and that would force us to use base64 encoding for the audio data, which would be a waste of
bandwidth. So instead, we use this file to make the request to ElevenLabs, and then return the
response as an ArrayBuffer. Unfortunately this means duplicating the code in the server-side
and client-side vs. the tRPC implementation. So at lease we recycle the input structures.
*/
const handler = async (req: Request) => {
try {
// construct the upstream request
const {
elevenKey, text, voiceId, nonEnglish,
streaming, streamOptimization,
} = speechInputSchema.parse(await req.json());
const path = `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}` + (streaming ? `/stream?optimize_streaming_latency=${streamOptimization || 1}` : '');
const { headers, url } = elevenlabsAccess(elevenKey, path);
const body: ElevenlabsWire.TTSRequest = {
text: text,
...(nonEnglish && { model_id: 'eleven_multilingual_v1' }),
};
// elevenlabs POST
const upstreamResponse: Response = await serverFetchOrThrow(url, 'POST', headers, body);
// NOTE: this is disabled, as we pass-through what we get upstream for speed, as it is not worthy
// to wait for the entire audio to be downloaded before we send it to the client
// if (!streaming) {
// const audioArrayBuffer = await upstreamResponse.arrayBuffer();
// return new NextResponse(audioArrayBuffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
// }
// stream the data to the client
const audioReadableStream = upstreamResponse.body || createEmptyReadableStream();
return new Response(audioReadableStream, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
} catch (error: any) {
const fetchOrVendorError = safeErrorString(error) + (error?.cause ? ' · ' + error.cause : '');
console.log(`api/elevenlabs/speech: fetch issue: ${fetchOrVendorError}`);
return new Response(`[Issue] elevenlabs: ${fetchOrVendorError}`, { status: 500 });
}
};
export const runtime = 'edge';
export { handler as POST };
-2
View File
@@ -1,2 +0,0 @@
export const runtime = 'edge';
export { openaiStreamingRelayHandler as POST } from '~/modules/llms/transports/server/openai/openai.streaming';
-19
View File
@@ -1,19 +0,0 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouterEdge } from '~/server/api/trpc.router-edge';
import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerEdgeRoutes = (req: Request) =>
fetchRequestHandler({
router: appRouterEdge,
endpoint: '/api/trpc-edge',
req,
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? '<no-path>'}:`, error)
: undefined,
});
export const runtime = 'edge';
export { handlerEdgeRoutes as GET, handlerEdgeRoutes as POST };
-19
View File
@@ -1,19 +0,0 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouterNode } from '~/server/api/trpc.router-node';
import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerNodeRoutes = (req: Request) =>
fetchRequestHandler({
router: appRouterNode,
endpoint: '/api/trpc-node',
req,
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}:`, error)
: undefined,
});
export const runtime = 'nodejs';
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
-4
View File
@@ -1,7 +1,3 @@
# Very simple docker-compose file to run the app on http://localhost:3000 (or http://127.0.0.1:3000).
#
# For more examples, such runnin big-AGI alongside a web browsing service, see the `docs/docker` folder.
version: '3.9'
services:
-126
View File
@@ -1,126 +0,0 @@
## Changelog
This is a high-level changelog. Calls out some of the high level features batched
by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### 1.8.0 - Dec 2023
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
- milestone: [1.8.0](https://github.com/enricoros/big-agi/milestone/8)
### What's New in 1.7.3 · Dec 13, 2023 · Attachment Theory 🌟
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
- Optimized Voice Input and Performance
- Latest Ollama and Oobabooga models
- For developers: **Password Protection**: HTTP Basic Auth. [Learn How](https://github.com/enricoros/big-agi/blob/main/docs/deploy-authentication.md)
- [1.7.1]: Improved Ollama chats. [#270](https://github.com/enricoros/big-agi/issues/270)
- [1.7.2]: OpenRouter login & free models 🎁
- [1.7.3]: Mistral Platform support. [#273](https://github.com/enricoros/big-agi/issues/273)
### What's New in 1.6.0 - Nov 28, 2023 · Surf's Up
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Branching Discussions**: Create new conversations from any message
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
- **Performance Boost**: Faster rendering for a smoother experience
- **UI Enhancements**: Refined interface based on user feedback
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
- **For Developers**: Code quality upgrades and snackbar notifications
### What's New in 1.5.0 - Nov 19, 2023 · Loaded
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
- **Visualization Tool**: Create data representations with our new visualization capabilities
- **Ollama Local Models**: Leverage local models support with our comprehensive guide
- **Text Tools**: Enjoy tools including highlight differences to refine your content
- **Mermaid Diagramming**: Render complex diagrams with our Mermaid language support
- **OpenAI 1106 Chat Models**: Experience the cutting-edge capabilities of the latest OpenAI models
- **SDXL Support**: Enhance your image generation with SDXL support for Prodia
- **Cloudflare OpenAI API Gateway**: Integrate with Cloudflare for a robust API gateway
- **Helicone for Anthropic**: Utilize Helicone's tools for Anthropic models
For Developers:
- Runtime Server-Side configuration: https://github.com/enricoros/big-agi/issues/189. Env vars are
not required to be set at build time anymore. The frontend will roundtrip to the backend at the
first request to get the configuration. See
https://github.com/enricoros/big-agi/blob/main/src/modules/backend/backend.router.ts.
- CloudFlare developers: please change the deployment command to
`rm app/api/trpc-node/[trpc]/route.ts && npx @cloudflare/next-on-pages@1`,
as we transitioned to the App router in NextJS 14. The documentation in
[docs/deploy-cloudflare.md](../docs/deploy-cloudflare.md) is updated
### 1.4.0: Sept/Oct: scale OUT
- **Expanded Model Support**: Azure and [OpenRouter](https://openrouter.ai/docs#models) models, including gpt-4-32k
- **Share and clone** conversations with public links
- Removed the 20 chats hard limit ([Ashesh3](https://github.com/enricoros/big-agi/pull/158))
- Latex Rendering
- Augmented Chat modes (Labs)
### July/Aug: More Better Faster
- **Camera OCR** - real-world AI - take a picture of a text, and chat with it
- **Anthropic models** support, e.g. Claude
- **Backup/Restore** - save chats, and restore them later
- **[Local model support with Oobabooga server](../docs/config-local-oobabooga)** - run your own LLMs!
- **Flatten conversations** - conversations summarizer with 4 modes
- **Fork conversations** - create a new chat, to try with different endings
- New commands: /s to add a System message, and /a for an Assistant message
- New Chat modes: Write-only - just appends the message, without assistant response
- Fix STOP generation - in sync with the Vercel team to fix a long-standing NextJS issue
- Fixes on the HTML block - particularly useful to see error pages
### June: scale UP
- **[New OpenAI Models](https://openai.com/blog/function-calling-and-other-api-updates) support** - 0613 models, including 16k and 32k
- **Cleaner UI** - with rationalized Settings, Modals, and Configurators
- **Dynamic Models Configurator** - easy connection with different model vendors
- **Multiple Model Vendors Support** framework to support many LLM vendors
- **Per-model Options** (temperature, tokens, etc.) for fine-tuning AI behavior to your needs
- Support for GPT-4-32k
- Improved Dialogs and Messages
- Much Enhanced DX: TRPC integration, modularization, pluggable UI, etc
### April / May: more #big-agi-energy
- **[Google Search](../docs/pixels/feature_react_google.png)** active in ReAct - add your keys to Settings > Google
Search
- **[Reason+Act](../docs/pixels/feature_react_turn_on.png)** preview feature - activate with 2-taps on the 'Chat' button
- **[Image Generation](../docs/pixels/feature_imagine_command.png)** using Prodia (BYO Keys) - /imagine - or menu option
- **[Voice Synthesis](../docs/pixels/feature_voice_1.png)** 📣 with ElevenLabs, including selection of custom voices
- **[Precise Token Counter](../docs/pixels/feature_token_counter.png)** 📈 extra-useful to pack the context window
- **[Install Mobile APP](../docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
- **[UI language](../docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
- **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
- **[SVG Drawing](../docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
- Chats: multiple chats, AI titles, Import/Export, Selection mode
- Rendering: Markdown, SVG, improved Code blocks
- Integrations: OpenAI organization ID
- [Cloudflare deployment instructions](../docs/deploy-cloudflare.md),
[awesome-agi](https://github.com/enricoros/awesome-agi)
- [Typing Avatars](../docs/pixels/gif_typing_040123.gif) ⌨️
<!-- p><a href="../docs/pixels/gif_typing_040123.gif"><img src="../docs/pixels/gif_typing_040123.gif" width='700' alt="New Typing Avatars"/></a></p -->
### March: first release
- **[AI Personas](../docs/pixels/feature_purpose_two.png)** - including Code, Science, Corporate, and Chat 🎭
- **Privacy**: user-owned API keys 🔑 and localStorage 🛡️
- **Context** - Attach or [Drag & Drop files](../docs/pixels/feature_drop_target.png) to add them to the prompt 📁
- **Syntax highlighting** - for multiple languages 🌈
- **Code Execution: Sandpack** -
[now on branch]((https://github.com/enricoros/big-agi/commit/f678a0d463d5e9cf0733f577e11bd612b7902d89)) `variant-code-execution`
- Chat with GPT-4 and 3.5 Turbo 🧠💨
- Real-time streaming of AI responses ⚡
- **Voice Input** 🎙️ - works great on Chrome / Windows
- Integration: **[Paste.gg](../docs/pixels/feature_paste_gg.png)** integration for chat sharing 📥
- Integration: **[Helicone](https://www.helicone.ai/)** integration for API observability 📊
- 🌙 Dark model - Wide mode ⛶
-87
View File
@@ -1,87 +0,0 @@
# Configuring Azure OpenAI Service with `big-AGI`
The entire procedure takes about 5 minutes and involves creating an Azure account,
setting up the Azure OpenAI service, deploying models, and configuring `big-AGI`
to access these models.
Please note that Azure operates on a 'pay-as-you-go' pricing model and requires
credit card information tied to a 'subscription' to the Azure service.
## Configuring `big-AGI`
If you have an `API Endpoint` and `API Key`, you can configure big-AGI as follows:
1. Launch the `big-AGI` application
2. Go to the **Models** settings
3. Add a Vendor and select **Azure OpenAI**
- Enter the Endpoint (e.g., 'https://your-openai-api-1234.openai.azure.com/')
- Enter the API Key (e.g., 'fd5...........................ba')
The deployed models are now available in the application. If you don't have a configured
Azure OpenAI service instance, continue with the next section.
## Setting Up Azure
### Step 1: Azure Account & Subscription
1. Create an account on [azure.microsoft.com](https://azure.microsoft.com/en-us/)
2. Go to the [Azure Portal](https://portal.azure.com/)
3. Click on **Create a resource** in the top left corner
4. Search for **Subscription** and select **[Create Subscription](https://portal.azure.com/#create/Microsoft.Subscription)**
- Fill in the required fields and click on **Create**
- Note down the **Subscription ID** (e.g., `12345678-1234-1234-1234-123456789012`)
### Step 2: Apply for Azure OpenAI Service
We'll now be creating "OpenAI"-specific resources on Azure. This requires to 'apply',
and acceptance should be quick (even as low as minutes).
1. Visit [Azure OpenAI Service](https://aka.ms/azure-openai)
2. Click on **Apply for access**
- Fill in the required fields (including the subscription ID) and click on **Apply**
Once your application is accepted, you can create OpenAI resources on Azure.
### Step 3: Create Azure OpenAI Resource
For more information, see [Azure: Create and deploy OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal)
1. Click on **Create a resource** in the top left corner
2. Search for **OpenAI** and select **[Create OpenAI](https://portal.azure.com/#create/Microsoft.CognitiveServicesOpenAI)**
3. Fill in the necessary fields on the **Create OpenAI** page
![Creating an OpenAI service](pixels/config-azure-openai-create.png)
- Select the subscription
- Select a resource group or create a new one
- Select the region. Note that the region determines the available models.
> For instance, **Canada East** offers GPT-4-32k models, For the full list, see [GPT-4 models](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models)
- Name the service (e.g., `your-openai-api-1234`)
- Select a pricing tier (e.g., `S0` for standard)
- Select: "All networks, including the internet, can access this resource."
- Click on **Review + create** and then **Create**
After creating the resource, you can access the API Keys and Endpoints. At any point, you can go to
the OpenAI Service instance page to get this information.
- Click on **Go to resource**
- Click on **Develop**
- Copy the `Endpoint`, called "Language API", e.g. 'https://your-openai-api-1234.openai.azure.com/'
- Copy `KEY 1`
### Step 4: Deploy Models
By default, Azure OpenAI resource instances don't have models available. You need to deploy the models you want to use.
1. Click on **Model Deployments > Manage Deployments**
2. Click on **+Create New Deployment**
![Deploying a model](pixels/config-azure-openai-deploy.png)
- Select the model you want to deploy
- Optionally select a version
- name the model, e.g., `gpt4-32k-0613`
Repeat as necessary for each model you want to deploy.
## Resources
- [Azure OpenAI Service Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/)
- [Guide: Create an Azure OpenAI Resource](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal)
- [Azure OpenAI Models](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models)
-87
View File
@@ -1,87 +0,0 @@
# Browse Functionality in big-AGI 🌐
Allows users to load web pages across various components of `big-AGI`. This feature is supported by Puppeteer-based
browsing services, which are the most common way to render web pages in a headless environment.
Once configured, the Browsing service provides this functionality:
- **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
First of all, you need to procure a Puppteer web browsing service endpoint. `big-AGI` supports services like:
| Service | Working | Type | Location | Special Features |
|--------------------------------------------------------------------------------------|---------|-------------|----------------|---------------------------------------------|
| [BrightData Scraping Browser](https://brightdata.com/products/scraping-browser) | Yes | Proprietary | Cloud | Advanced scraping tools, global IP pool |
| [Cloudflare Browser Rendering](https://developers.cloudflare.com/browser-rendering/) | ? | Proprietary | Cloud | Integrated CDN, optimized browser rendering |
| ⬇️ [Browserless 2.0](#-browserless-20) | Okay | OpenSource | Local (Docker) | Parallelism, debug viewer, advanced APIs |
| ⬇️ [Your Chrome Browser (ALPHA)](#-your-own-chrome-browser) | Alpha | Proprietary | Local (Chrome) | Personal, experimental use (ALPHA!) |
| other Puppeteer-based WSS Services | ? | Varied | Cloud/Local | Service-specific features |
## Configuration
1. **Procure an Endpoint**
- Ensure that your browsing service is running (remote or local) and has a WebSocket endpoint available
- Write down the address: `wss://${auth}@{some host}:{port}`, or ws:// for local services on your machine
2. **Configure `big-AGI`**
- navigate to **Preferences** > **Tools** > **Browse**
- Enter the 'wss://...' connection string provided by your browsing service
3. **Enable Features**: Choose which browse-related features you want to enable:
- **Attach URLs**: Automatically load and attach a page when pasting a URL into the composer
- **/browse Command**: Use the `/browse` command in the chat to load a web page
- **ReAct**: Enable the `loadURL()` function in ReAct for advanced interactions
### 🌐 Browserless 2.0
[Browserless 2.0](https://github.com/browserless/browserless) is a Docker-based service that provides a headless
browsing experience compatible with `big-AGI`. An open-source solution that simplifies web automation tasks,
in a scalable manner.
Launch Browserless with:
```bash
docker run -p 9222:3000 browserless/chrome:latest
```
Now you can use the following connection string in `big-AGI`: `ws://127.0.0.1:9222`.
You can also browse to [http://127.0.0.1:9222](http://127.0.0.1:9222) to see the Browserless debug viewer
and configure some options.
Note: if you are using `docker-compose`, please see the
[docker/docker-compose-browserless.yaml](docker/docker-compose-browserless.yaml) file for an example
on how to run `big-AGI` and Browserless simultaneously in a single application.
### 🌐 Your own Chrome browser
***EXPERIMENTAL - UNTESTED*** - You can use your own Chrome browser as a browsing service, by configuring it to expose
a WebSocket endpoint.
- close all the Chrome instances (on Windows, check the Task Manager if still running)
- start Chrome with the following command line options (on Windows, you can edit the shortcut properties):
- `--remote-debugging-port=9222`
- go to http://localhost:9222/json/version and copy the `webSocketDebuggerUrl` value
- it should be something like: `ws://localhost:9222/...`
- paste the value into the Endpoint configuration (see point 2 in the configuration)
### Server-Side Configuration
You can set the Puppeteer WebSocket endpoint (`PUPPETEER_WSS_ENDPOINT`) in the deployment before running it.
This is useful for self-hosted instances or when you want to pre-configure the endpoint for all users, and will
allow your to skip points 2 and 3 above.
Always deploy your own user authentication, authorization and security solution. For this feature, the tRPC
route that provides browsing service, shall be secured with a user authentication and authorization solution,
to prevent unauthorized access to the browsing service.
## Support
If you encounter any issues or have questions about configuring the browse functionality, join our community on Discord for support and discussions.
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
---
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
-34
View File
@@ -1,34 +0,0 @@
# Local LLM integration with `localai`
Integrate local Large Language Models (LLMs) with [LocalAI](https://localai.io).
_Last updated Nov 7, 2023_
## Instructions
### LocalAI installation and configuration
Follow the guide at: https://localai.io/basics/getting_started/
For instance with [Use luna-ai-llama2 with docker compose](https://localai.io/basics/getting_started/#example-use-luna-ai-llama2-model-with-docker-compose):
- clone LocalAI
- get the model
- copy the prompt template
- start docker
- -> the server will be listening on `localhost:8080`
- verify it works by going to [http://localhost:8080/v1/models](http://localhost:8080/v1/models) on
your browser and seeing listed the model you downloaded
### Integrating LocalAI with big-AGI
- Go to Models > Add a model source of type: **LocalAI**
- Enter the address: `http://localhost:8080` (default)
- If running remotely, replace localhost with the IP of the machine. Make sure to use the **IP:Port** format
- Load the models
- Select model & Chat
> NOTE: LocalAI does not list details about the mdoels. Every model is assumed to be
> capable of chatting, and with a context window of 4096 tokens.
> Please update the [src/modules/llms/transports/server/openai/models.data.ts](../src/modules/llms/transports/server/openai/models.data.ts)
> file with the mapping information between LocalAI model IDs and names/descriptions/tokens, etc.
-61
View File
@@ -1,61 +0,0 @@
# Local LLM Integration with `text-web-ui` :llama:
Integrate local Large Language Models (LLMs) with
[oobabooga/text-generation-webui](https://github.com/oobabooga/text-generation-webui),
a specialized interface that includes a custom variant of the OpenAI API for a smooth integration process.
_Last updated on Dec 7, 2023_
### Components
The implementation of local LLMs involves the following components:
* **text-generation-webui**: A Python application with a Gradio web UI for operating Large Language Models.
* **Local Large Language Models "LLMs"**: Use large language models on your personal computer with consumer-grade GPUs or CPUs.
* **big-AGI**: An LLM UI that offers features such as Personas, OCR, Voice Support, Code Execution, AGI functions, and more.
## Instructions
This guide assumes that **big-AGI** is already installed on your system. Note that the text-generation-webui IP address must be accessible from the server running **big-AGI**.
### Text-web-ui Installation & Configuration:
1. Install [text-generation-webui](https://github.com/oobabooga/text-generation-webui#Installation):
- Follow the instructions in the official page (basicall clone the repo and run a script) [~10 minutes]
- Stop the Web UI as we need to modify the startup flags to enable the OpenAI API
2. Enable the **openai extension**
- Edit `CMD_FLAGS.txt`
- Make sure that `--listen --api` is present and uncommented
3. Restart text-generation-webui
- Double-click on "start"
- You should see something like:
```
2023-12-07 21:51:21 INFO:Loading the extension "openai"...
2023-12-07 21:51:21 INFO:OpenAI-compatible API URL:
http://0.0.0.0:5000
...
INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)
Running on local URL: http://0.0.0.0:7860
```
- This shows that:
- The Web UI is running on port 7860: http://127.0.0.1:7860
- **The OpenAI API is running on port 5000: http://127.0.0.1:5000**
4. Load your first model
- Open the text-generation-webui at [127.0.0.1:7860](http://127.0.0.1:7860/)
- Switch to the **Model** tab
- Download, for instance, `TheBloke/Llama-2-7B-Chat-GPTQ`
- Select the model once it's loaded
### Integrating text-web-ui with big-AGI:
1. Integrating Text-Generation-WebUI with big-AGI:
- Go to Models > Add a model source of type: **Oobabooga**
- Enter the address: `http://127.0.0.1:5000`
- If running remotely, replace 127.0.0.1 with the IP of the machine. Make sure to use the **IP:Port** format
- Load the models
- The active model must be selected and LOADED on the text-generation-webui as it doesn't support model switching or parallel requests.
- Select model & Chat
![config-oobabooga-0.png](pixels/config-oobabooga-0.png)
Enjoy the privacy and flexibility of local LLMs with `big-AGI` and `text-generation-webui`!
-86
View File
@@ -1,86 +0,0 @@
# `Ollama` x `big-AGI` :llama:
This guide helps you connect [Ollama](https://ollama.ai) [models](https://ollama.ai/library) to
[big-AGI](https://big-agi.com) for a professional AI/AGI operation and a good UI/Conversational
experience. The integration brings the popular big-AGI features to Ollama, including: voice chats,
editing tools, models switching, personas, and more.
_Last updated Dec 11, 2023_
![config-local-ollama-0-example.png](pixels/config-ollama-0-example.png)
## Quick Integration Guide
1. **Ensure Ollama API Server is Running**: Follow the official instructions to get Ollama up and running on your machine
2. **Add Ollama as a Model Source**: In `big-AGI`, navigate to the **Models** section, select **Add a model source**, and choose **Ollama**
3. **Enter Ollama Host URL**: Provide the Ollama Host URL where the API server is accessible (e.g., `http://localhost:11434`)
4. **Refresh Model List**: Once connected, refresh the list of available models to include the Ollama models
> Optional: use the Ollama Admin interface to see which models are available and 'Pull' them in your local machine. Note
that this operation will likely timeout due to Edge Functions timeout on the big-AGI server while pulling, and
you'll have to press the 'Pull' button again, until a green message appears.
5. **Chat with Ollama models**: select an Ollama model and begin chatting with AI personas
### Ollama: installation and Setup
For detailed instructions on setting up the Ollama API server, please refer to the
[Ollama download page](https://ollama.ai/download) and [instructions for linux](https://github.com/jmorganca/ollama/blob/main/docs/linux.md).
### Visual Guide
* After adding the `Ollama` model vendor, entering the IP address of an Ollama server, and refreshing models:
<img src="pixels/config-ollama-1-models.png" alt="config-local-ollama-1-models.png" style="max-width: 320px;">
* The `Ollama` admin panel, with the `Pull` button highlighted, after pulling the "Yi" model:
<img src="pixels/config-ollama-2-admin-pull.png" alt="config-local-ollama-2-admin-pull.png" style="max-width: 320px;">
* You can now switch model/persona dynamically and text/voice chat with the models:
<img src="pixels/config-ollama-3-chat.png" alt="config-local-ollama-3-chat.png" style="max-width: 320px;">
### Advanced: Model parameters
For users who wish to delve deeper into advanced settings, `big-AGI` offers additional configuration options, such
as the model temperature, maximum tokens, etc.
### Advanced: Ollama under a reverse proxy
You can elegantly expose your Ollama server to the internet (and thus make it easier to use from your server-side
big-AGI deployments) by exposing it on an http/https URL, such as: `https://yourdomain.com/ollama`
On Ubuntu Servers, you will need to install `nginx` and configure it to proxy requests to Ollama.
```bash
sudo apt update
sudo apt install nginx
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com
```
Then, edit the nginx configuration file `/etc/nginx/sites-enabled/default` and add the following block:
```nginx
location /ollama/ {
proxy_pass http://localhost:11434;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Disable buffering for the streaming responses
proxy_buffering off;
}
```
Reach out to our community if you need help with this.
### Community and Support
Join our community to share your experiences, get help, and discuss best practices:
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
---
`big-AGI` is committed to providing a powerful, intuitive, and privacy-respecting AI experience.
We are excited for you to explore the possibilities with Ollama models. Happy creating!
-31
View File
@@ -1,31 +0,0 @@
# OpenRouter Configuration
[OpenRouter](https://openrouter.ai) is a standalone, premium service
that provides access to <Link href='https://openrouter.ai/docs#models' target='_blank'>exclusive AI models</Link>
such as GPT-4 32k, Claude, and more. These models are typically not available to the public.
This document details the process of integrating OpenRouter with big-AGI.
### 1. OpenRouter Account Setup and API Key Generation
1. Register for an OpenRouter account at [openrouter.ai](https://openrouter.ai) by clicking on Sign In > Continue with Google.
2. Top up your account (minimum $5) by navigating to [openrouter.ai/account](https://openrouter.ai/account) > Add Credits > Pay with Stripe.
3. Generate an API key at [openrouter.ai/keys](https://openrouter.ai/keys) > API Key > Generate API Key.
- **Remember to copy and securely store your API key** - the key will not be displayed again and will be in the format `sk-or-v1-...`.
- Keep the key confidential as it can be used to expend your credits.
### 2. Integrating OpenRouter with big-AGI
1. Launch big-AGI, and navigate to the AI **Models** settings.
2. Add a Vendor, and select **OpenRouter**.
![feature-openrouter-add.png](pixels/feature-openrouter-add.png)
3. Input the API key into the **OpenRouter API Key** field, and load the Models.
![feature-openrouter-configure.png](pixels/feature-openrouter-configure.png)
4. OpenAI GPT4-32k and other models will now be accessible and selectable in the application.
### Pricing
OpenRouter independently manages its service and pricing and is not affiliated with big-AGI.
For more detailed information, please visit [this page](https://openrouter.ai/docs#models).
Please note that running large models such as GPT-4 32k can be costly and may rapidly consume
credits - a single prompt may cost $1 or more, at the time of writing.
-45
View File
@@ -1,45 +0,0 @@
# Authentication
`big-AGI` does not come with built-in authentication. To secure your deployment, you can implement authentication
in one of the following ways:
1. Build `big-AGI` with support for ⬇️ [HTTP Authentication](#http-authentication)
2. Utilize user authentication features provided by your ⬇️ [cloud deployment platform](#cloud-deployments-authentication)
3. Develop a custom authentication solution
<br/>
### HTTP Authentication
[HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) is a simple method
to secure your application.
To enable it in `big-AGI`, you **must manually build the application**:
- Build `big-AGI` with HTTP authentication enabled:
- Clone the repository
- Rename `middleware_BASIC_AUTH.ts` to `middleware.ts`
- Build: usual simple build procedure (e.g. [Deploy manually](../README.md#-deploy-manually) or [Deploying with Docker](deploy-docker.md))
- Configure the following [environment variables](environment-variables.md) before launching `big-AGI`:
```dotenv
HTTP_BASIC_AUTH_USERNAME=<your username>
HTTP_BASIC_AUTH_PASSWORD=<your password>
```
- Start the application 🔒
<br/>
### Cloud Deployments Authentication
> This approach allows you to enable authentication without rebuilding the application by using the features
> provided by your cloud platform to manage user accounts and access.
Many cloud deployment platforms offer built-in authentication mechanisms. Refer to the platform's documentation
for setup instructions:
1. [CloudFlare Access / Zero Trust](https://www.cloudflare.com/zero-trust/products/access/)
2. [Vercel Authentication](https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/vercel-authentication)
3. [Vercel Password Protection](https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/password-protection)
4. Let us know when you test more solutions (Heroku, AWS IAM, Google IAP, etc.)
+34 -47
View File
@@ -1,68 +1,55 @@
# Deploying a Next.js App on Cloudflare Pages
# Deploying Next.js App on Cloudflare Pages
> WARNING: Cloudflare Pages does not support traditional NodeJS runtimes, but only Edge Runtime functions.
>
> In this project we use Prisma connected to serverless Postgres, which at the moment cannot run on
> edge functions, so we cannot deploy this project on Cloudflare Pages.
>
> Workaround: Step 3.4. has been added below, to DELETE the NodeJS traditional runtime - which means that some
> parts of this application will not work.
> - [Side effects](https://github.com/enricoros/big-agi/blob/main/src/apps/chat/trade/server/trade.router.ts#L19):
> Sharing functionality to DB, and import from ChatGPT share, and post to Paste.GG will not work
> - See [Issue 174](https://github.com/enricoros/big-agi/issues/174).
>
> Longer term: follow [prisma/prisma: Support Edge Function deployments](https://github.com/prisma/prisma/issues/21394)
> and convert the Node runtime to Edge runtime once Prisma supports it.
Follow these steps to deploy your Next.js app on Cloudflare Pages. This guide is based on
the [official Cloudflare developer documentation](https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/),
with a few additional steps.
This guide provides steps to deploy your Next.js app on Cloudflare Pages.
It is based on the [official Cloudflare developer documentation](https://developers.cloudflare.com/pages/framework-guides/deploy-a-nextjs-site/),
with some additional steps.
## Step 1: Fork the Repository
## Step 1: Repository Forking
Fork the repository to your own GitHub account.
Fork the repository to your personal GitHub account.
## Step 2: Connect Cloudflare Pages to Your GitHub Account
## Step 2: Linking Cloudflare Pages to Your GitHub Account
1. Go to the Cloudflare Pages section and click the `Create a project` button.
2. Click `Connect To Git` and give Cloudflare Pages either All GitHub account Repo access or selected Repo access. We
recommend using selected Repo access and selecting the forked repo from step 1.
1. Navigate to the Cloudflare Pages section and click on the `Create a project` button.
2. Click `Connect To Git` and grant Cloudflare Pages access to either all GitHub account repositories or selected repositories.
We recommend using selected Repo access and selecting the forked repository from step 1.
## Step 3: Setup Build and Deployments
## Step 3: Configuring Build and Deployments
1. Once you select the forked GitHub repo, click the `Begin Setup` button.
2. On this page, set your `Project name`, `Production branch` (e.g., main), and your Build settings.
3. Select `Next.js` from the `Framework preset` dropdown menu.
4. Leave the preset filled Build command and Build output directory as preset defaults.
5. Set `Environmental variables` (advanced) on this page to configure some variables as follows:
1. After selecting the forked GitHub repository, click the **Begin Setup** button
2. On this page, set your **Project name**, **Production branch** (e.g., main), and your Build settings
3. Choose `Next.js` from the **Framework preset** dropdown menu
4. Set a custom **Build Command**:
- `rm app/api/trpc-node/[trpc]/route.ts && npx @cloudflare/next-on-pages@1`
- see the tradeoffs for this deletion on the notice at the top
5. Keep the **Build output directory** as default
6. Click the **Save and Deploy** button
| Variable | Value |
|---------------------------|---------|
| `GO_VERSION` | `1.16` |
| `NEXT_TELEMETRY_DISABLED` | `1` |
| `NODE_VERSION` | `17` |
| `PHP_VERSION` | `7.4` |
| `PYTHON_VERSION` | `3.7` |
| `RUBY_VERSION` | `2.7.1` |
## Step 4: Monitoring the Deployment Process
6. Click the `Save and Deploy` button.
Observe the process as it initializes your build environment, clones the GitHub repository, builds the application, and deploys it
to the Cloudflare Network. Once complete, proceed to the project you created.
## Step 4: Monitor the Deployment Process
## Step 5: Required: Set the `nodejs_compat` compatibility flag
Watch the process run to initialize your build environment, clone the GitHub repo, build the application, and deploy to
the Cloudflare Network. Once that is done, proceed to the project you created.
1. Navigate to the [Settings > Functions](https://dash.cloudflare.com/?to=/:account/pages/view/:pages-project/settings/functions) page of your newly created project
2. Scroll to `Compatibility flags` and enter "`nodejs_compat`" for both **Production** and **Preview** environments.
It should look like this: ![](pixels/config-deploy-cloudflare-compat2.png)
3. Re-deploy your project for the new flags to take effect
## Step 6: (Optional) Custom Domain Configuration
## Step 5: Set up a Custom Domain
Use the `Custom domains` tab to set up your domain via CNAME.
## Step 7: (Optional) Access Policy and Web Analytics Configuration
## Step 6: Configure Access Policy and Web Analytics
Navigate to the `Settings` page and enable the following settings:
Go to the `Settings` page and enable the following settings:
1. Access Policy: Restrict [preview deployments](https://developers.cloudflare.com/pages/platform/preview-deployments/)
to members of your Cloudflare account via one-time pin and restrict primary `*.YOURPROJECT.pages.dev` domain.
Refer to [Cloudflare Pages known issues](https://developers.cloudflare.com/pages/platform/known-issues/#enabling-access-on-your-pagesdev-domain)
for more details.
See [Cloudflare Pages known issues](https://developers.cloudflare.com/pages/platform/known-issues/#enabling-access-on-your-pagesdev-domain)
for more information.
2. Enable Web Analytics.
Congratulations! You have successfully deployed your Next.js app on Cloudflare Pages.
Now you have successfully deployed your Next.js app on Cloudflare Pages.
+14 -48
View File
@@ -1,60 +1,26 @@
# Deploying `big-AGI` with Docker
# Deploy `big-AGI` with Docker 🐳
Utilize Docker containers to deploy the big-AGI application for an efficient and automated deployment process.
Docker ensures faster development cycles, easier collaboration, and seamless environment management.
Deploy the big-AGI application using Docker containers for a consistent, efficient, and automated deployment process. Enjoy faster development cycles, easier collaboration, and seamless environment management. 🚀
## Build and run your container 🔧
Docker is a platform for developing, packaging, and deploying applications as lightweight containers, ensuring consistent behavior across environments.
1. **Clone big-AGI**
```bash
git clone https://github.com/enricoros/big-agi.git
cd big-agi
```
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile:
```bash
docker build -t big-agi .
```
3. **Run the Docker Container**: start a Docker container from the newly built image,
and expose its http port 3000 to your `localhost:3000` using:
```bash
docker run -d -p 3000:3000 big-agi
```
4. Browse to [http://localhost:3000](http://localhost:3000)
## `big-AGI` Docker Components
## Documentation
The big-AGI repository includes a Dockerfile and a GitHub Actions workflow for building and publishing a
Docker image of the application.
The big-AGI repository includes a Dockerfile and a GitHub Actions workflow for building and publishing a Docker image of the application.
### Dockerfile
The [`Dockerfile`](../Dockerfile) describes how to create a Docker image. It establishes a Node.js environment,
installs dependencies, and creates a production-ready version of the application as a local container.
The [`Dockerfile`](../Dockerfile) sets up a Node.js environment, installs dependencies, and creates a production-ready version of the application.
### Official container images
### GitHub Actions Workflow
The [`.github/workflows/docker-image.yml`](../.github/workflows/docker-image.yml) file automates the
building and publishing of the Docker images to the GitHub Container Registry (ghcr) when changes are
pushed to the `main` branch.
The [`.github/workflows/docker-image.yml`](../.github/workflows/docker-image.yml) file automates building and publishing the Docker image when changes are pushed to the `main` branch.
Official pre-built containers: [ghcr.io/enricoros/big-agi](https://github.com/enricoros/big-agi/pkgs/container/big-agi)
## Deploy Steps
Run official pre-built containers:
```bash
docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi
```
1. Clone the big-AGI repository
2. Navigate to the project directory
3. Build the Docker image using the provided Dockerfile
4. Run the Docker container with the built image
### Run official containers
In addition, the repository also includes a `docker-compose.yaml` file, configured to run the pre-built
'ghcr image'. This file is used to define the `big-agi` service, the ports to expose, and the command to run.
If you have Docker Compose installed, you can run the Docker container with `docker-compose up`
to pull the Docker image (if it hasn't been pulled already) and start a Docker container. If you want to
update the image to the latest version, you can run `docker-compose pull` before starting the service.
```bash
docker-compose up -d
```
Leverage Docker's capabilities for a reliable and efficient big-AGI deployment.
Embrace the benefits of Docker for a reliable and efficient big-AGI deployment. 🎉
@@ -1,31 +0,0 @@
# This file is used to run `big-AGI` and `browserless` with Docker Compose.
#
# The two containers are linked together and `big-AGI` is configured to use `browserless`
# as its Puppeteer endpoint (from the containers intranet, it is available browserless:3000).
#
# From your host, you can access big-AGI on http://127.0.0.1:3000 and browserless on http://127.0.0.1:9222.
#
# To start the containers, run:
# docker-compose -f docs/docker/docker-compose-browserless.yaml up
version: '3.9'
services:
big-agi:
image: ghcr.io/enricoros/big-agi:main
ports:
- "3000:3000"
env_file:
- .env
environment:
- PUPPETEER_WSS_ENDPOINT=ws://browserless:3000
command: [ "next", "start", "-p", "3000" ]
depends_on:
- browserless
browserless:
image: browserless/chrome:latest
ports:
- "9222:3000" # Map host's port 9222 to container's port 3000
environment:
- MAX_CONCURRENT_SESSIONS=10
-124
View File
@@ -1,124 +0,0 @@
# Environment Variables
This document provides an explanation of the environment variables used in the big-AGI application.
**All variables are optional**; and _UI options_ take precedence over _backend environment variables_,
which take place over _defaults_. This file is kept in sync with [`../src/server/env.mjs`](../src/server/env.mjs).
### Setting Environment Variables
Environment variables can be set by creating a `.env` file in the root directory of the project.
The following is an example `.env` for copy-paste convenience:
```bash
# Database
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
# LLMs
OPENAI_API_KEY=
OPENAI_API_HOST=
OPENAI_API_ORG_ID=
AZURE_OPENAI_API_ENDPOINT=
AZURE_OPENAI_API_KEY=
ANTHROPIC_API_KEY=
ANTHROPIC_API_HOST=
MISTRAL_API_KEY=
OLLAMA_API_HOST=
OPENROUTER_API_KEY=
# Model Observability: Helicone
HELICONE_API_KEY=
# Text-To-Speech
ELEVENLABS_API_KEY=
ELEVENLABS_API_HOST=
ELEVENLABS_VOICE_ID=
# Text-To-Image
PRODIA_API_KEY=
# Google Custom Search
GOOGLE_CLOUD_API_KEY=
GOOGLE_CSE_ID=
# Browse
PUPPETEER_WSS_ENDPOINT=
# Backend Analytics
BACKEND_ANALYTICS=
# Backend HTTP Basic Authentication
HTTP_BASIC_AUTH_USERNAME=
HTTP_BASIC_AUTH_PASSWORD=
```
## Variables Documentation
### Database
To enable features such as Chat Link Shring, you need to connect the backend to a database. We require
serverless Postgres, which is available on Vercel, Neon and more.
Also make sure that you run `npx prisma db:push` to create the initial schema on the database for the
first time (or update it on a later stage).
| Variable | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `POSTGRES_PRISMA_URL` | The URL of the Postgres database used by Prisma - example: `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
| `POSTGRES_URL_NON_POOLING` | The URL of the Postgres database without pooling |
### LLMs
The following variables when set will enable the corresponding LLMs on the server-side, without
requiring the user to enter an API key
| Variable | Description | Required |
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| `OPENAI_API_KEY` | API key for OpenAI | Recommended |
| `OPENAI_API_HOST` | Changes the backend host for the OpenAI vendor, to enable platforms such as Helicone and CloudFlare AI Gateway | Optional |
| `OPENAI_API_ORG_ID` | Sets the "OpenAI-Organization" header field to support organization users | Optional |
| `AZURE_OPENAI_API_ENDPOINT` | Azure OpenAI endpoint - host only, without the path | Optional, but if set `AZURE_OPENAI_API_KEY` must also be set |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key, see [config-azure-openai.md](config-azure-openai.md) | Optional, but if set `AZURE_OPENAI_API_ENDPOINT` must also be set |
| `ANTHROPIC_API_KEY` | The API key for Anthropic | Optional |
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, to enable platforms such as [config-aws-bedrock.md](config-aws-bedrock.md) | Optional |
| `MISTRAL_API_KEY` | The API key for Mistral | Optional |
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-ollama.md](config-ollama.md) | |
| `OPENROUTER_API_KEY` | The API key for OpenRouter | Optional |
### Model Observability: Helicone
Helicone provides observability to your LLM calls. It is a paid service, with a generous free tier.
It is currently supported for:
- **Anthropic**: by setting the Helicone API key, Helicone is automatically activated
- **OpenAI**: you also need to set `OPENAI_API_HOST` to `oai.hconeai.com`, to enable routing
| Variable | Description |
|--------------------|--------------------------|
| `HELICONE_API_KEY` | The API key for Helicone |
### Specials
Enable the app to Talk, Draw, and Google things up.
| Variable | Description |
|:---------------------------|:------------------------------------------------------------------------------------------------------------------------|
| **Text-To-Speech** | [ElevenLabs](https://elevenlabs.io/) is a high quality speech synthesis service |
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
| **Browse** | |
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing, etc. |
| **Backend** | |
| `BACKEND_ANALYTICS` | Semicolon-separated list of analytics flags (see backend.analytics.ts). Flags: `domain` logs the responding domain. |
| `HTTP_BASIC_AUTH_USERNAME` | Username for HTTP Basic Authentication. See the [Authentication](deploy-authentication.md) guide. |
| `HTTP_BASIC_AUTH_PASSWORD` | Password for HTTP Basic Authentication. |
---
+45
View File
@@ -0,0 +1,45 @@
# Local LLM Integration with `text-web-ui` :llama:
Integrate local Large Language Models (LLMs) using
[oobabooga/text-generation-webui](https://github.com/oobabooga/text-generation-webui),
a specialized interface that incorporates a custom variant of the OpenAI API for a seamless integration experience.
_Last changed on Aug 8, 2023, using the CMD_FLAGS.txt file_
### Components
Implementation of local LLMs requires the following components:
* **text-generation-webui**: a python application with Gradio web UI for running Large Language Models
* **local Large Language Models "LLMs"**: use large language models on your own computer and with consumer GPUs or CPUs
* **big-AGI**: LLM UI, offering features such as Personas, OCR, Voice Support, Code Execution, AGI functions, and more
## Instructions
This guide presumes that **big-AGI** is already installed on your system - note that the text-generation-webui IP
address must be accessible from the Server running **big-AGI**.
1. Install [text-generation-webui](https://github.com/oobabooga/text-generation-webui#Installation)
- Download the one-click installer extract it, and double-click on "start" - 10 min
- Then close it, as we need to change the startup flags
2. Enable the **openai extension**
- Edit `CMD_FLAGS.txt`
- Update the contents from `--chat` to: `--chat --listen --extensions openai`
3. Restart text-generation-webui
- Double-click on "start"
- You will see something like: `OpenAI compatible API ready at: OPENAI_API_BASE=http://0.0.0.0:5001/v1`
- The OpenAI API is now running on port 5001, on both localhost (127.0.0.1) and your local IP address
4. Load your first model
- Open the text-generation-webui at [127.0.0.1:7860](http://127.0.0.1:7860/)
- Switch to the **Model** tab
- Download for instance `TheBloke/Llama-2-7b-Chat-GPTQ:gptq-4bit-32g-actorder_True` - 4.3 GB
- Select the model once loaded
5. Configure big-AGI:
- Models > Add a model source of type: **Oobabooga**
- Enter the address: `http://127.0.0.1:5001`
- replace 127.0.0.1 with the IP of the machine if running remotely - make sure to use the **IP:Port** format
- Load the models
- the active model must be selected on the text-generation-webui, as it doesn't support model switching or parallel requests
- Select model & Chat
Experience the privacy and flexibility of local LLMs with `big-AGI` and `text-generation-webui`! :tada:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+10
View File
@@ -0,0 +1,10 @@
# Scratchpad
Nobody will see this, right?
## Modules
### LLMs
- [ ] How to show server-side-configured OpenAI? - shall it be an auto-conf'd source that can be added?
- Would we allow people to add the key? ideally that conf would be immutable
-59
View File
@@ -1,59 +0,0 @@
/**
* Middleware to protect `big-AGI` with HTTP Basic Authentication
*
* For more information on how to deploy with HTTP Basic Authentication, see:
* - [deploy-authentication.md](docs/deploy-authentication.md)
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// noinspection JSUnusedGlobalSymbols
export function middleware(request: NextRequest) {
// Validate deployment configuration
if (!process.env.HTTP_BASIC_AUTH_USERNAME || !process.env.HTTP_BASIC_AUTH_PASSWORD) {
console.warn('HTTP Basic Authentication is enabled but not configured');
return new Response('Unauthorized/Unconfigured', unauthResponse);
}
// Request client authentication if no credentials are provided
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Basic '))
return new Response('Unauthorized', unauthResponse);
// Request authentication if credentials are invalid
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
if (
!username || !password ||
username !== process.env.HTTP_BASIC_AUTH_USERNAME ||
password !== process.env.HTTP_BASIC_AUTH_PASSWORD
)
return new Response('Unauthorized', unauthResponse);
return NextResponse.next();
}
// Response to send when authentication is required
const unauthResponse: ResponseInit = {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Secure big-AGI"',
},
};
export const config = {
matcher: [
// Include root
'/',
// Include pages
'/(call|index|news|personas|link)(.*)',
// Include API routes
'/api(.*)',
// Note: this excludes _next, /images etc..
],
};
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('next').NextConfig} */
let nextConfig = {
reactStrictMode: true,
env: {
// defaults to TRUE, unless API Keys are set at build time; this flag is used by the UI
HAS_SERVER_KEYS_GOOGLE_CSE: !!process.env.GOOGLE_CLOUD_API_KEY && !!process.env.GOOGLE_CSE_ID,
HAS_SERVER_KEY_ANTHROPIC: !!process.env.ANTHROPIC_API_KEY,
HAS_SERVER_KEY_ELEVENLABS: !!process.env.ELEVENLABS_API_KEY,
HAS_SERVER_KEY_OPENAI: !!process.env.OPENAI_API_KEY,
HAS_SERVER_KEY_PRODIA: !!process.env.PRODIA_API_KEY,
},
webpack(config, { isServer, dev }) {
// @mui/joy: anything material gets redirected to Joy
config.resolve.alias['@mui/material'] = '@mui/joy';
// @dqbd/tiktoken: enable asynchronous WebAssembly
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
};
// conditionally enable the nextjs bundle analyzer
if (process.env.ANALYZE_BUNDLE)
nextConfig = require('@next/bundle-analyzer')()(nextConfig);
module.exports = nextConfig;
-41
View File
@@ -1,41 +0,0 @@
/** @type {import('next').NextConfig} */
let nextConfig = {
reactStrictMode: true,
// Note: disabled to chech whether the project becomes slower with this
// modularizeImports: {
// '@mui/icons-material': {
// transform: '@mui/icons-material/{{member}}',
// },
// },
// [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052
experimental: {
serverComponentsExternalPackages: ['puppeteer-core'],
},
webpack: (config, _options) => {
// @mui/joy: anything material gets redirected to Joy
config.resolve.alias['@mui/material'] = '@mui/joy';
// @dqbd/tiktoken: enable asynchronous WebAssembly
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
};
// Validate environment variables, if set at build time. Will be actually read and used at runtime.
// This is the reason both this file and the servr/env.mjs files have this extension.
await import('./src/server/env.mjs');
// conditionally enable the nextjs bundle analyzer
if (process.env.ANALYZE_BUNDLE) {
const { default: withBundleAnalyzer } = await import('@next/bundle-analyzer');
nextConfig = withBundleAnalyzer({ openAnalyzer: true })(nextConfig);
}
export default nextConfig;
+1130 -1908
View File
File diff suppressed because it is too large Load Diff
+35 -49
View File
@@ -1,16 +1,15 @@
{
"name": "big-agi",
"version": "1.7.3",
"version": "1.3.5",
"private": true,
"engines": {
"node": "^18.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"env:pull": "npx vercel env pull .env.development.local",
"postinstall": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
"lint": "next lint"
},
"dependencies": {
"@dqbd/tiktoken": "^1.0.7",
@@ -18,54 +17,41 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/joy": "^5.0.0-beta.17",
"@next/bundle-analyzer": "^14.0.4",
"@prisma/client": "^5.7.0",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.44.1",
"@trpc/next": "^10.44.1",
"@trpc/react-query": "^10.44.1",
"@trpc/server": "^10.44.1",
"@vercel/analytics": "^1.1.1",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
"idb-keyval": "^6.2.1",
"next": "^14.0.4",
"pdfjs-dist": "4.0.269",
"@mui/icons-material": "^5.14.3",
"@mui/joy": "^5.0.0-beta.2",
"@next/bundle-analyzer": "^13.4.16",
"@tanstack/react-query": "4.32.6",
"@trpc/client": "^10.37.1",
"@trpc/next": "^10.37.1",
"@trpc/react-query": "^10.37.1",
"@trpc/server": "^10.37.1",
"@vercel/analytics": "^1.0.2",
"browser-fs-access": "^0.34.1",
"eventsource-parser": "^1.0.0",
"next": "^13.4.16",
"pdfjs-dist": "3.9.179",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.3",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "~4.3.9"
"react-markdown": "^8.0.7",
"remark-gfm": "^3.0.1",
"superjson": "^1.13.1",
"tesseract.js": "^4.1.1",
"uuid": "^9.0.0",
"zod": "3.21.4",
"zustand": "4.3.9"
},
"devDependencies": {
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.10.4",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.6",
"@types/uuid": "^9.0.7",
"eslint": "^8.55.0",
"eslint-config-next": "^14.0.4",
"prettier": "^3.1.1",
"prisma": "^5.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": "^20.0.0 || ^18.0.0"
"@types/node": "^20.4.10",
"@types/plantuml-encoder": "^1.4.0",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/uuid": "^9.0.2",
"eslint": "^8.47.0",
"eslint-config-next": "^13.4.16",
"prettier": "^3.0.1",
"typescript": "^5.1.6"
}
}
+41 -28
View File
@@ -1,42 +1,55 @@
import * as React from 'react';
import Head from 'next/head';
import { MyAppProps } from 'next/app';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { AppProps } from 'next/app';
import { CacheProvider, EmotionCache } from '@emotion/react';
import { CssBaseline, CssVarsProvider } from '@mui/joy';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Brand } from '~/common/app.config';
import { apiQuery } from '~/common/util/trpc.client';
import { apiQuery } from '~/modules/trpc/trpc.client';
import 'katex/dist/katex.min.css';
import '~/common/styles/CodePrism.css';
import '~/common/styles/CodePrism.css'
import '~/common/styles/GithubMarkdown.css';
import { ProviderBackend } from '~/common/state/ProviderBackend';
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
import { ProviderTheming } from '~/common/state/ProviderTheming';
import { Brand } from '~/common/brand';
import { createEmotionCache, theme } from '~/common/theme';
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
<>
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
<Head>
<title>{Brand.Title.Common}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no' />
</Head>
<ProviderTheming emotionCache={emotionCache}>
<ProviderTRPCQueryClient>
<ProviderSnacks>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
</ProviderSnacks>
</ProviderTRPCQueryClient>
</ProviderTheming>
export interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
function MyApp({ Component, emotionCache = clientSideEmotionCache, pageProps }: MyAppProps) {
const [queryClient] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
}));
return <>
<CacheProvider value={emotionCache}>
<Head>
<title>{Brand.Title.Common}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no' />
</Head>
{/* Rect-query provider */}
<QueryClientProvider client={queryClient}>
<CssVarsProvider defaultMode='light' theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<Component {...pageProps} />
</CssVarsProvider>
</QueryClientProvider>
</CacheProvider>
<VercelAnalytics debug={false} />
</>;
}
// enables the React Query API invocation
// enables the react-query api invocation
export default apiQuery.withTRPC(MyApp);
+5 -3
View File
@@ -1,11 +1,13 @@
import * as React from 'react';
import { AppType, MyAppProps } from 'next/app';
import { AppType } from 'next/app';
import { default as Document, DocumentContext, DocumentProps, Head, Html, Main, NextScript } from 'next/document';
import createEmotionServer from '@emotion/server/create-instance';
import { getInitColorSchemeScript } from '@mui/joy/styles';
import { Brand } from '~/common/app.config';
import { bodyFontClassName, createEmotionCache } from '~/common/app.theme';
import { Brand } from '~/common/brand';
import { bodyFontClassName, createEmotionCache } from '~/common/theme';
import { MyAppProps } from './_app';
interface MyDocumentProps extends DocumentProps {
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { elevenlabsAccess, elevenlabsVoiceId, ElevenlabsWire, speechInputSchema } from '~/modules/elevenlabs/elevenlabs.router';
/* NOTE: Why does this file even exist?
This file is a workaround for a limitation in tRPC; it does not support ArrayBuffer responses,
and that would force us to use base64 encoding for the audio data, which would be a waste of
bandwidth. So instead, we use this file to make the request to ElevenLabs, and then return the
response as an ArrayBuffer. Unfortunately this means duplicating the code in the server-side
and client-side vs. the TRPC implementation. So at lease we recycle the input structures.
*/
export default async function handler(req: NextRequest) {
try {
// construct the upstream request
const { elevenKey, text, voiceId, nonEnglish } = speechInputSchema.parse(await req.json());
const { headers, url } = elevenlabsAccess(elevenKey, `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}`);
const body: ElevenlabsWire.TTSRequest = {
text: text,
...(nonEnglish && { model_id: 'eleven_multilingual_v1' }),
};
// elevenlabs POST
const response = await fetch(url, { headers, method: 'POST', body: JSON.stringify(body) });
const audioArrayBuffer = await response.arrayBuffer();
// return the audio
return new NextResponse(audioArrayBuffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
} catch (error) {
console.error('api/elevenlabs/speech error:', error);
return new NextResponse(JSON.stringify(`textToSpeech error: ${error?.toString() || 'Network issue'}`), { status: 500 });
}
}
// noinspection JSUnusedGlobalSymbols
export const runtime = 'edge';
+207
View File
@@ -0,0 +1,207 @@
import { NextRequest, NextResponse } from 'next/server';
import { createParser as createEventsourceParser, EventSourceParser, ParsedEvent, ReconnectInterval } from 'eventsource-parser';
import { AnthropicWire } from '~/modules/llms/anthropic/anthropic.types';
import { OpenAI } from '~/modules/llms/openai/openai.types';
import { anthropicAccess, anthropicCompletionRequest } from '~/modules/llms/anthropic/anthropic.router';
import { chatStreamSchema, openAIAccess, openAIChatCompletionPayload } from '~/modules/llms/openai/openai.router';
/**
* Vendor stream parsers
* - The vendor can decide to terminate the connection (close: true), transmitting anything in 'text' before doing so
* - The vendor can also throw from this function, which will error and terminate the connection
*/
type AIStreamParser = (data: string) => { text: string, close: boolean };
// The peculiarity of our parser is the injection of a JSON structure at the beginning of the stream, to
// communicate parameters before the text starts flowing to the client.
function parseOpenAIStream(): AIStreamParser {
let hasBegun = false;
let hasWarned = false;
return data => {
const json: OpenAI.Wire.ChatCompletion.ResponseStreamingChunk = JSON.parse(data);
// an upstream error will be handled gracefully and transmitted as text (throw to transmit as 'error')
if (json.error)
return { text: `[OpenAI Issue] ${json.error.message || json.error}`, close: true };
if (json.choices.length !== 1)
throw new Error(`[OpenAI Issue] Expected 1 completion, got ${json.choices.length}`);
const index = json.choices[0].index;
if (index !== 0 && index !== undefined /* LocalAI hack/workaround until https://github.com/go-skynet/LocalAI/issues/788 */)
throw new Error(`[OpenAI Issue] Expected completion index 0, got ${index}`);
let text = json.choices[0].delta?.content /*|| json.choices[0]?.text*/ || '';
// hack: prepend the model name to the first packet
if (!hasBegun) {
hasBegun = true;
const firstPacket: OpenAI.API.Chat.StreamingFirstResponse = {
model: json.model,
};
text = JSON.stringify(firstPacket) + text;
}
// if there's a warning, log it once
if (json.warning && !hasWarned) {
hasWarned = true;
console.log('/api/llms/stream: OpenAI stream warning:', json.warning);
}
// workaround: LocalAI doesn't send the [DONE] event, but similarly to OpenAI, it sends a "finish_reason" delta update
const close = !!json.choices[0].finish_reason;
return { text, close };
};
}
// Anthropic event stream parser
function parseAnthropicStream(): AIStreamParser {
let hasBegun = false;
return data => {
const json: AnthropicWire.Complete.Response = JSON.parse(data);
let text = json.completion;
// hack: prepend the model name to the first packet
if (!hasBegun) {
hasBegun = true;
const firstPacket: OpenAI.API.Chat.StreamingFirstResponse = {
model: json.model,
};
text = JSON.stringify(firstPacket) + text;
}
return { text, close: false };
};
}
/**
* Creates a TransformStream that parses events from an EventSource stream using a custom parser.
* @returns {TransformStream<Uint8Array, string>} TransformStream parsing events.
*/
export function createEventStreamTransformer(vendorTextParser: AIStreamParser): TransformStream<Uint8Array, Uint8Array> {
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();
let eventSourceParser: EventSourceParser;
return new TransformStream({
start: async (controller): Promise<void> => {
eventSourceParser = createEventsourceParser(
(event: ParsedEvent | ReconnectInterval) => {
// ignore 'reconnect-interval' and events with no data
if (event.type !== 'event' || !('data' in event))
return;
// event stream termination, close our transformed stream
if (event.data === '[DONE]') {
controller.terminate();
return;
}
try {
const { text, close } = vendorTextParser(event.data);
if (text)
controller.enqueue(textEncoder.encode(text));
if (close)
controller.terminate();
} catch (error: any) {
// console.log(`/api/llms/stream: parse issue: ${error?.message || error}`);
controller.enqueue(textEncoder.encode(`[Stream Issue] ${error?.message || error}`));
controller.terminate();
}
},
);
},
// stream=true is set because the data is not guaranteed to be final and un-chunked
transform: (chunk: Uint8Array) => {
eventSourceParser.feed(textDecoder.decode(chunk, { stream: true }));
},
});
}
async function throwResponseNotOk(response: Response) {
if (!response.ok) {
const errorPayload: object | null = await response.json().catch(() => null);
throw new Error(`${response.status} · ${response.statusText}${errorPayload ? ' · ' + JSON.stringify(errorPayload) : ''}`);
}
}
function createEmptyReadableStream(): ReadableStream {
return new ReadableStream({
start: (controller) => controller.close(),
});
}
export default async function handler(req: NextRequest): Promise<Response> {
// inputs - reuse the tRPC schema
const { vendorId, access, model, history } = chatStreamSchema.parse(await req.json());
// begin event streaming from the OpenAI API
let upstreamResponse: Response;
let vendorStreamParser: AIStreamParser;
try {
// prepare the API request data
let headersUrl: { headers: HeadersInit, url: string };
let body: object;
switch (vendorId) {
case 'anthropic':
headersUrl = anthropicAccess(access as any, '/v1/complete');
body = anthropicCompletionRequest(model, history, true);
vendorStreamParser = parseAnthropicStream();
break;
case 'openai':
headersUrl = openAIAccess(access as any, '/v1/chat/completions');
body = openAIChatCompletionPayload(model, history, null, 1, true);
vendorStreamParser = parseOpenAIStream();
break;
}
// POST to our API route
upstreamResponse = await fetch(headersUrl.url, {
method: 'POST',
headers: headersUrl.headers,
body: JSON.stringify(body),
});
await throwResponseNotOk(upstreamResponse);
} catch (error: any) {
const fetchOrVendorError = (error?.message || typeof error === 'string' ? error : JSON.stringify(error)) + (error?.cause ? ' · ' + error.cause : '');
console.log(`/api/llms/stream: fetch issue: ${fetchOrVendorError}`);
return new NextResponse('[OpenAI Issue] ' + fetchOrVendorError, { status: 500 });
}
/* The following code is heavily inspired by the Vercel AI SDK, but simplified to our needs and in full control.
* This replaces the former (custom) implementation that used to return a ReadableStream directly, and upon start,
* it was blindly fetching the upstream response and piping it to the client.
*
* We now use backpressure, as explained on: https://sdk.vercel.ai/docs/concepts/backpressure-and-cancellation
*
* NOTE: we have not benchmarked to see if there is performance impact by using this approach - we do want to have
* a 'healthy' level of inventory (i.e., pre-buffering) on the pipe to the client.
*/
const chatResponseStream = (upstreamResponse.body || createEmptyReadableStream())
.pipeThrough(createEventStreamTransformer(vendorStreamParser));
return new NextResponse(chatResponseStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
});
}
// noinspection JSUnusedGlobalSymbols
export const runtime = 'edge';
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/modules/trpc/trpc.router';
import { createTRPCContext } from '~/modules/trpc/trpc.server';
/*
// NextJS (traditional, non-edge) API handler
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createTRPCContext } from '~/modules/trpc/trpc.server';
export default createNextApiHandler({
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC failed on ${path ?? '<no-path>'}:`, error)
: undefined,
});
*/
export default async function handler(req: NextRequest) {
return fetchRequestHandler({
endpoint: '/api/trpc',
router: appRouter,
req,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC failed on ${path ?? '<no-path>'}:`, error)
: undefined,
});
}
// noinspection JSUnusedGlobalSymbols
export const runtime = 'edge';
-14
View File
@@ -1,14 +0,0 @@
import * as React from 'react';
import { AppCall } from '../src/apps/call/AppCall';
import { AppLayout } from '~/common/layout/AppLayout';
export default function CallPage() {
return (
<AppLayout>
<AppCall />
</AppLayout>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useShowNewsOnUpdate } from '../src/apps/news/news.hooks';
import { AppLayout } from '~/common/layout/AppLayout';
export default function ChatPage() {
export default function HomePage() {
// show the News page on updates
useShowNewsOnUpdate();
+14
View File
@@ -0,0 +1,14 @@
import * as React from 'react';
import AppLabs from '../src/apps/labs/AppLabs';
import { AppLayout } from '~/common/layout/AppLayout';
export default function LabsPage() {
return (
<AppLayout suspendAutoModelsSetup>
<AppLabs />
</AppLayout>
);
}
-98
View File
@@ -1,98 +0,0 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Box, Typography } from '@mui/joy';
import { useModelsStore } from '~/modules/llms/store-llms';
import { AppLayout } from '~/common/layout/AppLayout';
import { InlineError } from '~/common/components/InlineError';
import { apiQuery } from '~/common/util/trpc.client';
import { navigateToIndex } from '~/common/app.routes';
import { openLayoutModelsSetup } from '~/common/layout/store-applayout';
function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
// external state
const { data, isError, error, isLoading } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
enabled: !!props.openRouterCode,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
// derived state
const isErrorInput = !props.openRouterCode;
const openRouterKey = data?.key ?? undefined;
const isSuccess = !!openRouterKey;
// Success: save the key and redirect to the chat app
React.useEffect(() => {
if (!isSuccess)
return;
// 1. Save the key as the client key
useModelsStore.getState().setOpenRoutersKey(openRouterKey);
// 2. Navigate to the chat app
navigateToIndex(true).then(() => openLayoutModelsSetup());
}, [isSuccess, openRouterKey]);
return (
<Box sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto',
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
}}>
<Box sx={{
// my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 4,
}}>
<Typography level='title-lg'>
Welcome Back
</Typography>
{isLoading && <Typography level='body-sm'>Loading...</Typography>}
{isErrorInput && <InlineError error='There was an issue retrieving the code from OpenRouter.' />}
{isError && <InlineError error={error} />}
{data && (
<Typography level='body-md'>
Success! You can now close this window.
</Typography>
)}
</Box>
</Box>
);
}
/**
* This page will be invoked by OpenRouter as a Callback
*
* Docs: https://openrouter.ai/docs#oauth
* Example URL: https://localhost:3000/link/callback_openrouter?code=SomeCode
*/
export default function Page() {
// get the 'code=...' from the URL
const { query } = useRouter();
const { code: openRouterCode } = query;
return (
<AppLayout suspendAutoModelsSetup>
<CallbackOpenRouterPage openRouterCode={openRouterCode as (string | undefined)} />
</AppLayout>
);
}
-18
View File
@@ -1,18 +0,0 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { AppChatLink } from '../../../src/apps/link/AppChatLink';
import { AppLayout } from '~/common/layout/AppLayout';
export default function ChatLinkPage() {
const { query } = useRouter();
const chatLinkId = query?.chatLinkId as string ?? '';
return (
<AppLayout suspendAutoModelsSetup>
<AppChatLink linkId={chatLinkId} />
</AppLayout>
);
}
-141
View File
@@ -1,141 +0,0 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Alert, Box, Button, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { setComposerStartupText } from '../../src/apps/chat/components/composer/store-composer';
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { AppLayout } from '~/common/layout/AppLayout';
import { LogoProgress } from '~/common/components/LogoProgress';
import { asValidURL } from '~/common/util/urlUtils';
import { navigateToIndex } from '~/common/app.routes';
/**
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
* See the /public/manifest.json for how this is configured. Parameters:
* - text: the text to share
* - url: the URL to share
* - if the URL is a valid URL, it will be downloaded and the content will be shared
* - if the URL is not a valid URL, it will be shared as text
* - title: the title of the shared content
*/
function AppShareTarget() {
// state
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [intentText, setIntentText] = React.useState<string | null>(null);
const [intentURL, setIntentURL] = React.useState<string | null>(null);
const [isDownloading, setIsDownloading] = React.useState(false);
// external state
const { query } = useRouter();
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
setComposerStartupText(text);
void navigateToIndex(true);
}, []);
// Detect the share Intent from the query
React.useEffect(() => {
// skip when query is not parsed yet
if (!Object.keys(query).length)
return;
// single item from the query
let queryTextItem: string[] | string | null = query.url || query.text || null;
if (Array.isArray(queryTextItem))
queryTextItem = queryTextItem[0];
// check if the item is a URL
const url = asValidURL(queryTextItem);
if (url)
setIntentURL(url);
else if (queryTextItem)
setIntentText(queryTextItem);
else
setErrorMessage('No text or url. Received: ' + JSON.stringify(query));
}, [query.url, query.text, query]);
// Text -> Composer
React.useEffect(() => {
if (intentText)
queueComposerTextAndLaunchApp(intentText);
}, [intentText, queueComposerTextAndLaunchApp]);
// URL -> download -> Composer
React.useEffect(() => {
if (intentURL) {
setIsDownloading(true);
callBrowseFetchPage(intentURL)
.then(page => {
if (page.stopReason !== 'error')
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + page.content + '\n```\n');
else
setErrorMessage('Could not read any data' + page.error ? ': ' + page.error : '');
})
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
.finally(() => setIsDownloading(false));
}
}, [intentURL, queueComposerTextAndLaunchApp]);
return (
<Box sx={{
backgroundColor: 'background.level2',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{/* Logo with Circular Progress */}
<LogoProgress showProgress={isDownloading} />
{/* Title */}
<Typography level='title-lg' sx={{ mt: 2, mb: 1 }}>
{isDownloading ? 'Loading...' : errorMessage ? '' : intentURL ? 'Done' : 'Receiving...'}
</Typography>
{/* Possible Error */}
{errorMessage && <>
<Alert variant='soft' color='danger' sx={{ my: 1 }}>
<Typography>{errorMessage}</Typography>
</Alert>
<Button
variant='solid' color='danger'
onClick={() => navigateToIndex()}
endDecorator={<ArrowBackIcon />}
sx={{ mt: 2 }}
>
Cancel
</Button>
</>}
{/* URL under analysis */}
<Typography level='body-xs'>
{intentURL}
</Typography>
</Box>
);
}
/**
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
* Example URL: https://localhost:3000/link/share_target?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
*/
export default function LaunchPage() {
return (
<AppLayout>
<AppShareTarget />
</AppLayout>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { AppNews } from '../src/apps/news/AppNews';
import AppNews from '../src/apps/news/AppNews';
import { useMarkNewsAsSeen } from '../src/apps/news/news.hooks';
import { AppLayout } from '~/common/layout/AppLayout';
+1 -1
View File
@@ -5,7 +5,7 @@ import { AppPersonas } from '../src/apps/personas/AppPersonas';
import { AppLayout } from '~/common/layout/AppLayout';
export default function PersonasPage() {
export default function HomePage() {
return (
<AppLayout>
<AppPersonas />
+144
View File
@@ -0,0 +1,144 @@
import * as React from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useComposerStore } from '../src/apps/chat/components/composer/store-composer';
// import { callBrowseFetchSinglePage } from '~/modules/browse/browse.client';
import { AppLayout } from '~/common/layout/AppLayout';
import { asValidURL } from '~/common/util/urlUtils';
const LogoProgress = (props: { showProgress: boolean }) =>
<Box sx={{
width: 64,
height: 64,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Box sx={{ position: 'absolute', mt: 0.75 }}>
<Image src='/icons/favicon-32x32.png' alt='App Logo' width={32} height={32} />
</Box>
{props.showProgress && <CircularProgress size='lg' sx={{ position: 'absolute' }} />}
</Box>;
/**
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
* Example URL: https://get.big-agi.com/share?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
*/
export default function SharePage() {
// state
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [intentText, setIntentText] = React.useState<string | null>(null);
const [intentURL, setIntentURL] = React.useState<string | null>(null);
const [isDownloading, setIsDownloading] = React.useState(false);
// external state
const { query, push: routerPush, replace: routerReplace } = useRouter();
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
useComposerStore.getState().setStartupText(text);
routerReplace('/').then(() => null);
}, [routerReplace]);
// Detect the share Intent from the query
React.useEffect(() => {
// skip when query is not parsed yet
if (!Object.keys(query).length)
return;
// single item from the query
let queryTextItem: string[] | string | null = query.url || query.text || null;
if (Array.isArray(queryTextItem))
queryTextItem = queryTextItem[0];
// check if the item is a URL
const url = asValidURL(queryTextItem);
if (url)
setIntentURL(url);
else if (queryTextItem)
setIntentText(queryTextItem);
else
setErrorMessage('No text or url. Received: ' + JSON.stringify(query));
}, [query.url, query.text, query]);
// Text -> Composer
React.useEffect(() => {
if (intentText)
queueComposerTextAndLaunchApp(intentText);
}, [intentText, queueComposerTextAndLaunchApp]);
// URL -> download -> Composer
React.useEffect(() => {
if (intentURL) {
setIsDownloading(true);
// TEMP: until the Browse module is ready, just use the URL, verbatim
queueComposerTextAndLaunchApp(intentURL);
setIsDownloading(false);
/*callBrowseFetchSinglePage(intentURL)
.then(pageContent => {
if (pageContent)
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + pageContent + '\n```\n');
else
setErrorMessage('Could not read any data');
})
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
.finally(() => setIsDownloading(false));*/
}
}, [intentURL, queueComposerTextAndLaunchApp]);
return (
<AppLayout suspendAutoModelsSetup>
<Box sx={{
backgroundColor: 'background.level2',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{/* Logo with Circular Progress */}
<LogoProgress showProgress={isDownloading} />
{/* Title */}
<Typography level='title-lg' sx={{ mt: 2, mb: 1 }}>
{isDownloading ? 'Loading...' : errorMessage ? '' : intentURL ? 'Done' : 'Receiving...'}
</Typography>
{/* Possible Error */}
{errorMessage && <>
<Alert variant='soft' color='danger' sx={{ my: 1 }}>
<Typography>{errorMessage}</Typography>
</Alert>
<Button
variant='solid' color='danger'
onClick={() => routerPush('/')}
endDecorator={<ArrowBackIcon />}
sx={{ mt: 2 }}
>
Cancel
</Button>
</>}
{/* URL under analysis */}
<Typography level='body-xs'>
{intentURL}
</Typography>
</Box>
</AppLayout>
);
}
-63
View File
@@ -1,63 +0,0 @@
// Prisma is the ORM for server-side (API) access to the database
//
// This file defines the schema for the database.
// - make sure to run 'prisma generate' after making changes to this file
// - make sure to run 'prisma db push' to sync the remote database with the schema
//
// Database is optional: when the environment variables are not set, the database is not used at all,
// and the storage of data in Big-AGI is limited to client-side (browser) storage.
//
// The database is used for:
// - the 'sharing' function, to let users share the chats with each other
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}
//
// Storage of Linked Data
//
model LinkStorage {
id String @id @default(uuid())
ownerId String
visibility LinkStorageVisibility
dataType LinkStorageDataType
dataTitle String?
dataSize Int
data Json
upVotes Int @default(0)
downVotes Int @default(0)
flagsCount Int @default(0)
readCount Int @default(0)
writeCount Int @default(1)
// time-based expiration
expiresAt DateTime?
// manual deletion
deletionKey String
isDeleted Boolean @default(false)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum LinkStorageVisibility {
PUBLIC
UNLISTED
PRIVATE
}
enum LinkStorageDataType {
CHAT_V1
}
+1 -1
View File
@@ -25,7 +25,7 @@
}
],
"share_target": {
"action": "/link/share_target",
"action": "/share",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-48
View File
@@ -1,48 +0,0 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Container, Sheet } from '@mui/joy';
import { AppCallQueryParams } from '~/common/app.routes';
import { InlineError } from '~/common/components/InlineError';
import { CallUI } from './CallUI';
import { CallWizard } from './CallWizard';
export function AppCall() {
// external state
const { query } = useRouter();
// derived state
const { conversationId, personaId } = query as any as AppCallQueryParams;
const validInput = !!conversationId && !!personaId;
return (
<Sheet variant='solid' color='neutral' invertedColors sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto',
minHeight: 96,
}}>
<Container maxWidth='sm' sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'center',
minHeight: '80dvh', justifyContent: 'space-evenly',
gap: { xs: 2, md: 4 },
}}>
{!validInput && <InlineError error={`Something went wrong. ${JSON.stringify(query)}`} />}
{validInput && (
<CallWizard conversationId={conversationId}>
<CallUI conversationId={conversationId} personaId={personaId} />
</CallWizard>
)}
</Container>
</Sheet>
);
}
-392
View File
@@ -1,392 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useRouter } from 'next/router';
import { Box, Card, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CallEndIcon from '@mui/icons-material/CallEnd';
import CallIcon from '@mui/icons-material/Call';
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined';
import MicIcon from '@mui/icons-material/Mic';
import MicNoneIcon from '@mui/icons-material/MicNone';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import { useChatLLMDropdown } from '../chat/components/applayout/useLLMDropdown';
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
import { SystemPurposeId, SystemPurposes } from '../../data';
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
import { streamChat } from '~/modules/llms/transports/streamChat';
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
import { useLayoutPluggable } from '~/common/layout/store-applayout';
import { CallAvatar } from './components/CallAvatar';
import { CallButton } from './components/CallButton';
import { CallMessage } from './components/CallMessage';
import { CallStatus } from './components/CallStatus';
function CallMenuItems(props: {
pushToTalk: boolean,
setPushToTalk: (pushToTalk: boolean) => void,
override: boolean,
setOverride: (overridePersonaVoice: boolean) => void,
}) {
// external state
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
const handleChangeVoiceToggle = () => props.setOverride(!props.override);
return <>
<MenuItem onClick={handlePushToTalkToggle}>
<ListItemDecorator>{props.pushToTalk ? <MicNoneIcon /> : <MicIcon />}</ListItemDecorator>
Push to talk
<Switch checked={props.pushToTalk} onChange={handlePushToTalkToggle} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem onClick={handleChangeVoiceToggle}>
<ListItemDecorator><RecordVoiceOverIcon /></ListItemDecorator>
Change Voice
<Switch checked={props.override} onChange={handleChangeVoiceToggle} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem>
<ListItemDecorator>{' '}</ListItemDecorator>
{voicesDropdown}
</MenuItem>
<MenuItem component={Link} href='https://github.com/enricoros/big-agi/issues/175' target='_blank'>
<ListItemDecorator><ChatOutlinedIcon /></ListItemDecorator>
Voice Calls Feedback
</MenuItem>
</>;
}
export function CallUI(props: {
conversationId: string,
personaId: string,
}) {
// state
const [avatarClickCount, setAvatarClickCount] = React.useState<number>(0);// const [micMuted, setMicMuted] = React.useState(false);
const [callElapsedTime, setCallElapsedTime] = React.useState<string>('00:00');
const [callMessages, setCallMessages] = React.useState<DMessage[]>([]);
const [overridePersonaVoice, setOverridePersonaVoice] = React.useState<boolean>(false);
const [personaTextInterim, setPersonaTextInterim] = React.useState<string | null>(null);
const [pushToTalk, setPushToTalk] = React.useState(true);
const [stage, setStage] = React.useState<'ring' | 'declined' | 'connected' | 'ended'>('ring');
const responseAbortController = React.useRef<AbortController | null>(null);
// external state
const { push: routerPush } = useRouter();
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
const { chatTitle, messages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
chatTitle: conversation ? conversationTitle(conversation) : 'no conversation',
messages: conversation ? conversation.messages : [],
};
}, shallow);
const persona = SystemPurposes[props.personaId as SystemPurposeId] ?? undefined;
const personaCallStarters = persona?.call?.starters ?? undefined;
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
const personaSystemMessage = persona?.systemMessage ?? undefined;
// hooks and speech
const [speechInterim, setSpeechInterim] = React.useState<SpeechResult | null>(null);
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
setSpeechInterim(result.done ? null : { ...result });
if (result.done) {
const transcribed = result.transcript.trim();
if (transcribed.length >= 1)
setCallMessages(messages => [...messages, createDMessage('user', transcribed)]);
}
}, []);
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000);
// derived state
const isRinging = stage === 'ring';
const isConnected = stage === 'connected';
const isDeclined = stage === 'declined';
const isEnded = stage === 'ended';
/// Sounds
// pickup / hangup
React.useEffect(() => {
!isRinging && playSoundUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
}, [isRinging, isConnected]);
// ringtone
usePlaySoundUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
/// CONNECTED
const handleCallStop = () => {
stopRecording();
setStage('ended');
};
// [E] pickup -> seed message and call timer
// FIXME: Overriding the voice will reset the call - not a desired behavior
React.useEffect(() => {
if (!isConnected) return;
// show the call timer
setCallElapsedTime('00:00');
const start = Date.now();
const interval = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - start) / 1000);
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
setCallElapsedTime(`${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`);
}, 1000);
// seed the first message
const phoneMessages = personaCallStarters || ['Hello?', 'Hey!'];
const firstMessage = phoneMessages[Math.floor(Math.random() * phoneMessages.length)];
setCallMessages([createDMessage('assistant', firstMessage)]);
// fire/forget
void EXPERIMENTAL_speakTextStream(firstMessage, personaVoiceId);
return () => clearInterval(interval);
}, [isConnected, personaCallStarters, personaVoiceId]);
// [E] persona streaming response - upon new user message
React.useEffect(() => {
// only act when we have a new user message
if (!isConnected || callMessages.length < 1 || callMessages[callMessages.length - 1].role !== 'user')
return;
switch (callMessages[callMessages.length - 1].text) {
// do not respond
case 'Stop.':
return;
// command: close the call
case 'Goodbye.':
setStage('ended');
setTimeout(() => {
void routerPush('/');
}, 2000);
return;
// command: regenerate answer
case 'Retry.':
case 'Try again.':
setCallMessages(messages => messages.slice(0, messages.length - 2));
return;
// command: restart chat
case 'Restart.':
setCallMessages([]);
return;
}
// bail if no llm selected
if (!chatLLMId) return;
// temp fix: when the chat has no messages, only assume a single system message
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = messages.length > 0
? messages
: personaSystemMessage
? [{ role: 'system', text: personaSystemMessage }]
: [];
// 'prompt' for a "telephone call"
// FIXME: can easily run ouf of tokens - if this gets traction, we'll fix it
const callPrompt: VChatMessageIn[] = [
{ role: 'system', content: 'You are having a phone call. Your response style is brief and to the point, and according to your personality, defined below.' },
...chatMessages.map(message => ({ role: message.role, content: message.text })),
{ role: 'system', content: 'You are now on the phone call related to the chat above. Respect your personality and answer with short, friendly and accurate thoughtful lines.' },
...callMessages.map(message => ({ role: message.role, content: message.text })),
];
// perform completion
responseAbortController.current = new AbortController();
let finalText = '';
let error: any | null = null;
streamChat(chatLLMId, callPrompt, responseAbortController.current.signal, (updatedMessage: Partial<DMessage>) => {
const text = updatedMessage.text?.trim();
if (text) {
finalText = text;
setPersonaTextInterim(text);
}
}).catch((err: DOMException) => {
if (err?.name !== 'AbortError')
error = err;
}).finally(() => {
setPersonaTextInterim(null);
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
// fire/forget
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
});
return () => {
responseAbortController.current?.abort();
responseAbortController.current = null;
};
}, [isConnected, callMessages, chatLLMId, messages, personaVoiceId, personaSystemMessage, routerPush]);
// [E] Message interrupter
const abortTrigger = isConnected && isRecordingSpeech;
React.useEffect(() => {
if (abortTrigger && responseAbortController.current) {
responseAbortController.current.abort();
responseAbortController.current = null;
}
// TODO.. abort current speech
}, [abortTrigger]);
// [E] continuous speech recognition (reload)
const shouldStartRecording = isConnected && !pushToTalk && speechInterim === null && !isRecordingAudio;
React.useEffect(() => {
if (shouldStartRecording)
startRecording();
}, [shouldStartRecording, startRecording]);
// more derived state
const personaName = persona?.title ?? 'Unknown';
const isMicEnabled = isSpeechEnabled;
const isTTSEnabled = true;
const isEnabled = isMicEnabled && isTTSEnabled;
// pluggable UI
const menuItems = React.useMemo(() =>
<CallMenuItems
pushToTalk={pushToTalk} setPushToTalk={setPushToTalk}
override={overridePersonaVoice} setOverride={setOverridePersonaVoice} />
, [overridePersonaVoice, pushToTalk],
);
useLayoutPluggable(chatLLMDropdown, null, menuItems);
return <>
<Typography
level='h1'
sx={{
fontSize: { xs: '2.5rem', md: '3rem' },
textAlign: 'center',
mx: 2,
}}
>
{isConnected ? personaName : 'Hello'}
</Typography>
<CallAvatar
symbol={persona?.symbol || '?'}
imageUrl={persona?.imageUri}
isRinging={isRinging}
onClick={() => setAvatarClickCount(avatarClickCount + 1)}
/>
<CallStatus
callerName={isConnected ? undefined : personaName}
statusText={isRinging ? 'is calling you' : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
regardingText={chatTitle}
micError={!isMicEnabled} speakError={!isTTSEnabled}
/>
{/* Live Transcript, w/ streaming messages, audio indication, etc. */}
{(isConnected || isEnded) && (
<Card variant='soft' sx={{
flexGrow: 1,
minHeight: '15dvh', maxHeight: '24dvh',
overflow: 'auto',
width: '100%',
borderRadius: 'lg',
flexDirection: 'column-reverse',
}}>
{/* Messages in reverse order, for auto-scroll from the bottom */}
<Box sx={{ display: 'flex', flexDirection: 'column-reverse', gap: 1 }}>
{/* Listening... */}
{isRecording && (
<CallMessage
text={<>{speechInterim?.transcript ? speechInterim.transcript + ' ' : ''}<i>{speechInterim?.interimTranscript}</i></>}
variant={isRecordingSpeech ? 'solid' : 'outlined'}
role='user'
/>
)}
{/* Persona streaming text... */}
{!!personaTextInterim && (
<CallMessage
text={personaTextInterim}
variant='solid' color='neutral'
role='assistant'
/>
)}
{/* Messages (last 6 messages, in reverse order) */}
{callMessages.slice(-6).reverse().map((message) =>
<CallMessage
key={message.id}
text={message.text}
variant={message.role === 'assistant' ? 'solid' : 'soft'} color='neutral'
role={message.role} />,
)}
</Box>
</Card>
)}
{/* Call Buttons */}
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly' }}>
{/* [ringing] Decline / Accept */}
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' onClick={() => setStage('declined')} />}
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='soft' onClick={() => setStage('connected')} />}
{/* [Calling] Hang / PTT (mute not enabled yet) */}
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' onClick={handleCallStop} />}
{isConnected && (pushToTalk
? <CallButton Icon={MicIcon} onClick={toggleRecording}
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} />
: null
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
// text={micMuted ? 'Muted' : 'Mute'}
// color={micMuted ? 'warning' : undefined} variant={micMuted ? 'solid' : 'outlined'} />
)}
{/* [ended] Back / Call Again */}
{(isEnded || isDeclined) && <Link noLinkStyle href='/'><CallButton Icon={ArrowBackIcon} text='Back' variant='soft' /></Link>}
{(isEnded || isDeclined) && <CallButton Icon={CallIcon} text='Call Again' color='success' variant='soft' onClick={() => setStage('connected')} />}
</Box>
{/* DEBUG state */}
{avatarClickCount > 10 && (avatarClickCount % 2 === 0) && (
<Card variant='outlined' sx={{ maxHeight: '25dvh', overflow: 'auto', whiteSpace: 'pre', py: 0, width: '100%' }}>
Special commands: Stop, Retry, Try Again, Restart, Goodbye.
{JSON.stringify({ isSpeechEnabled, isRecordingAudio, speechInterim }, null, 2)}
</Card>
)}
{/*{isEnded && <Card variant='solid' size='lg' color='primary'>*/}
{/* <CardContent>*/}
{/* <Typography>*/}
{/* Please rate the call quality, 1 to 5 - Just a Joke*/}
{/* </Typography>*/}
{/* </CardContent>*/}
{/*</Card>}*/}
</>;
}
-211
View File
@@ -1,211 +0,0 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typography } from '@mui/joy';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ChatIcon from '@mui/icons-material/Chat';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import MicIcon from '@mui/icons-material/Mic';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import WarningIcon from '@mui/icons-material/Warning';
import { navigateBack } from '~/common/app.routes';
import { openLayoutPreferences } from '~/common/layout/store-applayout';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
import { useUICounter } from '~/common/state/store-ui';
const cssRainbowBackgroundKeyframes = keyframes`
100%, 0% {
background-color: rgb(128, 0, 0);
}
8% {
background-color: rgb(102, 51, 0);
}
16% {
background-color: rgb(64, 64, 0);
}
25% {
background-color: rgb(38, 76, 0);
}
33% {
background-color: rgb(0, 89, 0);
}
41% {
background-color: rgb(0, 76, 41);
}
50% {
background-color: rgb(0, 64, 64);
}
58% {
background-color: rgb(0, 51, 102);
}
66% {
background-color: rgb(0, 0, 128);
}
75% {
background-color: rgb(63, 0, 128);
}
83% {
background-color: rgb(76, 0, 76);
}
91% {
background-color: rgb(102, 0, 51);
}`;
function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) {
return (
<Card sx={{ width: '100%' }}>
<CardContent sx={{ flexDirection: 'row' }}>
<ListItemDecorator>
{props.icon}
</ListItemDecorator>
<Typography level='title-md' color={props.hasIssue ? 'warning' : undefined} sx={{ flexGrow: 1 }}>
{props.text}
{props.button}
</Typography>
<ListItemDecorator>
{props.hasIssue ? <WarningIcon color='warning' /> : <CheckIcon color='success' />}
</ListItemDecorator>
</CardContent>
</Card>
);
}
export function CallWizard(props: { strict?: boolean, conversationId: string, children: React.ReactNode }) {
// state
const [chatEmptyOverride, setChatEmptyOverride] = React.useState(false);
const [recognitionOverride, setRecognitionOverride] = React.useState(false);
// external state
const recognition = useCapabilityBrowserSpeechRecognition();
const synthesis = useCapabilityElevenLabs();
const chatIsEmpty = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return !(conversation?.messages?.length);
});
const { novel, touch } = useUICounter('call-wizard');
// derived state
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
const overriddenRecognition = recognitionOverride || recognition.mayWork;
const allGood = overriddenEmptyChat && overriddenRecognition && synthesis.mayWork;
const fatalGood = overriddenRecognition && synthesis.mayWork;
if (!novel && fatalGood)
return props.children;
const handleOverrideChatEmpty = () => setChatEmptyOverride(true);
const handleOverrideRecognition = () => setRecognitionOverride(true);
const handleConfigureElevenLabs = () => {
openLayoutPreferences(3);
};
const handleFinishButton = () => {
if (!allGood)
return navigateBack();
touch();
};
return <>
<Box sx={{ flexGrow: 0.5 }} />
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, lineHeight: '1.5em', textAlign: 'center' }}>
Welcome to<br />
<Typography
component='span'
sx={{
backgroundColor: 'primary.solidActiveBg', mx: -0.5, px: 0.5,
animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
}}>
your first call
</Typography>
</Typography>
<Box sx={{ flexGrow: 0.5 }} />
<Typography level='body-lg'>
{/*Before you receive your first call, */}
Let&apos;s get you all set up.
</Typography>
{/* Chat Empty status */}
<StatusCard
icon={<ChatIcon />}
hasIssue={!overriddenEmptyChat}
text={overriddenEmptyChat ? 'Great! Your chat has messages.' : 'The chat is empty. Calls are effective when the caller has context.'}
button={overriddenEmptyChat ? undefined : (
<Button variant='outlined' onClick={handleOverrideChatEmpty} sx={{ mx: 1 }}>
Ignore
</Button>
)}
/>
{/* Add the speech to text feature status */}
<StatusCard
icon={<MicIcon />}
text={
((overriddenRecognition && !recognition.warnings.length) ? 'Speech recognition should be good to go.' : 'There might be a speech recognition issue.')
+ (recognition.isApiAvailable ? '' : ' Your browser does not support the speech recognition API.')
+ (recognition.isDeviceNotSupported ? ' Your device does not provide this feature.' : '')
+ (recognition.warnings.length ? ' ⚠️ ' + recognition.warnings.join(' · ') : '')
}
button={overriddenRecognition ? undefined : (
<Button variant='outlined' onClick={handleOverrideRecognition} sx={{ mx: 1 }}>
Ignore
</Button>
)}
hasIssue={!overriddenRecognition}
/>
{/* Text to Speech status */}
<StatusCard
icon={<RecordVoiceOverIcon />}
text={
(synthesis.mayWork ? 'Voice synthesis should be ready.' : 'There might be an issue with ElevenLabs voice synthesis.')
+ (synthesis.isConfiguredServerSide ? '' : (synthesis.isConfiguredClientSide ? '' : ' Please add your API key in the settings.'))
}
button={synthesis.mayWork ? undefined : (
<Button variant='outlined' onClick={handleConfigureElevenLabs} sx={{ mx: 1 }}>
Configure
</Button>
)}
hasIssue={!synthesis.mayWork}
/>
{/*<Typography>*/}
{/* 1. To start a call, click the "Accept" button when you receive an incoming call.*/}
{/* 2. If your mic is enabled, you'll see a "Push to Talk" button. Press and hold it to speak, then release it to stop speaking.*/}
{/* 3. If your mic is disabled, you can still type your messages in the chat and the assistant will respond.*/}
{/* 4. During the call, you can control the voice synthesis settings from the menu in the top right corner.*/}
{/* 5. To end the call, click the "Hang up" button.*/}
{/*</Typography>*/}
<Box sx={{ flexGrow: 2 }} />
{/* bottom: text & button */}
<Box sx={{ display: 'flex', justifyContent: 'space-around', alignItems: 'center', width: '100%', gap: 2, px: 0.5 }}>
<Typography level='body-lg'>
{allGood ? 'Ready, Set, Call' : 'Please resolve the issues above before proceeding with the call'}
</Typography>
<IconButton
size='lg' variant={allGood ? 'soft' : 'solid'} color={allGood ? 'success' : 'danger'}
onClick={handleFinishButton} sx={{ borderRadius: '50px' }}
>
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
</IconButton>
</Box>
<Box sx={{ flexGrow: 0.5 }} />
</>;
}
-48
View File
@@ -1,48 +0,0 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import { Avatar, Box } from '@mui/joy';
const cssScaleKeyframes = keyframes`
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}`;
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging: boolean, onClick: () => void }) {
return (
<Avatar
variant='soft' color='neutral'
onClick={props.onClick}
src={props.imageUrl}
sx={{
'--Avatar-size': { xs: '160px', md: '200px' },
'--variant-borderWidth': '4px',
boxShadow: !props.imageUrl ? 'md' : null,
fontSize: { xs: '100px', md: '120px' },
}}
>
{/* As fallback, show the large Persona Symbol */}
{!props.imageUrl && (
<Box
sx={{
...(props.isRinging
? { animation: `${cssScaleKeyframes} 1.4s ease-in-out infinite` }
: {}),
}}
>
{props.symbol}
</Box>
)}
</Avatar>
);
}
-43
View File
@@ -1,43 +0,0 @@
import * as React from 'react';
import { Box, ColorPaletteProp, IconButton, Typography, VariantProp } from '@mui/joy';
/**
* Large button to operate the call, e.g.
* --------
* | 🎤 |
* | Mute |
* --------
*/
export function CallButton(props: {
Icon: React.FC, text: string,
variant?: VariantProp, color?: ColorPaletteProp, disabled?: boolean,
onClick?: () => void,
}) {
return (
<Box
onClick={() => !props.disabled && props.onClick?.()}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: { xs: 1, md: 2 },
}}
>
<IconButton
disabled={props.disabled} variant={props.variant || 'solid'} color={props.color}
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
// boxShadow: 'lg',
}}>
<props.Icon />
</IconButton>
<Typography level='title-md' variant={props.disabled ? 'soft' : undefined}>
{props.text}
</Typography>
</Box>
);
}
-33
View File
@@ -1,33 +0,0 @@
import * as React from 'react';
import { Chip, ColorPaletteProp, VariantProp } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
export function CallMessage(props: {
text?: string | React.JSX.Element,
variant?: VariantProp, color?: ColorPaletteProp,
role: VChatMessageIn['role'],
sx?: SxProps,
}) {
return (
<Chip
color={props.color} variant={props.variant}
sx={{
alignSelf: props.role === 'user' ? 'end' : 'start',
whiteSpace: 'break-spaces',
borderRadius: 'lg',
mt: 'auto',
// boxShadow: 'md',
py: 1,
...(props.sx || {}),
}}
>
{props.text}
</Chip>
);
}
-47
View File
@@ -1,47 +0,0 @@
import * as React from 'react';
import { Box, Typography } from '@mui/joy';
import { InlineError } from '~/common/components/InlineError';
/**
* A status message for the call, such as:
*
* $Name
* "Connecting..." or "Call ended",
* re: $Regarding
*/
export function CallStatus(props: {
callerName?: string,
statusText: string,
regardingText?: string,
micError: boolean, speakError: boolean,
// llmComponent?: React.JSX.Element,
}) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{!!props.callerName && <Typography level='h3' sx={{ textAlign: 'center' }}>
<b>{props.callerName}</b>
</Typography>}
{/*{props.llmComponent}*/}
<Typography level='body-md' sx={{ textAlign: 'center' }}>
{props.statusText}
</Typography>
{!!props.regardingText && <Typography level='body-md' sx={{ textAlign: 'center', mt: 0 }}>
re: {props.regardingText}
</Typography>}
{props.micError && <InlineError
severity='danger' error='But this browser does not support speech recognition... 🤦‍♀️ - Try Chrome on Windows?' />}
{props.speakError && <InlineError
severity='danger' error='And text-to-speech is not configured... 🤦‍♀️ - Configure it in Settings?' />}
</Box>
);
}
+143 -344
View File
@@ -1,132 +1,96 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box } from '@mui/joy';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import { useChatLLM, useModelsStore } from '~/modules/llms/store-llms';
import { useModelsStore } from '~/modules/llms/store-llms';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
import { openLayoutLLMOptions, useLayoutPluggable } from '~/common/layout/store-applayout';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { useLayoutPluggable } from '~/common/layout/store-applayout';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatDrawerItemsMemo } from './components/applayout/ChatDrawerItems';
import { ChatDrawerItems } from './components/applayout/ChatDrawerItems';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { ChatMessageList } from './components/ChatMessageList';
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/commands';
import { CmdAddRoleMessage, extractCommands } from './commands';
import { Composer } from './components/composer/Composer';
import { Ephemerals } from './components/Ephemerals';
import { usePanesManager } from './components/usePanesManager';
import { TradeConfig, TradeModal } from './trade/TradeModal';
import { runAssistantUpdatingState } from './editors/chat-stream';
import { runBrowseUpdatingState } from './editors/browse-load';
import { runImageGenerationUpdatingState } from './editors/image-generate';
import { runReActUpdatingState } from './editors/react-tangent';
/**
* Mode: how to treat the input from the Composer
*/
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
const SPECIAL_ID_ALL_CHATS = 'all-chats';
// definition of chat modes
export type ChatModeId = 'immediate' | 'immediate-follow-up' | 'react' | 'write-user';
export const ChatModeItems: { [key in ChatModeId]: { label: string; description: string | React.JSX.Element; experimental?: boolean } } = {
'immediate': {
label: 'Chat',
description: 'AI-powered responses',
},
'immediate-follow-up': {
label: 'Chat & Follow-up',
description: 'Chat with follow-up questions',
experimental: true,
},
'react': {
label: 'Reason+Act',
description: 'Answer your questions with ReAct and search',
},
'write-user': {
label: 'Write',
description: 'No AI responses',
},
};
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
export function AppChat() {
// state
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('immediate');
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const [clearConfirmationId, setClearConfirmationId] = React.useState<string | null>(null);
const [deleteConfirmationId, setDeleteConfirmationId] = React.useState<string | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<string | null>(null);
// external state
const { chatLLM } = useChatLLM();
const {
chatPanes,
focusedConversationId,
navigateHistoryInFocusedPane,
openConversationInFocusedPane,
openConversationInSplitPane,
setFocusedPaneIndex,
} = usePanesManager();
const {
title: focusedChatTitle,
chatIdx: focusedChatNumber,
isChatEmpty: isFocusedChatEmpty,
areChatsEmpty,
newConversationId,
_remove_systemPurposeId: focusedSystemPurposeId,
prependNewConversation,
branchConversation,
deleteConversation,
wipeAllConversations,
setMessages,
} = useConversation(focusedConversationId);
// Window actions
const chatPaneIDs = chatPanes.length > 0 ? chatPanes.map(pane => pane.conversationId) : [null];
const setActivePaneIndex = React.useCallback((idx: number) => {
setFocusedPaneIndex(idx);
}, [setFocusedPaneIndex]);
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInFocusedPane(conversationId);
}, [openConversationInFocusedPane]);
const openSplitConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInSplitPane(conversationId);
}, [openConversationInSplitPane]);
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
if (navigateHistoryInFocusedPane(direction))
showNextTitle.current = true;
}, [navigateHistoryInFocusedPane]);
const { activeConversationId, isConversationEmpty, duplicateConversation, deleteAllConversations, setMessages, systemPurposeId, setAutoTitle } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === state.activeConversationId);
return {
activeConversationId: state.activeConversationId,
isConversationEmpty: conversation ? !conversation.messages.length : true,
// conversationsCount: state.conversations.length,
duplicateConversation: state.duplicateConversation,
deleteAllConversations: state.deleteAllConversations,
setMessages: state.setMessages,
systemPurposeId: conversation?.systemPurposeId ?? null,
setAutoTitle: state.setAutoTitle,
};
}, shallow);
// [0 to 1] create a conversation if there's none active
React.useEffect(() => {
if (showNextTitle.current) {
showNextTitle.current = false;
const title = (focusedChatNumber >= 0 ? `#${focusedChatNumber + 1} · ` : '') + (focusedChatTitle || 'New Chat');
const id = addSnackbar({ key: 'focused-title', message: title, type: 'title' });
return () => removeSnackbar(id);
}
}, [focusedChatNumber, focusedChatTitle]);
if (!activeConversationId)
useChatStore.getState().conversations.length === 0 && useChatStore.getState().createConversation();
}, [activeConversationId]);
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
const handleExecuteConversation = async (chatModeId: ChatModeId, conversationId: string, history: DMessage[]) => {
const { chatLLMId } = useModelsStore.getState();
if (!chatModeId || !conversationId || !chatLLMId) return;
if (!conversationId || !chatLLMId) return;
// "/command ...": overrides the chat mode
// /command: overrides the chat mode
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
if (lastMessage?.role === 'user') {
const pieces = extractCommands(lastMessage.text);
if (pieces.length == 2 && pieces[0].type === 'cmd' && pieces[1].type === 'text') {
const [command, prompt] = [pieces[0].value, pieces[1].value];
const command = pieces[0].value;
const prompt = pieces[1].value;
if (CmdRunProdia.includes(command)) {
setMessages(conversationId, history);
return await runImageGenerationUpdatingState(conversationId, prompt);
@@ -135,302 +99,147 @@ export function AppChat() {
setMessages(conversationId, history);
return await runReActUpdatingState(conversationId, prompt, chatLLMId);
}
if (CmdRunBrowse.includes(command) && prompt?.trim() && useBrowseStore.getState().enableCommandBrowse) {
setMessages(conversationId, history);
return await runBrowseUpdatingState(conversationId, prompt);
}
if (CmdAddRoleMessage.includes(command)) {
lastMessage.role = command.startsWith('/s') ? 'system' : command.startsWith('/a') ? 'assistant' : 'user';
lastMessage.sender = 'Bot';
lastMessage.text = prompt;
return setMessages(conversationId, history);
}
if (CmdHelp.includes(command)) {
return setMessages(conversationId, [...history, createCommandsHelpMessage()]);
}
}
}
// synchronous long-duration tasks, which update the state as they go
if (chatLLMId && focusedSystemPurposeId) {
if (chatModeId && chatLLMId && systemPurposeId) {
switch (chatModeId) {
case 'immediate':
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
case 'write-user':
return setMessages(conversationId, history);
case 'immediate-follow-up':
return await runAssistantUpdatingState(conversationId, history, chatLLMId, systemPurposeId, true, chatModeId === 'immediate-follow-up');
case 'react':
if (!lastMessage?.text)
break;
setMessages(conversationId, history);
return await runReActUpdatingState(conversationId, lastMessage.text, chatLLMId);
case 'draw-imagine':
case 'draw-imagine-plus':
if (!lastMessage?.text)
break;
const imagePrompt = chatModeId == 'draw-imagine-plus'
? await imaginePromptFromText(lastMessage.text) || 'An error sign.'
: lastMessage.text;
setMessages(conversationId, history.map(message => message.id !== lastMessage.id ? message : {
...message,
text: `${CmdRunProdia[0]} ${imagePrompt}`,
}));
return await runImageGenerationUpdatingState(conversationId, imagePrompt);
case 'write-user':
setMessages(conversationId, history);
return;
}
}
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
console.log('handleExecuteConversation: issue running', conversationId, lastMessage);
setMessages(conversationId, history);
}, [focusedSystemPurposeId, setMessages]);
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
// validate inputs
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
addSnackbar({
key: 'chat-composer-action-invalid',
message: 'Only a single text part is supported for now.',
type: 'issue',
overrides: {
autoHideDuration: 2000,
},
});
return false;
}
const userText = multiPartMessage[0].text;
// find conversation
const conversation = getConversation(conversationId);
if (!conversation)
return false;
// start execution (async)
void _handleExecute(chatModeId, conversationId, [
...conversation.messages,
createDMessage('user', userText),
]);
return true;
};
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
await _handleExecute('immediate', conversationId, history);
const _findConversation = (conversationId: string) =>
conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) ?? null : null;
const handleMessageRegenerateLast = React.useCallback(async () => {
const focusedConversation = getConversation(focusedConversationId);
if (focusedConversation?.messages?.length) {
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
return await _handleExecute('immediate', focusedConversation.id, lastMessage.role === 'assistant'
? focusedConversation.messages.slice(0, -1)
: [...focusedConversation.messages],
);
}
}, [focusedConversationId, _handleExecute]);
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
const handleTextImaginePlus = async (conversationId: DConversationId, messageText: string) => {
const conversation = getConversation(conversationId);
const handleSendUserMessage = async (conversationId: string, userText: string) => {
const conversation = _findConversation(conversationId);
if (conversation)
return await _handleExecute('draw-imagine-plus', conversationId, [
...conversation.messages,
createDMessage('user', messageText),
]);
return await handleExecuteConversation(chatModeId, conversationId, [...conversation.messages, createDMessage('user', userText)]);
};
const handleTextSpeak = async (text: string) => {
await speakText(text);
};
const handleExecuteChatHistory = async (conversationId: string, history: DMessage[]) =>
await handleExecuteConversation(chatModeId, conversationId, history);
// Chat actions
const handleConversationNew = React.useCallback(() => {
// activate an existing new conversation if present, or create another
setFocusedConversationId(newConversationId
? newConversationId
: prependNewConversation(focusedSystemPurposeId ?? undefined),
);
composerTextAreaRef.current?.focus();
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
const branchedConversationId = branchConversation(conversationId, messageId);
addSnackbar({
key: 'branch-conversation',
message: 'Branch started.',
type: 'success',
overrides: {
autoHideDuration: 3000,
startDecorator: <ForkRightIcon />,
},
});
const branchInAltPanel = useUXLabsStore.getState().labsSplitBranching;
if (branchInAltPanel)
openSplitConversationId(branchedConversationId);
else
setFocusedConversationId(branchedConversationId);
return branchedConversationId;
}, [branchConversation, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
setMessages(clearConversationId, []);
setClearConversationId(null);
const handleImagineFromText = async (conversationId: string, messageText: string) => {
const conversation = _findConversation(conversationId);
if (conversation) {
const prompt = await imaginePromptFromText(messageText);
if (prompt)
return await handleExecuteConversation('immediate', conversationId, [...conversation.messages, createDMessage('user', `${CmdRunProdia[0]} ${prompt}`)]);
}
}, [clearConversationId, setMessages]);
};
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
const handleClearConversation = (conversationId: string) => setClearConfirmationId(conversationId);
const handleConfirmedClearConversation = () => {
if (clearConfirmationId) {
setMessages(clearConfirmationId, []);
setAutoTitle(clearConfirmationId, '');
setClearConfirmationId(null);
}
};
const handleDeleteAllConversations = () => setDeleteConfirmationId(SPECIAL_ID_ALL_CHATS);
const handleConfirmedDeleteConversation = () => {
if (deleteConversationId) {
let nextConversationId: DConversationId | null;
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
setDeleteConversationId(null);
if (deleteConfirmationId) {
if (deleteConfirmationId === SPECIAL_ID_ALL_CHATS) {
deleteAllConversations();
}// else
// deleteConversation(deleteConfirmationId);
setDeleteConfirmationId(null);
}
};
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation)
setFocusedConversationId(deleteConversation(conversationId));
else
setDeleteConversationId(conversationId);
}, [deleteConversation, setFocusedConversationId]);
const handleImportConversation = () => setTradeConfig({ dir: 'import' });
const handleExportConversation = (conversationId: string | null) => setTradeConfig({ dir: 'export', conversationId });
// Shortcuts
const handleOpenChatLlmOptions = React.useCallback(() => {
const { chatLLMId } = useModelsStore.getState();
if (!chatLLMId) return;
openLayoutLLMOptions(chatLLMId);
}, []);
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
['o', true, true, false, handleOpenChatLlmOptions],
['r', true, true, false, handleMessageRegenerateLast],
['n', true, false, true, handleConversationNew],
['b', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationBranch(focusedConversationId, null)],
['x', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationClear(focusedConversationId)],
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
useGlobalShortcuts(shortcuts);
const handleFlattenConversation = (conversationId: string) => setFlattenConversationId(conversationId);
// Pluggable ApplicationBar components
const centerItems = React.useMemo(() =>
<ChatDropdowns conversationId={focusedConversationId} />,
[focusedConversationId],
<ChatDropdowns conversationId={activeConversationId} />,
[activeConversationId],
);
const drawerItems = React.useMemo(() =>
<ChatDrawerItemsMemo
activeConversationId={focusedConversationId}
disableNewButton={isFocusedChatEmpty}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
onConversationImportDialog={handleConversationImportDialog}
onConversationNew={handleConversationNew}
onConversationsDeleteAll={handleConversationsDeleteAll}
<ChatDrawerItems
conversationId={activeConversationId}
onImportConversation={handleImportConversation}
onDeleteAllConversations={handleDeleteAllConversations}
/>,
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
[activeConversationId],
);
const menuItems = React.useMemo(() =>
<ChatMenuItems
conversationId={focusedConversationId}
hasConversations={!areChatsEmpty}
isConversationEmpty={isFocusedChatEmpty}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationExport={handleConversationExport}
onConversationFlatten={handleConversationFlatten}
conversationId={activeConversationId} isConversationEmpty={isConversationEmpty}
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
onClearConversation={handleClearConversation}
onDuplicateConversation={duplicateConversation}
onExportConversation={handleExportConversation}
onFlattenConversation={handleFlattenConversation}
/>,
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
[activeConversationId, duplicateConversation, isConversationEmpty, isMessageSelectionMode],
);
useLayoutPluggable(centerItems, drawerItems, menuItems);
return <>
<Box sx={{
flexGrow: 1,
display: 'flex', flexDirection: { xs: 'column', md: 'row' },
overflow: 'clip',
}}>
<ChatMessageList
conversationId={activeConversationId}
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
onExecuteChatHistory={handleExecuteChatHistory}
onImagineFromText={handleImagineFromText}
sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto', // overflowY: 'hidden'
minHeight: 96,
}} />
{chatPaneIDs.map((_conversationId, idx) => (
<Box key={'chat-pane-' + idx} onClick={() => setActivePaneIndex(idx)} sx={{
flexGrow: 1, flexBasis: 1,
display: 'flex', flexDirection: 'column',
overflow: 'clip',
}}>
<ChatMessageList
conversationId={_conversationId}
chatLLMContextTokens={chatLLM?.contextTokens}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImaginePlus}
onTextSpeak={handleTextSpeak}
sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto',
minHeight: 96,
// outline the current focused pane
...(chatPaneIDs.length < 2 ? {}
: (_conversationId === focusedConversationId)
? {
border: '2px solid',
borderColor: 'primary.solidBg',
} : {
padding: '2px',
}),
}}
/>
<Ephemerals
conversationId={_conversationId}
sx={{
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}} />
</Box>
))}
</Box>
<Ephemerals
conversationId={activeConversationId}
sx={{
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}} />
<Composer
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
conversationId={focusedConversationId}
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
onAction={handleComposerAction}
conversationId={activeConversationId} messageId={null}
chatModeId={chatModeId} setChatModeId={setChatModeId}
isDeveloperMode={systemPurposeId === 'Developer'}
onSendMessage={handleSendUserMessage}
sx={{
zIndex: 21, // position: 'sticky', bottom: 0,
backgroundColor: 'background.surface',
@@ -440,35 +249,25 @@ export function AppChat() {
}} />
{/* Diagrams */}
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
{/* Import / Export */}
{!!tradeConfig && <TradeModal config={tradeConfig} onClose={() => setTradeConfig(null)} />}
{/* Flatten */}
{!!flattenConversationId && (
<FlattenerModal
conversationId={flattenConversationId}
onConversationBranch={handleConversationBranch}
onClose={() => setFlattenConversationId(null)}
/>
)}
{/* Import / Export */}
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
{!!flattenConversationId && <FlattenerModal conversationId={flattenConversationId} onClose={() => setFlattenConversationId(null)} />}
{/* [confirmation] Reset Conversation */}
{!!clearConversationId && <ConfirmationModal
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all messages?'} positiveActionText={'Clear conversation'}
{!!clearConfirmationId && <ConfirmationModal
open onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'}
/>}
{/* [confirmation] Delete All */}
{!!deleteConversationId && <ConfirmationModal
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
{!!deleteConfirmationId && <ConfirmationModal
open onClose={() => setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
? 'Are you absolutely sure you want to delete ALL conversations? This action cannot be undone.'
: 'Are you sure you want to delete this conversation?'}
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
positiveActionText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
? 'Yes, delete all'
: 'Delete conversation'}
/>}
@@ -1,16 +1,10 @@
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { CmdRunSearch } from '~/modules/google/search.client';
import { Brand } from '~/common/app.config';
import { createDMessage, DMessage } from '~/common/state/store-chats';
export const CmdAddRoleMessage: string[] = ['/assistant', '/a', '/system', '/s'];
export const CmdHelp: string[] = ['/help', '/h', '/?'];
export const commands = [...CmdRunBrowse, ...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
export const commands = [...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage];
export interface SentencePiece {
type: 'text' | 'cmd';
@@ -22,9 +16,6 @@ export interface SentencePiece {
* Used by rendering functions, as well as input processing functions.
*/
export function extractCommands(input: string): SentencePiece[] {
// 'help' commands are the only without a space and text after
if (CmdHelp.includes(input))
return [{ type: 'cmd', value: input }, { type: 'text', value: '' }];
const regexFromTags = commands.map(tag => `^\\${tag} `).join('\\b|') + '\\b';
const pattern = new RegExp(regexFromTags, 'g');
const result: SentencePiece[] = [];
@@ -46,12 +37,4 @@ export function extractCommands(input: string): SentencePiece[] {
result.push({ type: 'text', value: input.substring(lastIndex) });
return result;
}
export function createCommandsHelpMessage(): DMessage {
let text = 'Available Chat Commands:\n';
text += commands.map(c => ` - ${c}`).join('\n');
const helpMessage = createDMessage('assistant', text);
helpMessage.originLLM = Brand.Title.Base;
return helpMessage;
}
+79 -150
View File
@@ -4,173 +4,110 @@ import { shallow } from 'zustand/shallow';
import { Box, List } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { useChatLLM } from '~/modules/llms/store-llms';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { InlineError } from '~/common/components/InlineError';
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
import { openLayoutPreferences } from '~/common/layout/store-applayout';
import { useCapabilityElevenLabs, useCapabilityProdia } from '~/common/components/useCapabilities';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatMessageMemo } from './message/ChatMessage';
import { ChatMessage } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
import { PersonaSelector } from './persona-selector/PersonaSelector';
import { useChatShowSystemMessages } from '../store-app-chat';
/**
* A list of ChatMessages
*/
export function ChatMessageList(props: {
conversationId: DConversationId | null,
chatLLMContextTokens?: number,
conversationId: string | null,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => void,
onTextDiagram: (diagramConfig: DiagramConfig | null) => Promise<any>,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<any>,
onTextSpeak: (selectedText: string) => Promise<any>,
sx?: SxProps,
onExecuteChatHistory: (conversationId: string, history: DMessage[]) => void,
onImagineFromText: (conversationId: string, userText: string) => void,
sx?: SxProps
}) {
// state
const [isImagining, setIsImagining] = React.useState(false);
const [isSpeaking, setIsSpeaking] = React.useState(false);
const [selectedMessages, setSelectedMessages] = React.useState<Set<string>>(new Set());
// external state
const [showSystemMessages] = useChatShowSystemMessages();
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
const showSystemMessages = useUIPreferencesStore(state => state.showSystemMessages);
const { messages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
conversationMessages: conversation ? conversation.messages : [],
messages: conversation ? conversation.messages : [],
editMessage: state.editMessage, deleteMessage: state.deleteMessage,
historyTokenCount: conversation ? conversation.tokenCount : 0,
deleteMessage: state.deleteMessage,
editMessage: state.editMessage,
setMessages: state.setMessages,
};
}, shallow);
const { mayWork: isImaginable } = useCapabilityProdia();
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
const { chatLLM } = useChatLLM();
// derived state
const { conversationId, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
const handleMessageDelete = (messageId: string) =>
props.conversationId && deleteMessage(props.conversationId, messageId);
const handleMessageEdit = (messageId: string, newText: string) =>
props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true);
// text actions
const handleImagineFromText = (messageText: string) =>
props.conversationId && props.onImagineFromText(props.conversationId, messageText);
const handleRunExample = (text: string) =>
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
// message menu methods proxy
const handleConversationBranch = React.useCallback((messageId: string) => {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleConversationRestartFrom = React.useCallback((messageId: string, offset: number) => {
const messages = getConversation(conversationId)?.messages;
if (messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
conversationId && onConversationExecuteHistory(conversationId, truncatedHistory);
}
}, [conversationId, onConversationExecuteHistory]);
const handleConversationTruncate = React.useCallback((messageId: string) => {
const messages = getConversation(conversationId)?.messages;
if (conversationId && messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1);
setMessages(conversationId, truncatedHistory);
}
}, [conversationId, setMessages]);
const handleMessageDelete = React.useCallback((messageId: string) => {
conversationId && deleteMessage(conversationId, messageId);
}, [conversationId, deleteMessage]);
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
}, [conversationId, editMessage]);
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
const handleTextImagine = React.useCallback(async (text: string) => {
if (!isImaginable)
return openLayoutPreferences(2);
if (conversationId) {
setIsImagining(true);
await onTextImagine(conversationId, text);
setIsImagining(false);
}
}, [conversationId, isImaginable, onTextImagine]);
const handleTextSpeak = React.useCallback(async (text: string) => {
if (!isSpeakable)
return openLayoutPreferences(3);
setIsSpeaking(true);
await onTextSpeak(text);
setIsSpeaking(false);
}, [isSpeakable, onTextSpeak]);
// operate on the local selection set
const handleSelectAll = (selected: boolean) => {
const newSelected = new Set<string>();
if (selected)
for (const message of conversationMessages)
newSelected.add(message.id);
setSelectedMessages(newSelected);
const handleRestartFromMessage = (messageId: string, offset: number) => {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
props.conversationId && props.onExecuteChatHistory(props.conversationId, truncatedHistory);
};
const handleSelectMessage = (messageId: string, selected: boolean) => {
const handleRunExample = (text: string) =>
props.conversationId && props.onExecuteChatHistory(props.conversationId, [...messages, createDMessage('user', text)]);
// hide system messages if the user chooses so
// NOTE: reverse is because we'll use flexDirection: 'column-reverse' to auto-snap-to-bottom
const filteredMessages = messages.filter(m => m.role !== 'system' || showSystemMessages).reverse();
// when there are no messages, show the purpose selector
if (!filteredMessages.length)
return props.conversationId ? (
<Box sx={props.sx || {}}>
<PersonaSelector conversationId={props.conversationId} runExample={handleRunExample} />
</Box>
) : null;
const handleToggleSelected = (messageId: string, selected: boolean) => {
const newSelected = new Set(selectedMessages);
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
setSelectedMessages(newSelected);
};
const handleSelectionDelete = () => {
if (conversationId)
const handleSelectAllMessages = (selected: boolean) => {
const newSelected = new Set<string>();
if (selected)
for (const message of messages)
newSelected.add(message.id);
setSelectedMessages(newSelected);
};
const handleDeleteSelectedMessages = () => {
if (props.conversationId)
for (const selectedMessage of selectedMessages)
deleteMessage(conversationId, selectedMessage);
deleteMessage(props.conversationId, selectedMessage);
setSelectedMessages(new Set());
};
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
props.setIsMessageSelectionMode(false);
});
// text-diff functionality, find the messages to diff with
const { diffMessage, diffText } = React.useMemo(() => {
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const lenA = textA.length, lenB = textB.length;
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
return { diffMessage: msgB, diffText: textA };
}
return { diffMessage: undefined, diffText: undefined };
}, [conversationMessages]);
// no content: show the persona selector
const filteredMessages = conversationMessages
.filter(m => m.role !== 'system' || showSystemMessages) // hide the System message if the user choses to
.reverse(); // 'reverse' is because flexDirection: 'column-reverse' to auto-snap-to-bottom
if (!filteredMessages.length)
return (
<Box sx={{ ...props.sx }}>
{conversationId
? <PersonaSelector conversationId={conversationId} runExample={handleRunExample} />
: <InlineError severity='info' error='Select a conversation' sx={{ m: 2 }} />}
</Box>
);
// scrollbar style
// const scrollbarStyle: SxProps = {
// '&::-webkit-scrollbar': {
// md: {
// width: 8,
// background: theme.palette.neutral.plainHoverBg,
// },
// },
// '&::-webkit-scrollbar-thumb': {
// background: theme.palette.neutral.solidBg,
// borderRadius: 6,
// },
// '&::-webkit-scrollbar-thumb:hover': {
// background: theme.palette.neutral.solidHoverBg,
// },
// };
return (
<List sx={{
@@ -178,35 +115,27 @@ export function ChatMessageList(props: {
// this makes sure that the the window is scrolled to the bottom (column-reverse)
display: 'flex', flexDirection: 'column-reverse',
// fix for the double-border on the last message (one by the composer, one to the bottom of the message)
// marginBottom: '-1px',
marginBottom: '-1px',
}}>
{filteredMessages.map((message, idx) =>
props.isMessageSelectionMode ? (
<CleanerMessage
key={'sel-' + message.id}
message={message}
isBottom={idx === 0} remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
key={'sel-' + message.id} message={message}
isBottom={idx === 0} remainingTokens={(chatLLM ? chatLLM.contextTokens : 0) - historyTokenCount}
selected={selectedMessages.has(message.id)} onToggleSelected={handleToggleSelected}
/>
) : (
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffMessage ? diffText : undefined}
<ChatMessage
key={'msg-' + message.id} message={message}
isBottom={idx === 0}
isImagining={isImagining} isSpeaking={isSpeaking}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
onMessageDelete={() => handleMessageDelete(message.id)}
onMessageEdit={newText => handleMessageEdit(message.id, newText)}
onMessageRunFrom={(offset: number) => handleRestartFromMessage(message.id, offset)}
onImagine={handleImagineFromText}
/>
),
@@ -219,8 +148,8 @@ export function ChatMessageList(props: {
isBottom={filteredMessages.length === 0}
sumTokens={historyTokenCount}
onClose={() => props.setIsMessageSelectionMode(false)}
onSelectAll={handleSelectAll}
onDeleteMessages={handleSelectionDelete}
onSelectAll={handleSelectAllMessages}
onDeleteMessages={handleDeleteSelectedMessages}
/>
)}
+4 -2
View File
@@ -5,7 +5,7 @@ import { Box, Grid, IconButton, Sheet, Stack, styled, Typography, useTheme } fro
import { SxProps } from '@mui/joy/styles/types';
import CloseIcon from '@mui/icons-material/Close';
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
import { DEphemeral, useChatStore } from '~/common/state/store-chats';
const StateLine = styled(Typography)(({ theme }) => ({
@@ -32,6 +32,8 @@ function PrimitiveRender({ name, value }: { name: string, value: string | number
return <StateLine><b>{name}</b>: <b>{value}</b></StateLine>;
else if (typeof value === 'boolean')
return <StateLine><b>{name}</b>: <b>{value ? 'true' : 'false'}</b></StateLine>;
else if (typeof value === 'symbol')
return <StateLine><b>{name}</b>: <b>{value.toString()}</b></StateLine>;
else
return <StateLine><b>{name}</b>: unknown?</StateLine>;
}
@@ -124,7 +126,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
}
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
export function Ephemerals(props: { conversationId: string | null, sx?: SxProps }) {
// global state
const theme = useTheme();
const ephemerals = useChatStore(state => {
@@ -1,69 +1,77 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, ListDivider, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
import { Box, ListDivider, ListItemDecorator, MenuItem, Tooltip, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
import { MAX_CONVERSATIONS, useChatStore } from '~/common/state/store-chats';
import { setLayoutDrawerAnchor } from '~/common/layout/store-applayout';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { ChatNavigationItemMemo } from './ChatNavigationItem';
import { ConversationItem } from './ConversationItem';
import { OpenAIIcon } from '~/modules/llms/openai/OpenAIIcon';
// type ListGrouping = 'off' | 'persona';
type ListGrouping = 'off' | 'persona';
export const ChatDrawerItemsMemo = React.memo(ChatDrawerItems);
function ChatDrawerItems(props: {
activeConversationId: DConversationId | null,
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
onConversationImportDialog: () => void,
onConversationNew: () => void,
onConversationsDeleteAll: () => void,
export function ChatDrawerItems(props: {
conversationId: string | null
onDeleteAllConversations: () => void,
onImportConversation: () => void,
}) {
// local state
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
// const [grouping] = React.useState<ListGrouping>('off');
const [grouping] = React.useState<ListGrouping>('off');
// external state
const conversations = useChatStore(state => state.conversations, shallow);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
// derived state
const maxChatMessages = conversations.reduce((longest, _c) => Math.max(longest, _c.messages.length), 1);
const totalConversations = conversations.length;
const hasChats = totalConversations > 0;
const singleChat = totalConversations === 1;
const softMaxReached = totalConversations >= 50;
const conversationIDs = useChatStore(state => state.conversations.map(
conversation => conversation.id,
), shallow);
const { topNewConversationId, maxChatMessages, setActiveConversationId, createConversation, deleteConversation } = useChatStore(state => ({
topNewConversationId: state.conversations.length ? state.conversations[0].messages.length === 0 ? state.conversations[0].id : null : null,
maxChatMessages: state.conversations.reduce((longest, conversation) => Math.max(longest, conversation.messages.length), 0),
setActiveConversationId: state.setActiveConversationId,
createConversation: state.createConversation,
deleteConversation: state.deleteConversation,
}), shallow);
const { experimentalLabs, showSymbols } = useUIPreferencesStore(state => ({
experimentalLabs: state.experimentalLabs,
showSymbols: state.zenMode !== 'cleaner',
}), shallow);
const handleButtonNew = React.useCallback(() => {
onConversationNew();
closeLayoutDrawer();
}, [onConversationNew]);
const hasChats = conversationIDs.length > 0;
const singleChat = conversationIDs.length === 1;
const maxReached = conversationIDs.length >= MAX_CONVERSATIONS;
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
const closeDrawerMenu = () => setLayoutDrawerAnchor(null);
const handleNew = () => {
// if the first in the stack is a new conversation, just activate it
if (topNewConversationId)
setActiveConversationId(topNewConversationId);
else
createConversation();
closeDrawerMenu();
};
const handleConversationActivate = React.useCallback((conversationId: string, closeMenu: boolean) => {
setActiveConversationId(conversationId);
if (closeMenu)
closeLayoutDrawer();
}, [onConversationActivate]);
closeDrawerMenu();
}, [setActiveConversationId]);
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
const handleConversationDelete = React.useCallback((conversationId: string) => {
if (!singleChat && conversationId)
deleteConversation(conversationId);
}, [deleteConversation, singleChat]);
const NewPrefix = maxReached && <Tooltip title={`Maximum limit: ${MAX_CONVERSATIONS} chats. Proceeding will remove the oldest chat.`}><Box sx={{ mr: 2 }}></Box></Tooltip>;
// grouping
/*let sortedIds = conversationIDs;
let sortedIds = conversationIDs;
if (grouping === 'persona') {
const conversations = useChatStore.getState().conversations;
@@ -80,7 +88,7 @@ function ChatDrawerItems(props: {
// flatten grouped conversations
sortedIds = Object.values(groupedConversations).flat();
}*/
}
return <>
@@ -90,12 +98,9 @@ function ChatDrawerItems(props: {
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
<MenuItem disabled={maxReached || (!!topNewConversationId && topNewConversationId === props.conversationId)} onClick={handleNew}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
New
{/*<KeyStroke combo='Ctrl + Alt + N' />*/}
</Box>
{NewPrefix}New
</MenuItem>
<ListDivider sx={{ mb: 0 }} />
@@ -115,22 +120,22 @@ function ChatDrawerItems(props: {
{/* </ToggleButtonGroup>*/}
{/*</ListItem>*/}
{conversations.map(conversation =>
<ChatNavigationItemMemo
key={'nav-' + conversation.id}
conversation={conversation}
isActive={conversation.id === props.activeConversationId}
isLonely={singleChat}
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
{sortedIds.map(conversationId =>
<ConversationItem
key={'c-id-' + conversationId}
conversationId={conversationId}
isActive={conversationId === props.conversationId}
isSingle={singleChat}
showSymbols={showSymbols}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
maxChatMessages={experimentalLabs ? maxChatMessages : 0}
conversationActivate={handleConversationActivate}
conversationDelete={handleConversationDelete}
/>)}
</Box>
<ListDivider sx={{ mt: 0 }} />
<MenuItem onClick={props.onConversationImportDialog}>
<MenuItem onClick={props.onImportConversation}>
<ListItemDecorator>
<FileUploadIcon />
</ListItemDecorator>
@@ -138,12 +143,24 @@ function ChatDrawerItems(props: {
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
</MenuItem>
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
<MenuItem disabled={!hasChats} onClick={props.onDeleteAllConversations}>
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
<Typography>
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
Delete all
</Typography>
</MenuItem>
{/*<ListItem>*/}
{/* <Typography level='body-sm'>*/}
{/* Scratchpad*/}
{/* </Typography>*/}
{/*</ListItem>*/}
{/*<MenuItem>*/}
{/* <ListItemDecorator />*/}
{/* <Typography sx={{ opacity: 0.5 }}>*/}
{/* Feature <Link href={`${Brand.URIs.OpenRepo}/issues/17`} target='_blank'>#17</Link>*/}
{/* </Typography>*/}
{/*</MenuItem>*/}
</>;
}

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